Planet4.Me docs
Open the app

#Steps 13–18

#Chore detection and task capture (7c)

captureTasks splits a paragraph on intent rather than punctuation and notices which items are chores. A chore's rule is proposed by the model and then runs deterministically: rent is due on the 1st whether or not it was paid on time. Anything genuinely one-off gets a null rule, because inventing a rhythm for it means it returns forever.

#The seasonal pass (6)

Two halves, and only one needs a model.

src/lib/seasonal/pass.ts is the deterministic half: tracked staples whose window opens next month, found locally, same answer every run, offline.

The generative half exists because an item never added is otherwise permanently invisible — sunscreen bought before the app existed. It may suggest only from categories the household has confirmed, enforced both in the prompt and again on the client. That gate is the whole spam-prevention mechanism, and it is stronger than a dismissal list because it works before the first bad suggestion rather than after it.

Every question asks about a purchase, never an object. "Do you use a fireplace?" is unanswerable for anyone with a gas one — they own a fireplace and buy nothing for it, so both answers are wrong and whichever they pick teaches the app something false. "Do you buy firewood?" has one honest answer, and it is the answer the suggestion actually needs. A test enforces the shape; hints carry the cases where a reasonable person would still hesitate.

The question offers yes and no, and only those. An earlier build added a text escape hatch for the case where both are wrong ("it's a gas fireplace"); it went with the other individual chat entry points — see DECISIONS, "One chat surface: the drawer" — so a question neither button fits is left unanswered, and it keeps its slot until it is.

src/lib/seasonal/profile.ts holds the question bank. Its decisive property is that it is finite: about fifteen household-defining facts, asked one at a time, ahead of the season each matters for, and then done forever. Item suggestions never terminate; questions do. Facts the household's own staples already imply are never asked — tracking dog food answers the pets question.

The question is derived from the profile on every render, not scheduled. An earlier version computed it once a month inside an effect, which meant a reload lost it and the monthly flag stopped it returning: one chance to answer, ever. Only the generative call is scheduled, because only it costs anything.

Both share one "Coming up" slot — a question or items, never both. Each item appears on the list its category shops at, carried by a destination on the bank entry: overseed surfaces with the hardware run, pet food with the food shop. The bank decides, not the model — the model labels an item with a category, and where a category is bought is a fact about shopping that no phrasing changes. This also keeps "Add" free of routing: the list you are looking at is where it goes. The question keeps a single slot on the Grocery list, the one everybody opens.

Items offer four responses, because the app knows neither your inventory nor your arrangements: add, have it (creates a tracked staple with an implied acquisition, no purchase invented, so next year it comes from the deterministic half), it's a task (an errand the model mistook for a purchase — the name moves to Tasks and is dismissed as an item suggestion, reversibly), or not for me.

#Receipts (7f)

src/lib/receipt/seam.ts is pure and carries the two rules that matter.

Seam detection is positional, never by name. Overlap between photographed segments is found by matching a run of at least three consecutive lines. Name dedupe would be wrong in a way that silently loses purchases: two MILK lines are usually two real purchases. A segment with no detectable seam is appended whole, erring toward a duplicate the review step catches over a missing line nobody sees.

The receipt validates itself. ITEMS SOLD is the cleanest signal, the subtotal the fallback. Price is extracted for this check and then discarded, never stored. On a mismatch the user re-shoots one segment rather than starting over.

Names are returned verbatim — GV WHL MLK GAL, not "Great Value Whole Milk" — because those strings are a per-store dialect meant to be cached and reused.

The capture flow lives at /receipt — behind the + in the middle of the tab bar, because it is something you do when you walk in the door, not a property of any one list (absent when the layer is off). Its first job is filling the list: you bought these things, you will buy them again, and typing them back in is the manual work the app exists to remove.

Shooting never waits on the model. Every photo is accepted the instant the shutter closes; its read runs in the background, in parallel with the others, and a part that failed to read retries from its stored image without re-shooting. Review opens once the reads settle.

Each line carries two names: rawName, verbatim register dialect (GV WHL MLK GAL 007874203972), and name, what a person would write on a list ("whole milk"). The screen shows the human one — with a local code-stripping fallback (tidyReceiptName) for functions deployed before the field existed — while the verbatim string stays underneath as the dialect cache's key and the seam's evidence. Every line has a one-tap Add: a matched line goes to its staple's list with the staple attached; a new name goes to the grocery list, where the ordinary promotion machinery decides whether it becomes a staple — the receipt never creates staples itself.

