[pdrmdrs.dev]← projects
$ git log --oneline ~/projects/sip

sip

wip

A minimalist water-tracking mobile app — log every glass toward a daily goal, no account, fully offline. This is the log: every day I touched it, what I shipped, and why.

ExpoReact NativeTypeScriptNativeWindZustand
5
days logged
30
entries
Day 05
latest
wip
status
decisionlogdraftdiagramscreenshotlink
sort
Day 01·21 Augv1 build, from a design handoff

Goal: recreate the handoff's four screens pixel-faithfully, running on a real phone via Expo Go, fully offline, no account, with intake persisted locally.

Framework — Expo (React Native), expo-router file-based routing.

Language — TypeScript.

Styling — NativeWind v4, mapping Tailwind classes onto the handoff's design tokens.

State — Zustand + persist middleware over AsyncStorage.

Scope — all 4 screens; the Sip+ paywall is UI only, no notifications, no payments.

The handoff's ios-frame.jsx bezel is presentation-only — ignore it, the real device provides the OS chrome.

Encoded the handoff's token table once in tailwind.config.js — colors, radii, font families — so screens never hardcode a hex value, and defined the three named shadow recipes (button, card, hero) as RN style objects since Tailwind's shadow-* doesn't translate reliably across platforms. units.ts and date.ts became the single source of truth for anything printed on screen — every amount goes through formatAmount, never inline ml→L/oz arithmetic in a component. The store holds only goalMl, unit, onboarded, and today's entries; intake is derived from entries via a selector rather than stored, so the two can't drift. WaterCircle and Waves carry the one genuinely fiddly bit — a Reanimated fill plus two looping SVG sine paths — and it worked on the first try.

context
The web build looked right, but scanning the QR code on the phone returned "project is incompatible with this version of Expo Go".
problem
SDK 55 was never approved for the App Store build of Expo Go, and 56/57 only ship through eas go → TestFlight — which needs a paid Apple Developer membership that isn't available here.
options
ship SDK 57 via TestFlight · downgrade to SDK 54, the newest SDK a stock Expo Go can load
decision
Pinned to Expo SDK 54 (React Native 0.81) and documented it in AGENTS.md as deliberate — don't bump the SDK without a plan for device testing.
why
It's the only SDK boundary that matches the actual delivery path: a real iPhone running the unmodified App Store Expo Go.
risk
Two knock-on breaks came with it: a react-dom override SDK 57's hoisting needed now conflicted with SDK 54's direct pin (removed), and Zustand's ESM persist/devtools bundle uses import.meta, which SDK 54's web pipeline emits into a classic script and kills the page — metro.config.js now points web at Zustand's CJS build instead.
context
Verification up to this point had only ever run through npx expo start --web in a headless browser, since there was no device in the loop yet.
problem
PressableScale wrapped Animated.createAnimatedComponent(Pressable), which drops NativeWind's className on native even with cssInterop registered — no background, border, padding, or flex-1 on any button. On web, NativeWind emits real CSS classes, so the exact same component looked fine there.
decision
Rebuilt PressableScale on a plain Pressable, using NativeWind's active: variant for the press-scale instead of an animated wrapper, and pulled the related iOS modal top-padding fix into src/lib/insets.ts.
why
The web preview cannot catch this class of bug by construction — it needed a device screenshot to surface at all.
risk
Recorded as a standing rule in AGENTS.md: any change to how a styled component is constructed — wrappers, Animated.createAnimatedComponent, cssInterop, HOCs — must be checked on a device, not believed from the browser.

Added docs/plans/2026-08-21-mobile-v1.md: the plan as agreed before writing any code, followed by an Outcome section covering the SDK downgrade and the PressableScale bug above, and the gaps still open — onboarding is unreachable once onboarded is true and there's no Settings screen to change the daily goal afterward despite the onboarding copy promising you can, Home's settings button is wired to the paywall as a placeholder, and Stats/Profile are inert tab stubs. Settings screens are next.

