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.
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
| 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.
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.
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.
Dois níveis, para tudo acima do transporte ser compartilhado:
packages/api): um contrato de cliente http estreito de propósito (get, post, put, delete; headers, params, timeout e nada mais, para nenhum adaptador engolir uma opção em silêncio), um singleton de módulo, um ApiError tipado e o helper de stream SSE do agente.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.