Earned ramp: a dismissed Mon/Fri/Sat/Sun morning earns the minute; workdays hold, misses and free days earn nothing; workdays from freedays 0.3.0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-09-27 20:13:22 +02:00
parent a4876d84ec
commit 7f7fd916f7
31 changed files with 505 additions and 433 deletions

View File

@ -101,7 +101,7 @@ repos:
# than merely an old one. # than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.1#subdirectory=gatelock - 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 - 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 # Test suites intentionally use patterns (protected-access, magic-value
# comparisons) that don't belong in the source-code 10/10 gate. # comparisons) that don't belong in the source-code 10/10 gate.
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py) exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)

View File

@ -4,8 +4,8 @@
**The phone is the alarm.** `phone_app/` arms and rings it and gates the **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 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: published and starts the morning routine at that time. The time is an earned
08:00 down a minute a day to 07:00, in three copies — **`DOCS-ramp.md`**. 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 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, 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 - **The Firebase reader discovers device directories; it is not pinned to
`devices/phone/`.** The phone's device id is migrating from the fixed `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 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 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'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 `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 for that reason. Verify with `/usr/bin/python3 -c "import crdt_sync"`, not the
dev venv. dev venv.
- Resolution chain in `get_alarm()`: fresh cache (< 5 min) → live fetch → stale - 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. despite the per-minute tick.
- **A record older than `PUBLISHED_ALARM_MAX_AGE_SECONDS` (14 days) is skipped - **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 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. 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` → That is why the winning HLC's instant is carried through `_alarm_payload` →
`_sync_transport` → the cache's `published_at`. Expiring is safe because `_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 trusted, not expired: the GitHub mirror has no per-field HLC, so treating
"cannot tell" as "too old" would break that backend entirely. "cannot tell" as "too old" would break that backend entirely.
- **Degraded reads log at INFO, not WARNING.** `get_alarm()` runs every minute; - **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. 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 **`/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 uninstall. Its last known consumer, screen-locker's `_schedule_rtcwake`, was
out to `sudo rtcwake`, and removing the entry here breaks a live feature in dead code and deleted on 2026-09-27; retiring the entry is a separate,
another repo with no error message anywhere. deliberate decision, never a side effect of an uninstall.
## Operational gotchas ## Operational gotchas

View File

@ -26,7 +26,7 @@ got up at 07:00, the opposite of the point):
| the phone says | `exempt_until` | meaning | | 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 | | 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`, 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 | | `completed` *after* the cutoff | none | a phone switched back on at noon completes the legs then; that un-locks nothing |

View File

@ -1,20 +1,39 @@
# The wake-time ramp ("cooking the frog") # 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 ## The rule
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.
- **Evaluate the fire day, never the arm day.** The phone arms the evening - A **Monday, Friday, Saturday or Sunday** whose alarm rang and was dismissed
before; "today's" ramp there is tomorrow's time plus a minute, every day. that same day earns one minute (`RampProgress.recordDismiss`, called from the
- The PC clamps any synced time to *today's* ramp. A record up to 08:00 is ring screen's dismiss before it re-arms).
expected lag (the phone publishes when the app runs) and logs INFO; only a - **Tuesday–Thursday** (`freedays.WORKDAYS`) earn nothing and ring at the time
time later than 08:00 is a WARNING. of the most recent non-workday: a minute earned on Monday first shows on
- The one-shot earlier override lapses once the ramp catches up with it, and Friday.
the ring screen re-arms the policy after `resetAfterFire` — `dismiss()` - A **free day** never rings, so it earns nothing. A **missed** alarm (rang,
natively re-arms the *stored* time, i.e. the override just spent. 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.

View File

@ -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)
}
}

View File

@ -6,7 +6,7 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import java.time.ZonedDateTime import java.util.Calendar
/** /**
* Arms and cancels the one exact alarm this app owns. * 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() .putInt(KEY_HOUR, hour).putInt(KEY_MINUTE, minute).apply()
} }
/** /** Next local-time instant matching [hour]:[minute]; tomorrow if today has passed. */
* Next local-time instant for [hour]:[minute], never later than that day's ramp. private fun nextOccurrence(hour: Int, minute: Int): Long {
* val now = Calendar.getInstance()
* Every arm path funnels through here, so the stored time is only an upper val at = Calendar.getInstance().apply {
* bound: the dismiss/boot/free-day re-arms still move a minute earlier a day. set(Calendar.HOUR_OF_DAY, hour)
*/ set(Calendar.MINUTE, minute)
private fun nextOccurrence(hour: Int, minute: Int): Long = set(Calendar.SECOND, 0)
AlarmRamp.nextFire(hour * 60 + minute, ZonedDateTime.now()) set(Calendar.MILLISECOND, 0)
.toInstant().toEpochMilli() }
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. */ /** FLAG_IMMUTABLE is mandatory from API 31; without it this throws at arm time. */
private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast( private fun firePendingIntent(context: Context): PendingIntent = PendingIntent.getBroadcast(

View File

@ -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/challenge.dart';
import 'package:wake_alarm_sync/services/dismiss_gate.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/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/ring_startup.dart';
import 'package:wake_alarm_sync/services/room_proof.dart'; import 'package:wake_alarm_sync/services/room_proof.dart';
import 'package:wake_alarm_sync/services/session_handoff.dart'; import 'package:wake_alarm_sync/services/session_handoff.dart';
@ -217,6 +218,9 @@ class _RingScreenState extends State<RingScreen> {
// ramp baseline short of reinstalling — a ratchet with no reset is a // ramp baseline short of reinstalling — a ratchet with no reset is a
// trap, not a defence. // trap, not a defence.
await AlarmPolicy.resetAfterFire(); 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; // 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. // without re-arming the policy it rang again every day until home opened.
final next = await AlarmPolicy.load(); final next = await AlarmPolicy.load();

View File

@ -1,12 +1,14 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/services/alarm_ramp.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 schedule, which the user cannot weaken.
/// ///
/// The alarm is deliberately **not** opt-in: there is no "set alarm" button to /// 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 /// 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 /// earned ramp in `alarm_ramp.dart`, 08:00 down to 07:00 a minute per
/// the only edit the UI offers is moving the *next* alarm **earlier**. /// 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 /// 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 /// make costs them sleep, so a half-asleep user has no lever that helps them
@ -21,16 +23,21 @@ class AlarmPolicy {
this.overrideMinute, 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, /// 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 /// not the day it is armed: `main()` and the home screen usually arm the
/// evening before, and evaluating "today" there would arm tomorrow at /// evening before, and evaluating "today" there would arm tomorrow at
/// today's (one minute later) time, every single day. Today is tried /// today's (one minute later) time, every single day. Today is tried
/// first; tomorrow always qualifies because the ramp never moves later. /// 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<String> earned = const <String>{},
]) {
final today = DateTime(now.year, now.month, now.day); 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 // 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. // plus N minutes is not N minutes past midnight on the wall clock.
final at = DateTime( final at = DateTime(
@ -44,13 +51,14 @@ class AlarmPolicy {
policy = AlarmPolicy._forDay( policy = AlarmPolicy._forDay(
DateTime(now.year, now.month, now.day + 1), DateTime(now.year, now.month, now.day + 1),
stored, stored,
earned,
); );
} }
return policy; return policy;
} }
factory AlarmPolicy._forDay(DateTime day, int? stored) { factory AlarmPolicy._forDay(DateTime day, int? stored, Set<String> earned) {
final base = rampMinutesOn(day); final base = rampMinutesOn(day, earned);
// A stored value at or after the baseline would be a weakening, so it is // 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. // ignored rather than trusted — the store is not a trust boundary.
final override = stored != null && stored < base ? stored : null; final override = stored != null && stored < base ? stored : null;
@ -92,6 +100,7 @@ class AlarmPolicy {
return AlarmPolicy.resolve( return AlarmPolicy.resolve(
now ?? DateTime.now(), now ?? DateTime.now(),
prefs.getInt(kOverrideKey), prefs.getInt(kOverrideKey),
await RampProgress.load(),
); );
} }

View File

@ -1,36 +1,60 @@
import 'dart:math' as math; 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 // "Cooking the frog": a Monday/Friday/Saturday/Sunday morning whose alarm
// at 07:59, and so on until 2026-11-24 reaches the 07:00 floor, which is then // rang and was dismissed that same day earns one minute, and the alarm moves
// permanent. The time is a pure function of the date the alarm *fires* on — // a minute earlier from the next non-workday on. Workdays (Tue/Wed/Thu) earn
// no counter, no stored start date — so neither a reinstall nor wiped app // nothing and hold the latest earned time; a free day never rings so it earns
// data can reset the ramp. Every calendar day counts, free days included, so // nothing; a missed alarm earns nothing. 07:00 is the floor, for good.
// free days cannot stall it.
// //
// Mirrored by `AlarmRamp.kt` (native re-arm) and `wake_alarm/_ramp.py` (PC // The earned dates are a grow-only set (`RampProgress`, synced so a reinstall
// fallback and clamp); `wake_alarm/tests/test_ramp.py` parses both copies // gets them back): each date counts once, so no bug can run it away, and the
// and fails when the constants drift. // 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 /// Mirror of `freedays.WORKDAYS` (Tue/Wed/Thu), which Dart cannot import;
/// earlier. /// `wake_alarm/tests/test_workday_mirrors.py` fails when it drifts.
final DateTime rampAnchor = DateTime.utc(2026, 9, 25); const Set<int> 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; const int rampStartMinutes = 8 * 60;
/// The floor the ramp stops at, for good (07:00). /// The floor the ramp stops at, for good (07:00).
const int rampFloorMinutes = 7 * 60; 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<String> 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 /// Counts the earned non-workday dates strictly before the most recent
/// built from the local y/m/d, so a DST change (2026-10-25 is inside the /// non-workday on or before [day], so a minute earned on Monday first shows on
/// ramp) cannot turn a 23- or 25-hour day into a skipped or doubled minute. /// Friday and Tuesday-Thursday ring at Monday's time. Date keys compare as
/// Days before the anchor stay at 08:00 rather than extrapolating later. /// strings, so DST cannot skew the count.
int rampMinutesOn(DateTime day) { int rampMinutesOn(DateTime day, Set<String> earned) {
final date = DateTime.utc(day.year, day.month, day.day); var step = DateTime(day.year, day.month, day.day);
final elapsed = math.max(0, date.difference(rampAnchor).inDays); while (isWorkday(step)) {
return math.max(rampFloorMinutes, rampStartMinutes - elapsed); 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);
} }

View File

@ -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<Set<String>> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(kEarnedKey);
if (raw == null) return <String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is List) return decoded.whereType<String>().toSet();
} on FormatException {
// A corrupt value earns nothing rather than crashing the arm path.
}
return <String>{};
}
/// 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<bool> recordDismiss(DateTime now) async {
if (isWorkday(now)) return false;
return await merge(<String>{rampDateKey(now)});
}
/// Unions [dates] into the stored set; returns whether it grew.
static Future<bool> merge(Iterable<String> 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;
}
}

