The project owner supplied artwork and a full visual brand guide mid-session,
which answers #8: the three marks an agent must not fake now exist because a
person made them.
docs/data/img now holds icon.webp (512x512, 11 KB), logo.webp (1024 long edge,
28 KB) and banner.webp (2176x725, 20 KB) — all webp by magic bytes, all far
under the 512 KB ceiling. Produced from the supplied art rather than invented.
Two things had to be handled rather than converted blindly. The sources render
their rounded corners against black, so a naive crop carried black wedges into
the banner; the corners are flood-filled to the artwork's own ground first. And
the lockup is square, so a 3:1 crop would have cut the wordmark in half — the
banner is the whole lockup on a matching ground, which has no seam to see
because the artwork's edges ARE that ground.
The source PNGs and the guide moved to docs/design/: docs/data/img is for the
three marks the Command Center renders, and its README is explicit that
reference art and exported sources do not belong there.
THE PALETTE, AND ONE DELIBERATE CONFLICT
core/designsystem now carries the guide's colours — deep plum, rich purple,
period rose, lavender mist, and an eggplant dark theme that is never flat black.
§39 of the specification asks for a muted teal or sage fertile window, to hold
it apart from the period colour. The guide is plum and rose throughout and gives
fertility its own pink. The guide wins: it is newer, it is the owner's, and a
teal accent inside this identity would look borrowed from another app.
What §39 was protecting is kept by other means, and this is why the conflict is
safe to resolve that way: the calendar's states differ in SHAPE. A solid disc, a
dotted ring, a continuous ring and a star stay tellable apart in greyscale and
to a colourblind user, which is what §43 actually requires. The departure is
written down in docs/design/README.md rather than left to be discovered.
The launcher icon keeps its vector for now, in brand colours. The supplied
emblem has content close to its edges and an adaptive icon masks about a quarter
of the canvas away, so dropping it in unmodified would crop the shield. Fitting
it to the safe zone is Batch 08's final-artwork work.
closes#8
§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
§17 and §18. Ovulation is estimated a luteal phase before the PREDICTED next
period rather than counted forwards from the last one — the luteal phase is the
stable half of the cycle, which is why §17 asks for it that way — and the
fertile window opens five days before ovulation and closes one day after,
because sperm survive and the egg does not.
The uncertainty is inherited, not invented. Ovulation is derived from a
predicted date, so it can never be more certain than that prediction.
THE PART THE DEVICE TAUGHT
The first version showed a user one cycle in a fertile window of 8 Aug – 24 Aug.
Seventeen days. Arithmetically honest, and completely useless — over half a
cycle, dressed up as a feature.
So the estimate now returns null past a usable uncertainty, and Today says "Not
enough history to estimate. Log a few more cycles and the app will be able to
estimate ovulation." Three stable cycles later the same user gets 12 Aug – 20
Aug, which is worth reading. Verified in both states on a device.
That is the same shape as PredictionAccuracy refusing figures below three scored
forecasts and CycleInsights withholding an average below two intervals, and it
is now written down in the architecture doc as a rule rather than three
coincidences: the app declines rather than stretches.
§18'S PROHIBITION IS A TYPE, NOT A CONVENTION
FertilityLikelihood has LOWER, HIGHER and UNKNOWN and no fourth value. Somebody
reading "safe" would take a decision on it; the estimate comes from a predicted
date carrying days of uncertainty; and §18 has already promised this is not
contraception. A test asserts no label contains a permission word, so adding one
is a deliberate act with a failing test.
The disclaimer travels with the feature — same screen, same time. A disclaimer
one tab away is a disclaimer nobody read.
ANOTHER GREYSCALE COLLISION
The ovulation star was centred, which put it directly behind the numeral: in
greyscale "18" and the mark merged into one smudge. Ovulation is now the fertile
ring plus a small star low in the cell, which is also semantically right — that
day IS inside the window, and the pair reads as "that window, and this day".
Nine Compose ModifierParameter warnings fixed properly rather than suppressed.
164 tests, all passing. ./gradlew check green, 0 lint errors.
closes#21closes#22closes#23
§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
§23's path exactly: "Started period" opens a sheet, "Yes — today" closes it and
the forecast has already moved. No symptoms, no mood, no notes, no survey — §23
lists all four as things not to force, and each one is a reason somebody stops
logging at all. Confirmed on a device: two taps, then "Logged ✓ Your predictions
have been updated."
Spotting sits in the same sheet rather than behind another tap, because it is
the answer to the same question the user just asked themselves, and one more tap
is how it stops being recorded.
§25's question, and both halves of it
The paragraph that permits "was this your period or spotting?" also says not to
over-question. Both are tests: a one-day entry asks, a five-day entry does not,
and a dismissed question is not asked again for that record. Answering "Period"
is a complete answer — there is no "ask me later", which is the option that
turns one question into three.
Reclassifying deletes the period record and keeps the day as spotting, and the
test that matters asserts the FORECAST is unchanged either way. That is §25's
real requirement — spotting must not reset the cycle — and a forecast is the
only thing that can prove it. Verified on a device too: the screen went straight
back to cycle day 26 with the same 21 August forecast it had before.
§24's "Updated ✓" acknowledgement on ending a period, because a silent write
reads as a failed tap.
135 tests, all passing. ./gradlew check green.
closes#18
§21 and §22, each state its own screen rather than a variant of one. Which one
applies is decided by CycleStatusRules in a pure module with twelve tests on its
boundaries — and the boundaries are the point, because they are the days this
screen is most read: the day a period is due, the day after one ends, the day a
forecast slips.
The number dominates (§38): displayLarge at 72sp, in the primary colour, with
the unit as a separate quiet line so "4" reads instantly and "DAYS" is there if
you look. Its content description carries the whole sentence, so TalkBack says
"Period likely in: 4 days" rather than reading a bare numeral.
The state this screen exists to get right is the last one. Past the forecast the
app NEVER says late — late implies a schedule the user failed to keep, and the
truth is that an estimate was imprecise. It shows what it originally said, what
it says now, and asks.
A UX DEFECT FOUND BY DRIVING IT
Tapping "Period ended" changed nothing on screen. The logic was right — a period
that ends today still includes today, so the state does not change — but the
button looked broken, which is worse than being broken somewhere visible.
DuringPeriod now carries the end date, so the screen shows "Ended 18 August" and
offers only the useful action (undo) rather than a button that visibly does
nothing. §24's "Updated ✓" acknowledgement is there too. No test would have
caught this; it needed somebody to tap the button and look.
The banner slot is reserved and empty. §48 wants no layout jump when an ad loads
and a graceful gap when one fails, and both are properties of the space existing
whether or not it is filled — reserving it in Batch 07 instead means shipping
the jump first. Deliberately not a "your ad here" box, which would be a
placeholder for the thing a user pays to remove.
Fertility lines are absent rather than faked: §22 shows them and Batch 04
estimates them, and a placeholder number there would be inventing a fertility
estimate, which is the one thing this screen must not do.
Preview pairs for every state, light and dark. 129 tests, all passing.
closes#17
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
PersonalPredictionEngine keeps a discrete probability distribution over
candidate start dates rather than a date with a margin bolted on. Everything the
product needs falls out of that one structure: the most likely date is its mode,
the window is the narrowest span holding 80% of the mass, and a "Not yet" is the
distribution conditioned on what the user just said — which is what §13 asks for
and what a date-plus-margin design cannot express at all.
It is better, and that is a number rather than an opinion. EngineComparisonTest
scores both engines over the §51 fixtures on every build:
engine MAE mean window within +/-2 window covered
baseline 1.00 2.67 7/9 7/9
personal 0.67 4.56 9/9 9/9
COVERAGE IS THE MEASURE, NOT WIDTH
The first version of that test asserted the new windows must not be wider, and
it failed. Measuring showed why the assertion was wrong: the fixtures where the
personal engine is wider are the ones that are genuinely less certain — a
history with a suspected missing period, and one with a 45-day outlier — and the
baseline answers both with a two-day window and misses. What a window promises
is that the period starts inside it. An engine keeping that promise 7 times in 9
has a broken promise, not a tight forecast. The test now asserts coverage, with
a ceiling so "some time this month" still fails.
THREE MODELLING BUGS THE TESTS FOUND
Each was found by a test failing, not by reading the code:
- Median absolute deviation alone reads a user alternating 25 and 37 as
perfectly consistent, because half her deviations are zero. Twenty
disagreeing cycles came back High, breaking §15's rule that volume alone
must never buy High confidence. Spread is now the larger of MAD and mean
absolute deviation; robustness comes from IntervalAnalysis down-weighting
what is questionable, which is a better place for it.
- Recency weighting assumes the recent past predicts the near future. For a
variable user that is false — her latest cycle is a draw from a wide
distribution, not a signal — and weighting it equally cost three days on the
§51 variable fixture. Recency is now trusted in proportion to how much her
cycles actually agree.
- A fixed one-day floor on trend detection fired on a 42-day-cycle history
whose medians differed by a single day, turning an exact forecast into a
wrong one. One day is a real trend at 28 and rounding error at 42, so the
floor is relative to the user's own spread.
WIRED THROUGH, NOT JUST TESTED
PredictionInput carries recentAbsoluteErrors, and CycleRepository feeds the
scored errors back in. Without that the app stores every error it makes and
never reads one back — measuring accuracy rather than learning from it, with the
widening happening only in a unit test. A repository test asserts the errors
actually reach the engine.
BaselinePredictionEngine stays as the control, and both engines run the same
§51 acceptance suite, so the next engine's improvement is measurable too.
108 tests, all passing. ./gradlew check green. Verified on a device.
closes#10closes#11closes#12closes#14
checkModuleBoundaries holds the dependency tables in docs/architecture/README.md
as a check: every module's permitted project dependencies, plus the rule that
domain:cycle and domain:prediction must never apply an Android plugin. core:ads
is already in the map with an empty permitted set, before the module exists —
PRODUCT_PLAN.md §34 is non-negotiable, and a guard written alongside the code it
constrains is one shaped around whatever exception somebody wanted at the time.
It lists every violation rather than the first, and refuses to report a pass
when it examined no modules at all.
THE GUARD FAILED ITS OWN FIRST PROOF
prove-guard.sh injected a forbidden dependency into :domain:prediction and the
guard reported "7 modules checked, no violations". The root project is
configured before its subprojects, so reading subprojects.configurations from
the root script saw every configuration empty — it had been green over an empty
map since the moment it was written, and would have been trusted for months.
Collection moved into afterEvaluate, and the task now throws rather than passing
if it ends up with no modules. Three proofs recorded in the architecture doc,
all re-run and all red: a domain module reaching upward, :app reaching past the
repository straight to Room, and a module with no rule being reported as
unmeasured rather than assumed fine.
TWO REAL BUGS FROM WIRING IT INTO `check`
Running the whole check for the first time turned up Android lint errors that
would have shipped:
NewApi: java.time.LocalDate#ofInstant requires API 34 (minSdk is 26)
NewApi: java.time.LocalDate#EPOCH requires API 34 (minSdk is 26)
Both are on the recalculation path. On any device below Android 14 — most of
the install base this app targets — that is a crash. Neither the unit tests nor
the API 36 emulator could see it; lint is the only thing that could.
Replaced with atZone().toLocalDate() and ofEpochDay(0), which are API 26.
Also cleared the lint warnings that were real: a redundant activity label, and
a round launcher icon declared but never referenced. The two that remain are
deliberate and now say so where the warning is read — targetSdk 36 is Play's
floor and raising it opts into untested runtime behaviour, and the -v26 mipmap
qualifier stays because removing it makes AAPT fail to resolve the icon at all.
./gradlew check now passes with 0 lint errors across all seven modules.
70 unit tests, all passing.
closes#7
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