context
The plan's risk list flagged the wave animation and NativeWind/Reanimated babel-plugin ordering. Neither materialized. It missed both failures that actually cost time: the Expo Go SDK ceiling, and className not reaching an animated component.
problem
The risk list front-loaded the parts that looked visually hard and ignored the integration seams — does library X's styling actually reach component Y, will the runtime target load what's being built — which is where things actually broke.
decision
Added docs/plans/README.md: one dated plan file per chunk of work, the original kept verbatim, an Outcome filled in while it's fresh (never reconstructed later from the diff), and a Plan retrospective on where the plan itself was wrong, not just the code.
why
Plans get more precise only by looking back at where the last one was wrong. For the next plan: add an "assumptions to verify first" section for the cheap checks that would invalidate it, risk-list integration seams instead of visual complexity, and state plainly who can verify what on what hardware.
Day 02·22 Aughandoff #2 — the whole flow, real reminders, Settings

Goal: the whole flow walkable on a phone, the goal and name editable after first run, and reminders that are real OS notifications rather than a switch that lies.

Flow — goal → notifications → Sip+ → (sign-in, Sip+ branch only) → name → Home. Both branches collect the name, because reminder copy reads <Name>, time for a sip.

New screens — Settings, onboarding steps 2 and 3, sign-in, the name step, and step 1 reworked in-flow.

Out — any network call, real Apple/Google/email auth, IAP, actual cross-device sync, the Stats tab, and any SDK bump.

Wrote an assumptions to verify first section, exactly as the last retrospective asked: does expo-notifications drag the SDK forward, does a scheduled local notification actually fire in Expo Go, and does TextInput receive NativeWind's className on native.

Reconciled four README-vs-prototype conflicts in the plan — step 1 subcopy, step 3 feature copy, the SAVE 44% badge, and two-line headings — so none of them had to be relitigated mid-build. All four held.

context
Three of the six new screens are input-heavy, and yesterday's PressableScale bug was precisely this shape — the browser renders correctly whether or not native does, so the web preview proves nothing.
problem
The checkpoint needed hardware the implementer doesn't have, so writing it as a gate would have blocked every input screen until a phone was free.
options
build a throwaway screen and wait on a device screenshot · read react-native-css-interop and find out what is actually registered
decision
node_modules/react-native-css-interop/dist/runtime/components.js:23 registers TextInput with cssInterop, alongside View, Text and Pressable — so className does reach it. The throwaway screen was never built.
why
Yesterday's bug was never about Pressable; it was about Animated.createAnimatedComponent(Pressable) producing a different component identity with no registration. The real rule is narrower and far more useful than "check inputs on a device": never wrap a component NativeWind styles. It's a standing rule in AGENTS.md now.
risk
Worth generalising — when a plan says "verify X on hardware", first ask whether reading the implementation answers it more precisely. Hardware tells you whether; source tells you why, and the why transfers to the next screen.
context
expo-notifications local scheduling works in Expo Go on SDK 54, so this was reachable without the development build — and the paid Apple membership — that this project deliberately avoids.
problem
The obvious mechanism, a single TIME_INTERVAL trigger repeating every 2 h, keeps firing overnight and cannot express quiet hours at all.
options
one repeating interval trigger · one repeating DAILY trigger per reminder hour
decision
Seven repeating DAILY triggers (9, 11, … 21), comfortably under iOS's 64-pending cap. Every call goes through src/lib/reminders.ts, the only module in the app that imports expo-notifications.
why
A per-hour trigger expresses the waking window directly, instead of encoding it as an interval that then has to be fought at delivery time.
risk
syncReminders always cancels before scheduling — the OS has no "replace", so without the cancel every toggle stacks another seven triggers. Renaming yourself in Settings re-syncs on blur too, since already-scheduled bodies embed the old name.
context
No account service and no sync server exist, and neither is in scope for this pass.
problem
Without some path into a Pro state, Settings' "On · Sip+ active" sync row is unreachable and untestable — dead UI that nothing can exercise.
decision
Completing sign-in sets authProvider and flips isPro locally. Simulated, not purchased — there is no IAP anywhere in the app.
why
Real Apple auth would authenticate and then discard the identity anyway, and Google would additionally need OAuth client IDs, all for a screen that currently leads nowhere.
risk
The Google mark is a redraw rather than the asset from Google's branding guidelines — it has to be swapped before any store submission.