View File

@ -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 /// 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). /// 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:crdt_sync/crdt_sync.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.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/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/room_proof_settings.dart';
import 'package:wake_alarm_sync/services/session_sync.dart'; import 'package:wake_alarm_sync/services/session_sync.dart';
/// Field holding the registered tag ids, one string per slot. /// Field holding the registered tag ids, one string per slot.
const nfcTagsField = 'nfc_tag_ids'; 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 /// Field holding [HomeLocationSettings.toJson]; a cleared home is the
/// all-null shape, not an absent field, so clearing propagates too. /// all-null shape, not an absent field, so clearing propagates too.
const homeLocationField = 'home_location'; const homeLocationField = 'home_location';
@ -65,9 +69,27 @@ Future<void> stageLocalSettings(AlarmSyncService service) async {
home.toJson(), home.toJson(),
configured: home.isConfigured, configured: home.isConfigured,
); );
await _stageEarned(service);
await stageSessionData(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<void> _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<String> _datesOf(Object? value) =>
value is List ? value.whereType<String>().toSet() : <String>{};
/// The stamp of a pre-stamp value: just above the epoch, so any real edit /// 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. /// wins, but present, so it is pushed rather than silently dropped.
const _legacyMs = 1; const _legacyMs = 1;
@ -118,6 +140,7 @@ Future<void> applySyncedSettings(AlarmSyncService service) async {
); );
} }
} }
await RampProgress.merge(_datesOf(service.field(rampEarnedField)?.$1));
await applySyncedSessionData(service); await applySyncedSessionData(service);
} }

