phone: sick-day keep set (sickdays 0.2.0) in the Dart pool; alarm cache filtered

SickDayPool gains keep: mark/clear write state+actor+keep on one tick,
setKept re-stamps only keep+actor, read rules 1-5 in sick_day_keep.dart.
The native SickDays cache now holds only days on which the alarm stands
down, so a sick day that keeps the alarm rings like a normal day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 15:20:45 +02:00
parent 5d0f06b19e
commit 7fe6108df3
8 changed files with 497 additions and 15 deletions

View File

@ -8,6 +8,15 @@ import java.time.LocalDate
* The native copy of the shared sick-day pool (`~/src/utils/sickdays`): the
* ISO dates on which the alarm must not ring and the stick stands down.
*
* Not every sick day: since sickdays 0.2.0 a sick day may *keep* some
* lockers active, and Dart writes here only the days on which the `alarm`
* locker stands down (sick, alarm not kept; `SickDayPool.standDownIsoDates`
* in `sick_day_pool.dart`). A sick day that keeps the alarm is absent, so
* it rings, judges the stick and shows its stakes like a normal day. Every
* reader of this cache ([RingDecision], [StickCheck], the stakes card via
* [EnforcementChannel]) therefore means "the alarm stands down", and none
* of them needs to know the keep set exists.
*
* A separate cache from [FreeDays], with its own prefs file, because the
* pools are separate things: a sick day is never called a free day, in a
* log line or anywhere else. Dart rewrites it after every mark, clear and

View File

@ -0,0 +1,82 @@
import 'dart:developer' as developer;
import 'package:crdt_sync/crdt_sync.dart';
/// The per-day keep set (sickdays 0.2.0): which lockers stay active on a
/// sick day. A Dart port of `sickdays/_keep.py`; the contract is the
/// README's "keep (0.2.0)" section.
///
/// `keep` is one more last-writer-wins field on the day's record, holding a
/// **sorted, de-duplicated** list of locker ids. Sorting is wire format, not
/// tidiness: the RTDB rev is a SHA-256 of the exact serialised string.
///
/// Reading is where the safety lives ([readKeep]): a missing `keep` (every
/// 0.1.0 record) or one stamped older than `state` keeps nothing; a value
/// that is not a list of strings keeps *everything* (the locks apply); an
/// unknown id inside a valid list is ignored, for forward compatibility.
abstract final class SickLocker {
/// screen-locker's workout lock.
static const workout = 'workout';
/// leetcode-guard.
static const leetcode = 'leetcode';
/// The book locker.
static const book = 'book';
/// diet_guard.
static const diet = 'diet';
/// This app: the phone ring, the PC fallback ring, the RTC self-wake, the
/// missed-alarm penalty / stick and the morning routine.
static const alarm = 'alarm';
/// Every known id, in `sickdays.LOCKERS` order.
static const all = <String>[workout, leetcode, book, diet, alarm];
/// Returns [locker] if it is a known id; throws [ArgumentError] otherwise
/// (a programmer error, never a data problem).
static String validate(String locker) {
if (!all.contains(locker)) {
throw ArgumentError.value(
locker,
'locker',
'unknown locker; expected one of ${all.join(', ')}',
);
}
return locker;
}
}
/// The record field holding the keep set.
const keepField = 'keep';
/// The on-the-wire value of [keep]: validated, de-duplicated and sorted, so
/// two devices holding the same set write the same bytes.
List<String> keepWire(Iterable<String> keep) =>
keep.map(SickLocker.validate).toSet().toList()..sort();
/// Which known lockers [record]'s day keeps active.
///
/// Only meaningful while the day is sick; callers check that first. The
/// rules run in the README's order: missing -> keep nothing; older than
/// `state` -> stale, keep nothing (a stale malformed value is just stale);
/// not a list of strings -> keep every locker, logged; else the list minus
/// ids this version does not know.
Set<String> readKeep(Record record) {
final field = record.fields[keepField];
if (field == null) return <String>{};
final (value, hlc) = field;
final state = record.fields['state'];
if (state != null && hlc < state.$2) return <String>{};
if (value is! List || value.any((v) => v is! String)) {
developer.log(
'sick day ${record.id} has a malformed keep field $value -- every '
'locker applies',
name: 'SickDayPool',
level: 900,
);
return SickLocker.all.toSet();
}
return value.cast<String>().where(SickLocker.all.contains).toSet();
}