The flow's shape drove the routing. app/onboarding.tsx became app/onboarding/index.tsx so the existing segments[0] === 'onboarding' guard kept working untouched; only sign-in needed the guard edited, because it sits outside onboarding/ and has to be reachable both before onboarding and long after it. Anything already on screen twice got extracted instead of duplicated — BrandMark, UnitToggle, GoalStepper, and the entire Sip+ body as PlusOffer, which the paywall modal and onboarding step 3 now share behind one variant prop. The alternative was two near-identical copy sets drifting apart forever.

Two things the plan hadn't named surfaced during the build. router.back() from sign-in lands back on the paywall, so someone who just subscribed gets shown the upsell again — router.dismissTo('/settings') closes both. And extracting GoalStepper fixed a live bug: the prototype gives the value block a min-width of 156, the shipped v1 screen didn't, so switching units shifted the ± buttons sideways. Both sizes carry it now.

context
Adding userName, remindersEnabled, reminderSchedule and authProvider to the store came with a bump of persist's version to 2 — reasoned in the plan as being "for the record", since zustand shallow-merges persisted state over the initial state.
problem
zustand/middleware.js:392-412 says otherwise: when the stored version differs and no migrate function exists, it logs State loaded from storage couldn't be migrated and returns [false, undefined] — discarding the entire persisted payload. Every existing install would have lost its goal, unit, entries and onboarded flag on first launch.
decision
Added a migrate that passes the v1 payload straight through, untouched.
why
It was caught by reading the Metro log, not by any screen looking wrong — no visible UI state would have hinted at it until someone reopened the app with real data in it.
risk
The rule this earns: a claim about how a dependency behaves needs a citation or a check. Prose reasoning about a library's semantics is exactly where the expensive bug hid.

The big goal number was clipped. GoalStepper set lineHeight equal to fontSize to reproduce the prototype's line-height: 1. CSS paints glyphs outside the line box, so a browser renders the number whole; React Native clips to the line box and shaved the top off the tall digits. The line box now carries 25% headroom, and the sub-line's marginTop subtracts the resulting half-leading so the drawn gap stays on spec. A sweep confirmed GoalStepper was the only place where lineHeight === fontSize.

Sixteen reanimated warnings per session came from a SIZES key named value — reanimated's babel plugin statically flags any .value read inside a JSX style prop as a misused shared value. A false positive, but it was our naming, so the key is amount now.

Also added, unplanned: a __DEV__-only long-press on Settings' "Sip · v1.0" that replays onboarding. onboarded is persisted and survives reloads, Fast Refresh and force-quits, so the only way back into the flow was wiping Expo Go's entire storage.

docs/plans/2026-08-22-onboarding-settings.md keeps the plan verbatim plus what actually happened. The assumptions to verify first section earned its place immediately: one assumption took two minutes, and another was settled by a single grep of a dependency's source — better than the device screenshot the plan had asked for.

But the plan also sequenced two device checkpoints it had no way to enforce, writing them as gates to clear before any screen was built when the implementer — no phone, no Mac — could run neither. A checkpoint the implementer cannot execute is a handoff; the honest fix is to name it as one at planning time and decide explicitly whether the work blocks on it, rather than writing a firmer instruction.

The other lesson: "verify on device" is too coarse. The useful split is read the runtime log (free, instant, and it caught two of the three defects), look at the screens (caught the clipped number), and wait out real time (the notification behaviour). The plan bundled all three into a single checkbox, so the nearly-free ones never got scheduled early. Agreed as next, in order: a History page — the Stats tab is still an inert stub, and entries only keeps today, so it needs a persisted per-day log before any chart can exist — then more Settings, then real subscriptions and login.

Expo Go on SDK 54 does deliver scheduled local notifications on iOS, confirmed on my own phone, so screen 2c's promise holds without a development build. The docs said so all along — but yesterday's SDK ceiling is exactly why a doc claim about Expo Go isn't enough on its own, and only observation settled it.

