Commit Graph

28 Commits

Author SHA1 Message Date
null a12d8e5c49 fix: give every settings page a way out
Reported: "on the settings page, if you go to app lock you can't
unnavigate out of it." Two causes, both real.

The bottom nav saved and restored per-tab back stacks. From App lock,
tapping the Settings tab popped [settings, settings/lock], saved it, and
restored it in the same breath -- landing back on App lock. The saved
stack survived visiting other tabs, so the Settings tab stayed pinned to
App lock for the rest of the process. The KDoc above the NavHost claimed
the opposite.

And no screen in the app had a back arrow. A grep for TopAppBar,
navigationIcon, BackHandler and popBackStack across app/, core/ and
domain/ returned nothing at all. App lock had a headline styled like a
bar without being one, so the affordance a user reaches for was a label,
and after setting a PIN the only button on screen -- "Done" -- cleared a
message and navigated nowhere.

Settings is now a nested graph. Its children are inside the tab's
hierarchy, so the tab renders as selected on App lock rather than looking
unselected and inviting the tap that trapped you; and re-tapping the tab
you are already on pops to its root, which is the gesture people reach
for. Leaving Settings pops without saving, so there is nothing to
restore. Today, Calendar and Insights keep their place exactly as before.

One SettingsSubpage component carries the bar for all three children.
Three copies would drift -- one would get the ellipsis for long titles at
font scale 2.0 and the others would wrap mid-word, which is a defect the
tab labels already shipped once.

App lock's steps are remembered state, not destinations, so its back is
step-aware: inside a step the arrow and the system gesture both return to
the overview, and at the overview the handler is disabled so the gesture
falls through and pops the destination, exactly as the arrow does. Two
controls a hand's width apart now do the same thing.

ExportHost moves into the Scaffold's topBar. It was a sibling emitted
BEFORE the Scaffold inside PeriodTheme's Surface -- a Box, where later
siblings draw over earlier ones -- so an opaque Scaffold was painted on
top of it. It has almost certainly never been visible to anyone.

Compose UI tests run on the JVM under Robolectric; nothing in this project
could assert a navigation behaviour before. Proved with prove-guard, one
red each: unwiring the arrow, and removing the step-aware BackHandler.

Verified on the emulator: from App lock, tapping Settings now lands on the
settings tree, the Settings tab is highlighted while on a child, and the
arrow returns.

closes #61

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:49:57 -05:00
null ca7a187520 fix: make changing the PIN require knowing it
Change PIN asked for the current one, and then moved to "Choose a PIN"
without waiting for the answer. The check is asynchronous; the screen
reassigned its step outside the result and passed an empty callback. Any
four digits reached the replacement screen, and setPin enrolls without
verifying anything.

So anyone holding the phone while it was unlocked could change the app's
PIN. Under the no-recovery policy the owner's only way back into her own
history is to erase all of it. The file's own KDoc says this must not be
possible.

The step now advances from inside the verified callback, as "turn the
lock off" already did -- and the ViewModel refuses a replacement that no
successful check authorised. Two guards for one rule on purpose: a screen
is the kind of file that gets rewritten by somebody who has not read the
one behind it, and the place that writes the PIN is the place that has to
refuse. Cancelling withdraws the permission; a successful write spends it.

"Turn the lock off" had the same advance-before-answer shape. It was safe
-- the work was already inside the callback -- but its wrong-PIN message
landed on a screen that had gone, so ConfirmPin.wrong was dead code. Fixed
symmetrically.