View File

@ -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/ring_screen.dart';
import 'package:wake_alarm_sync/screens/session_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_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/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/challenge.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/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/room_proof_settings.dart';
import 'package:wake_alarm_sync/services/session_store.dart'; import 'package:wake_alarm_sync/services/session_store.dart';
import 'package:wake_alarm_sync/services/session_tag_settings.dart'; import 'package:wake_alarm_sync/services/session_tag_settings.dart';
@ -120,6 +122,12 @@ void main() {
); );
expect(armed.last, <String, int>{'hour': next.hour, 'minute': next.minute}); expect(armed.last, <String, int>{'hour': next.hour, 'minute': next.minute});
expect(next.isOverridden, isFalse); 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', ( testWidgets('the session hears tags after the ring screen is gone', (

View File

@ -1,6 +1,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/services/alarm_policy.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 /// Every test pins "now": the baseline is the ramp, so an unpinned test that
/// passes at 08:00 today fails at 07:59 tomorrow. /// 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', () { test('before the alarm on its own day, it is still today', () {
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 3), null); 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', () { test('once today has fired, it is tomorrow, with today earned', () {
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 5, 7, 50), null); // Saturday 07:57 has rung and been dismissed; Sunday counts it.
expect(hm(policy), '07:49'); 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', () { 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(<String, Object>{
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 { 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', () { 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 // 07:58 was an early start on Saturday; Monday's ramp is already 07:57,
// is 07:35, so the stale override would be a *later* alarm. // so the stale override would be a *later* alarm.
final policy = AlarmPolicy.resolve(DateTime(2026, 10, 19, 22), 7 * 60 + 45); final policy = AlarmPolicy.resolve(DateTime(2026, 9, 27, 22), 7 * 60 + 58);
expect(hm(policy), '07:35'); expect(hm(policy), '07:57');
expect(policy.isOverridden, isFalse); expect(policy.isOverridden, isFalse);
}); });

View File

@ -5,45 +5,64 @@ String _fmt(int minutes) =>
'${(minutes ~/ 60).toString().padLeft(2, '0')}:' '${(minutes ~/ 60).toString().padLeft(2, '0')}:'
'${(minutes % 60).toString().padLeft(2, '0')}'; '${(minutes % 60).toString().padLeft(2, '0')}';
String _on(int y, int m, int d, [Set<String> earned = const {}]) =>
_fmt(rampMinutesOn(DateTime(y, m, d), earned));
void main() { void main() {
test('the ramp: 08:00, a minute a day, then 07:00 for good', () { test('the seeds reproduce what already rang: 07:59, 07:58, then 07:57', () {
final cases = <DateTime, String>{ expect(_on(2026, 9, 25), '08:00'); // Fri: nothing earned before it
DateTime(2026): '08:00', expect(_on(2026, 9, 26), '07:59'); // Sat
DateTime(2026, 9, 25): '08:00', expect(_on(2026, 9, 27), '07:58'); // Sun
DateTime(2026, 9, 26): '07:59', expect(_on(2026, 9, 28), '07:57'); // Mon
DateTime(2026, 10, 5): '07:50', });
DateTime(2026, 11, 23): '07:01',
DateTime(2026, 11, 24): '07:00', test('workdays hold the latest non-workday time', () {
DateTime(2026, 11, 25): '07:00', final earned = {'2026-09-28'}; // Monday dismissed
DateTime(2030, 6): '07:00', 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(_on(2027, 12, 31, earned), '07:00');
expect(_fmt(rampMinutesOn(day)), expected, reason: '$day');
}
}); });
test('only the date matters, not the time of day', () { test('only the date matters, not the time of day', () {
expect( expect(
rampMinutesOn(DateTime(2026, 9, 26, 23, 59)), rampMinutesOn(DateTime(2026, 9, 26, 23, 59), const {}),
rampMinutesOn(DateTime(2026, 9, 26)), rampMinutesOn(DateTime(2026, 9, 26), const {}),
); );
}); });
test('the DST change on 2026-10-25 costs exactly one minute a day', () { test('the workday set is Tue/Wed/Thu', () {
// Local midnights either side of the fall-back are 25 hours apart; a expect(workdays, {DateTime.tuesday, DateTime.wednesday, DateTime.thursday});
// Duration-based count would see 0 or 2 days somewhere around here. expect(isWorkday(DateTime(2026, 9, 29)), isTrue);
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 24, 23, 30))), '07:31'); expect(isWorkday(DateTime(2026, 9, 28)), isFalse);
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 25, 0, 30))), '07:30');
expect(_fmt(rampMinutesOn(DateTime(2026, 10, 26, 0, 30))), '07:29');
}); });
test('never moves later from one day to the next', () { test('date keys are zero-padded ISO dates', () {
var previous = rampStartMinutes; expect(rampDateKey(DateTime(2026, 3, 4, 23)), '2026-03-04');
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;
}
}); });
} }

