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.
- 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)

View File

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

View File

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

View File

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

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.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(

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/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<RingScreen> {
// 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();

View File

@ -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<String> earned = const <String>{},
]) {
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<String> 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(),
);
}

View File

@ -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<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;
/// 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<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
/// 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<String> 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);
}

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
/// 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<void> 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<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
/// wins, but present, so it is pushed rather than silently dropped.
const _legacyMs = 1;
@ -118,6 +140,7 @@ Future<void> applySyncedSettings(AlarmSyncService service) async {
);
}
}
await RampProgress.merge(_datesOf(service.field(rampEarnedField)?.$1));
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/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, <String, int>{'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', (

View File

@ -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(<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 {
@ -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);
});

View File

@ -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<String> 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, String>{
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');
});
}

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: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);
});
});
}

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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["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=[]),

View File

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

View File

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

View File

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

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