§28, §29, §30 and §31. NotificationCopy is a pure function — privacy mode plus
kind plus day count in, two versions of the text out — so every combination is
tested exhaustively without an emulator. This is the one surface whose mistakes
are visible to somebody who is not the user, so the tests are exhaustive rather
than representative: every kind × every mode asserts that no health word reaches
a lock screen outside Direct, and that includes the ACTION LABELS, which §31
points out are visible text too. A perfectly discreet body under a button
reading "Started my period" leaks anyway.
TWO ANDROID BEHAVIOURS THAT LEAK IF YOU TRUST THE DOCS
A private notification with no public version does not blank the lock screen —
it shows the private text. NotificationText therefore has no nullable title and
an instrumented test asserts every kind attaches one.
And a notification channel is IMMUTABLE after creation: importance and
lock-screen visibility cannot be changed. One shared channel would have kept
whatever the user's first privacy mode set, forever — switching from Direct to
Maximum privacy would have appeared to work and changed nothing. There is now
one channel per mode. Found by an instrumented test on a device; nothing in the
unit tests could have seen it.
§30's stopping rule is a test of its own: the app asks a bounded number of times,
says "We'll stop checking for now. Log your period whenever it begins.", and
then says nothing more — while the engine keeps learning, which is the sentence
§30 puts right after it.
WorkManager, and no exact alarms. §31 rules them out and the new checkPermissions
task fails the build if one ever appears in the merged manifest — from here or
from a dependency. That guard also failed its own first proof, reading a stale
manifest because it did not depend on the task that writes one.
ReminderCoordinator reschedules whenever the forecast moves, which §31 asks for
and is the requirement most likely to be missed: a "Not yet" moves the forecast,
so work queued against the old one is aimed at a day that no longer means
anything.
188 unit tests and 6 instrumented, all passing. ./gradlew check green.
closes#24closes#25closes#26closes#27
§27's purpose is one sentence — show the user what the app has learned — which
means the screen has to be honest about how little that sometimes is. Every
figure is absent rather than approximated below the history that supports it,
because the easiest way to overstate accuracy is to average two numbers and
print a decimal place.
- one interval is an anecdote, not an average: no "average cycle" until two
- accuracy figures wait for three scored forecasts, and say why they are
waiting rather than showing a mean of one
- §27's learning copy is chosen by what the data supports, never by mood.
"Personalized to your cycle" is a claim, and it appears only when there are
enough confirmed cycles for the forecast to genuinely be hers
A BUG THE OUTLIER TEST CAUGHT
The typical-range quartiles were indexed off `size` instead of `size - 1`, which
on an even-length list puts the upper index on the largest value. A history of
29, 28, 30, 29, 61, 29 reported a "typical range" of 29–61 — describing a
regular cycle as wildly erratic, on the one screen whose whole job is to say
what has been learned about her. Now 29–30.
Nothing on this screen leaves the device. §46 names prediction_error= among the
values that must never become an analytics event, and this is exactly the screen
that would tempt somebody to send one. There is no network call in these files
and there must never be.
153 tests, all passing. ./gradlew check green.
closes#20
§26's four states plus spotting, distinguished by SHAPE: a solid disc for a
period you logged, a dotted ring for one predicted, a small low dot for
spotting, a continuous ring for the fertile window, a four-pointed mark for
ovulation. Verified the way the requirement is written — the screenshot was
converted to greyscale and every mark is still tellable apart.
Predicted is never a lighter confirmed. §26 forbids them looking identical, and
opacity alone fails exactly when the screen is dim or the user is not looking
closely.
TWO PROBLEMS THE GREYSCALE CHECK FOUND
Today was drawn as an underline under the numeral. Spotting is a dot low in the
same cell. On the one day that was both — which was, of course, the day I looked
at — they merged into an unreadable smudge. Today is now an outline around the
whole cell, which cannot overlap anything drawn inside it.
The legend's spotting row was misaligned for the same reason: a mark anchored to
the bottom of its box next to vertically centred text. Fixed by moving the dot
up off the floor of the cell.
Neither was visible in the colour screenshot. Both were obvious in greyscale,
which is the argument for doing the check the way §43 describes rather than
assuming shapes differ because they were designed to.
Marks are derived per call, never stored: a calendar_days table would be a
second copy of facts the period records and the forecast already hold, stale the
moment either changed. An unclosed period marks only its start day, because
filling forward to today would draw days the user never said were period days.
Every day carries a content description — "18, spotting, today" — because §43 is
right that a grid of bare numerals carries none of a calendar's information.
Weekday initials repeat, so those are labelled too.
Tapping a day logs or removes an entry there; future days do nothing rather than
opening a sheet whose every action is disabled.
145 tests, all passing. ./gradlew check green.
closes#19
Seven screens, §19 and §56 verbatim: welcome, last period, period end, previous
history, the privacy promise, notification privacy, first forecast. Verified end
to end on a device — the flow produces a forecast, the record persists, and a
relaunch goes straight to Today.
Three decisions with tests behind them:
- Nothing is written until the final step. Somebody who abandons onboarding
halfway has not asked this app to remember anything about them.
- Notification privacy is Discreet before the user touches anything (§28), and
Direct is last and never pre-selected. Checked on the device, not only in a
unit test.
- "Still going" and "I'm not sure" both mean no end date. §24: never invent
one. The date picker refuses future dates by not offering them rather than
by rejecting a tap it allowed.
DARK MODE WAS BROKEN FOR ALL OF BATCH 01
PeriodTheme never wrapped its content in a Surface, so every Text without an
explicit colour inherited Material's default — black — and the app background
never painted. In light mode that looked correct by accident, because dark text
on cream is what was wanted anyway. In dark mode the onboarding headings
rendered near-black on charcoal.
No test caught it and no test easily would have. It was found by opening the
app on a device and looking at it.
The Surface now lives in the theme, so a screen without a Scaffold cannot
forget, and every illustration has a light/dark preview pair. A preview is not
a test, but it is the cheapest thing that puts the failure in front of whoever
is editing the screen.
closes#16
The Batch 01 vertical slice from PRODUCT_PLAN.md §58 now runs on a device:
launch, log a period, it is stored, the forecast recalculates, edit or delete it
and the forecast moves again. Hilt wiring, a TodayViewModel exposing one
immutable state, and a working surface that says "Batch 01 · working surface" at
the top so nobody mistakes it for the designed Today screen, which is Batch 03.
THE DEFECT THIS FOUND, ON A DEVICE
Tapping "Started today" twice on the same day killed the app:
FATAL EXCEPTION: main
android.database.sqlite.SQLiteConstraintException: UNIQUE constraint failed:
period_records.startDate
Not a hypothetical — the crash was reproduced on emulator-5580, the fix
applied, and the same two taps then produced "That day is already logged." with
the process still alive and zero FATAL lines in logcat.
The constraint is right: a duplicate must not overwrite the original row and
lose its createdAt and source. The API around it was wrong. Repeating a tap
when you are not sure the first one registered is an ordinary thing for a person
to do, not a fault, and it must never be an exception. So the period writes
return PeriodWriteResult — Added, AlreadyRecorded, Updated, Conflict, NotFound —
and only genuine faults still throw.
editPeriod had the same hole: moving a record onto a date another record holds.
That is refused rather than merged, because merging would delete a period the
user entered and only they can settle it.
The ViewModel now installs a CoroutineExceptionHandler as a backstop. In a
health app a crash mid-write is adjacent to losing what was just entered, and a
message somebody can read beats a process that vanished. The message carries the
exception type and never a record's contents (§45).
Four regression tests pin all of it, plus two instrumented tests on a real
file-backed database that close and reopen it — what a force-stop actually does,
and something an in-memory database cannot fail.
70 unit tests and 2 instrumented tests, all passing. Release APK 1.2 MB.
closes#6
Period was a bare directory holding one 2,527-line specification, with no git
repository, no tracker and no documentation convention. This is the adoption
from Projects/Template/START-HERE-New-Project.md, plus a project that compiles
so the hooks and future guards have something real to run against.
Documents. scaffold.sh created 19 paths, 0 skipped. The specification moved to
docs/planning/PRODUCT_PLAN.md unchanged in substance, with a status header; the
capitalised Docs/ is gone. Every scaffolded document was filled in for Period.
docs/OPERATIONS.md deleted — an offline app is not a deployed service.
DOC_TRUST_MAP.md written last, describing what is actually here, including what
this project deliberately does not have.
Code. Four Gradle modules. domain/cycle and domain/prediction are kotlin("jvm")
and cannot see the Android SDK, so the engine is testable without an emulator —
17 tests pass, 12 of them the acceptance cases from PRODUCT_PLAN.md §51.
BaselinePredictionEngine is a robust-median prototype and explicitly not the
product; it exists so Batch 02's replacement can be shown to be better rather
than merely different.
Versions verified against their official sources today rather than inherited
from the specification's own numbers, which that document asks for: Kotlin
2.4.10, AGP 9.3.1, Gradle 9.7.0, Compose BOM 2026.08.00, Room 2.8.4, Hilt
2.60.1. AGP 9 ships Kotlin built in, so org.jetbrains.kotlin.android is no
longer applied. compileSdk is 37 because current AndroidX requires it; targetSdk
stays 36, Play's floor from 2026-08-31, and the difference is deliberate.
Six scripts taken into scripts/; the rest declined and named in docs/TOOLS.md.
Three hooks in .githooks/, with pre-commit adapted to Gradle.
closes#1closes#2