Privacy: Period Tracker is the only Privacy LLC product without a page on
privacyllc.dev — /work/bill-tracker, /work/closer-couples and /work/fruit-fall
exist, /work/privacy-period-tracker returns 404 — and until now no screenshot of
this app existed anywhere in the repository.
Eleven frames covering all seven onboarding steps plus Today, Calendar, Insights
and Settings, in the house style those pages use: portrait, captioned, no device
frames, no decorative background.
SEEDED, NOT REAL
Six invented starts — 8 Mar, 5 Apr, 3 May, 31 May, 28 Jun, 26 Jul 2026 — giving
gaps of 28/29/28/28/29 days. On a 19 August clock that is cycle day 25, and the
engine earns "period likely in 4 days, most likely 23 August, expected 22-24
August, confidence High". Nothing shown is real data, which #32 states is not a
nicety: a screenshot of a period tracker is a screenshot of health data.
Today was reshot once. The first seeding landed the capture day on the estimated
ovulation date, so the hero read "estimated ovulation in 0 days" — true, and a
poor lead. Shifting the history ten days gives the period countdown, which is the
screen the app is built around.
CHECKED, NOT ASSUMED
The status bar is SysUI demo mode so all eleven are identical, and the top strip
of every frame was cropped and compared in one image to confirm what #32 warns
about: no notification appears in any of them. The not-contraception line is
visible in the Today and Calendar frames. Light mode throughout, which also
avoids #44's wrong dark Welcome illustration.
520 KB for the set, 1080x2400 WebP, each far under the 512 KB ceiling.
The README carries the seeded dates, the demo-mode and text-input-picker recipe,
and the rules every frame must satisfy — so the next capture reproduces this one
rather than re-deriving it. Also corrects a stale `splash.png` in the trust map,
which became splash.webp when it was converted.
These are not the three marks privacyllc.dev renders for the project card; that
folder says plainly that screenshots do not belong in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#40, closes#41
The illustrations span the screen and fade into the page, at roughly a third of
its height, and the seven hand-rolled step layouts became one component.
A UNIFIED STEP
Every step wrote out Spacer / Illustration(size) / Spacer / Heading / controls,
in three different spacer rhythms with a reason recorded for only two. StepBody
holds the rhythm; a step declares what is different about it. artHeight stays
per-step: the two carrying three or four controls take a shorter hero so the
primary button is reachable without scrolling, which is the constraint the old
104dp values existed for.
The screen's padding moved from the scrolling column to the content inside each
step. Compose has no negative padding, so a full-bleed child cannot live inside
a padded parent.
THE CAP IS WHAT MAKES LANDSCAPE SURVIVE
A landscape phone is 914x411dp. A fixed 280dp hero would be 68% of it and every
step would break on rotation — nothing in this app uses WindowSizeClass and
onboarding does not lock orientation. Illustration caps at a third of the screen.
It reads LocalConfiguration.screenHeightDp rather than BoxWithConstraints: inside
a verticalScroll column a child is measured with an INFINITE height constraint,
so maxHeight there is Dp.Infinity and the cap would silently never apply. A guard
that looks right and does nothing.
DENSITY BUCKETS, FIXING A LIVE DEFECT
The old set sat in a density-less drawable/, treated as mdpi and pre-scaled at
decode — roughly 17 MB of heap for one bitmap on an xxxhdpi device, and this
artwork is far larger. Now four widths per theme, 617 to 1644 px, and no plain
drawable/ at all. 2.3 MB across every bucket; a release is an AAB and Play splits
by density, so a device downloads 130-500 KB.
That is also the error-handling fix: painterResource decodes inside composition
and there is no boundary anywhere, so an OOM would crash onboarding on first
launch. Sizing the bitmaps removes the failure rather than hiding it.
The clip is gone with the corners it existed for, and the bottom fade is drawn
from the theme background rather than baked into the art. Titles move to
headlineLarge, which is what 280dp of artwork above them needs.
Driven on PeriodMinSdk26: all steps, both themes, font scale 1.0 and 2.0, and
landscape. No crash, no OOM. ./gradlew check green.
KNOWN, FILED SEPARATELY: dark 01 Welcome is a forecast scene, so dark mode shows
a calendar on step 1. The artwork is wrong, not the wiring.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#39
Sixteen files, 3:2 landscape, composed natively for the wide frame rather than
cropped or padded from the portrait originals.
THE FIRST ATTEMPT MET THE CANVAS AND NOT THE BRIEF
It reached 2048x1365 by upscaling the old portrait art ~2.8x and padding the
sides with a blurred copy of itself. The sharp region was 1024x1365 — aspect
0.750 against the originals' 0.748, the same composition — and measured edge
detail fell 2.1x-8.8x in the outer quarters on 15 of 16 files. At real hero size
a hard seam showed around the sharp centre.
This set is natively wide: real painted content to all four edges, checked by
measuring detail across each image and looking for the step change a pasted
rectangle leaves. None has one.
A DUPLICATE THAT WOULD HAVE SHIPPED
07_Forecast dark was byte-identical to 01_Welcome dark. Light was correct, so
light mode looked perfect and only dark mode showed the wrong picture, on the
last onboarding step — the silent failure OnboardingPreviews.kt exists to expose
and that #42 would fail a build over. Replaced; all sixteen hashes are unique.
Dark 07 is 1536x1024 where the rest are 2048x1365 — the same 3:2 to within
0.02%, 274.00dp against 273.93dp at a 411dp hero, and ample resolution at every
density the app ships to.
The set still carries no text, no step numbers, and a forecast illustration that
names no fields. Recorded in dist/README.md, along with two things deliberately
left unmatched: light and dark 07 are different compositions, and eight files
have a busy bottom quarter under the fade.
Sources only. 4.2 MB here; the shipped copies are downscaled into density
buckets, which is #41.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#28
The owner supplied a redrawn lockup, docs/design/dist/splash.webp, which sets
the name as "Privacy: Period Tracker" — with the space after the colon.
NECESSARY AND NOT SUFFICIENT
The tempting move was to look at the new artwork and close the issue. #28's
Verify names the two DERIVED assets, not the lockup: the wordmark in
docs/data/img/logo.webp and banner.webp had to match app_full_name, and both
still carried the old spaceless form because they were generated from the
earlier source. Regenerated from the new lockup and checked by cropping the
colon out of each finished webp and reading it at 1350px wide.
THE BANNER IS COMPOSED, NEVER CROPPED
The lockup is square and the banner is 3:1, so a crop takes the ends off the
wordmark — the trap the first pass at these assets hit. A 660px square card
centred on 2176x725, matching how the existing asset did it. No flood-fill this
time either: splash.webp has real transparency where the earlier sources
rendered their rounded corners against black.
icon.webp is deliberately untouched. It carries no wordmark, it is the same
visual family, and #28 is about the name — regenerating it would be churn
dressed as consistency.
BRAND_GUIDE.md §10 carried a callout stating the drawn mark disagreed with the
written name. That is now false, so it records what happened instead and marks
lockup-light-source.png and lockup-dark-source.png as superseded rather than
deleting them; they are the owner's files.
Also corrects a path in the previous log entry, which named splash.png in
backticks — doc-claims reads that as a claim the file is present, and it is now
splash.webp. Named bare, per the convention DOC_TRUST_MAP.md documents for a
path that deliberately does not exist. Second time this session; the lesson is
that converting a file means grepping the docs for its old name.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#38
checkNoHealthLogging fails the build on any logging call in a module that can
see a cycle date. It runs in `./gradlew check`.
WHY IT IS A GUARD AND NOT A GREP
Both traps were already live in this repository. PeriodApplication passes
android.util.Log.WARN to WorkManager as a CONSTANT, which is not a log call.
ReminderWorker's KDoc says "a Log.d in a worker is the kind that survives",
explaining why there isn't one — a naive grep fails the build on the clearest
possible explanation, and the obvious fix is to delete the explanation. So it
matches a call shape, and strips comments first.
Proved both directions per GUARDS.md §1: an injected Log.d in CycleRepository
produced exactly one failure; a comment containing Log.d( and println( stayed
green. It also failed its own first run by walking domain/*/bin/, a gitignored
IDE output holding stale copies of test files — a guard that fails on untracked
build output is one somebody switches off.
THE LEAK IT WAS NOT LOOKING FOR
Prediction's init block interpolated dates into its require messages:
require(!windowStart.isAfter(windowEnd)) { "window start $windowStart is..." }
Five predicted dates across three messages, inside an IllegalArgumentException —
the one string a crash reporter collects without anybody choosing to log it.
§45 forbids exactly this and no logging statement was involved.
The same applies to every data class, since toString() renders every field into
any string that touches it. PeriodRecord, SpottingRecord, CycleRecord,
Prediction and NotYetObservation now override it: ids and cycle lengths survive,
dates do not. NoDatesInDiagnosticsTest pins seven cases and was itself proved to
fail.
R8 -assumenosideeffects strips android.util.Log from release, covering what a
source guard cannot reach: a dependency logging on our behalf, and a module
added without being listed in the guard.
VERIFIED ON A RELEASE BUILD, NOT REASONED ABOUT
assembleRelease signed with the debug keystore, installed, driven from
onboarding to a forecast and then logging a period: zero ISO dates in logcat,
zero health words, and the only mentions of the package are the system's own. A
screenshot confirms it reached a real forecast, because "no logs" is trivially
true of an app that did nothing.
201 tests pass. All three guards green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#30
The last placeholder. It was blocked on a real constraint rather than taste: an
adaptive icon masks roughly a quarter of the canvas away, and the first emblem
had content hard against its edges, so dropping it in would have cropped the
shield.
THE NEW EMBLEM DID NOT NEED REDRAWING, IT NEEDED PLACING
It puts the meaning in the middle — calendar, shield, padlock — with the ring and
botanicals as outer decoration. Dropped in full-bleed it still crops: simulating
the circular and rounded-square masks slices the ring apart and cuts the crescent
in half. Scaled into the 66dp safe circle on §18's Deep Plum it survives both
masks whole.
Both were checked by rendering the masks, and then by installing and looking at
the launcher rather than trusting the simulation.
Foreground ships as a density ladder, mdpi through xxxhdpi, 44 KB for all five.
Release APK 2.03 -> 2.08 MB.
THE MONOCHROME LAYER STAYS THE SIMPLIFIED VECTOR
Renamed to ic_launcher_monochrome.xml, which is now what it is. A themed icon is
a silhouette — Android discards colour and tints the alpha — so the emblem's
overlapping calendar, shield, padlock, crescent and leaves would flatten into an
unreadable blob. A ring with one dot still reads at 48dp in a single colour,
which is the whole job of that layer.
Source kept at docs/design/brand/icon.webp: same 1254px, 128 KB instead of 896.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#36
The deletion has existed since Batch 01 with an instrumented test and no way to
reach it. This adds the Privacy & Security section, a confirmation that says
what goes and what stays, and PrivacyViewModelTest.
A PRIVACY CONTROL WHOSE LABEL DID NOTHING
The confirmation promises "your reminder settings are unchanged". Proving that
meant changing a setting first, so I tapped "Maximum privacy" on a device and
nothing happened: PrivacyRow and onboarding's PrivacyOption both put onClick on
the RadioButton and left the row inert. The option that decides what a lock
screen shows could only be changed by hitting a 20dp circle — in both places a
user ever chooses it.
Modifier.selectable on the row, onClick = null on the radio. That is Material's
documented pattern and it also merges the semantics, so TalkBack announces one
selectable option instead of a radio button and two loose strings.
Found by trying to verify a different claim, which is the argument for verifying
claims rather than asserting them. The setting does survive deletion — set to
Maximum privacy, deleted everything, still Maximum privacy.
A DESIGN THAT WAS WRONG BEFORE IT WAS WRITTEN
The first draft cancelled the reminder schedule on delete. Seems obviously
right; is not. ReminderWorker reads the forecast each run and NoData maps to no
decision, so scheduled work already does nothing while there is nothing to say —
and scheduling only happens from ReminderCoordinator and the settings screen, so
cancelling would have left reminders silently off until the user next toggled
something, long after logging a new period. Checked the call sites instead of
reasoning from the name.
Delete touches health data only. UserPreferences is a separate store precisely
so a privacy action cannot reset a choice somebody made, and there is no undo —
§45 says irreversible, and an undo snackbar keeps the data alive for its
timeout.
Round 4 recorded in docs/qa/. 194 tests pass; ./gradlew check, schema-guard and
doc-claims all pass. Driven on PeriodMinSdk26.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#33
Settings is now a root with §36's sections rather than one screen of notification
toggles that called itself a working surface. Reminders move underneath it as a
child destination; the banner is gone because the claim it made stopped being
true, not because the screen changed.
A ROW FOR AN UNBUILT FEATURE IS ABSENT, NOT DISABLED
§36 lists six sections and this shows the two that have something behind them.
A greyed-out "Export My Data" is a promise with no delivery date, and a disabled
"Delete My Data" tells a user that control over their own health data exists and
that they may not have it. In a product whose whole argument is that the data is
theirs, that is a bad first thing to say. Privacy & Security arrives with #34 to
#37, Appearance with Batch 08, Premium with Batch 07.
ONE COPY OF THE FERTILITY DISCLAIMER
About needs it, and it was already a literal in TodayScreen and CalendarScreen —
a third copy would have made two versions of a safety promise inevitable. Now a
string resource all three read. Notifications still deliberately omit it: a long
sentence about fertility on a lock screen is the leak the discreet copy exists
to prevent.
The version is read from PackageManager rather than BuildConfig, so it reports
what is actually installed — the number a person reads out when something is
wrong.
A DOC TRIGGER THAT COULD NOT FIRE
docs/design/README.md's trigger is "any new user-facing screen or state" while
its Governs listed only docs/design/** and core/designsystem/**, so adding a
screen fired nothing. Same class of defect as the architecture doc's, found the
same way — by adding the thing the trigger is about and watching it not fire.
Widened, and verified by running doc-triggers.py against the new file.
Driven on PeriodMinSdk26: the root renders, rows expand in place, reminders open
as a child, and back returns to the root rather than exiting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At font_scale 2.0 the bottom navigation read "Calenda / r" and "Setting / s".
NavigationBarItem's label had no maxLines, so Compose wrapped it rather than
truncating, breaking a word across two lines in a 4-item tab bar.
maxLines = 1 with an ellipsis degrades to "Calen…" instead — still recognisable,
and the icon above it carries the meaning regardless.
FOUND BY DOING THE THING NOBODY HAD DONE
Font scaling was a standing QA gap: written down, never run. Driven at 1.3 and
2.0 on PeriodMinSdk26, through onboarding to Today.
Everything else held. The 72sp hero survives 2.0, Today scrolls so nothing below
the fold is lost, and every onboarding step keeps its primary button reachable —
including the two that carry three buttons or three option cards beneath an
illustration, which is why their art is 104dp where the rest take 120-128dp.
That sizing was guessed when the artwork landed and is now checked.
This defect is older than the artwork work and unrelated to it; the font-scale
pass is simply the first thing that looked.
Recorded in docs/qa/ClaudeQACoverage.md, where the gap it closes was listed.
Still unreached: font scaling in dark mode, and on anything other than a phone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
closes#29
All eight illustrations are in the app, every onboarding step has art for the
first time, and the theme swap is resource resolution rather than a branch:
drawable/ and drawable-night/, so Android picks the asset and the artwork follows
a device theme change with nothing of ours running.
NOT VECTORS, AND THE ISSUE WAS WRONG TO ASK FOR THEM
#29 called for a redraw as Compose vector paths taking colour from theme tokens.
That is right for artwork that does not exist yet and wrong for gradient
landscapes with glow and depth — there is no honest VectorDrawable of one, and
the attempt loses the drawing or produces path soup slower than the image. §42
objects to *unnecessary* raster, which this is not.
The cost was never measured before it was argued about: all sixteen files at the
height they are actually drawn at come to 130 KB. Release APK 1.90 -> 2.03 MB.
WHAT ONLY A DEVICE SHOWED
Each drawing is composed as a card with its own rounded corner painted in, so the
pixels outside that curve are the card's own backdrop — near-black in the dark
set. Rendered unclipped, every illustration showed four notches against the app
background. Illustration() now clips just inside the painted curve.
WHAT STAYED A VECTOR
The calendar markers and CycleProgressMark, because they are drawn from data — a
marker's shape is what a day is, and the progress arc is where the user stands in
their cycle. CycleProgressMark moves from Illustrations.kt to CycleMarkers.kt so
the file layout carries that boundary.
Sized by height rather than a square box: the set has no common aspect ratio, and
height is the dimension that decides whether the primary button stays on screen.
Steps 3 and 6 take 104dp because they carry three buttons and three option cards;
verified by driving all seven steps, not by reasoning about it.
OnboardingPreviews.kt now has one entry per illustration, because a missing
drawable-night asset is invisible in light mode.
Driven end to end on PeriodMinSdk26 in both themes, no crash. ./gradlew check,
schema-guard and doc-claims all pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
EmptyStateIllustration in both themes — Today's "nothing logged yet" state, and
the one illustration the onboarding set did not cover. Without it, onboarding
would have shipped real art while Today kept a placeholder a user sees in the
same session.
Deliberately unnumbered: it is not an onboarding step. Its ring is empty because
nothing has been logged, which is the same ring the other illustrations fill with
cycle-day markers — that is what makes the empty state read as waiting rather
than broken.
Two things recorded in dist/README.md for whoever redraws these as vectors:
- The fourteen onboarding illustrations are contained cards with a rounded
frame; these two are full-bleed with none. The frame should be consistent
across the set or deliberately absent from all of it.
- TodayScreen.kt calls EmptyStateIllustration(color = ..., size = ...) with a
single colour. These are full-colour drawings, so the vector version takes no
colour parameter and that call site changes with it.
Both were checked at the 110-160 dp the illustrations are actually drawn at, by
rendering them at that size rather than assuming: the detail survives, including
the five faint dots under the ring.
WebP at delivered resolution: 312 KB for all sixteen against 6.8 MB as PNG.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven illustrations in a matched pair, replacing the single light-only concept
set added in 9d2a4b2. Named for the composables they replace, and identical in
dimensions per card so a theme switch swaps the image without moving the layout.
Three corrections happened during review and each is easy to reintroduce by
regenerating carelessly, so dist/README.md records them:
- No text painted into the pixels. The app renders all seven titles through
Heading(...) already; art carrying its own title prints every heading twice,
cannot be translated, cannot scale with font size, and is invisible to
TalkBack.
- No step-number badge. LearningIllustration is also the Insights empty state,
where a "4" means nothing, and baked numbers freeze a running order that has
already changed once.
- 07_Forecast names no fields. One draft listed Fertile window and Ovulation,
which the first-forecast screen deliberately withholds — on a fresh install it
reports confidence Low and Today reads "Not enough history to estimate". A
later draft named the right fields but painted "Confidence — Low"
permanently, wrong for anyone with a settled cycle. Naming nothing is the only
version that cannot contradict the live forecast.
The dark set is a real dark composition rather than the light one dimmed, on the
midnight-plum ground BRAND_GUIDE.md §22 specifies.
WebP at the delivered resolution: 204 KB for all fourteen against 3.4 MB as PNG.
Delivery .zip archives are gitignored; the superseded concept set is removed and
remains recoverable at 9d2a4b2.
#29 stays open — these are references, and the vectors that ship are drawn from
them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven cards, one per onboarding step, supplied by the owner and generated from
BRAND_GUIDE.md §22's own master prompt — which is the workflow that section
exists to describe. They settle the direction #29 asked for, including all four
steps that had no art at all.
They are NOT the shipping assets, and docs/design/dist/README.md says so at the
point somebody would otherwise drop them into res/drawable:
- raster, against a 1.9 MB release APK and §42's ask for vector paths
- titles and subtitles painted into the pixels, while the app already renders
all seven through Heading(...) — used as-is every title appears twice, and
baked text cannot be translated, scaled with font size, or read by TalkBack
- light mode only, with dark mode inbound and a preview-pair rule that exists
because dark mode was silently broken for all of Batch 01
- card 7 shows a fertile window and ovulation that the first-forecast screen
deliberately withholds; on a fresh install it shows confidence Low and Today
reads "Not enough history to estimate"
Converted from the delivered PNGs to WebP at the original 1086x1448: same
resolution, 340 KB instead of 12 MB, and the format docs/data/img already uses.
Renamed from the generator's default filenames so the set reads as a sequence.
#29 stays open with the assessment recorded as a comment on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Welcoming, and visibly private" is the owner's directive for the app's artwork,
and it needs somewhere to live that outlasts the issues it was given for. A brief
that exists only in issue bodies disappears the day those issues close.
docs/design/README.md owns what the app should feel like, so the artwork section
now carries it, along with why the two halves are stated together: onboarding
today opens warm and then turns into a form, and the privacy idea appears on one
screen out of seven.
Also notes that the enclosure motif already in PrivacyIllustration is the cheaper
route to the second half than a padlock, which would read as a security product
rather than a calm one.
The work itself stays in the tracker as #29 to #32.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second AVD exists — PeriodMinSdk26, API 26, Pixel 6 — and the app was built,
installed and driven on it end to end. Everything before today ran on API 36,
which the standing gaps list has said since Batch 01.
The QA plan now names both emulators, because rounds cite PeriodQA by name and
nothing defined it. It also carries the recreate command and three mechanical
traps that each cost time today:
- a windowed emulator dies with its session's X server (XIO: fatal IO error on
X server ":0") and took one run down mid-flow; -no-window has no such
dependency and screencap still works
- adb regularly has three devices attached on this machine, since other projects
keep emulators running, so the serial must be resolved from the AVD name
rather than assumed to be emulator-5554
- the debug build's application id carries a .debug suffix, so
`monkey -p dev.privacyllc.period` aborts with "No activities found to run"
after a successful install
The coverage doc's "No device at minSdk" gap is marked partly closed, with what
was reached (onboarding to first forecast, the date picker, all four tabs, a
relaunch after a cold boot) and what was not (notifications, the instrumented
suites, anything needing more than one cycle of history).
Also fixes a guard failure I introduced in ad085fb: that commit's log entry
named docs/architecture/scripts/forgejo-issue.py in backticks, and doc-claims.sh
reads a backticked path as a claim the file exists. I had run the guard before
writing the entry and not after. Named bare now, per the convention
DOC_TRUST_MAP.md already documents; doc-claims reports 255 paths, all present.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WORK_CYCLE.md's check-in example passed "projectId":"<slug>", which every other
route on this API takes. This one does not: a slug is rejected with
{"error":{"code":"unknown","message":"Something went wrong."}} — a message that
names no field and reads like an outage. Cost two failed posts before the cause
was found, on the run that wrote the entry above.
The section now reads the id off the project first, and the extraction command
in it was run against the live API rather than written from memory. Also records
that there is no route to amend a check-in once posted, so the body has to be
right on the first send.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every document in the tree audited against the source, the tracker and git
history, each finding then given to a second reader tasked with refuting it.
74 raised, 12 refuted, 57 applied. No code changed.
THE README DESCRIBED A SKELETON
Its Status table — the one place a claim about what is built is allowed to live
— still read "there is no usable app yet", with Not built against Room, the four
core screens, fertility and notifications, and No round run against QA. Five
batches had shipped and three QA rounds had run.
TWO DOCUMENTS WERE SILENTLY NEVER FIRING
architecture/README.md and design/README.md wrote Governs: as prose ("the Gradle
module graph", "the design tokens in core/designsystem"). Neither contains a
path token, so doc-triggers.py reduced them to globs matching nothing, and one
real glob apiece made them look path-governing rather than subject-governing —
the state the script's own header calls invisible. Editing a Room entity never
fired the document owning the migration table. Both now fire, proved by running
the script.
SECURITY.md CLAIMED FOUR UNBUILT PROTECTIONS
App lock listed among what works offline; biometric/PIN gating described as
protecting app launch; the incognito launcher as existing; Play Billing in the
third parties table without the "not yet integrated" marker its neighbours
carry. All are Batch 06/07 work.
The advertising boundary was overstated in SECURITY.md and the README alike:
both said the ads module declares no dependency and a guard proves it. There is
no ads module. The pre-declared ":core:ads" to emptySet() rule is stricter than
the sentence it replaced and matches nothing until Batch 07, which is why the
guard is proved by injection rather than trusted.
SMALLER, EACH A REAL TRAP
WORK_CYCLE.md pointed at docs/architecture/scripts/forgejo-issue.py, a template
path absent here — missed by doc-claims.sh, which reads backticked prose and not
fenced blocks. ClaudeReport.md's Round notes said "No rounds yet" after three
rounds because ClaudeQAPlan.md's after-a-round list never named that section;
the playbook is fixed first. The instrumented-test count was eight in three
places and is four. HISTORY.md said the repository had no code and that nothing
had been tried and dropped, when three approaches had.
DELIBERATELY UNCHANGED
ClaudeReport.md's last verified build SHA stays at 0451fbe — no round has run
since, and moving it would claim a verification nobody performed. Every
DEVELOPMENT_LOG entry stays as written.
Guards: ./gradlew check, schema-guard.sh, doc-claims.sh (235 claimed paths, all
present), doc-triggers.py, and a link sweep over 21 markdown files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rename was mostly a set of decisions about what NOT to touch: 239 domain
references, the Kotlin package, the applicationId and period.db all stay,
because "Period" is the central domain word as well as the old product name.
Records the two things a later reader would otherwise retry: the Command Center
slug cannot be changed (PATCH accepts the field and ignores it, and recreating
the project to change an invisible internal key would throw away its history),
and three names have to agree — repository, git remote, and the site's
forgejoRepo mapping — with the third being the one that fails silently.
Issue #9's body named the old repository path and has been corrected in the
tracker, so the command in it works for whoever picks it up.
"Period" was always a working name — PRODUCT_PLAN.md §55 said so. The real
identity arrived with the brand guide and artwork, and this makes the project
call itself by it.
The repository is renamed in place on Forgejo (null/Period ->
null/Privacy-Period-Tracker), which keeps all 30 commits, all 27 issues, all 8
milestones and the four severity labels — verified by counting them on both
sides rather than assuming a rename is lossless. The git remote follows in the
same breath, so this commit's automatic push is what proves the new URL works.
WHAT DELIBERATELY DID NOT MOVE
"Period" is the product name AND the central domain word, and a mass rename
would have turned the data model into nonsense. PeriodRecord, PeriodWriteResult,
confirmPeriodStart, the period_records table and the button that says "Started
period" all describe a menstrual period rather than a product, and all 239
references to them are untouched.
So are the Kotlin package and applicationId. dev.privacyllc.period already reads
correctly under the new name — privacyllc is the company, period is the app —
and changing it would rewrite 68 files, rename the Room schema directory and
break the hardcoded path in schema-guard.sh for no gain. period.db stays for a
sharper reason: renaming a database file orphans the data on every device that
already has it.
Sixteen files changed, and the diff is small on purpose.
THE NAME HAS A SPACE AND THE ARTWORK DOES NOT
Canonical is "Privacy: Period Tracker". The supplied wordmark sets it without
one, so logo.webp and banner.webp now disagree with every document and with the
app itself. BRAND_GUIDE.md §10 is updated to the space form and says plainly
that the drawn mark has not caught up. Filed separately rather than papered
over, because closing that gap needs an artist and not a rename.
Round 3 adds pass F, which became runnable for the first time. Eight
instrumented tests assert what a lock screen would render; the row says plainly
that nobody has yet looked at an actual locked screen, because the tests check
the notification object and the last mile is what the system chooses to draw.
Three guards have now failed their first proof — schema, boundaries,
permissions. Recorded as a rule rather than a run of bad luck: assume a new
guard is broken until it has been watched failing.
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
Records the fertility work, the third instance of "the app declines rather than
stretches", and the pre-commit hook that made `git rm` impossible.
Also records my own error rather than tidying it away: `git reset --hard` after
the post-commit hook has pushed does not undo the push, and it discarded an
uncommitted fix mid-proof. Reconciled forward instead of rewriting published
history.
TOOLS.md said "exit code 2 is never a pass", which is right and was only half
the rule. The pre-commit hook read it as "2 is always a refusal" and made every
deletion-only commit impossible while reporting a credential that did not exist.
Both documents now carry the completed rule: on a 2, ask whether the check could
have had anything to look at. The hooks README records the one place the two
scripts are treated differently and why.
Commit 6fc12b6 was a throwaway used to prove the pre-commit hook would accept a
deletion-only commit. It was meant to be discarded locally, and it was — but the
post-commit hook had already pushed it, so the deletion reached origin while the
local reset removed the commit from my branch.
Restored forward rather than force-pushed. The commit is on the remote, and
rewriting published history to hide a two-minute mistake is a worse trade than a
fix that explains itself.
The lesson is why this message is longer than the diff. `git reset --hard` after
a commit the post-commit hook has already pushed does not undo the push — and it
silently discarded the uncommitted hook fix I was in the middle of proving,
which is why the second proof then failed for a reason that had nothing to do
with the guard. Commit the work before proving the guard, not after.
secrets.sh exits 2 for "nothing was scanned", which is the right answer for a
commit that only deletes files — a deletion has no added lines to look at. The
hook treated any non-zero as a refusal and printed "possible credential in the
staged changes" while doing it.
So `git rm` was impossible, and the message pointed at a security problem that
did not exist. Found while removing a bin/ directory that had been committed by
accident; the commit only went through because a .gitignore edit happened to be
staged alongside it, which gave the scanner something to read.
The safety property is kept rather than traded away: a 2 still refuses whenever
the staged diff adds any lines, because then the scanner did have something to
look at and checking nothing is exactly the failure exit 2 exists to report.
Eclipse/Buildship writes a duplicate copy of every .kt file into bin/main and
bin/test for the pure-JVM modules. Fourteen of them reached the previous commit,
because a directory full of Kotlin files is the one kind of build output that
does not look like build output in a `git status`.
Removed from the index and ignored. Nothing was lost — every file under bin/ was
a copy of one under src/.
§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
Round 2 covers every screen Batch 03 built, pass by pass, with each partial and
not-run row carrying why. Three new standing gaps, all of them things nobody has
done rather than things that failed: TalkBack has never been run, text has never
been scaled, and nothing has run at minSdk.
The rule this session keeps demonstrating is now stated plainly in both files:
the defects in this project are found by running it, not by reading it.
§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
Compose vector paths in core/designsystem/art — no raster anywhere under src/,
so everything stays crisp at any density and adds nothing to the APK.
The four calendar markers differ in SHAPE, not only colour: a solid disc for a
confirmed period, a dotted outline for a predicted one, a continuous ring for
the fertile window, and a four-pointed mark for ovulation. §26 and §43 both
require it and for the same reason — a calendar whose states differ only in
colour carries no information for a colourblind user, none in greyscale, and
none in the bug report somebody files about it. Predicted is deliberately not a
lighter confirmed: opacity alone fails exactly when the screen is dim.
Illustrations for welcome, empty state, learning and the privacy promise, plus
the cycle progress mark for Today. The visual language is overlapping circular
forms and nothing else — §42's forbidden list is a product decision, not
squeamishness: this app gets opened in public and a glance over a shoulder
should learn nothing.
Placeholders here and never in docs/data/img, which docs/design/README.md now
explains rather than leaving as an apparent inconsistency: §42 asks for these
explicitly, a replaceable name keeps them replaceable, and a screen with no
illustration cannot be evaluated while a project card with no icon just shows
initials.
closes#15
Three consecutive guards and models were wrong in ways reading them would never
have shown, and each was caught by running it: the Room schema check that Room
itself defeated, the boundary check that examined nothing, and three modelling
faults in the engine. Recorded as a habit rather than three coincidences.
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
IntervalAnalysis turns confirmed starts into weighted intervals and decides what
looks questionable — relative to this user's own history, never to a constant.
That distinction is the whole point. A global "over 40 days is suspicious" rule
gets exactly one group wrong, and it is the group whose cycles are already
unusual: the person this product exists for, and the one most tired of apps
assuming she is average. 45 days is unremarkable at a usual of 43 and worth
questioning at a usual of 29. Both are tests.
Two flags, because they earn different responses. A gap near a whole multiple of
the usual is a probable missed entry and produces §14's question. A gap merely
far from usual is down-weighted and left alone — asking would be the
over-questioning §25 warns against, and §51's 45-against-29 outlier is exactly
that case.
Nothing is ever dropped. §12 step 2: unusual data is marked for review or given
less influence, never silently deleted. A questionable interval keeps a quarter
of its recency weight, and a test asserts it is still present and still counts.
Recency decay is here too, ready for the centre in #10: newest cycle weight 1.0,
each older one 0.85 of the last.
12 new tests. The §51 acceptance cases still pass unchanged.
closes#13
Round 1 was partial and says so, pass by pass, with each not-run row carrying
why. It found two defects that 70 green unit tests could not: the primary
button crashing the app on a second tap, and two API 34 date calls on the
recalculation path that would crash every device below Android 14.
Neither is filed in the tracker. Both were found and fixed inside the batch that
introduced them, before any build left this repository, and an issue closed by
the commit that created the code would be bookkeeping rather than a record.
New standing gap: nothing has been run on a device at this project's own minSdk.
Lint is a good guard for that and is not a substitute for one run on API 26.
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
core/data is the seam between storage and everything else. Reads return domain
types, cycles are derived rather than stored, and the forecast is a function of
the data instead of a field somebody has to remember to refresh — so §11's
"recalculate after a confirmed start, after an edit, after a Not yet" is
automatic rather than three call sites.
Confirming a period is four writes in one transaction, because a partial result
is a corrupt history rather than a failed action: write the record, score the
forecast that was standing, clear the "not yet" observations it resolved, and
snapshot a fresh forecast.
THE DEFECT THIS FOUND
A test expecting one scored prediction found three. The cause was not the test:
every historical period entered during onboarding was scoring the current
forecast against a date in the past, inventing an error for a prediction nobody
had ever been shown. §16's "your predictions are getting better" would have been
populated with figures the app made up about itself — plausible ones, which is
what makes it expensive to notice.
Two rules now, both pinned by tests:
- exactly one unscored snapshot exists at a time. A forecast superseded before
its outcome was known is not a wrong forecast, and counting it lets one
cycle contribute several errors.
- a confirmed start only scores a forecast made on or before it. Anything
earlier is backfill and leaves the standing forecast alone.
Accuracy also stays quiet below three scored predictions. One lucky forecast
reading "average error: 0 days" is an overstatement, not a measurement.
THE ROOM BOUNDARY, HELD THREE WAYS
implementation rather than api on core:database; CycleRepository's constructor
internal because it names a PeriodDatabase; reads mapped to domain types in
Mappers.kt. Callers use CycleData.repository(context) and never learn Room
exists. Verified rather than asserted: grep -rn "androidx.room" app/src domain
is empty, and Room appears zero times in :app's debugCompileClasspath.
No fallbackToDestructiveMigration: it turns a forgotten migration into a silent
wipe of the user's entire cycle history on update.
Also fixed: `domain/*` inside a KDoc silently opened a nested block comment —
Kotlin block comments nest — which broke compilation in a way the error message
pointed nowhere near.
58 tests across the project, all passing.
closes#5
core/datastore holds the settings from PRODUCT_PLAN.md §10 — notification
privacy, reminder time, the three reminder toggles, biometric lock, theme, the
ads entitlement and whether onboarding finished.
Two defaults are decisions, and each has a test whose job is to stop it being
changed by accident:
- notification privacy defaults to DISCREET (§28). A default of DIRECT would
put menstrual detail on a lock screen before the user has been asked a
single question, and a notification read over a shoulder is the likeliest
real privacy breach in this product.
- fertility reminders default to off. Most users are not tracking fertility
and an unrequested ovulation notification is an unpleasant surprise.
An unrecognised stored value falls back to the SAFE option rather than to
whatever enum entry happens to be first — a rollback or a hand-edited file must
not be able to turn DISCREET into DIRECT. Tested for privacy mode, theme and an
out-of-range reminder time.
This is a separate store rather than two more Room tables, and the reason is a
deletion semantic: Delete My Data removes the health history and must leave the
settings alone. Handing a user back a weaker privacy setting at the exact
moment they are exercising a privacy control is the worst possible time to do
it, and separate stores make the correct behaviour the easy one.
The repository takes a DataStore rather than a Context, so its 10 tests run on
the JVM against a temporary file — no emulator, no Robolectric. The Android
instance is supplied by DI at the app layer, the only place that should know
where a file lives.
41 tests across the project, all passing.
closes#4
core/database holds the four entities from PRODUCT_PLAN.md §10 —
period_records, spotting_records, prediction_records, not_yet_observations —
with DAOs returning Flow, epoch-day/epoch-milli converters, and the schema
exported to core/database/schemas and committed.
Three constraints are structural rather than remembered:
- startDate is UNIQUE and inserts ABORT rather than REPLACE. REPLACE would
delete the original row with its createdAt and source; §14 says health
history is never modified silently.
- spotting has its own table, so no query for periods can reach it. §25: it
must never start or reset a cycle.
- a prediction snapshot can be scored but not rewritten — score() sets only
actualStartDate and absoluteErrorDays. A snapshot editable after the fact
can only ever report that the app was right, which would make §16's whole
accuracy feature a lie.
deleteEverything() is one transaction and the only bulk delete in the module: a
partial wipe leaves the cycle reconstructible from the tables the user asked to
be rid of.
14 tests, on the JVM under Robolectric — no emulator.
THE SCHEMA GUARD, AND WHY IT IS A SCRIPT
SchemaTest was written as a drift guard and proved not to be one. Room
regenerates the schema export during compilation, so adding a column to
PeriodRecordEntity without bumping VERSION leaves the suite green while the
committed schema quietly changes underneath it. That was not reasoned about, it
was run: the column was added, 1.json gained it, and every test passed. On a
device that is "Room cannot verify the data integrity" — a crash on update,
after shipping.
scripts/schema-guard.sh asks git instead, which Room cannot overwrite. Proved
both ways before being trusted: green on a clean tree, exit 1 on the injected
drift. It runs in pre-commit when an entity or the schema directory is staged,
and the hook treats its exit 2 as a refusal.
SchemaTest keeps its four tests and now documents what it does not catch.
Room's own MigrationTestHelper is not used: every constructor needs an
Instrumentation and schema assets, and AGP 9's library source-set DSL throws
DefaultAndroidLibrarySourceSet_Decorated cannot be cast to
AndroidLibrarySourceSet when you add an asset directory. Recorded so the next
person does not spend the afternoon on it.
Docs updated in this commit, as their triggers required: the migration table
now has its version 1 row and the trap that makes such tables go stale, TOOLS
explains the seventh script, and the hooks README lists the new guard.
closes#3
doc-claims.sh reported 19 claimed paths that do not exist. Every one was a
deliberate forward reference — the planned modules in the architecture table,
the two documents the trust map records as absent on purpose, and the release
script Period declined.
A backticked path is read as a claim the file is there, so a document saying
"core/database does not exist yet" was asserting the opposite of what it meant.
docs/history/BATCH_LEDGER.md already records the idiom for this case; it is now
applied and stated where it is used, so the next forward reference does not
reintroduce the failure.
Also corrects WORK_CYCLE.md, inherited from the template, which pointed at a
scripts/release.sh this project does not have. A release here is a signed AAB
and a Play submission, so the security checklist carries that procedure.
doc-claims.sh now reports 152 claimed paths, all present, across 20 files.
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