Recording purchase history rides along for matched lines (noon of the printed day, grouped per staple with quantities summed: two MILK lines are two units of one restock, not a burst of zero-day intervals; same-day duplicates skipped by default). Matching is local-only — the dialect cache, then the canonical name — and every recorded line caches its register string, so the next receipt from that store matches itself. The printed checksum is a footnote, not a gate: a disagreement is one quiet line offering to add the missing part.

#Pantry photos (7g)

readPantry takes a location tag as an input, not a label: a fridge yields perishables on short cadences, a garage yields stockpiled durables. It seeds staples with priors and an empty purchase history — a photo shows what you have, not what you buy — so the cadence engine still starts from zero. The prompt is explicitly conservative, since only the front row is visible and decanted goods are unidentifiable.

The intake lives at /settings/regulars/photo — "Track by photo", a quiet chip on the Lists home beside the receipt scanner's. Both chips are the gated part, absent rather than dead when the intelligence layer is off. Pick the location, photograph one product or a whole shelf, and every returned item is a proposal behind a checkbox — already-tracked names are marked and disabled. The route keeps the domain word while the screen it belongs to is labelled "Regulars". Its own back target is Lists, not Regulars, because it is reached from the Lists home rather than from the screen it files into. Confirmed items are created as estimated staples with the location as the prior's category. profileHints only ever answer unasked questions, and only with yes — a photo shows presence, never absence, and never re-opens something the household already answered. Answered hints land in the settings ledger like any other fact. Photos are downscaled client-side before upload; nothing is stored.

#The assistant (7h)

Chat appears in exactly one place: the assistant drawer. A floating button (ChatFab, bottom-right, over every section) opens a bottom sheet holding your one real thread: households/{hid}/assistantThreads/{uid}/messages, private to its owner and converging across their phones, roughly the newest twenty kept and the last ten turns sent to the model. Earlier builds also placed one-turn ask boxes on the Tasks and shopping list screens and an escape hatch beside the seasonal question; those individual entry points are gone — see DECISIONS, "One chat surface: the drawer" — and the button is absent rather than dead when the layer is off.

It runs assistantChat — Sonnet (claude-sonnet-5) rather than the shared Opus constant, because a conversation pays for latency in a way one-shot labelling does not — and may run up to three server-side web searches (web_search_20260209) per turn, so "what size filter does a Carrier 59SC5 take? make it a to-do" lands the looked-up size in the task's details. The model answers from what it knows or looks up (standard sizes, compatibilities); the fact goes in the task's details rather than only the reply, because the reply scrolls away and the detail is what needs to be in hand later.

The reply STREAMS into the sheet: the moment a turn is sent, an assistant bubble appears with three pulsing dots, and the reply's text grows in place as the function forwards it. What the model is DOING streams beside it as a log, not a status: a muted line per search (named once its query has streamed), per note read or asked for and not found, for its actions arriving and for the read budget closing — lines that stay for the turn (reduceActivity in src/lib/chat/, idempotent under a replayed chunk, a named search replacing the unnamed line it started as) rather than one "Searching the web…" that the first reply character wiped and nothing brought back. Once the reply lands the same lines sit behind a disclosure under it, with a one-line summary — "2 searches · 1 note read", "cut short". The seam is assistantChatStream, which answers {kind:'answer'} or {kind:'failure', code} — total like every other seam, but saying WHICH failure (AssistantFailureCode in src/lib/chat/failure.ts: rate limited, declined, unfinished, unreachable, not signed in, unreadable, empty), from the function's own error chunk when it got that far and the thrown code when it did not. What streams is a preview only: the turn's persisted reply is the callable's final text, and a failed turn writes nothing.

