Redux e Arquitetura de Dados

Este documento explica a camada de estado e dados: onde os slices vivem, o que persiste e o que deliberadamente não persiste, como a resposta de um check se espalha pela store e como o cliente HTTP é estruturado para web e mobile dividirem tudo acima do transporte.

Uma definição de estado, dois apps

Os slices vivem no pacote compartilhado packages/state, e cada app monta a própria store com eles sob chaves de reducer idênticas, então actions e selectors compartilhados se alinham em todo lugar.

flowchart LR
  subgraph pkg["packages/state"]
    SLICES["22 slices + lógica compartilhada<br/>applyRefreshUi · ordenação · ids de widgets<br/>marcos de streak · helpers de data"]
  end

  subgraph web["apps/web"]
    WSTORE["store + redux-persist<br/>blacklist: perfil · snapshot · celebration<br/>mood · notebook · studyCycles · quickNotes"]
  end

  subgraph mobile["apps/mobile"]
    MSTORE["store, sem persistência<br/>+ slices auth e tutorial<br/>logout zera todos os slices"]
  end

  SLICES --> WSTORE
  SLICES --> MSTORE

Os 22 slices

Slice Guarda
perfil O usuário: nome, e-mail, foto, frase, XP/level, streaks e dormência, widgets, tema, idioma, timezone e sua origem, estratégia de decaimento de XP, flag do tutorial, checkRevision
categories / habits / tasks / goals / routines As listas de entidades, cada uma com sua action de entrada e actions de refresh dirigido onde os checks as atualizam no lugar. A lista de metas vem plana do servidor; a árvore (buildGoalTree, childrenSummary, eligibleParents, rootsForFilter) e o baralho da vitrine (orderGoalsForViewer) derivam dela em funções puras partilhadas, para web e mobile mostrarem os mesmos números sem um segundo pedido
editCategory / editHabit / editTask / editGoal / editRoutine Rascunhos do modo de edição, populados campo a campo quando o botão de editar de um card é clicado
todayRoutine A rotina agendada de hoje, com refreshItemGroup aplicando o resultado de um check sem rebuscar
snapshot Snapshots históricos de rotina por data, mais a data selecionada
celebration Uma fila FIFO de celebrações pendentes (level-ups, marcos de streak)
viewFilters A ordenação escolhida por página, hidratada por uma whitelist de chaves. Guarda também goalsViewer, a ordenação do ecrã de uma meta de cada vez, separada da ordenação da página de metas, e goalsViewerLayout, se esse ecrã percorre só as metas principais (agrupada) ou cada meta como slide próprio (lista)
focus O Modo Foco: em que estado a tela está, o item selecionado e se a pessoa o escolheu à mão, o timer pomodoro como hora de fim absoluta mais as quatro durações editáveis e os dois interruptores de aviso de fim de ciclo (som, notificação), e um cache por item das micro-tarefas do servidor
mood O diário, indexado por dia: o nível e o texto de cada dia carregado. Um mapa e não uma lista, porque a semana do widget e o mês da página se sobrepõem, e duas listas teriam mostrado um dia marcado no dashboard e vazio na página
notebook O caderno de estudos, indexado por id: páginas, quadros por página, árvores de tópico e a home. A mesma página aparece ao mesmo tempo como nó de quadro, linha da árvore, tela da página e card da home, e o applyStatuses grava uma mudança de status nos quatro a partir da lista changed que o servidor devolve, então um nó fica verde em todo lugar sem refetch
studyCycles Os ciclos de estudo: a fatia de hoje (o card de Hoje na home do caderno e o widget do dashboard), os ciclos ativos e cada ciclo aberto por inteiro, por id. O refreshStudyCycles lê tudo de novo depois das escritas do próprio app e quando uma ferramenta do assistente avisa o domínio studyCycles, e um ciclo que responde STUDY_CYCLE_NOT_FOUND sai do slice
quickNotes O quadro de Ideias: active, as notas do quadro de trás para a frente, archived, que fica null até alguém abrir as arquivadas, já que a maioria das visitas nunca abre, e loaded, para a página distinguir um quadro vazio de um que ainda não chegou. Uma action só, quickNoteUpserted, põe a nota na lista certa pelo archivedAt, então arquivar, restaurar e converter caem todos no lugar certo. quickNotesMoved aplica a soltura antes de o servidor responder, para a nota ficar onde foi solta enquanto o salvamento está a caminho. refreshQuickNotes lê o quadro de novo, e as arquivadas só se já estiverem abertas, na montagem e quando uma ferramenta do assistente avisa o domínio quickNotes
register Um booleano para a tela de sucesso pós-cadastro
errorHandler Uma string global de erro

O barrel do pacote é curado: nomes de action que colidem entre slices não são re-exportados e precisam de import por caminho profundo, e o nameEnter do slice de perfil vira perfilNameEnter no barrel. Essa convenção é o que impede vinte e dois slices de pisarem uns nos outros em dois apps.

