diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index bda7c470..0a1a7698 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção). + **S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção). **PUB5** — slogan da campanha "Mais saúde mais futuro" no hero e no rodapé da home (jorgesolla1313.com.br), no lugar de "Um mandato do tamanho da Bahia" (discurso do mandato vigente; o novo é da campanha 2026 — saúde é o tema-âncora do candidato). Troca de copy cirúrgica, sem redesenho nem migration: h1 do hero em duas linhas uppercase "MAIS SAÚDE / MAIS FUTURO" com `aria-label` acompanhando ("MAIS SAÚDE MAIS FUTURO"), segunda linha já em `font-black` (anatomia existente), e o rodapé em sentence case com ponto ("Mais saúde mais futuro."). Testes que pinavam a string antiga atualizados (`campaignHero.unit`, `campaignHome.unit`, `frontend.e2e`). Fora de escopo decidido no gate: cabeçalho de artigos ("O mandato do tamanho da Bahia") e docs históricos. Verificação: unit 2269 + int 791 verdes, e2e frontend 8/8 em prod mode (`E2E_PROD=1`, `.next-e2e`), lint/format/typecheck/cycles/knip/build verdes; render conferido por screenshot + DOM (hero duas linhas, rodapé com ponto). `/simplify` 3 reviewers: sem P0/P1; P2 corrigidos no impl plan (redação + line refs); débitos descartados por serem convenções pré-existentes ou decisões do gate. diff --git a/docs/changelog/2026-08-18-s1.md b/docs/changelog/2026-08-18-s1.md index f28f82b1..a1374510 100644 --- a/docs/changelog/2026-08-18-s1.md +++ b/docs/changelog/2026-08-18-s1.md @@ -1 +1 @@ -**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta relativa "há X · categoria"; card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts`; a home ganhou `revalidate = 300` para a label relativa não congelar na ISR. Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 (empty-state + full-state desktop/mobile, fail-closed com tag oculta, click-through, teclado/setas, swipe, dots; criação via REST com hooks reais; cleanup convergindo a cache de dev), lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) e o congelamento da data relativa na ISR corrigidos; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção). +**S1** — seção "Acompanhe de perto" na home de campanha (jorgesolla1313.com.br), na 3ª posição entre a prova social e "Por que essa eleição importa": bento de 5 artigos do `post` collection (1 card grande + 4 pequenos) no desktop e carrossel de 1 conteúdo por tela com dots e contador "N de M" no mobile (~390px), badge de tipo (Notícia/Campanha/Artigo/Evento), capa, título e meta "há X · categoria" (relativa < 48h, data absoluta além — nunca mente congelada na ISR); card inteiro é link para a rota canônica do artigo. Fail-closed: sem posts visíveis (`isPostVisible` — tag `hidden` eleitoral nunca vaza) a seção some inteira e Prova → Problema voltam a ser contíguos. Zero schema/migration/Consent; leitura via `getVisiblePosts` sob a tag `posts` (home estática revalidada pelo mecanismo `posts` existente). Primeira fatia do board aprovado (S2/S3 = YouTube/Instagram). Verificação: unit 2282 + int 791 verdes, e2e frontend 10/10 em dev e prod (`E2E_PROD=1`, `.next-e2e`), suíte completa prod 169-170 passed com flakes ambientais de campanha (máquina compartilhada; testes diferentes a cada run, nenhum no código tocado); criação via REST com hooks reais; polling positivo do estado da home pós-revalidate (ISR serve stale uma vez sob carga) e navegação para about:blank antes do cleanup (prefetch de links apagados); lint/format/typecheck/cycles/knip/build verdes. `/simplify` 3 reviewers: 2 P1 de a11y (foco+setas no track, `inert` em slides inativos) corrigidos; o `revalidate = 300` proposto contra o congelamento da label relativa foi TESTADO e REVERTIDO — no Next 15.4 em prod ele trava o bust por tag (`revalidateTag('posts')` deixava de regenerar a home; medido em probe manual) — a label relativa ganhou threshold de 48h com fallback absoluto; P2 absorvidos (dots ≥3:1, aria-live no contador, motion-reduce na capa, fidelidade de gaps, secret do e2e espelhado da env); débitos deferidos documentados no impl plan (refusal do `formatRelativeAge` por contrato, gutter flow-centered, link `/artigos` sancionado pela intenção). diff --git a/docs/plans/secao-conteudos-home-artigos-impl.md b/docs/plans/secao-conteudos-home-artigos-impl.md index 5f227db4..8aeb6fa4 100644 --- a/docs/plans/secao-conteudos-home-artigos-impl.md +++ b/docs/plans/secao-conteudos-home-artigos-impl.md @@ -86,10 +86,10 @@ flowchart LR ## Ajustes pós-review (simplify, 2026-08-18) - **A11y do carrossel:** foco + setas no `
    ` (ArrowLeft/Right navegam), `inert` + `aria-hidden` nos slides inativos (saem do tab order/árvore a11y), `aria-live="polite"` no contador, dots com contraste ≥3:1 (`--campaign-muted`), `tabIndex` só com >1 item, `motion-reduce` corta o zoom da capa. -- **ISR congela a label relativa:** `export const revalidate = 300` na home — a página re-renderiza a cada 5 min com labels frescas; a lista de posts continua cacheada sob a tag `posts` (camadas independentes). +- **Label relativa vs ISR (testado e revertido):** a primeira resposta ao congelamento da label ("há 5 minutos" gravado no HTML estático) foi `export const revalidate = 300` na home — **medido em prod (Next 15.4): com `revalidate` temporal, `revalidateTag('posts')` deixa de regenerar a home (probe manual: `/artigos` convergia, a home ficava stale para sempre)** — o bust por tag é o contrato documentado do site, então o `revalidate` foi REMOVIDO e a solução virou threshold na formatação: relativa < 48h (drift limitado), data absoluta além (nunca mente congelada). - **Fidelidade ao rascunho:** `gap-2` nos cards pequenos (gap-3 no featured), `mt-1` no header. -- **Spec e2e:** secret espelhado da env (`process.env.REVALIDATE_SECRET ?? 'e2e-revalidate-secret'`), classe morta `.campaign-contents` removida, lock programático 700 ms (paridade com o irmão). -- **Decisões registradas (refusais):** (a) `formatRelativePostDate` não reusa `formatRelativeAge` (contratos divergentes: `numeric: 'auto'` → "anteontem" vs o "há 2 dias" do rascunho; `round` vs `floor`; fallback absoluto pós-30d; futuro → "agora"; entrada string de post) — a função é pequena e os consumidores existentes do `formatRelativeAge` (bell/sinal/município) não podem mudar de output; (b) gutter da seção usa `px-5/8/10` + `max-w-[1160px]` em vez do `--campaign-content-inset` dos irmãos (layout flow do rascunho manda — o bento wide não cabe em inset de 180px); (c) "Ver artigos →" → `/artigos` lista só notícias com o feed misto — corte sancionado na intenção (rabbit hole "feed infinito"), link por tipo fica como opção futura. +- **Spec e2e:** secret espelhado da env; polling POSITIVO do HTML da home até convergir pós-revalidate (ISR serve stale uma vez sob carga; DOM carregado não refresca); `about:blank` antes do cleanup (prefetch prod dos links apagados geraria 404 no guard); classe morta `.campaign-contents` removida; lock programático 700 ms (paridade com o irmão). +- **Decisões registradas (refusais):** (a) `formatRelativePostDate` não reusa `formatRelativeAge` (contratos divergentes: `numeric: 'auto'` → "anteontem" vs o "há 2 dias" do rascunho; `round` vs `floor`; fallback absoluto; futuro → "agora"; entrada string de post) — a função é pequena e os consumidores existentes do `formatRelativeAge` (bell/sinal/município) não podem mudar de output; (b) gutter da seção usa `px-5/8/10` + `max-w-[1160px]` em vez do `--campaign-content-inset` dos irmãos (layout flow do rascunho manda — o bento wide não cabe em inset de 180px); (c) "Ver artigos →" → `/artigos` lista só notícias com o feed misto — corte sancionado na intenção (rabbit hole "feed infinito"), link por tipo fica como opção futura. ## Riscos e mitigação diff --git a/src/app/(frontend)/(home)/page.tsx b/src/app/(frontend)/(home)/page.tsx index 3e09b393..a76693ac 100644 --- a/src/app/(frontend)/(home)/page.tsx +++ b/src/app/(frontend)/(home)/page.tsx @@ -77,11 +77,6 @@ const proofItems = [ { value: '3º', label: 'mandato de deputado federal' }, ] -// The content section shows relative recency labels ("há 5 minutos"); a -// static page would freeze them between editorial revalidations. Re-render -// every 5 minutes (posts list itself stays cached under the `posts` tag). -export const revalidate = 300 - export default async function HomePage() { return ( <> diff --git a/src/utilities/posts.ts b/src/utilities/posts.ts index 122ea2bc..49307764 100644 --- a/src/utilities/posts.ts +++ b/src/utilities/posts.ts @@ -79,9 +79,12 @@ export const formatPostDate = (date?: string | null): string | null => { const relativeTimeFormatter = new Intl.RelativeTimeFormat('pt-BR', { numeric: 'always' }) /** - * Compact recency label for content cards: "agora", "há 5 min", "há 3 horas", - * "há 2 dias" — falling back to the long absolute date past 30 days. Future - * dates (scheduled posts) read as "agora" instead of a fake countdown. + * Compact recency label for content cards: "agora", "há 5 minutos", + * "há 3 horas", "há 1 dia" — falling back to the long absolute date past + * 48 hours. The home is a static page revalidated only on editorial changes, + * so a relative label frozen in the HTML must stay honest: within 48 hours + * its drift is bounded, beyond that an absolute date never lies. Future dates + * (scheduled posts) read as "agora" instead of a fake countdown. */ export const formatRelativePostDate = (date?: string | null): string | null => { if (!date) return null @@ -98,7 +101,7 @@ export const formatRelativePostDate = (date?: string | null): string | null => { if (hours < 1) return relativeTimeFormatter.format(-minutes, 'minute') if (days < 1) return relativeTimeFormatter.format(-hours, 'hour') - if (days < 30) return relativeTimeFormatter.format(-days, 'day') + if (days < 2) return relativeTimeFormatter.format(-days, 'day') return formatPostDate(date) } diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 24a2e0f3..d220af3c 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -659,6 +659,11 @@ test.describe('Campaign home content section', () => { await carousel.getByRole('button', { name: 'Ir para o conteúdo 4 de 5' }).click() await expect(carousel.getByText('4 de 5 · deslize para ver os próximos')).toBeVisible() + + // Leave the home before deleting: prod prefetches the card links, and a + // prefetch answered after the deletion would 404 (guard noise). Leaving + // the page aborts in-flight prefetches and stops new ones. + await page.goto('about:blank') } finally { const headers2 = await adminHeaders(request).catch(() => undefined) if (headers2) { diff --git a/tests/unit/postsDateFormats.unit.spec.ts b/tests/unit/postsDateFormats.unit.spec.ts index e0f08bcf..6f763389 100644 --- a/tests/unit/postsDateFormats.unit.spec.ts +++ b/tests/unit/postsDateFormats.unit.spec.ts @@ -25,7 +25,7 @@ describe('formatRelativePostDate', () => { expect(formatRelativePostDate('2026-08-17T11:55:00Z')).toBe('há 5 minutos') expect(formatRelativePostDate('2026-08-17T10:30:00Z')).toBe('há 1 hora') expect(formatRelativePostDate('2026-08-17T08:00:00Z')).toBe('há 4 horas') - expect(formatRelativePostDate('2026-08-15T12:00:00Z')).toBe('há 2 dias') + expect(formatRelativePostDate('2026-08-16T12:00:00Z')).toBe('há 1 dia') }) it('floors partial units so 1h59m reads as "há 1 hora"', () => { @@ -33,8 +33,10 @@ describe('formatRelativePostDate', () => { expect(formatRelativePostDate('2026-08-17T10:01:00Z')).toBe('há 1 hora') }) - it('falls back to the long absolute date past 30 days', () => { + it('falls back to the long absolute date past 48 hours', () => { vi.setSystemTime(new Date('2026-08-17T12:00:00Z')) + expect(formatRelativePostDate('2026-08-16T12:00:00Z')).toBe('há 1 dia') + expect(formatRelativePostDate('2026-08-15T12:00:00Z')).toBe('15 de agosto de 2026') expect(formatRelativePostDate('2026-07-17T12:00:00Z')).toBe('17 de julho de 2026') }) })