This document explains the state and data layer: where the slices live, what persists and what deliberately does not, how a check response fans out across the store, and how the HTTP client is structured so web and mobile share everything above the transport.
The slices live in the shared packages/state, and each app assembles its own store from them under identical reducer keys, so shared actions and selectors line up everywhere.
flowchart LR
subgraph pkg["packages/state"]
SLICES["22 slices + shared logic<br/>applyRefreshUi · sorters · widgets ids<br/>streak milestones · date helpers"]
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, no persistence<br/>+ auth and tutorial slices<br/>logout resets every slice"]
end
SLICES --> WSTORE
SLICES --> MSTORE
| Slice | Holds |
|---|---|
| perfil | The user: name, e-mail, photo, phrase, XP/level, streaks and dormancy, widgets, theme, language, timezone and its source, XP decay strategy, tutorial flag, checkRevision |
| categories / habits / tasks / goals / routines | The entity lists, each with its enter action and targeted refresh actions where checks update them in place. The goals list is flat on the wire; the tree (buildGoalTree, childrenSummary, eligibleParents, rootsForFilter) and the viewer's deck (orderGoalsForViewer) are derived from it in shared pure functions, so web and mobile show the same numbers without a second request |
| editCategory / editHabit / editTask / editGoal / editRoutine | Edit-mode drafts, populated field by field when a card's edit button is clicked |
| todayRoutine | Today's scheduled routine, with refreshItemGroup applying a check result without a refetch |
| snapshot | Historical routine snapshots by date, plus the selected date |
| celebration | A FIFO queue of pending celebrations (level-ups, streak milestones) |
| viewFilters | The per-page sort choice, hydrated through a key whitelist. Also holds goalsViewer, the one-goal-at-a-time screen's ordering, kept apart from the goals page's sort, and goalsViewerLayout, whether that screen walks the main goals only (grouped) or every goal as its own slide (list) |
| focus | The Focus Mode: which state the screen is in, the selected item and whether the person chose it by hand, the pomodoro timer as an absolute end time plus its four editable lengths and the two cycle-end alert switches (sound, notification), and a per-item cache of the server's micro-tasks |
| mood | The diary, indexed by day: the level and the journal text for each day loaded. A map and not a list, because the widget's week and the page's month overlap, and two lists would have shown a day marked on the dashboard and blank on the page |
| notebook | The study notebook, indexed by id: pages, boards by page, topic trees and the home. The same page shows up as a board node, a tree row, the page screen and a home card at once, and applyStatuses writes a status change into all four from the changed list the server returns, so a node turns green everywhere without a refetch |
| studyCycles | The study cycles: today's slice (the notebook home's Today card and the dashboard widget), the active cycles, and every cycle opened in full, by id. refreshStudyCycles reads them again after the app's own writes and when an assistant tool reports the studyCycles domain, and a cycle that answers STUDY_CYCLE_NOT_FOUND leaves the slice |
| quickNotes | The Ideas board: active, the notes on the board back to front, archived, which stays null until someone opens the archive since most visits never do, and loaded, so a page can tell an empty board from one not fetched yet. One action, quickNoteUpserted, files a note by its archivedAt, so an archive, a restore and a conversion all land in the right list. quickNotesMoved applies a drop before the server answers, so a note stays where it was dropped while the save is in flight. refreshQuickNotes reads the board again, and the archive only when it is already open, on mount and when an assistant tool reports the quickNotes domain |
| register | One boolean for the post-registration success screen |
| errorHandler | One global error string |
The package's barrel is curated: action names that collide across slices are not re-exported and must be imported by deep path, and the profile slice's nameEnter is aliased to perfilNameEnter. That convention is what keeps twenty-two slices from stepping on each other in two apps.
Beside the slices sit the shared plain functions both apps use: the gamification apply function, the streak milestone list, the widget id registry, sorting logic, date helpers, the auto-refresh policy, the onboarding entity-creation helpers, the Daily Briefing's rules, the calendar's day grids, the notebook's applyStatuses and pathLevels (a board read as levels in study order, which is how the phone shows a roadmap), studyCycleView, which gives a study block its title and the figure beside it so the two apps word it the same way on the Today card, the cycle screen, the widget and the briefing, and the Ideas board's rules: dropPosition (the spot and z a drop saves, rounded, kept inside the board and brought to the front), quickNoteTaskPrefill (the first line of a note as the task's name and the rest as its description, each cut to 255) and noteColors, the paper, edge and ink of each note colour for the light and the dark base. The colours are concrete values on purpose, like the diagram palette, because the phone has no CSS variables and both apps must paint the same note.
The briefing deliberately adds no slice. Its dialog is the only consumer of its data, so the response lives in component state and dies with the dialog. What is shared is the judgement around it: whether the dialog should open at all, what happens to the list when the user resolves a row, how long to keep asking for prose that arrives late (pollNarrative), which goal list to read (briefingGoals, falling back to the older field for a server that predates goalsAhead), how a goal's pace is worded (goalPaceMessage), and whether today's study and yesterday's are worth a section (briefingStudy, briefingStudyRecap, which leaves out a zero because "0 min studied yesterday" on a rest day reads as a reproach). The mood shown inside the dialog is the exception that does use a slice: it goes through the shared mood slice, so a face picked in the dialog is already right on the dashboard widget behind it. Those are exactly the decisions that get a subtly different answer on each platform within a month of shipping, and the difference stays invisible until somebody says the phone nags them and the web does not.
The calendar adds no slice either. What it shows is the range of days on screen, so the page and the dashboard widget each fetch it through @beyou/api and keep the overview in component state, applying their own writes to it instead of asking for the range again. The shared part lives in @beyou/state as plain functions: the month grid with its padding weeks, the week range, the range a view needs (rangeForView), the timed and untimed split, the advance-notice labels, and upsertTaskInOverview / removeTaskFromOverview, which put a created, edited or deleted task into an overview already fetched. Completing a task is the one place it touches the store, because XP moved: the refreshUi in the answer goes through applyRefreshUi exactly as a routine check's does, so the XP bar and any level-up stay in step on every screen.
The web store persists to localStorage with a deliberate blacklist:
| Excluded slice | Why |
|---|---|
| perfil | Name, e-mail, and photo are PII and do not belong in localStorage; the profile re-hydrates from the API on every boot |
| snapshot | Routine history is PII by accumulation |
| celebration | Transient by definition: a queued level-up must not replay after a reload |
| notebook | Study notes, page by page, as personal as the journal and excluded for the same reason. Refetched on mount like snapshot, so nothing is lost by not keeping it. PERSIST_VERSION did not move: a key that was never persisted needs no migration |
| studyCycles | Server data read on demand, like notebook, so none of it needs to reach the disk. Every screen that shows it reads it on mount. The persisted shape did not change, so PERSIST_VERSION did not move either |
| quickNotes | Whatever crossed somebody's mind, free text written for themselves. Read on mount like notebook, so nothing is lost by not keeping it, and PERSIST_VERSION did not move for it |
| mood | Journal text, the most personal thing the product stores. The failure is silent: everything works, and somebody's diary simply sits in localStorage after they close the tab. A test therefore reads the blacklist from source rather than trusting the entry to survive an edit |
Everything else (entity lists, edit drafts, sort preferences) persists, so a reload paints instantly from local data while fresh data loads behind it.
Two web pages, habits and tasks, keep the list they render in local state instead of reading the slice. That is older than the shared store and not a problem in itself. What was a problem is that their fetches, and the refetch their forms run after a save, wrote only to that local list: the persisted slice kept the old name, and every routine row on the dashboard and on the focus screen resolves name, icon and phrase from these slices by id. So a habit renamed on its own page kept its old name in focus mode until that screen's own fetch happened to land. Both pages now fetch through one hook per entity (useSyncedHabits, useSyncedTasks) that hands the same list to the store, and a third caller cannot skip the dispatch because the api module is no longer the door. The dashboard's loader learned the matching rule on the read side: a fetch that failed is not applied, where it used to dispatch undefined into the slice and empty every name on screen after one throttled request. The focus screen also joins the auto-refresh policy the other data screens follow (coming back to the tab or the app, the day turning over, time passing), because it is the screen left open longest and a rename made on another device never reached it.
The focus slice persists on purpose, and it is the one slice that made persistence VERSIONED. A pomodoro is a promise about the next 25 minutes, so its absolute end time has to survive a reload; but redux-persist's default reconciler replaces a stored slice wholesale rather than merging it into the reducer's initial state, so every field added to a persisted slice is undefined in every browser holding the previous shape — and the first render reads it before any reducer can repair it. The web store therefore carries a version and a migration table (persistMigrations.ts): each bump drops the stored focus slice and lets the reducer supply the current shape. Later bumps fill new fields with defaults instead, where a running pomodoro is worth keeping, and v7 removes one: the notebook page title the timer used to store beside the page id. The rule that came out of it is written next to the table: a field added to a persisted slice needs a version bump AND a tolerant read, and a test that hands the reducer the OLD shape on purpose is what catches the one you forget. The mobile app persists nothing: tokens live in the secure store, data refetches on mount, and its logout action resets every slice through the root reducer. On web, logout instead purges the persistor and hard-navigates, which discards the in-memory store wholesale.
The heart of the gamification flow is a shared plain function, deliberately not a thunk, so a bare dispatch works identically on web and React Native:
sequenceDiagram
participant UI as Routine section
participant BE as Backend
participant AP as applyRefreshUi
participant ST as Store
UI->>BE: POST /routine/check
BE-->>UI: RefreshUI payload
UI->>AP: previous level + streak, payload
AP->>ST: celebration queue (level up? milestone crossed?)
AP->>ST: perfil: xp, level, streaks, dormancy off
AP->>ST: categories / habit / item group refresh
AP->>ST: checkRecorded → checkRevision bump
The order matters: the caller reads the previous level and streak from the store before applying, so the function can decide whether a celebration was earned. It force-clears the streak-dormancy flag (a run that just checked something cannot be dormant), and at the end it bumps a revision counter that day strips and heatmaps keep in their fetch dependencies, so today's square repaints without anyone refetching lists. Streak fields inside the payload are optional on purpose; readers fall back to existing values rather than zeroing, because category owners report zeros and older responses lack the fields.
Two tiers, so everything above the transport is shared:
packages/api): a deliberately narrow http client contract (get, post, put, delete; headers, params, timeout and nothing else, so no adapter can silently drop an option), a module-level singleton setter, a typed ApiError, and the agent SSE stream helper.The axios instance policy, in order:
| Rule | Behavior |
|---|---|
| Base | VITE_API_URL, credentials on |
| Refresh dedup | One module-level refresh promise: N concurrent 401s share a single refresh call, and the token is read from the X-Access-Token response header |
| Skip list | /auth/refresh, /auth/login, /auth/google pass through: they legitimately 401, and the auth screens name a throttle themselves rather than take a second toast on top |
| 429 | A translated rate-limit toast, for everything except the skip list and the agent stream, which do it at their own layer |
| 401, first time | Mark the request, refresh, write the token to the axios defaults and the retried request, replay |
| Refresh failed | Report the failure, hard-navigate to login, and reject with the original 401 rather than the refresh error, so an expired session is not misfiled as an unknown fault |
On boot, useSilentRefresh runs before the router mounts: it trades the httpOnly cookie for an access token, then re-fetches the profile and hydrates the perfil slice, which is necessary precisely because perfil is blacklisted from persistence. The agent's SSE stream rides raw fetch (axios buffers streams) but is configured with the same base URL, live auth header, and the same shared refresh function, so a stream 401 cannot race a second refresh. Riding outside axios also means it misses the interceptor above, so it reads the error key off the failure body itself — which is how a throttled chat says it was throttled instead of reporting a status nobody translated.