Tend
Two partners each privately pick one word a day for the weather between them, from a fixed vocabulary of eight. Neither sees the other's until both have logged. A year of that is a calendar of colour.
I designed and built it alone, and it has been on the App Store since 13 September 2026: an Expo client, a Firebase backend, seven Cloud Functions, and a recap system whose entire architecture exists to make one thing impossible.
The eight words
src/constants/words.tsThe product replaces the free-text box that every journalling app defaults to with a fixed vocabulary of eight words. That constraint is the whole product: it is what makes a year of a relationship renderable as a single field of colour, and it is what a neighbouring app cannot copy without adopting the same limitation.
The words are weather. The first vocabulary was a set of
first-person verdicts (close, warm, secure, steady, off, distant, tense), and its bad
end pointed at a person. Your partner reads your word, so distant landed as an
accusation, and people under-logged exactly the days the app exists to catch. Weather
moves the claim into the space between two people, which neither of them owns:
distant is something a person is; fog is something that happens
to a place.
The colours are data design, not a palette. Seven rungs run as one monotone ramp,
bright to dark and warm to cool, with adjacent bands an even ~1.16 apart in
luminance, so greyscale order equals ladder order and the scale reads with no colour
vision at all. clearing sits off-spectrum on purpose: it is a direction
rather than a rung. Every fill and ink is identical in the light and dark themes, and
an executable check asserts that every band stays clear of the empty tile.
Seven rungs bright to dark, plus one off-spectrum direction. Every word's name is real text on its own fill and clears 4.5:1 there, so the colour is never the only way to read it.
One month, in the eight fills
This is the artifact the whole product exists to produce, and it is the reason the vocabulary is fixed at eight words. A free-text box cannot be rendered. A constrained one can be read at a glance, across months.
Notice what the ramp buys: the difficult stretch in week three is
darker as well as cooler, so it reads without relying on hue at all. The single
clearing tile is the only off-spectrum colour on the grid, which is why a
turning point is visible from across the room.
The eight fills are the shipped values from
src/constants/words.ts. The pattern of days is authored demonstration
data, not a real couple's record.
Architecture
Expo SDK 57 · Firebase · Node 20requestRecap, generates recaps. The two that run on a schedule,
sendScheduledBeats and drainPushReceipts, only send and
reconcile push notifications, so a recap still exists only because a client asked for
one.
Compute, then narrate
functions/src/insights.tsThe recap is a warm paragraph about a couple's last week, month or year. It is also, structurally, a report full of quantities: an agreement rate, a trend against the prior window, the weekday that ran roughest, how a dip was recovered from. A language model asked to produce that paragraph will happily produce the quantities too, and it will sometimes be wrong.
Being wrong about a number in a spreadsheet is a bug. Being wrong about a number describing someone's relationship is something worse. The line written into the source is the design constraint:
A model that computes hallucinates relationship claims.
So the system splits in two. insights.ts is 315 lines of pure
arithmetic, with no Firestore, no clock and no model, and it is the
single source of every number. brief.ts turns the digest and those facts
into a plain-text brief, and recap-flow.ts hands it to the model with a Zod
output schema. The model's only permitted job is to phrase what the brief already
says.
The discipline shows up in what the engine refuses to say. Every insight
carries a hasEnoughData flag, so a thin period stays silent rather than
reaching. GapDirectionFacts, which partner held the warmer word on days
they diverged, deliberately ships without a leader field,
so no interface downstream can ever rank one partner above the other.
The four insights
and what silences each| Insight | Computes | Floor |
|---|---|---|
| Convergence | agreement rate, trend vs prior window, bucketed series | dead band 0.05 |
| Gap direction | who held the warmer word on divergent days | no leader field, by design |
| Recovery | dip episodes, and whether recovery came via clearing |
max 3 episodes |
| Weekday | roughest and warmest weekday | min 3 samples per day |
The weekday floor is the key row because it is the one people
assume you skipped: one bad Tuesday is not a pattern, and three samples is the price of
saying it is. clearing is excluded from the spectrum array the gap maths
runs over, because it is a direction rather than a rung.
The truncation incident
functions/src/recap-flow.tsIncident · a recap of literally ... reached a real couple
Root cause found
The generation call ran with a flat maxOutputTokens ceiling of 1024
against a thinking model. Measured against the sample digest, the
model spent 981 tokens reasoning and had 28 left to write
with. It stopped on finishReason: 'length', mid-word.
That alone would have been a visible failure. What made it a silent one is that
the truncated JSON then repaired, into a one-fragment narrative with the
nudges array missing entirely. A repaired truncation is
indistinguishable from a real answer, so nothing threw, nothing logged, and the
document was written as ready.
The fix is three parts, and the third is the one that matters: bounding the
thinking budget halved total token use for equal prose quality, raising the ceiling
per period gave the writing room, and throwing on
finishReason === 'length' is what makes the failure loud
forever. A pipeline that can silently repair its own corrupt output does not have a
token budget problem, it has an observability one.
| Setting | Before | After |
|---|---|---|
thinkingBudget | unbounded | 256 |
| maxOutputTokens · week | 1024 | 2048 |
| maxOutputTokens · month | 1024 | 4096 |
| maxOutputTokens · year | 1024 | 8192 |
finishReason 'length' | repaired | throws |
Failure handling downstream follows the same rule. A failed
generation never leaves the document in generating, and it restores the
prior document only if that one was ready; otherwise it deletes.
Restoring a stale not_enough_data would dress an outage up as "you
haven't logged enough", which is the one thing that status must never imply.
The digest is ETL
functions/src/digest.ts · 644 LOCStrip the subject matter away and the recap path is an extract-transform-load job
with a hard payload budget at the end of it. Extract: seven Firestore reads issued in
parallel: the couple document, logs range-queried on FieldPath.documentId(),
gratitudes, moments and flags range-queried on date, plus the events and profiles
collections. The prior equal-length window is fetched separately to give the
convergence trend a baseline.
Transform: a pure reduction into a compact object with explicit caps. The sampling rules are where the domain leaks into the pipeline in a good way. The window is cut into even buckets rather than read from the front, so a recap is not just a summary of the first three days, and each bucket's pick is scored for substance and notable days, with both partners guaranteed a voice. Flag snippets prefer resolved ones, because the recap's register is "came back from it", not re-litigating the argument.
My favourite function in the file exists purely to refuse to pass data
along. streakAsOf() will not hand the model
couple.currentStreak, because that counter does not decay. When the streak
cannot be known it returns undefined and the streak is left out of the
brief entirely, rather than 0. Telling a couple their streak is nothing
when it was twelve is its own kind of wrong answer.
Payload budget
week / month / year| Gratitude snippets | 6 / 10 / 2 a month |
|---|---|
| Moment snippets | 4 / 6 / 1 a month |
| Flag snippets | 3 |
| Day sequence | 40 days |
| Parallel reads | 7 |
| Recovery episodes | 3 |
The forty-day cap is the key row because it is what makes a yearly recap possible at all: past it, the day-by-day sequence is dropped and the model works from the bucketed series instead.
Cost engineering
every gate sits in front of the paid callAn LLM feature is the first thing most teams ship that costs real money per interaction, and the recap archive is scrollable: chevrons can walk a year of periods in a few seconds. Each of these gates exists because of a specific way that bill could have run away, and every one of them evaluates before the model is reached. One deliberate exception came later: a narrative that echoes a flag back gets a single retry, so one generation can cost two calls.
| Gate | Rule | What it prevents |
|---|---|---|
| 01 | Client debounce | 600 ms settle before the callable fires, so the periods you scrolled past are never billed |
| 02 | Entitlement | Checked server-side, defaulting closed. The source note: if the two ever drift, drift in this direction |
| 03 | Week-one floor | Seven tended days, counted inclusively. It shipped counting elapsed days, contradicted the app's own streak counter, and now has a test pinning it |
| 04 | Data floor | 3 days for a week, 6 for a month, 20 for a year. Below that the recap declines rather than reaches |
| 05 | Transactional lock | A Firestore transaction with a two-minute TTL returning serve, in progress, or generate, so two devices asking at once pay once |
| 06 | Daily regeneration cap | At most once per calendar day, in the couple's own timezone |
| 07 | Period freeze | A finalised period regenerates exactly once, when its end date finally matches the real period end, then never again |
| 08 | Window validation | Future and pre-pairing windows are rejected, so a hand-rolled call cannot walk backwards through invented history billing a call per step |
Authorisation as schema
firestore.rules · 257 LOCFirestore has no schema. src/types/models.ts is the schema, and
firestore.rules is the enforcement, because Cloud Functions run on the Admin SDK
and bypass rules entirely, so the rules file is the authorisation layer for
every client write in the product.
Writing it as a data-modelling exercise rather than a checklist is what makes it short. The strongest rules are the ones expressed as constraints on a diff rather than on an identity: a member may write to the day's log only if the set of changed keys in the entries map is exactly their own uid. The same shape is reused for resolving a flag.
The pairing claim is expressed the same way, and never became a Cloud Function. A
second partner may join a couple only if the members array currently has one entry, the
proposed array is exactly the existing member plus the caller, and three
get()s against the invite prove it is pending, matches the couple, and has
not expired.
The threat model is written into the file, your own partner, not a stranger, along with the loosening that model licenses. That is the part most rules files leave implicit, and it is the part a reviewer actually needs.
Three rules worth reading
| Self-key diff | entries.diff().affectedKeys().hasOnly([uid]): you may mutate your own key in the map and no other |
|---|---|
get, not read |
Invites allow get deliberately. read implies list, which would let any anonymous user enumerate every invite code, creator and couple id, and hijack pairing slots |
| No self-granting | Users and couples may be updated only if the changed keys exclude plus. The subscription field is written by the server alone |
Thirteen collections: users,
couples, invites, a server-only pushReceipts,
and nine subcollections under a couple. Recaps are allow write: if false;
they only ever arrive from the server.
Correctness under money
revenueCatWebhook · entitlements.tsA subscription for a couple is not a subscription for a user, and almost every bug in this area comes from that gap. One partner pays; both must be entitled; either may lapse; and the person who did not pay must never lose access because the person who did let their card expire.
The webhook is deliberately conservative about what revokes.
EXPIRATION does. CANCELLATION only means auto-renew is off,
and BILLING_ISSUE opens a grace period; revoking on either would lock out a
paying customer. Events carry a timestamp and are checked for ordering, because a
renewal overtaking the cancellation before it shows up as a customer randomly losing
access.
The entitlement is written in two places: the user document, and mirrored onto the couple. That mirror is what makes "one plan covers both of you" literally true rather than aspirational. Revoking clears the couple mirror only if the lapsing user is the recorded purchaser. Otherwise an expiring ex-subscriber would knock out their partner's own live subscription.
Another function exists solely because of onboarding order. Someone subscribes
while still solo; their partner joins days later; the couple document has no
entitlement and no webhook is due until renewal.
mirrorPlusOnPairing fires on the couple document updating and closes that
window. That is the main path, not an edge case.
Client priority chain
entitlements.ts| 1 | Live couple record |
|---|---|
| 2 | Live own record |
| 3 | Live SDK snapshot |
| 4 | Lapsed server record |
| 5 | Dev override |
| 6 | Default: not entitled |
Tier 3 sitting above tier 4 is a shipped bug fix. In the old order: trial ends, the webhook writes inactive, the user resubscribes, and the stale record still answered, dropping someone who had just paid back into a locked app. A snapshot may only ever grant, never revoke, because only the server sees refunds. The expiry grace is 24 hours, chosen asymmetrically: over-serving a lapsed subscriber for a day costs nothing, and locking out a paying one over a drifted device clock is the worst bug this file could have.
Gates that actually run
scripts/ · 1,200 LOCA design system that is not enforced is a document, and a document loses. The app
accumulated 573 Colors.light.* references before anything
was watching, which is what a rule with no runner costs. So the checks became executable,
each with a --check mode that fails a build.
theme-lint
Bans colour literals in screens and components, and bans naming a theme by name. 155 LOC.
theme-contrast
Computes WCAG ratios across both themes and asserts six guardrails, including a ceiling, because a word at 14.6:1 on a dark page is legible and awful. 256 LOC.
hearth-loop
Rewrites the animator's Lottie delivery into a seamless loop, selecting targets by behaviour rather than layer name, idempotently. 462 LOC.
Typed analytics
Events are a discriminated union, so a mistyped event name fails tsc. That is the only automated check the app side has, so it has to catch instrumentation drift.
A hundred and four backend tests run under tsx --test
across nine modules, from the digest and the insights engine to the brief, the output
validator and the scheduled pushes. The app side has no unit tests: it is checked by
tsc --noEmit and lint, and Sentry carries the rest: masked replays,
sendDefaultPii: false, and a 0.2 production trace sample. That is an honest
description of the coverage, not a target I am claiming to have hit.
Scope & method
2026-07-07 → 2026-09-25Measured
main at 2026-09-25| Commits | 433 | 11 weeks |
|---|---|---|
| Pull requests | 134 | merged |
| App source | 37,401 | LOC · 190 files |
| Functions | 7,797 | LOC · Node 20, incl. tests |
| Rules | 257 | LOC |
| Build scripts | 1,200 | LOC |
| Routes | 36 | expo-router |
| Collections | 13 | Firestore |
| Tests | 104 | backend only |
Stack. Expo SDK 57, React Native 0.86, React 19.2, TypeScript, with the React Compiler on. Firebase for auth, Firestore and Functions. Genkit with Gemini for narration. RevenueCat for subscriptions, Sentry for errors, GA4 and PostHog for the funnel, Expo for push. EAS for builds and OTA updates on a fingerprint runtime policy.
Identity is anonymous-first. Every user is a Firebase anonymous account from first open; onboarding runs on it, and linking Apple, Google or email later upgrades the same uid in place, so nothing is lost by trying it first. The account ask is deliberately placed after the first reveal, the emotional peak, and the ritual is never gated on it.
Method. This was built with AI-assisted development, on a feature-branch-per-change workflow with 134 merged pull requests behind those 433 commits. I mention it because it is true and because the guardrails in this write-up (the executable contrast checks, the typed analytics union, the deterministic engine the model is not allowed past) are the part of that workflow I would actually defend.
Status. Approved and released on the App Store on 13 September 2026 as version 1.0, and at version 1.2.0 now. I don't quote download, rating or retention figures here.