Planet4.Me docs
Open the app

#Screens (layers 3 and 4)

src/hooks/ owns every subscription; no component calls onSnapshot. One listener carries all open items across every list, and the screens filter it — the lists are tens of documents, and a where plus orderBy would need a composite index for no gain. firestore.indexes.json carries only the purchase rebuild's pair as a result. A second listener carries the checked items since the household's trips look-back (subscribeRecentlyChecked, a single-field range on checkedAt from a since the hook hands it, so no index either). Checked items are the one household set that only grows, and every reader of that listener looks at the recent past — the trip still running, the trips worth attributing, three adds worth promoting — so it is windowed by the calendar rather than by how long the household has been shopping. useLookBackWindows (src/hooks/useLookBack.ts) turns the household's setting into one {days, since} per surface, fixed per household snapshot, and App.tsx hands each screen its own; the trips listener WAITS for the household document (since is null until then, and null opens nothing) rather than opening at the default and reopening at the stored value a moment later, since a reopen is a full re-read and a pure derivation over the default is free. A changed setting reopens it. An item that ages past the cutoff mid-session leaves at the next open, which nothing on screen can tell from the day before.

Routing is react-router-dom: / for the list of lists, /list/:listId, /tasks for the list of to-do groups, /tasks/all, /tasks/:taskListId (any group slug), /tidy-up, /notes for the list of notes groups, /notes/all (the flat view, ahead of the rest for the reason /tasks/all is), /notes/personal and /notes/work (static routes, not a :noteListId param — /notes/:noteId shares the segment, two dynamic patterns on one path are ambiguous, and static outranks dynamic), /notes/area/:areaId for custom notes groups wherever they sit (their ids could wear a note id's shape, so they get a segment of their own), /notes/:noteId, /calendar, /privacy, /receipt, /questions (every question, open first and newest first, answered ones kept on a shelf underneath, the header's search over the questions' words, tags and people and its filter sheet narrowing by group (?q= and ?g=, subtree semantics and all, each write preserving the other)), /people and /people/:personId (the directory of everyone the household has named — filed by family name, each row wearing its person's nickname beside the name in the muted voice (the same .nick the @ menu and the search's offers use, and the merge roster too) because a directory filed by family name stands near-duplicates next to each other and the name alone is the one thing that cannot tell them apart — and "you", in the same voice, on the entry this account has claimed — and their job under the name in the subtitle voice every row wears (.row-note), with the header's search over names, nicknames, folded-away aliases and jobs (searchPeople, the merge card's own fold, so "chen" and "sam_chen" both find Sam Chen and "plumber" finds whoever was given that job; ?q= in the query string, written with replace like the notes search) and its filter sheet narrowing to whoever that corner's live texts name (peopleNamedIn), since people carry no group of their own, each write preserving the other and the rows carrying both back — and one person's page — a derivation over the live notes and tasks via personAppearances, grouped by the group each text lives in as a heading over one rows card, each row showing the line that names the person with the mention itself in the accent voice, with one quiet line under the title for what is done TO the person (.person-tools): "This is me" — first, because it is the one tool about the READER: the account claims this entry (claimPerson, releasing whichever it claimed before), the header then says "you" beside the name and the word becomes "Not me"; absent while another account holds the claim, since there is nothing honest to offer there — then Rename, then Merge where there is anyone to merge with, then Remove — only while nothing in the household names them (peopleReferenced, over EVERY document whatever its state and age: an archived note, a completed to-do and an answered question still say a name, and the look-back window is a rule about screens, not about what exists), asked once in the merge card's shape, and landing on the directory; a mention typed later births them again — and under it the two things TYPED about them, Nickname and Job, as fields of their own (PersonField, the Look back fields' clothes: the label over a full-width box, always a box, a draft re-seeded from the document only while nothing is being typed, commit on blur or Enter, Escape putting the stored value back; a refusal — a nickname that makes no handle — stays under the box with the words still in it, and the job's box offers the household's other jobs as chips while it is focused). Under the rows, two shelves that read sideways, each absent when empty: Named alongside — the people named with them in at least two of the same texts (CO_MENTION_MIN; one shared text is left out as coincidence, DECISIONS "One shared text is coincidence"), as the quiet person chips every note wears, most often first with the count in the nickname's voice, each a way to that page (coMentioned) — and Same details elsewhere — each phone number, email, link or address from the person's texts that other texts carry too, as its own fact chip (the note page's, shown short) over the rows of those texts in NoteRow's shape with the group under the title (sharedFacts). See BACKLOG, "Let me see options for search by related content", for the crossings not built. The header keeps the name and the way back, and a rename takes the tools line's place while it runs — Merge opens an inline card, never a sheet: a search over names, nicknames, folded-away aliases and jobs (searchPeople), a multi-select roster that scrolls inside the card so the confirm stays reachable however many people the household names, a "Keep this name" row naming the survivor — this page's person by default, and any of the people involved instead, because which spelling a household keeps is a fact about the household and the page that happened to be open is not evidence of it — and one danger-voiced button that states the consequence in the chosen direction ("Sam becomes Sammy…") and names the survivor ("Merge Sam into Sammy") before folding everyone else into them, because a merge deletes documents and has no unmerge. Keeping somebody else's name navigates to their page with replace, since the document being read is the one that just went; no New button, because a person is made by naming them in prose), /settings and its four subscreens — /settings/shops, /settings/trips, /settings/knows, and Regulars at /settings/regulars with /settings/regulars/photo and /settings/regulars/:stapleId beneath it, plus the two household screens — /settings/workspace/new (start a shared one) and /settings/members (who is in this one, and the invite link) — and /join/:hid/:code, the far end of an invite link. Sign-in is a gate above the router, not a route — and the address a signed-out arrival came in on survives the sign-in by being remembered, not by trusting the address bar: the popup leaves it alone, but an installed app that leaves for the OAuth handshake can come back at its start URL, which made an invite link a thing to tap twice. On the signed-out side the shell writes the path and query to localStorage (rememberSignInReturn, firebase/auth.ts) whenever the path is one worth returning to (isReturnPath: anything but /, /index.html and /privacy); on the signed-in side it takes the entry back, keeps it only if it is under a day old (signInReturn, both layer 1 in households/prefix.ts), and if it differs from the address on screen puts it back with replaceState and re-asks for its household — before the router mounts, which waits on the household anyway.

A household is a scope, and the second one is a card, not a group. An account may belong to more than one household (see "Households" above), and one is on screen at a time: every hook in the shell is scoped by the one hid, exactly as before, so no screen below the doors changed. The solo household owns the bare paths — /notes, /tasks/work — as it always has; a shared household lives under /h/{hid}/…, and the shell mounts BrowserRouter with that as its basename, keyed by household, because the router builds its history once and only strips the base it was born with. Every link inside the app is written against the bare shape and lands under whichever base is mounted, so backName, the tab bar's prefix match and the assistant's matchPath needed no change — they read useLocation().pathname, which is base-relative. src/lib/households/prefix.ts is the only reader and writer of the prefix: householdPrefix parses it, prefixedPath builds one. The shell reads it once before anything mounts and again on every popstate (Back across households lands outside the mounted base, which would otherwise draw nothing); a prefix the account does not own falls back to the solo household on the bare path — but only once the list is KNOWN, because until then the boot screen holds and the * catch-all cannot rewrite a cold deep link on a guess. The way between households is switchTo on HouseholdsContext (src/hooks/useHouseholds.ts): push the new address, then ask for the other household, and the keyed router remounts reading what it finds.

On screen a household is a workspace. The word people see — in the bar, the picker, Settings, the join screen, the move sheet — is workspace; the schema, the rules, the paths and this code keep household, because a rename there would touch every document path for no one's benefit (see DECISIONS). WorkspaceBar is one line of chrome above every screen, drawn by the shell as soon as a household is known: a dot, the name, a caret. Tapping it opens the picker — a Popup in the filter sheet's shape holding every workspace as the doors' own card (householdCards), the one on screen lit and ticked, and Start a shared workspace under them — and a pick calls switchTo(id, doorOf(pathname)): the switch keeps the TAB and drops the rest of the path, because a note's id or a group's means nothing over there (doorOf in prefix.ts, a pure map from a bare path to its door). The bar sits ABOVE <main>, outside the container, so it spans the whole width: a strip with one centred control on it. It is drawn only when the account is in more than one workspace — with one there is nothing to switch to, and a shared one is started from the row in Settings. There is no other way between workspaces: the doors carry no card and no subtitle.

A workspace's colour is the account's own. Six tones — emerald, blue, purple, rose, rust, slate — are six settings of the four accent tokens in tokens.css, and of the neutrals with them (ground, surfaces, border, the three text greys, the card shadow: the sage values with their hue turned to the tone's at the same lightness and chroma, emerald keeping the sage itself — see DECISIONS, "A workspace's colour is yours"), applied by data-ws on the document root for the workspace on screen (so antd-mobile's portalled sheets recolour too) and on any element wearing a tone of its own: the picker's dots, the swatch row. The choice lives on users/{uid}.tones, a map by household id, written by setWorkspaceTone with a merge and kept live by subscribeUserHouseholds beside the back-pointer — never on the household document, because a colour tells YOUR workspaces apart and the same shared one may be blue on your phone and rose on someone else's. Unchosen, a workspace takes its tone from its place in the account's list (defaultTone: the solo one emerald, the shared ones round the other five), so two workspaces differ from the first second. Settings' Workspace section carries the Colour row: six swatches on their own line under the label, the one in force ringed, one tap to change. --auto-add and --danger are not tones and do not move, so an auto-added row and a refusal keep their own voices in every workspace. A household cannot sit in the group tree — the two-level cap (groups/tree.ts) forbids a third level, and a group is a document inside a household — which is why the way between them is chrome above the doors and not a folder in them.

The first shelf wears a shared household's name, and Work is no shelf there. BuiltinNames (groups/groups.ts) is the one place what the built-in shelves ARE on screen lives: builtinNamesFor(household) answers {personal: household.name, work: false} for a shared household and the defaults ({personal: 'Personal', work: true}) otherwise, and every reader that renders a name or lists the shelves — builtinName, groupName, backName, the card and tree builders, the parent picker, the filter tree, the move sheet's targets — takes it as an optional trailing argument. Components read it through useBuiltinNames() from the same context. work: false means the mine-versus-job split does not exist in that household (the workspace is the split), so Work is not drawn empty and not offered as a destination; but an id something still names stays reachable, so builtinShelves(names, holdsSomething) lists it for as long as content or a subgroup sits under it on the surface in hand — the rule that keeps a custom group open, applied to a built-in. Its stored word stays reserved as a group name either way, since the id can reappear. Ids never change; builtinName(id) with no names still says which ids are built in, which is what the detection sites use, and builtinName('work', names) is "Work" in every household, for the route, the back link and the content still filed there. tagGroup deliberately still resolves #personal and not the household's name: the search grammar names shelves by their stored word.

Device-local keys that are really per household are namespaced by hid: the seasonal pass's month, the review pass's stamp, the open shop section, and the doors' fold state. The client-side passes (useAutoAdd, useSeasonal, useReviewPass, useBracketMigration) run for the household on screen only — other members' phones cover a shared one, and the solo one is only ever looked at by its one member.

Nesting added no routes. A group is addressed by its id at a flat path, never by its position: a path-addressed URL like /notes/area/renovation/kitchen would encode where a group currently SITS, so re-parenting would break every link to it — the exact failure opaque immutable ids exist to prevent. Depth is a display fact, read from a document the app already subscribes to. backName needed no change either: it resolves through groupName, so it follows renames and nesting for free. /privacy is the one page on BOTH sides of that gate: OAuth verification requires the policy readable without an account, so the signed-out branch checks the path by hand (no router exists out there) and serves the same router-free PrivacyScreen the signed-in route uses; the sign-in landing links to it. The routes are flat — there is no Outlet — and /staples, /staples/photo and /staples/:stapleId redirect to their new homes, because an installed PWA's own history, a bookmark and any link already shared all outlive a route rename.

A fixed bottom tab bar (antd-mobile TabBar) moves between /notes, /calendar, /tasks, /lists and More, in that order; the app opens on Notes — / is a redirect to /notes, so the PWA's start_url and every old bookmark land there, and the Lists home lives at /lists. The bar stays visible on every other route and highlights the nearest parent tab (/notes/* counts as Notes, /calendar as Calendar, /list/*, the photo intakes and Tidy up as Lists), and it carries no badges by design. More is a launcher, not a screen: its slot opens one ActionSheet over whatever is on screen, holding People, Questions and Settings (Settings apart under a divider — content above, the app below), each row a real route the sheet merely navigates to. The destinations live in one array (MORE_DESTINATIONS in AppTabBar.tsx) that both the sheet's rows and the highlight's prefixes read, so the More slot lights exactly when the current route is one of its own — /settings/*, every subscreen included, /people/* and /questions. The Questions row carries its open count ("Questions · 4 open") inside the sheet and nowhere else — open questions inside the questions look-back, the same cut the screen makes, so the number and the list agree. The sheet pushes no history entry; any navigation closes it, so Back never digs through a menu that was only a springboard. The bar itself is never gated: the two photo intakes live as quiet chips on the Lists home when the intelligence layer is on, and note creation lives on each notes area's own New button.

The Calendar screen carries no screen title — the month bar ("‹ August 2026 ›") is its name, a "Calendar" heading over it said the same thing twice, and the grid wants the height more. It is entirely derived: calendarEntries (src/lib/dates/) reads the live notes and tasks each render, so a day carries a dot because text names it or a recurring task's stored pending slot (walked forward on its own rule) lands on it — nothing is persisted, indexed, or subscribed beyond the listeners the app already holds. The selected day rides in the query string (?d=YYYYMMDD, written with replace), which is what lets every date chip in the app deep-link to its day; a month browsed with the arrows rides as ?m=YYYYMM with nothing selected, because moving between months must not invent a selection. A pending occurrence whose day has passed renders on that day in the same muted voice as any other past day.

Holding an entry — or right-clicking it — offers to move it a day, or take it off one. (useHold lives in src/components/ and serves the to-do and note rows too; the calendar had it first.) Held rather than shown: a row wearing a button is a row you read past a button, and moving a thing is rarer than seeing what is on a day. The sheet (antd-mobile ActionSheet) names the day it will land on rather than saying "snooze", and offers Open beside it so a hold meant as a tap is not a trap. useHold (src/components/useHold.ts) is the gesture: a timer the finger can outrun (past HOLD_SLOP_PX the list is being scrolled), plus contextmenu for the two platforms that have the gesture natively — a desktop right-click and Android's own long-press — whose default is prevented so the browser's menu does not open over ours. iOS raises no such event and is served by the timer, with -webkit-touch-callout: none keeping the callout off. The click a touch long-press leaves behind is swallowed, and only that one: a right-click is followed by no click, so it deliberately does not set the flag.

The move itself keeps the screen derived. A note or one-shot to-do dated by its text has that text rewritten (rescheduleDate over the title, then the details); a recurring task's slot is the one dated thing in this app that is a FIELD, so snoozeOccurrence moves that field and leaves the rule alone — every later slot is still the rule's, and completing re-arms from the rule, so a snooze can never drift a schedule. The day it lands on is then selected: the entry has left this list, and the honest confirmation is the day it went to, with the entry on it.

"Take off this day" is the same walk with uncommitDate in the middle, and it answers the one thing this screen could not do: a day filed by mistake had to be found in the note and unbracketed by hand. It says "this day" rather than "the calendar" because the document may commit to others and the gesture only ever reaches the one being held, and it is absent for a recurring slot, whose day comes from a rule rather than from any text — the honest answer there is to edit the rule, not to look like this menu can. Nothing is selected afterwards, because nothing went anywhere: the entry leaves the day's list under the write, which is the confirmation. The words stay; removing the sentence would be this screen editing prose it does not own. rewriteDatedText in CalendarScreen is the shared walk both actions take — title first, then the details, never both blindly, because every pass here answers null for text that does not name the day — and it answers whether anything was written, which is what lets the move confirm on screen only when there was something to confirm.

The calendar filters by group, and joins the namespace as a VIEW. The header's filter sheet (ToolHeader, the same one every list screen wears — the title bar came back for it; the month bar under it stays the month bar) narrows the screen to one group and everything filed inside it, both doors' groups because a day carries notes and to-dos side by side, spelled out as the summary chip under the header — and, once the account has claimed a person, to "Mine": the notes and to-dos that name that person are filtered BEFORE the days are read off them (mentionsPerson over noteText and the to-do's edit text), so a day is theirs when the text that put it there says their name; ?who=me, kept the same way. The filter rides the query string as ?g=<id>, beside ?d= and ?m=, so a filtered calendar is a link and Back undoes a filter; every write of d or m carries it, since setParams replaces the whole query. There is no calendar boolean on GroupDoc and there should not be: the calendar owns no content, so the only correct answer to "which groups may it show" is all of them, and a flag would permit a dated task whose group is calendar-hidden — the calendar lying about what is on a day.

A selected day's panel ends in two links of one voice, and keeps the screen derived. "Add a to-do on this day" is a CaptureLink — the box behind the link, as the group doors' is — where it used to be a full-width box with a solid Add button under every day you tapped, twice the weight of the note link beside it for the same kind of act. Open, it writes an ordinary home to-do whose title ends in the day, committed — "dentist [Sep 3, 2026]", commitLabel over pinnedDayLabel's locale-free format, exactly what the scanner reads back onto this screen; typed relative phrases are pinned first, as the Tasks screen pins them. "Start a note for this day" seeds an ordinary note with the day label as its body and opens it fresh — focused, caret on the empty first line, because the first line is the title and the title is yours to type (fresh rides the navigation state, gone after a reload like from). Both captures land in the group being FILTERED to, or Personal when nothing is — and in the folder itself, never one of its children, because the filter is a view and an add needs one shelf; guessing a child would be the calendar inventing filing. Each declares the destination on its own door in the same gesture (ensureTaskGroup / ensureNoteGroup). The calendar still persists nothing of its own; moving an entry off a day is editing its text.

Regulars is deliberately not on the bar. It is a ledger you consult rather than somewhere you go while standing in a shop, and it does not earn one of five permanent destinations; it is a row on the settings screen, beside Shops, Recent trips and What the app knows. Sign out lives there too. There is no app header: the screens start at their own titles, and the connection pill appears above them only when there is something to say.

The Notes tab opens on the list of groups — Personal, Work (where it is a shelf) and the household's own, one container of rows under the level search, as the Notes section above describes. Each group's list keeps its search and sort in the query string rather than in component state, so opening a note and coming back lands on the same filtered list; it is written with replace, because a history entry per keystroke would turn the back gesture into a way of un-typing a search one letter at a time. A note opens as one page you write on — a live textarea from the moment it appears, no read mode and no toggle — with any detected phone, address, email or link offered as chips beneath it, exactly as the Notes section above describes; its Back falls back to the note's own area when there is no navigation state to carry it somewhere more specific.

The quantity stepper appears only for quantityMode: 'prompt' staples and only at check-off — at add time you do not yet know what you will buy. It defaults to the last quantity, so the usual amount is one tap, and it is skippable.

Promotion is offered inline on the list, where the context already is, rather than filed into a queue. It counts adds, so the checked-item listener exists alongside the open-item one — the household's trips look-back of it, which is the window three adds have to land in. The auto-add sweep reads both for its own reason: an item ticked off on the trip still running counts as handled, so it is not offered again (see Auto-add and dormancy).

Regulars — now at /settings/regulars — opens with a dismissible note explaining that the engine improves with use and needs a few purchases first. The screen's restraint is otherwise indistinguishable from a broken feature: a new household sees "Learning" against every row and has no way to tell that the silence is deliberate. Dismissal is a regularsNoteDismissed flag in localStorage — per device, like lastStore, and no Firestore document is involved.

Capture lives on each list screen and adds to that list. It is not on the home screen, because until the classifier ships nothing can route a typed string to the right list, and asking the user to choose is the friction the design exists to remove. On a to-do group it waits behind its link: the line under the header reads New to-do, then New group where one may be made, and the box — with the Repeats rule under it — opens under the whole line when New to-do is tapped, exactly as the group box does (ActionLine, one open at a time, Cancel or Escape putting it away and forgetting the rule with it). The notes side has the same line with New note, a plain action, since a note is made by going into it. The doors carry New group alone — a note or a to-do needs a group to land in — and a subgroup's line carries only its own New.

Under a group's open to-dos sits its Done shelf — the notes' archived shelf to the word: closed by default, Done (n), and inside it this group's completed one-shots within the to-dos' look-back, newest first (doneTasks, layer 1), each row the title over "Done Sep 3, 2026 · Sam" (the completer resolved through personForUid, absent when nobody has claimed that account) and a Put back that reopens it (uncompleteTask). Not the subtree's and not while searching, which searches what is open. A repeating to-do never lands there — it re-arms — and the look-back is what keeps a year of finished one-shots from piling under the list. A put-back to-do's note line keeps its "(done …)" stamp, which the capture reads as "made once already", so the to-do is not made twice.

A list row is two targets: the box checks the item off, the name opens it for editing in place. The box keeps an oversized hit area to pay for the width that split costs it. A task row splits the same way — the box completes, the title opens the editor. The editor IS the note page's pattern, through the same layer-1 seam (taskEditText / splitTaskEdit in src/lib/tasks/, built on noteText / splitNoteText): one surface where the first line is the title, a blank line stands it apart, and the details — the fact that makes a task doable away from home (the filter size, the number to call) — follow, shown under the title on the resting row with line breaks preserved. The textarea grows with the text; Enter breaks a line, the save is the blur, Escape puts the saved text back, and the caret opens at the end — a detail-less task seeds as "title\n\n" so the caret lands under the separator, ready for the detail rather than mid-name, because details accrete rather than get retyped. The task-shaped edges of the seam: a blanked first line leaves the name alone (a to-do cannot be called nothing), and cleared details go back to null.

The row's chip strip — the detail line's facts and dates, and the "from" chip back to the note a captured to-do came from — is a second full-width line of the row (.task-facts), wearing the compact row chip (.chip .compact: the app's pill one size down, no tap height, clipped rather than wrapped — the one design every chip inside a list row wears, on to-do, question and note rows alike; a full-size chip is a tap target on a strip of its own), and NEVER unmounts. While the row is being edited it reads the draft, the note page's own arrangement: chips beside the working surface, so a just-typed number is offered without a tap away. At rest it reads the saved text, anchored at the task's own write time — the same chips, from what is actually stored. That the strip outlives the edit state is what makes a chip tappable at all: it used to be drawn only while editing, and the press that reached for it could blur the editor away and take the chip out of the DOM before the click landed. See DECISIONS, "The row's chips live on the row, not only inside its editor". A task made from a seasonal suggestion arrives with the suggestion's reason as its details. Only the "might be running low" shelf keeps a single whole-row control, since it has nothing to edit inline.

Checking an item off is undoable for a few seconds, and the undo reverses the purchase, not just the list row — the purchase stream is the only thing cadence learns from, so a mis-tap left behind would quietly shorten the staple's interval with nothing on screen to explain it. The undo bar is pinned to the viewport rather than placed in the page, and its clock runs only while the page is visible and focused: the first check-off of a trip raises the location permission prompt at the same instant the bar mounts, and an undo that expires under a prompt was never really offered. Session writes are not awaited before the tick lands, so the bar appears with the tap rather than a round trip later.

Checked items are completed, not gone: the current trip's check-offs stay below the list, struck through, and a tap puts one back — the purchase reversed by the timestamp the check-off stamped on both documents, minutes after the undo bar expired. What counts as "this trip" is the session-gap chain (tripChecked), the same 75-minute constant that ends a session, so the section empties itself by the next shop with no timer and no cleanup job. The purchase document is deleted rather than voided, because an undo means the event did not happen and a voided record would have to be filtered by every reader forever.

The settings screen (/settings) is a menu, not a ledger. Anything with a screen's worth of content has a screen — Regulars, Shops (/settings/shops), Recent trips (/settings/trips) and What the app knows (/settings/knows) are four rows under one Learned heading, each stating what it holds. Below them sit the two household facts that are actually anyone's to change, Name and Region, as rows that edit in place; then, in the solo workspace, Start a shared workspace (/settings/workspace/new: one name field, and the screen goes to the new workspace's Members page once the back-pointer says it is there to go to), or in a shared one, Members (who is in it, "started it" on the creator's row; the creator's X on every other row removes that person after a confirm; a joiner's Leave this workspace at the bottom, after a confirm, lands on Settings in the solo workspace), and in either a Colour row (this account's tone for the workspace; see "A workspace's colour is the account's own" above) (/settings/members: who is in it from useMembers, and the invite — Make an invite link mints one, shown whole with when it lapses, with Share (the phone's share sheet, the clipboard otherwise), New link and Withdraw; leaving or removing is not offered yet). Under the Workspace card, a Look back card of its own — a section like Learned and Workspace, always open, since it is about every surface rather than the workspace's name and members. At rest it is one field and a switch: Everything, the days every surface reaches back, and Different limits per area. On, the switch reveals a field per surface — Trips, Calendar, People and tags, Questions, To-do links and assistant — each blank ("Same as everything · 90" as its placeholder) unless given its own number; off, the numbers stay stored and stop acting (lookBack.overrides). The fields are DaysField: real inputs, always inputs, the label and hint on one line and the number on the next with "days" inside its right edge — not tap-to-edit rows, which put label, hint and value on one line and made only the label tappable. Each keeps a draft re-seeded from the document only while nothing is being typed, commits on blur or Enter through parseDays, and puts the stored value back on Escape or anything that is not digits. The switch is SwitchRow: a native checkbox with role="switch", skinned as a pill in CSS (.switch) — native for the Repeats checkbox's reason, that antd-mobile's hides its real input from the locators the e2e suites use. Blank on a surface writes null; blank on Everything writes nothing. The data layer clamps (setLookBack), so a typed 400 reads back 365 on the next snapshot, which is the honest feedback. Then Sign out. It was seven equally weighted cards, three of which were usually paragraphs explaining they had nothing in them.

/settings/knows is the ledger: every answered profile fact stated back in the exact words its question used, facts implied by the tracked staples (marked as such, with nothing to forget — they would be re-inferred), and the dismissed items. Those three are separate groups on purpose. The verbs are opposites — forgetting a fact makes its question askable again, restoring a dismissal makes an item suggestable again — and a dismissal is not something the household stated about itself: ListScreen writes one when a suggestion becomes a task, which is bookkeeping. .fact stays the class for a ledger row alone, so that counting facts counts facts. Facts are never edited there, only forgotten — the question's wording is part of the answer's meaning, so a changed circumstance is handled by the question becoming askable again. Cadences deliberately do not appear: they live with their staples.

The collapsible shelf the ledger used to hide behind is gone. It existed because the ledger was competing for room on a crowded screen; a <details> inside a screen that is itself the ledger is a shelf inside a cupboard.

Connection health is a watchdog rather than an error handler, because a Firestore listener that cannot reach the server never reports an error — it retries silently. src/data/health.ts watches for an unanswered ask: from the moment the app requests data it does not have — a subscription opening, or the bootstrap read in ensureUserDoc that gates everything else — until the first answer comes back. Eight seconds of that silence while the browser claims to be online is what a blocking extension looks like from inside the app, and renders as "can't sync". Time since the last snapshot proves nothing and is deliberately not measured: a healthy idle listener is silent because nothing changed.

Only a server snapshot is an answer (noteSnapshotFrom, the one line every subscription writes). A cache-served snapshot arrives precisely when the path is shut — instantly, and again on every local change — so counting it would silence the indicator exactly when the app has most to say: your data is on screen, correct, and nothing you write is leaving the phone. Before the cache-first boot the distinction was academic, because a blocked cold start had nothing on disk to serve.

That rule has two consequences every subscription carries, both learned from the strip lighting over a healthy connection and staying lit. First, the server's answer to an already-correct cache is nothing but metadata — fromCache flipping — and onSnapshot swallows metadata-only events by default, so every subscription listens with includeMetadataChanges: true (the shared LISTEN_OPTIONS), or its ask would starve exactly when the cache was warm and right, which is most of the time. Second, an ask can be abandoned: a subscription torn down before its first server answer — a note's photos, left before the round trip lands — has no listener left alive to answer for it, so noteListening returns a release and the watched wrapper ties it to every unsubscribe. Asks are held individually for that reason, though any one server answer still clears them all: one answer proves the path, and the path is the thing being measured. The bootstrap read participates because it must — with an empty cache it fails before any subscription opens, so it is the only ask the watchdog ever sees. When that read is rejected as unavailable, useHousehold retries on an interval instead of surfacing the SDK's "client is offline" message, which is worded wrongly for this case and describes a condition the SDK recovers from on its own. tests/e2e/blocked-firestore.mjs reproduces the blocked case end-to-end as part of npm run test:e2e; src/data/health.test.ts holds a fixture for each way the first implementation got this wrong.

No write is ever awaited before the UI updates. Firestore fires the local callback immediately, so awaiting the round trip is what turns a working offline app into a frozen one in a shop with no signal. tests/e2e/offline-add.mjs holds that line for the three shapes a dead connection takes — refused, hung, and airplane mode — and the row appears in tens of milliseconds under each, survives a reload, and reaches the server when the path returns.

But every write is owned (owned in src/data/writes.ts, wrapped around the writers in tasks.ts, notes.ts, items.ts and the transcript). A queued mutation can still be REFUSED when it finally arrives — a rules change, a document past the limit, a household somebody was removed from — and Firestore then rolls the document back out of the cache, so the row leaves the screen it appeared on. void write discarded that rejection and nothing said anything anywhere. owned attaches a recording branch that handles its own rejection and hands the original promise back unchanged, so the caller's void and the awaiting callers both behave exactly as before; the only difference is that the failure reaches serviceEvents (through setWriteFailureSink, installed beside the intelligence sink) and the connection strip, which grows a chip naming what was refused — "a to-do wasn't saved" — until it is tapped away. The writer names its own write, because it is the seam that knows.

The strip also counts what is WAITING. notePending in src/data/health.ts sums the documents with hasPendingWrites across the collections a person types into (tasks, notes, items), walked only when the query-level flag says something is pending, so the ordinary case costs one boolean per snapshot. The offline chip is a button now, like its can't-sync neighbour: it reads "offline · 3 waiting" and opens to say those changes are saved on this phone and will sync — the reassurance its hover title used to hoard, which a phone cannot show, with a number on it.