iOS passing does not carry Android. Android has two failure modes iOS doesn't: a notification posted without a channel is dropped from API 26, and API 33+ gates delivery behind the POST_NOTIFICATIONS runtime permission. ensureAndroidChannel() runs at app start and requestPermissionsAsync() covers the permission, but neither has been observed working on a real Android device — the one Android phone in the session never got past a network error.

Still not isolated on either platform: that the body renders the personalised copy rather than the empty-name fallback, that nothing arrives outside the waking window, and that toggling the switch repeatedly leaves exactly one set of triggers. syncReminders cancels before scheduling precisely to make the last one true, but nobody has counted pending triggers to prove it.

Day 03·23 Aughandoff #3 — a real log, then pinning the chrome that only looked fixed

Goal: the log survives past midnight, a week of it is visible and correctable, and the reminder cadence is editable after first run instead of frozen at whatever onboarding set.

New screens — History (3a), the edit-drink sheet (3b), manual add of a past drink (3c), Reminders (3d), Account (3e), and type-to-confirm deletion (3f).

The third handoff is a strict superset again: ios-frame.jsx byte-identical, eight purely additive README hunks, one rewritten overview sentence.

The real work is underneath. entries was a today-only scratchpad that rollDayIfNeeded wiped at midnight, so no chart could be drawn over it — which means a persist migration, the exact seam that silently ate every install last chunk.

Out — Month view, the post-deletion farewell screen and the custom-amount sheet (all three undesigned), swipe-to-delete, any real auth / IAP / sync, and any SDK bump.

Three of the four assumptions to verify first were closed by reading, in about ten minutes: the three new dependencies' SDK 54 pins in bundledNativeModules.json, and zustand/middleware.js:395-398 handing migrate the stored version — so one function covers both v1 and v2 payloads.

Eight spec conflicts reconciled in the plan with a stated winner. Twelve now across three handoffs; every one held, none was relitigated mid-build.

context
entries becomes one flat multi-day log of { id, ts, ml } — the day it counts towards, the clock time beside it and its container label are all derived from ts and ml. dayKey is demoted from a staleness flag to the cursor the today-scoped selectors filter on, and the log is pruned to 90 days instead of cleared.
problem
That is a v2 → v3 persist migration. Bumping the version without a correct migrate is not hypothetical — it shipped once yesterday and would have wiped every install on first launch.
options
run it once in a scratch node script · a repo fixture over real stored payloads
decision
src/store/migrate.test.ts behind npm run test:migrate — 14 assertions over real v1 and v2 payloads, including locale-dependent time strings and already-migrated entries.
why
The plan said "scratch node script", which would have been thrown away within the hour. Anything that can silently destroy user data earns a fixture — and that should be decided in the plan, not afterwards.
risk
The week chart still compares every past day against the current goal, because no per-day goal snapshot exists — change the goal and history retroactively recolours. That is one more migration, worth batching with whatever comes next rather than spending alone on a bar colour.
context
Screen 3d's interval chips include 30 min and 1.5 h, so the cadence moves from everyHours to everyMin, and quiet hours becomes a real switch — off now genuinely schedules around the clock rather than dimming a row.
problem
The README's summary-card formula floor((endH − startH) × 60 / everyMin) counts gaps, not firings. At the default it prints 6 where reminderTimes schedules 9, 11, … 21 — seven.
options
implement the handoff's formula as drawn · count the schedule that actually gets scheduled
decision
reminderTimes() is the single source: the summary card reads its .length, syncReminders iterates it, and it caps at 60 entries — under iOS's 64-pending ceiling, which quiet-hours-off can otherwise approach.
why
Otherwise the card advertises a number the OS contradicts, and nobody notices, because each half is self-consistent. Same rule that already forbids storing intakeMl beside the entries it sums.
risk
skipWhenAhead persists but schedules nothing for a Pro user: a repeating DAILY trigger cannot be conditionally suppressed from a backgrounded app, and cancelling one kills it permanently. Named as a gap rather than faked — the free-user paywall gate on that row is real and verified.

