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

+6
View File
@@ -41,6 +41,12 @@ corepack [email protected] start
| `pnpm test:watch` | Executa Jest em modo watch. |
| `pnpm check:projects` | Verifica URLs de demonstração e substitui atomicamente `data/project-health.json`. |
## Contagem de projetos do GitHub
O número "Mais de X projetos desenvolvidos" no topo da home vem da API do GitHub (`lib/github-stats.ts`), consultada só no servidor e renovada uma vez por dia (`revalidate = 86400` em `app/page.tsx`). Conta repositórios próprios, públicos e privados, sem forks e sem arquivados.
Para incluir os privados, defina `GITHUB_STATS_TOKEN` no ambiente de build e de execução (não use o prefixo `NEXT_PUBLIC_`). O token deve ser um [fine-grained personal access token](https://github.com/settings/personal-access-tokens/new) com acesso a todos os repositórios da conta e uma única permissão, **Metadata: Read-only**. Sem o token, ou se a consulta falhar, o número simplesmente não aparece.
## Stack
| Área | Pacotes usados |
+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>
+9 -2
View File
@@ -1,6 +1,10 @@
import { loadGithubRepoCounts } from "@/lib/github-stats";
import { loadFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
/** A contagem do GitHub é consultada no servidor e renovada uma vez por dia. */
export const revalidate = 86_400;
/** Anos de experiência vêm do próprio texto do portfólio ("5 anos de experiência"); sem o trecho, o número some. */
function yearsOfExperience() {
const match = portfolio.personal.summary.match(/(\d+)\s+anos de experi/i);
@@ -8,12 +12,15 @@ function yearsOfExperience() {
}
export default async function Home() {
const projects = await loadFeaturedProjects();
const [projects, repos] = await Promise.all([
loadFeaturedProjects(),
loadGithubRepoCounts(),
]);
return (
<CinematicHome
heroStats={{
years: yearsOfExperience(),
projects: projects.length,
repos: repos ?? undefined,
demos: projects.filter(
(project) =>
project.demoUrl && project.demoAvailability !== "unavailable",