Also fixes the lock-out race in the same function (#62). setPin wrote the
PIN and then unlocked the session; the gate is combine(hasPin, unlocked)
and closes on (true, false), so DataStore's emission could arrive in
between. AppLockGate disposes the whole app subtree when it closes, this
ViewModel is scoped to a destination inside it, and the unlock was
cancelled with the scope -- the user was thrown to the lock screen to type
the PIN she had chosen a second earlier. The development log records the
common case as fixed; the fix lived in the scope the race destroyed.

Unlocking first makes the bad pair unobservable: unlock() sets a
MutableStateFlow synchronously on this thread, before the write begins,
and combine always emits with the latest of both. If the write fails there
is no PIN and Unlocked is correct anyway.

The settings ViewModel had no test at all, which is how a wrong PIN
reaching the replacement screen went unnoticed. It has five now, against
the real repository over a host-JVM signing key -- core/security gains a
small public two-argument constructor for that, since AndroidKeyStore
cannot be reached off-device and faking the repository would prove nothing
about it.

Proved with scripts/prove-guard.sh, one red each: spending the
authorisation, and the old write-then-unlock order. Removing the
write-site guard entirely reddens three, which is that guard's whole
surface rather than a coincidence.

closes #60
closes #62

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:35:48 -05:00
null bde6528547 fix: stop "Ended" from recording the start of a period
The period-end check-in asks "Is your period over?" and offers Ended and
Still going. Tapping Ended inserted a NEW period record starting today,
in the middle of the period it was asking about. Still going filed a
censoring observation against a forecast that had already arrived.

The labels were chosen in NotificationCopy and the writes were attached
in ReminderWorker by position -- index 0 to "started", index 1 to "not
yet", for every kind of reminder. That holds while every reminder asks
the same question. It stopped holding the moment one did not.

It corrupted the health record and every forecast built on it, and the
user had no way to see it happen.

A button is now one thing carrying both halves: NotificationCopy.buttons
returns the label and the action together, and nothing downstream is
allowed to pair them up again. ENDED closes the period that is running
through setPeriodEnd -- the same call the Today screen makes -- and never
opens one. STILL_GOING deliberately writes nothing: it is the state the
record is already in, and the in-app equivalent is a no-op that would
still move updatedAt and read, in the history, as an edit she never made.

A start confirmed from a notification is now sourced
NOTIFICATION_CONFIRMATION rather than MANUAL. How a record arrived is
part of the record.

Actions travel as their enum name, and anything unrecognised -- including
the strings used before this change -- writes nothing. A notification
sitting in somebody's shade across the upgrade still opens the app; it
just does not guess what she meant. The extra key is now declared once in
core/notifications and read by MainActivity rather than repeated as a
literal on both sides.

The handler had no test at all, which is how this survived: it owns the
only two writes reachable from a locked phone. It has eight now, and the
first is not about a write -- it asserts the two halves agree, in every
privacy mode, as a property.

Proved: mutating the already-closed guard out reddens exactly one test
(scripts/prove-guard.sh). Reverting ENDED to its old write reddens three,
which is the whole ENDED semantics and not a coincidence.

closes #68

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 21:28:47 -05:00
null 866b7055f5 feat: project the next year onto the calendar, and say what it assumes
"Will I have my period the week of the wedding?" is a core prediction
question the app could not answer. The calendar marked only the next
forecast window and stopped browsing one month out, so a textbook-regular
user paged forward into blank months.

CycleProjection reaches a year. It is a separate type from Prediction,
deliberately: a forecast is something the app scores itself on -- the
period arrives inside the window or it does not, and PredictionRecord
writes down which -- while a projection eleven cycles out is never
scored, never learned from, and will have been replaced four times before
its date arrives. Separate types mean a projection cannot reach the
accuracy figures by accident.

Three rules keep it honest, each with a test named after it.

Uncertainty grows as sqrt(n), because cycle lengths are near-independent
draws: eleven cycles out is about three times as uncertain as one, not
eleven times. Real cycles do correlate, so that is the optimistic edge --
which is the second reason the assumption sentence is mandatory.

It declines rather than stretching. Past ten days either side a
projection stops being an answer, so the projection ends and reports that
it ended -- FertilityEstimate's precedent, which already refuses to show
a seventeen-day fertile window. The caller can tell "no period due here"
from "cannot see this far", and only the second invites her to log more.

The assumption is stated, not implied. Any month showing projected marks
carries "If your cycles continue as they have, this is the forecast" with
the confidence for that distance beside it. A year-ahead date drawn
without that is the clearest overstatement this app could make.

Cycle 1 is the engine's own forecast copied through unchanged, so the
calendar and the Today screen cannot disagree about the next period. The
typical cycle length comes from her own history and never a population
default -- projecting 29 days for a 35-day user is §3's core defect
repeated twelve times over.

PROJECTED_PERIOD is its own mark: the same dashed ring as a prediction,
thinner and finer. The family resemblance is the message -- still a
period, weaker claim -- and it survives greyscale, which an opacity
difference would not. Screen readers get the distance in the label, since
they cannot see that a square is months away.

Browsing now reaches a year; logging still stops at today.

ConfidenceRow and readable() move to feature/common and are shared with
the Today screen rather than copied. Two of them drifting apart is a
screen saying Medium in dots and Low in words.

closes #57

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:38:30 -05:00
null 426f5992c7 docs: separate the constants that are physiology from the ones that are tuning
PersonalPredictionEngine has thirty constants and no way to tell which
kind each is. Both are argued for in comments, in the same voice, so the
difference is invisible: MAD_TO_SCALE was tuned against fixtures and can
be retuned by anyone with a measurement, while FERTILE_DAYS_BEFORE_-
OVULATION is a claim about how long sperm survive and cannot.

SCIENCE.md is that line, with a verdict and a citation per constant:
supported, needs-a-caveat, contradicted, or not-a-scientific-claim. The
last category is listed deliberately, so nobody demands a citation for a
tuning parameter and nobody mistakes one for physiology and refuses to
tune it.

It is a sibling of architecture/README rather than a section in it. That
document's subject is the module graph, and it fires its review trigger on
a new Gradle module or a changed Room entity; the evidence for a luteal
phase changes when somebody publishes. Two subjects with unrelated review
cycles in one document means the one nobody is looking at goes stale
behind the one they are.

One value changed as a result. POPULATION_DEFAULT_DAYS -- the single
length assumed for a user with one confirmed period and no interval yet --
moves 28.0 to 29.0. Twenty-eight is the number everyone knows and the
large app-data studies agree it is not the average; the mean sits near 29
and only a minority of women average 28. The app's one unavoidable guess
is now the population's actual centre rather than folklore's.
BaselinePredictionEngine keeps 28.0: it is the frozen control, and moving
it would move the yardstick.

The luteal phase is filed rather than changed (#56). Fourteen is above the
measured mean of 12-13, but moving it shifts what the fertility screen
shows every user -- a product decision with its own evidence and QA, not a
line inside a calibration change.

Also fixes the one user-facing claim that outran its measurements. The
Insights banner told a user one cycle in that "a few more and the
predictions get noticeably better" -- an accuracy promise made before a
single forecast had been scored, and for a woman whose cycles genuinely
vary, one the engine can never keep: her forecast gets more honest, not
more precise. It now promises the mechanism, which is true by
construction and is the standard the rest of that screen already meets.

closes #54
closes #55

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 16:32:16 -05:00
null 93ec5b7f91 ui: add discreet launcher alias
closes #31
2026-08-20 02:37:29 -05:00
null 9a0a644fdb feat: export my data, as one plaintext file the user places
"Your cycle belongs to you" was a promise the app could not keep: there was no
way to get the data out.

## The format, because it outlives the batch

One pretty-printed JSON file. The issue asks for "human-readable" and means it —
this is what somebody's archive will be in for years, so it is a contract with
eight rules written down beside it, not an implementation detail.

Pinned byte-for-byte against a committed golden file, which doubles as the
documented example so SECURITY.md links at it rather than keeping a second copy
that would drift. A reformat, a reordered key or a changed date rendering all
fail in a test rather than in an archive.

Dates are ISO calendar dates with no timezone and no conversion, ever.
Converters.kt stores a LocalDate as its epoch day precisely so it "cannot carry
a timezone by accident", and a zone-aware formatter here would shift every date
for users east or west of whoever wrote it — a cycle tracker off by one day is
wrong in the way that matters. There is a test that renders the same fixture in
UTC, +14 and -12 and requires identical bytes, because that bug would never fire
where it was written.

## Plaintext, and that is the decision rather than the default

An earlier note said this would be encrypted. It should not be, and SECURITY.md
now says why: the export is the copy that makes a lost Keystore key survivable
instead of final — the exact condition recorded for ever revisiting database
encryption. Putting it behind a passphrase reproduces the failure that decision
was taken to avoid: a forgotten secret and an archive nobody, including this
app, can open. §45's "prefer encrypted backup/export formats" is scoped to
backup, which this is not.

## Only the user's own data, as a compile error

:core:export is pure JVM and depends on :domain:cycle alone. Prediction,
PredictionAccuracy, FertilityEstimate and CycleRecord live in
:domain:prediction and are simply not on its classpath, and kotlin("jvm") keeps
android.os.Build off it too — so a forecast or a device fact cannot be added by
accident. The key set is asserted with assertEquals rather than contains, so a
new field is a failing test rather than a silent addition.

Row ids are out because they are monotonic and would disclose how many records
the user DELETED. The Play entitlement is out because a purchase one file-edit
away from being granted is a purchase that will be.

## No second copy, ever

The Storage Access Framework writes straight into the document the user picked.
The alternative — write to cacheDir, share by FileProvider, delete after —
creates the temporary file the issue warns about and races the receiving app
still reading it. A test walks cacheDir after a successful export and requires
it empty; it fails the moment anybody reintroduces that pattern.

The destination is parked until the session is unlocked. Returning from the
picker can re-lock, and writing while locked would hand the whole history to
whoever took the phone during the save dialog.

## Two new guards, both proved to fail

checkNoSharedStorageWrites: §45's shared-storage ban was enforced by nobody
having typed it. checkPermissions structurally cannot see it — it matches
<uses-permission>, and a <provider> declaring FileProvider merges green.

checkNoHealthLogging gains a completeness check. A module missing from
modulesSeeingHealthData was silently exempt with a green build, which
app/proguard-rules.pro has described as a hazard since before :core:security and
:core:export existed. Every module must now be in that list or in an explicit
modulesWithNoHealthData with its reason; being in neither is a violation rather
than an exemption.

261 JVM tests, none skipped. Five guards green.

closes #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:13:07 -05:00
null 4456f351ac feat: the privacy promise appears in Settings, from one string
§4 requires the promise in onboarding, in Settings, and on a public privacy
page. It was made once, during onboarding, before the user had entered a single
date — which makes it a marketing line. Repeated above the controls that act on
that data, it is a statement somebody can hold the product to.

One copy, in strings.xml, read by both screens. A second literal is how two
versions of a promise come to exist, which is the failure DOC_TRUST_MAP.md
exists to prevent, here in code rather than in prose.

There is NO Privacy Policy row. §4 wants one and no hosted page exists, and a
policy link that 404s is worse than no link — which is also the convention
SettingsScreen already states: a row for something unbuilt is absent, not
disabled. The issue's verify line allows exactly this.

Three tests, and the second is the one that matters. The promise must say we
never SELL the data, and must NOT have been strengthened into claims the app
cannot keep — no third party, never shared, end-to-end — because Play Billing
and an ad SDK eventually will process something, and a promise the
implementation cannot keep is worse than a narrower one that holds. The third
scans Kotlin for a re-introduced literal, with comments stripped first per
GUARDS.md §2, or the KDoc explaining the rule would fail it.

Proved: replacing the resource lookup with the literal fails exactly one test.

## Two defects found on the way, both pre-existing

**No Robolectric test in :app could read a string resource.** core/database and
core/data have carried unitTests.isIncludeAndroidResources since they were
written; app never did. So the module owning almost all of the user-facing copy
was the one module whose copy could not be tested, and every getString() threw
NotFoundException with an id that had resolved perfectly well.

**checkPermissions read manifests that do not ship.** Turning the above on made
AGP write merged_manifest/debugUnitTest/, the guard walked the whole tree, and
the build failed on REORDER_TASKS — a test-runner permission no user ever sees.
The tempting fix is to allowlist it, which would then permit it in the real
manifest too and quietly undo the guard. It now reads only debug and release,
and refuses to pass unless it read BOTH: checking debug while release went
unread is the failure that matters, since the Play listing and the Data Safety
form describe the release manifest.

That is strictly stricter than before, and proved twice — a forbidden permission
in the app manifest still fails it, and a missing release manifest now fails it
where it used to pass.

GUARDS.md §8 gains a third prove-guard edge, found while proving the above: a
FAIL_PATTERN matching nothing gives the same "caught it, and only it" verdict as
one matching exactly once, because the script only refuses on more than one. The
empty "what failed" block is the tell.

closes #37

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 22:00:06 -05:00
null d7cdf740e5 chore: hiltViewModel comes from the package that still supports it
androidx.hilt.navigation.compose.hiltViewModel is deprecated — it moved to
androidx.hilt.lifecycle.viewmodel.compose, and every one of the ten files
calling it was importing the old path, so the whole app module compiled with a
deprecation warning on the single most-used composable helper in it.

hilt-lifecycle-viewmodel-compose was already resolving onto the classpath
transitively at the same 1.4.0. It is declared explicitly now rather than leaned
on, because it is called directly in ten files and a transitive that quietly
stops being pulled is a compile error with a confusing cause.

The app module now compiles with zero deprecation warnings.

Checked while auditing library currency: every artifact in the catalog, all four
plugins and the Gradle wrapper are already at their newest stable. The only
version that looks old is androidx.biometric 1.1.0, which IS the newest stable —
1.4.0 is alpha-only and biometric-ktx has never shipped a stable release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 21:52:10 -05:00
null fa1cfe5515 fix: a stray NUL byte made a Kotlin source file binary to git
AppLockViewModel.kt landed in 1d8d7cc with a single 0x00 at offset 5468, inside
the char literal of pin.fill(...). It compiled — Kotlin accepted the raw byte as
a character — and every test passed, so nothing caught it except the commit
summary reporting "Bin 0 -> 7069 bytes" where every other file showed a line
count.

That matters more than a stray byte usually would. A source file git treats as
binary produces no diff, so this file would have been unreviewable in every
future change — and it is the file that decides whether the app is locked.

It is written as an explicit unicode escape now, which is what was meant. The
two fills in LockSettingsViewModel used a space, blanking a PIN rather than
zeroing it, and now use the same escape: the whole point of the call is to leave
nothing behind.

Verified: `file` reports UTF-8 text, and no tracked source contains a NUL byte —
only the images and the Gradle wrapper jar do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:04:25 -05:00
null 1d8d7cc688 feat: app lock, with no way to reset a forgotten PIN
§45 asks for biometric/PIN gating. UserPreferences.biometricLockEnabled has
existed since Batch 01 with nothing outside its own module reading it; this
wires it, and adds the rest.

The recovery question was the reason #34 sat open, and it is decided: there is
no recovery. A backdoor into a period tracker's lock would be used by exactly
the person the lock exists to stop. Everything below follows from that.

## The gate

AppLockGate wraps the whole composition rather than being a screen inside it.
Today, Calendar and Insights each start collecting from CycleRepository the
moment they compose, so a lock implemented as a nav destination would already
have read the history before the user proved anything. content() is invoked
only in the unlocked branch.

Re-lock on ON_STOP, not ON_PAUSE — pause fires for the shade, quick settings
and a permission dialog. Two guards on top: isChangingConfigurations, or
rotation and the fontScale-2.0 pass both re-lock; and authInProgress, or an OEM
biometric overlay that stops the activity produces a lock that can never be
opened. No grace period: SECURITY.md leads with "someone who picks up an
unlocked phone", which is the window a grace period covers.

The unlock flag lives in a @Singleton, never in saved state. rememberSaveable
looks like the obvious home and would restore a background-killed app already
unlocked — the single most likely way to meet the lock screen would be the one
path that skipped it.

## What is stored is not the PIN

  mac = HMAC(keystoreKey, 0x01 || salt || PBKDF2-SHA256(pin, salt, 210k))

Two layers because they defend different things. The Keystore MAC is what makes
a six-digit PIN safe at all — a million candidates is nothing to an attacker who
can compute the hash, and impossible for one who cannot get the key off the
device. PBKDF2 underneath is for the day that assumption breaks. 0x01 is a
domain-separation tag; the lockout counter is MACed under 0x02.

The key omits six builder calls and the KDoc names every one. setUserAuthenti-
cationRequired is the important absence: it would bind the key to the device
lock, so changing a passcode would destroy it — and under no-recovery that is
somebody's whole history gone for an unrelated reason. It would also be a
bypass, since SECURITY.md already names "someone who knows the unlock PIN" as
an adversary. The biometric key is separate and takes the opposite policy,
where invalidation correctly degrades to "use your PIN".

## Wrong PINs cost time, never data

Four free attempts, then 30s/1m/2m/5m/15m, capped forever. No attempt limit and
no auto-wipe: under no-recovery an auto-wipe would let a partner, a child or a
pocket destroy a history while knowing nothing. Both clock bypasses are closed —
the wait is the longer of a wall-clock and a monotonic deadline, and a reboot
re-applies it in full, detected by elapsedRealtime going backwards.

## Two writes that had to move

Tapping "Not yet" on a reminder writes a NotYetObservation. That button is on
the phone's own lock screen, reachable by anybody, so the action is now parked
in AppLockController and applied only after an unlock — dropped if the session
never unlocks. Behaviour is unchanged when the lock is off.

The erase behind "Forgot your PIN?" deletes health data, then the Keystore key,
then the lock store. Skipping the middle step leaves the user erased AND still
locked out; prove-guard mutates that line out and requires exactly one red.

## Found by testing, not by review

  - A fresh install began in a 15-minute lockout: "no counter yet" and "counter
    was tampered with" were the same value. They are now distinct.
  - Setting a PIN locked you out of the session you set it in. Found on the
    emulator, not in a test.
  - Kotlin block comments nest, so `domain/*` in a KDoc opens one. Twice.

## Verified

244 JVM tests, 0 skipped. KeystoreVerifierTest runs on PeriodMinSdk26 and
PeriodQA — including that PBKDF2WithHmacSHA256 exists at API 26, the one choice
here with no margin, and that the key is not auth-bound on either.

On device: wrong PIN refused, correct PIN opens, am kill then reopen lands on
the lock screen, turning the lock off requires the current PIN, and
`adb exec-out screencap` returns mean=0 stddev=0 — FLAG_SECURE is real.

androidx.biometric 1.1.0 is the newest stable (1.4.0 is alpha; biometric-ktx
never shipped one). It merges USE_BIOMETRIC and USE_FINGERPRINT, which failed
checkPermissions until they were allowed on purpose, and it drags fragment to
1.5.1 — pinned to 1.9.0 since MainActivity is now a FragmentActivity.

closes #34

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 04:02:47 -05:00
null 424a513336 fix: a throw in a background flow no longer kills the process
The application scope in PeriodApplication was built with SupervisorJob and
no CoroutineExceptionHandler, and ReminderCoordinator launchIns two Room
flows on it. SupervisorJob stops a failing child cancelling its siblings; it
does not stop the exception, which reaches the thread's default handler and
ends the process.

That scope is the one that runs with nobody watching. Application.onCreate
runs in every process, including the ones WorkManager starts after a reboot
and at the daily reminder — no Activity, no screen, nothing to show an error.
Both ViewModels already install a handler; the one place a crash is invisible
did not.

The trigger is real rather than theoretical: repository.forecast runs the
prediction engine inside the flow, and Prediction's init block enforces its
window invariants with require.

Three layers, outermost last:

  - ReminderCoordinator catches per chain, so one failing collection cannot
    take the other down. Doing nothing on failure is deliberate — cancelling
    the schedule would turn a failed read into reminders silently switched
    off until the user next touched a notification setting.
  - ReminderWorker returns success and posts nothing when it cannot read what
    it needs, which is already its behaviour with no history. Cancellation is
    rethrown rather than swallowed.
  - The scope handler is a backstop whose only job is that the process lives.
    It cannot log: checkNoHealthLogging covers this module, and an exception
    message here can carry a date derived from a cycle.

The chains moved into internal functions taking flows so the catch is
reachable from a test. CycleRepository is final with an internal constructor,
which is right for a data boundary and wrong for faking, and adding a mocking
library to reach one catch would have been the worse trade.

Proved to fail, per GUARDS.md §1: removing the handler fails exactly one test
(ApplicationScopeTest.kt:69), and removing either catch fails exactly its own.

GUARDS.md gains §8. prove-guard.sh decides a guard caught the mutation from
the runner's exit code, and cannot tell a broken test from a malformed
command. Its first use here reported a clean catch when Gradle had actually
rejected `:app:test --tests` as an unknown option and run nothing. The same
tool's line-counting fallback also means the three documented boundary proofs
in architecture/README.md have been exiting 3 rather than 0 since they were
written; they now carry the fail pattern that makes them exit 0.

closes #45

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 03:06:10 -05:00
null ae27ef6490 feat: full-bleed onboarding heroes, in density buckets
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>
2026-08-19 01:02:06 -05:00
null 8807951553 feat: Delete My Data, and fix a privacy option nobody could tap
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>
2026-08-18 21:03:16 -05:00
null f43e1c2824 feat: the designed Settings screen, structured as §36 specifies
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>
2026-08-18 20:44:15 -05:00
null 928680a61a fix: navigation labels wrapped mid-word at the largest font scale
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>
2026-08-18 20:34:38 -05:00
null 5fc94536fe feat: ship the onboarding artwork in both themes
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>
2026-08-18 20:25:59 -05:00
null 99dbc36802 feat: reminders that stay quiet on a lock screen
§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 #24
closes #25
closes #26
closes #27
2026-08-18 15:26:59 -05:00
null 5da7c18364 feat: fertility that declines rather than stretches
§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 #21
closes #22
closes #23
2026-08-18 14:56:05 -05:00
null 19edf4c920 feat: Insights, and most of it is about staying quiet
§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
2026-08-18 04:04:49 -05:00
null 9d2d15038c feat: calendar whose states survive greyscale
§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
2026-08-18 04:01:47 -05:00
null 035a9d4162 feat: two-tap period logging, and spotting that cannot reset the cycle
§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
2026-08-18 03:56:52 -05:00
null afd22dddfa feat: the Today screen and its six states, with the number as the hero
§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
2026-08-18 03:50:28 -05:00
null 2479a1ddf4 feat: onboarding, and the Surface that dark mode was missing
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
2026-08-18 03:44:46 -05:00
null 2fe423cf47 feat: the prediction engine section 12 specifies, and it beats the baseline
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 #10
closes #11
closes #12
closes #14
2026-08-18 03:16:12 -05:00
null f5e9fbe53c feat: module boundary guard, and two API-level bugs it uncovered
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
2026-08-18 03:00:11 -05:00
null adc50751d8 feat: period CRUD end to end, and stop a double tap killing the app
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
2026-08-18 02:52:35 -05:00
null 96dd878ac5 chore: adopt the project template and add the Kotlin/Compose skeleton
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 #1
closes #2
2026-08-18 02:16:47 -05:00