wake-alarm/DOCS-free-days.md
Krzysztof kuhy Rudnicki 34037e55c1 phone: sick days — picker, silent ring, stick stand-down, pre-alarm sync
- 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
2026-10-16 13:22:40 +02:00

84 lines
4.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Free days — the phone picker for the shared pool
The pool itself is `~/src/utils/freedays` (Python): one global set of days on
which every gate app stands down, 35 per calendar year, declared for today or
later, never backdated, refunded only while still in the future. Read its
README for the rules. This file covers the phone half, which lives in this
repo because the alarm is the one gate where standing down is a physical
event — the phone stays silent — rather than an absent dialog.
## Where things are
| | |
|---|---|
| Picker | `phone_app/lib/screens/free_days_screen.dart` (+ `free_days_body.dart`, the shared `day_calendar.dart`), the calendar icon on the home screen |
| Rules, Dart port | `phone_app/lib/services/free_day_pool.dart` — same record shape as the Python package, byte for byte |
| Sync | `phone_app/lib/services/free_day_sync.dart` — Firebase only, `freedays-sync/devices/<uuid>/free_days.json` |
| Ring path | `android/.../FreeDays.kt` + the check at the top of `AlarmReceiver.onReceive` |
## The calendar
A Monday-first month grid (adapted from punchme's, whose own free days are a
separate concept and are not read here). One tap per future date: an empty day
is booked with no reason prompt, a booked day is released and refunded. Today
and the past are read-only — releasing a day that has started still spends
it. Tue/Wed/Thu (`workdays`, mirroring `freedays.WORKDAYS`) are shaded; past
free days stay visible, faded. Months run from January of this year to
December of next. The "Booked ahead" list below keeps reasons from PC marks
and its own Release buttons.
## Why there is a native cache
The alarm fires from Kotlin with no Flutter engine running, so at 08:00 the
Dart pool is unreadable. Dart hands the current free dates to
`FreeDays.store` (channel method `setFreeDays`) after **every** mark, release
and sync; `AlarmReceiver` reads that set before starting `AlarmService`. On a
free day it logs `free day: not ringing; re-armed for tomorrow` and returns —
nothing rings, nothing is shown, and the next morning is normal.
**Fail closed.** An unreadable or missing cache is "not a free day", matching
`freedays.is_free_day()` on the PC: a corrupt pool restores the normal rules,
it never silences the alarm. Verified on the `wake_alarm_api36` AVD
(2026-09-11, `adb root` + `am broadcast --include-stopped-packages -n
…/.AlarmReceiver`): today in the set → skip line, no service; a different
date → service start; a garbage XML file → service start.
## What reaches the phone when
- **Marked on the phone:** immediate. The cache is rewritten before the sync
even runs, so an offline phone still stands down on a day it marked itself.
- **Marked on the PC (`freedays mark …`):** on the phone's next sync: app
launch, either day picker opening, or the **pre-alarm sync** (21:00 and
A − 30 min, app closed; `DOCS-sick-days.md`). That sync needs network at
its slot; if it fails the cache keeps its last sync and the phone rings.
A date is never both free and sick: this picker syncs the sick-day pool on
open and refuses a sick date (`… is a sick day — clear it in Sick days
first.`), and refuses every booking if that pool cannot be read.
## Cross-language check
The Dart port is a second implementation of the rules, so it is verified
against the Python one rather than against itself:
`scripts/free_days_interop/check.sh` (a pre-commit hook on both inputs)
writes one pool through `FreeDayPool` — mark, mark today, mark+release a
future day, mark+release after arrival — replays the same edits through
`freedays.mark`/`release` into a scratch pool, and fails unless the free set,
spent set, `remaining` and `is_free_day` verdicts agree. Dart's own numbers
are never the reference: comparing them to what Python reads from Dart's
file cannot see a drift in what Dart *writes*. Proved on 2026-09-11 by
dropping the sticky `consumed` from `release` — the gate reported
`remaining: dart=33 python=32`. Needs the freedays venv
(`~/src/utils/freedays/install.sh`).
## Do NOT
- Don't give this namespace a GitHub mirror. The alarm record keeps its
mirror because un-migrated installs depend on it; this pool never had one.
- Don't tombstone a date to un-mark it. crdt tombstones are monotonic; a
deleted date could never be marked again. Flip `state` to `cleared`.
- Don't write `consumed` back to `false`. It is the sticky flag that stops
two devices either side of midnight from refunding a day already used.
- Don't add a warning, a notification or a ring-time prompt. The pool is
consulted silently; the count is shown only when the picker is opened.