The log follows the newest words until you look away. The sheet is a column — the log scrolling, the composer fixed under it — and the dots, the activity lines and the streaming bubble all live inside the log, so whatever grows it grows it below the fold. It used to pin to the bottom only when a message landed or the streamed text changed length, and everything else that changes the log's height while a turn is in flight — an activity line, the dots giving way to those lines, the summary under a finished reply, a failure notice, the photo picker folding open and shrinking the log frame by frame, the keyboard shrinking the sheet — left the newest thing out of sight. A ResizeObserver on the log's content (.chat-log-inner) and on the log itself now pins on every change of size, and a layout effect pins before paint on every change of the thread's shape, while one ref says whether to: stuck, set from the log's own scroll events by atEnd (src/lib/chat/pin.ts, a few pixels of slack), true while the reader is at the end, false the moment they scroll up to read something older, true again when they come back or send. scrollTop on the log itself, never scrollIntoView, which would drag the page behind the mask; and the Popup's afterShow still does the first pin, because the sheet animates in with no height.

Its action schema is adds, one annotation, and one rewriteadd_item (with an explicit shopping list), add_task (with an explicit to-do list, home unless they clearly mean their job), add_note (with a notes area, personal unless they clearly mean their job), annotate_task, and edit_note — planned by planAssistantActions in src/lib/chat/, which decides what each returned intent means before anything is written; the hook only performs the writes. The destructive verbs are otherwise deliberately absent: the drawer floats over screens it cannot see, and a remove on a guess is the one mistake that loses data. So a wrong guess can put something on the wrong shelf, one tap from fixed, but never take a row away. Adds get no undo bar, as everywhere. A failed turn writes nothing to the thread; on the phone that asked, its question wears the failure's own sentence and a Retry underneath it, this phone's news only and never a turn in the conversation — filed where it belongs rather than as one line at the bottom that the next send erased, which left a thread of questions with no answers and no record of why. Only an "unreachable" failure clears itself when the network returns; a decline or an empty reply was never about the network. A question nothing answered is also left OUT of the history the next turn sends (historyWindow), because sent back it put the same question in front of the model twice — once as history it never answered, once live — and a reply of ":" was once what came of that. See DECISIONS, "A thread in the drawer, one turn in the ask boxes", for why the outcome-not-transcript rule bends for this thread.

annotate_task is how information reaches a to-do that already exists. The context snapshot lists open to-dos with their existing details in parentheses; the planner matches the echoed title by canonical name, narrowed to the action's to-do list when it names one, so a same-titled to-do on the other list is out of reach. The note is appended to the details already there, never a replacement: the model saw those details as one line in a snapshot and cannot be trusted to re-send them, and "add the vet's number" must not eat the filter size somebody typed by hand. An add_task carrying a note whose title matches an open to-do is planned as the same annotation, whatever kind it came back labelled — "add the vet's number to the flea treatment task" lands on the existing row, never beside it as a duplicate. A title matching no open to-do becomes its own to-do carrying the note, because wrong shelf beats vanished; an annotation with nothing to attach is dropped, because performing it could only mean touching details it was not given.

The assistant reaches the two built-in groups only. Its action schema says home and work for to-dos and personal and work for notes — the same shelf under both of its stored names, which is why home on an add_note is read as personal rather than refused; the system prompt tells it the home list is shown to the household as "Personal", and the context labels the lists by their display names. Custom groups stay out of the snapshot deliberately: the schema cannot target them, and a to-do the model can see but never reach only invites annotations that go nowhere.

edit_note rewrites the note on screen, and can reach nothing else. The schema has no field naming a note: the planner supplies the id from the route, so a model that invents a target has nowhere to put it. It is dropped unless a note is open AND the model was shown that note whole — NOTE_CONTEXT_CAP in useAssistant is both the snapshot's slice and the guard's yardstick, so a rewrite of a note the model saw only the head of cannot delete the tail. What comes back replaces the note: name is the new first line, note the new body, through the same title/body seam typing uses. The blank-name guard every action passes is what keeps an edit from emptying a note. On screen the change lands through NoteScreen's reseed (below), which also offers the one tap back. See DECISIONS, "The assistant may rewrite the note in front of you, and nothing else".

The assistant is told what the app is. Its system prompt ends with APP_CAPABILITIES (functions/src/capabilities.ts): a plain-prose, present-tense description of every screen, the note grammar, groups, photos and their read text, the calendar, people, tags, questions, Settings, offline behaviour, and the assistant's own limits — so "how do I make a question in a note?" is answered from a document rather than a guess, and the prompt tells it to say so when the document does not cover the question. It is a claim made to the household, held honest two ways: prompts.test.ts asserts the note markers appear in it in the blank note's own words, and CLAUDE.md puts the file under the same same-commit rule as this one. The whole system prompt is sent as one cached block (cacheSystem in askStream, cache_control: ephemeral), and each pass logs cache_read_input_tokens so a silent invalidator shows up as a read of zero.