The plan's web verification step read "all five routes reachable, no console errors". Doing exactly that returned HTTP 200 for twelve routes and proved nothing — expo-router serves one SPA shell for every path, and every route had silently redirected to onboarding because onboarded was false. "Reachable" is not a check; "rendered the right thing" is. The pass that found things drove a real browser at the mockup's 402×874, walked the onboarding flow, seeded localStorage['sip-store'] with a realistic week, then asserted on rendered text and on what came back out of the store.

syncReminders threw on web: cancelAllScheduledNotificationsAsync is "not available on web" and raises rather than no-opping, so every interval tap produced an unhandled rejection. It returns early off native now — while the permission calls deliberately do not, since the browser's own Notification API backs them and guarding those too left the whole Reminders screen permanently dimmed for no gain.

The Reminders master switch also disagreed with its own screen. With permission denied it rendered off (enabled && permitted) while the sections below stayed undimmed, because the dimming keyed off enabled alone. One active constant now feeds the switch, the dimming, the pointerEvents and the "next nudge" line.

One thing the plan never anticipated: formatAmount was the wrong formatter for a drink. formatAmount(400, 'metric') is "0.4 L", but the handoff writes every drink in millilitres — so formatDrink joined units.ts, and quick-add's custom amount now honours the unit preference where it previously did not.

context
/history was a Stack push, so the bar only looked persistent while standing still. Three consequences beyond the animation: History could be swiped back like a detail screen, both screens unmounted on every switch, and History's undo toast — absolute bottom-3 inside a view that contained the bar — landed across it.
problem
Two independent bars can only exist because each screen declares which tab it is. A screen that has to announce that can disagree with the navigator.
decision
Both screens moved into app/(tabs)/, whose layout owns the single bar, and TabBar now reads the active tab from the navigator's own state instead of a prop.
why
No new dependency — @react-navigation/bottom-tabs was already present via expo-router, and it is pure JS, so the SDK 54 ceiling is untouched. Parentheses keep the group out of the URL, so / and /history are unchanged and not one router call site moved.
risk
Verified by node identity rather than by eye: the same DOM node across Home → History → Home, exactly one of it, top fixed at 818px. A screenshot taken after a transition settles cannot tell a fixed bar from one that slid and stopped. The undo toast fixed itself when the bar left the scene, and any future useEffect in these two screens now runs once per launch rather than once per visit.
context
The plan had Settings, Reminders and Account push above the tabs, covering the bar — which is how the handoff draws those screens, a back ‹ and no bar.
problem
On a device, a bar that vanishes the moment you open Settings does not read as fixed. It reads as inconsistent.
decision
All three moved into a nested Stack inside a third Profile tab. (profile) is a route group, so no URL moved: /settings, /reminders and /account resolve exactly as before and not one router call changed.
why
Reversing cost a single commit only because route groups had kept every URL stable — a real path segment would have moved every call site with it. When a fork is about how something feels, expect the answer to be provisional and pick the structure that is cheap to reverse.
risk
Two things followed. <Tabs backBehavior="history">, because Settings keeps its back ‹ and at the root of the Profile stack goBack bubbles to the tab navigator — the default would always dump you on Home instead of the tab you came from. And the Profile icon gained the active state the earlier pass said it would never have; it is a real tab now, so lighting it up is honest. delete-account deliberately stays on the root stack covering the bar — removing the one-tap escape is the point of that screen. Fixed while testing: Home's gear and the Profile tab both carried accessibilityLabel="Settings", two visible controls with one name.
context
Every onboarding step rendered its own chrome: steps 1 and 2 each mounted a BrandMark and a ProgressBar, step 3 got both from inside PlusOffer, and the name step mounted its own BackRow. The steps are Stack pushes, so Continue slid a whole screen in from the right — mark, bar and all — past the outgoing screen's copy of the same thing.
decision
app/onboarding/_layout.tsx now owns one OnboardingHeader and the flow's padding; the four steps are pure content, with the step derived from useSegments() so a screen never declares its own number.
why
Two reconciliations the handoff forced. It draws steps 1–2 as [mark][bar] and step 3 as [bar][Sip+], and a header that stays put cannot hold two orders — so the order is unified and the mark carries the difference, switching to Sip+ on step 3. And the name step, which has no bar by design so the flow never reads as "4 of 3", keeps the slot and swaps its contents to the back ‹ rather than letting the header disappear.
risk
OnboardingHeader always returns a wrapper View instead of returning BackRow directly. That is the whole difference between "the header stays" and "a header stays" — without it React swaps the subtree at the last hop and the node is silently re-created. Verified as the same node at {top: 30, left: 26} on all four steps, with /paywall coming out byte-identical (same md5), and checked at 375×667 as well as the mockup size, since step 3 is the densest screen and this costs it ~18px.
context
All three were in the browser suite. The tell was identical each time: a suite assertion disagreeing with a focused probe of the same thing.
problem
One root cause — taking the first DOM match instead of asking whether any match is visible. Navigators keep stale, aria-hidden, 0×0 copies of screens in the tree, and those sort earlier in document order.
options
assert on body.innerText · playwright's isVisible() · count matching nodes · walk for an aria-hidden / display:none ancestor, across every match
decision
.filter(…).some(visible) with an ancestor walk, now a rule in AGENTS.md with the reason attached.
why
The three disguises: vacuous passes — with both tab scenes mounted, innerText holds every screen's text at once, so three checks passed regardless of which tab was showing; false failures — react-navigation suppresses the inactive scene with stacking plus aria-hidden, not display: none, so a hidden node still has a real bounding box and isVisible() returns true; and a phantom bug — .find() landing on the corpse and reporting the live screen missing.
risk
The generalisation is worth more than the fix: a verification technique carried over from the last change is itself an assumption, and a structural change can invalidate it silently. Text assertions were correct for single-scene Stack screens and became meaningless for tabs, with nothing raised to announce it. Third consecutive chunk where the tooling, not the code, was what needed debugging.

