mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 18:07:05 +01:00
- Sick days screen (own name, sick icon, cat-2 fill): tap today or a later day to mark it sick, tap again to clear; past days inert. Writes the sickdays wire format (state sick/cleared + actor, one HLC per write) to sickdays-sync/devices via crdt_sync syncLog. - Mutual exclusion in both pickers: each syncs the other pool on open and refuses a date it holds, naming the pool; an unreadable pool refuses all. - Native SickDays cache (fail closed via DayCache); AlarmReceiver skips the ring with reason "sick_day" (no markFired, no ring_started); StickCheck verdict sick_day, streak skips sick days; stakes card says so. - Pre-alarm pool sync: allow-while-idle alarm at 21:00 and A-30 (request code 1006) runs a headless engine (poolSyncMain) that pulls both pools from RTDB and rewrites both native caches, so a PC mark arrives with the app closed. The ring decision stays a cache read. - JVM unit tests (JUnit 4.13.2) for DayCache, RingDecision, PoolSyncSchedule, StickCheck; Dart tests; Dart->Python interop check. pylint hook skipped: verify.py imports sickdays, not in the hook env until the sickdays-v0.1.0 tag exists; run by hand in a sickdays venv (10.00/10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
163 lines
5.6 KiB
Dart
163 lines
5.6 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:crdt_sync/crdt_sync.dart';
|
|
import 'package:crdt_sync/crdt_sync_io.dart';
|
|
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';
|
|
|
|
/// The shared sick-day pool, as the phone sees it.
|
|
///
|
|
/// A Dart port of `~/src/utils/sickdays` (Python), which owns the rules:
|
|
/// every day is normal until marked sick; only today or a later day can be
|
|
/// 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.
|
|
///
|
|
/// This is not the free-day pool: its own file, its own remote node
|
|
/// (`sickdays-sync/devices`), its own native cache. Nothing here touches
|
|
/// the network; `sick_day_sync.dart` does that.
|
|
class SickDayPool {
|
|
/// Wraps an already-loaded store writing as [deviceId].
|
|
SickDayPool(this._store, {required this.deviceId});
|
|
|
|
final LogStore _store;
|
|
|
|
/// The device uuid stamped as `actor` on every write.
|
|
final String deviceId;
|
|
|
|
/// Remote layout shared with the Python package: `<prefix>/<device>/<file>`.
|
|
static const pathPrefix = 'sickdays-sync/devices';
|
|
|
|
/// The per-device file name under [pathPrefix].
|
|
static const filename = 'sick_days.json';
|
|
|
|
static const _stateField = 'state';
|
|
static const _actorField = 'actor';
|
|
static const _sick = 'sick';
|
|
static const _cleared = 'cleared';
|
|
|
|
/// Opens the on-disk pool under the install's one device uuid (the same
|
|
/// one the alarm and free-day stores write as).
|
|
static Future<SickDayPool> open({
|
|
LogPersistence? persistence,
|
|
String? deviceId,
|
|
}) async {
|
|
final backend = persistence ?? await _defaultPersistence();
|
|
final id = deviceId ?? await AlarmSyncService.loadDeviceId();
|
|
final store = LogStore(persistence: backend, nodeId: id);
|
|
await store.load();
|
|
return SickDayPool(store, deviceId: id);
|
|
}
|
|
|
|
static Future<LogPersistence> _defaultPersistence() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
return FileLogPersistence(File(p.join(dir.path, 'sick_days_log.json')));
|
|
}
|
|
|
|
/// The whole log, for `syncLog`.
|
|
Log snapshot() => _store.snapshot();
|
|
|
|
/// Replaces the log with a merged result and persists it.
|
|
Future<void> replaceAll(Log merged) => _store.replaceAll(merged);
|
|
|
|
/// Anything not understood -- a tombstone, a missing or unknown state --
|
|
/// reads as "not sick", exactly like `sickdays.is_sick_record`.
|
|
static bool _isSick(Record? record) {
|
|
if (record == null || record.deleted) return false;
|
|
return record.fields[_stateField]?.$1 == _sick;
|
|
}
|
|
|
|
/// Whether [day] is a sick day.
|
|
bool isSick(DateTime day) =>
|
|
_isSick(_store.get(FreeDayPool.toIso(FreeDayPool.dateOnly(day))));
|
|
|
|
/// Every currently-sick date, ascending.
|
|
List<DateTime> sickDays() {
|
|
final days = <DateTime>[
|
|
for (final record in _store.values)
|
|
if (_isSick(record)) ?FreeDayPool.parseIso(record.id),
|
|
];
|
|
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.
|
|
List<String> sickIsoDates() => sickDays().map(FreeDayPool.toIso).toList();
|
|
|
|
/// 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.
|
|
Future<void> add(
|
|
DateTime day, {
|
|
required bool Function(DateTime day) isFree,
|
|
DateTime? now,
|
|
}) async {
|
|
final id = _checkNotPast(day, now, 'marked');
|
|
if (_isSick(_store.get(id))) {
|
|
throw SickDayPolicyError('$id is already a sick day.');
|
|
}
|
|
if (isFree(FreeDayPool.dateOnly(day))) {
|
|
throw SickDayPolicyError(
|
|
'$id is a free day — release it in Free days first.',
|
|
);
|
|
}
|
|
await _write(id, sick: true);
|
|
}
|
|
|
|
/// Clears [day] back to a normal day.
|
|
///
|
|
/// Throws [SickDayPolicyError] for a past day or a day that is not sick.
|
|
Future<void> remove(DateTime day, {DateTime? now}) async {
|
|
final id = _checkNotPast(day, now, 'cleared');
|
|
if (!_isSick(_store.get(id))) {
|
|
throw SickDayPolicyError('$id is not a sick day.');
|
|
}
|
|
await _write(id, sick: false);
|
|
}
|
|
|
|
/// The ISO id of [day], or a refusal when it is before [now]'s date.
|
|
String _checkNotPast(DateTime day, DateTime? now, String verb) {
|
|
final target = FreeDayPool.dateOnly(day);
|
|
final reference = FreeDayPool.dateOnly(now ?? DateTime.now());
|
|
final id = FreeDayPool.toIso(target);
|
|
if (target.isBefore(reference)) {
|
|
throw SickDayPolicyError(
|
|
'$id has already passed — only today or later can be $verb.',
|
|
);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
/// Both fields share one tick, as the Python writer does.
|
|
Future<void> _write(String id, {required bool sick}) async {
|
|
final hlc = _store.nextHlc();
|
|
await _store.upsert(
|
|
Record(
|
|
id: id,
|
|
fields: {
|
|
_stateField: (sick ? _sick : _cleared, hlc),
|
|
_actorField: (deviceId, hlc),
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// An add or remove 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);
|
|
|
|
/// Why the pool said no.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => message;
|
|
}
|