The context snapshot carries note titles only — except the note on screen. The FAB is rendered inside the router (AssistantFab in App.tsx), so it knows when the current route is an open note; while one is, that note's full text (capped at 8000 chars with the same cut marker, inside the callable's own 24000-char context cap, CONTEXT_CHARS) joins the snapshot, and the system prompt tells the model "this note" means that one. So "summarize this note" and "make to-dos out of this" work while reading it, and no note's body is ever sent from anywhere else — everywhere else the assistant sees titles alone — the titles, the to-dos and the questions all from inside the household's to-do-links look-back, the same cut the function makes for read_note, so the snapshot never names a note the tool would refuse and the tool never hands over a note the snapshot withheld — and reads the rest: the request names the household, and when a title tells the model which note holds the answer, it reads that note through the read_note tool (see The proxy above — fetched server-side behind the same membership check, folded-title equality, at most eight notes a turn, 8000 chars each) — without asking first, several in one batch when several titles fit the question, because the fullest answer is the point and "want me to check that note?" spends a whole turn on a yes the question already gave. While it reads, the wait line names the note; the drawer's busy states say what is actually happening — Reading your note "Boiler service"…, Searching the web for "…"… — rather than one generic word. Notes read this way are readable, never editable: edit_note still reaches only the note on screen. See DECISIONS, "The assistant reads a note by asking for it". Photos ride only when picked (below) — and the reply can show one back: read_note appends a numbered listing of the note's photos, each with the head of its transcript where one exists, and the open note's photos ride the snapshot the same way; a second household-gated tool, show_photo({title, photo}), resolves the title and number server-side to the stored photo (the same folded-title match, the note screen's own addedAt order), refuses in words the model can read when there is no such note or number, and puts {noteId, photoId, url} on the answer's photos — at most three, each once. The drawer persists them on the assistant's message and renders them under its bubble as thumbnails, each a link into the note that holds it (the viewer lives there), closing the sheet on the way. The model is told it can show only what a listing named and can neither make nor find images. Nothing is staged by default, because most questions about a note are about its text and unasked images are tokens and seconds nobody chose to spend. Two kinds share one selection. The note's photos: AssistantFab holds the same photo subscription the note screen holds (one SDK listener serves both), and the request names the staged ones — ids only, in pick order, the bytes fetched by the function after the same membership check transcription makes — so "what's on this receipt? add what we're out of" works while looking at the note that holds the receipt. Photos from the phone: the strip's first tile opens the camera or the library (a plain <input type="file" accept="image/*" multiple>, no capture, so the system sheet offers both); each file goes through toJpegBase64 in src/components/photo.ts — the receipt intake's own encoding, 1568 px and EXIF-stripped by the canvas — and rides the request inline as images, never stored anywhere, held in the hook only until the turn that carries it lands. The function keeps at most three images in all, the note's first, and tells the model under the snapshot which came from where. The staging surface lives on the thread useAssistant returns (attachable / selected / togglePhoto / addDevicePhoto), with the pick arithmetic — the cap, the order-as-numbering, a fourth pick displacing the oldest, a deleted photo pruned rather than sent as a ghost id, and the two kinds told apart by the device: id prefix — as fixtures in src/lib/chat/selection.ts. Leaving a note drops that note's picks and keeps the phone's (keepDevicePicks); a delivered turn consumes exactly its own picks, so a failed turn keeps them for the retry and a pick made mid-generation survives for the next send. An unstaged phone photo is let go entirely — it was only ever here for one turn. No note photo is ever named from any other screen.

The snapshot carries the Questions sections too — answers included. Questions live as documents (households/{hid}/questions), and the document is the only place the whole record exists: a long answer never reaches the source note's stamped line, so no amount of read_note finds it. The drawer is therefore handed the same questions array the app already subscribes (plus people, to name who a question is for), and questionSnapshotLines (src/lib/questions/snapshot.ts, fixture-tested) folds them into two lines: open questions (newest asked first, capped at 12, "text" (for Name)) and answered ones (newest answered first, capped at 8, the answer riding sliced at 150 chars). The caps are the point — the snapshot is working context, the Questions screen's shelf is the memory — and the system prompt tells the model what the sections are, that an (answered …) stamp met while reading a note means the same thing, and that it can neither ask nor answer a question: no action does either.

