- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later
day to mark it sick, tap again to clear; past days inert. Writes the
sickdays wire format (state sick/cleared + actor, one HLC per write) to
sickdays-sync/devices via crdt_sync syncLog.
- Mutual exclusion in both pickers: each syncs the other pool on open and
refuses a date it holds, naming the pool; an unreadable pool refuses all.
- Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the
ring with reason "sick_day" (no markFired, no ring_started); StickCheck
verdict sick_day, streak skips sick days; stakes card says so.
- Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request
code 1006) runs a headless engine (poolSyncMain) that pulls both pools
from RTDB and rewrites both native caches, so a PC mark arrives with the
app closed. The ring decision stays a cache read.
- JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision,
PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check.
pylint hook skipped: verify.py imports sickdays, not in the hook env until
the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
The shared pool (~/utils/freedays) was CLI-only: the gates on the PC stood
down, but the phone kept ringing at 08:00 and there was nowhere to mark a
day from bed. This is the one picker for every app.
FreeDayPool is a Dart port of the Python rules with the identical record
shape (id = ISO date; state/consumed/actor/reason on one tick; un-mark is
a state flip, never a tombstone), verified by a real cross-language round
trip: a log written here reads back through freedays.is_free_day and
remaining() with the same verdicts and count, and the PC's live pool reads
here as the same 27 of 35 that `freedays status` prints.
The alarm fires from Kotlin with no engine running, so the pool is
mirrored into a native SharedPreferences set after every mark, release and
sync (channel method setFreeDays), and AlarmReceiver consults it before
starting AlarmService. Fail closed: a missing or corrupt cache rings. All
three branches exercised on the API 36 AVD by broadcasting the receiver.
Firebase only for this namespace -- no GitHub mirror; the alarm record
keeps its own because un-migrated installs depend on it.
Known gap, documented rather than papered over: a day marked on the PC
reaches the phone at the next app launch or picker open, not overnight.
The ring path takes no background dependency by design, so mark on the
phone, or open the app once after marking on the PC.
Verified on the phone (release build, 23181JEGR08034): picker shows the
budget, marks 2026-09-30 with a reason, pushes it to
freedays-sync/devices/<uuid>, releases it with a full refund back to 35.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019pBgaXSJKyY3DarYpDatDF
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to
PC", so it could be defeated by simply not tapping it, or by cancelling it the
night before. It is now automatic and there is no lever a half-asleep user can
pull that buys them sleep.
- AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI
offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or
after the baseline, and refuses to relax an active override. Earlier-only is
the whole design: every available change costs the user sleep.
- The ratchet resets after each fire, so an early start is one-shot. A one-way
ratchet with no reset is a trap, not a defence — the first 05:00 would
otherwise be permanent with no way back short of reinstalling.
- load() re-validates the stored value and ignores anything at or after the
baseline. SharedPreferences is not a trust boundary.
- No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the
capability genuinely does not exist rather than being one invokeMethod away.
- Arming happens in main()'s init seam and via AlarmWatchdog, a daily
self-rescheduling check that re-arms if nothing is scheduled — closing the
gap where a force-stop or a failed re-arm goes unnoticed until the morning
it does not ring. BootReceiver re-establishes it, since setInexactRepeating
does not survive a reboot.
- Publishing to the PC is automatic too; it used to need the same tap.
PC half, so the two agree with the network down:
- FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a
Firebase outage silently moved the alarm five hours LATER — the one
direction the design refuses to allow.
- _trigger._clamp_to_baseline() refuses any resolved alarm later than the
baseline, whatever its source. Without it the PC would faithfully reproduce
a stale or tampered remote value and the forbidden direction stays reachable
through sync.
Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions
are covered, per the standing rule about monotonic counters. test_sync.py and
test_trigger.py were split (payload, token, CLI, clamp) to stay under the
250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm,
which now have direct tests instead.
`python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and
still print real output.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Two defects that each defeated the alarm in a different way:
1. Nothing finished AlarmActivity after a correct answer. Sound stopped and
the alarm re-armed, but the dismiss screen stayed up over the lockscreen
forever — back is disabled in both halves by design — and the live instance
kept _inRoom=true with the solved answer still typed, so the next ring
could reuse an already-passed gate. AlarmActivity now handles
closeRingScreen; the shared AlarmChannel deliberately holds
applicationContext and cannot finish an activity, hence the split handler.
2. Nothing could write roomProof.nfcTagId — there was no registration UI — so
checkNfc always answered "no tag registered" and checkQr was never called.
Functionally that left steps as the only satisfiable proof, which is
exactly the state the decision rules out ("NFC or QR qualifies; steps alone
does NOT") and removes the redundancy whose purpose is that one failing
mechanism at 06:00 must never block dismissal. Added RoomProofScreen
(reader mode from MainActivity, since registration needs an Activity) and
linked it from Settings.
Also: StepCounter kept one listener instead of registering a fresh one per
ring and never unregistering, and the ring screen drops its NFC handler on
dispose.
New tests cover what had been dead code: a registered tag satisfies the proof
and reveals the challenge, a foreign tag does not, and a correct answer
invokes BOTH dismiss and closeRingScreen. The emulator cannot reach the
dismiss path at all (no step sensor, no NFC), which is why it is covered by
widget tests rather than claimed from the AVD run.
Documented ringNow/main_ring_probe.dart in AGENTS.md as a deliberate manual
test handle, the phone-side equivalent of wake-alarm.service.
flutter test: 69 pass. dart analyze lib/ test/: clean. pre-commit: passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Three defects found by reviewing the emulator run, all on the dismiss path:
1. ACTIVITY_RECOGNITION was declared but never requested at runtime (API 29+).
Denied, TYPE_STEP_COUNTER delivers nothing, so checkSteps() reports "sensor
unavailable" forever. With steps the only wired proof, that made the alarm
*undismissable*, not merely degraded. MainActivity now requests it
alongside POST_NOTIFICATIONS.
2. checkNfc/checkQr were dead code — no reader, no channel method, no
registration — so steps was the only reachable proof. That inverts the
decision ("NFC or QR qualifies; steps alone does NOT") and removes exactly
the redundancy whose purpose is that one failing mechanism at 06:00 must
never block dismissal. Added NfcReader (hand-written on the existing
channel, NfcAdapter.enableReaderMode so the tap does not restart the
activity and reset the challenge) plus RoomProofSettings for the registered
tag/QR. mobile_scanner stays the droppable extra.
3. StepCounter blocked the platform main thread up to 3s per read, called from
the MethodChannel handler — jank-to-ANR on the ring screen. Now registers a
listener and serves the last sample; reads never block.
Also: raiseAlarmVolume() is guarded. setStreamVolume can throw under a zen
rule, and by then startForeground() has already posted — a failed volume bump
must not take the ring down with it.
Verified on the API 36 AVD with the real app widget and route (the earlier
probe rendered its own UI, so it proved the Kotlin path but nothing about
/ring): screen Asleep -> Awake, AlarmActivity top resumed over the lockscreen,
RingScreen showing the room-proof gate. New widget tests cover the /ring route
resolving and the challenge staying gated behind the room proof.
flutter test: 57 pass. dart analyze: clean. Zero Python files touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
Dart side of the alarm (items 1, 4, 3). main.dart had no init seam at all;
it now has ensureInitialized(), a navigatorKey and a /ring route.
- challenge.dart: all five kinds ported from _challenges.py so both halves
behave identically. kDismissCodeLength/kDismissFlashSeconds are duplicated
from _constants.py, not synced — noted in the file, since Dart cannot read
Python.
- alarm_scheduler.dart: the Dart side of the single MethodChannel.
- ring_screen.dart: room proof first, then a challenge, then dismiss. Back is
a no-op (PopScope) so the alarm cannot be escaped.
- room_proof.dart: NFC or QR satisfies on its own; steps NEVER do alone —
the phone is stationary while you walk to it, so steps only accrue after
pickup and can be faked by shaking it in bed. Steps count only together
with elapsed time, as the fallback when both others fail.
- home_screen: arms the app's own alarm instead of asking the user to confirm
in the Clock app (item 1's "no user interaction"). SET_ALARM stays as
redundancy with SKIP_UI=true.
Re-arm has three hooks and needs all three: startup, BOOT_COMPLETED, and
dismiss. The plan assumed LogStore.changes would cover this; that stream does
not exist in crdt_sync_dart (verified — the library has no Stream at all), so
the picker's save is only one hook and a restart would otherwise arm nothing.
Fixed a real gap the API 36 AVD exposed: POST_NOTIFICATIONS was never
requested at runtime, so a fresh install sat at importance=NONE and the
full-screen intent was rejected outright — the alarm would ring with no
dismiss screen. MainActivity now requests it, and canUseFullScreenIntent()
also checks areNotificationsEnabled() so the check gates rather than warns.
Verified on the API 36 AVD: screen Asleep -> Awake, AlarmActivity became the
top resumed activity over the lockscreen, service isForeground=true
types=0x2 (mediaPlayback) category=alarm, no crash. flutter test: 54 pass.
dart analyze: clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh