Compare commits

..

9 Commits

Author SHA1 Message Date
89e6eed105 Bump sync_settings_ui to 0.6.1
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,free-days-interop) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / free-days-interop (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 18:28:23 +01:00
06b70ab312 Bump crdt_sync to v0.12.0
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-08 14:24:46 +01:00
10938ed012 Align pre-commit sickdays pin to 0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:38:22 +01:00
f182453567 Add morning-session corner strip
Top-right always-on-top strip while the signed morning session is
in_progress: title, "Next: <step>", "Locks return at HH:MM".
wake-alarm-strip.path starts it on every file rewrite; it re-reads
the file each second, asks for a refresh every 30 s, exits 0 when
the session ends. No display/output: warning, exit 1.
Bump gatelock pin to 0.12.0 (corner_strip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:19:42 +01:00
537a3a79e4 Retry session refresh; sign next_step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:12:01 +01:00
672eaf8aba Add todo app git hooks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 13:56:22 +01:00
13f6e3f5d2 Use google_account_token for sign-in
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWY1k4muZfdVJfb7YGJ
2026-11-02 11:27:35 +01:00
5525ca6f95 Move TODOs to todo app
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 11:14:40 +01:00
58c4260b6e Pin focus-project 0.3.0, sickdays 0.4.0
Conflicting git-tag pins in the shared user site let the last install downgrade focus_project/sickdays (no harvest, no focus locker).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:02:56 +01:00
40 changed files with 1219 additions and 585 deletions

View File

@ -8,6 +8,7 @@ default_language_version:
default_stages: [pre-commit]
fail_fast: false
default_install_hook_types: [pre-commit, pre-push, post-commit, post-merge, post-rewrite]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
@ -105,10 +106,10 @@ repos:
# direct URLs for one distribution, so any pin that drifts from
# the manifest becomes an unresolvable hook environment rather
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
- sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays
# 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)
@ -186,6 +187,30 @@ repos:
language: system
files: \.(md|markdown)$
- id: todo-closer
name: close Todo trailer tasks
entry: scripts/todo_hooks.sh closer
language: system
stages: [post-commit]
always_run: true
pass_filenames: false
- id: todo-import-pr-merge
name: import PR Todo sections (pull)
entry: scripts/todo_hooks.sh import-pr post-merge
language: system
stages: [post-merge]
always_run: true
pass_filenames: false
- id: todo-import-pr-rewrite
name: import PR Todo sections (pull --rebase)
entry: scripts/todo_hooks.sh import-pr post-rewrite
language: system
stages: [post-rewrite]
always_run: true
pass_filenames: false
# Every dependency on its ecosystem's newest STABLE release, exact-pinned
# so the question is mechanically answerable at all.
#

View File

@ -82,8 +82,18 @@ cutoffs, neither imports `crdt_sync`, neither talks to Firebase:
**A PC booted mid-morning** has a missing or yesterday's file until the
timer's `Persistent=true` catch-up lands, and that needs the network. Only
the enforce path, only inside 05:00–11:00, and only when the file is not
today's, both consumers retry for ≤ 30 s (5 s steps) and then proceed as
today's, both consumers retry for ≤ 180 s (5 s steps) and then proceed as
normal with a WARNING. Status paths never wait; tests pin the retry to 0.
The refresher itself retries an unreadable backend every 5 s for 150 s
(`REFRESH_RETRY_SECONDS`) before exiting 1 with a WARNING: a PC booted at
07:48 had no DNS for its first seconds, and the old single try left the next
chance at the 07:50 tick (2026-11-07).
**`next_step`** (signed with the rest): while the session is `in_progress`,
the label of the first leg not in the phone's `passed` map, else `null`.
Names and labels live in `_session_legs.py` and are pinned to the phone's
`SessionLeg` by `wake_alarm/tests/fixtures/session_legs.json`, asserted by a
Python and a Dart test.
## Known limits (v1, deliberate)

View File

@ -1,23 +0,0 @@
# Wake-alarm hardening — residue
REMOVE ME AFTER FINISH
Phases 0-7 shipped. The PC alarm window, gate and `_alarm.py` were deleted in
`e67cef0` (2026-08-30), so the old "item 8 mutual gating" (PC half) no longer
has anything to gate and was dropped. Context: `DOCS-wake-enforcement.md`.
## Remaining
1. **Item 13, black screen.** Host-only, other repo
(`~/src/testsAndMisc/linux_configuration`). The original plan file
(`~/.claude/plans/wake-alarm-still-does-witty-toucan.md`) is gone, so the
symptom is no longer written down: needs kuhy to restate it, or drop it.
2. **Two-tag dismiss sequence, end to end on the Pixel.** Needs kuhy: a second
physical NFC tag (only one is registered, so every on-device run fell
through to the 5-minute bypass).
3. **Locked-keyguard dismiss, run to completion.** Needs kuhy: a run with a
locked phone (PIN-prompt path is implemented but unproven).
## done
Items 1-3 each confirmed on the phone or dropped by kuhy; then delete this file.

View File

@ -1,63 +0,0 @@
# Hardware ideas: defeat a fully-powered-off phone
REMOVE ME AFTER FINISH
Both items below were scoped during the 2026-09-23 workday-stick session
(`DOCS-morning-session-pc.md`) and explicitly deferred out of scope for that
session: no existing firmware project and no owned wearable, both need a real
purchase before any code gets written. Written here instead of
`~/src/todo/BACKLOG.md` because that file is gitignored/export-only from the
todo app and a manual edit there is silently lost on the next export — this
is the durable copy.
## Item 1 — second wake-alarm device: rings independently of the phone's power state
### what
An ESP32 + buzzer that polls the same Firebase alarm/session record
wake-alarm already reads and keeps ringing until it sees a dismiss or a
started session — so powering the phone off can no longer silently kill the
whole wake attempt the way it does today.
### where
new repo (no existing ESP32/PlatformIO/Arduino project anywhere in `~/src`
to build on — checked). Firebase-only, per the "new syncing apps are
Firebase-only" rule.
### must
- must not require the phone to relay anything — the whole point is
surviving the phone being off.
- optional: a physical snooze/dismiss button, since the phone's own dismiss
gate isn't reachable from the device.
### done
On a Tue/Wed/Thu, power the phone off right at the alarm and the ESP32
still rings, and keeps ringing, until the phone's session shows a start.
### verify
Bench test first (USB power, no case), then a real overnight run.
### read first
- `DOCS-phone-alarm.md` (the Firebase alarm schema to poll)
- `DOCS-morning-session-pc.md` (why this exists: the workday-stick project,
2026-09-23)
## Item 2 — wearable wake-defeat check
### what
A cheap fitness band whose own sleep log (stored on the band, synced later)
can show "asleep again after the alarm rang" even when the phone was fully
off and missed the whole thing.
### where
new app or an extension of `phone_app/`, once a device is picked.
### must
- must not: buy or integrate anything before checking Gadgetbridge's
current supported-device list — research first, kuhy owns no wearable and
none is integrated anywhere in `~/src` today (checked).
### done
Not yet scoped — picking a supported device is the blocking step.
### verify
Blank — desktop, once there's something to verify.

View File

@ -1,90 +0,0 @@
# Wake enforcement v2 — follow-ups for the next session
REMOVE ME AFTER FINISH
Handoff from the 2026-10-01 session that built v2. Read
`DOCS-wake-enforcement.md` (the contract + verification status) and
`AGENTS.md` first. Start **after the morning of 2026-10-02** so there is real
evidence to check.
## Prompt to paste into the new session
> Work through ~/src/wake-alarm/TODO-wake-enforcement-v2-followups.md top to
> bottom. Verify each item yourself on the real target (phone 23181JEGR08034,
> this PC) before calling it done, then delete the TODO file in the final
> commit.
## 1. Check the first real morning (2026-10-02, Fri, carrot day)
- `python -m wake_alarm._audit 2026-10-02` (and the 11:05 file in
`~/data/wake-alarm/incidents/`). Expected: phone `alarm_fired`
(`reason=daily`), `ring_started_published` with `source=alarm` (headless
engine, release build — never verified in release before) or
`ring_screen`, `stick_check` verdict for a non-stick day, no fallback ring
unless the phone really was silent.
- `adb logcat` for `WakeAlarmHeadless` / `WakeAlarmStick*` if anything is
missing. Firebase fields `ring_started`, `phone_events` must be non-empty
(`enforcement_view(fetch_firebase_logs())`).
- Any MISMATCH in the report is a bug to fix, not a note.
## 2. focus-owner reacting to a real lockdown (needs kuhy's OK + timing)
Never verified on the Pixel. Either wait for the first real Tue/Wed/Thu miss
and read focus-owner's Debug log (`LOCKDOWN_ACTIVATED` with the through-date,
then a `WORKDAY_LOCKDOWN` pass), or — with kuhy's explicit OK, at a time he
names — trigger one and lift it with the escape button (logs an escape).
## 3. Root ring vs a card held busy by PipeWire
Host test 2026-10-01: `wake-alarm-ring-alsa.service` got `Device or resource
busy` while the user's PipeWire held card `Generic_1` (ALC897). kuhy approved
killing the holder (`fuser -k -TERM /dev/snd/pcmC<N>D0p` after 3 busy opens,
in `scripts/pc/ring_alsa.sh`), but the auto-mode classifier refused the edit
twice. **kuhy must add a permission rule first**, or choose the non-killing
alternative: when the root unit cannot open the card, `_ring_player` falls
back to `PipeWireBackend` (PipeWire holds the card, so it can mix the ring
in). Add a vmbox case that holds `snd-dummy` open and proves the ring still
plays.
## 4. S5 (poweroff) RTC wake test on the host
Hibernate (S4) wake is verified and is the default mode. S5 is not. The
self-resume boot unit (`~/data/claude-scratch/wake-fw/s5_after_boot.sh`,
never installed) was refused by the classifier. Simplest: with kuhy present,
`sudo -n rtcwake -m no -s 240` + record the epoch, `systemctl poweroff`; kuhy
powers on if it does not wake; then compare `uptime -s` with the epoch. If S5
works, decide with kuhy whether to switch `pc_wake.json` to `poweroff` (covers
a typed `shutdown`, not just the power key).
## 5. utils dependency upgrade — prepared, uncommitted
In `~/src/utils` (do NOT stage the other session's `ts_config/README.md`,
`scripts/lib/alias-imports.mjs`, `test/alias-imports.test.js`): pylint 4.1.1
×3, typescript-eslint 8.71.0 (ts_config/ts_core/web_ui), @eslint-react
5.23.3, eslint-plugin-sonarjs 4.2.2, pnpm 12.8.1 (+ lockfiles,
`ts_config/pnpm-workspace.yaml`), allowlist: typescript-eslint entry removed,
typescript entry now `blocked_by: transitive:typescript-eslint@8.71.0`.
Lint + tests pass in all three. `finish_auto.sh` fails at its test step with
corepack `MODULE_NOT_FOUND .../pnpm/12.8.1/bin/pnpm.cjs` (pnpm 12.8 ships a
native binary; corepack 0.36 can't launch it). Global pnpm 12.8.1 is now
installed directly (`corepack disable pnpm`); find what in the gate still
calls corepack (read `~/data/claude-scratch/wake-v2/utils-commit.log`), fix,
commit, watch CI green.
## 6. Fleet repos still pinning typescript-eslint 8.70.0
The allowlist entry that excused them is gone, so their freshness gate turns
red on the next run: konbini-67, iron-and-anvil, awesome-mcp-explorer,
europe-county-map and any other repo pinning it (`grep -rl
'"typescript-eslint": "8.70.0"' ~/src --include=package.json`). Bump each to
8.71.0, lint + test, commit, CI green.
## 7. Small leftovers
- `~/src/todo`: `cupertino_icons` 1.0.9 → 2.0.0 — another session had
uncommitted work there; only bump once its tree is clean.
- Global `~/.claude/CLAUDE.md` sandbox-first note says the vmbox guest is
BIOS-only; vmbox has `--uefi` and `vm wake` (RTC-wake emulation) since
utils 694c013. Ask kuhy before editing that file.
- Clear the test data left in the `wake_alarm_api36` emulator (escape for
10-01, ringStarted, stick state) before it is ever signed into sync.

View File

@ -11,7 +11,7 @@
# What it does:
# 1. Installs wake_alarm + dependencies for /usr/bin/python
# 2. Installs the systemd user units and enables their timers (trigger,
# session, fallback, rtc, audit)
# session, strip, fallback, rtc, audit)
# 3. Installs the root pieces of the fallback alarm: the ring WAVs, the
# ALSA ring script + system unit, the sudoers lines that let the user
# start/stop that unit, and (pc_wake.json "hibernate" mode only) the
@ -47,6 +47,8 @@ readonly TRIGGER_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-trigger.service"
readonly TRIGGER_TIMER_SRC="$SCRIPT_DIR/wake-alarm-trigger.timer"
readonly SESSION_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-session.service"
readonly SESSION_TIMER_SRC="$SCRIPT_DIR/wake-alarm-session.timer"
readonly STRIP_SERVICE_SRC="$SCRIPT_DIR/wake-alarm-strip.service"
readonly STRIP_PATH_SRC="$SCRIPT_DIR/wake-alarm-strip.path"
readonly SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
# Enforcement v2 user units (service + timer each).
readonly V2_UNITS=(wake-alarm-fallback wake-alarm-rtc wake-alarm-audit)
@ -134,7 +136,8 @@ echo " wake_alarm and gatelock import under /usr/bin/python."
echo "[2/4] Installing systemd user units..."
mkdir -p "$SYSTEMD_USER_DIR"
cp "$TRIGGER_SERVICE_SRC" "$TRIGGER_TIMER_SRC" \
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" "$SYSTEMD_USER_DIR/"
"$SESSION_SERVICE_SRC" "$SESSION_TIMER_SRC" \
"$STRIP_SERVICE_SRC" "$STRIP_PATH_SRC" "$SYSTEMD_USER_DIR/"
for unit in "${V2_UNITS[@]}"; do
cp "$SCRIPT_DIR/$unit.service" "$SCRIPT_DIR/$unit.timer" "$SYSTEMD_USER_DIR/"
done
@ -143,6 +146,8 @@ systemctl --user enable --now wake-alarm-trigger.timer
echo " Trigger timer enabled and started (ticks once a minute)."
systemctl --user enable --now wake-alarm-session.timer
echo " Session timer enabled (07:00-11:00, every 2 min)."
systemctl --user enable --now wake-alarm-strip.path
echo " Strip path enabled (corner reminder while the morning is in progress)."
for unit in "${V2_UNITS[@]}"; do
systemctl --user enable --now "$unit.timer"
done

View File

@ -1,11 +0,0 @@
/// Platform gate for the programmatic Google sign-in flow.
///
/// Conditional export because the answer differs per platform *and* because
/// asking the plugin directly is unsafe: `supportsAuthenticate()` throws
/// `UnimplementedError` where no implementation is registered -- an `Error`,
/// not an `Exception`, so it escapes an ordinary catch and would take down
/// the settings screen's `build()`.
library;
export 'google_platform_io.dart'
if (dart.library.js_interop) 'google_platform_web.dart';

View File

@ -1,10 +0,0 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The io half: Android has the Credential Manager flow; Linux desktop does
/// not, and this app's desktop build is the web one anyway.
library;
import 'dart:io';
/// True only on Android, the one platform shipping the programmatic flow.
bool get googleSignInSupported => Platform.isAndroid;

View File

@ -1,8 +0,0 @@
/// Whether the programmatic Google sign-in flow exists on this platform.
///
/// The web half: Google Identity Services signs in only through its own
/// rendered button, so `authenticate()` throws `UnimplementedError` here.
library;
/// Always false: the web plugin has no programmatic sign-in.
bool get googleSignInSupported => false;

View File

@ -2,9 +2,9 @@
///
/// Why this is its own file: `crdt_sync` is deliberately pure Dart (only
/// `crypto` and `http`), because the same library runs on Linux desktop and
/// headless under systemd, where `google_sign_in` does not exist. So the
/// plugin lives here, in the app, and the library only ever receives a token
/// string through a closure.
/// headless under systemd, where Android's AccountManager does not exist. So
/// the plugin call lives here, in the app, and the library only ever receives
/// a token string through a closure.
///
/// What this buys: on a fresh install the sync account is reached by tapping
/// one account-picker row instead of typing a long password on a phone
@ -12,10 +12,7 @@
/// credential for desktop and the Python side.
library;
import 'dart:developer';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:wake_alarm_sync/services/google_platform.dart' as platform_gate;
import 'package:google_account_token/google_account_token.dart' as plugin;
/// The account the security rules pin.
///
@ -50,73 +47,26 @@ const kServerClientId =
/// Whether this platform can sign in programmatically.
///
/// False on web, where Google Identity Services only signs in through its own
/// rendered button: `authenticate()` throws `UnimplementedError` there. The
/// desktop build of this app *is* the web build, so without this check the
/// "Sign in with Google" button would crash the settings screen on desktop
/// rather than falling back; see `google_platform.dart` for why this is a
/// platform check rather than a question asked of the plugin.
/// True only on Android (the plugin's own gate): the desktop build of this
/// app *is* the web build, and neither has an AccountManager, so without this
/// check the "Sign in with Google" button would show a flow that can never
/// succeed instead of falling back to the password path.
///
/// Also false when no client id is compiled in: a button that cannot possibly
/// succeed is worse than no button, and without this check an unconfigured
/// build reports "cancelled" and sends you debugging the OAuth console for a
/// problem that is really a missing constant.
/// succeed is worse than no button.
bool get googleSignInSupported =>
platform_gate.googleSignInSupported && kServerClientId.isNotEmpty;
plugin.googleAccountTokenSupported && kServerClientId.isNotEmpty;
/// Returns a Google ID token for the signed-in account, or null.
///
/// Null rather than throwing when the user dismisses the picker: cancelling is
/// an ordinary outcome, and the caller falls back to the password path.
///
/// [signInFn] and [serverClientId] exist for tests, which cannot reach the
/// platform channel the plugin uses.
/// [signInFn] and [serverClientId] exist for tests that bypass the plugin.
Future<String?> googleIdToken({
Future<String?> Function()? signInFn,
String serverClientId = kServerClientId,
}) async {
if (signInFn != null) return await signInFn();
if (serverClientId.isEmpty) {
// A build without --dart-define=GOOGLE_SERVER_CLIENT_ID would otherwise
// fail inside the plugin with a far less obvious message.
log(
'Google sign-in unavailable: GOOGLE_SERVER_CLIENT_ID was not set at '
'build time; falling back to the password path',
level: 900,
);
return null;
}
// The plugin reaches the OS account picker through a platform channel,
// which `flutter test` has no binding for -- the same reason main.dart and
// firebase_backend.dart's keystore adapters are excluded. Everything above
// this point is pure Dart and is covered.
// coverage:ignore-start
try {
await GoogleSignIn.instance.initialize(serverClientId: serverClientId);
if (!GoogleSignIn.instance.supportsAuthenticate()) {
// Web/desktop: sign-in must go through the rendered GIS button, which
// this app does not host yet. Fall back rather than throwing.
log(
'Google sign-in is not available on this platform; falling back to '
'the password path',
level: 900,
);
return null;
}
final account = await GoogleSignIn.instance.authenticate();
return account.authentication.idToken;
} on GoogleSignInException catch (error, stackTrace) {
// Includes the user simply dismissing the picker, which is not an error
// worth surfacing -- but log it, because a *configuration* failure
// (unregistered SHA-1, wrong client id) arrives through the same path and
// is otherwise indistinguishable from "the user changed their mind".
log(
'Google sign-in did not complete',
level: 900,
error: error,
stackTrace: stackTrace,
);
return null;
}
// coverage:ignore-end
return await plugin.googleIdToken(serverClientId: serverClientId);
}

View File

@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: android_file_picker
sha256: "03d3dd3cb578ab26b1e528aeea27bf6b976942f5a24e3da82d270369b8e161a8"
sha256: "868183c959e754e915954d8074c20cfa4cfdb4684b35ecc08378437be1a542d8"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
version: "2.1.3"
android_intent_plus:
dependency: "direct main"
description:
@ -101,11 +101,11 @@ packages:
dependency: "direct main"
description:
path: crdt_sync_dart
ref: "crdt_sync_dart-v0.11.3"
resolved-ref: a2e7334dfec5131170a084510d70361d53dc40bc
ref: "crdt_sync_dart-v0.12.0"
resolved-ref: "59b33949e4a130bb76c77e7434ddf4bbf8cde3a3"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.11.3"
version: "0.12.0"
cross_file:
dependency: transitive
description:
@ -134,11 +134,11 @@ packages:
dependency: "direct main"
description:
path: data_transfer_flutter
ref: "data_transfer_flutter-v0.2.0"
resolved-ref: bbb7f0f0c0cd891518418472955c8a2ac6cbca4d
ref: "data_transfer_flutter-v0.2.1"
resolved-ref: d4fca83c9f2ed33b3315cd838b9ca532ae8e559c
url: "https://github.com/kuhyx/utils"
source: git
version: "0.2.0"
version: "0.2.1"
dbus:
dependency: transitive
description:
@ -183,18 +183,18 @@ packages:
dependency: transitive
description:
name: file_picker
sha256: "02f2945a7f29535c735f6b283c3c5553810566d3052d730429aec78fe9d06592"
sha256: c64036857780d7125f011d543f89eedcb5df61da473668bace4eee9ab875284d
url: "https://pub.dev"
source: hosted
version: "13.3.1"
version: "13.3.2"
file_picker_darwin:
dependency: transitive
description:
name: file_picker_darwin
sha256: b845af092ca7a3cff0e768029f835157d371a8341750f4158a7ad358143b0e54
sha256: daeb0219f6279c73eb9c63ae9a566beef04ae13d59b8feba0be2a7f3b258d3d4
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
file_picker_linux:
dependency: transitive
description:
@ -371,54 +371,15 @@ packages:
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.2"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
google_account_token:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "57782125965ca87b03d42a147d40b046f1fd6a09141a58913e1b86671a5ef22e"
url: "https://pub.dev"
source: hosted
version: "7.2.16"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950
url: "https://pub.dev"
source: hosted
version: "6.3.0"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: transitive
description:
name: google_sign_in_web
sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14
url: "https://pub.dev"
source: hosted
version: "1.1.3"
path: google_account_token
ref: "google_account_token-v0.1.0"
resolved-ref: db70ba204d124c6eadf9b8808cd3d3b3e70363d1
url: "https://github.com/kuhyx/utils"
source: git
version: "0.1.0"
hooks:
dependency: transitive
description:
@ -772,11 +733,11 @@ packages:
dependency: "direct main"
description:
path: sync_settings_ui
ref: "sync_settings_ui-v0.5.2"
resolved-ref: "648bde44b1756485d95ca3c3a8714cd2015d5be4"
ref: "sync_settings_ui-v0.6.1"
resolved-ref: ebedbf4b27fa332f7b8e6d80117d1c409bf3a7ad
url: "https://github.com/kuhyx/utils"
source: git
version: "0.5.2"
version: "0.6.1"
term_glyph:
dependency: transitive
description:

View File

@ -12,7 +12,7 @@ dependencies:
crdt_sync:
git:
url: https://github.com/kuhyx/utils
ref: crdt_sync_dart-v0.11.3
ref: crdt_sync_dart-v0.12.0
path: crdt_sync_dart
cupertino_icons: 2.0.0
@ -20,7 +20,7 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.0
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
flutter:
sdk: flutter
@ -33,7 +33,11 @@ dependencies:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.2
path: github_device_auth
google_sign_in: 7.2.0
google_account_token:
git:
url: https://github.com/kuhyx/utils
ref: google_account_token-v0.1.0
path: google_account_token
http: 1.6.0
path: 1.9.1
path_provider: 2.1.6
@ -45,7 +49,7 @@ dependencies:
sync_settings_ui:
git:
url: https://github.com/kuhyx/utils
ref: sync_settings_ui-v0.5.2
ref: sync_settings_ui-v0.6.1
path: sync_settings_ui
url_launcher: 6.3.3
uuid: 4.6.0

View File

@ -1,14 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
void main() {
test('reports whether the programmatic Google flow exists here', () {
// Android is the only platform shipping it. Asked as a platform question
// rather than of the plugin, because GoogleSignIn.supportsAuthenticate()
// itself throws UnimplementedError where no implementation is registered
// -- an Error, not an Exception, so it would escape an ordinary catch and
// take down the settings screen's build(). The test host is Linux, so
// this is false here and the password path stays visible.
expect(googleSignInSupported, isFalse);
});
}

View File

@ -1,15 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
const _channel = MethodChannel('dev.kuhy/google_account_token');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
tearDown(() {
debugDefaultTargetPlatformOverride = null;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, null);
});
group('googleIdToken', () {
test('returns the token the sign-in function yields', () async {
// The injected path: proves the closure is what feeds crdt_sync, and
// that no platform channel is touched to get there.
expect(
await googleIdToken(signInFn: () async => 'a-token'),
'a-token',
);
expect(await googleIdToken(signInFn: () async => 'a-token'), 'a-token');
});
test('passes a null cancellation straight through', () async {
@ -19,12 +28,24 @@ void main() {
});
test('reports "not configured" when the client id is missing', () async {
// Passed explicitly rather than relying on the const: an app whose
// client id is filled in would otherwise reach the real plugin here.
// The behaviour under test is the empty-id fallback itself.
// An empty client id must fall back rather than reach the OS chooser.
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(await googleIdToken(serverClientId: ''), isNull);
});
test('asks the account-token plugin for the Web client id', () async {
// The real path: the plugin's channel gets the pinned Web client id.
debugDefaultTargetPlatformOverride = TargetPlatform.android;
Object? sent;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_channel, (call) async {
sent = call.arguments;
return 'plugin-token';
});
expect(await googleIdToken(), 'plugin-token');
expect(sent, {'serverClientId': kServerClientId});
});
test('pins the uid the security rules expect', () {
// If this ever drifts from database.rules.json, a Google sign-in would
// be accepted here and then denied every read and write.

View File

@ -0,0 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/google_sign_in_backend.dart';
void main() {
tearDown(() => debugDefaultTargetPlatformOverride = null);
test('the one-tap button is hidden off Android', () {
// flutter_test defaults to Android, so pin Linux (the desktop build): no
// AccountManager, so the password path stays the only option.
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
expect(googleSignInSupported, isFalse);
});
test('the one-tap button shows on Android', () {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
expect(googleSignInSupported, isTrue);
});
}

View File

@ -0,0 +1,24 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/morning_session.dart';
/// The PC reads leg names (the keys of the published `passed` map) and labels
/// (its `next_step`) from the same literal: the Python half asserts it in
/// `wake_alarm/tests/test_session_legs.py`. Neither side's own round-trip can
/// catch a rename on the other.
void main() {
test('SessionLeg names and labels equal the shared fixture', () {
final raw = File(
'../wake_alarm/tests/fixtures/session_legs.json',
).readAsStringSync();
final legs = (jsonDecode(raw) as Map<String, Object?>)['legs']! as List;
expect(
<(String, String)>[for (final l in SessionLeg.values) (l.name, l.label)],
<(String, String)>[
for (final l in legs) (l['name'] as String, l['label'] as String),
],
);
});
}

View File

@ -1,202 +0,0 @@
> **This file is a ready-to-use prompt.** Open this repo and say
> "do dopamine-ux-wake-alarm". It is self-contained -- it needs no context from
> any other session.
>
> Canonical copy: `~/src/utils/dopamine-ux/09-wake-alarm.md` (source of truth; this is a
> distributed copy). Running order and cross-prompt rules: `~/src/utils/dopamine-ux/00-INDEX.md`.
> Generated 2026-08-16 from a survey of this repo -- line numbers are accurate
> as of that date; **anchor on the symbol names, not the line numbers.**
## ⛔ WHEN YOU FINISH THIS PROMPT
Do **both**, in the same session that completes the work:
1. **Delete this file** and commit that deletion together with the
implementation:
```bash
git rm prompts/dopamine-ux-wake-alarm.md
```
Do not leave a finished prompt lying in the repo -- a stale prompt is
indistinguishable from a pending one, and the next session will re-run it.
2. **Log completion in the canonical index**, in `~/src/utils` (a *different* repo,
so it needs its own commit):
```bash
cd ~/src/utils
# append to the "Completion log" table in dopamine-ux/00-INDEX.md:
# | 09-wake-alarm.md | DONE <YYYY-MM-DD> | <impl commit sha> | <one-line note> |
git add dopamine-ux/00-INDEX.md
git commit -m "dopamine-ux: mark 09-wake-alarm.md done"
git push
```
If you finish only part of the prompt, do **neither** -- instead edit this file
to strike out what is done and state what remains, so the next session starts
from the true state. Partial completion must never be recorded as DONE.
---
Add motion and haptics to the wake-alarm phone app
## findings (2, ranked by value over effort)
1. **Setting an alarm gives no confirmation or haptic** — and since the real
alarm lives in another app entirely, confirming the handoff is the only
reassurance this app can offer.
2. **Zero motion, and the thinnest local theme in the fleet** — it lacks
`AppSpacing`/`AppRadius`/`AppTextSize`, so widgets copied from sibling apps
will not compile here.
## what
`~/src/wake-alarm/phone_app` is the smallest surface in this project: 12 Dart files,
a sync companion that sets a time, syncs it, and hands off to the system clock
app. It has **zero motion, zero haptics, zero audio, zero progress feedback** —
verified 2026-08-16. Its settings screen is a 56-line `StatelessWidget` with two
`ListTile`s and no state at all.
Bring it up to the fleet's baseline: a confirmation the alarm was actually set,
motion tokens on its transitions, and a haptic on the one action that matters.
**This app is deliberately last and deliberately small.** It also carries the
project's sharpest hazard — see `must not` about alarm audio.
## where
Repo: `~/src/wake-alarm`. Flutter app: `~/src/wake-alarm/phone_app` (**underscore**, not
`phone-app`). The repo root is a Python package; the Flutter app is only the
`phone_app/` subdirectory.
Primary:
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` ~:86-96 builds an
`AndroidIntent(action: 'android.intent.action.SET_ALARM', arguments: {HOUR,
MINUTES, SKIP_UI: false, MESSAGE: 'Wake Alarm'})` via `android_intent_plus`.
The `CircularProgressIndicator` is at ~:189. The only `Duration` in the whole
app is `.timeout(const Duration(seconds: 3))` ~:95 — **an intent timeout, not
animation. Do not tokenise it.**
- `phone_app/lib/screens/settings_screen.dart` — 56 lines, `StatelessWidget`,
`ListView` ~:21-53, `MaterialPageRoute` ~:30, :49.
- `phone_app/lib/services/sync_settings.dart` — holds **only** the GitHub token
in the keystore (`FlutterSecureStorage`, `_secureTokenKey = 'github_token'`
~:31, `load()` ~:36, `save()` ~:61). There is no general app-preferences class.
- `phone_app/lib/ui/theme.dart` — local hand-copy, **no `design_system`
dependency**. `buildAppTheme()` ~:12, `AppStatusColors` ~:54. ⚠️ Unlike the
other apps it does **not** declare `AppSpacing`/`AppRadius`/`AppTextSize`, so a
widget copied from diet-guard or workout_app **will not compile here**. Wired at
`phone_app/lib/main.dart` ~:18.
## must
**Step 1 — motion + haptics (no new dependency).**
- Add `HapticFeedback.mediumImpact()` when the alarm is successfully set — the
single most important action in the app. Fire it on the tap, not after the
intent round-trips. `HapticFeedback` is in `package:flutter/services.dart`; add
`<uses-permission android:name="android.permission.VIBRATE"/>` to
`phone_app/android/app/src/main/AndroidManifest.xml` if absent.
- Give setting the alarm a **clear confirmation**: which time was set, and that
the handoff to the system clock succeeded. Today the user gets a `_status`
string and a spinner. Since the actual alarm lives in another app, confirming
the handoff is the only reassurance this app can offer — make it unambiguous.
- Transcribe the motion tokens from prompt 01 into `phone_app/lib/ui/theme.dart`,
matching the existing hand-copy convention. Add `AppSpacing`/`AppRadius`/
`AppTextSize` **only if** you need them; otherwise leave the file's shape alone.
- Apply motion tokens to the two `MaterialPageRoute` transitions.
- Honour `MediaQuery.of(context).disableAnimations`.
**Step 2 — sound (optional here; consider skipping).**
- A UI confirmation cue would need an audio dependency, an `assets:` block, a
settings toggle, **and** a preferences class that does not exist yet (see
below). For an app whose entire job is to hand off to another app, that is a
poor value ratio.
- **Recommendation: skip sound in this app** and say so explicitly in the session
summary. If you do add it, it needs the full opt-out toggle like everywhere
else — and the toggle work below.
- To add any toggle here you must do three things the other apps do not require:
(a) convert `SettingsScreen` from `StatelessWidget` to `StatefulWidget`,
(b) create a preferences class — there is no `AppSettings` equivalent; model it
on `~/src/todo/lib/data/app_settings.dart`, and note `shared_preferences: ^2.3.0`
is **already a dependency**, (c) add the `SwitchListTile` to the `ListView`.
**Both steps:**
- must not: **touch anything under `~/src/wake-alarm/wake_alarm/`.** That is the
desktop Python daemon that produces the real alarm noise — sine-tone WAVs via
`paplay` → `aplay` → `speaker-test` (`_audio.py`, `_play_on_all_sinks` ~:218,
pcspkr evdev fallback `_beep_pcspkr` ~:81), escalating 440Hz → 1000Hz → loud in
the loop at `_alarm.py` ~:393-407, with sink volume save/restore in `_sinks.py`.
**Never route a UI-sound opt-out through any of it.** A toggle that silences a
wake-up alarm is a bug that makes someone late for work.
- The two systems never share a process, package, or file — `phone_app` has no
audio engine at all — so a UI sound layer here **structurally cannot** touch
alarm playback. Keep it that way; do not build a bridge between them.
- must not: change the alarm-setting intent, its arguments, or the 3s timeout.
Feedback wraps that call; it does not modify it.
- must not: add a streak, a counter, or any reward for setting an alarm. Waking
up is not a metric this app owns, and rewarding "alarms set" rewards the wrong
act entirely.
- must not: copy a widget from another app without checking it only uses symbols
this app's thinner theme actually declares.
## done
1. Setting an alarm produces a haptic and an unambiguous confirmation naming the
time that was set.
2. Page transitions use the shared motion tokens; no inline animation `Duration`
remains (the 3s intent timeout stays).
3. `cd ~/src/wake-alarm/phone_app && flutter analyze` is clean.
4. `cd ~/src/wake-alarm/phone_app && flutter test` passes.
5. With OS "remove animations" enabled, the app works and durations are zero.
6. `git diff` shows **no changes under `~/src/wake-alarm/wake_alarm/`**.
7. The summary states whether sound was added or deliberately skipped.
## verify
**On the phone.**
```
adb devices # confirm 23181JEGR08034
cd ~/src/wake-alarm/phone_app
flutter build apk --release
adb install -r build/app/outputs/flutter-apk/app-release.apk
```
Never `flutter install`, never `adb uninstall`, never `pm clear` — the app stores
a GitHub token in the keystore and wiping it means re-authenticating.
Then set a real alarm on the device: confirm the haptic fired (state it as a
physical observation), the confirmation named the right time, and **the system
Clock app actually received the alarm**. The last one is the real test — this
app's job is the handoff, so verify the alarm exists in the Clock app, not just
that this screen said so.
Do **not** test by letting an alarm actually fire unless kuhy asks — that
involves the desktop daemon and real noise.
## read first
- `phone_app/lib/screens/home_screen.dart` — `_setPhoneAlarm()` in full,
including the timeout and the `_status` string handling.
- `phone_app/lib/ui/theme.dart` — note what it does **not** declare before
writing any widget.
- `~/src/todo/lib/data/app_settings.dart` — the preferences-class pattern, only if
you decide to do step 2.
- `~/src/utils/unified-design-system/motion.md` — vocabulary from prompt 01.
**Prompt 01 must have run first.**
## context you would otherwise rediscover
- **The Flutter app produces no audio whatsoever.** No audio package, no
`assets:` block; `assets/` contains only launcher icons. It fires an Android
`SET_ALARM` intent and the **system Clock app** rings. The desktop Python
daemon is an entirely separate program on a different machine.
- The repo root (`~/src/wake-alarm/wake_alarm/`, `wake_alarm.egg-info`) is the Python
package. Only `phone_app/` is Flutter.
- This app has **no `design_system` dependency** and the thinnest local theme in
the fleet. Adding the dependency is out of scope here (see prompt 07 for what
that migration looks like when done properly).
- No streaks, no charts, no progress, no completion counts exist — and none
should be added. This is a two-button utility.
REMOVE ME AFTER FINISH

View File

@ -8,7 +8,7 @@ version = "1.0.0"
description = "Trigger that starts the morning routine at the phone's alarm time"
requires-python = ">=3.14"
dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock",
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock",
# Firebase transport for the alarm read. _sync.py imports it lazily and
# 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.
@ -16,7 +16,7 @@ dependencies = [
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays",
# Sick days stand the PC's enforcement down (_sick.py). Imported behind
# a guard: missing means "not sick", never a crashed tick.
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays",
"sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays",
]
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python

View File

@ -4,10 +4,10 @@
# it is missing — but it is required here for mypy/pylint/tests.
bandit==1.9.4
codespell==2.4.3
coverage==7.17.0
coverage==7.17.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.11.0#subdirectory=gatelock
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.12.0#subdirectory=gatelock
mypy==2.4.1
pre-commit==4.7.0
pylint==4.1.3
@ -16,5 +16,5 @@ pytest-cov==7.1.0
pytest-randomly==5.0.0
pytest-sugar==1.1.1
pytest-xdist==3.8.0
ruff==0.17.3
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays
ruff==0.17.4
sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.4.0#subdirectory=sickdays

12
scripts/todo_hooks.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Shim: forward to the shared todo git hooks in ~/src/utils (silent if absent).
set -euo pipefail
readonly SHARED="${UTILS_ROOT:-$HOME/src/utils}/scripts/todo_hooks.sh"
main() {
[[ -f "$SHARED" ]] || exit 0
exec bash "$SHARED" "$@"
}
main "$@"

View File

@ -86,10 +86,14 @@ remove_trigger() {
echo " Disabling the trigger timer"
systemctl --user disable --now wake-alarm-trigger.timer >/dev/null 2>&1 || true
systemctl --user disable --now wake-alarm-session.timer >/dev/null 2>&1 || true
systemctl --user disable --now wake-alarm-strip.path >/dev/null 2>&1 || true
systemctl --user stop wake-alarm-strip.service >/dev/null 2>&1 || true
rm -f "$SYSTEMD_USER_DIR/wake-alarm-trigger.service" \
"$SYSTEMD_USER_DIR/wake-alarm-trigger.timer" \
"$SYSTEMD_USER_DIR/wake-alarm-session.service" \
"$SYSTEMD_USER_DIR/wake-alarm-session.timer"
"$SYSTEMD_USER_DIR/wake-alarm-session.timer" \
"$SYSTEMD_USER_DIR/wake-alarm-strip.service" \
"$SYSTEMD_USER_DIR/wake-alarm-strip.path"
}
remove_enforcement() {

View File

@ -11,8 +11,9 @@ OnCalendar=*-*-* 05..06:00/10:00
OnCalendar=*-*-* 07..10:00/2:00
OnCalendar=*-*-* 11:00:00
AccuracySec=1s
# A PC booted mid-morning refreshes at once; the consumers wait up to 30 s
# for exactly this catch-up before treating the file as stale.
# A PC booted mid-morning refreshes at once, retrying in-process every 5 s
# for 150 s (no DNS yet at boot); the consumers wait up to 180 s for exactly
# this catch-up before treating the file as stale.
Persistent=true
[Install]

14
wake-alarm-strip.path Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Show the morning-session strip whenever the signed session file is rewritten
Documentation=https://github.com/kuhyx/wake-alarm
[Path]
# wake-alarm-session.timer rewrites this every 2 min through the morning (and
# the strip itself asks for a refresh every 30 s while it is up). Starting a
# service that is already running is a no-op, so the rewrites cost nothing;
# once the session is over the strip exits 0 at once.
PathChanged=%h/.local/state/wake_alarm/morning_session.json
Unit=wake-alarm-strip.service
[Install]
WantedBy=paths.target

14
wake-alarm-strip.service Normal file
View File

@ -0,0 +1,14 @@
[Unit]
Description=Wake Alarm morning-session strip (top-right corner reminder)
Documentation=https://github.com/kuhyx/wake-alarm
[Service]
Type=simple
# Same display handling as leetcode-guard.service, the other Tk user unit:
# the session's own X server, no env fallbacks invented here.
Environment=DISPLAY=:0
Environment=XAUTHORITY=%h/.Xauthority
# Exits 0 by itself when the morning session is not in progress (the normal
# case for most path triggers) and again the moment it ends. Exit 1 means no
# display or no live output; the next file rewrite tries again.
ExecStart=/usr/bin/python -m wake_alarm._session_strip

View File

@ -156,6 +156,13 @@ SESSION_COMPLETED_UNTIL: tuple[int, int] = (11, 0)
# PC). A phone switched off at the alarm locks this long after it.
SESSION_NONE_GRACE: timedelta = timedelta(minutes=15)
# A PC booted mid-morning often has no DNS for its first seconds (2026-11-07:
# one try at 07:48:43, exit 1, next timer tick at 07:50). ``--refresh`` keeps
# trying in-process this long, every REFRESH_POLL_SECONDS, before giving up.
# Consumers (gatelock.morning_session) wait up to 180 s, so this stays under.
REFRESH_RETRY_SECONDS: int = 150
REFRESH_POLL_SECONDS: int = 5
# Workday stick (a Tue/Wed/Thu miss -> tomorrow loses its leniency). The days
# themselves are freedays.WORKDAYS, the one set every gate app reads.

View File

@ -31,10 +31,17 @@ from datetime import datetime
import json
import logging
import sys
import time
from typing import TYPE_CHECKING
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from wake_alarm._constants import FALLBACK_ALARM_HOUR, MORNING_SESSION_FILE
from wake_alarm._constants import (
FALLBACK_ALARM_HOUR,
MORNING_SESSION_FILE,
REFRESH_POLL_SECONDS,
REFRESH_RETRY_SECONDS,
)
from wake_alarm._enforcement_payload import enforcement_view
from wake_alarm._escapes import note_escapes
from wake_alarm._logging_setup import configure_logging
@ -45,6 +52,9 @@ from wake_alarm._sync_transport import fetch_firebase_logs
from wake_alarm._workday_penalty import cancel_for_sick_day as _cancel_for_sick_day
from wake_alarm._workday_penalty import maybe_apply as _maybe_apply_workday_penalty
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
@ -81,18 +91,49 @@ def read_entry() -> dict[str, object] | None:
return entry
def refresh(now: datetime) -> dict[str, object] | None:
def _fetch_with_retry(
sleep: Callable[[float], None], clock: Callable[[], float]
) -> list[tuple[str, str]] | None:
"""``fetch_firebase_logs`` retried every poll interval within the budget.
A PC booted mid-morning usually has no DNS for its first seconds, and the
next timer tick is minutes away while consumers are already waiting on the
file. None only once ``REFRESH_RETRY_SECONDS`` have passed.
"""
started = clock()
while True:
published = fetch_firebase_logs()
if published is not None:
return published
waited = clock() - started
if waited + REFRESH_POLL_SECONDS > REFRESH_RETRY_SECONDS:
_logger.warning(
"Morning session not refreshed: backend unreadable for %d s "
"(retried every %d s)",
REFRESH_RETRY_SECONDS,
REFRESH_POLL_SECONDS,
)
return None
sleep(REFRESH_POLL_SECONDS)
def refresh(
now: datetime,
*,
sleep: Callable[[float], None] = time.sleep,
clock: Callable[[], float] = time.monotonic,
) -> dict[str, object] | None:
"""Fetch, select, and rewrite the file; None when the backend was unreadable.
An unreadable backend leaves the file alone: whatever it says is still
the newest thing known, and a consumer judges freshness by its date. A
penalty issued on a day since marked sick is retracted first, from the
local pool alone, so it does not wait on Firebase.
An unreadable backend is retried for ``REFRESH_RETRY_SECONDS``, then
leaves the file alone: whatever it says is still the newest thing known,
and a consumer judges freshness by its date. A penalty issued on a day
since marked sick is retracted first, once, from the local pool alone, so
it does not wait on Firebase. ``sleep`` and ``clock`` are seams for tests.
"""
_cancel_for_sick_day()
published = fetch_firebase_logs()
published = _fetch_with_retry(sleep, clock)
if published is None:
_logger.info("Morning session not refreshed: backend unreadable")
return None
today = now.date().isoformat()
record = newest_session(published)

View File

@ -0,0 +1,30 @@
"""The morning session's legs, in order, as the phone names and labels them.
Mirrors ``SessionLeg`` and ``SessionLegSlot.label`` in
``phone_app/lib/services/morning_session.dart``. A leg's *name* is the key of
the phone's ``passed`` map; its *label* is what the PC's ``next_step`` shows.
``tests/fixtures/session_legs.json`` is asserted by a Python and a Dart test,
so a rename on one side fails the other's build.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection
LEGS: tuple[tuple[str, str], ...] = (
("bathroomIn", "Tap the bathroom tag"),
("showerDone", "Shower, then tap the bathroom tag again"),
("dressed", "Get dressed — tap the wardrobe tag"),
("atDesk", "Sit down — tap the desk tag"),
)
def next_step(passed: Collection[str]) -> str | None:
"""Label of the first leg (in order) not in ``passed``; None when all are."""
for name, label in LEGS:
if name not in passed:
return label
return None

View File

@ -52,6 +52,8 @@ class SessionRecord:
re_rings: int
device_id: str
published_at: float | None
# Names (``_session_legs.LEGS``) of the legs the phone has passed.
passed_legs: tuple[str, ...] = ()
@property
def finished(self) -> bool:
@ -73,6 +75,19 @@ def _float_or_none(value: object) -> float | None:
return float(value)
def _passed_legs(value: object) -> tuple[str, ...]:
"""Leg names in the phone's ``passed`` map that carry a pass timestamp."""
if not isinstance(value, dict):
return ()
return tuple(
name
for name, entry in value.items()
if isinstance(name, str)
and isinstance(entry, dict)
and isinstance(entry.get("at"), str)
)
def record_from_json(
value: object,
*,
@ -106,6 +121,7 @@ def record_from_json(
re_rings=_int_or_none(value.get("re_rings")) or 0,
device_id=device_id,
published_at=published_at,
passed_legs=_passed_legs(value.get("passed")),
)

View File

@ -25,6 +25,7 @@ from wake_alarm._constants import (
SESSION_PROVISIONAL_CAP,
SESSION_PROVISIONAL_GRACE,
)
from wake_alarm._session_legs import next_step
if TYPE_CHECKING:
from wake_alarm._session_payload import SessionRecord
@ -113,6 +114,7 @@ def to_entry(
"device_id": "",
"published_at": None,
"refreshed_at": refreshed_at,
"next_step": None,
}
if alarm is not None:
entry["exempt_until"] = (_at(today, alarm) + SESSION_NONE_GRACE).isoformat()
@ -128,6 +130,10 @@ def to_entry(
"device_id": record.device_id,
"published_at": record.published_at,
"refreshed_at": refreshed_at,
# Signed with the rest: the HMAC covers every key of the entry.
"next_step": (
next_step(record.passed_legs) if record.outcome == "in_progress" else None
),
}
until = exempt_until(record)
if until is not None:

View File

@ -0,0 +1,247 @@
"""A corner strip while the morning session is started but not finished.
wake-alarm's signed ``morning_session.json`` makes the lockers stand down
while the morning is live. The screen simply clears, though, and kuhy still
owes the rest of the routine: the locks return at ``exempt_until`` out of
nowhere. This strip is the reminder: a small always-on-top box in the
top-right corner of the primary output (``gatelock.corner_strip``), saying the
session is not finished, what the phone's next step is, and when locks return.
python -m wake_alarm._session_strip
``wake-alarm-strip.path`` starts it whenever the file is rewritten (every two
minutes through the morning); starting a running service is a no-op. It shows
only while the entry is *signed, dated today, in_progress, and now <
exempt_until*, re-reading the file every second, and exits 0 the moment that
stops being true. While visible it also asks systemd every 30 s to run
``wake-alarm-session.service`` so ``next_step`` follows the phone instead of
the two-minute timer.
Fail modes, out loud: nothing to show is exit 0 (normal, logged at info); no
display or no live output is a warning and exit 1, so the unit shows failed;
a missing, unreadable or tampered file shows nothing (the lockers do not
honour it either) and warns once per distinct problem, not once a second.
"""
from __future__ import annotations
from datetime import datetime
import json
import logging
import subprocess
import sys
import time
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import LockConfig, enumerate_outputs
from gatelock.corner_strip import StripContent, build_corner_strip, corner_rect
from gatelock.log_integrity import verify_entry_hmac
from wake_alarm._constants import MORNING_SESSION_FILE
from wake_alarm._logging_setup import configure_logging
if TYPE_CHECKING:
from subprocess import Popen
_logger = logging.getLogger(__name__)
TITLE = "Morning session not finished"
REFRESH_EVERY_SECONDS = 30.0
REFRESH_COMMAND = [
"systemctl",
"--user",
"start",
"--no-block",
"wake-alarm-session.service",
]
class _Once:
"""Warns about a problem once, until a clean read clears it.
The strip reads the file every second; a file that stays broken for an
hour must be one journal line, not 3600.
"""
def __init__(self) -> None:
self.last: str | None = None
def warn(self, message: str) -> None:
"""Log ``message`` at warning level unless it was just logged."""
if message != self.last:
_logger.warning(message)
self.last = message
def clear(self) -> None:
"""A clean read happened: the next problem is news again."""
self.last = None
_once = _Once()
def _local_now() -> datetime:
"""The current time as an aware local datetime (a test seam)."""
return datetime.now().astimezone()
def _monotonic() -> float:
"""Seconds on the monotonic clock (a test seam)."""
return time.monotonic()
def _popen(cmd: list[str]) -> Popen[bytes]:
"""Start ``cmd`` without waiting for it (a test seam)."""
return subprocess.Popen(cmd)
def _make_root() -> tk.Tk:
"""A withdrawn Tk root; only the strip's Toplevel is ever visible."""
root = tk.Tk()
root.withdraw()
return root
def read_entry() -> dict[str, object] | None:
"""The file's parsed JSON object, or None. Verification is the caller's."""
try:
text = MORNING_SESSION_FILE.read_text()
except FileNotFoundError:
return None
except OSError as exc:
_once.warn(f"Cannot read {MORNING_SESSION_FILE}: {exc}")
return None
try:
entry = json.loads(text)
except json.JSONDecodeError as exc:
_once.warn(f"Cannot parse {MORNING_SESSION_FILE}: {exc}")
return None
if not isinstance(entry, dict):
_once.warn(f"{MORNING_SESSION_FILE} is not a JSON object")
return None
_once.clear()
return entry
def _why_not_today(entry: dict[str, object], now: datetime) -> str | None:
"""Why ``entry`` is not a verified, live, in-progress morning; None if it is."""
if not verify_entry_hmac(entry):
return "morning session entry is unsigned or tampered"
if entry.get("date") != now.date().isoformat():
return f"morning session is dated {entry.get('date')}, not today"
if entry.get("outcome") != "in_progress":
return f"morning session outcome is {entry.get('outcome')}"
return None
def _exempt_until(entry: dict[str, object]) -> datetime | str:
"""The entry's aware ``exempt_until``, or the sentence saying why not."""
until = entry.get("exempt_until")
if not isinstance(until, str):
return "morning session has no exempt_until"
try:
parsed = datetime.fromisoformat(until)
except ValueError:
return f"morning session exempt_until is unreadable: {until!r}"
if parsed.tzinfo is None:
# Guessing its zone would be inventing a carrot the signer never gave.
return "morning session exempt_until has no timezone"
return parsed
def _verdict(entry: dict[str, object] | None, now: datetime) -> StripContent | str:
"""The strip's content, or the sentence saying why there is none."""
if entry is None:
return "no morning session file"
reason = _why_not_today(entry, now)
if reason is not None:
return reason
exempt_until = _exempt_until(entry)
if isinstance(exempt_until, str):
return exempt_until
if now >= exempt_until:
return f"the exemption ended at {exempt_until.isoformat()}"
lines: list[str] = []
step = entry.get("next_step")
if isinstance(step, str) and step:
lines.append(f"Next: {step}")
lines.append(f"Locks return at {exempt_until.astimezone(now.tzinfo):%H:%M}")
return StripContent(TITLE, tuple(lines))
def strip_content(
entry: dict[str, object] | None, now: datetime
) -> StripContent | None:
"""What the strip says for ``entry`` at local ``now``; None shows nothing.
Something is shown only for a verified entry dated today, still
``in_progress``, whose ``exempt_until`` has not passed.
"""
verdict = _verdict(entry, now)
return verdict if isinstance(verdict, StripContent) else None
class Refresher:
"""Starts wake-alarm-session.service every 30 s, never two at once."""
def __init__(self, *, start: float) -> None:
self._due = start + REFRESH_EVERY_SECONDS
self._proc: Popen[bytes] | None = None
def tick(self, now: float) -> None:
"""Reap a finished refresh, and start the next one when it is due."""
if self._proc is not None:
code = self._proc.poll()
if code is None:
return
if code != 0:
_logger.warning("the session refresh start exited %d", code)
self._proc = None
if now < self._due:
return
self._due = now + REFRESH_EVERY_SECONDS
try:
self._proc = _popen(REFRESH_COMMAND)
except OSError as exc:
_logger.warning("could not start the session refresh: %s", exc)
def main() -> int:
"""Show the strip while the morning is in progress; 0 when it is over."""
configure_logging(component="session-strip")
verdict = _verdict(read_entry(), _local_now())
if not isinstance(verdict, StripContent):
_logger.info("Nothing to show: %s", verdict)
return 0
try:
root = _make_root()
except tk.TclError as exc:
_logger.warning("Cannot open the display for the strip: %s", exc)
return 1
rect = corner_rect(enumerate_outputs(root))
if rect is None:
_logger.warning("Cannot place the strip: no live output on this display")
root.destroy()
return 1
strip = build_corner_strip(root, LockConfig(), verdict, rect=rect)
refresher = Refresher(start=_monotonic())
ending: list[str] = []
def supply() -> StripContent | None:
current = _verdict(read_entry(), _local_now())
if not isinstance(current, StripContent):
ending.append(current)
return None
refresher.tick(_monotonic())
return current
strip.schedule(supply, on_close=root.quit)
root.mainloop()
root.destroy()
_logger.info("Strip closed: %s", ending[0] if ending else "window gone")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -38,6 +38,8 @@ import freedays
import pytest
import sickdays
from wake_alarm import _session_strip
if TYPE_CHECKING:
from collections.abc import Generator
from pathlib import Path
@ -49,6 +51,33 @@ def _no_process(cmd: list[str]) -> object:
raise AssertionError(msg)
class FakeClock:
"""A monotonic clock that only moves when ``sleep`` is called.
Pass ``clock.sleep`` and ``clock.now`` to ``_session.refresh`` so a dead
backend burns its 150 s retry budget instantly instead of for real.
"""
def __init__(self) -> None:
self.elapsed = 0.0
self.sleeps: list[float] = []
def now(self) -> float:
"""Seconds elapsed on the fake clock."""
return self.elapsed
def sleep(self, seconds: float) -> None:
"""Advance the clock without waiting."""
self.sleeps.append(seconds)
self.elapsed += seconds
@pytest.fixture
def fake_clock() -> FakeClock:
"""A fresh :class:`FakeClock`."""
return FakeClock()
@pytest.fixture(autouse=True)
def _hermetic(tmp_path: Path) -> Generator[None]:
"""Keep signing keys, state writes and processes inside ``tmp_path``."""
@ -67,6 +96,15 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
"wake_alarm._session.MORNING_SESSION_FILE",
tmp_path / "morning_session.json",
),
# The strip imported the path by value too; its tick re-reads it every
# second, its refresh spawns systemctl, and its warn-once memory must
# not leak from one test into the next.
patch(
"wake_alarm._session_strip.MORNING_SESSION_FILE",
tmp_path / "morning_session.json",
),
patch("wake_alarm._session_strip._popen", _no_process),
patch("wake_alarm._session_strip._once", _session_strip._Once()),
patch(
"wake_alarm._workday_penalty.WORKDAY_PENALTY_FILE",
tmp_path / "workday_penalty.json",

View File

@ -0,0 +1,8 @@
{
"legs": [
{"name": "bathroomIn", "label": "Tap the bathroom tag"},
{"name": "showerDone", "label": "Shower, then tap the bathroom tag again"},
{"name": "dressed", "label": "Get dressed — tap the wardrobe tag"},
{"name": "atDesk", "label": "Sit down — tap the desk tag"}
]
}

View File

@ -172,12 +172,6 @@ class TestRefresh:
assert entry is not None
assert entry["exempt_until"] == _local("2026-09-19T06:45:00").isoformat()
def test_unreadable_backend_leaves_the_file_alone(self) -> None:
write_entry(to_entry(_record(), today="2026-09-19", refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW) is None
assert read_entry() is not None
def test_write_failure_is_none(self) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=[]),

View File

@ -0,0 +1,30 @@
"""The leg names and labels the PC reads must equal the phone's enum.
``fixtures/session_legs.json`` is one literal both sides assert: this test
against ``_session_legs.LEGS`` and ``phone_app/test/services/
session_legs_fixture_test.dart`` against ``SessionLeg``. Each side's own
round-trip cannot catch a naming drift with its sibling.
"""
from __future__ import annotations
import json
from pathlib import Path
from wake_alarm._session_legs import LEGS, next_step
FIXTURE = Path(__file__).parent / "fixtures" / "session_legs.json"
def test_legs_equal_the_shared_fixture() -> None:
legs = json.loads(FIXTURE.read_text(encoding="utf-8"))["legs"]
assert [(leg["name"], leg["label"]) for leg in legs] == list(LEGS)
def test_next_step_is_the_first_leg_not_passed() -> None:
assert next_step(()) == "Tap the bathroom tag"
assert next_step(("bathroomIn",)) == "Shower, then tap the bathroom tag again"
# Order of the legs decides, not the order they were passed in.
assert next_step(("showerDone",)) == "Tap the bathroom tag"
assert next_step(("atDesk", "bathroomIn", "showerDone", "dressed")) is None
assert next_step(("unknownLeg",)) == "Tap the bathroom tag"

View File

@ -64,6 +64,26 @@ class TestRecordFromJson:
assert record.finished_at is None
assert not record.finished
def test_passed_legs_are_the_keys_with_a_timestamp(self) -> None:
passed = {
"bathroomIn": {"at": "2026-09-19T08:05:00.000", "how": "tag"},
"showerDone": "garbage",
"dressed": {"how": "tag"},
}
record = record_from_json(
_session("2026-09-19", "active", passed=passed),
device_id="p",
published_at=None,
)
assert record is not None
assert record.passed_legs == ("bathroomIn",)
for bad in (None, [], "x"):
record = record_from_json(
_session(passed=bad), device_id="p", published_at=None
)
assert record is not None
assert record.passed_legs == ()
def test_rejects_every_malformed_shape(self) -> None:
bad = [
"text",

View File

@ -0,0 +1,118 @@
"""``refresh`` retries an unreadable backend; ``next_step`` is signed."""
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from wake_alarm import _session
from wake_alarm._constants import REFRESH_POLL_SECONDS, REFRESH_RETRY_SECONDS
from wake_alarm._session import read_entry, refresh, to_entry, write_entry
from wake_alarm._session_payload import SessionRecord
from wake_alarm._sync import Alarm
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
NOW = datetime(2026, 9, 19, 8, 30).astimezone()
TODAY = "2026-09-19"
def _live(passed: tuple[str, ...] = ("bathroomIn",)) -> SessionRecord:
return SessionRecord(
date=TODAY,
outcome="in_progress",
started_at="2026-09-19T08:01:00.000",
finished_at=None,
energy=3,
weight_kg=73.8,
re_rings=0,
device_id="p",
published_at=1.0,
passed_legs=passed,
)
@pytest.fixture(autouse=True)
def _alarm() -> object:
with patch("wake_alarm._session.get_alarm", return_value=Alarm(8, 0, "cache")):
yield
class TestRefreshRetry:
"""2026-11-07: booted without DNS; one try, then a 2-minute gap."""
def test_unreadable_backend_leaves_the_file_alone(
self, fake_clock: FakeClock
) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
with patch("wake_alarm._session.fetch_firebase_logs", return_value=None):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert read_entry() is not None
def test_a_backend_that_recovers_is_retried_into_todays_entry(
self, fake_clock: FakeClock
) -> None:
with patch(
"wake_alarm._session.fetch_firebase_logs", side_effect=[None, None, []]
) as fetch:
entry = refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
assert entry is not None
assert (entry["date"], entry["outcome"]) == (TODAY, "none")
assert read_entry() == entry
assert fetch.call_count == 3
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS, REFRESH_POLL_SECONDS]
def test_a_dead_backend_gives_up_after_the_budget_and_says_so(
self, caplog: pytest.LogCaptureFixture, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
caplog.at_level(logging.WARNING),
):
assert refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now) is None
assert fake_clock.sleeps == [REFRESH_POLL_SECONDS] * (
REFRESH_RETRY_SECONDS // REFRESH_POLL_SECONDS
)
warnings = [r.getMessage() for r in caplog.records if r.levelno >= 30]
assert any(f"{REFRESH_RETRY_SECONDS} s" in w for w in warnings)
def test_sick_day_cancel_runs_once_before_the_loop(
self, fake_clock: FakeClock
) -> None:
with (
patch("wake_alarm._session.fetch_firebase_logs", return_value=None),
patch("wake_alarm._session._cancel_for_sick_day") as cancel,
):
refresh(NOW, sleep=fake_clock.sleep, clock=fake_clock.now)
cancel.assert_called_once()
class TestNextStep:
"""The entry names the next leg and signs it."""
def test_in_progress_entry_names_the_next_leg(self) -> None:
entry = to_entry(_live(), today=TODAY, refreshed_at=5.0)
assert entry["next_step"] == "Shower, then tap the bathroom tag again"
def test_next_step_is_null_unless_the_morning_is_live(self) -> None:
done = replace(_live(), outcome="completed", finished_at="2026-09-19T08:40")
assert to_entry(done, today=TODAY, refreshed_at=5.0)["next_step"] is None
failed = replace(_live(), outcome="failed")
assert to_entry(failed, today=TODAY, refreshed_at=5.0)["next_step"] is None
assert to_entry(None, today=TODAY, refreshed_at=5.0)["next_step"] is None
def test_tampering_next_step_breaks_verification(self) -> None:
write_entry(to_entry(_live(), today=TODAY, refreshed_at=5.0))
assert read_entry() is not None
path = _session.MORNING_SESSION_FILE
tampered = json.loads(path.read_text())
tampered["next_step"] = None
path.write_text(json.dumps(tampered))
assert read_entry() is None

View File

@ -0,0 +1,128 @@
"""The corner strip's content rules, file reader and thin seams."""
from __future__ import annotations
from datetime import UTC, timedelta
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock.corner_strip import StripContent
import pytest
from wake_alarm import _session_strip
from wake_alarm.tests.test_strip_support import NOW, STEP, signed
if TYPE_CHECKING:
from pathlib import Path
# conftest replaces _popen for every test; the real one is captured at import.
REAL_POPEN = _session_strip._popen
class TestStripContent:
def test_in_progress_says_next_step_and_when_locks_return(self) -> None:
assert _session_strip.strip_content(signed(), NOW) == StripContent(
"Morning session not finished",
(f"Next: {STEP}", "Locks return at 08:45"),
)
def test_next_step_null_drops_the_next_line(self) -> None:
content = _session_strip.strip_content(signed(next_step=None), NOW)
assert content == StripContent(
"Morning session not finished", ("Locks return at 08:45",)
)
def test_exempt_until_is_shown_in_local_time(self) -> None:
utc = (NOW + timedelta(minutes=45)).astimezone(UTC).isoformat()
content = _session_strip.strip_content(signed(exempt_until=utc), NOW)
assert content is not None
assert content.lines[-1] == "Locks return at 08:45"
@pytest.mark.parametrize("outcome", ["completed", "failed", "none", "weird"])
def test_other_outcomes_show_nothing(self, outcome: str) -> None:
assert _session_strip.strip_content(signed(outcome=outcome), NOW) is None
def test_yesterday_shows_nothing(self) -> None:
assert _session_strip.strip_content(signed(date="2026-11-07"), NOW) is None
def test_expired_shows_nothing(self) -> None:
later = NOW + timedelta(minutes=45)
assert _session_strip.strip_content(signed(), later) is None
def test_tampered_shows_nothing(self) -> None:
entry = signed()
entry["next_step"] = "Something else"
assert _session_strip.strip_content(entry, NOW) is None
def test_unsigned_shows_nothing(self) -> None:
entry = signed()
del entry["hmac"]
assert _session_strip.strip_content(entry, NOW) is None
def test_missing_entry_shows_nothing(self) -> None:
assert _session_strip.strip_content(None, NOW) is None
@pytest.mark.parametrize("until", [None, 5, "garbage", "2026-11-08T09:00:00"])
def test_unusable_exempt_until_shows_nothing(self, until: object) -> None:
assert _session_strip.strip_content(signed(exempt_until=until), NOW) is None
@pytest.mark.parametrize("step", [5, ""])
def test_unusable_next_step_is_left_out(self, step: object) -> None:
content = _session_strip.strip_content(signed(next_step=step), NOW)
assert content is not None
assert content.lines == ("Locks return at 08:45",)
class TestReadEntry:
def test_missing_file_is_none_and_quiet(
self, caplog: pytest.LogCaptureFixture
) -> None:
with caplog.at_level(logging.DEBUG):
assert _session_strip.read_entry() is None
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_reads_the_file(self) -> None:
entry = signed()
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))
assert _session_strip.read_entry() == entry
@pytest.mark.parametrize("text", ["{nope", "[1, 2]"])
def test_unreadable_file_warns_once_per_state(
self, text: str, caplog: pytest.LogCaptureFixture
) -> None:
_session_strip.MORNING_SESSION_FILE.write_text(text)
with caplog.at_level(logging.WARNING):
assert _session_strip.read_entry() is None
assert _session_strip.read_entry() is None
assert len([r for r in caplog.records if r.levelno >= logging.WARNING]) == 1
def test_directory_in_the_way_warns(self, caplog: pytest.LogCaptureFixture) -> None:
_session_strip.MORNING_SESSION_FILE.mkdir()
with caplog.at_level(logging.WARNING):
assert _session_strip.read_entry() is None
assert "Cannot read" in caplog.text
class TestHelpers:
def test_local_now_is_aware(self) -> None:
assert _session_strip._local_now().tzinfo is not None
def test_monotonic_moves_forward(self) -> None:
first = _session_strip._monotonic()
assert _session_strip._monotonic() >= first
def test_make_root_builds_a_withdrawn_tk_root(self) -> None:
with patch("wake_alarm._session_strip.tk.Tk") as tk_root:
root = _session_strip._make_root()
root.withdraw.assert_called_once()
assert root is tk_root.return_value
def test_popen_runs_the_command(self) -> None:
with patch("wake_alarm._session_strip.subprocess.Popen") as popen:
REAL_POPEN(["true"])
popen.assert_called_once_with(["true"])
def test_log_state_path_is_the_conftest_scratch(self, tmp_path: Path) -> None:
assert _session_strip.MORNING_SESSION_FILE.parent == tmp_path

View File

@ -0,0 +1,196 @@
"""The corner strip's refresher and its main loop, on a fake Tk."""
from __future__ import annotations
from contextlib import ExitStack
from datetime import timedelta
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
from gatelock.corner_strip import StripContent
import pytest
from wake_alarm import _session_strip
from wake_alarm.tests.test_strip_support import (
NOW,
STEP,
Display,
Procs,
signed,
write,
)
if TYPE_CHECKING:
from collections.abc import Generator
class TestRefresher:
@pytest.fixture
def procs(self) -> Procs:
fake = Procs()
with patch("wake_alarm._session_strip._popen", fake):
yield fake
def test_first_refresh_is_thirty_seconds_in(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=100.0)
refresher.tick(129.0)
assert procs.started == []
refresher.tick(130.0)
assert procs.started == [
["systemctl", "--user", "start", "--no-block", "wake-alarm-session.service"]
]
def test_one_start_per_interval(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=0.0)
for second in range(30, 60):
refresher.tick(float(second))
procs.procs[-1].poll.return_value = 0
assert len(procs.started) == 1
refresher.tick(60.0)
assert len(procs.started) == 2
def test_no_second_spawn_while_the_first_runs(self, procs: Procs) -> None:
refresher = _session_strip.Refresher(start=0.0)
refresher.tick(30.0)
refresher.tick(60.0)
refresher.tick(95.0)
assert len(procs.started) == 1
procs.procs[0].poll.return_value = 0
refresher.tick(96.0)
assert len(procs.started) == 2
def test_failed_exit_warns(
self, procs: Procs, caplog: pytest.LogCaptureFixture
) -> None:
refresher = _session_strip.Refresher(start=0.0)
refresher.tick(30.0)
procs.procs[0].poll.return_value = 5
with caplog.at_level(logging.WARNING):
refresher.tick(31.0)
assert "exited 5" in caplog.text
def test_spawn_failure_warns_and_waits_for_the_next_interval(
self, procs: Procs, caplog: pytest.LogCaptureFixture
) -> None:
procs.error = FileNotFoundError("systemctl")
refresher = _session_strip.Refresher(start=0.0)
with caplog.at_level(logging.WARNING):
refresher.tick(30.0)
refresher.tick(31.0)
assert caplog.text.count("could not start") == 1
procs.error = None
refresher.tick(60.0)
assert len(procs.started) == 1
@pytest.fixture
def display(monkeypatch: pytest.MonkeyPatch) -> Generator[Display]:
"""A fake Tk world with a pinned clock and a recording ``_popen``."""
fake = Display()
monkeypatch.setattr("wake_alarm._session_strip._local_now", lambda: NOW)
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: 0.0)
monkeypatch.setattr("wake_alarm._session_strip._popen", Procs())
with ExitStack() as stack:
for item in fake.patches():
stack.enter_context(item)
yield fake
class TestMain:
def test_nothing_to_show_exits_zero_without_a_window(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
write(signed(outcome="completed"))
with caplog.at_level(logging.INFO):
assert _session_strip.main() == 0
display.root.mainloop.assert_not_called()
assert display.built == []
assert "completed" in caplog.text
def test_missing_file_exits_zero(self, display: Display) -> None:
assert _session_strip.main() == 0
assert display.built == []
def test_no_output_is_a_warning_and_a_failure(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
write(signed())
display.rect = None
with caplog.at_level(logging.WARNING):
assert _session_strip.main() == 1
assert "no live output" in caplog.text
display.root.destroy.assert_called_once()
def test_shows_then_closes_when_the_session_completes(
self, display: Display
) -> None:
write(signed())
def flip(tick: int) -> None:
if tick == 3:
write(signed(outcome="completed", next_step=None))
display.on_tick = flip
assert _session_strip.main() == 0
assert display.built == [
StripContent(
"Morning session not finished",
(f"Next: {STEP}", "Locks return at 08:45"),
)
]
assert display.ticks == 3
display.window.destroy.assert_called_once()
display.root.quit.assert_called_once()
display.root.destroy.assert_called_once()
def test_closes_when_the_exemption_ends(
self, display: Display, monkeypatch: pytest.MonkeyPatch
) -> None:
write(signed())
def expire(tick: int) -> None:
if tick == 2:
monkeypatch.setattr(
"wake_alarm._session_strip._local_now",
lambda: NOW + timedelta(minutes=45),
)
display.on_tick = expire
assert _session_strip.main() == 0
assert display.ticks == 2
def test_refresh_starts_while_visible_and_not_after_close(
self, display: Display, monkeypatch: pytest.MonkeyPatch
) -> None:
procs = Procs()
clock = {"t": 0.0}
monkeypatch.setattr("wake_alarm._session_strip._popen", procs)
monkeypatch.setattr("wake_alarm._session_strip._monotonic", lambda: clock["t"])
write(signed())
def advance(tick: int) -> None:
clock["t"] = tick * 10.0
if tick == 4:
write(signed(outcome="completed"))
display.on_tick = advance
assert _session_strip.main() == 0
# Ticks run at t=10, 20, 30 (refresh due), then 40 closes the strip.
assert len(procs.started) == 1
def test_unopenable_display_is_logged_and_fails(
self, display: Display, caplog: pytest.LogCaptureFixture
) -> None:
import tkinter as tk
write(signed())
with (
patch(
"wake_alarm._session_strip._make_root",
side_effect=tk.TclError("no display"),
),
caplog.at_level(logging.WARNING),
):
assert _session_strip.main() == 1
assert "no display" in caplog.text

View File

@ -7,6 +7,7 @@ from datetime import date, datetime
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
@ -32,6 +33,9 @@ from wake_alarm._workday_penalty import (
write_penalty,
)
if TYPE_CHECKING:
from wake_alarm.tests.conftest import FakeClock
TUESDAY = date(2026, 9, 22)
AFTER_GRACE = datetime(2026, 9, 22, 9, 0).astimezone()
MISS: dict[str, object] = {
@ -131,10 +135,15 @@ class TestRetraction:
)
assert not cancel_for_sick_day()
def test_refresh_retracts_even_offline(self) -> None:
def test_refresh_retracts_even_offline(self, fake_clock: FakeClock) -> None:
mark_sick(TUESDAY)
with patch.object(_session, "fetch_firebase_logs", return_value=None):
assert _session.refresh(AFTER_GRACE) is None
assert (
_session.refresh(
AFTER_GRACE, sleep=fake_clock.sleep, clock=fake_clock.now
)
is None
)
assert issued_date() is None

View File

@ -0,0 +1,103 @@
"""Shared fakes for the corner-strip tests: signed entries, Tk and Popen."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock.corner_strip import CornerStrip, StripContent
from gatelock.log_integrity import compute_entry_hmac
from wake_alarm import _session_strip
if TYPE_CHECKING:
from collections.abc import Callable
TZ = timezone(timedelta(hours=1))
NOW = datetime(2026, 11, 8, 8, 0, tzinfo=TZ)
STEP = "Shower, then tap the bathroom tag again"
def signed(**overrides: object) -> dict[str, object]:
"""A verified in-progress entry for NOW's day, with fields overridden."""
entry: dict[str, object] = {
"date": "2026-11-08",
"outcome": "in_progress",
"exempt_until": (NOW + timedelta(minutes=45)).isoformat(),
"next_step": STEP,
}
entry.update(overrides)
entry["hmac"] = compute_entry_hmac(entry)
return entry
class Procs:
"""A fake ``_popen`` recording starts; each proc finishes when told."""
def __init__(self) -> None:
self.started: list[list[str]] = []
self.procs: list[MagicMock] = []
self.error: OSError | None = None
def __call__(self, cmd: list[str]) -> MagicMock:
if self.error is not None:
raise self.error
self.started.append(cmd)
proc = MagicMock()
proc.poll.return_value = None
self.procs.append(proc)
return proc
class Display:
"""A fake Tk world: records the strip's tick callbacks and drives them."""
def __init__(self) -> None:
self.root = MagicMock(name="root")
self.window = MagicMock(name="window")
self.callbacks: list[Callable[[], None]] = []
self.window.after.side_effect = self._after
self.root.quit.side_effect = self.callbacks.clear
self.root.mainloop.side_effect = self._mainloop
self.ticks = 0
self.on_tick: Callable[[int], None] = lambda _n: None
self.rect: object = MagicMock(name="rect")
self.built: list[StripContent] = []
def _after(self, _ms: int, callback: Callable[[], None]) -> str:
self.callbacks.append(callback)
return "job"
def _mainloop(self) -> None:
while self.callbacks:
self.ticks += 1
self.on_tick(self.ticks)
self.callbacks.pop(0)()
if self.ticks > 50: # runaway-loop guard
msg = "strip never closed"
raise AssertionError(msg)
def build(
self, _root: object, _config: object, content: StripContent, **kwargs: object
) -> object:
assert kwargs["rect"] is self.rect
self.built.append(content)
return CornerStrip(self.window, MagicMock(), [MagicMock(), MagicMock()])
def patches(self) -> list[object]:
return [
patch("wake_alarm._session_strip._make_root", return_value=self.root),
patch("wake_alarm._session_strip.enumerate_outputs"),
patch(
"wake_alarm._session_strip.corner_rect",
side_effect=lambda _scan: self.rect,
),
patch("wake_alarm._session_strip.build_corner_strip", self.build),
]
def write(entry: dict[str, object]) -> None:
_session_strip.MORNING_SESSION_FILE.write_text(json.dumps(entry))