#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.