feat: features 025-032 - favoritos, contatos, trabalhe-conosco, area-cliente, navbar, hero-light-dark, performance-homepage
- feat(025): favoritos locais com FavoritesContext, HeartButton, PublicFavoritesPage
- feat(026): central de contatos admin (leads/contatos unificados)
- feat(027): configuração da página de contato via admin
- feat(028): trabalhe conosco - candidaturas com upload e admin
- feat(029): UX área do cliente - visitas, comparação, perfil
- feat(030): navbar UX - menu mobile, ThemeToggle, useFavorites
- feat(031): hero light/dark - imagens separadas por tema, upload, preview, seed
- feat(032): performance homepage - Promise.all parallel fetches, sessionStorage cache,
preload hero image, loading=lazy nos cards, useInView hook, will-change carrossel,
keyframes em index.css, AgentsCarousel e HomeScrollScene via props
- fix: light mode HomeScrollScene - gradiente, cores de texto, scroll hint
migrations: g1h2i3j4k5l6 (source em leads), h1i2j3k4l5m6 (contact_config),
i1j2k3l4m5n6 (job_applications), j2k3l4m5n6o7 (hero theme images)
This commit is contained in:
parent
6ef5a7a17e
commit
cf5603243c
106 changed files with 11927 additions and 1367 deletions
302
specs/032-performance-homepage/performance-audit.md
Normal file
302
specs/032-performance-homepage/performance-audit.md
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
# Auditoria de Performance — Página Inicial (Home)
|
||||
|
||||
> Versão: 1.0 · Data: 2026-04-22
|
||||
> Escopo: `HomePage.tsx`, `HomeScrollScene.tsx`, `AgentsCarousel.tsx`, `PropertyRowCard.tsx`, `Navbar.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. Resumo Executivo
|
||||
|
||||
A página inicial é a mais crítica em termos de first impression e conversão. A análise identificou **5 categorias de problemas** que impactam diretamente o Core Web Vitals (LCP, CLS, INP) e a experiência percebida do usuário:
|
||||
|
||||
| Categoria | Severidade | Impacto estimado no LCP |
|
||||
|---|---|---|
|
||||
| Requests em cascata (waterfall) | 🔴 Alta | +600–1200 ms |
|
||||
| Imagem hero sem preload / sem dimensões | 🔴 Alta | +800–1500 ms |
|
||||
| Scroll scene: `useTheme` + re-renders desnecessários | 🟡 Média | +60–150 ms INP |
|
||||
| AgentsCarousel: autoplay sem `requestAnimationFrame` | 🟡 Média | jank visual |
|
||||
| `RiseCard`: IntersectionObserver por instância | 🟢 Baixa | +n×2 ms compositing |
|
||||
|
||||
---
|
||||
|
||||
## 2. Problemas Identificados
|
||||
|
||||
### 2.1 Waterfall de Requests (Crítico)
|
||||
|
||||
**Arquivo:** `HomePage.tsx` + `HomeScrollScene.tsx`
|
||||
|
||||
**Problema atual:**
|
||||
```
|
||||
Render HomePage
|
||||
└─ useEffect: getHomepageConfig() ← request 1 (bloqueia backgroundImage)
|
||||
└─ setState(config) → re-render
|
||||
└─ HomeScrollScene recebe props
|
||||
└─ useEffect: getFeaturedProperties() ← request 2 só começa AQUI
|
||||
└─ useEffect: getAgents() ← request 3 só começa APÓS mount
|
||||
```
|
||||
|
||||
Os três requests são **seriais por dependência de render**: `getAgents` e `getFeaturedProperties` só disparam após `HomeScrollScene` montar, que depende do render de `HomePage`, que depende de `getHomepageConfig`.
|
||||
|
||||
**Impacto:** Em conexões 3G, o usuário espera 3× o RTT antes de ver qualquer conteúdo real. LCP pode ultrapassar 4 s.
|
||||
|
||||
**Solução:** Paralelizar os três fetches no topo de `HomePage` com `Promise.all` e passar dados via props.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Hero Image sem `<link rel="preload">` e sem width/height (Crítico)
|
||||
|
||||
**Arquivo:** `HomeScrollScene.tsx`, linha da tag `<img>`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
```
|
||||
|
||||
- Sem `width`/`height` → o browser não reserva espaço → **CLS** quando a imagem carrega
|
||||
- Sem `<link rel="preload">` no `<head>` → a imagem começa a baixar somente quando o React renderiza o componente, não durante o parse do HTML
|
||||
- Sem `fetchpriority="high"` → browser não prioriza sobre outros assets
|
||||
- Sem `loading="eager"` explícito → comportamento depende do browser
|
||||
|
||||
**Impacto:** LCP degradado. Em telas de 1080p com imagem de 1 MB, o atraso pode ser de 1–2 s adicionais.
|
||||
|
||||
**Solução:**
|
||||
1. Adicionar `fetchpriority="high"` e `loading="eager"` na tag `<img>`
|
||||
2. Injetar `<link rel="preload">` dinamicamente via `useEffect` assim que `backgroundImage` estiver disponível (ou via `<Helmet>`)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `useTheme` sendo chamado duas vezes para o mesmo dado
|
||||
|
||||
**Arquivos:** `HomePage.tsx` e `HomeScrollScene.tsx`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
// HomePage.tsx
|
||||
const { resolvedTheme } = useTheme()
|
||||
const themedBackgroundImage = resolvedTheme === 'dark' ? ... : ...
|
||||
|
||||
// HomeScrollScene.tsx (recebe backgroundImage, mas chama useTheme DE NOVO)
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isLight = resolvedTheme === 'light'
|
||||
```
|
||||
|
||||
`resolvedTheme` é derivado duas vezes. Qualquer mudança de tema causa re-render em dois componentes separados com lógica duplicada. Além disso, `HomeScrollScene` não precisa conhecer o tema se receber `backgroundImage` já resolvida pelo pai — mas ainda usa `isLight` para estilos internos, o que é legítimo. O problema é a derivação `themedBackgroundImage` estar fora do componente que a consome.
|
||||
|
||||
**Solução:** Manter `isLight` em `HomeScrollScene` (necessário para estilos), mas mover `themedBackgroundImage` para dentro de `HomeScrollScene` eliminando a prop intermediária e a chamada dupla ao context em `HomePage`.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `AgentsCarousel`: `setInterval` sem cleanup confiável + CSS transform sem `will-change`
|
||||
|
||||
**Arquivo:** `AgentsCarousel.tsx`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
const autoplayRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
// ...sem uso de requestAnimationFrame
|
||||
// CSS translate via inline style, sem will-change: transform
|
||||
```
|
||||
|
||||
- `setInterval` para animação de scroll não é sincronizado com o frame rate do browser → pode causar jank em displays 120 Hz
|
||||
- Ausência de `will-change: transform` no track → o browser não promove a camada para GPU antes da primeira animação → primeiro frame pode ser janky
|
||||
- Duplicação de slides (`[...agents, ...agents]`) sem `key` estável baseada em `agent.id` único (usa índice implicitamente)
|
||||
|
||||
---
|
||||
|
||||
### 2.5 `RiseCard`: IntersectionObserver por instância sem threshold otimizado
|
||||
|
||||
**Arquivo:** `HomeScrollScene.tsx`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
// Cria 1 IntersectionObserver por card (até 6+ na home)
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, { threshold: 0.05 })
|
||||
```
|
||||
|
||||
Cada `RiseCard` instancia seu próprio `IntersectionObserver`. Com 6 cards, são 6 observers ativos simultaneamente. O ideal é um único observer compartilhado (padrão singleton/context) que observa todos os elementos.
|
||||
|
||||
**Impacto:** Pequeno em volume baixo, mas escala mal. 6 observers × scroll events = trabalho desnecessário na main thread.
|
||||
|
||||
---
|
||||
|
||||
### 2.6 `<style>` inline com `@keyframes` renderizado dentro do componente
|
||||
|
||||
**Arquivo:** `HomeScrollScene.tsx`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
<style>{`
|
||||
@keyframes fadeDown {
|
||||
0%, 100% { opacity: 0; transform: translateY(-4px); }
|
||||
50% { opacity: 1; transform: translateY(4px); }
|
||||
}
|
||||
`}</style>
|
||||
```
|
||||
|
||||
Isso insere uma tag `<style>` no DOM a cada render. O browser re-parseia o CSS. O correto é mover para `index.css` ou um módulo CSS estático.
|
||||
|
||||
---
|
||||
|
||||
### 2.7 `PropertyRowCard` dentro da Home: fotos sem `loading="lazy"`
|
||||
|
||||
**Arquivo:** `PropertyRowCard.tsx` → `SlideImage`
|
||||
|
||||
**Problema atual:**
|
||||
```tsx
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onLoad={() => setLoaded(true)}
|
||||
className={...}
|
||||
draggable={false}
|
||||
/>
|
||||
```
|
||||
|
||||
Sem `loading="lazy"`, o browser baixa todas as imagens dos cards de destaque imediatamente, competindo com a imagem hero pelo bandwidth no carregamento inicial.
|
||||
|
||||
---
|
||||
|
||||
## 3. Core Web Vitals — Estado Atual vs. Meta
|
||||
|
||||
| Métrica | Estado estimado atual | Meta (Good) | Gap |
|
||||
|---|---|---|---|
|
||||
| LCP (Largest Contentful Paint) | ~3.5–5 s | < 2.5 s | ~1.5 s |
|
||||
| CLS (Cumulative Layout Shift) | ~0.12–0.18 | < 0.1 | ~0.05 |
|
||||
| INP (Interaction to Next Paint) | ~150–250 ms | < 200 ms | borderline |
|
||||
| FCP (First Contentful Paint) | ~1.2–1.8 s | < 1.8 s | borderline |
|
||||
| TTFB (Time to First Byte) | ~200–400 ms | < 800 ms | OK |
|
||||
|
||||
---
|
||||
|
||||
## 4. Plano de Implementação
|
||||
|
||||
### Fase 1 — Quick Wins (alto impacto, baixo risco) · Estimativa: 1 dia
|
||||
|
||||
| # | Ação | Arquivo | Impacto |
|
||||
|---|---|---|---|
|
||||
| 1.1 | Adicionar `fetchpriority="high"` + `loading="eager"` na hero image | `HomeScrollScene.tsx` | LCP −400 ms |
|
||||
| 1.2 | Mover `@keyframes fadeDown` para `index.css` | `HomeScrollScene.tsx` + `index.css` | Elimina re-parse CSS |
|
||||
| 1.3 | Adicionar `loading="lazy"` nos `SlideImage` dos cards | `PropertyRowCard.tsx` | Bandwidth LCP |
|
||||
| 1.4 | Adicionar `will-change: transform` no track do carousel | `AgentsCarousel.tsx` | Elimina jank GPU |
|
||||
|
||||
### Fase 2 — Paralelização de Requests (crítico) · Estimativa: 0.5 dia
|
||||
|
||||
| # | Ação | Arquivo | Impacto |
|
||||
|---|---|---|---|
|
||||
| 2.1 | `Promise.all([getHomepageConfig(), getFeaturedProperties(), getAgents()])` em `HomePage` | `HomePage.tsx` | LCP −600 ms |
|
||||
| 2.2 | `HomeScrollScene` e `AgentsCarousel` passam a receber dados via props | Ambos | Elimina waterfalls |
|
||||
| 2.3 | Preload dinâmico da hero image via `<link rel="preload">` | `HomePage.tsx` | LCP −300 ms |
|
||||
|
||||
### Fase 3 — Refatoração de Observers (qualidade) · Estimativa: 0.5 dia
|
||||
|
||||
| # | Ação | Arquivo | Impacto |
|
||||
|---|---|---|---|
|
||||
| 3.1 | Criar hook `useIntersectionObserver` compartilhado | `hooks/useIntersectionObserver.ts` | Reduz observers de N→1 |
|
||||
| 3.2 | Refatorar `RiseCard` para usar o hook compartilhado | `HomeScrollScene.tsx` | Main thread reduzida |
|
||||
| 3.3 | Consolidar lógica de `resolvedTheme` / `isLight` | `HomeScrollScene.tsx` | Elimina re-render duplo |
|
||||
|
||||
### Fase 4 — Optimistic UI e Caching (avançado) · Estimativa: 1 dia
|
||||
|
||||
| # | Ação | Impacto |
|
||||
|---|---|---|
|
||||
| 4.1 | Cache de `homepageConfig` em `sessionStorage` (TTL 5 min) | FCP sem spinner na navegação de volta |
|
||||
| 4.2 | Skeleton de altura fixa na hero para evitar CLS | CLS −0.08 |
|
||||
| 4.3 | `React.memo` em `PropertyRowCard` e `AgentSlide` | INP nos re-renders de tema |
|
||||
|
||||
---
|
||||
|
||||
## 5. Referências de Código — Soluções Concretas
|
||||
|
||||
### 5.1 Paralelização de requests (Fase 2.1)
|
||||
|
||||
```tsx
|
||||
// HomePage.tsx — ANTES
|
||||
useEffect(() => {
|
||||
getHomepageConfig().then(setConfig).finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
|
||||
// HomePage.tsx — DEPOIS
|
||||
useEffect(() => {
|
||||
Promise.all([getHomepageConfig(), getFeaturedProperties(), getAgents()])
|
||||
.then(([cfg, props, agts]) => {
|
||||
setConfig(cfg)
|
||||
setFeaturedProperties(props)
|
||||
setAgents(agts)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [])
|
||||
```
|
||||
|
||||
### 5.2 Preload dinâmico da hero (Fase 2.3)
|
||||
|
||||
```tsx
|
||||
// HomePage.tsx
|
||||
useEffect(() => {
|
||||
if (!themedBackgroundImage) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'preload'
|
||||
link.as = 'image'
|
||||
link.href = themedBackgroundImage
|
||||
document.head.appendChild(link)
|
||||
return () => { document.head.removeChild(link) }
|
||||
}, [themedBackgroundImage])
|
||||
```
|
||||
|
||||
### 5.3 Hero image com prioridade correta (Fase 1.1)
|
||||
|
||||
```tsx
|
||||
<img
|
||||
src={backgroundImage}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
fetchPriority="high"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
```
|
||||
|
||||
### 5.4 Hook compartilhado de IntersectionObserver (Fase 3.1)
|
||||
|
||||
```ts
|
||||
// hooks/useInView.ts
|
||||
export function useInView(options?: IntersectionObserverInit) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, options)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
return { ref, inView }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Métricas Esperadas Após Implementação Completa
|
||||
|
||||
| Métrica | Atual (estimado) | Após Fase 1+2 | Após Fase 1+2+3+4 |
|
||||
|---|---|---|---|
|
||||
| LCP | ~4 s | ~2.2 s | ~1.8 s |
|
||||
| CLS | ~0.15 | ~0.08 | ~0.04 |
|
||||
| INP | ~200 ms | ~150 ms | ~80 ms |
|
||||
| Requests em paralelo | 3 seriais | 3 paralelos | 3 paralelos + cache |
|
||||
136
specs/032-performance-homepage/plan.md
Normal file
136
specs/032-performance-homepage/plan.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Plan — 032: Performance Homepage
|
||||
|
||||
## Visão Técnica
|
||||
|
||||
A estratégia central é **inverter o fluxo de dados**: em vez de cada componente buscar seus próprios dados (model-per-component), o `HomePage` orquestra todos os fetches em paralelo e distribui via props. Isso elimina o waterfall e permite paralelizar corretamente.
|
||||
|
||||
```
|
||||
ANTES (serial):
|
||||
HomePage mount → fetch config → render HomeScrollScene → fetch properties
|
||||
→ fetch agents (AgentsCarousel)
|
||||
|
||||
DEPOIS (paralelo):
|
||||
HomePage mount → Promise.all([config, properties, agents]) → render com dados prontos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Arquitetura das Mudanças
|
||||
|
||||
### 1. `HomePage.tsx` — Orchestrator
|
||||
|
||||
- Gerencia 3 estados: `config`, `featuredProperties`, `agents`
|
||||
- `Promise.all` no único `useEffect`
|
||||
- Injeta `<link rel="preload">` via efeito secundário quando `backgroundImage` resolve
|
||||
- Cache de `config` em `sessionStorage` com TTL 5 min
|
||||
- Passa `properties`, `loadingProperties`, `agents`, `loadingAgents` como props para baixo
|
||||
|
||||
### 2. `HomeScrollScene.tsx` — Apresentação
|
||||
|
||||
- Remove `useEffect` de `getFeaturedProperties` e estado interno de `properties`
|
||||
- Recebe `properties: Property[]` e `loadingProperties: boolean` via props
|
||||
- Mantém `isLight` (necessário para estilos internos)
|
||||
- Remove `<style>` inline com `@keyframes` (migra para `index.css`)
|
||||
- Adiciona `fetchPriority="high"` na hero `<img>`
|
||||
- `RiseCard` passa a usar hook `useInView`
|
||||
|
||||
### 3. `AgentsCarousel.tsx` — Apresentação
|
||||
|
||||
- Remove `useEffect` de `getAgents` e estado interno de `agents`/`loading`
|
||||
- Recebe `agents: Agent[]` e `loading: boolean` via props
|
||||
- Adiciona `will-change: transform` no track CSS
|
||||
|
||||
### 4. `PropertyRowCard.tsx` — Folha
|
||||
|
||||
- `SlideImage`: adiciona `loading="lazy"` e `decoding="async"`
|
||||
|
||||
### 5. `hooks/useInView.ts` — Utilitário
|
||||
|
||||
- Encapsula `IntersectionObserver` com `disconnect` no `isIntersecting`
|
||||
- Aceita `options?: IntersectionObserverInit`
|
||||
- Retorna `{ ref, inView }`
|
||||
|
||||
### 6. `index.css`
|
||||
|
||||
- Adiciona bloco `@keyframes fadeDown` que estava inline em `HomeScrollScene`
|
||||
|
||||
---
|
||||
|
||||
## Interface das Props Alteradas
|
||||
|
||||
```ts
|
||||
// HomeScrollScene — novas props
|
||||
interface HomeScrollSceneProps {
|
||||
headline: string
|
||||
subheadline: string | null
|
||||
ctaLabel: string
|
||||
ctaUrl: string
|
||||
backgroundImage?: string | null
|
||||
isLoading?: boolean
|
||||
properties: Property[] // NOVO — antes buscado internamente
|
||||
loadingProperties: boolean // NOVO
|
||||
}
|
||||
|
||||
// AgentsCarousel — novas props
|
||||
interface AgentsCarouselProps {
|
||||
agents: Agent[] // NOVO — antes buscado internamente
|
||||
loading: boolean // NOVO
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estratégia de Cache — `sessionStorage`
|
||||
|
||||
```ts
|
||||
const CACHE_KEY = 'homepage_config'
|
||||
const CACHE_TTL = 5 * 60 * 1000 // 5 min
|
||||
|
||||
function getCachedConfig(): HomepageConfig | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||||
if (!raw) return null
|
||||
const { data, ts } = JSON.parse(raw)
|
||||
if (Date.now() - ts > CACHE_TTL) return null
|
||||
return data
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function setCachedConfig(data: HomepageConfig) {
|
||||
try {
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ data, ts: Date.now() }))
|
||||
} catch {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tratamento de CLS na Hero
|
||||
|
||||
O `HomeScrollScene` receberá `isLoading` do pai. Enquanto `isLoading=true`, o container sticky terá `min-height: 100svh` preservado via className, garantindo que o browser reserve o espaço mesmo antes da imagem carregar.
|
||||
|
||||
O skeleton inline já existente está correto — o problema atual é que o `backgroundImage` resolve mais tarde que o skeleton desaparece, causando re-layout. Com o preload dinâmico, a imagem chega antes.
|
||||
|
||||
---
|
||||
|
||||
## Decisões de Design
|
||||
|
||||
| Decisão | Alternativa rejeitada | Motivo |
|
||||
|---|---|---|
|
||||
| `sessionStorage` para cache | `localStorage` | sessionStorage expira ao fechar a aba — adequado para conteúdo editorial |
|
||||
| Preload via `document.createElement` | `react-helmet` | Evita dependência extra para um caso simples |
|
||||
| Props drilling em vez de Context | Context global de dados da home | Overkill para componentes de folha; props é suficiente e mais rastreável |
|
||||
| `useInView` hook simples | Biblioteca externa (react-intersection-observer) | Sem dependência extra, controle total |
|
||||
|
||||
---
|
||||
|
||||
## Sequência de Implementação
|
||||
|
||||
```
|
||||
1. hooks/useInView.ts (sem dependências)
|
||||
2. index.css (keyframes) (sem dependências)
|
||||
3. PropertyRowCard.tsx (loading=lazy) (sem dependências)
|
||||
4. AgentsCarousel.tsx (props) (depende de tipos Agent)
|
||||
5. HomeScrollScene.tsx (props + refat) (depende de useInView)
|
||||
6. HomePage.tsx (orchestrator) (depende de todos acima)
|
||||
```
|
||||
97
specs/032-performance-homepage/spec.md
Normal file
97
specs/032-performance-homepage/spec.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
# Spec — 032: Performance Homepage
|
||||
|
||||
## Identificação
|
||||
- **ID:** 032
|
||||
- **Nome:** performance-homepage
|
||||
- **Prioridade:** Alta
|
||||
- **Tipo:** Refatoração / Otimização
|
||||
- **Dependências:** Nenhuma nova tabela ou endpoint. Apenas frontend.
|
||||
|
||||
---
|
||||
|
||||
## Problema
|
||||
|
||||
A página inicial apresenta waterfall de requests, imagem hero sem priorização correta e re-renders desnecessários que degradam os Core Web Vitals (LCP, CLS, INP). Detalhes completos em [`performance-audit.md`](./performance-audit.md).
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
|
||||
Atingir **LCP < 2.5 s**, **CLS < 0.1** e **INP < 150 ms** na página inicial sem alterar a aparência visual ou quebrar funcionalidades existentes.
|
||||
|
||||
---
|
||||
|
||||
## Escopo
|
||||
|
||||
### Incluído
|
||||
- Paralelização dos 3 fetches da home (`homepageConfig`, `featuredProperties`, `agents`)
|
||||
- Preload dinâmico da imagem hero quando URL estiver disponível
|
||||
- `fetchPriority="high"` + `loading="eager"` + `decoding="async"` na hero image
|
||||
- `loading="lazy"` nas imagens dos `PropertyRowCard` dentro da home
|
||||
- Mover `@keyframes fadeDown` de inline para `index.css`
|
||||
- `will-change: transform` no track do `AgentsCarousel`
|
||||
- Hook compartilhado `useInView` para `RiseCard`
|
||||
- Cache de `homepageConfig` em `sessionStorage` com TTL de 5 minutos
|
||||
- Skeleton de altura fixa na área hero para evitar CLS
|
||||
- `React.memo` em `PropertyRowCard` e `AgentSlide`
|
||||
- Consolidação de `resolvedTheme` / `isLight` para evitar chamada dupla ao `ThemeContext`
|
||||
|
||||
### Excluído
|
||||
- Mudanças no backend
|
||||
- Mudanças em outras páginas que não sejam dependências diretas
|
||||
- SSR / SSG
|
||||
- CDN ou otimização de infraestrutura
|
||||
- Bundle splitting (separado, escopo de build)
|
||||
|
||||
---
|
||||
|
||||
## Requisitos Funcionais
|
||||
|
||||
| ID | Requisito |
|
||||
|---|---|
|
||||
| RF-01 | Todos os dados da home devem ser buscados em paralelo no mount de `HomePage` |
|
||||
| RF-02 | A imagem hero deve ter `fetchPriority="high"` quando `backgroundImage` estiver disponível |
|
||||
| RF-03 | Um `<link rel="preload">` deve ser inserido no `<head>` assim que `backgroundImage` for resolvida |
|
||||
| RF-04 | As imagens dos cards de propriedades devem ter `loading="lazy"` |
|
||||
| RF-05 | `AgentsCarousel` deve receber `agents` e `loading` via props em vez de fazer fetch próprio |
|
||||
| RF-06 | `HomeScrollScene` deve receber `properties` e `loadingProperties` via props |
|
||||
| RF-07 | `homepageConfig` deve ser cacheado em `sessionStorage` por 5 minutos |
|
||||
| RF-08 | O skeleton da hero deve ter `min-height: 100svh` antes do config carregar para evitar CLS |
|
||||
|
||||
---
|
||||
|
||||
## Requisitos Não Funcionais
|
||||
|
||||
| ID | Requisito |
|
||||
|---|---|
|
||||
| RNF-01 | LCP medido via Lighthouse deve ser < 2.5 s em conexão Fast 3G emulada |
|
||||
| RNF-02 | CLS deve ser < 0.1 |
|
||||
| RNF-03 | INP deve ser < 150 ms |
|
||||
| RNF-04 | Nenhuma regressão visual nos temas light e dark |
|
||||
| RNF-05 | Build TypeScript sem erros após todas as mudanças |
|
||||
| RNF-06 | Nenhuma mudança na API pública dos componentes exceto as necessárias para passar dados via props |
|
||||
|
||||
---
|
||||
|
||||
## Impacto em Componentes
|
||||
|
||||
| Componente | Tipo de mudança |
|
||||
|---|---|
|
||||
| `HomePage.tsx` | Paralelizar fetches, preload, cache, passar props |
|
||||
| `HomeScrollScene.tsx` | Receber props de dados, mover keyframes, consolidar theme |
|
||||
| `AgentsCarousel.tsx` | Receber props em vez de fetch interno, will-change |
|
||||
| `PropertyRowCard.tsx` | `loading="lazy"` no `SlideImage` |
|
||||
| `index.css` | Adicionar `@keyframes fadeDown` |
|
||||
| `hooks/useInView.ts` | Criar hook novo |
|
||||
|
||||
---
|
||||
|
||||
## Critérios de Aceite
|
||||
|
||||
- [ ] Lighthouse (Mobile, Fast 3G) reporta LCP < 2.5 s
|
||||
- [ ] Lighthouse reporta CLS < 0.1
|
||||
- [ ] DevTools Network mostra os 3 fetches disparando simultaneamente
|
||||
- [ ] Segunda visita à home não faz request a `/homepage-config` dentro do TTL
|
||||
- [ ] Nenhum erro TypeScript (`npm run build` passa)
|
||||
- [ ] Tema light e dark funcionam visualmente como antes
|
||||
- [ ] `AgentsCarousel` e cards de destaque renderizam normalmente
|
||||
203
specs/032-performance-homepage/tasks.md
Normal file
203
specs/032-performance-homepage/tasks.md
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
# Tasks — 032: Performance Homepage
|
||||
|
||||
> Ordem de execução respeita dependências. Cada task é atômica e pode ser validada individualmente.
|
||||
|
||||
---
|
||||
|
||||
## FASE 1 — Fundação (sem quebra de interface)
|
||||
|
||||
### TASK-01: Criar hook `useInView`
|
||||
- **Arquivo:** `frontend/src/hooks/useInView.ts` (criar)
|
||||
- **Ação:**
|
||||
```ts
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
export function useInView(options?: IntersectionObserverInit) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [inView, setInView] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, options)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return { ref, inView }
|
||||
}
|
||||
```
|
||||
- **Validação:** `get_errors` sem erros
|
||||
|
||||
---
|
||||
|
||||
### TASK-02: Mover `@keyframes fadeDown` para `index.css`
|
||||
- **Arquivo:** `frontend/src/index.css` (editar — adicionar ao final)
|
||||
- **Conteúdo a adicionar:**
|
||||
```css
|
||||
@keyframes fadeDown {
|
||||
0%, 100% { opacity: 0; transform: translateY(-4px); }
|
||||
50% { opacity: 1; transform: translateY(4px); }
|
||||
}
|
||||
```
|
||||
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx` (editar — remover o bloco `<style>` inline)
|
||||
- **Validação:** Build passa, setas do scroll hint continuam animadas
|
||||
|
||||
---
|
||||
|
||||
### TASK-03: `loading="lazy"` + `decoding="async"` em `SlideImage`
|
||||
- **Arquivo:** `frontend/src/components/PropertyRowCard.tsx`
|
||||
- **Alvo:** função `SlideImage`, tag `<img>`
|
||||
- **Adicionar atributos:** `loading="lazy"` e `decoding="async"`
|
||||
- **Cuidado:** Não adicionar `loading="lazy"` na imagem hero de `HomeScrollScene` (ela deve ser eager)
|
||||
- **Validação:** `get_errors` sem erros
|
||||
|
||||
---
|
||||
|
||||
### TASK-04: `will-change: transform` no track do `AgentsCarousel`
|
||||
- **Arquivo:** `frontend/src/components/AgentsCarousel.tsx`
|
||||
- **Alvo:** elemento `div` com `ref={trackRef}` que recebe `transform` no estilo inline
|
||||
- **Ação:** Adicionar `willChange: 'transform'` no objeto de style do track
|
||||
- **Validação:** `get_errors` sem erros
|
||||
|
||||
---
|
||||
|
||||
## FASE 2 — Refatoração de `AgentsCarousel` para receber props
|
||||
|
||||
### TASK-05: Adicionar props de dados em `AgentsCarousel`
|
||||
- **Arquivo:** `frontend/src/components/AgentsCarousel.tsx`
|
||||
- **Ação:**
|
||||
1. Definir `interface AgentsCarouselProps { agents: Agent[]; loading: boolean }`
|
||||
2. Receber `{ agents, loading }` como props do componente
|
||||
3. Remover `useEffect` que chama `getAgents()` e os estados `agents` / `loading`
|
||||
4. Remover import de `getAgents`
|
||||
5. Manter toda a lógica de carrossel (autoplay, prev/next, etc.) inalterada
|
||||
- **Validação:** `get_errors` sem erros (vai reportar erro de prop em `HomePage.tsx` — resolver na TASK-08)
|
||||
|
||||
---
|
||||
|
||||
## FASE 3 — Refatoração de `HomeScrollScene` para receber props
|
||||
|
||||
### TASK-06: Adicionar props de dados em `HomeScrollScene`
|
||||
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
|
||||
- **Ação:**
|
||||
1. Adicionar ao `HomeScrollSceneProps`: `properties: Property[]` e `loadingProperties: boolean`
|
||||
2. Remover `const [properties, setProperties] = useState<Property[]>([])`
|
||||
3. Remover `const [loading, setLoading] = useState(true)`
|
||||
4. Remover `useEffect(() => { getFeaturedProperties()... })`
|
||||
5. Remover import de `getFeaturedProperties`
|
||||
6. No JSX, substituir uso de `loading` por `loadingProperties`
|
||||
- **Validação:** `get_errors` (vai ter erro em uso — resolver na TASK-08)
|
||||
|
||||
---
|
||||
|
||||
### TASK-07: Refatorar `RiseCard` para usar `useInView`
|
||||
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
|
||||
- **Ação:**
|
||||
1. Importar `useInView` de `'../hooks/useInView'`
|
||||
2. Substituir o corpo de `RiseCard`: remover `useRef`, `useState(false)`, `useEffect` com `IntersectionObserver`
|
||||
3. Usar `const { ref, inView } = useInView({ threshold: 0.05 })`
|
||||
4. Manter a div com `ref={ref}` e classes condicionais em `inView`
|
||||
- **Validação:** `get_errors` sem erros
|
||||
|
||||
---
|
||||
|
||||
## FASE 4 — `HomePage` como orchestrator
|
||||
|
||||
### TASK-08: Paralelizar fetches + cache + preload em `HomePage`
|
||||
- **Arquivo:** `frontend/src/pages/HomePage.tsx`
|
||||
- **Ação:**
|
||||
1. Adicionar imports: `getFeaturedProperties` de `'../services/properties'`, `getAgents` de `'../services/agents'`, `Agent` de `'../types/agent'`, `Property` de `'../types/property'`
|
||||
2. Adicionar estados: `featuredProperties: Property[]`, `agents: Agent[]`, `loadingProperties: boolean`, `loadingAgents: boolean`
|
||||
3. Criar helpers de cache:
|
||||
```ts
|
||||
const CFG_CACHE_KEY = 'homepage_config_v1'
|
||||
const CFG_CACHE_TTL = 5 * 60 * 1000
|
||||
|
||||
function getCachedConfig(): HomepageConfig | null { ... }
|
||||
function setCachedConfig(data: HomepageConfig): void { ... }
|
||||
```
|
||||
4. Substituir `useEffect` de `getHomepageConfig` por `Promise.all`:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
const cached = getCachedConfig()
|
||||
const configFetch = cached
|
||||
? Promise.resolve(cached)
|
||||
: getHomepageConfig().then(d => { setCachedConfig(d); return d })
|
||||
|
||||
Promise.all([configFetch, getFeaturedProperties(), getAgents()])
|
||||
.then(([cfg, props, agts]) => {
|
||||
setConfig(cfg)
|
||||
setFeaturedProperties(props)
|
||||
setAgents(agts)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
setIsLoading(false)
|
||||
setLoadingProperties(false)
|
||||
setLoadingAgents(false)
|
||||
})
|
||||
}, [])
|
||||
```
|
||||
5. Adicionar `useEffect` de preload:
|
||||
```ts
|
||||
useEffect(() => {
|
||||
if (!themedBackgroundImage) return
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'preload'
|
||||
link.as = 'image'
|
||||
link.href = themedBackgroundImage
|
||||
document.head.appendChild(link)
|
||||
return () => { document.head.removeChild(link) }
|
||||
}, [themedBackgroundImage])
|
||||
```
|
||||
6. Passar `properties={featuredProperties}` e `loadingProperties={loadingProperties}` para `<HomeScrollScene>`
|
||||
7. Passar `agents={agents}` e `loading={loadingAgents}` para `<AgentsCarousel>`
|
||||
- **Validação:** `get_errors` sem erros; `npm run build` passa
|
||||
|
||||
---
|
||||
|
||||
### TASK-09: `fetchPriority="high"` + `loading="eager"` na hero image
|
||||
- **Arquivo:** `frontend/src/components/HomeScrollScene.tsx`
|
||||
- **Alvo:** tag `<img>` do `backgroundImage` (dentro do bloco `{backgroundImage ? (`)
|
||||
- **Adicionar:** `fetchPriority="high"` e `loading="eager"` e `decoding="async"`
|
||||
- **Nota:** `fetchPriority` é atributo HTML5 — TypeScript pode reclamar; usar `{...{ fetchpriority: 'high' } as React.ImgHTMLAttributes<HTMLImageElement>}` se necessário, ou verificar suporte em `@types/react`
|
||||
- **Validação:** `get_errors` sem erros
|
||||
|
||||
---
|
||||
|
||||
## FASE 5 — Validação Final
|
||||
|
||||
### TASK-10: Build e checklist final
|
||||
- **Ação:**
|
||||
1. Executar `npm run build` no diretório `frontend/`
|
||||
2. Verificar zero erros TypeScript
|
||||
3. Testar manualmente:
|
||||
- [ ] Home carrega no tema dark sem erro visual
|
||||
- [ ] Home carrega no tema light sem erro visual
|
||||
- [ ] Cards de destaque aparecem com animação rise
|
||||
- [ ] Carousel de corretores funciona (autoplay, prev/next)
|
||||
- [ ] Troca de tema reage corretamente no gradiente hero
|
||||
- [ ] DevTools Network: 3 requests paralelos no load inicial
|
||||
- [ ] Segunda visita (< 5 min): `/homepage-config` não é chamado
|
||||
|
||||
---
|
||||
|
||||
## Checklist de Qualidade
|
||||
|
||||
- [X] TASK-01 concluída — `hooks/useInView.ts` criado
|
||||
- [X] TASK-02 concluída — `@keyframes` em `index.css`, sem `<style>` inline
|
||||
- [X] TASK-03 concluída — `loading="lazy"` em `SlideImage`
|
||||
- [X] TASK-04 concluída — `will-change: transform` no carrossel
|
||||
- [X] TASK-05 concluída — `AgentsCarousel` recebe props
|
||||
- [X] TASK-06 concluída — `HomeScrollScene` recebe props
|
||||
- [X] TASK-07 concluída — `RiseCard` usa `useInView`
|
||||
- [X] TASK-08 concluída — `HomePage` paraleliza fetches + cache + preload
|
||||
- [X] TASK-09 concluída — hero image com prioridade correta
|
||||
- [X] TASK-10 concluída — build verde, smoke test manual OK
|
||||
Loading…
Add table
Add a link
Reference in a new issue