docs/plans/README.md gained a Lessons that outlived their plan section — a plan's own retrospective says what that plan got wrong, but the recurring ones are the set worth reading before writing the next one.

Close assumptions by reading, not by scheduling a device check. Three chunks running, the verify first section has been the cheapest part of the plan and has never once been where things broke: a version pin in bundledNativeModules.json, a line in zustand/middleware.js, a prop in a .d.ts. Reading tells you why, and the why transfers; hardware only tells you whether. Reserve device checkpoints for genuinely native claims — is the module in the Expo Go binary, what does the keyboard do — and name them as handoffs.

The risks you name get designed around; what bites is the seam you didn't know existed. Every risk list so far has been accurate and largely inert, because writing a risk down is most of the work of avoiding it. The real defects came from elsewhere — a persist.version bump reasoned safe in prose, cancelAllScheduledNotificationsAsync throwing instead of no-opping, a switch whose dimming disagreed with itself. The lesson is not "write a better risk list"; it is to schedule the cheap rendering pass that finds what was never on it.

Write the one assertion that can only pass for the right reason. "The tab bar is fixed" became the same DOM node, top=818 on five screens. "The onboarding header is static" became the same node at {30,26} across four steps. Everything else in a suite is supporting evidence.

The index was corrected too. The tab bar work was confirmed on device; the two things still genuinely unverified are the platform time picker inside Expo Go — which gates three screens — and the name step's keyboard behaviour, which today's restructure moved from a padded root KeyboardAvoidingView to a nested child. Next, in order: that device pass, a per-day goal snapshot so the week chart stops recolouring history, Month view, then subscription and login for real.

Day 04·25 Augpointing the agent skills at the docs this repo already keeps

AGENTS.md gained an Agent skills block pointing at three files. Issues are GitHub Issues on pdrmdrs/sip, driven through the gh CLI, with external PRs kept out of the triage queue. Triage keeps the five canonical labels unchanged. Domain docs are single-context — a root CONTEXT.md and docs/adr/, neither of which exists yet, which the rules say to pass over silently rather than scaffold upfront.

One departure from the seed template: docs/plans/ joins the read-before-exploring list. A skill that skips it re-argues decisions this repo already settled and wrote down — which is the entire reason the plans live in the repo instead of in a chat log.

The block lands in AGENTS.md rather than CLAUDE.md, since the latter is one @AGENTS.md line and everything reaches Claude through it anyway.