View File

@ -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(<String, Object>{}));
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(<String, Object>{
RampProgress.kEarnedKey: '{not json',
});
expect(await RampProgress.load(), isEmpty);
SharedPreferences.setMockInitialValues(<String, Object>{
RampProgress.kEarnedKey: '{"a": 1}',
});
expect(await RampProgress.load(), isEmpty);
});
}

View File

@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.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/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/room_proof_settings.dart';
import 'package:wake_alarm_sync/services/settings_sync.dart'; import 'package:wake_alarm_sync/services/settings_sync.dart';
@ -178,4 +179,57 @@ void main() {
expect(service.field(homeLocationField)?.$2, hlc); 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);
});
});
} }

View File

@ -13,7 +13,7 @@ dependencies = [
# falls back to its stdlib GitHub path if the import fails, so a broken # 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. # 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", "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 # Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

View File

@ -4,9 +4,9 @@
# it is missing — but it is required here for mypy/pylint/tests. # it is missing — but it is required here for mypy/pylint/tests.
bandit==1.9.4 bandit==1.9.4
codespell==2.4.3 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 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 gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.8.2#subdirectory=gatelock
mypy==2.3.1 mypy==2.3.1
pre-commit==4.6.2 pre-commit==4.6.2

View File

@ -15,12 +15,14 @@ from pathlib import Path
ALARM_DAYS: frozenset[int] = frozenset({0, 1, 2, 3, 4, 5, 6}) 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 # Hour at which the alarm fires when the phone's alarm cannot be resolved at
# (no network *and* no cached read ever written) is ``_ramp.baseline_time`` — # all — no network *and* no cached read ever written. Minute is implicitly 0.
# 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 # Deliberately the SAME baseline the phone bakes in (AlarmPolicy.baselineHour),
# 13:00, which meant a Firebase outage silently moved the alarm five hours # so the two halves agree even with the network down. It used to be 13:00,
# later — the one direction the whole design refuses to allow. # 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) # 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 # 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 # yet published under its uuid. Keep reading it until every install has
# migrated; deleting it early means a stale phone's alarm silently stops # 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" SYNC_ALARM_PATH: str = "wake-alarm-sync/devices/phone/alarm.json"
# The directory holding one subdirectory per publishing device. # 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 # 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 # for three weeks -- a publish that never happens is indistinguishable from one
# that has not changed, so the last successful publish is authoritative # that has not changed, so the last successful publish is authoritative
# forever. Expiring to the ramp baseline is safe precisely because that # forever. Expiring to FALLBACK_ALARM_HOUR is safe precisely because that
# is the phone's own baseline, so a lapsed record degrades to the # constant is the phone's own baseline, so a lapsed record degrades to the
# right answer rather than a guess. # right answer rather than a guess.
PUBLISHED_ALARM_MAX_AGE_SECONDS: float = 14 * 24 * 60 * 60 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) SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
# --- Workday stick (Tue/Wed/Thu miss -> tomorrow loses its leniency) ------ # Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
# The same weekdays screen-locker calls "relaxed" (_weekly_check.is_relaxed_day) # themselves are freedays.WORKDAYS, the one set every gate app reads.
# 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_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json" WORKDAY_PENALTY_FILE: Path = LOG_STATE_DIR / "workday_penalty.json"

