Malek Aldoss

Tend

Case study · personal product · 2026 Live on the App Store · v1.2.0

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.

01

The eight words

src/constants/words.ts

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

1goldenrare and bright#E9CF7E
2sunnybright#F0B87A
3mildsettled#EEA383
4cloudyneutral#99AAB8
5mistveiled#789DD7
6fogdistance#7B8DD2
7stormfriction#9975C4
—clearingrecovery, off‑spectrum#6FAE9A

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

golden sunny mild cloudy mist fog storm clearing

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.

02

Architecture

Expo SDK 57 · Firebase · Node 20
The recap generation path A left-to-right pipeline of six stages. The client fires a callable with a 600 millisecond debounce. The Cloud Function first runs its gates (entitlement, the week-one floor, a transactional lock and a freshness check), all of which sit before any paid call. It then fetches seven Firestore reads in parallel, reduces them to a capped digest, computes every number in a deterministic insights engine, and only then hands the model a bounded plain-text brief to phrase. The result is written to a recaps document, which the client is already subscribed to. EVERYTHING BEFORE THE PAID CALL CLIENT useRecapTrigger httpsCallable 600ms debounce GATES entitlement · solo week-1 floor · lock freshness · freeze FETCH 7 parallel reads range-queried on doc id and date DIGEST buildDigest() capped, bucketed across the window INSIGHTS insights.ts · 315 LOC pure arithmetic every number MODEL brief.ts → recap-flow Gemini via Genkit phrasing only recaps/{periodId} · the client is already subscribed, so generating → ready arrives with no polling the model may not derive a quantity that is not read from here
The recap path. Seven Cloud Functions exist in total, and only one of them, requestRecap, 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.
03

Compute, then narrate

functions/src/insights.ts

The 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
InsightComputesFloor
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.

04

The truncation incident

functions/src/recap-flow.ts

Incident · 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.

SettingBeforeAfter
thinkingBudgetunbounded256
maxOutputTokens · week10242048
maxOutputTokens · month10244096
maxOutputTokens · year10248192
finishReason 'length'repairedthrows

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.

05

The digest is ETL

functions/src/digest.ts · 644 LOC

Strip 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 snippets6 / 10 / 2 a month
Moment snippets4 / 6 / 1 a month
Flag snippets3
Day sequence40 days
Parallel reads7
Recovery episodes3

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.

06

Cost engineering

every gate sits in front of the paid call

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

The gates in front of every model call
GateRuleWhat it prevents
01Client debounce 600 ms settle before the callable fires, so the periods you scrolled past are never billed
02Entitlement Checked server-side, defaulting closed. The source note: if the two ever drift, drift in this direction
03Week-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
04Data floor 3 days for a week, 6 for a month, 20 for a year. Below that the recap declines rather than reaches
05Transactional lock A Firestore transaction with a two-minute TTL returning serve, in progress, or generate, so two devices asking at once pay once
06Daily regeneration cap At most once per calendar day, in the couple's own timezone
07Period freeze A finalised period regenerates exactly once, when its end date finally matches the real period end, then never again
08Window validation Future and pre-pairing windows are rejected, so a hand-rolled call cannot walk backwards through invented history billing a call per step
07

Authorisation as schema

firestore.rules · 257 LOC

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

08

Correctness under money

revenueCatWebhook · entitlements.ts

A 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
1Live couple record
2Live own record
3Live SDK snapshot
4Lapsed server record
5Dev override
6Default: 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.

09

Gates that actually run

scripts/ · 1,200 LOC

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

10

Scope & method

2026-07-07 → 2026-09-25

Measured

main at 2026-09-25
Commits43311 weeks
Pull requests134merged
App source37,401LOC · 190 files
Functions7,797LOC · Node 20, incl. tests
Rules257LOC
Build scripts1,200LOC
Routes36expo-router
Collections13Firestore
Tests104backend 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.