Day 05·28 Augaccounts & a real entitlement — the second draft, after arguing with the first

Goal: isPro stops being stored and becomes something the app reads from a server row it cannot write. Today signIn() sets the provider, discards the identity and flips isPro: true — *sign-in is the purchase* (useSipStore.ts:111-112), and the paywall CTA routes to that same screen.

The first draft (2026-08-23, "accounts, sync & a real subscription") was written to be argued with, so it got argued with. It framed identity, sync and entitlement as three concerns usually treated as one — correctly — and then bundled all three into seven commits and one migration anyway.

Sync is out. Deferred, and possibly permanently: Apple Health / Google Health may turn out to be the sync layer, at which point a bespoke device-to-device engine never needs to exist.

Cutting it takes the tombstone problem, the prune/pull loop, the outbox, clock skew and five of the nine assertions with it — and leaves the change that was actually urgent: a client can grant itself Pro.

In — one subscriptions table with its RLS policies, a stripe-webhook Edge Function, src/lib/supabase.ts + src/lib/auth.ts (no screen imports the SDK directly), store v3 → v4, real email auth, a restore path, real account deletion, and the copy pass. Six commits.

Out — any SDK bump, any sync, profiles / entries tables, Google and Apple sign-in, store IAP, Focus lock, non-US storefronts, and any new styled component — so for once the native-styling class of bug is genuinely out of scope.

The delivery question finally got an answer: Sip ships as a real end-user product on the App Store and Play, with the portfolio value as a by-product rather than the point. That single answer puts the SDK 54 pin on a clock.

Assumptions closed by reading, per the standing lesson: @supabase/supabase-js is pure JS so nothing here needs a dev build, metro's web-only zustand→CJS redirect branches on startsWith('zustand') only, and .gitignore covers .env*.local — so a committed anon key is a decision (RLS is the protection), not an accident, and every real secret lives in Edge Function env.

context
isPro is a persisted boolean today, gating skipWhenAhead and read from the store by five screens. Entitlement moves to our own subscriptions table — user_id, status, current_period_end, source — which the app only ever selects from.
problem
Every payment vendor wants to be the source of truth for whether someone paid, and the app then wants to cache that locally. Both moves put a writable Pro flag back on the client, which is the bug being fixed.
options
keep isPro in the store and set it after a verified purchase · read entitlement from the payment vendor's SDK at the call site · our own table, one read policy, and no write policy at all
decision
create policy "read own subscription" … for select using (auth.uid() = user_id), and nothing else — the webhook writes with the service role. Client-side, selectIsPro = proUntil !== null && proUntil > Date.now() joins intake, day, container label, initials, nudge count, active tab and onboarding step on the derived list.
why
The absence is the design. With no insert, update or delete policy, a client cannot grant itself Pro — not "should not". That is the keystone assertion of the verification suite, the only one that can fail for exactly one reason, and the thing this whole chunk exists to make passable. lifetime is a status, not a second column, so the client has one code path for both products and adding a yearly plan later changes nothing.
risk
partialize is an allow-list (useSipStore.ts:152-176) and userId / proUntil must be named in both halves — a proUntil that silently fails to persist signs a paying customer out of their purchase on every cold start, with no error. And v4 is the same persist seam that ate every install once already, so migrate.test.ts is extended in the same commit, never after. Existing isPro: true installs are dropped deliberately: nobody has paid, and migrating a fake flag forward would manufacture free subscribers and hide the bug.
context
Linking out to an external purchase is unrestricted only on the US App Store storefront, and only under a court order (May 2025). Play permits US link-outs at a 20% service fee, under a regime that expires 1 Nov 2027. Outside the US, guideline 3.1.1 is unchanged and a link-out needs an entitlement or gets rejected.
problem
Committing to a payment vendor on day one means either betting on a contested carve-out or paying 15–30% before there is a single subscriber.
decision
Launch US-only on a Stripe link-out, and keep subscriptions writer-agnostic — it has a source column and no client writer, so RevenueCat later is a webhook change and zero app changes. Two price cards, monthly $2.99 and lifetime $29.99; yearly dropped, and SAVE 44% (which was yearly-vs-monthly arithmetic) becomes BEST VALUE.
why
Decoupling the app from the payment vendor is the part that has to be right on day one; picking the vendor is not. The unit economics agree from the other side: at $2.99 the flat 30¢ makes Stripe's effective cut 13%, barely under Apple's 15% Small Business rate — the rail's advantage is almost entirely in the lifetime tier (3.9% vs 15%, $4.50 more per sale). Break-even is about four subscribers on Supabase's free tier.
risk
App Store Connect availability has to be set to US-only before submission, or review will find it for you. Use openBrowserAsync and an external link rather than openAuthSessionAsync — an in-app browser purchase surface reads closer to "buying inside the app", and that distinction is what keeps the non-US door open later. Note also what lifetime means: pre-selling the entire roadmap to the people most likely to buy on day one, when the tier is at its thinnest.
context
paywall.tsx:26 and onboarding/plus.tsx:21 are the only two links to /sign-in, and the free app deliberately never shows a login screen — no signup wall on a water tracker.
problem
With a link-out rail there is no StoreKit "Restore Purchases" to fall back on, and nothing in the app led a returning payer back to what they had already bought.
decision
*With this rail, restore is signing in — so it has to be linked. "Already subscribed? Sign in" on PlusOffer, in both* variants, plus a Sign in row in Settings → Account for the signed-out case.
why
"Purchase-only accounts" stayed the right call and was being read as "purchase-only entrance", which is a different and much worse thing. The first draft missed it because it was reasoning about the store's data flow rather than about the person holding a new phone.
risk
router.dismissTo('/settings') has to survive the rewrite: router.back() from sign-in lands on the paywall and shows the upsell to someone who has just paid. Stripe Checkout metadata must carry the Supabase user_id, or the webhook has a payment and nobody to attach it to — symptom, "I paid and nothing happened", the worst possible first experience of a paid tier. And the webhook upserts on user_id and ignores stale events, because Stripe retries and a duplicate checkout.session.completed must not extend a period twice.