Transient bars are one component. Toast (src/components/) carries the card, the edge, the enter and exit motion, and the countdown; UndoBar and DayOffer are thin wrappers over it, keeping their own reasoning next to the thing it explains. The edges are claimed and the reasons written into the CSS: bottom is the undo bar's, where a thumb already is on a list; top is everything else's, clear of the screen header. A screen shows one bar at a time, an error before an offer. What earns one is narrow — an event, at most one action, safe to miss — and DECISIONS, "What earns a toast", records what stays inline instead.

The exit is driven by a timer in Toast, never by animationend. The bar arrives over --dur-open and leaves over --dur-close, and the timer is CLOSE_MS from src/components/motion.ts, the vocabulary's JS half, which MUST match the tokens. Under prefers-reduced-motion the durations are zero, so animationend would never fire — an accessibility setting would have stranded the bar on screen permanently — and the timer goes to zero with them. Reduced motion therefore removes the movement and keeps the leaving.

Motion is one vocabulary. tokens.css names it: --dur-fast (120ms — a caret turning, a press wash fading after the finger lifts), --dur-open (200ms) and --dur-close (150ms) for anything that folds, with --ease-out on the way in and --ease-in on the way out. Closing is faster than opening on purpose; DECISIONS, "A fold closes faster than it opens", says why. Under prefers-reduced-motion the three durations go to zero at the root, so every transition keeps its end state and drops the travel — one rule, not one per component. The seven <details> shelves (the six .shelf sections and the chat's "What it did") are Shelf (src/components/): still a <details> for its semantics and the suites' selectors, but the summary's click is React's and the body is a Fold, because the native disclosure snaps and its one animation (::details-content with interpolate-size) is Chrome's alone — on an iPhone every shelf snapped beside a folding door. Shelf keeps the element's open through the leaving so the rows can fold out before they are hidden. The .shelf summary carries the chevron the flex display had cost it, turned by its own aria-expanded in the shop sections' idiom. The fold heads (.group-head, .door-head) take their wash the instant they are pressed and fade it only on release.

Fold (src/components/) is the one way a React-rendered body opens and shuts. It wraps what used to be expanded ? <ul/> : null at the shop groups (StoreGroup), the group door (GroupCards) and the group tree's children (GroupTree), and keeps that pattern's one virtue: closed and settled, it renders nothing, so a folded shop with sixty rows costs nothing. Open, it renders the body in a grid whose single track the CSS animates from 0fr to 1fr — no measured heights, no JS on open. Three rules, argued in DECISIONS, "A fold keeps nothing mounted and moves nothing on first paint": a body open on the render that mounts Fold paints at rest and only a later flip of open earns the enter motion; unmounting after a close waits on a timer matched to --dur-close (motion.ts holds OPEN_MS/CLOSE_MS, which MUST match the tokens, and reducedMotion()), zero under reduced motion, never transitionend; and the state that starts the leaving is derived during render, since an effect would be a frame late. data-motion is on the element only while it moves — it is what starts an enter from nothing and clips the body only then, so at rest a focus ring inside is never cut. The tree's caret is now one chevron turned, the shop sections' idiom, where it used to swap two icons. The same primitive folds a trip's detail (TripsScreen), the explanation under the offline and can't-sync chips (App), and the chat's photo picker (ChatDrawer, where onSettled pins the log once more at the picker's final height). Every control that folds something wears the one caret, .group-caret, turned by its own aria-expanded; the trip row and the chips gained theirs with their motion. The capture links' box (ActionLine), the search bar under a tool header (ToolHeader), the Regulars explainer (RegularsNote) and the note shelf's facts and photos strips (NoteScreen) fold the same way; for the strips the editor keeps pace, transitioning its own computed height at the fold's timings, so the two move as one (DECISIONS, "One motion, the fold"). Every collapsible in the app now moves, or deliberately does not; antd's own sheets and drawers keep the library's motion. Everything else that moves — a card pressed, a box taking focus, a checkbox filling, the switch, a meter's fill, the toast — spends the same tokens: --dur-fast for a press or a focus, --dur-open/--dur-close for an arrival or a leaving. The one duration outside the vocabulary is the row flash, an event fading over 1.8s, and the chat dots' ambient pulse, which reduced motion stops outright.

