Every "today", "due today", streak, countdown and statistic in an Expo app is computed on the user's local civil-date keys from one injected clock, survives midnight, DST, travel and a clock set back, is folded by pure tested functions in one write per event, and reaches the screen as a finished summary on iOS and Android alike; two checkers prove it on the app's own code.
Rules that must hold
- A day is a local civil-date key from
clock.today(), and day arithmetic is integer maths on keys.DateKey'YYYY-MM-DD'throughsrc/core/dates/date-key.ts(addDays,daysBetween,isoWeekday); nevertoISOString().slice(0, 10)orgetUTC*for a day, nevernew Date(key)orDate.parse(key), never a key built by hand fromgetFullYear()/getMonth()/getDate()outsidesrc/services/clock/(a picker'sDateconverts throughlocal-date.tsthere), never a duration divided by 86,400,000. Why: the UTC day is wrong for hours every day outside UTC, a date-only string parses as UTC midnight, and a local day lasts 23, 24 or 25 hours. (utc-day,date-only-parse,local-day-key,day-ms-arithmetic,date-math) - Only the clock adapter reads the wall clock; day, streak and statistics code receives time as
values.
ClockPort(nowMs,today,msUntilNextLocalDay; architecture-and-boundaries owns it) is the one source; noDate,Date.now,Math.random,Intl.DateTimeFormatortoLocale*in day code. Why: one reader makes every rule a millisecond test with the fake clock, and two devices never disagree about the day. (wall-clock,clock-countdown) - "Today" is read on every render, never cached.
useToday()re-renders at local midnight (a timer armed frommsUntilNextLocalDay(), at most a minute long) and when the app returns to the foreground; handlers callclock.today()again; nouseState,useRef,useMemoor module constant, no effect that copiestoday()into state. Why: a screen left open overnight, or resumed the next morning, otherwise shows yesterday. (today-cached,today-hook) - An event belongs to the local day it started, captured then and passed into every fold, which
never re-reads the clock. Why: a session finished after midnight would otherwise count for a day the
user never started it on. (
completion-record) - Streaks follow one pure model. The first event of a day marks it and repeats change nothing; the
streak lives while the last active day is today or yesterday; a longer gap restarts it at 1; the
best is kept; a clock set back (or a flight west) never removes a done day or counts one twice; a
derived streak agrees with the stored one. Grace, freezes and repairs are product data recorded as
days, never branches in the rule. Why: these are the rules users check by hand.
(
streak-first-of-day,streak-rule,streak-clock-back,streak-week,streak-derive) - Every number has one source of truth. Derive it on read from rows the app keeps forever, or, when
the detail is pruned, capped or deletable, store it as a counter updated by the same fold;
never count a pruned window (
Object.keys(byDay).length) and never store a copy of what you derive. Why: a 400-day streak must not read 60, and two sources drift. (streak-prune,stats-best,view-stats) - A completion updates its row, the streak and the statistics in one transaction, before the
confirmation shows. One pure
recordCompletion(progress, completion); practice (a sample, a tutorial, a replay of a scored day) records nothing; a repeat on an active day counts in the statistics and leaves the streak untouched; folds never mutate their input. Why: a kill between two writes leaves a streak that contradicts the history. (completion-record,fold-immutable) - Statistics fold by fixed rules. Totals, successes, failures and time add up; any failure resets
the run of successes and the longest is kept; per-day buckets cover a fixed window counted back from
the newest day; custom counters have stable kebab-case ids (stored keys) and fold by
'sum'or'max'over the final committed steps, so undone steps never count. Why: a renamed id orphans a stored total, and an undo must not inflate a record. (stats-counts,stats-run,stats-days,stats-counters) - Screens only format a pure summary.
buildStreakSummary/buildStatsSummaryproduce every number, mark and flag (seven days oldest first with ISO weekdays, rates as 0..1 with a zero guard, whole-unit durations,isEmptyfor a new user; statistics take the streak counts fromstreakCountsOf, never the streak section); components and hooks do no day arithmetic and read no raw buckets; labels and digits come from the i18n layer. Why: maths in a view is untested and differs between screens. (day-maths-in-ui,view-streak,view-stats) - The time to the next day comes from
msUntilNextLocalDay().useNextDayCountdown()with whole-minute snapshots and minutes rounded up by the puresplitCountdowninsrc/core/countdown/(never "0 h 0 min" before midnight); never86_400_000 - nowMs % 86_400_000. Why: only the adapter knows the zone and DST, and the modulo form is the time to midnight in London. (view-countdown,day-maths-in-ui)
Workflow
-
Read the reference for the task:
Task Read "today", due days, midnight, DST, time zones, a date picker, a server date, a countdown, a date off by one references/calendar-days.md a streak, a week strip, a daily check-in or daily challenge references/streaks.md statistics, counters, the completion write, a stats screen, "Reset statistics" references/statistics.md a checker failure, the contract, SKIP or NOT APPLICABLE lines, multi-package references/checkers.md -
Check the prerequisites. The clock port, its system adapter and fake, and the composition root (
useServices,createTestServices) come from architecture-and-boundaries;date-key.tsfrom seeded-determinism-and-golden-vectors; the render helpers (@test/render-with-providers) from unit-and-component-tests. This skill carries synced copies of the clock and date-key files intemplates/src/services/clock/andtemplates/src/core/dates/: copy them only when the app lacks them, and never edit them in the app (the skills that own them change them). -
Copy the templates the app needs to the paths their first lines name: always
templates/src/hooks/(use-today.ts,use-next-day-countdown.ts, tests) andtemplates/src/core/countdown/(the puresplitCountdownthe countdown hook imports); for streakstemplates/src/core/streaks/; for statisticsactivity-stats.tsandstats-summary.tsfromtemplates/src/core/stats/, plusrecord-completion.tsonly when the app has streaks too (it is the one file that imports both folders); andtemplates/contracts/day-logic.jsonwith its schema, setting a module tonullwhen the app does not have it (the checkers refuse a module whose dependency isnull). Each module ships with its test. -
Decide what counts. Write the qualifying rule once in feature logic (
completionOf(task, today)in examples/complete-task.ts): which event marks a day, what is a success, which items are practice, which counters exist (kebab-case ids,'sum'or'max'). -
Wire the write. The handler reads
clock.today(), folds withrecordCompletion, and writes the row and the progress in one repository transaction throughupdateAndPublish(examples/complete-task.ts; persistence-and-migrations owns the table and its migration, state-management-zustand the store and the publish). -
Wire the screens. A model hook reads
useToday(), the stored sections through selectors anduseNextDayCountdown(), builds the summary and formats it with the i18n layer (examples/use-streak-card-model.ts, examples/stats-cells.ts); the component renders only what it gets. -
Test first for any rule change, then run
npx jest src/core/streaks src/core/stats src/core/dates src/services/clock src/hooks --ci --selectProjects unit(paths before the flag) andnpm run typecheck. -
Run the checks from the repo root:
node ${CLAUDE_SKILL_DIR}/scripts/check-streaks-and-stats.mjs .andnode ${CLAUDE_SKILL_DIR}/scripts/check-calendar-code.mjs .; fix everyFAILline (file, rule and fix are printed) and rerun until both printRESULT: PASS.
Definition of done
- No day is computed from a UTC string, a
getUTC*getter, a key parsed withnew Date(key)or milliseconds divided by a day; every exception carries// not-a-calendar-day: <why>. - Only
src/services/clock/*-adapter.tsreads the wall clock (Date.now(),new Date()); day, streak and statistics code takes keys and numbers as arguments. - Every screen that shows a day reads
useToday(); handlers readclock.today()again; the countdown readsuseNextDayCountdown(). - The streak, statistics and completion modules are the templates or pass the same tests;
contracts/day-logic.jsonnames each module or sets it tonull, never naming a module whose dependency isnull. - A completion is one transaction (row + progress) published after the commit, before the confirmation; practice records nothing.
- Screens read only the summaries; a new user sees the empty state; labels and digits come from the i18n layer.
- The module tests and
npm run typecheckpass. -
node ${CLAUDE_SKILL_DIR}/scripts/check-streaks-and-stats.mjs .printsRESULT: PASS(an app without streaks or statistics printsNOT APPLICABLE: no streak or statistics module: ...). -
node ${CLAUDE_SKILL_DIR}/scripts/check-calendar-code.mjs .printsRESULT: PASS(allowed while another skill has not created its file:SKIP src/services/clock/clock-port.ts [clock-countdown] not yet created (architecture-and-boundaries creates it)andSKIP src/core/dates/date-key.ts [date-math] not yet created (seeded-determinism-and-golden-vectors creates it)).
Anti-patterns
new Date().toISOString().slice(0, 10)for today. That is the date in London. Useclock.today().new Date(task.dueOn)to seed a date picker. A date-only string is UTC midnight: the picker opens on the previous day everywhere west of UTC.localNoonOfDateKey(task.dueOn), anddateKeyOfLocalDate(picked)back.Math.floor((dueAtMs - nowMs) / 86_400_000)for "due in N days". Off by one around every DST change and every midnight.daysBetween(today, task.dueOn).const [today] = useState(clock.today()), oruseEffect(() => setDay(clock.today()), []). The screen keeps yesterday after midnight.useToday().`${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`in feature code. A second, untested day source that skips the injected clock (and the fake in tests).clock.today().- A
useTodaythat listens only to AppState. A tablet left open overnight never turns the day. Arm a timer frommsUntilNextLocalDay()too, as the template does. Object.keys(streak.byDay).lengthfor "Days active". It stops at the window. ReadactiveDays.daysBetween(lastDate, today) <= 1in a component. The rule lives incurrentStreak; the component formatssummary.currentStreak.- Resetting the streak when the clock goes back. Keep done days; show the streak while today is before the last active day.
- A streak freeze as
if (hasFreeze) return lengthinsidecurrentStreak. Record the freeze as a day throughrecordDay. - Recording the completion under the day it was written when the activity started before midnight. Pass the start day.
- Writing the task row, then the progress row, in two calls. A kill between them leaves a done task without its day. One transaction, then publish.
- Storing the streak and also deriving it from the history. Two sources drift; pick one.
- Renaming a counter id "to read better". The stored total is orphaned. Add a migration instead.
86_400_000 - (clock.nowMs() % 86_400_000)for "New day in". UTC midnight.useNextDayCountdown().toLocaleDateString()orIntl.DateTimeFormatto label a day in a screen or summary. The device calendar and digits leak in; format throughsrc/i18n/(i18n-strings-and-catalogs).- Showing a new user a wall of zeros. Switch on
summary.isEmpty.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/calendar-days.md | Date keys, the clock port, useToday, midnight and DST (verified zones), travel and a clock set back, pickers and server dates, the countdown, counting days, formatting hand-off, iOS/Android table, testing, device probes, React Navigation appendix |
Workflow step 1; any "today" or date change |
| references/streaks.md | Streak rules, the stored section and recordDay table, incremental counts, stored vs derived, the kept window, what counts, grace as data, the streak summary and week strip, the daily-challenge profile, Tasks mapping, checker rules |
Workflow step 1; any streak change |
| references/statistics.md | The statistics section, recordActivity, counters, the completion write, the summary, formatting at the edge, empty state and reset, Tasks mapping, checker rules |
Workflow step 1; any statistics or completion change |
| references/checkers.md | Both checkers' rules and scopes, the marker, the contract, layout options, expected SKIP / NOT APPLICABLE lines, multi-package, troubleshooting | Workflow step 8, and when a check fails |
templates/src/hooks/ |
use-today.ts, use-next-day-countdown.ts and their tests |
Workflow step 3 |
templates/src/core/countdown/ |
split-countdown.ts (hours and minutes to midnight, rounded up) and its test; needs nothing but itself |
Workflow step 3, with the hooks |
templates/src/core/streaks/ |
day-streak.ts (the streak model and streakOfDays), streak-summary.ts (summary, week strip, streakCountsOf) and their tests |
Workflow step 3, for streaks |
templates/src/core/stats/ |
activity-stats.ts, stats-summary.ts (no streak import), record-completion.ts (needs the streaks folder too) and their tests |
Workflow step 3, for statistics |
templates/src/services/clock/ |
ClockPort, the system adapter, the fake clock and their tests (synced from architecture-and-boundaries; do not edit here) |
Workflow step 2, only when the app lacks them |
templates/src/core/dates/ |
date-key.ts and its test (synced from seeded-determinism-and-golden-vectors; do not edit here) |
Workflow step 2, only when the app lacks it |
templates/contracts/day-logic.json |
The contract both checkers read: module paths (or null), extra day-code and UI-code globs |
Workflow step 3 |
templates/contracts/day-logic.schema.json |
Its JSON Schema | With the contract |
| examples/complete-task.ts | A Tasks app's qualifying rule, counters, the one-transaction repository method and the handler | Workflow steps 4 and 5 |
| examples/use-streak-card-model.ts | A streak card's model hook: today, the stored streak, the countdown, i18n labels, testIDs | Workflow step 6 |
| examples/stats-cells.ts | The statistics summary mapped to cells (testID, label key, value kind) | Workflow step 6 |
| examples/stats-cells.test.ts | Its test | Workflow step 6 |
| examples/local-date.ts | dateKeyOfLocalDate and localNoonOfDateKey: a picker's Date to a key and back (src/services/clock/local-date.ts) |
A date picker, a calendar library, date-only-parse |
| examples/local-date.test.ts | Its test, which holds in any time zone | With the example |
scripts/check-calendar-code.mjs |
Scans src/ for UTC days, keys parsed as UTC midnight, hand-built day keys, day counts from milliseconds, cached today, wall-clock reads in day code, day maths in UI; checks the clock port, the day hooks, date-key.ts and split-countdown.ts |
Workflow step 8 |
scripts/check-streaks-and-stats.mjs |
Runs the app's streak, statistics, summary and completion modules against the rules | Workflow step 8 |
scripts/lib/day-logic-contract.mjs |
Reads and validates contracts/day-logic.json for both checkers |
Never by hand |
scripts/lib/load-error.mjs |
Names the real cause of a module-load failure (a missing repo file and the skill that creates it, or an impure import) |
Never by hand |
scripts/lib/calendar-checks.mjs |
The pinned calendar facts date-math and view-countdown run |
When a date or countdown fact changes |
scripts/lib/streak-checks.mjs |
The streak and streak-summary rules | When a streak rule changes |
scripts/lib/stats-checks.mjs |
The statistics, summary and completion rules | When a statistics rule changes |
scripts/lib/app-modules.mjs |
Imports the app's pure TypeScript with tsconfig aliases (synced from the library; do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Shared source helpers, isTestFile (synced from the library; do not edit here) |
Never by hand |
scripts/check-lib.mjs |
Shared checker library (synced from the library; do not edit here) | Never by hand |
scripts/selftest.mjs |
Proves both checkers pass the templates and catch every planted bug | After changing a checker, a rule or a template |
assets/shared.json |
The synced files this skill declares | When a synced file is added |
tests/ |
bases/app/ (a minimal app) and fixtures/<checker>/ overlay cases: good/ is the templates, one bad-* per rule, pass-* cases (partial copies: streaks only, statistics only, hooks only) and error-* cases |
When adding a rule |
Related skills
seeded-determinism-and-golden-vectors-date-key.ts, daily seeds and the content of a day.architecture-and-boundaries- the clock port, its adapter and fake, the composition root.persistence-and-migrations- the tables and the transaction a completion writes in.state-management-zustand- the stores, selectors andupdateAndPublish.i18n-strings-and-catalogs- date labels, durations, percentages, plurals and digits.unit-and-component-tests- render helpers, fake clocks and fake timers in tests.local-notifications- a reminder before midnight with DST-safe schedules.value-objects-money-and-units- instants and their formatting.level-generation-and-solvers- a generated daily level for the daily-challenge profile.