<main id="root"> ficava VAZIO no HTML estático; browser esperava o React bootar (~2.8s no Moto G Power emulado) antes de pintar QUALQUER coisa. Fix cirúrgico: replicar visualmente o conteúdo da tela de login (logo "monify.app" + "COPILOTO COMPORTAMENTAL" + <h1>Evita o colapso financeiro</h1> + tagline completa) DENTRO de <main id="root"> como HTML estático. CSS do skeleton inline no <style> critical com classes .lcp-skel-* usando CSS vars (--bg/--tx/--lime) — themes dark/light continuam funcionando. Browser pinta o LCP element IMEDIATAMENTE no parse do HTML; quando React monta, createRoot().render() substitui sem flash perceptível (visual idêntico). Resultado esperado: LCP delay 2810ms → ~300ms, PageSpeed mobile 92 → 95-97. 17 tests novos = 3034 verdes (era 3017, +17). Backend INTACTO. Frontend: index.html. Base: ⚡ Lote 60.75 (code-split AdminPanel — PageSpeed mobile 92 → ~95+): user reportou que PageSpeed mobile caiu de 95+ pra 92 com warning "245KB de JS não usado" no bundle inicial. Diagnóstico do build: bundle único `index-*.js` de 1.2MB (308KB gzip), warning explícito do Vite sobre chunks >500KB. Causa: AdminPanel (2940 linhas, painel super-admin usado por <1% do tráfego) vinha junto no bundle inicial. Fix cirúrgico: extrair AdminPanel pra `src/AdminPanel.tsx` separado + `React.lazy(() => import("./AdminPanel"))` + `applyUsersFilters(users, filters) + hasActiveUsersFilters exportados em escopo de módulo pra teste isolado. AND entre dimensões, OR dentro da mesma dimensão (multi-select). (2) Chip LIME "💳 PIX" / "💳 Cartão" ao lado do plano pra identificar conversão paga real (paymentMethod ∈ {pix, card} e NÃO trial) — distingue visualmente conversão paga de cortesia Trial (amber). (3) public/privacy.html atualizada: linha explícita sobre "método de autenticação utilizado (e-mail e senha, login com Google)" na seção 3.1 + parágrafo novo explicando que no login social a Monify recebe do provedor apenas nome + e-mail verificado, NUNCA a senha do usuário no provedor. ADMIN_VERSION 1.5.0 → 1.6.0. 39 tests novos em lote60-74 = 2995 verdes (era 2956, +39). Backend INTACTO. Frontend: src/App.tsx. Base: 🛂 Lote 60.73 (coluna Auth no painel admin): após Lote 60.72 recuperar 2 users Google invisíveis em PROD, user pediu visibilidade de qual método de autenticação cada user usou (Senha tradicional vs Login com Google) pra suporte operacional + observabilidade. Análise LGPD: authProviders é metadado operacional, NÃO PII sensível Art. 5 II — base legal Art. 7 V (execução de contrato). Admin já vê dados mais sensíveis (CPF redacted, partnerEmail). Backend: api/admin/users.ts parseia authProviders defensivamente (array OR string JSON via Upstash auto-parse), inferência retroativa ["password"] quando passwordHash existe sem campo (users pré-60.60). Retorna em PROD e DEV. Frontend: coluna nova "Auth" entre "Plano" e (DEV-only Pref), chip cinza "🔑 Senha" + chip BLUE "G Google" lado a lado quando há múltiplos providers. Tooltips explicam cada chip. Empty state "—" pra legado raro. ADMIN_VERSION 1.4.0 → 1.5.0. 19 tests novos = 2956 verdes (era 2937, +19). Base: 🔧 Lote 60.72 (backfill admin retroativo): user reportou em PROD que mesmo após Lote 60.71 (que corrigiu o zadd faltante pra cadastros NOVOS) um usuário cadastrado via Google continuava INVISÍVEIS no painel admin — porque ele tinha sido criado ANTES do deploy de v1.2.145 e nunca foi indexado em users:by_created. Solução: novo endpoint admin POST /api/admin/backfill-users-index (GET=dry-run) que varre Redis via SCAN cursor-based (não bloqueia Upstash), filtra chaves do shape ^user:[^:]+@[^:]+$ (rejeita subkeys :txs/:push/:paid_faturas/:categories), lê createdAt do hash e adiciona no zset com esse score (preserva ordem cronológica real). Idempotente via zscore — não sobrescreve quem já está indexado, pode rodar várias vezes. Fallback Date.now() com marcador "fallback-now" se createdAt ausente/inválido. Audit log backfill-users-index. 13 tests novos = 2937 verdes (era 2924, +13). Base: 🩹 Lote 60.71 (2 bugs CRÍTICOS PROD): Bug 1 — usuários cadastrados via Login com Google estavam INVISÍVEIS pro painel admin pq socialAuth.ts esquecia zadd("users:by_created", ...) que /api/signup já fazia (linha 105). Fix: espelha lógica do signup tradicional no helper Google quando isNewUser=true, não-bloqueante. Bug 2 — convite Juntos via login Google não vinculava automaticamente (cenário real Tiago→Ligia em PROD v1.2.144): user precisou vincular manualmente via /api/admin/force-link. Causa: race condition do useEffect retry de invite com React 19 batching de setStates. Fix: chamar tryAcceptPendingInvite(newProfile) EXPLICITAMENTE dentro do callback Google após hidratar profile + setLoggedIn, sem depender de timing de useEffect. Idem em handleReactivateAccount (cobertura completa pra cenário raro de reativação + invite)./assets/index-*.js de 308.9 KiB.
Diagnóstico: bundle único de 1.2MB (308KB gzip) com warning explícito do Vite — "Some chunks are larger than 500 kB after minification. Consider: Using dynamic import() to code-split". Causa: AdminPanel (2940 linhas, painel super-admin usado por <1% do tráfego) vinha junto no bundle inicial que 99% dos usuários carregam.
Solução cirúrgica:
src/AdminPanel.tsx (2937 linhas movidas + header + imports)const AdminPanel = lazy(() => import("./AdminPanel"));<Suspense fallback="carregando painel admin…"> na rota /adminexport em constantes/helpers/types do App.tsx que AdminPanel usa: BG/BG2/SURF/SURF2/B1/B2/LIME/RED/AMBER/BLUE/TX/TX2/TX3/ENV/ADMIN_VERSION/ADMIN_API_URLS/SHOW_PULSE_ANALYTICS/formatActiveTimeimport() dinâmico — zero config adicionalnpm run build:
index-*.js: 1228KB → 1023KB (-205KB raw, -17%)readFileSync(App.tsx) + toMatch() em strings do AdminPanel foram atualizados pra concatenar App.tsx + AdminPanel.tsx antes do match. Continuam validando o mesmo conteúdo, agora distribuído em 2 arquivos.
22 tests novos em lote60-75-code-split-admin.test.ts (estrutura do split, exports necessários, anti-regressão features). 3017 verdes (era 2995, +22). Backend INTACTO. Frontend: src/App.tsx (perdeu 2940 linhas) + src/AdminPanel.tsx (NOVO). Tag v1.2.148+.
PageSpeed mobile deve voltar pra 95+ após deploy. Próximas otimizações possíveis (lotes futuros se necessário): lazy de modais grandes (AppTourModal, BetaActivatedModal, EditFluxoEventModal) + manualChunks pra react vendor.
applyUsersFilters(users, filters) + hasActiveUsersFilters exportados em escopo de módulo (testáveis isolados, sem montar React).paymentMethod ∈ {pix, card} e NÃO trial. Distingue visualmente conversão paga de cortesia. Footprint pequeno (~3 linhas JSX). Reusa campo paymentMethod que já estava no payload PROD desde Lote 54.5.
(3) Política de Privacidade (LGPD). Implementa a recomendação opcional do Lote 60.73. Atualizações em public/privacy.html:
src/App.tsx (helper + states + UI dos filtros + chip Pagante) + public/privacy.html. 39 tests novos em lote60-74-admin-filtros-pagante-lgpd.test.ts (helper puro com matriz de cenários AND/OR + UI checks + chip Pagante + LGPD). 2995 verdes (era 2956, +39). Tag v1.2.147+.
Fecha o ciclo aberto pelos Lotes 60.71/60.72/60.73: admin agora tem visibilidade completa de quem cadastrou via Google, quem está em trial, quem pagou de verdade, e consegue cruzar filtros pra responder perguntas operacionais em 1 clique.
authProviders é metadado operacional do método de autenticação, NÃO dado pessoal sensível (não é PII direto como nome/CPF/email/biometria; não é categoria especial Art. 5 II como origem racial/religião/saúde/orientação). Base legal: Art. 7 V — execução de contrato (o backend precisa saber como autenticar o user). Admin já vê dados muito mais sensíveis (CPF redacted, email, plan, partnerEmail, paymentMethod). Recomendação opcional: linha explícita na Política de Privacidade mencionando "método de autenticação" em "dados que tratamos" (melhoria de transparência, não obrigação).
Backend: api/admin/users.ts parseia authProviders com mesmo padrão defensivo do /api/user/profile.ts — aceita array (Upstash auto-parse) ou string JSON com fallback try/catch. Inferência retroativa: quando passwordHash existe mas campo está vazio → ["password"] (users criados ANTES do Lote 60.60). Retorna em PROD e DEV modes (LGPD-compatível em ambos).
Frontend: nova coluna "Auth" entre "Plano" e (DEV-only Pref) — aparece em PROD pq user opera principalmente em PROD. Renderiza:
password (cor TX2 + bg neutro)google (consistente com vínculo Juntos)api/admin/users.ts — adição aditiva). Frontend: src/App.tsx (AdminUser interface + 1 th + 1 td). 19 tests novos em lote60-73-admin-coluna-auth.test.ts (parse defensivo + inferência retroativa + UI + posicionamento da coluna + cores + anti-regressão Lotes 60.71/60.72/60.19). 2956 verdes (era 2937, +19). Tag v1.2.146+.
Fecha o ciclo de visibilidade aberto pelos Lotes 60.71/60.72: admin agora vê quem cadastrou via Google + ordem cronológica real + auditoria de adoção da feature Google Sign-In.
users:by_created — e como /api/admin/users lista users via redis.zrange("users:by_created", ...) (linha 52), eles permanecem invisíveis. Bug histórico que afeta TODO mundo que se cadastrou via Google entre v1.2.140 e v1.2.144 (~3 dias da janela do Lote 60.60 até o 60.71).
Solução: novo endpoint admin POST /api/admin/backfill-users-index (e GET pra dry-run) que:
SCAN cursor (não KEYS, não bloqueia Upstash) com match "user:*"^user:([^:]+@[^:]+)$ — aceita só chaves do shape user:<email>, rejeita subkeys :txs/:push/:paid_faturas/:categorieszscore("users:by_created", email) — se já tem score, skipa (idempotente, preserva ordem cronológica real de quem foi indexado pelo /api/signup)createdAt ISO do hash, Date.parse, usa como scoreDate.now() com marcador createdAtSource: "fallback-now" se createdAt ausente/inválidoemail (não polui o índice com chaves órfãs)"backfill-users-index" com count added/already/skipped{scanned, candidates, alreadyIndexed, added[], skipped[], dryRun}authenticateAdmin do resto). Idempotente: pode rodar várias vezes sem efeito colateral — só insere quem falta. Não-destrutivo: nunca remove nada do zset.
13 tests novos em lote60-72-backfill-users-index.test.ts (regex shape user:<email> validado contra 7 variantes, idempotência, dry-run, fallback, audit). 2937 verdes (era 2924, +13). Backend: 1 arquivo novo + 1 linha em audit.ts (novo tipo). Frontend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.145+.
Como usar (admin): POST /api/admin/backfill-users-index com headers de admin auth — retorna lista detalhada de quem foi adicionado, scores aplicados, e quem já estava indexado. Use GET ou ?dry=1 pra preview antes.
api/_lib/socialAuth.ts esqueceu de fazer await redis.zadd("users:by_created", { score: Date.now(), member: email }) quando criava conta nova. /api/signup (signup tradicional) fazia isso (linha 105), mas não espelhei no helper Google quando implementei o Lote 60.60. Fix: adicionar zadd no branch isNewUser do loginOrCreateUserFromGoogle, não-bloqueante (try/catch com log). Re-login não re-adiciona (zadd só roda quando isNewUser=true).
Bug 2 — Convite Juntos via login Google não vinculava: cenário real Tiago→Ligia. Tiago mandou convite Juntos por email pra Ligia. Ligia clicou no link ?invite=<token>, logou via Google — vínculo NÃO rolou. User precisou vincular manualmente via /api/admin/force-link. Causa: tryAcceptPendingInvite rola via useEffect com deps [loggedIn, profile?.email, profile?.sessionId] — race condition com React 19 batching de setState. O useEffect pode disparar com profile incompleto OU já ter rodado antes com profile=null. Fix: chamar tryAcceptPendingInvite(newProfile) EXPLICITAMENTE dentro do useEffect callback Google após setProfile + setLoggedIn. Idem em handleReactivateAccount (cobertura completa pra reativação + invite pendente — cenário raro mas defensivo). Sem-op se não há token pending (early return na função).
12 tests novos em lote60-71-google-admin-visibility-e-invite-retry.test.ts. 2924 verdes (era 2912, +12). Backend tocado: 1 arquivo (socialAuth.ts) — única adição é zadd. Frontend: src/App.tsx. ADMIN_VERSION mantida 1.4.0. Tag v1.2.144+.
Resolve dois problemas reais que afetavam usuários PROD hoje mesmo: admin não via novos cadastros via Google + vinculação Juntos manual via admin (force-link) virou desnecessária.
HeaderClock hoistado em escopo de módulo (não dentro de MainApp pra evitar nova ref por render — mesmo padrão de NAV_TABS, Lote 60.9 fix anti-pisca-Mony). State now: Date com useState(() => new Date()) lazy init. useEffect com setInterval(1000) + cleanup clearInterval (sem memory leak ao desmontar). Format PT-BR via toLocaleDateString + toLocaleTimeString (hour12: false) sem deps externas. Weekday "sex." → "Sex" (capitaliza primeiro char + remove ponto). Renderiza como Fragment <>{weekday}, {day} {month} · {time}</>.
fontSize do container subiu 10 → 11 pra acomodar string maior sem cortar em mobile pequeno (iPhone SE).
Anti-regressão: outras chamadas monthLabel() (filtro Lançamentos, badges de mês) preservadas — SÓ o header do MainApp foi trocado pelo <HeaderClock/>.
Custo: 1 re-render/s só do componente HeaderClock (string curta, sem fetch/IO). State local não vaza pra MainApp. Bateria desprezível em 1Hz.
13 tests novos em lote60-70-header-clock-live.test.ts. 2912 verdes (era 2899, +13). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.143+.
/api/auth/google/callback troca code por id_token (server-to-server com CLIENT_SECRET), valida via tokeninfo (aud + email_verified + exp), helper compartilhado loginOrCreateUserFromGoogle faz account linking automático por email — se conta com mesmo email existe (signup tradicional), vincula "google" ao authProviders array e mantém senha; se não existe, cria conta nova com plan:free sem passwordHash. Rate limit socialAuth 10/min por IP. Setup completo em SETUP_GOOGLE_OAUTH.md (15 min Google Cloud Console + Vercel envs).
(60.60.1) Estilo dark-glass. Botão Google branco oficial destoava da paleta dark+lime — refatorado pra rgba(255,255,255,.04) + blur(8px) + color:TX + border .12, alinha com .glass-input do form acima. Hover state .08.
(60.61) Exclusão de conta google-only (LGPD CRÍTICO). Modal exigia senha; conta google-only não tem passwordHash → impossibilitava exclusão → violação LGPD Art. 18 VI. Fix: backend aceita request sem senha quando !user.passwordHash (sessionId basta); frontend oculta input + box informativo "🔐 Você está logado via Google, sua sessão atual confirma identidade — não precisa digitar senha". Bonus: olhinho no campo senha (paridade UX com login/signup).
(60.62) Dispensar senha quando Google vinculado. Pós-60.61, conta com ["password","google"] ainda pedia senha. Decisão: quem controla a conta Google JÁ pode logar sem senha → exigir aqui é redundância. Mas...
(60.63) Modais custom + fluxo de reativação. (a) 13 alerts + 1 confirm nativos (iOS) substituídos por showAlert / showConfirm customs estilo Monify, 4 kinds (info/success/warning/danger). (b) Após excluir conta, backend adiciona deleted:<email> Redis TTL 30d; próximo login Google detecta + retorna requiresReactivationConfirmation + reactivationToken (cacheado 5min single-use); frontend abre modal amber 🌱 "Reativar conta?" com data formatada + aviso LGPD; user confirma → 2ª POST com token → cria conta nova vazia.
(60.64) Logs ostensivos pra debug. User reportou modal de reativação não aparecer; adicionei console.log em delete-self + socialAuth pra rastrear via Vercel Functions logs.
(60.65) showAlert sempre visível + force authView=login. Causa raiz do silêncio: setLoginError só renderiza na tela LOGIN, mas user vinha de SIGNUP → invisível. Todos os caminhos de erro do callback Google migrados pra showAlert custom + força authView="login" antes.
(60.66) Overlay 'Validando login Google...'. State googleCallbackPhase + overlay full-screen com spinner lime pra feedback visual definitivo durante callback.
(60.66.1) CRITICAL: modais auth NÃO renderizavam na tela de login. Root cause descoberto: if(!loggedIn) return <LoginScreen/> early return na linha 19363 — TODOS os modais novos (customModal + reactivation + overlay) estavam DEPOIS desse return, invisíveis pra user deslogado. Fix: duplicação intencional dos 3 elementos no return do login. Funcionalidade idêntica nos 2 lugares (mesmo state + handlers).
(60.67) Mensagem de cancel honesta. Cancelar reativação dizia "aguardar 30 dias" — mentira (user pode reativar a qualquer momento clicando Google de novo). Texto atualizado pra "pode reativar a qualquer momento".
(60.68) Tracking loggedInVia. User reportou: signup+senha → login Google (linking) → sair → login com SENHA → modal Excluir dizia "logado via Google" (mentira). Causa: Lote 60.62 dispensava senha sempre que conta tinha Google em authProviders, ignorando QUAL provider foi usado na sessão atual. Fix: backend retorna loggedInVia: "password" (login) ou "google" (Google callback); frontend hidrata Profile.loggedInVia; modal usa hasPassword = hasPasswordOnAccount && !loggedInViaGoogle. Matriz: 5 casos cobertos (Teste B do user pré-PROD).
(60.69) Categorias custom 10 → 20. User: "necessidade real". Pro/Juntos/Household sobem; Free continua 5 (preserva diferenciador). Sem migration.
BASELINES VISUAL REGENERADAS: 72 baselines em 8 viewport/theme combos. npm run test:visual volta a passar 72/72 em ~52s. Próximo deploy:prod NÃO precisa mais de SKIP_VISUAL=1.
2899 testes verdes (era 2758, +141 acumulados). Backend tocado: api/login.ts, api/signup.ts, api/user/profile.ts, api/user/delete-self.ts, api/user/categories.ts, novo api/auth/google/callback.ts, novo api/_lib/socialAuth.ts, api/_lib/rateLimit.ts (limiter socialAuth). Frontend: src/App.tsx. ADMIN_VERSION mantida 1.4.0. Tag v1.2.140+.
Setup requerido pós-deploy (uma vez): Google Cloud Console + 3 envs Vercel (GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET + VITE_GOOGLE_CLIENT_ID) em ambos projetos (DEV + PROD). Guia em SETUP_GOOGLE_OAUTH.md.
"ões" após palavra fixa "provisão" em vez de trocar a palavra inteira. Fix: {...?"provisão":"provisões"}. (60.58 — sugestão UX): user PROD pediu "Na listagem do fluxo de caixa, a ordem está primeiramente a saída e depois a entrada! O interessante é inverter isso! Todos gostam de ver o quanto tem pra depois ver o quanto deve!" Padrão de extrato bancário (crédito acima do débito). Fix: sort stable no eventsToShow via isIncomeEvent (Lote 60.41) como source of truth; .slice() antes do .sort() pra não mutar o array do computeFluxoProjection. (60.59 — 2 fallbacks no LLM Monitor pós-v1.2.125): "com quantos termino o mês" ($0.0040) + "com certeza usbtos termino o mês" ($0.0036) caíam em fallback porque Q3 (Lote 60.13/60.15) exigia "vou/irei" antes do verbo. Fix: 8ª regex catch-all permissivo /^(c[ôo]mo|com)\s+[\s\S]{0,40}(termin[oar]|encerr[oar]|fech[oar]|acab[oar])\s+(o\s+)?m[êe]s/i — aceita 1ª pessoa direta (termino) + typos no prefixo (com X X termino). Ancora em "como|com" no INÍCIO + limite 40 chars no meio pra anti-sequestro ("sempre termino o mês cansado" / "termino o mês no vermelho" não caem). Reusa todo o cálculo Q3 existente (burn rate × dias restantes). 21 tests novos (11 do 60.58 + 10 do 60.59) + 3 atualizados (Lotes 60.21/60.36 pelo banner pluralizado novo). 2758 verdes (era 2737, +21). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.126+.
t.type === "expense" no filtro. Não importava o que o user perguntasse — só saídas vinham. Quando pedia subtração entrada-saída, sem entrada disponível, "resultado" virava o valor da saída só. Fix em 4 modos detectados por regex: wantsBalance (subtrair/sobra/saldo projetado/com quanto termino/fim do mês) → calcula entradas - saídas + balance_realizado e mostra projeção fim do mês; wantsIncomeOnly (entrada/recebimento/salário SEM palavra de saída) → lista só pendingIncomes; wantsExpenseOnly (saída/pagar/despesa SEM palavra de entrada) → lista só pendingExpenses (Lote 60.29 preservado); default ('tenho provisões pendentes?') → ambos em blocos separados com totais + CTA "pergunta saldo projetado pra calcular". Plus: regex expandido pra "entradas pendentes/previstas/a receber/provisionad" (4 variantes que não casavam antes). FB10 (3 fallbacks novos): $0.0034 + $0.0032 + $0.0031 = ~$0.01/call. User reclamando de resposta anterior errada ("vc não me respondeu corretamente" + 2 variantes) vira handler determinístico — tom humilde ("foi mal" / "desculpa" / "vish, falhei"), pede contexto específico (qual número esperava? qual mês? qual categoria?), convida a reformular ou usar chips. 3 templates rotativos via pickTemplate. Anti-regressão: FB8 do Lote 60.47 (cálculo errado) continua com word boundary \berrado\b e não é sequestrado. 26 tests novos em lote60-57-fc15-income-e-fb10-resposta-errada.test.ts. 2737 verdes (era 2711, +26). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.124+.
vercel deploy (que pega o working tree LOCAL) mas NÃO commitados no git remoto — o scripts/tag-release.mjs só commita SYNCED_FILES (docs + version sync), então mudanças em src/App.tsx/api/*/tests ficavam órfãs. Risco real: se a máquina some, 15+ lotes somem com ela; git history não reflete o que está em PROD. Fix em 2 partes: (1) NOVO scripts/check-clean-tree.mjs verifica git status --porcelain antes do pipeline. Se houver mudança fora de SYNCED_FILES, aborta com exit 1 e mensagem clara (lista offenders, orienta git add + commit, explica por que importa). Defensivo: fail-open se não é repo git ou git status falha. Bypass via env SKIP_CLEAN_CHECK=1 pra emergency. Cap 30 offenders no print (anti-spam). Bug Windows CRLF pego durante smoke test e consertado. (2) package.json — deploy:dev e deploy:prod chamam o script ANTES de npm run predeploy* (fail-fast). 20 tests novos. 2711 verdes. Sem deploy — script de build ativa no próximo deploy:*.
SKIP_VISUAL=1 porque baselines anteriores (capturadas pré-Lote 60.43) não batiam mais com o DEV atual após mudanças visuais legítimas (modal scroll dvh, badge cartão+parcelado, provisão pending, ✓ Paguei fatura, etc). Resultado: 32 falhas previsíveis no test:visual após cada deploy. Não bloqueava deploy (SKIP_VISUAL documentado pra emergência), mas guarda visual ficou inativa por 5 deploys — janela cega pra regressões. Fix: rodou npm run test:visual:update contra DEV v1.2.122. 33 baselines regeneradas nos 8 viewport/theme combos (iphone-SE/14 + ipad + laptop, dark/light). Validação: npm run test:visual rodou 72/72 PASSED em 49.6s após regen. Próximo deploy:prod NÃO precisa mais de SKIP_VISUAL=1. Sem mudança de código de app — só PNGs de QA.
txs.slice(0,4) direto — pegava as 4 últimas adicionadas independente de mês ou impacto. Já Lançamentos (Lote 60.52) filtra por filterTxsByImpactMonth(txs, currentMonth) — agrupa cartão pelo mês da fatura via calcFaturaMonthKey. Resultado: tx de cartão com data 10/06 + closingDay anterior cai na fatura Julho → aparece em "Recentes" do Dashboard mas SUME em Lançamentos Junho. Botão "Ver todos →" virava promessa quebrada. Fix: trocou txs.slice(0,4) por filterTxsByImpactMonth(txs).slice(0,4) no card "Recentes". Single source of truth: o que está em "Recentes" do Dashboard está garantido em Lançamentos do mês corrente (mesmo helper). Novo estado intermediário: se user tem txs mas nenhuma impacta o mês corrente (só compras de cartão com fatura futura, por exemplo), card mostra "Sem movimentações em {mês}" + link "Ver outros meses →" que leva pra Lançamentos. Antes esse cenário não existia porque o card mostrava qualquer tx independente de mês. Preservado: isEmpty continua sendo txs.length === 0 (zero state global — user nunca lançou). Empty state grande "+ Adicionar primeiro" preservado. Lançamentos INTACTO. 11 tests novos em lote60-54-recentes-impact-month.test.ts (comentário Lote 60.54, substituição correta, anti-regressão txs.slice direto, botão condicional, estado intermediário, link "Ver outros meses", monthLabelStr via helper, isEmpty preservado, empty state grande preservado, Lançamentos ainda usa filterTxsByImpactMonth, helper ainda exportado). 2691 verdes (era 2680, +11). Backend INTACTO. Frontend: 1 arquivo (src/App.tsx), 1 trecho cirúrgico no card "Recentes" do Dashboard. ADMIN_VERSION mantida 1.4.0. Tag v1.2.122+.
impactMonth?: string (YYYY-MM). (2) filterTxsByImpactMonth atualizada com precedência card > impactMonth > date — cartão sempre tem regra própria (fatura cai pelo closingDay, independente do impactMonth); tx sem card respeita impactMonth quando setado; fallback pro filtro por date original. (3) FluxoCaixaReal monthTxs mesma lógica — consistência total entre Lançamentos e Fluxo. (4) Backend sanitize (api/user/txs.ts) aceita impactMonth validando regex ^\d{4}-\d{2}$, fail-silent em shape inválido. (5) saveTxInternal aceita parâmetro opcional overrideImpactMonth?: string; modal retroativo redesenhado com 2 cards clicáveis: 📅 Lançar em [mês passado] (amber · modifica saldo fechado · saveTxInternal() sem override) / 💚 Contar em [mês corrente] · RECOMENDADO (lime · mantém data DD/MM/AAAA visível mas conta no saldo de hoje · saveTxInternal(nowMonthKey)). Aviso azul informativo quando cartão (fatura sempre cai pelo closingDay). Texto contextual diferente income vs expense ("recebeu antes mas só está organizando" vs "vai pagar este mês mas a compra foi antes"). Botão "← Voltar e ajustar a data" no rodapé. Modal com maxHeight:92dvh + overflowY:auto (anti-corte iPhone SE — Lote 60.43 pattern). 27 tests novos em lote60-53-impact-month-override.test.ts (Tx interface + filterTxsByImpactMonth precedência + FluxoCaixaReal consistência + backend sanitize + saveTxInternal override + modal 2 cards + anti-regressão Lotes 60.50/60.51/60.52). + 6 tests atualizados nos Lotes 60.42/60.50/60.51/60.52. 2680 verdes (era 2587, +93 considerando lotes acumulados em DEV). Backend tocado: 1 arquivo (api/user/txs.ts) — só extensão de sanitize. Frontend: 1 arquivo (src/App.tsx). ADMIN_VERSION mantida 1.4.0. Tag v1.2.120+.
syncTxsToBackend é fire-and-forget (POST sem await pra não bloquear UI), e o polling household sync de 3s (Lote 53.6) rodava entre o POST e a propagação no Redis → GET trazia estado antigo → setTxs sobrescrevia o save. Linha do tempo do bug: t=0ms save+POST → t=200ms POST em trânsito → t=500ms POST commitado no Redis → t=2999ms polling roda → t=3200ms GET volta com dados antigos → state sobrescrito. Fix cirúrgico: novo skipTxPollUntilRef = useRef<number>(0). Cada syncTxsToBackend marca Date.now() + 5_000. Polling skipa iteração enquanto Date.now() < skipTxPollUntilRef.current. 5s cobre latência POST (~200-500ms) + propagação Redis + 1-2 ciclos. Não bloqueia UI. Trade-off consciente: 5s sem update do partner em Juntos (aceitável — menor que qualquer ação humana). API preservada (assinatura + .catch silent + POLL_MS 3s + condição isHousehold). Frente 2 — Chip estilizado: user reportou "deixar com visual do nosso app de botão, assim está meio estranho o visual" pro texto "▶ 3 realizados" no Fluxo. Substituído por chip/pill: display:inline-flex + padding:5px 10px + borderRadius:8. Background/border/color condicionais (LIME tint quando expandido, B1/B2/TX2 quando colapsado). Transition all .18s ease. Chevron em <span aria-hidden> (a11y). Click continua no card pai (toggleExpand preservado). 20 tests novos em lote60-49-race-condition-save-polling.test.ts. 2587 verdes (era 2567, +20). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.116.
(#${feedbackId}) em api/admin/feedback.ts — threading do Gmail funciona por "Re:" + similar text mesmo sem ID; (b) rodapé ref: ideia-N removido (suporte continua via email do user + conteúdo no admin); (c) <span>#{data.id}</span> removido do header da sugestão no FeedbackReplyModal. data.id continua intacto INTERNAMENTE (URL ?reply=<id> + fetch + sessionStorage). (2) User: "Cadê a Mony no modal de quando o adm me respondeu?" — espelha decisão do Lote 60.47 (Mony 48px no email reply). Fix: avatar <div>💬</div> substituído por <img src="/mony.png" width=44 height=44 objectFit:contain> com MESMO círculo verde (background/border/padding preservados). 15 tests novos em lote60-48-id-hidden-mony-in-modal.test.ts + 1 atualizado (Lote 60.47 que validava ID presente). 2567 verdes (era 2552, +15). Backend tocado: 1 arquivo (só remoção). Frontend: 1 arquivo (emoji→img + remoção do span). ADMIN_VERSION mantida 1.4.0. Tag v1.2.113.
App.tsx (custo $0/call vs ~$0.0028/call no Claude Haiku; <100ms vs ~3s). FB1 "uso" sozinho → pede contexto. FB2 "crescer como adulto" (cobre typo "aldulto") → 3 pilares de maturidade financeira cruzando com previousBalance/expense/income/balance pra diagnóstico personalizado. FB3 "valor por semana até fim do ano" → totalAvailable / semanasRestantes dinâmico. FB4 "outros exemplos" → pede tema. FB5 "sim quero lançar" → fluxo numerado 5 passos. FB6 "seja mais dormal" (typo) → confirma tom. FB7 clima off-topic. FB8 "errou X" → Mony não é calculadora (bug pego em testes: regex errado sem \b batia em "ferrado" sequestrando EMO-FERRADO — fix com word boundary). FB9 ambíguos ("aim", "cara como assim cara"). Frente 2 — Mony no email reply: PNG 48px no header (user pediu "senti falta da Mony"). Frente 3 — Mony no push: icon: "/mony.png" no payload; SW + push-send aceitam icon/image opcionais com validação anti-XSS (só paths relativos OU monify-*.vercel.app). Frente 4 — #ID escondido: header "Sua Ideia #4" → "Sua Ideia" (ID preservado em subject pra threading Gmail + rodapé técnico ref: ideia-4). Frente 5 — Modal "Recebemos sua mensagem!" alinhado: ✅+texto em flex horizontal (era empilhado vertical). 28 tests novos em lote60-47-fb-handlers-pos-prod.test.ts. 2552 verdes (era 2524, +28). Backend tocado: 3 arquivos (feedback.ts/push-send.ts/sw.ts) — aditivos. ADMIN_VERSION mantida 1.4.0. Tag v1.2.110.
checkLimit antigo só retornava headers X-RateLimit-* no caminho de bloqueio (429). Em 200 OK não havia visibilidade — user não conseguia validar visualmente no DevTools que o rate limit estava ativo, e client legítimo não podia se auto-regular antes de bater no limite. Discussão de segurança documentada: expor o cap em 200 NÃO cria gap real (atacante descobre o cap em 30s testando empiricamente; RFC 6585 padroniza esses headers e todas APIs grandes — GitHub/Stripe/Twitter/Upstash — expõem em 200). Fix em 3 partes: (1) Tipo de retorno de checkLimit muda de Response | null pra { block: Response | null; headers: Record<string,string> }. Interface RateLimitResult exportada. Fallback in-memory também retorna headers (com X-RateLimit-Fallback: in-memory). (2) Novo helper withRateLimitHeaders(response, headers) — clona Response adicionando headers extras via new Headers(response.headers), preservando body + status + headers originais. (3) 30+ call sites migrados via perl bulk-replace: if (rl) return rl → if (rl.block) return rl.block. Casos especiais: llm-fallback (limMin/limDay com recordLLMEvent em bloco), user/feedback (lim ao invés de rl). Nos 12 GETs do Lote 60.45, response de sucesso envelopada: return withRateLimitHeaders(json({...}), rl.headers). Pulse-usage tem fluxo unificado — rlHeaders em escopo amplo com default {}. 52 tests novos em lote60-46-rate-limit-headers-200.test.ts + 2 atualizados. 2524 verdes (era 2472, +52). Backend tocado: 19 arquivos (1 lib + 18 endpoints), mas só lib mudou comportamento. ADMIN_VERSION mantida 1.4.0. Tag v1.2.107.
/api/user/*, /api/session/check) NÃO tinham rate limit — escopo histórico do projeto era só writes (saveTx 60/min) + LLM (10/min + 100/dia). Risco real: scraping ou scan dreneria cota Upstash (Hobby tem cap diário) e/ou faria função Edge invocations escalar volumetricamente — mesmo sem comprometer dado de user, comprometia plano gratuito. Fix: novo limiter userRead em api/_lib/rateLimit.ts com slidingWindow 240/min, chave granular email:endpoint-name (bucket por recurso — scraping pesado em txs não consome budget de profile). Cap 240/min cobre cenário de polling mais pesado (household sync 3s em txs/goals/ceiling = ~20/min cada) com 12× de headroom — usuário normal nunca encosta. Em Redis fora, cai pro fallback in-memory do Lote 60.17. Aplicado em 12 endpoints GET: profile (poll 60s), txs/goals/ceiling (poll 3s = ~20/min), categories (poll 10s), paid-faturas (fetch inicial), feedback-reply, beta-modal, chat-context, pulse-usage (GET-only), export, session/check. Pulse-usage tem fluxo unificado GET+POST — rate limit condicional ao método. 57 tests novos em lote60-45-rate-limit-get.test.ts. 2472 verdes (era 2415, +57). Backend tocado: 13 arquivos (rateLimit.ts + 12 endpoints), aditivo sem mudança de comportamento default. ADMIN_VERSION mantida 1.4.0. Tag v1.2.106.
calcFaturaMonthKey. O que importa pra cartão é a data da compra. Fix em 2 partes: (1) Precedência do label: cartão domina parcelado. Branch "da compra" agora é form.cardOn && !form.provisionedOn; branch "1ª parcela" ganhou !form.cardOn adicional. (2) Dica dinâmica abaixo do campo Data quando cardOn + dados do cartão + data válida: calcula em tempo real dia <= closingDay ? "deste mês" : "próximo mês" e mostra "↳ Compras até dia 11 entram na fatura do mês corrente (vence dia 20); depois disso, próxima fatura. Esta cai na fatura próximo mês". Atualiza on-the-fly conforme user troca a data — vê na hora quando muda de fatura. 13 tests novos em lote60-44-label-data-cartao-precedence.test.ts + 2 do Lote 60.39 atualizados. 2415 verdes (era 2402, +13). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.103.
App.tsx:15006) NÃO tinha maxHeight nem overflowY:auto (padrão já estabelecido em EditProvisionModal/goalModal/ManageCardsModal — esse modal específico ficou pra trás). Backdrop com alignItems:center empurrava o topo atrás da status bar quando o card excedia a viewport. Fix: adiciona maxHeight:"92dvh" + overflowY:"auto" no card interno. Por que dvh em vez de vh: dvh é dynamic viewport height — em iOS Safari/PWA a barra superior some/aparece ao scrollar mudando a viewport; vh fica fixo no primeiro layout e cortaria. 92% deixa 4% de margem em cima e em baixo pra status bar / home indicator. 8 tests novos em lote60-43-modal-lancamentos-scroll.test.ts. 2402 verdes (era 2394, +8). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.100.
!form.installmentsOn && !form.cardOn. User: "acredito que já tinhamos corrigido isso..." — corrigimos Recurring × Provisionar (Lote 60.35) mas esquecemos o espelho exato pra Parcelado × Provisionar. Mesma lógica: parcela 1 com data futura "ainda não paguei" é cenário válido (compra agendada com parcelas planejadas). Fix em 4 partes: toggle vira fluxoBeta && !form.cardOn (remove mutex installmentsOn); saveTx aceita provisionedField + installmentsField juntos; texto contextual quando installmentsOn ativo ("1ª parcela pendente + próximas N-1 parcelas nos meses futuros"); banner provisão ganha sufixo "· Parcela N/M". Algoritmo de computeFluxoProjection JÁ era robusto. Bug 2: criar provisão Internet com data errada (30/07) → ✏️ ao lado dela no Fluxo abria EditFluxoEventModal cujo título dizia "Editar valor da provisão" — só campo VALOR. User não conseguia editar a DATA, ficava preso. Há 2 modais no código: EditFluxoEventModal (Lote 60.20, valor) e EditProvisionModal (Lote 60.36, valor + nome + data). O ✏️ no Fluxo abria o errado. Fix: roteamento condicional em App.tsx:13193 — kind=provisioned + mode=edit → EditProvisionModal (3 campos); outros casos ou mode=skip → mantém EditFluxoEventModal. 23 tests novos em lote60-42-parcelado-provisionar-coexistem.test.ts + 3 atualizados (Lote 60.21/60.22). 2394 verdes (era 2371, +23). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.99.
paid-faturas era o único recurso NÃO household-aware (txs/ceiling/goals/categories já eram). Owner marca "✓ Paguei" Nubank Junho → partner via como NÃO paga. Fix em 9 arquivos: novo helper getPaidFaturasStoreKey em api/_lib/household.ts + migratePaidFaturasToHousehold (merge household>owner>partner cap 200) + splitPaidFaturasFromHousehold (copia hash pros 2 fail-safe). api/user/paid-faturas.ts usa helper em GET/POST/DELETE. Integração no ciclo de vida: accept/leave/remove/force-link chamam migrate/split. Cascade LGPD em delete-self/export/admin-wipe. (Fix 2) Helpers isIncomeEvent+isExpenseEvent exportados — antes findMonthMainCause filtrava txReal sem checar direction (txReal direction="in" do Lote 60.32 vazava como despesa) e simulateMonthWithoutEvent só checava kind=salary. Fonte única da verdade aplicada em 3 ocorrências. (Fix 3) MAX_GOALS_INDIVIDUAL=5/MAX_GOALS_HOUSEHOLD=10 + helper getMaxGoals(isHousehold) espelhado do backend. Bug: frontend hardcoded goals.length < 5 ignorando household — user Juntos via "+ Nova" sumir aos 5 mas backend aceitava até 10. (Fix 4) anyInlineModalOpen cobre +7 modais full-screen do MainApp (cpfModal, showInviteModal, showLeavePartnerConfirm, showRemovePartnerConfirm, inviteCallback, showTrialWelcome, showTrialEnded) + useLockBodyScroll(showPulseLimitModal) dedicado no GVA. Bloco movido pra DEPOIS dos useStates de Trial/Invite/CPF. 43 tests novos em lote60-41-padroes-sistemicos.test.ts + 4 do Lote 60.32 atualizados. 2371 verdes (era 2328, +43). Backend tocado (aditivo, sem mudança de comportamento default): household.ts + 8 endpoints. ADMIN_VERSION mantida 1.4.0. Tag v1.2.95.
saveTx descartava installments.skipped/overrides (Lote 60.20 — EditFluxoEventModal), recurring.skipped/overrides, e forçava provisioned.status: "pending" hardcoded em QUALQUER edit. Cenário real: user aplica override de R$ 510 na parcela 5/12 → edita tx pelo Lançamentos pra atualizar nome → override some silenciosamente. Pior: editar provisão confirmed/skipped via +Novo voltava pra pending → sumia do Dashboard e reaparecia como banner amarelo. Fix: originalTx = txs.find(t.id === editingId) ANTES do build do field; preserva skipped[]+overrides{} se array/object não vazios; preservedStatus = originalTx?.provisioned?.status || "pending"; confirmedAsTxId também preservado. (Fix 4) useLockBodyScroll(anyLancamentosModalOpen) cobre os 5 modais inline de Lançamentos (modal +Novo, viewingTx, goalModal, goalConfirmDelete, showBulkDeleteConfirm) — modal +Novo é o mais usado do app, page de trás scrollava em iOS PWA. Flag composta separada do MainApp porque Lançamentos é componente próprio. (Fix 5) fmtSigned(totalAvailable) nos 2 ramos cross-sign do handler "saldos anteriores": dívida arrastada com mês positivo (totalAvailable pode ser POS — abateu dívida — OU ainda NEG) e reserva com mês negativo (POS — reserva cobre — OU NEG — consumiu tudo). Sem sinal, fmt() dava "saldo é R$ 200" sem revelar se positivo ou negativo — quebra Golden Rule #11. 22 tests novos em lote60-40-criticos-bloco.test.ts + 1 do Lote 60.21 atualizado (provisionedField com preservedStatus). 2328 verdes (era 2306, +22). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.94.
saveTx agora aceita data futura quando QUALQUER modifier ativo — variável futureAllowed = provisionedOn || installmentsOn || cardOn. Parcela 1 numa data futura é cenário válido (agendou compra parcelada). (2) Mensagem de erro lista os 3 modifiers: "marque 📋 Provisionar, 🔁 Parcelado ou 💳 Cartão pra agendar". (3) <input type="date"> max=undefined também quando installmentsOn || cardOn (datepicker nativo iOS bloqueava data > max, inconsistente com a validação do saveTx). (4) Label da Data ganha hint contextual: "prevista" (provisionado — pré-existente), "1ª parcela" (parcelado), "compra" (cartão). 9 tests novos em lote60-39-data-futura-parcelado-cartao.test.ts. + 2 tests do Lote 60.21/60.34 atualizados (msg de erro nova + max=undefined com 3 modifiers). 2306 verdes (era 2297, +9). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.90.
provisioned — aparece como entrada ativa em Lançamentos (não pending). Lançando outros, todos os anteriores que estavam "no futuro" caem na lista atual. Causa raiz: backend api/user/txs.ts:143 tinha condition t.provisioned && t.type === "expense" herdada do Lote 60.21 (quando provisão era só pra saída). Eu estendi o frontend no Lote 60.34 pra aceitar income (salário previsto pro dia X) MAS esqueci de estender o backend match. Backend descartava silenciosamente o campo. Fix: condition agora aceita (t.type === "expense" || t.type === "income"). Adicionado branch defensivo final que só remove em shape inválido (não condicional ao type). Bug 2 (UX critico): scroll lock do Lote 60.37 não funcionava nos modais do Fluxo de Caixa. Causa raiz: app tem containers internos das tabs (Dashboard, Lançamentos, FluxoCaixaReal) com flex:1 + overflow:auto — esses scrollam INDEPENDENTE do body. Travar só o body (via position:fixed) não bloqueia os internos. Fix: (a) hook agora também aplica overflow:hidden no <main id="root"> (parent constrainer); (b) adiciona classe monify-scroll-locked no body; (c) CSS global trava elementos com atributo data-tab-scroll; (d) marcadores data-tab-scroll adicionados nos 3 wrappers das tabs (Dashboard, Lançamentos, FluxoCaixaReal). 12 tests novos em lote60-38-fix-2-bugs-criticos-prod.test.ts cobrindo: backend aceita income+expense, status enum, cleanup com mainRoot, classlist add/remove, CSS data-tab-scroll, 3 markers presentes nas tabs, anti-regressão da técnica core do Lote 60.37. + 3 tests do Lote 60.21/60.37 atualizados (expectativas antigas). 2297 verdes (era 2285, +12). Backend INTACTO em outros endpoints; só 1 condição de sanitize alterada. Tag v1.2.89.
direction: "in" | "out" em FluxoEvent. Antes, income sem recurring (Reembolso, Renda Extra) virava kind: "txReal" = mesmo kind de despesa pontual — render mostrava vermelho. Agora cruza kind === "salary" || direction === "in". (60.33) Botão "⚠️ Resetar meus dados financeiros (beta)" sutil no rodapé de Lançamentos (atrás de fluxoCaixa beta). Novo endpoint api/user/reset-data.ts (POST com triple confirm: sessionId + senha + texto "RESETAR DADOS"). Bloqueado em modo Juntos (afeta partner). Apaga txs/goals/ceiling/categories/paid_faturas/chat-context — preserva conta+plano+sessionId. Cap 200 anti-DoS, audit log. (60.33.1) Sirene SVG custom no modal (substitui emoji 🚨 que era vermelho-laranja entre OSes e conflitava com RED=déficit). Paleta amber + pulse opacity-only (GPU-composable, prefers-reduced-motion). (60.34) Bug 1: input "Até quando?" com type="date" estourava container em iOS Safari (min-width intrínseco) — máscara DD/MM/AAAA com state local recurringUntilInput (padrão Lote 44.6). Bug 2: provisão de INCOME (salário previsto pro dia X que ainda não recebi) — modal +Novo + computeFluxoProjection aceitam income; toggle "💰 Provisionar (ainda não recebi)?" mutex com recurring inicial. (60.35) User: "mas eu poderia provisionar e recorrer junto correto?". Sim — removi mutex; coexistem (mês corrente "a receber" + futuros "recorrência"). Fix Gmail reset link: URL path-based /reset/TOKEN (Gmail wrapping menos suscetível) + target="_blank" rel="noopener noreferrer" em TODOS os emails do app (forgot, invite, feedback-reply, grant-trial, pix-expiry). Frontend aceita ambos formatos retro-compat. (60.36) Botão ✏️ Editar inline no banner de provisões pending — abre modal compacto (valor + nome + data com máscara DD/MM/AAAA). Resolve "como ajusto se recebi dia 29 em vez de 30?". Banner agora COLAPSÁVEL (chevron ▶/▼ igual fatura) com header sempre visível mostrando soma agregada (income LIME + expense RED separados). (60.37) Hook useLockBodyScroll (iOS-safe com position:fixed; top:-scrollY) aplicado em 7 modais (Edit Provision, Edit Fluxo Event, Explicar, Corrigir, Manage Cards, Beta Activated, Feedback Reply) + flag composta no MainApp pra 8 inline modais (Reset, Delete, Feedback, MonyChoice, AppTour, Privacy, Logout, Pro). Page de trás trava com modal aberto — bug que afetava iOS Safari especialmente. 112 tests novos (lote60-32 11 + lote60-33 26 + lote60-34 21 + lote60-35 16 + lote60-36 23 + lote60-37 15) = 2285 verdes (era 2173). Backend INTACTO em endpoints existentes; só NOVO /api/user/reset-data + extensões de sanitize (provisioned aceita income). ADMIN_VERSION mantida 1.4.0. Tag v1.2.88.
Profile.paidFaturas?: Record<string, true> com chave "cardName|YYYY-MM". Storage: hash Redis user:<email>:paid_faturas (campo=key, valor="1") com cap 200 anti-payload-bomb. Novo endpoint api/user/paid-faturas.ts (GET lista / POST marca / DELETE desmarca) com auth por sessionId + regex de validação da key (/^[^|]{1,40}\|\d{4}-\d{2}$/) + cap defensivo no POST. Frontend: FluxoCaixaReal ganha props paidFaturas, onMarkFaturaPaid, onUnmarkFaturaPaid. Botão "✓ Paguei essa fatura" (verde Mony #4ade80, full-width, minHeight 32) só aparece no card de fatura do mês corrente — meses futuros não fazem sentido (fatura ainda nem fechou). Click marca optimistic + POST backend + rollback em erro. computeFluxoProjection ganha parâmetro paidFaturas (default {}) e PULA o event "fatura" se a key tá marcada — expense do mês também NÃO inclui a fatura paga. Compras subjacentes INTACTAS: continuam em Dashboard (gasto comportamental) e em Lançamentos. Só o agrupamento "fatura virtual" some do Fluxo de Caixa. MainApp: state paidFaturas + handlers markFaturaPaid/unmarkFaturaPaid (useCallback) + fetch GET inicial quando profile login (refetch quando email/session muda). 13 tests novos em lote60-31-fatura-paga.test.ts: computeFluxoProjection respeita paidFaturas, default {} mantém retro-compat, chave inexistente não afeta, paidFaturas só afeta mês específico (Personnalite paga, Nubank continua), Profile interface, FluxoCaixaReal props, botão só no isCurrent, handlers MainApp optimistic+rollback, fetch GET inicial. + 2 tests do Lote 60.28 atualizados (chamada de computeFluxoProjection agora inclui paidFaturas). 2173 verdes (era 2160, +13). Backend INTACTO (zero mudança em endpoints existentes; só NOVO endpoint /api/user/paid-faturas). UI atrás do beta flag fluxoCaixa. ADMIN_VERSION mantida 1.4.0. Tag v1.2.87.
"-R$ 88,00 · 100% do déficit" — se o mês NÃO é déficit (reserva cobre), falar em "déficit" confunde. Fix: render do card cruza m.riskLevel === "deficit" pra decidir entre "% do déficit" (deficit real) vs "% das saídas" (attention com reserva cobrindo). Também alterna entre cause.pctOfDeficit e cause.pctOfExpense. aria-label virou dinâmico. (2) Radar do Dia disparava mood "crítico" com mensagem "qualquer gasto novo aprofunda o vermelho" mesmo com saldo total projetado positivo (cenário: balance -R$ 88, reserva +R$ 6.467, projectedTotalEnd +R$ 5.147 — sem vermelho real). Fix em computeRadarOfDay: mood "crítico" agora EXIGE balance < 0 E projectedTotalEnd < 0. Quando balance NEG mas total POS, mood vira "apertado" com moodLabel "no limite (reserva cobre)" e 3 variantes de mensagem NOVAS que NÃO mencionam "vermelho" — fala em "reserva cobre", "atenção pra não consumir muito da poupança". CTA muda de "Como sair do vermelho?" pra "Como não comer a reserva?". 8 tests novos em lote60-30-1-causa-radar-coerentes.test.ts cobrindo cenário PROD exato (Flores -88 + reserva +6467 → mood não-crítico, sem "vermelho"), mensagem inclui "reserva/cobre", CTA correta, fallback mantém "vermelho" quando há vermelho real. + 1 teste do Lote 60.24 atualizado (aria-label dinâmico). 2160 verdes (era 2152, +8). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.86.
riskLevel = "deficit" quando net < -0.005 SEM cruzar com totalAccum. No Lote 60.29 corrigi nos HANDLERS do chat, mas esqueci de aplicar o mesmo 4-quadrant logic no riskLevel do FluxoMonth (renderiza o badge visual). Fix em computeFluxoProjection: deficit agora EXIGE net < 0 E totalAccum < 0. 4 quadrantes consistentes: balance NEG + total NEG → deficit; balance NEG + total POS → attention (reserva cobre); balance POS + total NEG → risk; balance POS + total POS + margem ≥10% → healthy. Bug 2 (gráfico "→ R$ 0,00"): cenário do mesmo print — gráfico saldo acumulado projetado mostrava header "→ R$ 0,00" (trend = último − primeiro = 0 quando linha horizontal). User lia como "saldo é zero" enquanto saldo real era +R$ 6.379,82 (e linha visual estava no TOPO do gráfico, não no zero). Fix em FluxoCaixaChart: header agora mostra finalBalance em destaque (fontSize 13, fontWeight 800) + tendência sutil. Quando |trend| < 0.005 mostra "→ estável" em vez de "→ R$ 0,00". Resultado: header passa a mostrar "📊 Saldo acumulado projetado R$ 6.379,82 → estável" — coerente com Dashboard. 11 tests novos em lote60-30-bugs-prod-grafico-badge.test.ts: cenário PROD exato (Flores -88 + reserva +6467 → attention, não deficit), 4 quadrantes do riskLevel, gráfico mostra finalBalance, trend "estável" quando próximo de zero, integração. + 1 teste do Lote 60.28 atualizado (expectativa antiga "deficit mesmo com reserva alta" virou "attention quando reserva cobre"). 2152 testes verdes (era 2141, +11). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.85.
visto/passaporte — substring "preVISTO" disparava handler "sair do país" off-topic. (F2) Novo handler FC15 de provisões pendentes (Lote 60.21 tinha UI mas sem camada conversacional — 8 de 15 queries caíam em fallback). (F3) Alerta saldo eufórico quando há provisões pending. (F4) Saúde financeira cruza balance × totalAvailable × provisões (antes só balance/savPct). (F5) Handlers diretos riskLevel: "to em risco?", "estou em déficit?", "to saudável?" com 4 quadrantes (reserva cobre vs não cobre). (F6) computeFluxoProjection projeta DESPESAS recorrentes (antes só income) + toggle "🔁 Recorrente" no modal +Novo agora aparece pra expense. (F7) fmt(previousBalance) perdia sinal no J2 — contradizia "estancar o vermelho" mostrando "saldos anteriores R$ 19k" positivo. (F8) Handler "qual minha situação" humanizado com framework Bloco 1 (antes "Sendo direta:"). (F9) FC12 graceful miss: cartão inexistente lista os cadastrados. (F10) Novo FC16 "qual minha causa principal?" reusa findMonthMainCause. (F11) Imperativo "lança 250" orienta +Novo (não confabula nem cai em fallback). (F12) FC14 guards: emocional ("minha vida" → CVV 188) + número puro pede esclarecimento. (F13) Salário consulta txs.recurring antes de "não me contou". (F14) FC14 negação "e se eu NÃO adiar X" → cenário inverso. QA do user em DEV pegou refino crítico: mês -504 mas reserva +6353 (totalAvailable +5849) virava "🔴 Déficit" em vez de "🟡 Atenção" — handlers riskLevel/saúde/situação refinados pra considerar 4 quadrantes (balance × totalAvailable). Mony agora bate com Dashboard ("R$ 5.849,87 ✓") e Radar ("Mês no limite"). 175 tests novos (lote60-29 27 + 8 probe-*.test.ts com 143 cenários consolidados). 2141 verdes (era 1966). Backend INTACTO. ADMIN_VERSION mantida 1.4.0. Tag v1.2.84.
mainCause (maior despesa do mês quando mood ∈ crítico/acelerado/apertado, threshold ≥ 20% pra ser "principal"). Render linha "📍 PRINCIPAL CAUSA: X — N% do gasto" no Dashboard. "Mony pode te ajudar" no Fluxo de Caixa com 4 perguntas DINÂMICAS baseadas no estado real (primeiro mês negativo → "Como evitar o vermelho em X?"; causa desse mês → "E se eu adiar Y?"; faturas projetadas → "Qual minha próxima fatura?"; fallback genérico). (60.27.1) FC14 handler em localReply responde "E se eu adiar X?" reusando simulateMonthWithoutEvent com 3 caminhos (mês NEG vira POS / NEG continua NEG / já POS). Match permissivo includes(target), fallback honesto com sugestões. Valores negativos no chat passam a render RED brilhante (regex /^[-−]\s*R\$|^[-−]\s*\d/ mesma do Radar). (60.27.2) Fix cor positiva: regra antiga "negativo → RED, resto → cor do humor" pintava +R$ 6.353,88 de vermelho em mood crítico. Nova regra: sinal explícito dita cor — +R$ sempre LIME, -R$ sempre RED, sem sinal segue moodTheme. (60.28 — Bloco 2) Classificação 4 níveis substitui binário 🟢🔴: healthy (net+, acumulado+, margem ≥ 10% renda) / attention (net ≥ 0 mas margem apertada) / risk (positivo isolado mas cumulative+previousBalance < 0) / deficit (mês fecha vermelho). Badge pill com label uppercase + aria-label completo. Tooltip do gráfico SVG expandido de 1 linha pra card 3-zonas: header + grid Entradas/Saídas/Saldo do mês + Acumulado total destacado. 74 tests novos (22 + 12 + 10 + 24 + 6 grep/integração) em lote60-27 / 60.27.1 / 60.27.2 / 60.28. 1966 verdes (era 1898). Backend INTACTO. UI atrás do beta flag fluxoCaixa. ADMIN_VERSION 1.3.0 → 1.4.0. Tag v1.2.83.
citesCompetitor(reply) checa palavra inteira contra COMPETITOR_NAMES (11 nomes); 5 queries off-topic N1 não citam + redirecionam pro escopo financeiro; L1 sem Mercado Livre; 3 handlers antigos limpos; exceção privacidade no contexto certo; auditoria broad em 14 queries off-topic distintas garantindo ZERO menção; determinismo; tom acolhedor sempre menciona escopo financeiro. + 8 tests do Lote 60.6/60.7 atualizados (esperavam Google/Wikipédia → agora aceita só finanças/dinheiro/orçamento/trivia). 1324 verdes (era 1308, +16). Backend INTACTO. Resolve bug crítico de marca + estabelece regra inegociável anti-regressão. Tag v1.2.36.
balance (mês corrente isolado) em vez de totalAvailable (alinhado com Dashboard + KPI saldo do header da Mony pós-Lote 60.4). Fix: ambos agora SEMPRE usam totalAvailable. J1 ("O que dá pra comprar com X"): quando totalAvailable < 0, alerta firme "⚠️ Seu saldo total está em -R$ X. Gastar mais Y te leva pra -Z. Estancar o vermelho primeiro" + CTA. Quando positivo e valor ≤ totalAvailable, mostra "% do saldo disponível". Quando valor > totalAvailable, alerta "maior que saldo". J2 ("Dá pra comprar carro/moto com X à vista"): quando totalAvailable < 0, nega à vista com plano sair do vermelho. Quando ~zero, alerta. Quando positivo cabe, mostra remaining + 4 considerações. Quando não cabe, dá 3 caminhos. Bug bonus J2: índice dos grupos do regex estava errado após adicionar grupo (consigo|posso) — j2Match[6] virou o capture do "$", deveria ser j2Match[7] (número) e j2Match[8] ("mil"/"milhão"). Por isso primeira tentativa do test falhou ainda mostrando "Tecnicamente cabe" — era o handler pi legado pegando porque meu J2 não parseava o valor. N1 regex expandido: "Diferença entre macaco e humano" (sem prefixo "qual a") caía em fallback. Agora regex aceita ^diferen[çc]a\s+entre + me\s+(diga|conta|fala)\s+a\s+diferen[çc]a + ^(será\s+que\s+foi\s+por\s+causa|foi\s+por\s+causa) + "qual o maior" + "onde fica". Novo handler O1 (single word categoria): user digita só "Casa" / "Lazer" / "Alimentação" → resumo das tx daquela cat no mês corrente: n lançamentos + total + % das saídas + % da renda vs CAT_LIMITS com status ✅/⚠️ + top 5 itens (nome/valor/data). Resolve gap conversacional natural — antes "Casa" caía em fallback. Detecta single-word (length < 20, sem espaço) e procura case-insensitive em cat.toLowerCase(). Cleanup: removido bloco morto N1 antigo (if (false) { ... }) que sobrou após substituição do regex. 17 tests novos: cenário PROD exato; J1 anti-regressão "não diz 'consome 1% R$ 15.353'" + vermelho com alerta; J1 positivo usa totalAvailable; J1 valor > total alerta; J2 anti-regressão "não diz 'tecnicamente cabe R$ 15.353' quando negativo" + saldo positivo cabe; J2 não cabe dá 3 caminhos; N1 sem prefixo + "foi por causa de X"; O1 "Casa" lista + "Lazer" + word não-cat não dispara; anti-regressão Lote 60.6; determinismo. 1308 verdes (era 1291, +17). Backend INTACTO. Resolve bug grave de confiança PROD + fecha gap "Casa" / "Diferença entre X". Tag v1.2.34.
name="Luz" cat="Casa" e Mony não encontrava porque handler antigo buscava name === "Conta de Luz" strict. Fix: regex \b(luz|energia|eletric|elétric|enel|cemig|copel|cpfl|coelba|celpe|equatorial|kwh)\b em monthTxs.find() cobre name E cat (case-insensitive), incluindo 7 concessionárias BR. Resposta mostra valor + % das saídas + comparação com média BR (~R$ 150-300/mês) + 4 dicas (standby, chuveiro modo verão, LED, ar 23°C). (I1) "Qual % do meu salário tenho ainda" → gasto % + sobra % + status 🟢🟡🔴 vs teto 80%. (I2) "Qual valor tenho sobrando" → saldo + breakdown + totalLine quando previousBalance != 0; mês negativo mostra rombo + maior saída. (J1) "O que dá pra comprar com X reais" → sugestões por faixa de valor (refeições <50 / mercado+livro <200 / tênis+eletro <500 / smartphone+notebook <2k / notebook+viagem <10k / carro+entrada imóvel >=10k) + impacto no saldo. (J2) "Dá pra comprar carro/casa com X reais" detecta à vista quando valor >= R$ 1k (resolve bug do screenshot — "50209 reais" virava "/mês"). Compara com totalAvailable, mostra remaining se cabe ou cenários (juntar 30%/mês + parcelar + reduzir escopo) se não cabe. (K1) Matemática simples "Quanto é 43+4" / "100 x 3" — Function() sandboxed com whitelist regex, disclaimer "sou IA financeira, não calculadora". (L1) Preço de item "Quanto é um álbum/camisa/X" — redireciona honesta pra Mercado Livre + sugere lançar em Lançamentos. (M1) "Qual gasto mensal posso ter com esse valor" — calcula tetos 80%/50%/30%/20% baseado na renda + status atual vs teto. (N1) Off-topic trivia gentil ("diferença entre macaco e humano", "quantos ossos tem rato", "qual a capital de Y") — 3 variantes simpáticas redirecionando pra Google/ChatGPT/Wikipedia. 31 tests novos em lote60-6-bug-luz-e-mais-fallbacks.test.ts (4 H1 + 3 I1 + 3 I2 + 3 J1 + 2 J2 + 4 K1 + 2 L1 + 2 M1 + 3 N1 + 3 anti-quebra + 2 determinismo). 1291 verdes (era 1260, +31). Backend INTACTO. Resolve gap conversacional do user real (lançou "Luz" e Mony não viu). Tag v1.2.33.
general-purpose (Claude) em paralelo pra rascunhar variantes de resposta em 7 categorias enquanto o main lê estrutura do localReply. Cada handler tem 3 variantes rotativas via pickTemplate(qq, 3) determinístico por seed hash. (A) META/SELF (5): A1 "Quem é a Mony / Me fale sobre você" (apresentação + disclaimer "não substituo consultor profissional"); A2 "De onde surgiu Monify / nome" (GVA Software, money + -ify); A3 "Visão geral do app" (4 telas + 50/30/20); A4 "De onde surgiu o layout" (time GVA); A5 "Outros idiomas" (PT-BR only, decisão consciente). (B) POLICY/DOCS (4): B1 política privacidade (LGPD-first BR + servidor BR + PBKDF2 100k + LLM agregados only + /privacy.html); B2 termo uso (/terms.html); B3 código fonte (proprietário, transparência por docs); B4 idade (não armazenamos — minimização LGPD). (C) TETO/IDEAL (2): C1 % ideal por gasto (50/30/20 + CAT_LIMITS Moradia 35%/Alimentação 20%/Transporte 15%/Saúde 15%/Lazer 10%/Geral 25%); C2 teto saudável (80% renda). (D) PLAN/UPSELL (3): D1 por que Pro (honest, sem trial-trap); D2 finalidade Juntos (2 emails sync 3s); D3 diferencial vs Mobills/Organizze (comportamental vs calculadora). (E) EDUCATIONAL (3): E1 criptomoeda + disclaimer; E2 bitcoin + disclaimer; E3 Real/BRL (SELIC+IPCA+Tesouro Selic). (F) CONTINUATIONS (4): F1 bare "6000 mensal" com income>0 computa impacto; F2 "quanto ficaria negativo" diagnóstico; F3 "qual destes você sugere" pós-investment NÃO recomenda ativo (regra inegociável reforçada) + dá critério objetivo/prazo/perfil; F4 bare "400 reais" pós-apostas_redirect calcula cenário Tesouro Selic. (G) ESPECIAL (1): G1 "tô comendo a reserva" quando previousBalance < 0 (dívida arrastada do Lote 60.4) — espelho honesto "você não tem reserva, tem dívida sendo arrastada". Guard `!_isInvestContinuation` adicionado em "minha situação" handler (line 3602) pra não roubar F3 pós-investment. Variante D2[2] ajustada pra mencionar "Monify Juntos" explícito. 34 tests novos em lote60-5-fallbacks-treated.test.ts (Block A/B/C/D/E/F/G + anti-recomendação ativo + 3 variantes determinísticas + anti-quebra de handlers existentes — quem é você legado / minha reserva 60.2 / mês de X 60.4). 1260 verdes (era 1226, +34). Backend INTACTO. Resolve gap conversacional massivo cobrindo TODAS as queries dos screenshots do dono. Tag v1.2.32.
calcReserve somava só positivos (premissa antiga do Lote 60: "user já viveu o vermelho, não desconta"). Bug grave de confiança — user lança despesa e dinheiro não some. Premissa revertida: agora signed sum (positivos + negativos). (1) calcReserve retorna `total` (signed), sem filtro `> 0`. (2) Dashboard breakdown renderiza quando `Math.abs(previousBalance) > 0.005`; formata negativo com prefixo "- " + cor RED. (3) GVA (Mony header) refatorado: currentMonth + monthTxs + s=calcMonthStats(monthTxs) + previousBalance=calcReserve(...) + totalAvailable=previousBalance+s.balance. KPI "saldo" usa totalAvailable (não mais `calcStats(txs)` acumulado). (4) Handlers Lote 60.2 distinguem 3 casos: `Math.abs(prev) < 0.005` (sem histórico → didática 🌱); `prev > 0` (reserva → antigo); `prev < 0` (NEW: "Você tá arrastando R$ X de meses anteriores fechados no vermelho 📉"). Handler "saldo disponível total" formata com sinal/emoji (⚠️ negativo, 💚 positivo) + contexto reserva/dívida. Fix B (feature): user perguntou "Me fale sobre meu mês de janeiro" e Mony caiu em fallback ("Hmm, não entendi" 🤔). NOW handler dedicado. (5) MONTH_LOOKUP_PT (12 meses PT-BR + variantes sem acento marco/março). (6) parseMonthQuery(query, today) exportado: detecta menção PT-BR via word-boundary + heurística most-recent-past (mês posterior ao corrente sem ano → ano anterior). Aceita ano explícito ("de 2025", "/2025", "2024"). Input não-string → null. (7) NEW handler em localReply: dispara com verbo de resumo OU mês isolado. Verbos: "me fale/conta/diz/explica/mostra/fala sobre", "como foi/tava/ficou", "balanço de", "resumo de", "relatório de", "o que aconteceu em/com", "fechamento de", "fechei o mês de", "recap de". Resposta: entradas + saídas + status (🟢 positivo / 🔴 vermelho / ⚪ zerado) + top categoria de saída + contexto saldos anteriores (vinha de reserva OR arrastando dívida 📉). Mês sem dados: "Não tenho registros de Janeiro 2026 🌱 — você ainda não lançou nada desse mês". 29 tests novos em lote60-4-divida-arrastada-e-mes-especifico.test.ts cobrindo: cenário PROD exato (Multa R$ 10k); calcReserve signed sum; Dashboard breakdown novo com RED; GVA modelo fluxo de caixa + anti-regressão `const s=calcStats(txs)`; 3 casos do handler "saldos anteriores"; handler "saldo disponível total" formata negativo; parseMonthQuery (todos meses + variantes + ano + heurística); handler "mês de X" 8 cenários. + 3 tests do Lote 60 atualizados (calcReserve não filtra mais positivos). 1226 verdes (era 1197, +29). Backend INTACTO. Resolve bug PROD do user real + fecha gap conversacional nos 12 meses. Tag v1.2.30.
canGoBack derivava do índice em availableMonths (Set que só tinha meses com tx), então com 1 elemento → ambas setas desativadas. Fix: nav agora é LIVRE pro passado, capada no currentMonth pro futuro. (1) 2 helpers novos exportados: incrementMonth("2026-12") → "2027-01" e decrementMonth("2026-01") → "2025-12" (viram o ano corretamente; input inválido → mesmo input pra ser defensivo). (2) Lancamentos simplificado: canGoBack = true (sempre, sem limite arbitrário); canGoForward = !isCurrentMonth (futuro continua bloqueado — saveTx já bloqueia data > hoje, ver mês futuro vazio é zero valor). Seta esquerda → setSelectedMonth(decrementMonth(effectiveMonth)). Seta direita → incrementMonth(effectiveMonth) + cap em currentMonth. (3) Guard de effectiveMonth simplificou: era índice em availableMonths; agora /^\d{4}-\d{2}$/.test(selectedMonth) ? selectedMonth : currentMonth (proteção extra defensiva, nav SEMPRE seta valor válido). (4) availableMonths/listAvailableMonths preservados pra contagem informacional e tests — sem papel na navegação. 9 tests novos em describe "Lote 60.3.1": incrementMonth normal/vira-ano/inválido; decrementMonth normal/vira-ano/inválido; ida-e-volta (incrementMonth(decrementMonth(x)) === x); pode voltar 30 meses (Maio 2026 → Novembro 2023); canGoBack=true sempre + anti-regressão; canGoForward=!isCurrentMonth; nav usa decrementMonth/incrementMonth direto; cap em currentMonth no goNext. + 3 tests do 60.3 atualizados pra refletir nova lógica. 1197 verdes (era 1188, +9). Comportamento agora: user com qualquer histórico (até zero tx) consegue navegar pra qualquer mês anterior — empty state já mostra "Sem lançamentos em X · Navegue pra outro mês". Tag v1.2.28.
monthLabelFromKey("2026-05") → "Maio 2026" (com guard pra mês inválido → string vazia) + listAvailableMonths(txs, currentMonth?) (Set ordenado ASC, sempre inclui currentMonth pra user novo conseguir lançar mesmo sem histórico). (2) State + lógica: `selectedMonth` default = currentMonth; `effectiveMonth` defensivo (cai pro currentMonth se selectedMonth saiu de availableMonths via sync household — partner deletou todas tx daquele mês); `canGoBack = effectiveIdx > 0`; `canGoForward = effectiveMonth !== currentMonth` (não navega pro futuro — saveTx já bloqueia data > hoje); `monthTxs = filterTxsCurrentMonth(txs, effectiveMonth)`; `s = calcMonthStats(monthTxs)`. (3) `checkRisk` preserva `currentMonthStats` — quando user adiciona nova tx, ela vai cair em hoje (modal +Novo default = data atual). Risk warnings precisam comparar com renda/despesa do mês CORRENTE, não do mês navegado. Se isCurrentMonth reusa `s`; senão recalcula via `calcMonthStats(filterTxsCurrentMonth(txs, currentMonth))`. (4) UI do seletor: 2 botões 48×48 CSS px (regra 18 tap target) com `aria-label="Mês anterior"` / `"Próximo mês"` + label centrado `monthLabelFromKey(effectiveMonth)` + sub-label monospace com contagem ("24 lançamentos" / "1 lançamento" / "sem lançamentos") + badge "· MÊS ATUAL" condicional. Setas desabilitadas viram opacity 0.4 + cursor not-allowed (visual claro pro user). (5) Cards ENTRADAS/SAÍDAS contam de `monthTxs.filter(...).length` (antes contavam `txs.filter(...).length` — anti-regressão coberta nos testes). `s.income`/`s.expense` já vinham de `calcMonthStats`, só a contagem trocou. (6) Lista paginada (`filtered = filter==="all" ? monthTxs : monthTxs.filter(...)`) usa monthTxs. Filter "🎯 Metas" continua escondendo o seletor inteiro (metas são cross-month). (7) Empty state menciona o mês: "Sem lançamentos em Maio 2026" (ou "de entrada"/"de saída em Maio 2026" conforme filter). Hint condicional: current = "Registre suas movimentações..."; outro = "Navegue pra outro mês ou registre...". Botão também muda label: "+ Adicionar primeiro" vs "+ Novo lançamento". (8) UX consistente: trocar mês reseta `lancPage = 0` + `selectedIds = new Set()` (mesma UX que trocar filter type). (9) Backend INTACTO: storage continua sendo o array histórico completo; `syncTxs` continua enviando array inteiro — só a EXIBIÇÃO ficou mensal. Testes anti-regressão garantem que `syncTxs(monthTxs)` NUNCA aparece (seria bug crítico: deletaria tx de outros meses do Redis). 20 tests novos em lote60-3-lancamentos-seletor-mes.test.ts cobrem: monthLabelFromKey (todos os meses + inválido); listAvailableMonths (sempre inclui currentMonth, ordena ASC, ignora date inválido, default = hoje); Lancamentos declara selectedMonth/availableMonths/effectiveMonth/monthTxs no topo; canGoBack/canGoForward derivados; filtered usa monthTxs; checkRisk usa currentMonthStats; setas com aria-label + tap targets ≥48; label + pluralização da contagem; badge "MÊS ATUAL" só quando isCurrentMonth; cards contam de monthTxs; empty state menciona mês + hint condicional + botão muda label; seletor só fora de filter="goals"; trocar mês reseta lancPage+selectedIds; edge case fora de availableMonths cai pro currentMonth; backend não recebe monthTxs no syncTxs. 1188 testes verdes (era 1168, +20). Trio fechado: Dashboard + Mony + Lançamentos agora 100% consistentes no modelo de fluxo de caixa contábil. Tag v1.2.27.
calcStats(txs) da história inteira — desalinhamento confuso (Dashboard mostrava "Saldo R$15k" enquanto Mony falava "Você tem R$80k"). (1) localReply refatorado: agora usa const monthTxs = filterTxsCurrentMonth(txs, currentMonth); const {`{income,expense,balance,savPct,alim,alimPct,cats}`} = calcMonthStats(monthTxs); const previousBalance = calcReserve(txs, currentMonth); const totalAvailable = previousBalance + balance; + flag derivada isEatingReserve = balance < 0 && previousBalance > 0 && totalAvailable >= 0. (2) Handler "comendo a reserva": dispara em "tô comendo a reserva", "tô estourando", "tô passando", "tô no vermelho mas tenho reserva". Alerta firme — rombo absoluto + queda de saldos anteriores ({`{prev}`} → {`{totalAvail}`}) + top 3 categorias do mês formatadas inline + recomendação "não é sustentável se virar padrão". (3) Handler "saldos anteriores / minha reserva / reserva acumulada / quanto guardei / sobrou dos meses anteriores": com negative regex guard !(onde|qual.*(melhor|bom)|investir|aplicar|tesouro|cdb|lci|lca|fgc|poupan[çc]a) — perguntas tipo "onde guardar minha reserva?" passam pro handler de investimento. Devolve previousBalance + tail conforme estado do mês (positivo "esse mês tá positivo em R$X" / negativo "esse mês tá em -R$X" / zerado). Se previousBalance <= 0, mensagem de incentivo "Você ainda não tem saldos anteriores 🌱". (4) Handler "saldo disponível / total disponível / quanto tenho no total / posso gastar": formata totalAvailable com breakdown "↳ Saldos anteriores: R$X · ↳ Esse mês: R$Y". Se previousBalance <= 0, fallback simples mostra só balance do mês. (5) Handler genérico de saldo: ganha early return pra isEatingReserve ANTES das respostas neg/pos puras — "Esse mês fechou em -R$X ⚠️ entradas Y vs saídas Z. Seu saldo disponível total caiu de R$prev pra R$total (saldos anteriores absorveram o rombo)". (6) Mensagem de sucesso: ganha totalLine opcional quando previousBalance > 0 — "💰 Saldo disponível total: R$X (anteriores R$Y + esse mês R$Z)". Nomenclatura A: "Saldo disponível" / "Saldos anteriores" / "Esse mês" — consistente com o card visual. Reforça vocabulário comum sem misturar "reserva" (Lote 60.1 removeu o card separado). (7) calcStats(txs) preservado pra buildAlerts, projeções, salaryAware insights e outros utilitários que ainda operam em toda a história. 12 tests novos em lote60-2-mony-fluxo-caixa.test.ts: handler reserva positiva/zero/com mês positivo/negativo; saldo disponível com breakdown completo vs fallback; comendo reserva trigger + composição; saldo genérico intercepta isEatingReserve; localReply escala mês (não inclui R$ da história fora); negative guards (investimento passa pro handler de investir). 1168 testes verdes (líquido +12 net depois de 1 teste removido por conflito de poupança). Mony agora fala a língua do Dashboard. Próximo: Lote 60.3 = aba Lançamentos com seletor de mês (Fase 3). Tag v1.2.25.
const totalAvailable = previousBalance + s.balance; card principal mostra totalAvailable formatado (trata negativos via Math.abs); estados neg/zero baseados em totalAvailable (não s.balance isolado); labels voltaram pra "Saldo disponível" / "Saldo em déficit" / "Saldo zerado" (semântica de fluxo total). (2) Breakdown dentro do card: monospace 10px, "↳ Saldos anteriores R$ X" + "↳ Mês atual (Maio 2026) R$ Y", só renderiza quando previousBalance > 0. Linha do mês atual pode ficar vermelha se o mês isolado tá em déficit (sinaliza "comendo reserva"). (3) Card "Reserva acumulada" separado REMOVIDO — informação agora integrada no breakdown. Comentário documenta a remoção. (4) Sub-labels mantidos: ENTRADAS/SAÍDAS/POUPANÇA continuam só do mês corrente. (5) 7 tests novos + 4 removidos: totalAvailable; label "Saldo disponível" voltou; neg/zero baseados em totalAvailable; valor exibido = totalAvailable; breakdown condicional previousBalance>0; card separado removido anti-regressão; sub-labels usando s.*. 20 helpers tests mantidos intactos (helpers não mudaram, só composição mudou). 1168 verdes (era 1153, +3 net). Comportamento: hoje (Maio) → "Saldo R$15.263 · ↳ Mês atual R$15.263"; em 01/06 → "Saldo R$15.263 · ↳ Anteriores R$15.263 · ↳ Mês atual R$0"; +R$5000-R$200 em junho → "Saldo R$20.063 · ↳ Anteriores R$15.263 · ↳ Mês atual R$4.800". Tag v1.2.24.
calcStats(txs) no Dashboard somava TODA a história — saldo crescia indefinidamente, incoerente com Radar do Dia que sempre operou em escala mensal. Plano faseado pra reduzir risco em PROD com user ativo: Fase 1 (este lote) = helpers + Dashboard; Fase 2 (Lote 60.1) = Mony chat handlers; Fase 3 (Lote 60.2) = aba Lançamentos com seletor de mês. (1) 4 helpers novos: getCurrentMonth (YYYY-MM), filterTxsCurrentMonth, calcMonthStats (delega pra calcStats com subset), calcReserve (soma APENAS saldos positivos de meses fechados; meses negativos NÃO descontam — premissa de produto). (2) Dashboard refatorado: const currentMonth + monthTxs + s=calcMonthStats(monthTxs) + reserve=calcReserve(...). Sub-labels Entradas/Saídas/Poupança = só do mês. Labels: "Saldo disponível" → "Saldo do mês"; "Saldo em déficit" → "Mês em déficit"; "Saldo zerado" → "Mês zerado". Alinha com Radar do Dia. (3) Novo card "Reserva acumulada": 💰 + label lime monospace + texto "Sobra preservada dos meses fechados — não some na virada do mês" + fmt(reserve). Border lime sutil reforça "conquista preservada". Renderiza condicional {`{txsBootstrapped && reserve > 0 && (...)}`} — user novo não vê slot vazio. (4) Backend intacto + calcStats preservado pra compatibilidade (Pulse/Mony chat, alertas, projeções). 20 tests novos: 4 helpers + simulação virada de mês (maio +R$1500 → junho mostra balance=0 + reserve=R$1500; maio negativo → reserve permanece 0) + Dashboard JSX assertions. 1168 verdes (+20). Tag v1.2.23.
mony-tchau.png (acenando — modal Sair), mony-entradas.png (thumbs-up sorrindo — toast entrada), mony-saidas.png (triste/neutra — toast saída). (2) Modal "Sair do Monify": div 52×52 com bg rgba(232,67,90,.1) + SVG seta vermelho sai; entra <img src="/mony-tchau.png" width=84 height=84 objectFit:contain> SEM círculo (pattern Lote 57.1). Mascote solto = momento de despedida mais acolhedor. (3) Toasts ENTRADA/SAÍDA REGISTRADA: tipo palette refatorado icon: string → icon: React.ReactNode; palette.in ganha img mony-entradas (era ✅), palette.out ganha img mony-saidas (era 📤). Edit (✏️) e delete (🗑️) mantêm emoji compacto (escopo restrito). (4) Mony "ideia" responsiva: era width:56, height:56 hardcoded (Lote 57.3); agora clamp(64px, 17vw, 88px) em ambas — escala iPhone SE (375×17vw=64) → iPhone padrão (~70) → iPad+ (88 max). Min 64 passa folgado do requisito Google de 48 (regra 18 tap target). Position ajustada top:-8→-10, right:-4→-6 pra compensar crescimento. 14 tests novos + 1 test do Lote 57.3 atualizado (clamp pattern em vez de number literal). 1168 verdes (+14). Zero mudança de comportamento. Tag v1.2.21.
api/partner/accept.ts: ANTES de ler invite token, verifica se partnerUser.partnerOf bate com owner.partnerEmail. Se sim, retorna ok:true + alreadyAccepted:true. Cobre race conditions, double-clicks, refresh post-accept. (2) Frontend: constant LS_PENDING_INVITE_TOKEN persiste token em localStorage. (3) Função centralizada tryAcceptPendingInvite — lê URL OR LS; persiste em LS sempre; trata 4 ramos: sucesso (limpa LS+URL, só refetch txs se foi novo accept) / 401 (mantém LS pra retry após relogin) / erro definitivo 404/403/409 (limpa LS) / erro rede (mantém LS pra retry automático). (4) processingInviteRef boolean substitui acceptedInviteTokensRef Set in-memory. (5) Dois useEffects: mount (sem deps) + [loggedIn, profile.email, profile.sessionId] que dispara automaticamente quando partner cria conta — cobre o cenário exato do bug Lote 58. 15 tests novos em lote58-1-invite-robust.test.ts: backend idempotência check + posição ANTES de ler invite token; frontend constant + processingInviteRef + lê URL or LS + persist em LS + sucesso/401/erro/rede com cleanup correto + useEffect mount + useEffect retry + apenas 1 fetch a /api/partner/accept em todo o código (centralização). 1168 testes verdes (+15). Próximas vinculações funcionam mesmo em edge cases (partner criou conta antes, PWA strip, refresh, double-click). Force-link admin continua disponível. Tag v1.2.20.
api/admin/force-link.ts (Edge runtime + authenticateAdmin): POST { ownerEmail, partnerEmail }. 5 bloqueios de segurança: owner != partner; ambos users existem; owner não é partnerOf de outro; owner não tem outro partnerEmail; partner não é owner de outro household nem vinculado a outro; owner em plano pro/juntos. Idempotência: já vinculados → alreadyLinked:true sem refazer. Execução: hset partner (partnerOf+plan=juntos+pro=true) + hset owner (partnerEmail+plan=juntos com upgrade auto + limpa invitePendingToken) + 4 cascades (migrate txs/ceiling/goals/categories) + deleta invite:<token> pendente. Audit log: logAdminAction("force-link", "owner ↔ partner", adminEmail). Tipo AdminAction extendido com "force-link". (2) UI: novo botão "🔗 Vincular Juntos" (lime) na barra de ações do admin → modal com 2 inputs email + aviso de irreversibilidade âmbar + validação local (preenchidos + formato + owner != partner) + handler chama POST /api/admin/force-link com adminAuthHeaders. Após sucesso: update otimista da tabela (linhas dos 2 users) + toast. ADMIN_VERSION 1.1.0 → 1.2.0. 20 tests novos em lote58-force-link.test.ts (backend Edge + auth admin + 4 migrate funcs + hset partner/owner + idempotência + 5 bloqueios + invite cleanup + audit log + frontend state + handler endpoint + validação local + botão + modal + update tabela). 1168 testes verdes (+20). Resolução: admin clica "Vincular Juntos" → Gustavo↔Andressa em 1s, txs migram. Causa raiz (race condition useEffect[loggedIn] vs sessionId vs URL clean) → Lote 58.1 dedicado. Tag v1.2.19.
feedback_tap_targets_48px). Mudança: width:40, height:40 → width:56, height:56 (passa do mínimo legal de 48 com folga + dá presença visual real pra Mony). Position ajustado top:-2 → top:-8 + right:0 → right:-4 pra continuar verticalmente centralizado no bloco da saudação ("BOA TARDE · Gustavo · Maio 2026") mesmo crescendo 16px. 1 test novo: localiza o style={{ ... }} inline do button, extrai width/height numéricos, valida ≥48 (anti-regressão pra evitar refactor futuro reduzir abaixo do mínimo de acessibilidade). 1168 testes verdes (+1). Visual baselines regen pra refletir Mony maior. Comportamento 100% preservado (onClick onOpenFeedback intacto). Tag v1.2.18.
<span overflow:hidden> wrapper + <img width:125% height:125% objectFit:cover objectPosition:"50% 22%"> pra dar zoom no rosto. Com zoom + foco no topo, o translateY -3px da animação levava o topo da imagem além da borda → corte. Refactor cirúrgico: wrapper <span> removido (sem propósito após Lote 57.1 que removeu o círculo); <img> direto no <button>; objectFit: cover → contain (mostra mascote inteiro centralizado); objectPosition removido (contain centraliza sozinho); width/height: 125% → 100%. Resultado: Mony aparece inteiro de cabeça aos pés dentro do button 40×40, e translateY -3px da animação só desloca o conjunto sem cortar nada. 2 tests do Lote 57 atualizados: removidas assertions de cover/objectPosition/wrapper; adicionadas anti-presença (contain + sem objectPosition + sem <span>). 1168 testes verdes (+1). Zero mudança de comportamento. Tag v1.2.17.
.feedback-lightbulb: background: transparent + border: none + box-shadow: none. (2) Pseudo-element .feedback-lightbulb::after (glow pulse via opacity) totalmente removido. (3) @keyframes feedbackPulse deletado (sem uso agora). (4) Mantidos: transition transform + :hover scale(1.08) + :active scale(.95) — feedback visual de "é clicável" continua via scale, sem precisar de moldura. JSX em App.tsx inalterado, só CSS mudou. Mascote continua flutuando via monyFloat na própria <img> (Lote 57). 2 tests do Lote 57 atualizados: anti-presença do círculo (verifica background:transparent + border:none + box-shadow:none + ausência de ::after + ausência de @keyframes) + assertions de hover/active scale preservados. 1168 testes verdes (mantido — 2 tests atualizados, nenhum adicionado). Visual baselines regen pra refletir novo look clean. Zero mudança de comportamento (onClick onOpenFeedback intacto). Tag v1.2.16.
public/mony-idea.png (PNG transparente 408×612 RGBA 160KB): variação do mascote com lâmpada acesa, semanticamente combina com "sugerir ideia". (2) Botão refatorado: emoji sai; entra <img src="/mony-idea.png"> em wrapper <span overflow:hidden borderRadius:50%> (crop circular) + objectPosition: 50% 22% foca no rosto/braço + animation: monyFloat 3.2s (reusa keyframe do Lote 55, translateY ±3px GPU-composable). Comportamento 100% preservado: onClick onOpenFeedback, aria-label/title "Sugerir uma melhoria", hover scale(1.08), active scale(.95). (3) @keyframes feedbackPulse refatorado: antes animava box-shadow no keyframe (não-composable, regra 20 PROJECT_RULES). Agora pulse vem de pseudo-element .feedback-lightbulb::after (inset:-6px, pointer-events:none) com box-shadow estático grande lime + animation: feedbackPulse que anima só opacity (0.85 → 0). Glow equivalente, GPU-composable. (4) Estrutura DOM: <button> (sem overflow:hidden pra ::after fluir além das bordas) → <span overflow:hidden> (crop imagem circular) → <img>. Separação cirúrgica que faz tudo funcionar. 6 testes novos em lote57-mony-idea.test.ts: asset existe; botão renderiza img + monyFloat + objectFit/objectPosition; emoji 💡 sumiu de dentro do botão; wrapper interno com overflow:hidden + borderRadius:50%; keyframe feedbackPulse só opacity; ::after tem box-shadow + animation feedbackPulse. 1168 verdes (+6). Zero mudança de comportamento — só asset visual + animação composable. Tag v1.2.15.
{`{!showAddCategory && customCategories.length < categoriesCap && }`} (e o equivalente "Limite · Pro" quando free atinge cap) de depois do .map() dos chips pra antes dele. Botão fica fixo na primeira posição do scroll horizontal, fora da ordem alfabética dos chips — primeira coisa que o user vê. 1 teste novo em lote56-custom-categories.test.ts: verifica via app.indexOf("+ Nova") < app.indexOf("allCats: Array...") que o botão precede o bloco do map — anti-regressão pra evitar voltar pro fim em refactor futuro. 1168 testes verdes (era 1069, +1). Zero risco — só reordena JSX. Tag v1.2.14.
api/user/categories.ts (Edge runtime): GET/POST/DELETE, sanitize max 24 chars + remove control chars, bloqueia dup vs built-in (case-insensitive — "Alimentação"/"ALIMENTACAO" conta como mesma), bloqueia dup vs custom existente, cap por plano com mensagem de upgrade no 403, rate limit userWrite (60/min). DELETE filtra do array sem tocar em txs (histórico preservado). (2) Helpers em household.ts: getCategoriesStoreKey resolve user vs household; migrateCategoriesToHousehold dedupe lowercase + cap 10 + createdBy; splitCategoriesFromHousehold devolve por autor. (3) LGPD cascade completo: export.ts inclui custom_categories (Art. 18 II acesso); delete-self.ts cascade key individual + household quando owner + move pro owner quando partner; admin/wipe.ts limpa ambas. (4) Partner cascade: accept chama migrate, leave + remove chamam split. (5) Frontend: LS_CATEGORIES_KEY cache + BUILTIN_CATEGORIES const + CustomCategory interface; state global + categoriesCap no Monify(); load/create/delete useCallback retornando {ok}/{error}; polling 10s padrão JUNTOS. (6) Modal +Novo refatorado: chips built-in + custom misturados alfabético com localeCompare("pt-BR") (acentos certinhos); custom marca com 🏷️; cada custom tem ✕ que vira ✓ vermelho pra confirmar delete (clique 2 = DELETE); botão "+ Nova" dashed lime se cap não atingido; vira "Limite · Pro" âmbar que abre planos quando free atinge cap; input inline com autoFocus + maxLength 24 + Enter/Esc handlers + validação dup local antes do POST (vs built-in case-insensitive E custom existente — mostra erro sem hit no servidor); contador "N/M custom" no header. 29 tests novos em lote56-custom-categories.test.ts (backend helpers + endpoint + LGPD + partner cascade + frontend constants/state/sync/props/modal/validação/delete). 2 tests Lote 55.3 atualizados (chips agora dinâmicos via const + sort). 1168 testes verdes (+29). Zero regressão funcional. Tag v1.2.13.
["Alimentação","Geral","Lazer","Moradia","Renda Extra","Saúde","Transporte"]. (3) Fade gradient nas bordas via mask-image: linear-gradient(to right, transparent 0, #000 14px, #000 calc(100% - 14px), transparent 100%) + WebkitMaskImage pra Safari. Zero elementos DOM extras, swipe horizontal preservado (UX standard mobile). Recusei sugestão de setas ◀ ▶ — seriam ruído. (4) spellCheck={false} no <textarea> do chat: iPhone sublinhava "Mony" como erro (não está no dicionário). Fix cirúrgico: desativa só o underline vermelho, mantém autoCorrect ativo (user ainda recebe correção de português). (5) Sugestão "botão + pra criar categoria custom" triada pra Lote 56 dedicado — trabalho grande (backend novo /api/user/categories + schema Redis + sync household JUNTOS + cascade LGPD export/delete + validação + edge case "transação com categoria removida"), risco médio em PROD com users ativos. 4 tests novos em lote55-mony-rebrand.test.ts: label "Atalhos categoria" existe + antigo sumiu; chips na ordem alfabética exata; container chips tem WebkitMaskImage + maskImage; textarea contém spellCheck={false}. 1168 testes verdes (+4). Zero regressão funcional. Tag v1.2.12.
LoginSplash refatorado: <div fontSize:128 LIME>M</div> central sai; entra <img src="/mony.png" alt="Mony" height={200}> com animation: splashBreatheScale (continua respirando — scale 1 → 1.04, 2.6s ease, transform+opacity composable). (2) Rings + ambient removidos do JSX + dos @keyframes inline (splashRing 2× + splashAmbient). (3) Mantemos UM glow halo estático sutil atrás do mascote (radial-gradient 240px, blur 22px, splashBreatheGlow opacity-only 0.4 → 0.75 — composable) pra mascote não ficar perdido no fundo preto. (4) Wordmark "monify.app" + tagline "COPILOTO FINANCEIRO" + barra de progresso indeterminada permanecem intactos, com fade-up sequence preservada. Resultado: splash agora é "Mony chegando" em vez de "M abstrato com ondas". Identidade visual coerente: nav 22px → header 42px → splash 200px = mesmo mascote em escalas. 4 tests novos em lote55-mony-rebrand.test.ts: LoginSplash usa <img src="/mony.png"> com height:200 + splashBreatheScale; não renderiza mais o glyph M; não tem mais @keyframes ou animations splashRing/splashAmbient; mantém wordmark/tagline. 1168 testes verdes (+4). 72/72 visual baselines regen necessário. Tag v1.2.11.
public/mony.png (107 KB, vertical ~2:3). (2) MonyAvatar refatorado: <img src="/mony.png"> com objectFit:cover + objectPosition:"50% 22%" (foca no rosto, já que o PNG é vertical e o avatar circular é quadrado). width/height:120% dá zoom no rosto + corta fundo branco residual. Mantém círculo lime gradient + glow no hero (header chat), animação monyFloat (translateY ±3px, GPU-composable). (3) AIIcon bottom nav: mesmo <img> em 22×22, filter:grayscale(1) + opacity:0.55 inactive (coerência com Dashboard/Lançamentos/Fluxo); colorido + plena opacidade active. User vê o mesmo Mony "crescendo" do nav (22px cinza) → header (42px colorido glow) → bubble (28px discreto). 3 tests novos em lote55-mony-rebrand.test.ts (3 antigos do SVG removidos): mony.png existe, MonyAvatar usa <img> com objectPosition, AIIcon usa filter:grayscale inactive. 1168 testes verdes (+3 net). 72/72 visual baselines regen. Tag v1.2.10.
/api/pulse/*, Redis pulse:*, telemetria PulseEvent, identifiers internos (pulseUsage, PulseLLMSnapshot) ficam como estão. Zero risco de quebrar PROD com users ativos. (1) Strings UI em App.tsx (~25 trocas): greeting "Sou a Mony — sua assistente...", handler identidade, header chat "Mony" + "Mony ilimitada ⭐", disclaimer "A Mony possui caráter auxiliar", bottom nav label, Dashboard hero "Descubra com a Mony", Inteligência Preditiva subtitle, 3 planos features ("Mony ilimitada — sem restrições" / "3 análises Mony por mês" / "Mony responde para os 2 usuários"), trial-ended modal, delete-self, push template, 3 off-topic handlers. (2) Mascote Mony: novo componente MonyAvatar (variant hero 42px gradient lime + glow, variant bubble 28px discreto). SVG inline robozinho fofo — antenas com dots lime, cabeça oval, visor escuro com olhos lime sorrindo em curva. Substitui o ✦ sparkle nos 4 lugares do chat (header hero, msg AI, premium reply, loading). @keyframes monyFloat flutua translateY(0 → -3px → 0), 3.2s ease infinite, transform-only = GPU-composable (regra 20 PROJECT_RULES). (3) AIIcon bottom nav refeito: antes "círculo central + raios cardinais"; agora mesmo mascote (antenas + visor + olhos sorrindo só active pra contraste). Coerência identitária: user vê o mesmo Mony "crescer" do nav pro header. (4) ai-policy.html: 3 mentions atualizadas. (5) pulse-map + pulse-showcase: title + hero text + cards atualizados; URLs mantidas. Labels técnicos arquiteturais (Pulse Engine, Pulse Capabilities) ficam — refactor desses fica pra lote dedicado. 19 testes novos em lote55-mony-rebrand.test.ts protegem regressão (strings UI/MonyAvatar/SVG mascote/monyFloat/4+ usos no JSX/✦ legacy removido/AIIcon sem raios+com antennas/ai-policy.html sem "Pulse" como IA). 2 tests pulse-stress atualizados. 1168 testes verdes (era 1010, +19). Zero regressão funcional. Tag v1.2.9.
<link rel="stylesheet"> principal, mesmo com o preload do Lote 54.4 (preload acelera fetch mas link síncrono ainda BLOQUEIA first paint até parse do CSS completo). Solução padrão da web adaptada pra CSP-safe: media-swap async. (1) Novo public/css-async.js (~150 bytes): script externo defer que seleciona link[data-async][rel="stylesheet"] e seta .media = "all". CSP-safe (sem onload inline). (2) vite.config.js cssPreloadPlugin agora também TRANSFORMA o <link rel="stylesheet"> do Vite em <link ... media="print" data-async> — print não bloqueia render de tela. <noscript> fallback pra users sem JS. Injeta <script src="/css-async.js" defer> antes de </head>. (3) Critical CSS inline do Lote 54.9 é a peça-chave: cobre o initial paint ANTES do CSS principal aplicar → swap invisível, sem FOUC. 2 testes novos em lote54-build.test.ts: css-async.js existe + seleciona data-async + media=all + sem <script inline; vite.config.js emite media=print + data-async + noscript + css-async.js defer. 1010 testes verdes (era 1008). PageSpeed esperado: 96-99 Desempenho (de 93), FCP cai 2.1s → ~1.4s, LCP melhora. Zero mudança visual. Tag v1.2.8.
<link rel="stylesheet"> render-blocking (browser bloqueia first paint até CSS principal baixar, mesmo com preload); (b) @keyframes ctaGlow + @keyframes iDotPulse animando box-shadow = non-composable (paint/layout a cada frame). Escolhi Opção A (mais segura) dado PROD com usuários ativos — sem mexer em code-split do admin (Opção B, risco médio-alto). (1) Critical CSS inline em index.html: <style> minimal (~700 bytes) antes de </head> cobrindo :root + [data-theme="light"] (vars críticas --bg/--tx/--tx3) + reset * { box-sizing: border-box } + html, body, #root (margin/padding 0, height 100%, bg+color via vars, font-family system, antialiasing, overscroll-behavior, touch-action manipulation, -webkit-text-size-adjust 100%). Tela pinta IMEDIATAMENTE no parse — sem flash branco/preto. Stylesheet completo continua carregando e adiciona resto. (2) Animations GPU-composable: @keyframes iDotPulse (dot do "i" no wordmark) antes animava box-shadow no keyframe; agora só anima transform: scale + opacity. Shadow virou estático em .i-glyph::after { box-shadow: ... }. @keyframes ctaGlow (botão login) idem: keyframe só anima opacity: 0→1→0; shadow grande virou pseudo-element overlay novo .login-btn:not(.loading)::after com box-shadow estático + ctaGlow fade. Botão mantém shadow menor estático sempre visível — nunca "apaga", só ganha overlay maior pulsando. :hover cancela animation no overlay (fica fixo sem flicker). 6 testes novos em lote54-build.test.ts bloqueiam regressão: keyframes ctaGlow/iDotPulse não podem ter box-shadow; .i-glyph::after tem shadow estático; .login-btn:not(.loading)::after existe com shadow + animation ctaGlow; index.html tem critical CSS inline com :root + light theme + reset; inline NÃO inclui keyframes/componentes (deve ficar minimal). 1008 testes verdes (era 1002). PageSpeed esperado: Desempenho 96-98, Acessibilidade 100, Práticas 100, SEO 100. Zero mudança de UX visível. Tag v1.2.7.
public/robots.txt + public/sitemap.xml + landmark <main id="root"> em index.html. 54.4 Performance 93→96+: novo cssPreloadPlugin em vite.config.js (inject <link rel="preload" as="style"> + modulepreload antes do script tag, -300ms render-blocking) + splash animations refatoradas pra composable (era filter:drop-shadow animado, agora transform+opacity com glow halo estático). 54.5 Admin trial vs pagante: backend agora retorna paymentMethod/proExpiresAt/partnerEmail/partnerOf em PROD; card "Por plano" com 4 categorias (Free/Pro/Juntos/Trial 🎁) + "X pagantes (Y% conversão real)" exclui trials; tabela mostra ↔ Compartilha com / ↤ Convidado por nos users Juntos. ADMIN_VERSION 1.0→1.1. 54.7 CI fix REAL: TOTP crypto.subtle.importKey rejeitava ArrayBuffer cru no Node 20 LTS Ubuntu (jsdom strict). Trocado por new Uint8Array(keyBytes) que funciona universalmente. 10 testes TOTP voltaram a verde no CI; memória feedback_webcrypto_uint8array criada. 54.8 Pinch-to-zoom + tap targets 48×48: 4 camadas bloqueavam pinch (viewport user-scalable=no + CSS touch-action pan-x pan-y + 3 JS gesture* listeners em main.tsx + inputs <16px que disparavam iOS auto-zoom). Todas removidas/ajustadas. Tap targets dos auth screens (forgot-link, eye icon, footer links, theme toggle, switchers signup/login) bumpados pra 48×48 cumprir Lighthouse + WCAG. Bônus: clique mobile ~300ms mais rápido (touch-action manipulation). 13 testes novos em src/__tests__/lote54-build.test.ts validam build artifacts (robots/sitemap existem; viewport sem user-scalable=no; touch-action manipulation; .glass-input 16px; main.tsx sem gesture listeners; inputs ≥16; ADMIN_VERSION; TOTP usa Uint8Array). 989 → 1002 verdes. PageSpeed esperado: Desempenho 96+, Acessibilidade 100, Práticas 100, SEO 100. Tag v1.2.6.
vite.config.js trocou registerType:'autoUpdate' por 'prompt' + injectRegister:false; novo componente PwaUpdateBanner registra SW via virtual:pwa-register e escuta onNeedRefresh; banner discreto no topo (lime gradient, slide-down) com botão "Atualizar" → user clica = reload imediato; detector de atividade (mousedown/keydown/touchstart/scroll) + idle 5min = reload silencioso. User SEMPRE controla o momento → impossível ter race com push subscribe ou checkout. Stub virtual:pwa-register em vitest pra testes não quebrarem. 72/72 visual baselines regen + 1002 testes verdes. Próximo passo natural (não nesse lote): SSE pra sub-segundo se 3s não bastar.
humanizePushError() em usePushNotifications.ts traduz 5 tipos de erro técnico pra mensagens amigáveis acionáveis (timeout subscribe / timeout VAPID-SW / permissão negada / VAPID 503 / backend HTTP). Detalhes técnicos vão pra console.error pra debug. Fim do silent hotfix: a partir desse deploy, versão volta a bumpar visível — v1.1.X pra patches, v1.2.0 pra próximo lote relevante, v2.0.0 pra breaking changes. v1.0.0 foi o lançamento + acumulou 5 hotfixes silenciosos (52.1, 53, 53.1, 53.3, 53.4); v1.1.0 marca a consolidação.
Timeout em pushManager.subscribe (15000ms). Causa raiz: combinação skipWaiting + clients.claim trava pushManager.subscribe() em iOS PWA (bug conhecido do Safari quando SW transiciona durante subscribe). Fix cirúrgico: sw.ts volta ao comportamento original (sem skipWaiting/clientsClaim); detector de controllerchange removido do App.tsx. Trade-off aceito: PWA não atualiza mais sozinho — user fecha/abre. Mantido: sync JUNTOS (53.3) + install prompt (53/53.1). Gotcha em memória: nunca usar skipWaiting+clientsClaim em apps com push iOS.
getGoalsStoreKey + migrateGoalsToHousehold + splitGoalsFromHousehold; cap 5→10 no household; createdBy preservado pra split-back). Custo Upstash: ~360 req/h/par (era ~80) — aceitável. 33 testes novos = 1002 verdes.
beforeinstallprompt (1-tap nativo); iOS tem 4 passos com share button. Link discreto "📱 Instalar no celular" no footer da tela de login. Janela "Mais tarde" caiu de 30d pra 2d (fase beta). Splash refinado: cluster M+wordmark+tagline relative, rings ancorados nele em vez de viewport (não cruzam mais o cluster pelo meio).
@upstash/redis 1.x faz JSON.parse em cada campo do hgetall — "true" (string gravada via hset) volta como boolean true, e admin.mfaEnabled === "true" sempre dava false. Fix: String(admin.mfaEnabled) === "true" em login.ts + mfa-setup-init.ts + mfa-challenge.ts. Corrigi também erro TS pré-existente em wipe.ts (scan retornando any). Gotcha documentada em memória pra evitar regressão.
Mesmo padrão latente em user.pro === "true" — mascarado por computeIsPro/plan, não corrigido nesse lote.
0.1.X interno (50+ lotes) e entramos em v1.0.0 pra lançar pra um grupo piloto. Splash redesenhado com glow ambiente respirando + 2 anéis concêntricos expandindo + M com breathe sutil (filter drop-shadow, scale 3%) + wordmark "monify.app" + tagline "Copiloto financeiro" + barra de progresso minimalista. Versão removida do header pós-login (só em auth screens + modal LGPD + admin). Wipe agressivo agora apaga TUDO de user-data + agregados (telemetria, cache, fluxo:set, feedback, contadores globais) preservando admins/audit/MFA. Política de versões: DEV continua 0.1.x, PROD em 1.0.x.
Major bump manual desse deploy (npm version 1.0.0 antes do vercel deploy); próximos retornam ao patch automático.
public/ai-policy.html (6 seções) e public/security.html (4 seções) reforçam: IA é apoio informacional (caráter auxiliar, não substitui aconselhamento profissional, não é recomendação individualizada de investimento), e Monify adota medidas técnicas/administrativas pra segurança. Disclaimer discreto fixo abaixo do input do Pulse com link pra ai-policy.html. Footer público do app + dos 4 docs jurídicos com cross-links (Privacidade · Termos · IA · Segurança · LGPD).
Alinhamento com §9 dos Termos v3.0 + Política IA = limitação de responsabilidade clara e visível em todo ponto de contato.
api/partner/{invite,leave,remove} (atacante disparava convites Resend em nome da vítima + desvinculava partnership), api/checkout.ts e api/checkout-pix.ts (atacante criava sessão Stripe/MP em nome da vítima — DoS gateway). api/admin/logout.ts agora chama authenticateAdmin. 4 callers do frontend atualizados.
P3 (cleanup de 66 console.error verbose) adiado pra próximo lote — ROI baixo. CSP style-src 'unsafe-inline' aguarda refactor CSS modules.
api/user/txs.ts aceitava GET/POST sem sessionId — qualquer pessoa lia/sobrescrevia txs alheias. (2) api/user/profile.ts idem — enumerava plan/partnership de qualquer email. (3) api/track.ts aceitava deltas sem auth — atacante inflava contadores globais. (4) api/admin/login.ts usava === pra senha legacy — timing attack viável no bootstrap. Tudo bloqueado: 3 endpoints exigem sessionId obrigatório (mesmo padrão de goals.ts/export.ts), track.ts ganhou caps defensivos, helper timingSafeEqualStr exportado de _lib/auth.ts. 5 testes novos validam comparação byte-a-byte com tempo constante. 881 testes verdes.
npm audit: 0 vulnerabilidades. CSP, HSTS, headers, hash de senha, sessão única, audit log e webhook signature já estavam sólidos.
/api/admin/pulse-llm-monitor agrega telemetria detalhada do Claude Haiku armazenada em pulse:llm:events:YYYY-MM-DD. Cada chamada LLM grava tokens reais (input/output), custo USD calculado, latência fim-a-fim, flag cache hit, e classificação de erro (timeout/rate_limit/anthropic_http/empty/auth/config) — permite custo REAL não estimativa. UI no admin: 6 KPI cards com cor por status (verde/âmbar/vermelho conforme threshold), alertas inteligentes — "top 5 queries dominam X% do custo, vira handler local pra economizar US$/mês" + "projeção mensal estourando 80% do orçamento" — gráfico de barras custo/dia, tabela top 20 perguntas ordenadas por custo com paid/cache/fail breakdown + tokens médios + latência + ação inline "✓ tratar" (reusa hash pulse:fb_handled compartilhado com Pulse Analytics, marca pra virar handler local) e botão 📋 copiar pra começar regex local, seção dedicada "❌ LLM também falhou — urgência alta" com último erro classificado, chips de breakdown de erros. Frontend manda sid anônimo no body do endpoint LLM pra correlação sem expor email. Janela padrão = hoje (decisão é de agora). 769 testes verdes.
DONE · admin tem visibilidade total do custo Anthropic · ações pra cortar gasto em 1 clique · zero PII vaza pro painel.
/api/pulse/llm-fallback que chama Claude Haiku 4.5 via fetch direto (edge runtime, sem SDK). Envia snapshot agregado (saldo, totais, % poupança, categoria topo, ctx declarado) + últimos 6 turnos da conversa — NUNCA nomes/descrições/datas individuais de transações. Sessão validada via sessionId. Rate limit 10/min + 100/dia por email (proteção anti-abuso e teto de custo). Cache Redis 15min por hash(pergunta+snapshot). Fail-open: erro do LLM mantém fallback original, UX nunca trava. Telemetria nova: outcomes llm_succeeded/llm_failed permitem medir ROI do fallback inteligente. Texto institucional "Sobre o Monify" + "Como funciona a Pulse" + "Privacidade" atualizados pra refletir o novo modelo híbrido com transparência sobre o que é enviado e o que não é. Bugs UI tratados no mesmo lote: toast de lançamento legível em ambos os temas, modal Cortesia bloqueado quando trialGrantedDays=0 + expiração com hora:min:seg, versão importada de __APP_VERSION__ em vez de hardcoded.
DONE · 769 testes verdes · zero regressão · ANTHROPIC_API_KEY pendente nas env vars Vercel.