View File

@ -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()

View File

@ -34,9 +34,8 @@ import sys
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac 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._logging_setup import configure_logging
from wake_alarm._ramp import baseline_time
from wake_alarm._session_payload import newest_session from wake_alarm._session_payload import newest_session
from wake_alarm._session_policy import to_entry from wake_alarm._session_policy import to_entry
from wake_alarm._sync import get_alarm from wake_alarm._sync import get_alarm
@ -100,8 +99,8 @@ def refresh(now: datetime) -> dict[str, object] | None:
today=today, today=today,
refreshed_at=now.timestamp(), refreshed_at=now.timestamp(),
# Same clamp as the trigger: the phone may only move the alarm earlier # 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. # than the baseline, so a later synced value never widens the grace.
alarm=min((alarm.hour, alarm.minute), baseline_time(now.date())), alarm=min((alarm.hour, alarm.minute), (FALLBACK_ALARM_HOUR, 0)),
) )
if not write_entry(entry): if not write_entry(entry):
return None return None

View File

@ -33,10 +33,10 @@ from wake_alarm._alarm_payload import in_range
from wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_CACHE_FILE, ALARM_CACHE_FILE,
ALARM_CACHE_MAX_AGE_SECONDS, ALARM_CACHE_MAX_AGE_SECONDS,
FALLBACK_ALARM_HOUR,
PUBLISHED_ALARM_MAX_AGE_SECONDS, PUBLISHED_ALARM_MAX_AGE_SECONDS,
) )
from wake_alarm._logging_setup import configure_logging from wake_alarm._logging_setup import configure_logging
from wake_alarm._ramp import baseline_time, local_today
from wake_alarm._sync_transport import ( from wake_alarm._sync_transport import (
_FIREBASE_AVAILABLE, _FIREBASE_AVAILABLE,
_fetch_firebase_alarm, _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). 1. A cache entry younger than *max_cache_age_seconds* (no network).
2. A live fetch, Firebase first (cached on success). 2. A live fetch, Firebase first (cached on success).
3. Any cache entry, however stale — better an old alarm than none. 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 A record the phone published longer ago than
``PUBLISHED_ALARM_MAX_AGE_SECONDS`` is skipped at *every* step, including ``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") _logger.info("Using stale cached alarm — sync read failed")
return Alarm(cached[0], cached[1], "stale-cache") return Alarm(cached[0], cached[1], "stale-cache")
hour, minute = baseline_time(local_today()) _logger.info(
_logger.info("No synced or cached alarm — falling back to %02d:%02d", hour, minute) "No synced or cached alarm — falling back to %02d:00",
return Alarm(hour, minute, "fallback") FALLBACK_ALARM_HOUR,
)
return Alarm(FALLBACK_ALARM_HOUR, 0, "fallback")
def _parse_args(argv: list[str]) -> argparse.Namespace: def _parse_args(argv: list[str]) -> argparse.Namespace:

View File

@ -109,7 +109,7 @@ def fetch_firebase_logs() -> list[tuple[str, str]] | None:
Discovers device directories rather than reading one fixed path: the Discovers device directories rather than reading one fixed path: the
phone's device id is migrating from the constant "phone" to a per-install 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 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 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. SYNC_ALARM_PATH remains as a last resort for a backend that cannot list.

View File

@ -21,7 +21,7 @@ must be built in the same timezone the user reads off their phone.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
from datetime import UTC, date, datetime, timedelta from datetime import UTC, datetime, timedelta
import logging import logging
import subprocess import subprocess
import sys import sys
@ -30,11 +30,11 @@ import freedays
from wake_alarm._constants import ( from wake_alarm._constants import (
ALARM_DAYS, ALARM_DAYS,
FALLBACK_ALARM_HOUR,
TRIGGER_CATCHUP_MINUTES, TRIGGER_CATCHUP_MINUTES,
TRIGGER_TARGET_UNIT, TRIGGER_TARGET_UNIT,
) )
from wake_alarm._logging_setup import configure_logging from wake_alarm._logging_setup import configure_logging
from wake_alarm._ramp import RAMP_START_MINUTES, baseline_time
from wake_alarm._state import ( from wake_alarm._state import (
save_trigger_fired, save_trigger_fired,
was_trigger_fired_today, was_trigger_fired_today,
@ -170,42 +170,37 @@ def _parse_args(argv: list[str]) -> argparse.Namespace:
return parser.parse_args(argv) return parser.parse_args(argv)
def _clamp_to_baseline(alarm: Alarm, day: date) -> Alarm: def _clamp_to_baseline(alarm: Alarm) -> Alarm:
"""Refuse to let any resolved alarm land later than *day*'s ramp baseline. """Refuse to let any resolved alarm land later than the baseline.
The phone arms at ``_ramp.baseline_time`` and only ever ratchets earlier, The phone bakes in ``FALLBACK_ALARM_HOUR`` and only ever ratchets *earlier*,
so honour an earlier value and clamp a later one. This is the PC half of so a synced value later than the baseline means something is wrong — a
the watchdog: without it the one thing the design forbids — the alarm stale record from before the policy landed, a half-written payload, or a
silently moving later — would be reachable through sync. 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 This is the PC half of the watchdog. Without it the PC would faithfully
publishes when the app runs, so yesterday's record is a minute behind reproduce a bad remote value, and the one thing the design forbids —
today's ramp. That logs at INFO (this runs every tick); only a value later the alarm silently moving later — would be reachable through sync.
than the old 08:00 baseline — stale or tampered — is worth a WARNING.
Args: Args:
alarm: The alarm as resolved by ``get_alarm``. alarm: The alarm as resolved by ``get_alarm``.
day: The local date the alarm fires on.
Returns: 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) <= (FALLBACK_ALARM_HOUR, 0):
if (alarm.hour, alarm.minute) <= base:
return alarm return alarm
level = logging.INFO _logger.warning(
if alarm.hour * 60 + alarm.minute > RAMP_START_MINUTES: "Resolved alarm %02d:%02d is later than the %02d:00 baseline "
level = logging.WARNING "(source=%s) — clamping. The phone can only move it earlier, so a "
_logger.log( "later value means a stale or tampered record.",
level,
"Resolved alarm %02d:%02d is later than today's %02d:%02d baseline "
"(source=%s) — clamping.",
alarm.hour, alarm.hour,
alarm.minute, alarm.minute,
*base, FALLBACK_ALARM_HOUR,
alarm.source, alarm.source,
) )
return Alarm(*base, f"clamped-{alarm.source}") return Alarm(FALLBACK_ALARM_HOUR, 0, f"clamped-{alarm.source}")
def main() -> None: def main() -> None:
@ -214,7 +209,7 @@ def main() -> None:
args = _parse_args(sys.argv[1:]) args = _parse_args(sys.argv[1:])
now = _now() now = _now()
alarm = _clamp_to_baseline(get_alarm(), now.date()) alarm = _clamp_to_baseline(get_alarm())
should_fire, reason = _decide(now, alarm) should_fire, reason = _decide(now, alarm)
if args.dry_run: if args.dry_run:

View File

@ -24,15 +24,15 @@ import logging
import freedays import freedays
from gatelock.log_integrity import compute_entry_hmac 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 from wake_alarm._session_policy import NONE_OUTCOME
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def is_stick_day(day: date) -> bool: def is_stick_day(day: date) -> bool:
"""Tuesday/Wednesday/Thursday -- the same set already treated as lenient.""" """Tuesday/Wednesday/Thursday: ``freedays.WORKDAYS``, the shared set."""
return day.weekday() in STICK_DAYS return freedays.is_workday(day)
def is_workday_miss(entry: dict[str, object], now: datetime) -> bool: def is_workday_miss(entry: dict[str, object], now: datetime) -> bool:

View File

@ -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

View File

@ -163,16 +163,6 @@ class TestRefresh:
assert entry is not None assert entry is not None
assert entry["exempt_until"] == _local("2026-09-19T08:15:00").isoformat() 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: def test_an_earlier_synced_alarm_shortens_the_grace(self) -> None:
with ( with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]), patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
import json import json
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -10,6 +9,9 @@ import urllib.error
import pytest import pytest
from wake_alarm._constants import (
FALLBACK_ALARM_HOUR,
)
from wake_alarm._sync import ( from wake_alarm._sync import (
Alarm, Alarm,
_fetch_github_alarm, _fetch_github_alarm,
@ -224,11 +226,12 @@ class TestGetAlarm:
"stale-cache", "stale-cache",
) )
def test_falls_back_to_the_ramp(self) -> None: def test_falls_back_to_constant(self) -> None:
"""With no cache and no network, today's ramp baseline is used.""" """With no cache and no network, the fixed fallback hour is used."""
with ( with patch("wake_alarm._sync.fetch_remote_alarm", return_value=None):
patch("wake_alarm._sync.fetch_remote_alarm", return_value=None),
patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)),
):
alarm = get_alarm() 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

