Planet4.Me docs
Open the app

#Modules

#src/lib/types.ts

Every domain type as plain data. Timestamps are Millis — epoch milliseconds from the device clock, never a Firestore sentinel. Contains the persisted document shapes (StapleDoc, TaskDoc, NoteDoc, ListItemDoc, SessionDoc, StoreDoc, HouseholdDoc, UserDoc, PurchaseDoc), the value objects they are built from (PurchaseEntry, Season, Recurrence, LatLng), and the projections the pure engine consumes (StapleSnapshot, CadenceResult, AutoAddCandidate).

#src/lib/time.ts

Local-time date math: DAY_MS, daysBetween, intervalDays (rounded to whole days, minimum 1), addDays, monthOf, startOfLocalDay, dayKey, and addMonthsClamped (Jan 31 + 1 month lands on Feb 28, not Mar 3).

#src/lib/cadence/ — the recurrence engine

Client-side arithmetic over at most twenty numbers per staple. computeCadence is the entry point; cadenceFor adapts a StapleSnapshot to it.

ModuleHolds
statsmedian, mad, regularity
seasonmonth-range predicates that wrap the year, and crossesSeasonBoundary
intervalsgaps between purchases, seasonal exclusion, the six-interval window, the per-unit series
confidencethe 0.20 / 0.45 regularity bands
predictprior blending, per-unit horizon, computeCadence, cadenceFor
leadTimetrip cadence from store visits, and the two-day fallback
autoAddwhich staples earn a place on a list today
dormancywhen a staple has gone quiet, with the seasonal exemption
snoozesnooze horizon and the six-hour duplicate-check-off window

A CadenceResult carries n, medianDays, madDays, regularity, effectiveIntervalDays, nextDueAt, confidence, and inSeason. medianDays and madDays describe observed real-world gaps; regularity describes the per-unit series the prediction is built from, so it is deliberately not madDays / medianDays.

#src/lib/normalize/

canonicalize reduces a typed string to a resolution key: lowercase, depunctuate, drop quantity and filler tokens, drop a trailing "for …" clause, and singularize conservatively. Percentage variants such as 2% survive as variant markers, so 2% milk and milk stay distinct — whether they are the same staple depends on what else the household buys and is not decidable from the strings. Fuzzy matching is src/lib/resolve/'s job, layered on top at add time; the normalizers themselves stay deterministic string surgery.

#src/lib/sessions/

boundaries holds the 75-minute inactivity threshold, the strict comparison that keeps a session open at exactly 75:00, and the backdating of closedAt to the last activity. (The checked-item listener's window used to live here as a constant; it is the household's to set now — see src/lib/lookBack/.) storeInference holds haversine distance and inferStore, which returns a match, an explicit ambiguity, a nearest (within two kilometres — close enough to pre-select as an answer to correct, never to apply on its own), or nothing — a 150 m match radius with an 80 m collision radius, so two stores in one shopping centre produce a question rather than a guess.

#src/lib/lookBack/

How far back the app reads, as arithmetic. LookBack (in types.ts) is what the household document stores: days, one number for everything; overrides, the switch that puts the surfaces' own numbers in force at all; and surfaces, a per-surface number or null for "same as everything", over five surfaces — trips, calendar, people, questions, todos. readLookBack is the total reader (the rules let any member write any shape here, so a string, a negative or a missing map all read as something the app can run on; a document from before the switch reads as switched on exactly when a surface holds a number, so nobody's window moves on upgrade); clampDays rounds and clamps to whole days in [1, 365]; lookBackDays / lookBackStart answer one surface; lookBackWindows answers all five at once as {days, since} pairs for a now; parseDays is the Settings row's parse (blank, digits, or invalid — not clamped, the data layer clamps). notesSince / tasksSince / questionsSince are the filters every seam applies, inclusive at the boundary, each by the one stamp a document ages by — a note by updatedAt, a to-do by createdAt (it has no other), a question by askedAt — and each hands the SAME array back for a null window, so a memo keyed on the result holds. Ninety days is the default and it is fixed, not "unlimited": a household's rhythm fits in three months, and it is what the checked-item listener can carry without reading the household's whole shopping life at boot.

