UI Components and Page Structure

This document maps the web frontend: how pages and components are organized, the patterns every feature follows, how the dashboard widgets and the tutorial work, and how the bundle is split. The web app lives in apps/web of the frontend monorepo and consumes the shared packages (state, theme, i18n, validation, icons, api) as source through Vite aliases.

Routing and the shell

flowchart TD
  APP["⚛️ App.tsx<br/>ThemeProvider + Router + ErrorBoundary"]
  APP --> PUB["Public routes<br/>/ · /register · /forgot-password<br/>/reset-password · /auth/verify"]
  APP --> PROT["ProtectedRoute (layout route)"]
  PROT --> SHELL["Shell, mounted once:<br/>Sidebar · BottomNav · AgentWidget<br/>RunningTimerHub · PomodoroOwner · CalendarReminderHub<br/>QuickCaptureHub"]
  SHELL --> PAGES["/dashboard · /categories · /habits · /goals · /goals/view<br/>/tasks · /routines · /focus · /mood · /calendar · /ideas · /configuration · /feedback<br/>/notebook · /notebook/review · /notebook/:pageId<br/>/notebook/:pageId/board · /notebook/:pageId/study<br/>/notebook/cycles · /notebook/cycles/new<br/>/notebook/cycles/:cycleId/edit · /notebook/cycles/:cycleId/history"]
  PROT --> ADMIN["AdminRoute → /admin/feedback"]