Ao lado dos slices ficam as funções puras que os dois apps usam: a aplicação de gamificação, a lista de marcos de streak, o registro de ids de widgets, a lógica de ordenação, helpers de data, a política de auto-refresh, os helpers de criação de entidades do onboarding, as regras do Resumo do Dia, as grades de dias do calendário, o applyStatuses e o pathLevels do caderno (um quadro lido em níveis na ordem de estudo, que é como o celular mostra um roadmap), o studyCycleView, que dá a um bloco de estudo o título e o número ao lado, para os dois apps escreverem igual no card de Hoje, na tela do ciclo, no widget e no resumo, e as regras do quadro de Ideias: dropPosition (o lugar e o z que uma soltura salva, arredondados, dentro dos limites do quadro e trazidos para a frente), quickNoteTaskPrefill (a primeira linha da nota como nome da tarefa e o resto como descrição, cada um cortado em 255) e noteColors, o papel, a borda e a tinta de cada cor de nota para a base clara e a escura. As cores são valores concretos de propósito, como a paleta dos diagramas, porque o celular não tem variáveis CSS e os dois apps precisam pintar a mesma nota.

O resumo não acrescenta slice nenhum, de propósito. O diálogo dele é o único consumidor dos seus dados, portanto a resposta vive em estado de componente e morre com o diálogo. O que é partilhado é o julgamento à volta: se o diálogo deve sequer abrir, o que acontece à lista quando o usuário resolve uma linha, por quanto tempo continuar a pedir o texto que chega atrasado (pollNarrative), que lista de metas ler (briefingGoals, que recua para o campo antigo quando o servidor ainda não tem goalsAhead), como se descreve o ritmo de uma meta (goalPaceMessage), e se o estudo de hoje e o de ontem merecem uma seção (briefingStudy, briefingStudyRecap, que deixa um zero de fora porque "0 min estudados ontem" num dia de descanso soa como bronca). O humor mostrado dentro do diálogo é a exceção que usa um slice: passa pelo slice mood partilhado, para um rosto escolhido no diálogo já estar certo no widget do painel atrás dele. São exactamente as decisões que ganham uma resposta subtilmente diferente em cada plataforma no espaço de um mês, e a diferença fica invisível até alguém dizer que o telemóvel chateia e a web não.

O calendário também não acrescenta slice. O que ele mostra é o intervalo de dias que está na tela, então a página e o widget do dashboard buscam esse intervalo cada um pelo @beyou/api e guardam a visão geral em estado de componente, aplicando as próprias escritas nela em vez de pedir o intervalo de novo. A parte compartilhada fica no @beyou/state como funções puras: a grade do mês com as semanas de preenchimento, o intervalo da semana, o intervalo que cada visualização precisa (rangeForView), a separação entre tarefas com e sem horário, os rótulos de antecedência, e upsertTaskInOverview / removeTaskFromOverview, que colocam uma tarefa criada, editada ou apagada numa visão geral já buscada. Concluir uma tarefa é o único ponto em que ele mexe na store, porque o XP mudou: o refreshUi da resposta passa pelo applyRefreshUi igual ao de um check de rotina, então a barra de XP e qualquer subida de level ficam em dia em todas as telas.

Persistência, e o que se recusa a persistir

A store do web persiste em localStorage com uma blacklist deliberada:

Slice excluído Por quê
perfil Nome, e-mail e foto são PII e não pertencem ao localStorage; o perfil re-hidrata da API a cada boot
snapshot Histórico de rotina é PII por acúmulo
celebration Transitório por definição: um level-up na fila não pode tocar de novo depois de um reload
notebook Anotações de estudo, página por página, tão pessoais quanto o diário e excluídas pelo mesmo motivo. Rebuscadas na montagem como snapshot, então nada se perde por não guardá-las. O PERSIST_VERSION não mudou: uma chave que nunca foi persistida não precisa de migração
studyCycles Dado do servidor lido sob demanda, como notebook, então nada disso precisa ir para o disco. Toda tela que mostra esses dados lê na montagem. A forma persistida não mudou, então o PERSIST_VERSION também não
quickNotes O que passou pela cabeça de alguém, texto livre escrito para si. Lido na montagem como o notebook, então nada se perde por não guardá-lo, e o PERSIST_VERSION não mudou por causa dele
mood Texto de diário, o dado mais pessoal que o produto guarda. A falha é silenciosa: tudo funciona, e o diário de alguém fica simplesmente no localStorage depois de fechar a aba. Por isso um teste lê a blacklist do código-fonte em vez de confiar que a entrada sobrevive a uma edição

Todo o resto (listas de entidades, rascunhos de edição, preferências de ordenação) persiste, então um reload pinta na hora com dados locais enquanto dados frescos carregam por trás. O app mobile não persiste nada: tokens vivem no armazenamento seguro, dados rebuscam ao montar e o logout dele zera todos os slices pelo root reducer. No web, o logout purga o persistor e navega de forma dura, o que descarta a store em memória por inteiro.