Two rules every consumer follows. A document is never hidden by the window — notes stay on their shelves, to-dos on their lists, each opens as it did; only what is DERIVED from them forgets. And the window is fixed per household snapshot (useLookBackWindows reads Date.now() inside its memo), the checked listener's own rule made everyone's: a boundary that slid with the clock would re-run every derivation on every render for a change nobody could see.

#src/lib/recurrence/ and src/lib/promotion/

nextOccurrenceAt walks a local calendar grid anchored to the rule, never to when a task was completed. promotionCandidates groups manual adds by canonical name and offers to track anything added three times — within the household's trips look-back, which is what the checked-item listener carries (ninety days unless Settings says otherwise; src/lib/lookBack/), plus whatever is open now.

#src/lib/notes/

What the Notes screen shows, as arithmetic: the archived split, keyword matching, the three sort orders, the one-line preview under each title, and noteText / splitNoteText — the seam that turns a stored title and body into one editable surface and back. noteText stands the title apart with one blank line; splitNoteText absorbs that one line, so the separator is never stored and every blank line past it belongs to the writer. Matching is substring over folded text (lowercased, combining marks stripped, whitespace collapsed) and every whitespace-separated token must appear somewhere, though not in the same field.

It deliberately does not reuse canonicalize or trigramSimilarity. Both are right for their own jobs and wrong for prose: canonicalize strips quantity words and singularizes, so a note holding "300g strong white flour" would stop being findable by "300g"; trigramSimilarity ranks short entity names, and over a multi-paragraph body the trigram set dilutes, so a note gets less findable the more is written in it.

detectFacts (facts.ts) scans prose — note bodies and to-do detail lines — for phone numbers, emails, URLs and street addresses, conservatively and in a fixed claim order (email, url, phone, address; a span claimed once is dead to the rest), returning every deduplicated fact in the writer's order, each with the tel: / mailto: / https:// / maps href a chip navigates to; factChips is the first six (MAX_FACTS), the strip's ceiling, and the note shelf's closed line counts the whole. Tuned to miss before it lies — see DECISIONS, "A false chip is worse than a missed one".

captureNoteTodos (todos.ts) scans a note's body lines — never its title line — for lines opening with a to-do marker (todo / to-do in any case behind at most a list bullet, then a colon, a spaced dash, or whitespace; the two-word to do only with its colon), returning the titles to create and the text with each captured line pinned (now a to-do). The pin in the text is the idempotency, exactly as the pinned dates carry theirs — see DECISIONS, "A to-do typed in a note is captured, and its line is pinned". A note's area IS the to-do list its captures land on — one value, no translation, since canonicalGroupId collapsed the one pair that used to need it. todoChips walks the pinned lines back the other way: each line whose to-do is still OPEN on the note's own list yields a chip carrying the task's id and real title — matched by exact title first (the line is the very text the task was created from; canonicalize strips numbers, so "pack box 2" and "pack box 3" are canonical twins), canonical equality as the drift net. A completed, renamed-away or deleted task yields no chip. stampCompletedTodos shares that exact resolution (one resolveTodo, so the chip and the stamp can never disagree): completing a to-do rewrites each line's pin to "(done Sep 3, 2026, 2:41 PM)" — locale-free, device clock — and capture skips any "(done …)" parenthetical, so a completed to-do can never be resurrected by the note's next save. todoSources reads the same lines backwards — task id → the note(s) whose pinned lines resolve to it, archived notes included — for the task row's mark and its way-back chip.

completionStamp.ts and answeredStamp.ts are the two stamp SHAPES, each a handful of lines importing nothing (the second takes only Span as a type). They exist as their own modules because several scanners must agree about a shape and none may import another: the to-do capture and the question capture both refuse to re-capture a stamped line, and detectDates refuses to chip the date inside one — a completion and an answer are records of the past, and these scanners read a note for what is still ahead of it. Both are matched loosely (any (done …) / (answered …) parenthetical), so a hand-typed one earns every refusal too. The WRITERS live with the features that need the pins' day format — stampCompletedTodos in todos.ts, stampAnsweredQuestion in questions/ask.ts — which is what keeps these two from ever depending on the date layer that depends on them. Fixtures bind each pair: a test stamps a line and asserts every scanner leaves it alone.

#src/lib/groups/

