feat(home): trocar o texto fixo de projetos publicados pela contagem real do GitHub

O hero mostra 'Mais de X projetos desenvolvidos' com o detalhamento de
publicos e privados; sem a contagem (token ausente ou API fora) o item some.
A home passa a revalidar uma vez por dia. README documenta o token.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Evandro Fonseca JuniorandClaude Sonnet 5.5 committed 2026-10-08 04:12:03 -03:00
1 parent 4ba955e2c3
commit 0350e7e883
4 files changed
+76 -11

No files matched your search

+19
View File
@@ -81,4 +81,23 @@ describe("Hero", () => {
expect(link).toHaveAttribute("rel", "noopener noreferrer");
}
});
it("shows the real GitHub repo count with the public/private breakdown", () => {
render(<Hero stats={{ repos: { total: 68, public: 25, private: 43 } }} />);
expect(screen.getByText("projetos desenvolvidos")).toBeInTheDocument();
expect(screen.getByText("Mais de")).toBeInTheDocument();
expect(screen.getByText("68")).toBeInTheDocument();
expect(screen.getByText("25 públicos · 43 privados")).toBeInTheDocument();
expect(screen.queryByText(/projetos publicados/i)).not.toBeInTheDocument();
});
it("omits the repo count when the GitHub lookup is unavailable", () => {
render(<Hero stats={{ years: 5 }} />);
expect(screen.getByText("anos de experiência")).toBeInTheDocument();
expect(
screen.queryByText(/projetos desenvolvidos/i),
).not.toBeInTheDocument();
});
});
+42 -9
View File
@@ -17,20 +17,43 @@ const SOCIAL_LINKS = [
},
];
/** Números reais, calculados em app/page.tsx a partir de public/portfolio-data.json (nada digitado à mão). */
/**
* Números reais, calculados em app/page.tsx (nada digitado à mão): anos e demos vêm de public/portfolio-data.json;
* repos vem da API do GitHub (lib/github-stats.ts) e some quando a consulta não está disponível.
*/
export type HeroStats = {
years?: number;
projects?: number;
repos?: { total: number; public: number; private: number };
demos?: number;
};
function statItems(stats?: HeroStats) {
type StatItem = {
value: string;
label: string;
prefix?: string;
detail?: string;
};
function statItems(stats?: HeroStats): StatItem[] {
if (!stats) return [];
return [
stats.years ? [String(stats.years), "anos de experiência"] : null,
stats.projects ? [String(stats.projects), "projetos publicados"] : null,
stats.demos ? [String(stats.demos), "com demonstração online"] : null,
].filter((item): item is [string, string] => item !== null);
const { repos } = stats;
const items: Array<StatItem | null> = [
stats.years
? { value: String(stats.years), label: "anos de experiência" }
: null,
repos && repos.total > 0
? {
prefix: "Mais de",
value: String(repos.total),
label: "projetos desenvolvidos",
detail: `${repos.public} públicos · ${repos.private} privados`,
}
: null,
stats.demos
? { value: String(stats.demos), label: "com demonstração online" }
: null,
];
return items.filter((item): item is StatItem => item !== null);
}
/** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */
@@ -125,12 +148,22 @@ export default function Hero({ stats }: { stats?: HeroStats }) {
<div className="flex flex-wrap items-end gap-x-sp-8 gap-y-sp-4 border-t border-white/10 pt-sp-4">
{items.length > 0 && (
<dl className="flex gap-x-sp-5 gap-y-sp-4 sm:flex-wrap sm:gap-x-sp-8">
{items.map(([value, label]) => (
{items.map(({ prefix, value, label, detail }) => (
<div key={label} className="flex flex-col-reverse">
<dt className="text-[13px] leading-[1.4] text-[rgba(244,242,237,.64)]">
{label}
{detail && (
<span className="block text-[12px] text-[rgba(244,242,237,.55)]">
{detail}
</span>
)}
</dt>
<dd className="font-mono text-[clamp(22px,1.1rem+0.8vw,36px)] font-semibold leading-none tracking-[-0.03em] text-[var(--slash)]">
{prefix && (
<span className="mr-sp-2 text-[13px] font-normal tracking-normal text-[rgba(244,242,237,.64)]">
{prefix}
</span>
)}
{value}
</dd>
</div>