src/components/PlusOffer.tsx:10-52 is rendered by both /paywall and onboarding step 3, and it sells three rows. Sync everywhere is cut from this chunk and undecided as a feature at all. Reminders that find you describes a local notification that the free tier already has. Focus lock needs FamilyControls, a dev build and a per-bundle-ID Apple entitlement — it cannot ship on the current runtime, ever.

So the copy pass ships in the same commits as the code, not after it. The FEATURES array is rewritten, Settings' "Sync across devices" row has to become something true, Account's privacy alert — "Sip keeps everything on this device. Nothing is uploaded, and there is no account unless you start Sip+" — stops being accurate the moment a payment record exists, and delete-account.tsx's "Cancel in the App Store / Play Store" line is simply wrong for a web subscription. Nothing may advertise unlimited history without the words on this device.

This chunk makes the paywall real. It does not make it worth buying. After it lands, the paid tier is skipWhenAhead plus unlimited on-device history, and the features that justify a price — Month view, streaks, averages, export, themes, custom containers, per-day goals — are a chunk of comparable size that is not this one. The consequence is a sequencing rule rather than a scope change: do not submit to the App Store between this chunk and the paid-features chunk. Shipping the gate before there is anything behind it is the right order; a live paywall charging $2.99 for a reminder toggle is the failure mode that order creates, and simply not submitting yet avoids it.

Two smaller things went with the redraft. expo-secure-store is retired in favour of the AsyncStorage already backing persist — deleting an assumption and a ~2048-byte silent-truncation risk that would have shown up as "signed out at random". And the SDK 54 pin is reworded as a development constraint rather than architecture: Expo Go is a dev client, not a distribution channel, so the pin dies the day the $99 is paid, and every paid feature past launch needs it gone.

One verification note carried forward, because it is now the third plan running where a structural change quietly invalidates how things get checked. The browser suite has only ever needed localStorage['sip-store']; it now needs a live second Supabase project, and the existing "sign in → Settings reads Sip+ active" assertion would still pass while proving the opposite of what it claims. It gets rewritten, not reused.

$ HEAD — more to come as I build in the open
LOG~/projects/sipday 01wip30 entries