Duas páginas do web, hábitos e tarefas, guardam a lista que renderizam em estado local em vez de ler o slice. Isso é mais antigo que a store compartilhada e não é um problema por si. O problema era que as buscas delas, e a rebusca que os formulários fazem depois de salvar, escreviam só nessa lista local: o slice persistido ficava com o nome antigo, e toda linha de rotina no dashboard e na tela de foco resolve nome, ícone e frase a partir desses slices pelo id. Então um hábito renomeado na própria página continuava com o nome antigo no modo foco até a busca daquela tela acontecer de chegar. As duas páginas agora buscam por um hook por entidade (useSyncedHabits, useSyncedTasks) que entrega a mesma lista à store, e um terceiro chamador não consegue pular o dispatch porque o módulo de api deixou de ser a porta. O loader do dashboard aprendeu a regra correspondente do lado da leitura: uma busca que falhou não é aplicada, onde antes despachava undefined no slice e apagava todos os nomes da tela depois de um pedido barrado por limite. A tela de foco também entra na política de auto-refresh que as outras telas de dados seguem (voltar à aba ou ao app, o dia virar, o tempo passar), porque é a tela que fica aberta por mais tempo e um rename feito noutro aparelho nunca chegava a ela.

O slice focus persiste de propósito, e foi o slice que tornou a persistência VERSIONADA. Um pomodoro é uma promessa sobre os próximos 25 minutos, então a hora de fim absoluta dele tem de sobreviver a um reload; mas o reconciliador padrão do redux-persist substitui um slice guardado por inteiro em vez de fundi-lo com o estado inicial do reducer, então todo campo novo num slice persistido chega como undefined em todo browser que guardou a forma anterior — e o primeiro render lê antes que qualquer reducer possa consertar. A store do web por isso carrega uma version e uma tabela de migrações (persistMigrations.ts): cada bump descarta o slice focus guardado e deixa o reducer fornecer a forma atual. Os bumps seguintes preenchem campos novos com valores padrão, quando vale a pena manter um pomodoro rodando, e o v7 remove um: o título da página do caderno que o timer guardava ao lado do id. A regra que saiu disso está escrita ao lado da tabela: um campo adicionado a um slice persistido precisa de bump de versão E de leitura tolerante, e um teste que entrega ao reducer a forma ANTIGA de propósito é o que apanha o que você esqueceu.

A resposta do check: applyRefreshUi

O coração do fluxo de gamificação é uma função pura compartilhada, deliberadamente não um thunk, para um dispatch simples funcionar igual no web e no React Native:

sequenceDiagram
  participant UI as Seção da rotina
  participant BE as Backend
  participant AP as applyRefreshUi
  participant ST as Store

  UI->>BE: POST /routine/check
  BE-->>UI: Payload RefreshUI
  UI->>AP: level + streak anteriores, payload
  AP->>ST: fila de celebração (subiu de level? cruzou marco?)
  AP->>ST: perfil: xp, level, streaks, dormência desligada
  AP->>ST: refresh de categorias / hábito / grupo de item
  AP->>ST: checkRecorded → incrementa checkRevision

A ordem importa: quem chama lê o level e o streak anteriores da store antes de aplicar, para a função decidir se uma celebração foi conquistada. Ela desliga à força a flag de dormência de streak (uma execução que acabou de marcar algo não pode estar dormente) e, ao final, incrementa um contador de revisão que as faixas de dias e os heatmaps mantêm nas dependências de busca, então o quadrado de hoje repinta sem ninguém rebuscar listas. Os campos de streak do payload são opcionais de propósito; leitores caem para os valores existentes em vez de zerar, porque donos de categoria reportam zeros e respostas antigas não têm os campos.

A camada HTTP

Dois níveis, para tudo acima do transporte ser compartilhado:

A política da instância axios, em ordem:

Regra Comportamento
Base VITE_API_URL, credenciais ligadas
Dedup de refresh Uma promise de refresh no módulo: N 401s simultâneos dividem uma única chamada, e o token é lido do header de resposta X-Access-Token
Lista de exceção /auth/refresh, /auth/login, /auth/google passam direto: eles dão 401 legitimamente, e as telas de auth nomeiam o limite por conta própria em vez de levar um segundo toast em cima
429 Um toast traduzido de limite de requisições, para tudo fora da lista de exceção e do stream do agente, que fazem isso na própria camada
401, primeira vez Marca a requisição, faz refresh, escreve o token nos defaults do axios e na requisição repetida, repete
Refresh falhou Reporta a falha, navega duro para o login e rejeita com o 401 original em vez do erro do refresh, para uma sessão expirada não ser arquivada como falha desconhecida

No boot, o useSilentRefresh roda antes de o router montar: troca o cookie httpOnly por um access token, depois rebusca o perfil e hidrata o slice perfil, o que é necessário justamente porque o perfil está na blacklist da persistência. O stream SSE do agente anda sobre fetch cru (o axios bufferiza streams), mas é configurado com a mesma URL base, o header de auth vivo e a mesma função compartilhada de refresh, então um 401 no stream não corre contra um segundo refresh. Andar fora do axios também significa perder o interceptor acima, então ele lê a chave de erro do próprio corpo da falha — é assim que um chat barrado por limite diz que foi barrado, em vez de reportar um status que ninguém traduziu.