Every route component is lazy, including AdminRoute itself, so ordinary users never download the admin gate or its API calls. Two Suspense boundaries catch those chunks, and where they sit is load-bearing. The one in App.tsx, with a full-screen spinner, serves the public routes. The one inside ProtectedRoute, with a spinner sized to the page, wraps the page outlet alone, so a page loading for the first time can blank the page area and nothing else. For a while the outer boundary was the only one, and the first visit to any page hid the whole shell while the chunk came down. React tears down layout effects on a Suspense hide, and framer-motion keeps its animation state in one, so an assistant panel closed in that same tick (which is what the agent's internal links do) lost its exit animation and came back at full opacity with the widget already believing it closed. The bubble drew over the chat and Escape did nothing. On boot, useSilentRefresh holds the app in a "checking" state until the refresh cookie has been traded for a token, which is what prevents a flash of 401s or a bounce to login on reload.

The shell mounts once inside the protected layout route: the collapsible desktop Sidebar (order: Dashboard, Categories, Habits, Tasks, Routines, Goals, Calendar, Notebook, Ideas, Diary, with Feedback and Config in the footer), the phone BottomNav (Dashboard, Routines, the Assistant in the center slot as the only entry to the agent, Habits, and a More sheet that holds the rest, Calendar, Notebook and Ideas included), and the floating AgentWidget. Beside them sit the focus timer's floating hub, the owner that finishes a running pomodoro wherever the person is, the calendar's reminder hub, which renders nothing, and the Alt+N quick capture, which renders nothing until the shortcut is pressed. Pages render no header of their own; a shared PageHeader component is the in-page title block. Nav items carry data-tutorial-id anchors for the tutorial spotlight.

Auth pages deliberately avoid the icon registry, keeping the icon and emoji chunks out of the unauthenticated first load.

Component organization

src/
  pages/        one folder per route, tests colocated
  components/   per-domain folders (agent, categories, dashboard, goals,
                habits, routines, tasks, tutorial, widgets, ...)
  ui/           design-system primitives with no domain knowledge
  hooks/  context/  lib/  services/  redux/  utils/

The ui/ layer holds the primitives: Card, Chip, Ring, XpBar, XpSparkline, StatTile, SegmentedControl, IconButton, IconTile, CheckStrip, GhostAdd, BeyouIcon, PageHeader. Domain components compose these. The shared Modal is a portal with a real focus trap: Tab cycling, focus restore on close, Escape, and aria-labelledby, and every dialog in the app renders through it.

The entity quartet

Every domain entity (category, habit, task, goal, routine) follows the same four-part pattern:

Part Role
createX / editX Thin modal wrappers choosing the form's mode
XForm The shared react-hook-form component, one per entity
xBox The expandable card showing one item, with edit and delete actions
renderXs The responsive grid that maps the list

Forms resolve through zod schemas that live in the shared validation package, written as factories taking the translation function, so every validation message is bilingual by construction. Every label goes through one FormLabel (FieldLabel on mobile) that draws the field's standing: an accent asterisk where the server refuses the form without it, a muted "optional" where it may stay empty, one marker per field and never both. The flags follow the request DTOs, not taste; a task's importance and difficulty are the case that made the rule, since both clients required them for months while the server never did. Cross-field routine rules (overlapping section times, overnight ranges) live beside them as plain functions the form and the routine builder both call. A list routine is validated by its own schema rather than a branch inside the daily one: the two forms hold genuinely different state, an array of timed sections against a flat array of picked habits and tasks, and one schema accepting both would check neither properly.

Dashboard and widgets

The dashboard composes a profile card, shortcut links, today's routine with its check-in flow, a goals rail, and the configurable widget area.

The new-day dialog

The first dashboard open of a day can raise the Daily Briefing: a shared Modal on web, a BottomSheet on mobile, two columns from lg and stacked below it, with the actionable half leading on both axes.

The left half is the only part you can act on: yesterday's items that are neither checked nor skipped, each with the check and skip the snapshot endpoints already provide. A skip stays out of that list because it is an answer the user already gave; it shows in the summary line instead. Two different empty states share the space and must not be collapsed into one, because a day the user finished and a day nothing was scheduled on are not the same result and only the first deserves the accent. Older recoverable days sit behind a disclosure, since the panel is about yesterday and seven days of arrears every morning is how you teach somebody to close a dialog unread.

The right half is two pages behind a pager: what today holds, then how yesterday went. Nothing moves it on its own, and a timer here would be worse than in most panels. The prose arrives from an LLM whenever it arrives, so a reader who has just started the page is the one most likely to be moved off it. The pager is therefore the only thing that changes the page, and it is two labelled tabs rather than dots: the user has to know they can go somewhere, not just where they currently are, and two words say that where a pair of circles does not.

The today page ends on where the user is heading. Up to three open goals, closest to their date on either side, each with a progress bar, a tick where a straight line from start to end says the goal should be today, and a pace line worded from the server's verdict: behind with the daily amount that still gets there, on pace, a met target that only needs marking done to pay its XP, or a date that came first. The wording is picked in @beyou/state (goalPaceMessage), so the two apps cannot phrase one goal two ways. Under it sits "How are you today?", the same five faces as the dashboard widget, writing through the same PATCH that cannot touch a journal; the note is offered only once today's entry has loaded, prefilled with whatever is already written, so a quick line here cannot replace a longer one written on the phone. The recap page shows yesterday's face and journal, clamped behind "Read all", with a line saying the written summary never sees it, which is true because the client reads the entry from the mood API and the briefing payload never carries it. Checking items in the dialog does not reload the dashboard each time: the reload is six list reads, a long yesterday used to drain the read budget, and it now runs once, on close, if anything changed.

The older-days list is grouped by day, with the date as a heading and the expiring day flagged. A row there carries its section name and what checking it is now worth, and neither answers the question the panel exists to ask. "Did I do this?" is a question about a DAY: people remember last Monday, not a habit floating free of one. Grouped rather than stamping the date on all of them, because a week of a full routine is dozens of items and the same six dates repeated down the list is noise to read past rather than structure to scan. Yesterday's own list needs none of it, since every row in it is yesterday and the heading already says so.

Closing it is a click, and a click near the edge of a modal is easy to make before you have read it. The configuration screen therefore carries a "Show today's briefing" control, which sends the user to the dashboard with the dialog forced open. That request beats both "already seen" and "closed in this session", because those exist to stop the dialog appearing unasked and this is the opposite. It does not beat "there is nothing to say": the answer to asking for an empty briefing is the dashboard, not an empty modal. Reopening deliberately leaves the server's seenAt alone. The day WAS acknowledged, and clearing the column would reopen the dialog on the person's other devices, which nobody asked for.

Whether the dialog opens at all is decided in @beyou/state, not in either app. It stays shut when the server says the day was already acknowledged, when there is nothing worth saying, while any tutorial phase owns the screen, and after the user has closed it in this session. It is also only mounted while it should be visible, so a response shape nobody expected cannot reach a render on the app's home screen.

Goals: the tree and the viewer

The goals page groups sub-goals under their main goal by default, with a flat list one toggle away. A card with sub-goals carries a "n/m sub-goals" chip, a thin second bar for the children's mean progress, and a fold that lists them as compact rows with their own stepper, whose counter opens the same amount dialog the main goal has (add or remove any amount, not just one); when every child is complete the card offers to complete the parent, because that is still the only thing that pays the parent's XP. Search and the deep link look through the hierarchy: a match on a sub-goal keeps its main goal on the page, dimmed when it only made it through the child. The form's "Main goal" picker is pre-filtered with the same rule the server enforces (not itself, not a descendant, three levels), and "Add sub-goal" on a card opens it with the parent, its categories and its deadline already filled in. Deleting a parent warns that the children become main goals. Archiving is the gentler way out: a card's Archive action puts the goal away with its sub-goals, a toast says how many went along, and the status filter gains an Archived view (with its count) where each card shows its frozen progress and a Restore button instead of the stepper. The viewer, the dashboard's timeline and the parent pickers only ever see active goals.

/goals/view is the same goal, one at a time: a fixed inset-0 layer over the shell, like the Focus Mode, with Escape as the way out. Each slide gives the motivation the room the card never had, a large progress ring, the deadline as days left, the categories, the same stepper and Complete button as the card, and the sub-goals as a list. The deck has two shapes, chosen next to the sort and kept per device in viewFilters.goalsViewerLayout. Grouped, the default, walks the main goals only: a sub-goal is never a slide of its own, tapping it opens it in full on top of its parent's slide (its own ring, stepper, Complete and sub-goals), and a back control, or the browser's back, returns to the parent; the position counter keeps counting main goals. List is the older shape, every goal as its own slide, sub-goals included. Ordering is by status by default (in progress, then not started, then completed), or by category, deadline, progress or name, persisted per device in viewFilters.goalsViewer; the arrows and the keyboard walk the same deck, and ?goal= opens on a given slide, or on the parent's slide with the sub-goal open when the deck is grouped. The mobile app has the same screen at the root of its router, outside the tab group, for the same reason the focus screen lives there: the bottom bar is a sibling of the screens, not an overlay, so a screen inside the group cannot hide it.

The Focus Mode screens (/focus, and the root-level focus route on mobile) follow two rules the dashboard already keeps. A check pays visibly: the Ultra Foco step shows the XP the completion earned, read off the check response itself rather than the store, because the routine slice does not carry a fresh xpGenerated for an item that was unchecked and checked again, and never updates a list routine's items at all. Skipped is its own look: the Done button of a skipped item drops the accent colour, so a glance tells skipped from pending. The pomodoro's end is announced, on both platforms, by the two switches kept in the focus settings: a short synthesised beep (web) or the notification's own sound (mobile), and a system notification whose permission is asked on the first start of a cycle, never at boot, and whose refusal only makes the timer quieter. On web the beep is armed on the wall clock at the moment the cycle starts, because a background tab throttles the tick to about once a minute, and the audio context is created inside the start click so the browser's autoplay policy lets the first cycle sound.

Widget identity is shared state: the list of ids lives in the state package (worstArea, constance, constanceHeatmap, betterArea, dailyProgress, fastTips, levelProgress, categoryBalance, moodWeek, studyToday, calendarToday), and both apps read it. Four render full-width. A fabric component maps id to component, so adding a widget is one entry in the map plus one entry in the shared list. The mobile map is an exhaustive Record<WidgetId, …>, so a new id fails the mobile build until mobile implements it. That is deliberate: the alternative is a widget the two platforms disagree about.

Most widgets are handed their data by the dashboard. Four fetch their own. The constance heatmap, the mood week and today in the calendar each read a date range nothing else on the page needs, and studyToday reads the study cycles' day from the same store slice as the notebook's Today card, so a block closed on one is closed on the other. All four are optional, so a user without them should not pay for the request. A self-fetching widget owes the rail a stable height while it loads: moodWeek renders the week's shape with placeholder circles and a data-loading attribute rather than deciding between its two views, because deciding early painted a full week of blank days and then flipped, twice, for anyone who had not marked today. Its two states are the five faces when today is unmarked and, once it is, seven days each drawn with that day's own face. Those are the same icons the scale and the month grid use, so the three places a mood appears say it the same way. A day nobody recorded has no face, so it keeps a muted circle, which is also what holds the row's height steady across a week with gaps.

Selection lives in Configuration: a drag-to-reorder list that autosaves on every change, pushing the new order to the backend and Redux together, and rolling nothing into Redux when the server rejects. On phones the dashboard renders widgets in a snap-scroll carousel, one per screen, so adding widgets never pushes today's routine below the fold.

The diary

/mood is an ordinary page inside the shell, and the only one whose content is something the person wrote rather than something the app recorded. Four blocks down the page: the day with its five faces, the journal box with a Save button, a month calendar with a face per recorded day, and the recent entries.

From lg it is two columns: the day and its month on the left, the writing and what was written on the right. The journal collapses, because somebody comparing a month of faces does not want nine rows of textarea between them and the calendar, and the choice is remembered. The month grid draws each recorded day with the same five faces the scale uses rather than a coloured dot, so a month reads in the language the rest of the page already speaks. The entry list is paged and says where it ends, with one filter chip per level carrying its count, reusing the chip pattern from the dashboard's goal horizons.

A tap on the face that is already chosen leaves the day unrecorded. The scale is a set of toggles and aria-pressed already says so, so un-pressing has to mean something. Without it a day could be changed but never taken back, and the only way out was the trash icon in the entry list. It asks first when the day carries writing, because removing the entry removes the note with it and that is the one thing here nobody can get back. A day with only a level is one tap from being re-recorded, so that goes straight through rather than opening a dialog about nothing.

Four details hold this together, and all four are about not losing writing.

The faces and the Save button send DIFFERENT requests. A face sends the level alone. Save sends the whole entry. That is why tapping a face on the dashboard widget cannot wipe the morning's journal, since the request has no field for it, and the server enforces the same split.

The text area seeds from the stored entry, keyed on the day AND on the entry itself. Keyed on the day alone it seeded empty before the day's entry had arrived and never re-seeded, so a day with a journal showed blank and Save then cleared it: the same data loss the two-verb split exists to prevent, reintroduced one layer up. It also refuses to replace typed text with nothing, so a late-arriving empty entry cannot swallow what somebody is in the middle of writing.

The comparison that decides all of that runs in the effect body, never inside the setState updater. React may invoke an updater more than once for the same state. While the "which day is on screen" ref was written inside it, the second invocation saw the first one's mutation, concluded "same day", and returned the PREVIOUS day's text, so moving to another day kept the old entry on screen, ready to be saved onto the wrong date. The rule this leaves behind is the general one: an updater has to be pure, and a ref that a decision depends on is written once, outside it. The symptom looked like a stale render and the cause was not, which is the part that cost time. Both clients had it, both are fixed, and a StrictMode test holds it, since that is the only condition under which the buggy version failed.

The calendar

/calendar puts one-off dated tasks next to what else each day holds: its routine, the goals ending on it, and the mood, with a marker when the diary has writing that day. Two views share the page, picked with a segmented control and remembered per device. Month is a grid with markers per day and a panel listing the selected day. Week is seven columns with an all-day strip on top and an hour grid where timed tasks sit at their hour. The day on screen lives in the URL as ?date=, which is how a reminder notification or a reminder mail opens the page on the task's day. A date that does not exist, like 2026-02-31, is turned away by isIsoDay instead of rolling into March.

The page asks for one overview per visible range and keeps it in local state, and creating, editing, completing or deleting a task updates that overview in place. A past task nobody did stays on its day, marked as not done. A past day's routine shows how it went ("Morning, 7 of 9"), and a future day shows the routine its weekday schedule assigns, with no count. The task dialog renders through the shared Modal: title, date, an optional time, the advance notice (at the time, 15 or 30 minutes, 1 or 2 hours, or 1 day before) and an optional note. Nothing on the page works out a reminder time. Every task comes back with notifyAt already resolved by the server.

Browser reminders come from CalendarReminderHub, mounted once in the authenticated shell beside the focus timer's components and rendering nothing. It does any work only when two gates are open: the browser granted notifications, and the account's reminderWeb switch is on. Then it polls /calendar/tasks/upcoming on mount, every ten minutes, when the tab becomes visible again, and whenever a calendar write is announced in this tab or another. It arms a timer per reminder in the next day and shows one missed by less than an hour straight away. localStorage remembers which reminders it already showed, keyed by task id plus instant, so a second tab or a reload does not show one twice and a moved task gets a fresh one. A click opens the calendar on the task's day and sends the acknowledgement that stops the mail. A notification can sit in the system tray while its task is moved, completed or deleted, so each poll takes down the ones the server no longer lists, and a click acknowledges only when a fresh look at the list still has that exact reminder. Otherwise an old card would cancel the moved task's new reminder. There is no service worker: with every Beyou tab closed nothing shows, and the mail covers that.

Settings live in a Reminders card on the configuration page, beside the engagement-mail switch and independent of it: browser notifications, reminder mails, and the default time for tasks without one. The permission prompt is offered from a click, the only place browsers allow it, and the card says where the permission stands in this browser.

The dashboard widget, "Today in the calendar", lists the day's tasks with a checkbox each and links into the page. It is off until someone adds it in the widget settings, since calendar tasks stay on the Calendar page unless asked for. It reads the day the server calls today, so a profile zone that differs from the device's still lands on the right day. Its checkbox and the page's share one toggle hook, so the two cannot pay XP differently.

The phone has the same screen as a month grid or a week strip with the selected day below, plus the widget and a Reminders section with the mail switch and the default time. It shows no notifications of any kind yet, and the screen says so. The same delivery fixed the phone's mood month, whose cells now scale to the width they are given instead of sitting small inside a large card.

The study notebook

The notebook adds nine routes. /notebook is the home: the study cycles' Today card on top, topic cards with a miniature of each board, "Continue", and today's reviews. /notebook/:pageId is a page with its tree, the notes and the board drawn inline where the "Roadmap board" block sits. /notebook/:pageId/board and /notebook/:pageId/study cover the shell the way /focus does, one for panning a board and one for the study room. /notebook/review is a review session, keys included: Space shows the answer and 1 to 4 rate it. The other four belong to the study cycles and sit inside the shell: /notebook/cycles for the cycles and their blocks, /notebook/cycles/new and /notebook/cycles/:cycleId/edit for the editor, and /notebook/cycles/:cycleId/history. They are static segments, so they win over /notebook/:pageId.

The editor is BlockNote on Mantine 8 (Mantine 9 wants React 19), themed through the same CSS variables as everything else, and autosaves 900 ms after the last change. The board is React Flow, and "Tidy up" puts its nodes back on a grid of three to a row. One hook, useBoard, serves the inline board and the full-screen one, and every write lands in the store from the server's answer except a drag, which is drawn first and saved after. The study notebook topic covers the model under all of it.

The Ideas board

/ideas is an inbox for raw ideas, drawn as post-its on a free board. It sits inside the shell but switches off the desktop bottom spacer, so the board fills the page. A capture field is pinned above it: Enter saves the idea and Shift+Enter starts a new line, like a chat box, and the server finds the note a free spot. A double-click on empty board writes a note right there instead. The header counts the notes against the 200 limit and holds "Tidy up", "Show all" and the archive.

The board is React Flow (@xyflow/react, already a dependency for the notebook's board) with one custom node type, the sticky note, drawn at the fixed 200 by 160 the server places notes with, so a note draws and fits before React Flow has measured it. The store is the truth, and the board keeps a local copy only so a drag moves smoothly before it is saved. A drop is applied to the store first and saved after through PUT /quick-notes/layout, with the z from the shared dropPosition, so the dropped note comes to the front on every client the same way. Arrow keys nudge a keyboard-focused note, and a run of presses is saved once, 400 ms after the last. Double-click a note, or press Enter on it, to edit the text in place; Escape puts it back and Ctrl+Enter or leaving the field keeps it. The colour swatches, "Make it a task" and "Archive" are on the note. A move or an edit the server refuses re-reads the board, because after a refusal nobody can say for sure what the server holds. Note colours come from noteColors in @beyou/state, with a light and a dark set, never from hex in a component.

"Make it a task" opens the ordinary TaskForm in the shared Modal, prefilled by quickNoteTaskPrefill, with its submit pointed at POST /quick-notes/{id}/task. One request makes the task and archives the note, so a task that fails leaves the note on the board and a task that saves takes it off. The archive is a panel over the board (a column on the right on desktop), read each time it opens, since the assistant or another tab may have archived something meanwhile, with restore and a confirmed delete.

Alt+N captures an idea from any signed-in page. QuickCaptureHub, mounted once in the shell and rendering nothing until then, opens a small "New idea" dialog with the same capture field, and a toast says where the note went. On /ideas the shortcut focuses the field already there. It matches the physical key (code), because on a Mac Option+N is a dead key and a check on the character never fires. It stays out of text fields on purpose, since Option+N is also how a Mac with a US layout types the tilde in "não", and it does nothing while another dialog is open.

The phone has the same board without a new native module. react-native-gesture-handler is not a direct dependency, so the board runs on React Native's own PanResponder with reanimated shared values. The gestures live in a small state machine with no React in it (boardGestures.ts), which the tests drive with plain points: a quick tap opens the note's sheet, one finger moving pans the board, a finger held still on a note for 250 ms lifts it and drags it, and two fingers pinch-zoom between 0.4 and 1.6. The capture field keeps the keyboard up after Enter, so three ideas in a row are three Enters. The sheet edits the text and colour, archives, and hands over to the mobile task form prefilled the same way.

The tutorial, in two systems

Onboarding is a phase machine persisted in localStorage, with values whitelist-validated on read:

intro → ai-onboarding → dashboard → categories → habits-dashboard → habits
→ routines-dashboard → routines → routines-summary → config-dashboard → config → done

Two distinct systems ride that machine:

The AI onboarding wizard walks five steps (categories, habits and tasks, routine, goals, summary), fetching typed suggestions from the backend and creating real entities step by step through the ordinary REST endpoints. Progress persists to localStorage as step-plus-created-references only; the suggestions themselves are deliberately not persisted, so a reload re-fetches instead of double-creating. The creates check the account first and skip a name that is already there, which is what keeps the error banner's Try again from adding a second copy of everything a failed pass had got through before it failed. A failure now says which kind it is: a suggestion call that failed keeps the AI-unavailable screen, while a rejected entity write names what fell over, shows the server's reason and lists what the wizard already saved.

Gamification feedback

Three pieces turn a routine check into visible progress:

Data freshness is handled by a shared auto-refresh policy with three prompts: returning to the tab, the local day rolling over, and a five-minute interval while visible. Single-flight, silent on failure, and paused entirely while the tab is hidden or a check animation is mid-flight. A refresh replaces the list in Redux with new objects for the same rows, so a form must never re-seed itself on an object's identity. The create-goal form opened from "Add sub-goal" did, and every return to the tab wiped what had been typed. Forms now borrow from the list once per opening, keyed on the id, and never over a field the person already filled.

Code splitting

Route-level laziness plus five manual chunks, in an order that matters:

Chunk Contents Why
icons-base react-icons The heaviest optional weight
telemetry Sentry SDK Must be matched before the forms rule: the SDK ships a file with "zod" in its path. Tree-shaken away entirely in builds without a DSN
motion framer-motion Only needed after login
forms react-hook-form, resolvers, zod Form-heavy pages only
vendor react, router, redux family The stable base

The study notebook's editor and board (BlockNote, Mantine, ProseMirror, React Flow) are deliberately left unnamed. A named chunk becomes the home of whatever shared helper Rollup meets first in it, and the entry then preloads the whole editor at boot to get two tiny functions; left alone, they split with the lazy notebook routes, and React Flow with the lazy /ideas route too.

The dev server pre-bundles the lazy-route dependencies, the notebook's editor and board among them, because discovering them mid-session used to trigger a re-optimization and a full reload halfway through using the app.

Conventions worth keeping