The shared group namespace as arithmetic: groupSlug (a name folded to one comparable form, exactly as search folds — no longer an identity, only how sameGroupName tells two names apart, and kept in preference to foldForSearch because the two induce different equivalence classes and it is the slug's that says "School-run" and "School run" are one name), canonicalGroupId (the one value a shelf is stored under — 'home' folds onto 'personal', applied inside readTask and readNote so nothing above layer 2 meets both words; see DECISIONS, "One shelf, one stored word"), groupName (built-ins are Personal and Work on both surfaces, on either spelling — see "The first shelf wears a shared household's name" under Households for what a shared household calls the first and does with the second; a custom name comes from its document, and a group with no document is UNNAMED_GROUP — "Untitled group" — because an opaque id is not a name and showing one would put a key on screen), rejectGroupName (blank, a reserved NAME — Personal or Work, not Home, which is a reserved id only — a duplicate name, or a name alive on the other door, which is refused and pointed at the chip that joins it deliberately), rejectGroupRename (the same, minus the id-shape rules, which belonged to the birth of a slug id; excludes the group itself, scans every door, and accepts a ghost id whose document is gone), taskGroupCards / noteGroupCards (ONE SCREEN's cards, flat and by name: the DOOR (level: null) shows the built-ins first — Personal then Work, Work only where it is a shelf (builtinShelves) or holds something on this surface — then the top-level customs, and groups only, never content; inside a top-level group, its subgroups; inside a subgroup, nothing, by the ceiling. Declared on the surface OR implied by content, so nothing an id still names can become unreachable, and a group brings its PARENT onto the door with it. Counts are ROLLUPS — the group plus everything filed under it; only a custom empty through its whole subtree and holding no children is removable, and archived notes hold a notes group open), canHoldGroups (whether a group's screen may hold groups at all — the writers' half of the ceiling, asked before a screen renders a group section or a New group box; false for a group whose document is not in hand, a ghost or one not yet loaded, since nothing could be filed under it anyway), and notesAreaPath / tasksGroupPath (where a group lives).

tree.ts beside it is nesting as arithmetic: groupParent, groupAncestors, groupDepth (0 or 1, never more), fileTargetTree (every place a to-do or note can be filed, for the row's move sheet — Personal, Work, then the customs, each with its own children under it, and a child whose parent is not itself a destination lifted to the top rather than lost), groupSubtree (self-inclusive, because every caller wants the folder counted with what it holds — and, at two levels, just the group and its direct children), groupParentChoices (the built-ins and the top-level customs, flat), and rejectGroupParent — self, cycle, missing parent, a destination that is itself a subgroup, and a group that holds groups trying to leave the top. All flat checks, which is what MAX_GROUP_DEPTH (2) buys.

Two levels, and the ceiling is in the data model. groupParent refuses to RENDER a third level as firmly as rejectGroupParent refuses to write one: a parent that is itself a subgroup, a dangling parent, or a cycle all read as null, so however a document is mangled every group stays reachable at depth 0 or 1. That is what makes breadcrumbs, tree pickers and recursive move validation not merely avoided but inexpressible — every list of groups in the app is flat.

The cap and the acyclicity are product rules, not boundaries. Firestore rules cannot traverse a parent chain and this app's are membership-only by design, so rejectGroupParent runs on one device with one snapshot and two phones can write a cycle between them regardless. Every reader here is therefore total against data that breaks the rules: a chain that returns to where it started is not a chain (groupParent answers null, so BOTH ends of a cycle render as roots — visible and fixable — rather than as children of each other, which would make both vanish), a parent naming a group that does not exist is no parent, and every walk is bounded by the group count.

#src/lib/groups/search.ts

The level search as arithmetic. levelScope says what a screen's level covers — a group and everything under it (groupSubtree), or null for the door, which covers everything on its surface, ghost ids included — and inLevel asks whether a document's group is inside it. searchGroupCards narrows a level's cards to the names containing the query, folded as search folds, and from the door reaches one level further down, each found subgroup saying where it lives (In Renovation) in place of a hint; the cards come from the surface's own builder, so a found card carries its own level's count, removable flag and hold sheet. An empty query is no search and hands the level's cards back untouched. matchesQuery (in src/lib/notes/) is the one text rule both surfaces match documents with.

#src/lib/people/

@ mentions found in prose, under the chip scanners' charter. detectPeople (detect.ts) claims one token per mention — letters, digits, hyphen, underscore, opening with @ and ending at whitespace or punctuation, so @sam. is Sam and a full stop — and never guesses at a bare capitalised word. The fact shapes go first as refusals: sam@work.com is an email and the @sam inside a pasted URL chips nobody. There is no orchestrator sharing claims across scanners, so the agreement with detectFacts is bound by fixtures feeding both scanners the same strings, the completionStamp way. The known people arrive as an argument (calendarEntries' pattern) and stay a pure input; resolution folds the handle against every name and alias, greedy only where that is safe — @sam chen claims two tokens only when somebody already answers to that two-word fold. An unresolved mention comes back with personId: null, the unborn state the note's save pass turns into a person. personAppearances and peopleNamedIn (appearances.ts) take the household's people-and-tags look-back as since: a note last written, a to-do made or a question asked before it is not read, so a person's page and the directory's group filter forget the same texts. The people themselves are documents and are never windowed — the roster, the merge and every resolution see everyone. coMentioned and sharedFacts (related.ts) are the page's two crossings, over the same live texts and the same window: everyone else the texts naming the person name, most often first (a question counts when it is theirs, and an addressee naming them counts back), and the facts in those texts — by href, detectFacts' own identity — that texts NOT naming the person also carry, each with those texts, newest first. A fact only the person's own texts carry is left out: it leads nowhere new. me.ts is the account's link to a person (personForUid) and the readings over it (mentionsPerson, questionFor, youLine), see the Firestore section on people.

fold.ts holds the handle fold — lowercased, -/_ read as spaces, whitespace collapsed, and what the handle grammar cannot carry gone: accents stripped, apostrophes and punctuation dropped, the Latin letters that decompose to nothing spelled out (Sam O'Brien, José and Søren fold to sam obrien, jose, soren, the folds of the tokens the @ menu types for them; letters of any script survive) — which is WITHHELD from the public contract for groupSlug's reason: it is how prose resolves against what exists, never a key anything may derive identity from. The barrel exports the operations built on it instead: nameFromHandle (what a bare handle births), insertHandle (the typed token the @ menu inserts — a word resolved back through the fold, never an id: "Sam Chen" → sam_chen), handleFor (that token whole, with the nickname glued on in parentheses when there is one — sam_chen_(Sammy) — so the text says both what they are called and what the household calls them, and either half resolves), and renamedPerson / mergedAliases, the alias arithmetic that lets a rename or a merge cost no prose because the mention in the text is the link.

#src/lib/questions/

The ask marker, through the to-do capture's machinery (ask.ts). captureNoteQuestions scans a note's body lines — never its title — for lines opening ask @sam: … / ask @sam … (the @ makes the colon optional) or bare ask: … (colon required — "Ask the plumber about the boiler" is prose), returning the questions to create, each carrying the handle after ask @ or null for a question addressed to nobody, and the text with each captured line pinned (now a question). The pin is the idempotency, the to-dos' own idiom; capture refuses completion-stamped lines, so a stamped to-do can never double as a question. Tags stay in the captured text — they belong to the line and are derived, never stored. questionChips walks the pinned lines back: each line whose question is still OPEN from this note — unanswered and not archived (sourceNoteId scopes it; a question knows its note, so no list-matching) — earns a chip with the question's id and real text. One private resolveQuestion — exact text, then a whitespace-collapsed lowercase fold (deliberately not canonicalize, which is grocery-tuned) — is shared by the chips and the answered stamp, resolveTodo's precedent, so the two can never disagree about which question a line means. stampAnsweredQuestion writes the answer back over the pin in the shape ../notes/answeredStamp reads: a short answer rides the line ((answered Sep 3, 2026: two years from install)), while one over ANSWER_INLINE_MAX, or carrying parentheses or a newline the one-line stamp cannot hold, leaves the date alone in the line and the body on the document. Distinct from seasonal/profile's household-profile questions ("do you have a fireplace"), which interview the household about its purchases; these are questions the household asks its own people.

tags.ts is the crossing axis. detectTags reads #renovation off a question's own text on every render — the handle's grammar with a #, one token, deduped by fold in the writer's order, and refusing a purely numeric token so "issue #4" tags nothing. Nothing is stored, so there is no array to drift from the words and editing the question edits its tags — the pinned dates' seam. tagGroup(tag, groups) answers the group a tag names, built-ins included, or null: a LOOKUP over the live documents through sameGroupName, never a derivation of an id from a name, which is the distinction groupSlug's withholding protects. A tag that resolves navigates to that group's notes; one that names nothing is a concept that groups questions and filters /questions?tag=, with no document and nothing to clean up — and the day a group of that name is made, every question carrying the tag starts pointing at it with nothing rewritten. sameTag is exported for the same reason sameGroupName is: an index or a filter comparing tags any other way would invent a second equivalence. textWithoutTags renders a question's sentence with the tag tokens taken out, for the rows that carry those tags as chips beside it — the handles' own bargain, where the text carries the value and the chip renders it, so nothing is read twice. The stored text is never touched.

#src/lib/tasks/

taskEditText / splitTaskEdit (edit.ts): the task row's edit surface, which is the note page's pattern through the notes' own seam — the first line is the title, one blank line stands it apart, the details follow. These two carry only the task-shaped edges: a blanked first line keeps the current title (a to-do cannot be called nothing), details are end-trimmed and cleared to null rather than '', and a detail-less task seeds as "title\n\n" so the editor's end-of-text caret lands ready for the detail.

lineContextAt / NamingContext (src/lib/context.ts): the line that put a row somewhere, split at the span that did it — one slicer shared by the three surfaces that derive references from text (the calendar's days, a person's appearances, a to-do's way back). The preview quotes the line rather than naming the document, because a title says which note and the line says what is happening. Withheld wherever the line would only read the row back: a date in a title, a recurring slot's rule, and a captured to-do whose line is its own title plus a marker.

selectTasks / taskTagIndex / TASK_SORTS (select.ts): what the flat view shows, the shape selectNotes set — one pure entry point doing the filtering and the sorting together, so no screen reimplements either. Open to-dos only, narrowed by a group set the caller expanded through groupSubtree and by a tag through sameTag, ordered newest-first, A–Z, or by day with the undated last (Infinity, so a to-do with no day sorts after every dated one rather than burying them).

#src/lib/dates/

detectDates (detectDates.ts) scans the same prose for the days it names, under the same charter and the same architecture (claim spans, fixed pass order, dedupe by destination day, writer's order; every day, and dateChips is the first six, MAX_DATES, the strip's ceiling — the note shelf's closed line counts the whole, and the calendar carries every day a note names). It claims ISO 2026-08-28, slashed dates only with a four-digit year, capitalised month-name forms in either order (yearless ones roll forward), and the relative phrases today, tomorrow and in N days/weeks/months. It refuses, by name: bare m/d (recipe fractions), two-digit years, weekday names, lowercase months, bare ordinals, and month-or-year alone. It also refuses a span outright, whatever it holds: a to-do's completion stamp, "(done Sep 3, 2026, 2:41 PM)". That shape lives in notes/completionStamp.ts — its own module, because the to-do capture reads it too (a stamped line never re-captures) and neither scanner may import the other — and pinDates walks the same refusal, so a stamp is never rewritten either. The two scanners never contest a span — the phone pass refuses date shapes by name, and the forms claimed here contain nothing the fact passes want.

A relative phrase resolves against the anchor the caller passes — the document's own write time — and is pinned at save: pinDates appends the resolved day in parentheses ("in 3 days (Aug 28, 2026)", year always included), and from then on the parenthetical is the value; the relative words are never re-resolved, so editing the document cannot move the day. The text itself carries the date — no schema, offline-merge safe, user-editable. Day-stepping walks the local calendar (addLocalDays), never milliseconds.

A day reaches the calendar only in brackets. [Sep 5, 2026] is a commitment; a bare "Sep 5" is a mention. DetectedDate.committed carries the difference — the test is adjacency, the character before the claim's span and the one after it, which works unchanged for [tomorrow (Sep 5, 2026)] because a pinned phrase's span already swallows its own parenthetical. Both kinds still get a chip, because a mentioned day is worth a glance; only a committed one gets a square. Where one day is said twice, the committed phrasing wins the strip's dedupe however late it comes, or the document would carry a commitment the calendar never saw.

commitLabel writes the brackets, from pinnedDayLabel, so writer and reader stay the same code. newlyMentioned(previous, next, anchor) answers the one question the offer needs — did this save add a day the document did not have, and not commit to it — by comparing whole texts rather than remembering anything. pinDates still pins inside brackets, and rescheduleDate needs no bracket handling at all: it rewrites the claim's span, which sits inside them.

Old documents were rewritten, once. migrateToBrackets (src/data/) is the app's only real data migration, and it exists because the usual answer does not reach here: everywhere else a schema change is absorbed by a total reader's default, and the reader IS the migration — but these days live in prose, so prose had to change. It reads every note and to-do, pins each against ITS OWN anchor and then brackets what it finds (bracketDates), batches the writes, and stamps household.bracketsAt last. Pinning first is load-bearing: the write moves a note's updatedAt, which is the anchor an unpinned "tomorrow" resolves against, so bracketing without pinning would change the day in the same breath it recorded it.

It runs from the client (useBracketMigration), because this app has no operator to run a script. That is safe because the pass is idempotent (a bracketed day is skipped, so a second device writes nothing), resumable (the stamp lands only after every write, so a half-finished run resumes), and faithful (every day that filed before still files after). Households created since are born stamped, so they never run it.

calendarEntries and monthGrid (calendar.ts) derive the Calendar screen: text dates from live notes and open tasks, plus recurring tasks' slots walked forward from each task's stored pending nextOccurrenceAt — the pending slot renders even when its day has passed; slots before it are not invented. Every entry carries the GROUP its document lives in — never null, where the field it replaced threw a note's away — and only narrows the whole derivation to a set of group ids, which the screen builds from groupSubtree so that filtering to a folder means everything filed inside it. since is the household's calendar look-back, and it is applied to the DAY as much as to the document: an entry leaves only when the document AND the day it names are both behind the window — an old note's plan for next winter is still a plan, while the day it filed last spring is a record. Recurring slots are exempt: a weekly chore made a year ago is re-armed by every completion, so when it was made says nothing about its age. pinnedDayLabel exports the pin's day format ("Sep 3, 2026") for the one surface that writes a document FOR a day — the calendar's own capture — so the writer and the scanner are the same code.

rescheduleDate is the reverse: it moves a day IN the text that names it, replacing every phrase claiming that day (a pinned phrase whole, parenthetical and all, since the pin is the value and the words in front were only its input; ISO stays ISO). It shares the scan with detectDates, so the day a chip shows and the day a move rewrites cannot be found by two different rules, and answers null for text that does not name the day — which is how a caller learns whether the day lived in a title or a body without scanning twice. nextDayFor (calendar.ts) is where "the next day" is: one day on for something still ahead, tomorrow for something whose day has gone, the clock time carried along for a recurring slot.

uncommitDate takes a day off the calendar the same way: it is the inverse of bracketDates, removing the brackets round every phrase committing to that day and leaving the words exactly where they were, so "call the vet [Sep 4]" becomes "call the vet Sep 4" — off the calendar, still chipped because the note still names the day, and committed again by typing the brackets back. It shares the scan and the null contract with rescheduleDate, and frees EVERY committed phrase on the day for that function's own reason: one entry per day per document, so a second pair of brackets left standing would leave the entry where it was. A phrase that merely mentions the day is untouched — it was never on the calendar to take off it. Only the two characters go and nothing around them moves, which is a rule you can predict by looking at the text; the price is that brackets doing some other job around a date come apart like any others, which is isBracketed's pure adjacency showing through rather than anything this pass decides.

#The public contract

src/lib/index.ts is the surface layers 2–4 may use. It deliberately withholds the engine's primitives (median, purchaseIntervals, blendPrior, confidenceFor, …) so that no consumer can assemble a second cadence path that skips prior blending, per-unit scaling, or the seasonal gate. groupSlug is withheld for the same kind of reason: it folds a name for sameGroupName to compare, and a consumer holding it could turn a name back into a document id — the derivation opaque ids exist to end. src/lib/layering.test.ts enforces both the purity of layer 1 and the withheld surface mechanically.