View File

@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:wake_alarm_sync/services/alarm_sync_service.dart';
import 'package:wake_alarm_sync/services/free_day_pool.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
/// The shared sick-day pool, as the phone sees it.
///
@ -14,9 +15,17 @@ import 'package:wake_alarm_sync/services/free_day_pool.dart';
/// marked or cleared; no cap, no reason, no countdown; and a free day is
/// never also a sick day. The record shape is the Python package's wire
/// format: the record id *is* the ISO date, fields `state` (`sick` /
/// `cleared`) and `actor`, both stamped with one HLC tick. Clearing flips
/// `state` rather than tombstoning, because crdt tombstones are monotonic
/// and a deleted date could never be marked sick again.
/// `cleared`), `actor` and `keep` (0.2.0, `sick_day_keep.dart`), all three
/// stamped with one HLC tick on a mark or a clear. Clearing flips `state`
/// rather than tombstoning, because crdt tombstones are monotonic and a
/// deleted date could never be marked sick again. A keep-only edit
/// ([setKept]) re-stamps `keep` and `actor` only.
///
/// Every tick comes from [LogStore.nextHlc], which the store keeps after
/// the greatest HLC of *any* node in the log -- so a tick is always later
/// than both this device's last one and everything on the day's record,
/// which is the contract's "never older than the state it was made
/// against".
///
/// This is not the free-day pool: its own file, its own remote node
/// (`sickdays-sync/devices`), its own native cache. Nothing here touches
@ -85,19 +94,46 @@ class SickDayPool {
return days..sort();
}
/// Every ISO date currently sick, past ones included, for the native
/// cache: the stick skips past sick days when it walks back the streak.
/// Every ISO date currently sick, past ones included, whatever it keeps.
List<String> sickIsoDates() => sickDays().map(FreeDayPool.toIso).toList();
/// The lockers [day] keeps active; empty when the day is not sick.
Set<String> keptLockers(DateTime day) {
final record = _store.get(FreeDayPool.toIso(FreeDayPool.dateOnly(day)));
if (record == null || !_isSick(record)) return <String>{};
return readKeep(record);
}
/// Whether [locker] is switched off on [day]: the day is sick and does
/// not keep it. Throws [ArgumentError] for an unknown locker id.
bool standsDown(String locker, DateTime day) {
SickLocker.validate(locker);
return isSick(day) && !keptLockers(day).contains(locker);
}
/// Every ISO date on which [locker] stands down, past ones included.
///
/// With [SickLocker.alarm] this is the native cache: the ring, the stick
/// (which skips past sick days when it walks back the streak) and the
/// stakes card must only see days on which the alarm stands down.
List<String> standDownIsoDates(String locker) => [
for (final day in sickDays())
if (standsDown(locker, day)) FreeDayPool.toIso(day),
];
/// Marks [day] sick, everywhere, for every app.
///
/// Throws [SickDayPolicyError] for a past day, a day already sick, or a
/// day [isFree] says is in the free-day pool. [now] is injectable.
/// [keep] names the lockers that stay active (default: none, so every
/// locker stands down). Throws [ArgumentError] for an unknown locker id,
/// and [SickDayPolicyError] for a past day, a day already sick, or a day
/// [isFree] says is in the free-day pool. [now] is injectable.
Future<void> add(
DateTime day, {
required bool Function(DateTime day) isFree,
DateTime? now,
Iterable<String> keep = const [],
}) async {
final wire = keepWire(keep);
final id = _checkNotPast(day, now, 'marked');
if (_isSick(_store.get(id))) {
throw SickDayPolicyError('$id is already a sick day.');
@ -107,7 +143,7 @@ class SickDayPool {
'$id is a free day — release it in Free days first.',
);
}
await _write(id, sick: true);
await _write(id, sick: true, keep: wire);
}
/// Clears [day] back to a normal day.
@ -118,7 +154,40 @@ class SickDayPool {
if (!_isSick(_store.get(id))) {
throw SickDayPolicyError('$id is not a sick day.');
}
await _write(id, sick: false);
await _write(id, sick: false, keep: const []);
}
/// Replaces the kept lockers of the existing sick day [day].
///
/// Copies the record and re-stamps only `keep` and `actor`, with one
/// fresh tick: `state` keeps its own clock, so this edit can never
/// re-assert "sick" over a concurrent clear from another device. An empty
/// [keep] stands every locker down again. Throws [ArgumentError] for an
/// unknown id and [SickDayPolicyError] for a past day or a normal day.
Future<void> setKept(
DateTime day,
Iterable<String> keep, {
DateTime? now,
}) async {
final wire = keepWire(keep);
final id = _checkNotPast(day, now, 'changed');
final existing = _store.get(id);
if (existing == null || !_isSick(existing)) {
throw SickDayPolicyError('$id is not a sick day.');
}
final hlc = _store.nextHlc();
await _store.upsert(
Record(
id: id,
fields: {
...existing.fields,
keepField: (wire, hlc),
_actorField: (deviceId, hlc),
},
deleted: existing.deleted,
deletedHlc: existing.deletedHlc,
),
);
}
/// The ISO id of [day], or a refusal when it is before [now]'s date.
@ -134,8 +203,14 @@ class SickDayPool {
return id;
}
/// Both fields share one tick, as the Python writer does.
Future<void> _write(String id, {required bool sick}) async {
/// Every field shares one tick, as the Python writer does; `keep` is
/// always written (`[]` on a clear), so a keep set from before a clear can
/// never survive a later re-add.
Future<void> _write(
String id, {
required bool sick,
required List<String> keep,
}) async {
final hlc = _store.nextHlc();
await _store.upsert(
Record(
@ -143,13 +218,15 @@ class SickDayPool {
fields: {
_stateField: (sick ? _sick : _cleared, hlc),
_actorField: (deviceId, hlc),
keepField: (keep, hlc),
},
),
);
}
}
/// An add or remove the pool's rules refuse. The message is for the user.
/// An add, remove or keep edit the pool's rules refuse; the message is for
/// the user.
class SickDayPolicyError implements Exception {
/// Creates the refusal with its user-facing [message].
SickDayPolicyError(this.message);

View File

@ -2,6 +2,7 @@ import 'package:crdt_sync/crdt_sync.dart';
import 'package:wake_alarm_sync/services/alarm_scheduler.dart';
import 'package:wake_alarm_sync/services/day_pool_sync.dart';
import 'package:wake_alarm_sync/services/firebase_backend.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
/// Moves the sick-day pool between this phone and every other device, over
@ -9,7 +10,9 @@ import 'package:wake_alarm_sync/services/sick_day_pool.dart';
///
/// Every sync ends by handing the merged pool to the native ring path
/// ([AlarmScheduler.setSickDays]): that cache, not the Dart store, is what
/// `AlarmReceiver` reads when it decides whether to ring.
/// `AlarmReceiver` reads when it decides whether to ring. Only days on
/// which the *alarm* stands down go there: a sick day that keeps the alarm
/// rings, judges the stick and shows its stakes exactly like a normal day.
class SickDaySync {
/// Creates a syncer; both seams are injectable for tests.
SickDaySync({
@ -35,9 +38,12 @@ class SickDaySync {
logName: 'SickDaySync',
);
/// Writes the pool's sick dates where `AlarmReceiver` can read them.
/// Writes the dates on which the alarm stands down (sick, alarm not
/// kept; past ones included) where `AlarmReceiver` can read them. The one
/// place the keep set is applied for the native side: every native reader
/// (ring, stick, stakes) means "the alarm stands down".
Future<void> publishToNative(SickDayPool pool) => publishQuietly(
() => _scheduler.setSickDays(pool.sickIsoDates()),
() => _scheduler.setSickDays(pool.standDownIsoDates(SickLocker.alarm)),
logName: 'SickDaySync',
);
}

View File

@ -92,3 +92,25 @@ const pythonSickLog =
/// The PC's device directory in [pythonSickLog].
const pythonDeviceId = 'ad78e209-49cd-4989-9235-da5ec179f183';
/// A log exactly as `sickdays` 0.2.0 (Python) writes it, generated by
/// `add_sick_day` / `set_kept_lockers` on 2026-10-16, not hand-written:
/// 2026-10-20 sick keeping leetcode + workout; 2026-10-22 sick, then a
/// keep-only edit to keep the alarm (state keeps its older tick);
/// 2026-10-23 sick keeping nothing.
const pythonKeepLog =
'{"2026-10-20": {"id": "2026-10-20", "fields": {"state": ["sick", '
'"2026-10-16T13:16:33.057Z-0000-$_py"], "actor": ["$_py", '
'"2026-10-16T13:16:33.057Z-0000-$_py"], "keep": [["leetcode", '
'"workout"], "2026-10-16T13:16:33.057Z-0000-$_py"]}, "deleted": false, '
'"deleted_hlc": null}, "2026-10-22": {"id": "2026-10-22", "fields": '
'{"state": ["sick", "2026-10-16T13:16:33.058Z-0000-$_py"], "actor": '
'["$_py", "2026-10-16T13:16:33.058Z-0001-$_py"], "keep": [["alarm"], '
'"2026-10-16T13:16:33.058Z-0001-$_py"]}, "deleted": false, '
'"deleted_hlc": null}, "2026-10-23": {"id": "2026-10-23", "fields": '
'{"state": ["sick", "2026-10-16T13:16:33.059Z-0000-$_py"], "actor": '
'["$_py", "2026-10-16T13:16:33.059Z-0000-$_py"], "keep": [[], '
'"2026-10-16T13:16:33.059Z-0000-$_py"]}, "deleted": false, '
'"deleted_hlc": null}}';
const _py = '578c399d-4b1d-4558-ab86-b8a9c5aa123a';

View File

@ -71,6 +71,27 @@ void main() {
expect(done, ['done']);
});
test('a PC sick day that keeps the alarm never reaches the cache', () async {
// `sickdays add 2026-10-22` + `sickdays keep 2026-10-22 alarm` on the PC.
final remote = MemRemote();
remote.files['sickdays-sync/devices/pc/sick_days.json'] = pythonKeepLog;
await refreshDayPools(
openFree: () =>
FreeDayPool.open(persistence: MemPersistence(), deviceId: 'phone'),
openSick: () =>
SickDayPool.open(persistence: MemPersistence(), deviceId: 'phone'),
freeSync: FreeDaySync(
openClient: () async => remote,
scheduler: scheduler,
),
sickSync: SickDaySync(
openClient: () async => remote,
scheduler: scheduler,
),
);
expect(caches['setSickDays'], ['2026-10-20', '2026-10-23']);
});
test('a pool that cannot open leaves the other one syncing', () async {
final remote = MemRemote();
remote.files['sickdays-sync/devices/pc/sick_days.json'] = pythonSickLog;

View File

@ -0,0 +1,244 @@
import 'dart:convert';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wake_alarm_sync/services/sick_day_keep.dart';
import 'package:wake_alarm_sync/services/sick_day_pool.dart';
import '../fake_day_pool_store.dart';
/// The per-locker keep set (sickdays 0.2.0, README "keep (0.2.0)").
final _today = DateTime(2026, 10, 16);
final _day = DateTime(2026, 10, 20);
bool _never(DateTime _) => false;
void main() {
late MemPersistence disk;
late SickDayPool pool;
setUp(() async {
disk = MemPersistence();
pool = await SickDayPool.open(persistence: disk, deviceId: 'phone-uuid');
});
Map<String, Object?> wireFields(String iso) {
final json = jsonDecode(disk.text!) as Map<String, Object?>;
final record = json[iso]! as Map<String, Object?>;
return record['fields']! as Map<String, Object?>;
}
Hlc hlcOf(String iso, String field) =>
pool.snapshot()[iso]!.fields[field]!.$2;
test('mark defaults to keep nothing: every locker stands down', () async {
await pool.add(_day, isFree: _never, now: _today);
final fields = wireFields('2026-10-20');
expect((fields['keep']! as List).first, isEmpty);
expect(
{for (final f in fields.values) (f! as List).last},
hasLength(1),
reason: 'state, actor and keep share one tick',
);
for (final locker in SickLocker.all) {
expect(pool.standsDown(locker, _day), isTrue, reason: locker);
}
expect(pool.keptLockers(_day), isEmpty);
});
test('a kept locker behaves normally; the rest stand down', () async {
await pool.add(
_day,
isFree: _never,
now: _today,
keep: [SickLocker.workout, SickLocker.leetcode, SickLocker.workout],
);
expect((wireFields('2026-10-20')['keep']! as List).first, [
'leetcode',
'workout',
], reason: 'sorted and de-duplicated on the wire');
expect(pool.standsDown(SickLocker.workout, _day), isFalse);
expect(pool.standsDown(SickLocker.leetcode, _day), isFalse);
expect(pool.standsDown(SickLocker.alarm, _day), isTrue);
expect(pool.keptLockers(_day), {'leetcode', 'workout'});
});
test('a normal day stands nothing down and keeps nothing', () {
expect(pool.standsDown(SickLocker.alarm, _day), isFalse);
expect(pool.keptLockers(_day), isEmpty);
});
test('an unknown locker id is a programmer error', () async {
expect(() => pool.standsDown('gym', _day), throwsArgumentError);
await expectLater(
pool.add(_day, isFree: _never, now: _today, keep: ['gym']),
throwsArgumentError,
);
expect(pool.isSick(_day), isFalse, reason: 'nothing written');
});
test(
'clear writes keep [] in the same tick; a re-add keeps nothing',
() async {
await pool.add(_day, isFree: _never, now: _today, keep: ['alarm']);
await pool.remove(_day, now: _today);
final fields = wireFields('2026-10-20');
expect((fields['keep']! as List).first, isEmpty);
expect({for (final f in fields.values) (f! as List).last}, hasLength(1));
await pool.add(_day, isFree: _never, now: _today);
expect(pool.standsDown(SickLocker.alarm, _day), isTrue);
},
);
test('a keep-only edit stamps keep and actor, never state', () async {
await pool.add(_day, isFree: _never, now: _today);
final stateBefore = hlcOf('2026-10-20', 'state');
await pool.setKept(_day, [SickLocker.alarm], now: _today);
expect(hlcOf('2026-10-20', 'state'), stateBefore);
expect(hlcOf('2026-10-20', 'keep'), hlcOf('2026-10-20', 'actor'));
expect(hlcOf('2026-10-20', 'keep') > stateBefore, isTrue);
expect(pool.standsDown(SickLocker.alarm, _day), isFalse);
expect(pool.standsDown(SickLocker.diet, _day), isTrue);
});
test('a keep edit ticks after a PC state stamped in the future', () async {
final future = Hlc(
wallTimeMs: DateTime.now().toUtc().millisecondsSinceEpoch + 3600000,
counter: 7,
nodeId: 'pc',
);
await pool.replaceAll({
'2026-10-20': Record(
id: '2026-10-20',
fields: {'state': ('sick', future), 'actor': ('pc', future)},
),
});
await pool.setKept(_day, [SickLocker.book], now: _today);
expect(hlcOf('2026-10-20', 'keep') > future, isTrue);
expect(pool.keptLockers(_day), {'book'}, reason: 'not stale');
});
test('a keep edit never re-asserts sick over a concurrent clear', () async {
await pool.add(_day, isFree: _never, now: _today);
final base = pool.snapshot();
final other = await SickDayPool.open(
persistence: MemPersistence(),
deviceId: 'pc-uuid',
);
await other.replaceAll(base);
await other.remove(_day, now: _today);
await pool.setKept(_day, [SickLocker.alarm], now: _today);
await pool.replaceAll(mergeLogs(pool.snapshot(), other.snapshot()));
expect(pool.isSick(_day), isFalse);
expect(pool.standsDown(SickLocker.workout, _day), isFalse);
});
test('setKept refuses a past day, a normal day and unknown ids', () async {
await expectLater(
pool.setKept(DateTime(2026, 10, 15), const [], now: _today),
throwsA(
isA<SickDayPolicyError>().having(
(e) => e.message,
'message',
contains('2026-10-15 has already passed'),
),
),
);
await expectLater(
pool.setKept(_day, const [], now: _today),
throwsA(
isA<SickDayPolicyError>().having(
(e) => e.message,
'message',
'2026-10-20 is not a sick day.',
),
),
);
await pool.add(_day, isFree: _never, now: _today);
await expectLater(
pool.setKept(_day, const ['gym'], now: _today),
throwsArgumentError,
);
});
group('read rules, in order', () {
const state = Hlc(wallTimeMs: 1000, counter: 0, nodeId: 'pc');
const older = Hlc(wallTimeMs: 999, counter: 0, nodeId: 'pc');
Future<Set<String>> keptWith(Field? keep) async {
await pool.replaceAll({
'2026-10-20': Record(
id: '2026-10-20',
fields: {'state': ('sick', state), 'keep': ?keep},
),
});
return pool.keptLockers(_day);
}
test('no keep field (0.1.0) keeps nothing', () async {
expect(await keptWith(null), isEmpty);
});
test('a keep older than state is stale: keeps nothing', () async {
expect(await keptWith((<Object?>['alarm'], older)), isEmpty);
});
test('an equal tick is not stale', () async {
expect(await keptWith((<Object?>['alarm'], state)), {'alarm'});
});
test('a malformed keep keeps every locker (locks apply)', () async {
expect(await keptWith(('alarm', state)), SickLocker.all.toSet());
expect(await keptWith((<Object?>['alarm', 3], state)), hasLength(5));
expect(pool.standsDown(SickLocker.alarm, _day), isFalse);
});
test('a stale malformed keep is just stale', () async {
expect(await keptWith(('alarm', older)), isEmpty);
});
test('unknown ids in a valid list are ignored', () async {
expect(await keptWith((<Object?>['gym', 'diet'], state)), {'diet'});
});
});
test('reads a keep log the Python package wrote', () async {
await pool.replaceAll(logFromJson(pythonKeepLog));
expect(pool.keptLockers(_day), {'leetcode', 'workout'});
expect(pool.keptLockers(DateTime(2026, 10, 22)), {'alarm'});
expect(pool.keptLockers(DateTime(2026, 10, 23)), isEmpty);
expect(pool.standDownIsoDates(SickLocker.alarm), [
'2026-10-20',
'2026-10-23',
]);
});
test('a 0.1.0 Python log keeps nothing on its sick day', () async {
await pool.replaceAll(logFromJson(pythonSickLog));
expect(pool.standDownIsoDates(SickLocker.alarm), ['2026-10-20']);
});
test('stand-down dates keep past days, drop kept ones', () async {
final past = DateTime(2026, 10, 10);
final hlc = Hlc.newTick('pc');
await pool.replaceAll({
'2026-10-10': Record(
id: '2026-10-10',
fields: {'state': ('sick', hlc), 'keep': (<Object?>[], hlc)},
),
'2026-10-11': Record(
id: '2026-10-11',
fields: {
'state': ('sick', hlc),
'keep': (<Object?>['alarm'], hlc),
},
),
});
expect(pool.standsDown(SickLocker.alarm, past), isTrue);
expect(pool.standDownIsoDates(SickLocker.alarm), ['2026-10-10']);
expect(pool.sickIsoDates(), ['2026-10-10', '2026-10-11']);
});
}

View File

@ -75,6 +75,27 @@ void main() {
expect(lastCache(), isEmpty);
});
test('only days on which the alarm stands down reach the cache', () async {
final pool = await openPool();
await pool.add(_today, isFree: _never, now: _today, keep: ['alarm']);
await pool.add(
DateTime(2026, 10, 20),
isFree: _never,
now: _today,
keep: ['leetcode'],
);
final sync = SickDaySync(
openClient: () async => null,
scheduler: scheduler,
);
await sync.sync(pool);
expect(lastCache(), ['2026-10-20'], reason: 'kept alarm rings');
await pool.setKept(DateTime(2026, 10, 20), ['alarm'], now: _today);
await sync.sync(pool);
expect(lastCache(), isEmpty, reason: 'a keep-only edit reaches it too');
});
test('not connected to Firebase still refreshes the cache', () async {
final pool = await openPool();
await pool.add(_today, isFree: _never, now: _today);