View File

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import date
import json import json
import time import time
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -11,6 +10,7 @@ from unittest.mock import patch
import pytest import pytest
from wake_alarm._constants import ( from wake_alarm._constants import (
FALLBACK_ALARM_HOUR,
PUBLISHED_ALARM_MAX_AGE_SECONDS, PUBLISHED_ALARM_MAX_AGE_SECONDS,
) )
from wake_alarm._sync import ( from wake_alarm._sync import (
@ -84,10 +84,10 @@ class TestLapsedPublishedAlarm:
"wake_alarm._sync.fetch_remote_alarm", "wake_alarm._sync.fetch_remote_alarm",
return_value=(7, 0, ancient), return_value=(7, 0, ancient),
), ),
patch("wake_alarm._sync.local_today", return_value=date(2026, 10, 5)),
): ):
alarm = get_alarm() 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: def test_a_recent_remote_is_used(self, tmp_path: Path) -> None:
"""A freshly stamped record is honoured as normal.""" """A freshly stamped record is honoured as normal."""

View File

@ -2,12 +2,12 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import datetime
import logging
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from wake_alarm._constants import FALLBACK_ALARM_HOUR
from wake_alarm._sync import Alarm from wake_alarm._sync import Alarm
from wake_alarm._trigger import ( from wake_alarm._trigger import (
_clamp_to_baseline, _clamp_to_baseline,
@ -38,52 +38,27 @@ def _ungated() -> None:
yield yield
# 2026-09-26 is the ramp's first step: 07:59.
_DAY = date(2026, 9, 26)
class TestClampToBaseline: 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: def test_an_earlier_alarm_passes_through_untouched(self) -> None:
"""The phone ratchets earlier, so earlier values are legitimate.""" """The phone ratchets earlier, so earlier values are legitimate."""
alarm = Alarm(6, 30, "remote") 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: def test_the_baseline_itself_passes_through(self) -> None:
"""Equal to the day's baseline is not later than it.""" """Equal to the baseline is not later than it."""
alarm = Alarm(7, 59, "remote") alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote")
assert _clamp_to_baseline(alarm, _DAY) == alarm assert _clamp_to_baseline(alarm) == alarm
def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None: def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
"""The one direction the design forbids must not be reachable.""" """The one direction the design forbids must not be reachable."""
clamped = _clamp_to_baseline(Alarm(13, 0, "remote"), _DAY) clamped = _clamp_to_baseline(Alarm(13, 0, "remote"))
assert (clamped.hour, clamped.minute) == (7, 59) assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
assert clamped.source == "clamped-remote" 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.""" """08:30 is later than 08:00, so the minute matters too."""
clamped = _clamp_to_baseline(Alarm(8, 30, "cache"), date(2026, 9, 1)) clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache"))
assert (clamped.hour, clamped.minute) == (8, 0) assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
assert clamped.source == "clamped-cache" 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]

View File

@ -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<int> 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