When a save introduces a day the document has not committed to, DayOffer appears at the top of the screen — clear of the header, since a bar over Back and the title takes away the way out at the moment somebody has just stopped typing — naming the day with one action. It expires rather than waiting to be dismissed and remembers nothing: ignoring it is the answer, and it is the common one. Accepting appends commitLabel through the same commit path typing uses, so the day is pinned, captured and written exactly as if it had been typed by hand — at the end of a note's body, or the end of a to-do's title, where a to-do already wears its day. Its countdown is useVisibleCountdown, shared with the undo bar, so the "an undo that cannot be seen is not an undo" lesson is not re-derived for a second transient bar.

In the sheet this is a photo-stack button left of Send, live on every screen — the phone's camera and library are always behind it — carrying the count: the number staged once anything is, else the open note's photo total. The count is load-bearing rather than decorative: the sheet covers the note, so without it nobody can see there are photos to offer at all. It opens a strip inside the sheet, never a second sheet — modal over modal gives two dismiss layers and an ambiguous back gesture — whose first tile, always, is "From device" (a dashed outline, so it reads as a door rather than a picture), then any phone photos already taken this turn, then the note's; the strip is the note page's own thumbnail row, down to the token. A file the phone cannot decode is said so in one quiet line above the composer, and the rest still land. A picked thumbnail wears an accent ring AND its send-order number, so the state never rests on colour alone, and that number is what makes the cap legible without a "max 3" label anywhere. With the strip closed over them, the staged photos ride as small removable chips above the input; showing both at once would be the same three photos twice in one sheet. The one thing the eye gets free and a screen reader does not is the displacement — a fourth pick releasing the earliest — so that alone is announced through a polite live region (.sr-only, the app's first), while aria-pressed carries the ordinary attach and detach. /notes/personal and /notes/work match the same route pattern, but a Firestore auto-id can never be either word, so the lookup finds nothing there and nothing extra is sent.

The sheet and the keyboard. The popup is fixed to the bottom of the LAYOUT viewport, and on iOS that viewport does not shrink when the keyboard comes up — only the visual one does, and dvh tracks the browser's bars, not the keys — so the composer sat exactly under the keyboard the first time the input took focus. While the sheet is open the drawer reads window.visualViewport (height and offset against innerHeight) and writes the covered height to --keyboard-h on the root; .chat-sheet is lifted by that much and capped at what is left above the keys, and the log's ResizeObserver re-pins the newest message as it shrinks. Where the browser resizes the layout viewport instead, the difference is zero and nothing moves.

#The review pass and Tidy up (7d)

The pass proposes; it never decides. It writes only to households/{hid}/suggestions, never to a staple or cadence directly. So a cadence that moved can be traced to a proposal someone accepted, the pass failing breaks nothing, accept-rate per type shows which prompts earn their keep, and a model that answers differently across runs is merely inconsistent rather than an unreproducible bug in the data.

It runs weekly and only past a purchase threshold, because against a thin dataset it will confidently describe patterns that do not exist.

src/lib/suggestions/ holds the deterministic gate every proposal passes before anyone sees it: a proposal naming a staple that no longer exists is dropped silently; duplicates against pending and within a batch are removed; dismissals — recorded on the household document's dismissals array as {type, targets, at, permanent} and read back with the household — are suppressed for ninety days when temporal, permanently for merges and splits, since "milk and almond milk are different things" does not become untrue in ninety days. The reader keeps type, targets and at; permanence is re-derived from the type, so the stored flag is a courtesy to anyone inspecting the document. The queue is capped at five by dropping the least valuable, so a split arriving late displaces a cadence nudge rather than queuing behind it.

Most types never reach the queue. "Still buying this?" is answered inline where the staple already is; co-occurrence and prior drift are observations with nothing to accept and are not surfaced at all.

The Tidy up card is last on the home screen and deliberately unbadged. Every suggestion is the app asking for maintenance on its own data model, and nagging about that inverts the relationship. If it is never opened, everything keeps working.