diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c29d32e..268aeea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -101,7 +101,7 @@ repos: # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays + - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays # Test suites intentionally use patterns (protected-access, magic-value # comparisons) that don't belong in the source-code 10/10 gate. exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) diff --git a/AGENTS.md b/AGENTS.md index 959e351..79274cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,8 +4,8 @@ **The phone is the alarm.** `phone_app/` arms and rings it and gates the dismissal; this repo's Python half only reads the alarm time the phone -published and starts the morning routine at that time. The time is a ramp: -08:00 down a minute a day to 07:00, in three copies — **`DOCS-ramp.md`**. +published and starts the morning routine at that time. The time is an earned +ramp, 08:00 down to 07:00, owned by the phone alone — **`DOCS-ramp.md`**. The PC's waking role was deleted on 2026-08-30: no alarm window, no audio escalation, no smart plug, no curtains, no `rtcwake`, no `shutdown` wrapper, @@ -101,7 +101,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires. - **The Firebase reader discovers device directories; it is not pinned to `devices/phone/`.** The phone's device id is migrating from the fixed constant `phone` to a per-install uuid, and a pinned reader would silently - stop seeing the alarm and fall back to the ramp baseline — i.e. the wrong + stop seeing the alarm and fall back to 08:00 — i.e. the wrong time. `_fetch_firebase_alarm()` lists `SYNC_DEVICES_PREFIX`, reads every device's `alarm.json`, and picks the newest by the hour field's **HLC**, with `device_id` breaking exact ties. A payload that does not parse is skipped @@ -127,7 +127,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires. for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the dev venv. - Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale - cache → today's ramp baseline. The cache bounds remote reads to ~288/day + cache → 08:00. The cache bounds remote reads to ~288/day despite the per-minute tick. - **A record older than `PUBLISHED_ALARM_MAX_AGE_SECONDS` (14 days) is skipped at every step, including the cache.** The check is on when the *phone @@ -136,7 +136,7 @@ systemd-sleep hook: a suspended or just-booted machine still fires. minutes, so `fetched_at` is always fresh however old the record behind it is. That is why the winning HLC's instant is carried through `_alarm_payload` → `_sync_transport` → the cache's `published_at`. Expiring is safe because - the ramp baseline *is* the phone's baseline. An **undated** record is + 08:00 is the latest the phone can ever arm. An **undated** record is trusted, not expired: the GitHub mirror has no per-field HLC, so treating "cannot tell" as "too old" would break that backend entirely. - **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute; @@ -196,9 +196,9 @@ gated on `sudo -n`: the cleanup needs root, and a hard failure there would abort an install that had already placed the package and enabled the timer. **`/etc/sudoers.d/wake-alarm` is deliberately left in place** even by a full -uninstall: screen-locker's own `_wake_alarm.py:_schedule_rtcwake` still shells -out to `sudo rtcwake`, and removing the entry here breaks a live feature in -another repo with no error message anywhere. +uninstall. Its last known consumer, screen-locker's `_schedule_rtcwake`, was +dead code and deleted on 2026-09-27; retiring the entry is a separate, +deliberate decision, never a side effect of an uninstall. ## Operational gotchas diff --git a/DOCS-morning-session-pc.md b/DOCS-morning-session-pc.md index be4ee85..68bac57 100644 --- a/DOCS-morning-session-pc.md +++ b/DOCS-morning-session-pc.md @@ -26,7 +26,7 @@ got up at 07:00, the opposite of the point): | the phone says | `exempt_until` | meaning | |---|---|---| -| nothing started today (`none`) | **alarm + 15 min** (`SESSION_NONE_GRACE`; alarm = the phone's, clamped to ≤ today's ramp baseline like the trigger) | up early is fine; the phone rings at the alarm, and a dismiss that has not produced a session by then means "not up" | +| nothing started today (`none`) | **alarm + 15 min** (`SESSION_NONE_GRACE`; alarm = the phone's, clamped to ≤ 08:00 like the trigger) | up early is fine; the phone rings at the alarm, and a dismiss that has not produced a session by then means "not up" | | started, not finished (`in_progress`) | `min(started_at + 90 min, 09:30)` | provisional — the legs take ~40 min; relative to the start because an alarm earlier than 08:00 is legal and a fixed 09:30 would hand an aborted 06:15 session three hours | | `completed`, finished inside that same cutoff | **11:00** | the carrot | | `completed` *after* the cutoff | none | a phone switched back on at noon completes the legs then; that un-locks nothing | diff --git a/DOCS-ramp.md b/DOCS-ramp.md index a2b40bd..76065c4 100644 --- a/DOCS-ramp.md +++ b/DOCS-ramp.md @@ -1,20 +1,39 @@ # The wake-time ramp ("cooking the frog") +The alarm starts at 08:00 and moves one **earned** minute earlier at a time, +down to a permanent 07:00. Owned entirely by the phone +(`phone_app/lib/services/alarm_ramp.dart`, `ramp_progress.dart`). -The baseline is not a constant: 08:00 through 2026-09-25, then one minute -earlier per **calendar** day (free days count) to 07:00 on 2026-11-24, which -is permanent. A pure function of the date the alarm **fires** on — no counter, -no stored start — so a reinstall cannot reset it. Three copies must agree: -`wake_alarm/_ramp.py` (PC fallback + clamp), `phone_app/lib/services/alarm_ramp.dart` -(`AlarmPolicy`) and `AlarmRamp.kt` (every native arm path funnels through -`AlarmScheduler.nextOccurrence`, so the dismiss/boot/free-day/watchdog re-arms -keep stepping without Dart). `tests/test_ramp.py` parses the other two. +## The rule -- **Evaluate the fire day, never the arm day.** The phone arms the evening - before; "today's" ramp there is tomorrow's time plus a minute, every day. -- The PC clamps any synced time to *today's* ramp. A record up to 08:00 is - expected lag (the phone publishes when the app runs) and logs INFO; only a - time later than 08:00 is a WARNING. -- The one-shot earlier override lapses once the ramp catches up with it, and - the ring screen re-arms the policy after `resetAfterFire` — `dismiss()` - natively re-arms the *stored* time, i.e. the override just spent. +- A **Monday, Friday, Saturday or Sunday** whose alarm rang and was dismissed + that same day earns one minute (`RampProgress.recordDismiss`, called from the + ring screen's dismiss before it re-arms). +- **Tuesday–Thursday** (`freedays.WORKDAYS`) earn nothing and ring at the time + of the most recent non-workday: a minute earned on Monday first shows on + Friday. +- A **free day** never rings, so it earns nothing. A **missed** alarm (rang, + never dismissed) earns nothing. +- Ring time for day D = 08:00 − |earned dates before the latest non-workday + ≤ D|, floored at 07:00. + +## Why a set of dates, not a counter + +The earned dates are a grow-only set: each date counts once, so a re-run, a +duplicate sync or a double dismiss cannot move the alarm twice, and nothing can +run it away. It rides the synced `alarm.json` record (`settings_sync.dart`, +field `ramp_earned_dates`) as a union, so a reinstall gets every minute back. +`rampSeedDates` holds the three mornings earned before dates were recorded +(2026-09-25/26/27). + +## Where it is *not* + +The PC no longer computes a ramp: it trusts the time the phone publishes, +clamped to ≤ 08:00, and falls back to 08:00. The Kotlin side has no ramp +either: the alarm only moves when a Dart dismiss earns a minute and re-arms, so +the native re-arm after a free day, a missed ring, a boot or the watchdog is +already the right time by re-using the stored one. + +`workdays` in `alarm_ramp.dart` and `STICK_DAYS` in `WorkdayStick.kt` are +literal copies of `freedays.WORKDAYS`; +`wake_alarm/tests/test_workday_mirrors.py` fails when either drifts. diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmRamp.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmRamp.kt deleted file mode 100644 index cab3229..0000000 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmRamp.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.kuhy.wake_alarm_sync - -import java.time.LocalDate -import java.time.ZonedDateTime -import java.time.temporal.ChronoUnit - -/** - * The baseline wake time, lowered one minute per calendar day to 07:00. - * - * Native copy of `lib/services/alarm_ramp.dart`: the daily re-arm after a - * dismiss, a free day, a boot or the watchdog runs here with no Dart at all, - * so without this copy it would re-arm the stored time and the ramp would - * stall until the app was next opened. `wake_alarm/tests/test_ramp.py` parses - * these constants and fails when they drift from the Dart and Python copies. - */ -object AlarmRamp { - - /** The last day at the old 08:00 baseline; each day after is a minute earlier. */ - val ANCHOR: LocalDate = LocalDate.of(2026, 9, 25) - - /** Minutes of day on the anchor date (08:00). */ - const val START_MINUTES = 8 * 60 - - /** The floor the ramp stops at, for good (07:00). */ - const val FLOOR_MINUTES = 7 * 60 - - /** Baseline on [day], in minutes of day. Calendar days, so DST cannot skew it. */ - fun minutesOn(day: LocalDate): Int { - val elapsed = maxOf(0L, ChronoUnit.DAYS.between(ANCHOR, day)) - return maxOf(FLOOR_MINUTES.toLong(), START_MINUTES - elapsed).toInt() - } - - /** - * Next fire instant after [now] for a requested time of [requested] minutes. - * - * The ramp is evaluated for the day the alarm fires on, and wins whenever - * it is earlier than the request; tomorrow always qualifies because the - * ramp never moves later. - */ - fun nextFire(requested: Int, now: ZonedDateTime): ZonedDateTime { - val today = now.toLocalDate() - val first = at(today, requested, now) - return if (first.isAfter(now)) first else at(today.plusDays(1), requested, now) - } - - private fun at(day: LocalDate, requested: Int, now: ZonedDateTime): ZonedDateTime { - val minutes = minOf(requested, minutesOn(day)) - return day.atTime(minutes / 60, minutes % 60).atZone(now.zone) - } -} diff --git a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt index 59758bd..8bc832c 100644 --- a/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt +++ b/phone_app/android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmScheduler.kt @@ -6,7 +6,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.os.Build -import java.time.ZonedDateTime +import java.util.Calendar /** * Arms and cancels the one exact alarm this app owns. @@ -162,15 +162,18 @@ object AlarmScheduler { .putInt(KEY_HOUR, hour).putInt(KEY_MINUTE, minute).apply() } - /** - * Next local-time instant for [hour]:[minute], never later than that day's ramp. - * - * Every arm path funnels through here, so the stored time is only an upper - * bound: the dismiss/boot/free-day re-arms still move a minute earlier a day. - */ - private fun nextOccurrence(hour: Int, minute: Int): Long = - AlarmRamp.nextFire(hour * 60 + minute, ZonedDateTime.now()) - .toInstant().toEpochMilli() + /** Next local-time instant matching [hour]:[minute]; tomorrow if today has passed. */ + private fun nextOccurrence(hour: Int, minute: Int): Long { + val now = Calendar.getInstance() + val at = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, hour) + set(Calendar.MINUTE, minute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + if (!at.after(now)) at.add(Calendar.DAY_OF_YEAR, 1) + return at.timeInMillis + } /** FLAG_IMMUTABLE is mandatory from API 31; without it this throws at arm time. */ private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( diff --git a/phone_app/lib/screens/ring_screen.dart b/phone_app/lib/screens/ring_screen.dart index fcbcc43..25c040f 100644 --- a/phone_app/lib/screens/ring_screen.dart +++ b/phone_app/lib/screens/ring_screen.dart @@ -8,6 +8,7 @@ import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/dismiss_gate.dart'; import 'package:wake_alarm_sync/services/home_presence.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; import 'package:wake_alarm_sync/services/ring_startup.dart'; import 'package:wake_alarm_sync/services/room_proof.dart'; import 'package:wake_alarm_sync/services/session_handoff.dart'; @@ -217,6 +218,9 @@ class _RingScreenState extends State { // ramp baseline short of reinstalling — a ratchet with no reset is a // trap, not a defence. await AlarmPolicy.resetAfterFire(); + // A dismissed non-workday morning earns the ramp a minute; recorded + // before the re-arm below so it is what the next non-workday uses. + await RampProgress.recordDismiss(DateTime.now()); // dismiss() re-armed the *stored* time, i.e. the override just spent; // without re-arming the policy it rang again every day until home opened. final next = await AlarmPolicy.load(); diff --git a/phone_app/lib/services/alarm_policy.dart b/phone_app/lib/services/alarm_policy.dart index c790421..57fc7dd 100644 --- a/phone_app/lib/services/alarm_policy.dart +++ b/phone_app/lib/services/alarm_policy.dart @@ -1,12 +1,14 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/services/alarm_ramp.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; /// The alarm schedule, which the user cannot weaken. /// /// The alarm is deliberately **not** opt-in: there is no "set alarm" button to /// forget and no cancel to reach for at 02:00. The time is baked in — the -/// daily ramp in `alarm_ramp.dart`, 08:00 down to 07:00 a minute a day — and -/// the only edit the UI offers is moving the *next* alarm **earlier**. +/// earned ramp in `alarm_ramp.dart`, 08:00 down to 07:00 a minute per +/// dismissed non-workday morning — and the only edit the UI offers is +/// moving the *next* alarm **earlier**. /// /// Earlier-only is what makes this tamper-resistant: every change the user can /// make costs them sleep, so a half-asleep user has no lever that helps them @@ -21,16 +23,21 @@ class AlarmPolicy { this.overrideMinute, }); - /// Resolves the next alarm after [now], given a [stored] one-shot override. + /// Resolves the next alarm after [now], given a [stored] one-shot override + /// and the [earned] ramp dates (`RampProgress`). /// /// The ramp is evaluated for the day the alarm will actually **fire** on, /// not the day it is armed: `main()` and the home screen usually arm the /// evening before, and evaluating "today" there would arm tomorrow at /// today's (one minute later) time, every single day. Today is tried /// first; tomorrow always qualifies because the ramp never moves later. - factory AlarmPolicy.resolve(DateTime now, int? stored) { + factory AlarmPolicy.resolve( + DateTime now, + int? stored, [ + Set earned = const {}, + ]) { final today = DateTime(now.year, now.month, now.day); - var policy = AlarmPolicy._forDay(today, stored); + var policy = AlarmPolicy._forDay(today, stored, earned); // Built from fields, not today + Duration: on a DST day local midnight // plus N minutes is not N minutes past midnight on the wall clock. final at = DateTime( @@ -44,13 +51,14 @@ class AlarmPolicy { policy = AlarmPolicy._forDay( DateTime(now.year, now.month, now.day + 1), stored, + earned, ); } return policy; } - factory AlarmPolicy._forDay(DateTime day, int? stored) { - final base = rampMinutesOn(day); + factory AlarmPolicy._forDay(DateTime day, int? stored, Set earned) { + final base = rampMinutesOn(day, earned); // A stored value at or after the baseline would be a weakening, so it is // ignored rather than trusted — the store is not a trust boundary. final override = stored != null && stored < base ? stored : null; @@ -92,6 +100,7 @@ class AlarmPolicy { return AlarmPolicy.resolve( now ?? DateTime.now(), prefs.getInt(kOverrideKey), + await RampProgress.load(), ); } diff --git a/phone_app/lib/services/alarm_ramp.dart b/phone_app/lib/services/alarm_ramp.dart index 682b1b4..f60a603 100644 --- a/phone_app/lib/services/alarm_ramp.dart +++ b/phone_app/lib/services/alarm_ramp.dart @@ -1,36 +1,60 @@ import 'dart:math' as math; -// The baseline wake time, lowered one minute per calendar day to 07:00. +// The baseline wake time, lowered one *earned* minute at a time to 07:00. // -// "Cooking the frog": 2026-09-25 is the last 08:00 morning, 2026-09-26 rings -// at 07:59, and so on until 2026-11-24 reaches the 07:00 floor, which is then -// permanent. The time is a pure function of the date the alarm *fires* on — -// no counter, no stored start date — so neither a reinstall nor wiped app -// data can reset the ramp. Every calendar day counts, free days included, so -// free days cannot stall it. +// "Cooking the frog": a Monday/Friday/Saturday/Sunday morning whose alarm +// rang and was dismissed that same day earns one minute, and the alarm moves +// a minute earlier from the next non-workday on. Workdays (Tue/Wed/Thu) earn +// nothing and hold the latest earned time; a free day never rings so it earns +// nothing; a missed alarm earns nothing. 07:00 is the floor, for good. // -// Mirrored by `AlarmRamp.kt` (native re-arm) and `wake_alarm/_ramp.py` (PC -// fallback and clamp); `wake_alarm/tests/test_ramp.py` parses both copies -// and fails when the constants drift. +// The earned dates are a grow-only set (`RampProgress`, synced so a reinstall +// gets them back): each date counts once, so no bug can run it away, and the +// time is a pure function of the set and the day the alarm fires on. -/// The last day at the old 08:00 baseline; each day after it is a minute -/// earlier. -final DateTime rampAnchor = DateTime.utc(2026, 9, 25); +/// Mirror of `freedays.WORKDAYS` (Tue/Wed/Thu), which Dart cannot import; +/// `wake_alarm/tests/test_workday_mirrors.py` fails when it drifts. +const Set workdays = { + DateTime.tuesday, + DateTime.wednesday, + DateTime.thursday, +}; -/// Minutes of day on the anchor date (08:00). +/// Minutes of day before anything was earned (08:00). const int rampStartMinutes = 8 * 60; /// The floor the ramp stops at, for good (07:00). const int rampFloorMinutes = 7 * 60; -/// Baseline wake time on [day]'s local calendar date, in minutes of day. +/// Minutes earned before earned dates were recorded: the three dismissed +/// mornings that had already taken the alarm to 07:59 (Sat), 07:58 (Sun) and +/// 07:57 (Mon) when the ramp stopped being a pure calendar count, 2026-09-27. +const Set rampSeedDates = {'2026-09-25', '2026-09-26', '2026-09-27'}; + +/// Whether [day] is a weekly workday, on which the ramp holds. +bool isWorkday(DateTime day) => workdays.contains(day.weekday); + +/// `YYYY-MM-DD` of [day]'s local calendar date. +String rampDateKey(DateTime day) => + '${day.year.toString().padLeft(4, '0')}-' + '${day.month.toString().padLeft(2, '0')}-' + '${day.day.toString().padLeft(2, '0')}'; + +/// Baseline wake time for an alarm firing on [day], in minutes of day. /// -/// Only the date of [day] matters. Days are counted between UTC midnights -/// built from the local y/m/d, so a DST change (2026-10-25 is inside the -/// ramp) cannot turn a 23- or 25-hour day into a skipped or doubled minute. -/// Days before the anchor stay at 08:00 rather than extrapolating later. -int rampMinutesOn(DateTime day) { - final date = DateTime.utc(day.year, day.month, day.day); - final elapsed = math.max(0, date.difference(rampAnchor).inDays); - return math.max(rampFloorMinutes, rampStartMinutes - elapsed); +/// Counts the earned non-workday dates strictly before the most recent +/// non-workday on or before [day], so a minute earned on Monday first shows on +/// Friday and Tuesday-Thursday ring at Monday's time. Date keys compare as +/// strings, so DST cannot skew the count. +int rampMinutesOn(DateTime day, Set earned) { + var step = DateTime(day.year, day.month, day.day); + while (isWorkday(step)) { + step = DateTime(step.year, step.month, step.day - 1); + } + final cutoff = rampDateKey(step); + final counted = {...earned, ...rampSeedDates}.where((key) { + final date = DateTime.tryParse(key); + return date != null && key.compareTo(cutoff) < 0 && !isWorkday(date); + }); + return math.max(rampFloorMinutes, rampStartMinutes - counted.length); } diff --git a/phone_app/lib/services/ramp_progress.dart b/phone_app/lib/services/ramp_progress.dart new file mode 100644 index 0000000..3b6dfa0 --- /dev/null +++ b/phone_app/lib/services/ramp_progress.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/alarm_ramp.dart'; + +/// The dates whose dismissed alarm earned the ramp a minute. +/// +/// A grow-only set: a date is added once and never removed, so recording is +/// idempotent and the ramp can move at most one minute per earning day -- +/// the property a bare counter lacks (a re-run or a duplicate sync would +/// double-count it). Synced as a union (`settings_sync.dart`), so a reinstall +/// or a second install gets every earned minute back. +class RampProgress { + const RampProgress._(); + + /// SharedPreferences key holding the earned dates as a JSON list. + static const String kEarnedKey = 'ramp.earnedDates'; + + /// The earned date keys (`YYYY-MM-DD`); unreadable storage reads as none. + static Future> load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(kEarnedKey); + if (raw == null) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is List) return decoded.whereType().toSet(); + } on FormatException { + // A corrupt value earns nothing rather than crashing the arm path. + } + return {}; + } + + /// Records that the alarm firing on [now]'s date was dismissed. + /// + /// Only a non-workday earns: on Tuesday-Thursday the ramp holds. Returns + /// whether a new date was added. + static Future recordDismiss(DateTime now) async { + if (isWorkday(now)) return false; + return await merge({rampDateKey(now)}); + } + + /// Unions [dates] into the stored set; returns whether it grew. + static Future merge(Iterable dates) async { + final current = await load(); + final merged = {...current, ...dates.where(_isDateKey)}; + if (merged.length == current.length) return false; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(kEarnedKey, jsonEncode(merged.toList()..sort())); + return true; + } + + /// Only canonical `YYYY-MM-DD` keys: the ramp compares them as strings. + static bool _isDateKey(String key) { + final date = DateTime.tryParse(key); + return date != null && rampDateKey(date) == key; + } +} diff --git a/phone_app/lib/services/settings_sync.dart b/phone_app/lib/services/settings_sync.dart index 0027b87..4a4a55b 100644 --- a/phone_app/lib/services/settings_sync.dart +++ b/phone_app/lib/services/settings_sync.dart @@ -1,4 +1,4 @@ -/// The NFC tags and home location, carried on the synced alarm record. +/// The NFC tags, home location and ramp progress, on the synced alarm record. /// /// Both used to live only in SharedPreferences, so a reinstall lost them and /// the user re-registered two tags and re-captured home by hand (2026-09-11). @@ -20,12 +20,16 @@ library; import 'package:crdt_sync/crdt_sync.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; import 'package:wake_alarm_sync/services/session_sync.dart'; /// Field holding the registered tag ids, one string per slot. const nfcTagsField = 'nfc_tag_ids'; +/// Field holding the ramp's earned dates ([RampProgress]), merged as a union. +const rampEarnedField = 'ramp_earned_dates'; + /// Field holding [HomeLocationSettings.toJson]; a cleared home is the /// all-null shape, not an absent field, so clearing propagates too. const homeLocationField = 'home_location'; @@ -65,9 +69,27 @@ Future stageLocalSettings(AlarmSyncService service) async { home.toJson(), configured: home.isConfigured, ); + await _stageEarned(service); await stageSessionData(service); } +/// Pushes the union of the local and logged earned dates when it is new. +/// +/// A grow-only set rides a last-writer-wins field safely: every writer pushes +/// a superset of what it last saw, and a device whose dates lost a race still +/// holds them locally and re-pushes the union on its next sync. +Future _stageEarned(AlarmSyncService service) async { + final local = await RampProgress.load(); + final logged = _datesOf(service.field(rampEarnedField)?.$1); + if (local.difference(logged).isEmpty) return; + await service.setFields({ + rampEarnedField: ({...logged, ...local}.toList()..sort()), + }, Hlc.newTick(service.deviceId)); +} + +Set _datesOf(Object? value) => + value is List ? value.whereType().toSet() : {}; + /// The stamp of a pre-stamp value: just above the epoch, so any real edit /// wins, but present, so it is pushed rather than silently dropped. const _legacyMs = 1; @@ -118,6 +140,7 @@ Future applySyncedSettings(AlarmSyncService service) async { ); } } + await RampProgress.merge(_datesOf(service.field(rampEarnedField)?.$1)); await applySyncedSessionData(service); } diff --git a/phone_app/test/ring_session_handoff_test.dart b/phone_app/test/ring_session_handoff_test.dart index ad30929..6bd111a 100644 --- a/phone_app/test/ring_session_handoff_test.dart +++ b/phone_app/test/ring_session_handoff_test.dart @@ -7,9 +7,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/screens/ring_screen.dart'; import 'package:wake_alarm_sync/screens/session_screen.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; +import 'package:wake_alarm_sync/services/alarm_ramp.dart'; import 'package:wake_alarm_sync/services/alarm_scheduler.dart'; import 'package:wake_alarm_sync/services/challenge.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart'; @@ -120,6 +122,12 @@ void main() { ); expect(armed.last, {'hour': next.hour, 'minute': next.minute}); expect(next.isOverridden, isFalse); + // The dismiss earned today's minute -- unless today is a workday. + final now = DateTime.now(); + expect( + (await RampProgress.load()).contains(rampDateKey(now)), + !isWorkday(now), + ); }); testWidgets('the session hears tags after the ring screen is gone', ( diff --git a/phone_app/test/services/alarm_policy_test.dart b/phone_app/test/services/alarm_policy_test.dart index c5ca428..12518f2 100644 --- a/phone_app/test/services/alarm_policy_test.dart +++ b/phone_app/test/services/alarm_policy_test.dart @@ -1,6 +1,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/services/alarm_policy.dart'; +import 'package:wake_alarm_sync/services/alarm_ramp.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; /// Every test pins "now": the baseline is the ramp, so an unpinned test that /// passes at 08:00 today fails at 07:59 tomorrow. @@ -27,16 +29,32 @@ void main() { test('before the alarm on its own day, it is still today', () { final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 3), null); - expect(hm(policy), '07:50'); + expect(hm(policy), '07:57'); }); - test('once today has fired, it is tomorrow', () { - final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 7, 50), null); - expect(hm(policy), '07:49'); + test('once today has fired, it is tomorrow, with today earned', () { + // Saturday 07:57 has rung and been dismissed; Sunday counts it. + final policy = AlarmPolicy.resolve(DateTime(2026, 10, 3, 7, 57), null, { + '2026-10-03', + }); + expect(hm(policy), '07:56'); }); test('past the floor it stays at 07:00', () { - expect(hm(AlarmPolicy.resolve(DateTime(2027, 3, 1, 12), null)), '07:00'); + final earned = { + for (var i = 0; i < 200; i++) rampDateKey(DateTime(2026, 10, 2 + i)), + }; + expect( + hm(AlarmPolicy.resolve(DateTime(2027, 12, 1, 12), null, earned)), + '07:00', + ); + }); + + test('load reads the earned dates from storage', () async { + SharedPreferences.setMockInitialValues({ + RampProgress.kEarnedKey: '["2026-10-03"]', + }); + expect(hm(await AlarmPolicy.load(now: DateTime(2026, 10, 3, 22))), '07:56'); }); test('an earlier time is accepted and applied', () async { @@ -89,10 +107,10 @@ void main() { }); test('an override the ramp has caught up with lapses', () { - // 07:45 was an early start in September; by 2026-10-20 the ramp itself - // is 07:35, so the stale override would be a *later* alarm. - final policy = AlarmPolicy.resolve(DateTime(2026, 10, 19, 22), 7 * 60 + 45); - expect(hm(policy), '07:35'); + // 07:58 was an early start on Saturday; Monday's ramp is already 07:57, + // so the stale override would be a *later* alarm. + final policy = AlarmPolicy.resolve(DateTime(2026, 9, 27, 22), 7 * 60 + 58); + expect(hm(policy), '07:57'); expect(policy.isOverridden, isFalse); }); diff --git a/phone_app/test/services/alarm_ramp_test.dart b/phone_app/test/services/alarm_ramp_test.dart index d0c8bce..fb96894 100644 --- a/phone_app/test/services/alarm_ramp_test.dart +++ b/phone_app/test/services/alarm_ramp_test.dart @@ -5,45 +5,64 @@ String _fmt(int minutes) => '${(minutes ~/ 60).toString().padLeft(2, '0')}:' '${(minutes % 60).toString().padLeft(2, '0')}'; +String _on(int y, int m, int d, [Set earned = const {}]) => + _fmt(rampMinutesOn(DateTime(y, m, d), earned)); + void main() { - test('the ramp: 08:00, a minute a day, then 07:00 for good', () { - final cases = { - DateTime(2026): '08:00', - DateTime(2026, 9, 25): '08:00', - DateTime(2026, 9, 26): '07:59', - DateTime(2026, 10, 5): '07:50', - DateTime(2026, 11, 23): '07:01', - DateTime(2026, 11, 24): '07:00', - DateTime(2026, 11, 25): '07:00', - DateTime(2030, 6): '07:00', + test('the seeds reproduce what already rang: 07:59, 07:58, then 07:57', () { + expect(_on(2026, 9, 25), '08:00'); // Fri: nothing earned before it + expect(_on(2026, 9, 26), '07:59'); // Sat + expect(_on(2026, 9, 27), '07:58'); // Sun + expect(_on(2026, 9, 28), '07:57'); // Mon + }); + + test('workdays hold the latest non-workday time', () { + final earned = {'2026-09-28'}; // Monday dismissed + expect(_on(2026, 9, 29, earned), '07:57'); // Tue + expect(_on(2026, 9, 30, earned), '07:57'); // Wed + expect(_on(2026, 10, 1, earned), '07:57'); // Thu + expect(_on(2026, 10, 2, earned), '07:56'); // Fri: Monday's minute + }); + + test('no earned minute (a miss or a free day) means no step', () { + // Friday missed: Saturday rings at Friday's time. + final earned = {'2026-09-28'}; + expect(_on(2026, 10, 3, earned), '07:56'); + // Saturday dismissed, Sunday free (never rings, earns nothing): + earned.add('2026-10-03'); + expect(_on(2026, 10, 4, earned), '07:55'); + expect(_on(2026, 10, 5, earned), '07:55'); + }); + + test('a workday date in the set earns nothing', () { + expect(_on(2026, 10, 2, {'2026-09-29', '2026-09-30'}), '07:57'); + }); + + test('unparseable keys are ignored', () { + expect(_on(2026, 10, 2, {'nonsense', ''}), '07:57'); + }); + + test('07:00 is the floor', () { + final earned = { + for (var i = 0; i < 200; i++) rampDateKey(DateTime(2026, 10, 2 + i)), }; - for (final MapEntry(key: day, value: expected) in cases.entries) { - expect(_fmt(rampMinutesOn(day)), expected, reason: '$day'); - } + expect(_on(2027, 12, 31, earned), '07:00'); }); test('only the date matters, not the time of day', () { expect( - rampMinutesOn(DateTime(2026, 9, 26, 23, 59)), - rampMinutesOn(DateTime(2026, 9, 26)), + rampMinutesOn(DateTime(2026, 9, 26, 23, 59), const {}), + rampMinutesOn(DateTime(2026, 9, 26), const {}), ); }); - test('the DST change on 2026-10-25 costs exactly one minute a day', () { - // Local midnights either side of the fall-back are 25 hours apart; a - // Duration-based count would see 0 or 2 days somewhere around here. - expect(_fmt(rampMinutesOn(DateTime(2026, 10, 24, 23, 30))), '07:31'); - expect(_fmt(rampMinutesOn(DateTime(2026, 10, 25, 0, 30))), '07:30'); - expect(_fmt(rampMinutesOn(DateTime(2026, 10, 26, 0, 30))), '07:29'); + test('the workday set is Tue/Wed/Thu', () { + expect(workdays, {DateTime.tuesday, DateTime.wednesday, DateTime.thursday}); + expect(isWorkday(DateTime(2026, 9, 29)), isTrue); + expect(isWorkday(DateTime(2026, 9, 28)), isFalse); }); - test('never moves later from one day to the next', () { - var previous = rampStartMinutes; - for (var n = -5; n < 90; n++) { - final minutes = rampMinutesOn(DateTime(2026, 9, 25 + n)); - expect(minutes, lessThanOrEqualTo(previous)); - expect(minutes, greaterThanOrEqualTo(rampFloorMinutes)); - previous = minutes; - } + test('date keys are zero-padded ISO dates', () { + expect(rampDateKey(DateTime(2026, 3, 4, 23)), '2026-03-04'); }); } diff --git a/phone_app/test/services/ramp_progress_test.dart b/phone_app/test/services/ramp_progress_test.dart new file mode 100644 index 0000000..045f2e9 --- /dev/null +++ b/phone_app/test/services/ramp_progress_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('a fresh install has earned nothing', () async { + expect(await RampProgress.load(), isEmpty); + }); + + test('a dismissed non-workday morning earns its date, once', () async { + expect( + await RampProgress.recordDismiss(DateTime(2026, 9, 28, 7, 58)), + isTrue, + ); + expect(await RampProgress.recordDismiss(DateTime(2026, 9, 28, 9)), isFalse); + expect(await RampProgress.load(), {'2026-09-28'}); + }); + + test('a workday dismiss earns nothing', () async { + expect(await RampProgress.recordDismiss(DateTime(2026, 9, 29, 8)), isFalse); + expect(await RampProgress.load(), isEmpty); + }); + + test('merge is a union and drops non-canonical keys', () async { + await RampProgress.merge({'2026-09-28'}); + expect( + await RampProgress.merge({'2026-10-02', '2026-9-3', 'junk'}), + isTrue, + ); + expect(await RampProgress.merge({'2026-10-02'}), isFalse); + expect(await RampProgress.load(), {'2026-09-28', '2026-10-02'}); + }); + + test('corrupt storage reads as nothing earned', () async { + SharedPreferences.setMockInitialValues({ + RampProgress.kEarnedKey: '{not json', + }); + expect(await RampProgress.load(), isEmpty); + SharedPreferences.setMockInitialValues({ + RampProgress.kEarnedKey: '{"a": 1}', + }); + expect(await RampProgress.load(), isEmpty); + }); +} diff --git a/phone_app/test/services/settings_sync_test.dart b/phone_app/test/services/settings_sync_test.dart index 2bc83e1..d690df8 100644 --- a/phone_app/test/services/settings_sync_test.dart +++ b/phone_app/test/services/settings_sync_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:wake_alarm_sync/services/alarm_sync_service.dart'; import 'package:wake_alarm_sync/services/home_location_settings.dart'; +import 'package:wake_alarm_sync/services/ramp_progress.dart'; import 'package:wake_alarm_sync/services/room_proof_settings.dart'; import 'package:wake_alarm_sync/services/settings_sync.dart'; @@ -178,4 +179,57 @@ void main() { expect(service.field(homeLocationField)?.$2, hlc); }); }); + + group('ramp earned dates', () { + test('local dates are pushed as the union with the logged ones', () async { + SharedPreferences.setMockInitialValues({}); + await RampProgress.merge({'2026-09-28'}); + final service = await _open(); + await service.setFields({ + rampEarnedField: ['2026-10-02'], + }, _at('2026-10-02T08:00:00Z')); + + await stageLocalSettings(service); + + expect(service.field(rampEarnedField)?.$1, ['2026-09-28', '2026-10-02']); + }); + + test('nothing new locally means nothing pushed', () async { + SharedPreferences.setMockInitialValues({}); + await RampProgress.merge({'2026-09-28'}); + final service = await _open(); + final hlc = _at('2026-10-02T08:00:00Z'); + await service.setFields({ + rampEarnedField: ['2026-09-28', '2026-10-02'], + }, hlc); + + await stageLocalSettings(service); + + expect(service.field(rampEarnedField)?.$2, hlc); + }); + + test('a reinstall gets every earned date back', () async { + SharedPreferences.setMockInitialValues({}); + final service = await _open(); + await service.setFields({ + rampEarnedField: ['2026-09-28', 'junk', 7], + }, _at('2026-10-02T08:00:00Z')); + + await applySyncedSettings(service); + + expect(await RampProgress.load(), {'2026-09-28'}); + }); + + test('a malformed field earns nothing', () async { + SharedPreferences.setMockInitialValues({}); + final service = await _open(); + await service.setFields({ + rampEarnedField: 'not a list', + }, _at('2026-10-02T08:00:00Z')); + + await applySyncedSettings(service); + + expect(await RampProgress.load(), isEmpty); + }); + }); } diff --git a/pyproject.toml b/pyproject.toml index 2756bf2..a419973 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ # falls back to its stdlib GitHub path if the import fails, so a broken # install degrades the read instead of killing the per-minute trigger. "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync", - "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays", ] # Package discovery must be explicit: phone_app/ is a Flutter app, not a Python diff --git a/requirements.txt b/requirements.txt index 08497bd..eb3f41f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,9 @@ # it is missing — but it is required here for mypy/pylint/tests. bandit==1.9.4 codespell==2.4.3 -coverage==7.16.1 +coverage==7.16.2 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync -freedays @ git+https://github.com/kuhyx/utils@freedays-v0.2.0#subdirectory=freedays +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock mypy==2.3.1 pre-commit==4.6.2 diff --git a/wake_alarm/_constants.py b/wake_alarm/_constants.py index b6bf60d..58ab60c 100644 --- a/wake_alarm/_constants.py +++ b/wake_alarm/_constants.py @@ -15,12 +15,14 @@ from pathlib import Path ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6}) -# The time the alarm fires at when the phone's alarm cannot be resolved at all -# (no network *and* no cached read ever written) is ``_ramp.baseline_time`` — -# deliberately the SAME baseline the phone bakes in (``alarm_ramp.dart``), so -# the two halves agree even with the network down. It used to be a fixed -# 13:00, which meant a Firebase outage silently moved the alarm five hours -# later — the one direction the whole design refuses to allow. +# Hour at which the alarm fires when the phone's alarm cannot be resolved at +# all — no network *and* no cached read ever written. Minute is implicitly 0. +# +# Deliberately the SAME baseline the phone bakes in (AlarmPolicy.baselineHour), +# so the two halves agree even with the network down. It used to be 13:00, +# which meant a Firebase outage silently moved the alarm five hours later — +# the one direction the whole design refuses to allow. +FALLBACK_ALARM_HOUR: int = 8 # State file for wake alarm (HMAC-signed) @@ -69,7 +71,7 @@ LOG_DEDUPE_FILE: Path = LOG_STATE_DIR / "last_event.json" # SYNC_DEVICES_PREFIX and falls back to this path for a phone that has not # yet published under its uuid. Keep reading it until every install has # migrated; deleting it early means a stale phone's alarm silently stops -# being seen, and the trigger falls back to the ramp baseline. +# being seen, and the trigger falls back to FALLBACK_ALARM_HOUR. SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json" # The directory holding one subdirectory per publishing device. @@ -112,8 +114,8 @@ SYNC_HTTP_TIMEOUT_SECONDS: float = 10.0 # phone left one record stamped 2026-08-09, and the PC honoured it faithfully # for three weeks -- a publish that never happens is indistinguishable from one # that has not changed, so the last successful publish is authoritative -# forever. Expiring to the ramp baseline is safe precisely because that -# is the phone's own baseline, so a lapsed record degrades to the +# forever. Expiring to FALLBACK_ALARM_HOUR is safe precisely because that +# constant is the phone's own baseline, so a lapsed record degrades to the # right answer rather than a guess. PUBLISHED_ALARM_MAX_AGE_SECONDS: float = 14 * 24 * 60 * 60 @@ -156,11 +158,6 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0) SESSION_NONE_GRACE: timedelta = timedelta(minutes=15) -# --- Workday stick (Tue/Wed/Thu miss -> tomorrow loses its leniency) ------ -# The same weekdays screen-locker calls "relaxed" (_weekly_check.is_relaxed_day) -# and leetcode-guard prices as cheap (Pricing.doubled_days) -- both already go -# easy on exactly these three days, which is why a miss costs nothing today -# without this file. Python weekday(): Mon=0 ... Sun=6. -STICK_DAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu - +# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days +# themselves are freedays.WORKDAYS, the one set every gate app reads. WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json" diff --git a/wake_alarm/_ramp.py b/wake_alarm/_ramp.py deleted file mode 100644 index 722611e..0000000 --- a/wake_alarm/_ramp.py +++ /dev/null @@ -1,71 +0,0 @@ -"""The baseline wake time, lowered one minute per calendar day to 07:00. - -"Cooking the frog": 2026-09-25 is the last 08:00 morning, 2026-09-26 rings -at 07:59, and so on until 2026-11-24 reaches the 07:00 floor, which is then -permanent. The time is a pure function of the date the alarm fires on — no -counter, no stored start date — so neither a reinstall nor a wiped state file -can reset the ramp, and there is nothing that could run away in either -direction. - -Every calendar day counts, free days included: counting only rung days would -let free days stall the ramp and move its finish date. - -Three copies of this function exist and must agree: this one (PC fallback -and clamp), ``phone_app/lib/services/alarm_ramp.dart`` (the policy the phone -arms and publishes) and ``AlarmRamp.kt`` (the native re-arm after a dismiss, -a free day, a boot or the watchdog). ``tests/test_ramp.py`` parses the other -two and fails when their constants drift from these. -""" - -from __future__ import annotations - -from datetime import UTC, date, datetime - -# The last day at the old 08:00 baseline; each day after it is a minute earlier. -RAMP_ANCHOR: date = date(2026, 9, 25) - -# Minutes of day on the anchor date (08:00). -RAMP_START_MINUTES: int = 8 * 60 - -# The floor the ramp stops at, for good (07:00). -RAMP_FLOOR_MINUTES: int = 7 * 60 - - -def baseline_minutes(day: date) -> int: - """Return the baseline wake time on *day*, as minutes after midnight. - - Days before the anchor stay at 08:00 rather than extrapolating later: - the ramp only ever moves the alarm earlier. - - Args: - day: The local calendar date the alarm fires on. - - Returns: - Minutes after local midnight, between the floor and the start. - """ - elapsed = max(0, (day - RAMP_ANCHOR).days) - return max(RAMP_FLOOR_MINUTES, RAMP_START_MINUTES - elapsed) - - -def baseline_time(day: date) -> tuple[int, int]: - """Return the baseline wake time on *day* as ``(hour, minute)``. - - Args: - day: The local calendar date the alarm fires on. - - Returns: - The hour and minute of the baseline. - """ - return divmod(baseline_minutes(day), 60) - - -def local_today() -> date: - """Return today's *local* calendar date — the date the phone rings on. - - Never ``_state._today_str()``, which is UTC: between local midnight and - 02:00 in summer it names yesterday, a minute later than today's ramp. - - Returns: - The system-local date. Also the seam tests patch. - """ - return datetime.now(tz=UTC).astimezone().date() diff --git a/wake_alarm/_session.py b/wake_alarm/_session.py index 2266eeb..6bc6cfc 100644 --- a/wake_alarm/_session.py +++ b/wake_alarm/_session.py @@ -34,9 +34,8 @@ import sys from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac -from wake_alarm._constants import MORNING_SESSION_FILE +from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE from wake_alarm._logging_setup import configure_logging -from wake_alarm._ramp import baseline_time from wake_alarm._session_payload import newest_session from wake_alarm._session_policy import to_entry from wake_alarm._sync import get_alarm @@ -100,8 +99,8 @@ def refresh(now: datetime) -> dict[str, object] | None: today=today, refreshed_at=now.timestamp(), # Same clamp as the trigger: the phone may only move the alarm earlier - # than today's ramp baseline, so a later value never widens the grace. - alarm=min((alarm.hour, alarm.minute), baseline_time(now.date())), + # than the baseline, so a later synced value never widens the grace. + alarm=min((alarm.hour, alarm.minute), (FALLBACK_ALARM_HOUR, 0)), ) if not write_entry(entry): return None diff --git a/wake_alarm/_sync.py b/wake_alarm/_sync.py index a4de9c2..dd001a8 100644 --- a/wake_alarm/_sync.py +++ b/wake_alarm/_sync.py @@ -33,10 +33,10 @@ from wake_alarm._alarm_payload import in_range from wake_alarm._constants import ( ALARM_CACHE_FILE, ALARM_CACHE_MAX_AGE_SECONDS, + FALLBACK_ALARM_HOUR, PUBLISHED_ALARM_MAX_AGE_SECONDS, ) from wake_alarm._logging_setup import configure_logging -from wake_alarm._ramp import baseline_time, local_today from wake_alarm._sync_transport import ( _FIREBASE_AVAILABLE, _fetch_firebase_alarm, @@ -167,7 +167,7 @@ def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) -> 1. A cache entry younger than *max_cache_age_seconds* (no network). 2. A live fetch, Firebase first (cached on success). 3. Any cache entry, however stale — better an old alarm than none. - 4. Today's ramp baseline (``_ramp.baseline_time``). + 4. ``FALLBACK_ALARM_HOUR:00``. A record the phone published longer ago than ``PUBLISHED_ALARM_MAX_AGE_SECONDS`` is skipped at *every* step, including @@ -206,9 +206,11 @@ def get_alarm(*, max_cache_age_seconds: float = ALARM_CACHE_MAX_AGE_SECONDS) -> _logger.info("Using stale cached alarm — sync read failed") return Alarm(cached[0], cached[1], "stale-cache") - hour, minute = baseline_time(local_today()) - _logger.info("No synced or cached alarm — falling back to %02d:%02d", hour, minute) - return Alarm(hour, minute, "fallback") + _logger.info( + "No synced or cached alarm — falling back to %02d:00", + FALLBACK_ALARM_HOUR, + ) + return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback") def _parse_args(argv: list[str]) -> argparse.Namespace: diff --git a/wake_alarm/_sync_transport.py b/wake_alarm/_sync_transport.py index 72b6b81..dd4b979 100644 --- a/wake_alarm/_sync_transport.py +++ b/wake_alarm/_sync_transport.py @@ -109,7 +109,7 @@ def fetch_firebase_logs() -> list[tuple[str, str]] | None: Discovers device directories rather than reading one fixed path: the phone's device id is migrating from the constant "phone" to a per-install uuid, and a reader pinned to the old path would stop seeing the alarm the - moment the phone migrated — falling back to the ramp baseline, i.e. + moment the phone migrated — falling back to FALLBACK_ALARM_HOUR, i.e. waking the user at the wrong time. Listing covers both ids, and SYNC_ALARM_PATH remains as a last resort for a backend that cannot list. diff --git a/wake_alarm/_trigger.py b/wake_alarm/_trigger.py index 2a1c52a..c2df2bf 100644 --- a/wake_alarm/_trigger.py +++ b/wake_alarm/_trigger.py @@ -21,7 +21,7 @@ must be built in the same timezone the user reads off their phone. from __future__ import annotations import argparse -from datetime import UTC, date, datetime, timedelta +from datetime import UTC, datetime, timedelta import logging import subprocess import sys @@ -30,11 +30,11 @@ import freedays from wake_alarm._constants import ( ALARM_DAYS, + FALLBACK_ALARM_HOUR, TRIGGER_CATCHUP_MINUTES, TRIGGER_TARGET_UNIT, ) from wake_alarm._logging_setup import configure_logging -from wake_alarm._ramp import RAMP_START_MINUTES, baseline_time from wake_alarm._state import ( save_trigger_fired, was_trigger_fired_today, @@ -170,42 +170,37 @@ def _parse_args(argv: list[str]) -> argparse.Namespace: return parser.parse_args(argv) -def _clamp_to_baseline(alarm: Alarm, day: date) -> Alarm: - """Refuse to let any resolved alarm land later than *day*'s ramp baseline. +def _clamp_to_baseline(alarm: Alarm) -> Alarm: + """Refuse to let any resolved alarm land later than the baseline. - The phone arms at ``_ramp.baseline_time`` and only ever ratchets earlier, - so honour an earlier value and clamp a later one. This is the PC half of - the watchdog: without it the one thing the design forbids — the alarm - silently moving later — would be reachable through sync. + The phone bakes in ``FALLBACK_ALARM_HOUR`` and only ever ratchets *earlier*, + so a synced value later than the baseline means something is wrong — a + stale record from before the policy landed, a half-written payload, or a + tampered store. Whatever the cause, the safe direction is unambiguous: + honour it if it is earlier, clamp it if it is later. - Up to 08:00 a later value is expected lag, not tampering: the phone - publishes when the app runs, so yesterday's record is a minute behind - today's ramp. That logs at INFO (this runs every tick); only a value later - than the old 08:00 baseline — stale or tampered — is worth a WARNING. + This is the PC half of the watchdog. Without it the PC would faithfully + reproduce a bad remote value, and the one thing the design forbids — + the alarm silently moving later — would be reachable through sync. Args: alarm: The alarm as resolved by ``get_alarm``. - day: The local date the alarm fires on. Returns: - The same alarm, or the ramp baseline when the resolved time is later. + The same alarm, or the baseline when the resolved time is later. """ - base = baseline_time(day) - if (alarm.hour, alarm.minute) <= base: + if (alarm.hour, alarm.minute) <= (FALLBACK_ALARM_HOUR, 0): return alarm - level = logging.INFO - if alarm.hour * 60 + alarm.minute > RAMP_START_MINUTES: - level = logging.WARNING - _logger.log( - level, - "Resolved alarm %02d:%02d is later than today's %02d:%02d baseline " - "(source=%s) — clamping.", + _logger.warning( + "Resolved alarm %02d:%02d is later than the %02d:00 baseline " + "(source=%s) — clamping. The phone can only move it earlier, so a " + "later value means a stale or tampered record.", alarm.hour, alarm.minute, - *base, + FALLBACK_ALARM_HOUR, alarm.source, ) - return Alarm(*base, f"clamped-{alarm.source}") + return Alarm(FALLBACK_ALARM_HOUR, 0, f"clamped-{alarm.source}") def main() -> None: @@ -214,7 +209,7 @@ def main() -> None: args = _parse_args(sys.argv[1:]) now = _now() - alarm = _clamp_to_baseline(get_alarm(), now.date()) + alarm = _clamp_to_baseline(get_alarm()) should_fire, reason = _decide(now, alarm) if args.dry_run: diff --git a/wake_alarm/_workday_penalty.py b/wake_alarm/_workday_penalty.py index dc8b84d..51f5649 100644 --- a/wake_alarm/_workday_penalty.py +++ b/wake_alarm/_workday_penalty.py @@ -24,15 +24,15 @@ import logging import freedays from gatelock.log_integrity import compute_entry_hmac -from wake_alarm._constants import STICK_DAYS, WORKDAY_PENALTY_FILE +from wake_alarm._constants import WORKDAY_PENALTY_FILE from wake_alarm._session_policy import NONE_OUTCOME _logger = logging.getLogger(__name__) def is_stick_day(day: date) -> bool: - """Tuesday/Wednesday/Thursday -- the same set already treated as lenient.""" - return day.weekday() in STICK_DAYS + """Tuesday/Wednesday/Thursday: ``freedays.WORKDAYS``, the shared set.""" + return freedays.is_workday(day) def is_workday_miss(entry: dict[str, object], now: datetime) -> bool: diff --git a/wake_alarm/tests/test_ramp.py b/wake_alarm/tests/test_ramp.py deleted file mode 100644 index 053de31..0000000 --- a/wake_alarm/tests/test_ramp.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Tests for the daily ramp — and that its three copies agree.""" - -from __future__ import annotations - -from datetime import date -from pathlib import Path -import re - -import pytest - -from wake_alarm._ramp import ( - RAMP_ANCHOR, - RAMP_FLOOR_MINUTES, - RAMP_START_MINUTES, - baseline_minutes, - baseline_time, - local_today, -) - -_PHONE = Path(__file__).resolve().parents[2] / "phone_app" -_DART = _PHONE / "lib/services/alarm_ramp.dart" -_KOTLIN = _PHONE / "android/app/src/main/kotlin/com/kuhy/wake_alarm_sync/AlarmRamp.kt" - - -class TestBaseline: - """A minute earlier per calendar day, from 08:00 to a permanent 07:00.""" - - @pytest.mark.parametrize( - ("day", "expected"), - [ - (date(2026, 1, 1), (8, 0)), - (date(2026, 9, 25), (8, 0)), - (date(2026, 9, 26), (7, 59)), - (date(2026, 10, 24), (7, 31)), - (date(2026, 10, 25), (7, 30)), - (date(2026, 10, 26), (7, 29)), - (date(2026, 11, 23), (7, 1)), - (date(2026, 11, 24), (7, 0)), - (date(2026, 11, 25), (7, 0)), - (date(2030, 6, 1), (7, 0)), - ], - ) - def test_the_ramp(self, day: date, expected: tuple[int, int]) -> None: - """Before the anchor stays 08:00; the floor holds for good.""" - assert baseline_time(day) == expected - - def test_never_moves_later(self) -> None: - """Day over day, the baseline is monotonically non-increasing.""" - days = [date.fromordinal(RAMP_ANCHOR.toordinal() + n) for n in range(-5, 90)] - minutes = [baseline_minutes(d) for d in days] - assert minutes == sorted(minutes, reverse=True) - - def test_local_today_is_a_date(self) -> None: - """The seam returns the system-local calendar date.""" - assert isinstance(local_today(), date) - - -def _grab(pattern: str, text: str) -> tuple[str, ...]: - match = re.search(pattern, text) - assert match is not None, pattern - return match.groups() - - -class TestCopiesAgree: - """The Dart and Kotlin copies carry the same constants as this one. - - Three copies of a pure function are cheap; three copies that silently - disagree mean the phone rings at one time and the PC fires at another. - """ - - def test_dart(self) -> None: - """``alarm_ramp.dart`` matches ``_ramp.py``.""" - text = _DART.read_text() - y, m, d = _grab(r"rampAnchor = DateTime\.utc\((\d+), (\d+), (\d+)\)", text) - assert date(int(y), int(m), int(d)) == RAMP_ANCHOR - h, mi = _grab(r"rampStartMinutes = (\d+) \* (\d+);", text) - assert int(h) * int(mi) == RAMP_START_MINUTES - h, mi = _grab(r"rampFloorMinutes = (\d+) \* (\d+);", text) - assert int(h) * int(mi) == RAMP_FLOOR_MINUTES - - def test_kotlin(self) -> None: - """``AlarmRamp.kt`` matches ``_ramp.py``.""" - text = _KOTLIN.read_text() - y, m, d = _grab( - r"ANCHOR: LocalDate = LocalDate\.of\((\d+), (\d+), (\d+)\)", text - ) - assert date(int(y), int(m), int(d)) == RAMP_ANCHOR - h, mi = _grab(r"START_MINUTES = (\d+) \* (\d+)", text) - assert int(h) * int(mi) == RAMP_START_MINUTES - h, mi = _grab(r"FLOOR_MINUTES = (\d+) \* (\d+)", text) - assert int(h) * int(mi) == RAMP_FLOOR_MINUTES diff --git a/wake_alarm/tests/test_session.py b/wake_alarm/tests/test_session.py index 96febf9..2a630d2 100644 --- a/wake_alarm/tests/test_session.py +++ b/wake_alarm/tests/test_session.py @@ -163,16 +163,6 @@ class TestRefresh: assert entry is not None assert entry["exempt_until"] == _local("2026-09-19T08:15:00").isoformat() - def test_the_clamp_follows_the_ramp(self) -> None: - # 2026-10-05 is 07:50 on the ramp, so a synced 08:00 grants 08:05. - with ( - patch("wake_alarm._session.fetch_firebase_logs", return_value=[]), - patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")), - ): - entry = refresh(datetime(2026, 10, 5, 7, 0).astimezone()) - assert entry is not None - assert entry["exempt_until"] == _local("2026-10-05T08:05:00").isoformat() - def test_an_earlier_synced_alarm_shortens_the_grace(self) -> None: with ( patch("wake_alarm._session.fetch_firebase_logs", return_value=[]), diff --git a/wake_alarm/tests/test_sync.py b/wake_alarm/tests/test_sync.py index b577135..e27244d 100644 --- a/wake_alarm/tests/test_sync.py +++ b/wake_alarm/tests/test_sync.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import date import json from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -10,6 +9,9 @@ import urllib.error import pytest +from wake_alarm._constants import ( + FALLBACK_ALARM_HOUR, +) from wake_alarm._sync import ( Alarm, _fetch_github_alarm, @@ -224,11 +226,12 @@ class TestGetAlarm: "stale-cache", ) - def test_falls_back_to_the_ramp(self) -> None: - """With no cache and no network, today's ramp baseline is used.""" - with ( - patch("wake_alarm._sync.fetch_remote_alarm", return_value=None), - patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)), - ): + def test_falls_back_to_constant(self) -> None: + """With no cache and no network, the fixed fallback hour is used.""" + with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None): alarm = get_alarm() - assert (alarm.hour, alarm.minute, alarm.source) == (7, 50, "fallback") + assert (alarm.minute, alarm.source) == (0, "fallback") + # Bound to the constant, not a literal: the fallback deliberately + # matches the phone's baked-in baseline, so hardcoding the number here + # would fail every time that shared value moves. + assert alarm.hour == FALLBACK_ALARM_HOUR diff --git a/wake_alarm/tests/test_sync_lapsed.py b/wake_alarm/tests/test_sync_lapsed.py index 8779398..48a96d7 100644 --- a/wake_alarm/tests/test_sync_lapsed.py +++ b/wake_alarm/tests/test_sync_lapsed.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import date import json import time from typing import TYPE_CHECKING @@ -11,6 +10,7 @@ from unittest.mock import patch import pytest from wake_alarm._constants import ( + FALLBACK_ALARM_HOUR, PUBLISHED_ALARM_MAX_AGE_SECONDS, ) from wake_alarm._sync import ( @@ -84,10 +84,10 @@ class TestLapsedPublishedAlarm: "wake_alarm._sync.fetch_remote_alarm", return_value=(7, 0, ancient), ), - patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)), ): alarm = get_alarm() - assert (alarm.hour, alarm.minute, alarm.source) == (7, 50, "fallback") + assert alarm.source == "fallback" + assert alarm.hour == FALLBACK_ALARM_HOUR def test_a_recent_remote_is_used(self, tmp_path: Path) -> None: """A freshly stamped record is honoured as normal.""" diff --git a/wake_alarm/tests/test_trigger_clamp.py b/wake_alarm/tests/test_trigger_clamp.py index 60aa474..e29e863 100644 --- a/wake_alarm/tests/test_trigger_clamp.py +++ b/wake_alarm/tests/test_trigger_clamp.py @@ -2,12 +2,12 @@ from __future__ import annotations -from datetime import date, datetime -import logging +from datetime import datetime from unittest.mock import patch import pytest +from wake_alarm._constants import FALLBACK_ALARM_HOUR from wake_alarm._sync import Alarm from wake_alarm._trigger import ( _clamp_to_baseline, @@ -38,52 +38,27 @@ def _ungated() -> None: yield -# 2026-09-26 is the ramp's first step: 07:59. -_DAY = date(2026, 9, 26) - - class TestClampToBaseline: - """The PC-side watchdog: a synced alarm may never land later than the ramp.""" + """The PC-side watchdog: a synced alarm may never land later than 08:00.""" def test_an_earlier_alarm_passes_through_untouched(self) -> None: """The phone ratchets earlier, so earlier values are legitimate.""" alarm = Alarm(6, 30, "remote") - assert _clamp_to_baseline(alarm, _DAY) == alarm + assert _clamp_to_baseline(alarm) == alarm def test_the_baseline_itself_passes_through(self) -> None: - """Equal to the day's baseline is not later than it.""" - alarm = Alarm(7, 59, "remote") - assert _clamp_to_baseline(alarm, _DAY) == alarm + """Equal to the baseline is not later than it.""" + alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote") + assert _clamp_to_baseline(alarm) == alarm def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None: """The one direction the design forbids must not be reachable.""" - clamped = _clamp_to_baseline(Alarm(13, 0, "remote"), _DAY) - assert (clamped.hour, clamped.minute) == (7, 59) + clamped = _clamp_to_baseline(Alarm(13, 0, "remote")) + assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0) assert clamped.source == "clamped-remote" - def test_before_the_ramp_the_baseline_is_eight(self) -> None: + def test_a_later_minute_within_the_baseline_hour_is_clamped(self) -> None: """08:30 is later than 08:00, so the minute matters too.""" - clamped = _clamp_to_baseline(Alarm(8, 30, "cache"), date(2026, 9, 1)) - assert (clamped.hour, clamped.minute) == (8, 0) + clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache")) + assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0) assert clamped.source == "clamped-cache" - - def test_yesterdays_time_is_clamped_quietly( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """A record a minute behind the ramp is lag, not tampering: INFO only. - - This runs every tick, so a WARNING here would be 1440 lines a day on - every morning the phone app was not opened. - """ - with caplog.at_level(logging.INFO, logger="wake_alarm._trigger"): - clamped = _clamp_to_baseline(Alarm(8, 0, "remote"), _DAY) - assert (clamped.hour, clamped.minute) == (7, 59) - assert [r.levelno for r in caplog.records] == [logging.INFO] - - def test_later_than_eight_still_warns( - self, caplog: pytest.LogCaptureFixture - ) -> None: - """Past the old 08:00 baseline no phone could have published it.""" - with caplog.at_level(logging.INFO, logger="wake_alarm._trigger"): - _clamp_to_baseline(Alarm(8, 1, "remote"), _DAY) - assert [r.levelno for r in caplog.records] == [logging.WARNING] diff --git a/wake_alarm/tests/test_workday_mirrors.py b/wake_alarm/tests/test_workday_mirrors.py new file mode 100644 index 0000000..244d524 --- /dev/null +++ b/wake_alarm/tests/test_workday_mirrors.py @@ -0,0 +1,37 @@ +"""The Dart and Kotlin copies of the workdays agree with ``freedays.WORKDAYS``. + +The phone code cannot import the Python package that owns the definition, so +it keeps literal copies; this is the check that keeps them honest. +""" + +from __future__ import annotations + +from pathlib import Path +import re + +import freedays + +_PHONE = Path(__file__).resolve().parents[2] / "phone_app" +_KOTLIN = _PHONE / "android/app/src/main/kotlin/com/kuhy/wake_alarm_sync" +_NAMES = ("monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday") + + +def _weekdays(block: str) -> frozenset[int]: + """Map the weekday names written in *block* to Python weekday() numbers.""" + return frozenset(i for i, name in enumerate(_NAMES) if name in block.lower()) + + +def test_dart_ramp_workdays() -> None: + """``alarm_ramp.dart``'s ``workdays`` is ``freedays.WORKDAYS``.""" + text = (_PHONE / "lib/services/alarm_ramp.dart").read_text() + match = re.search(r"const Set workdays = \{(.*?)\};", text, re.DOTALL) + assert match is not None + assert _weekdays(match.group(1)) == freedays.WORKDAYS + + +def test_kotlin_workday_stick() -> None: + """``WorkdayStick.kt``'s ``STICK_DAYS`` is ``freedays.WORKDAYS``.""" + text = (_KOTLIN / "WorkdayStick.kt").read_text() + match = re.search(r"STICK_DAYS =\s*setOf\((.*?)\)", text, re.DOTALL) + assert match is not None + assert _weekdays(match.group(1)) == freedays.WORKDAYS