mirror of
https://github.com/kuhyx/diet-guard.git
synced 2026-11-10 20:27:11 +01:00
Sync the meal schedule as sched: fields on the budget record
Without this the schedule would be device-local, which is the failure this
whole feature has to avoid: the phone sets five meals, the PC still derives
four, and the PC's gate nags for a 12:00 checkpoint the phone never offered
and can never satisfy. So sync lands before either edit surface, not after.
The history rides the existing `budget` CRDT record as one
`sched:<YYYY-MM-DD>` field per entry, exactly like the budget's own `hist:`
fields. merge_record is per-field LWW over the *union* of field names and both
devices push the merged record, so a device that predates the feature relays
the fields untouched -- no coordinated release. There is a test for precisely
that: merging our push with a budget-only peer keeps both schedule entries.
Verified the two languages agree on the wire, not just in their own suites --
Python and Dart both emit
{"sched:1970-01-01":{"f":8,"l":20,"n":4},"sched:2026-08-16":{"f":8,"l":20,"n":5}}
The Hlc is derived from the *parsed* timestamp on both sides, which matters
because the epoch fallback formats differently in each language
(1970-01-01T00:00:00+00:00 vs ...000Z) and a string comparison would diverge.
Two guards worth naming:
- A device with no history contributes no `sched:` fields at all, so an unset
default can never outrank a peer's real schedule; and an empty merge result
is never written back, so a pre-feature peer cannot erase a real history.
- Dart's `_syncBudget` synthesises a local record when the schedule has been
edited but the budget never has -- `budgetToLog(null, ...)` returns an empty
Log and would otherwise drop the schedule fields too. Its budget value
carries an epoch stamp, so it loses every LWW race against a real edit.
sync_merge/_schedule.py and sync_merge_schedule.dart are separate modules
rather than additions to the budget adapters, which were at 186 and 170 lines
against the 250 cap. Each declares its own record-id constant so the Dart
pair does not import each other.
772 Python tests at 100% branch coverage, 619 Dart.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159b4E5hh512jYUeaJVNxfM
This commit is contained in:
parent
d2e62bf210
commit
24dbffc432
@ -14,7 +14,9 @@ import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/services/budget_schedule.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_history.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
|
||||
|
||||
/// Stable id: exactly one budget record per device-pushed `budget.json`.
|
||||
const budgetRecordId = 'budget';
|
||||
@ -75,6 +77,7 @@ Hlc historyHlc(BudgetEntry entry) {
|
||||
Log budgetToLog(
|
||||
Map<String, dynamic>? record, [
|
||||
List<BudgetEntry> entries = const [],
|
||||
List<ScheduleEntry> scheduleEntries = const [],
|
||||
]) {
|
||||
if (record == null) return {};
|
||||
final hlc = budgetHlc(record);
|
||||
@ -88,6 +91,9 @@ Log budgetToLog(
|
||||
historyHlc(entry),
|
||||
);
|
||||
}
|
||||
// The meal-schedule history rides the same record as its own `sched:`
|
||||
// fields; see `sync_merge_schedule.dart`.
|
||||
fields.addAll(scheduleFields(scheduleEntries));
|
||||
return {budgetRecordId: Record(id: budgetRecordId, fields: fields)};
|
||||
}
|
||||
|
||||
|
||||
98
app/lib/services/sync_merge_schedule.dart
Normal file
98
app/lib/services/sync_merge_schedule.dart
Normal file
@ -0,0 +1,98 @@
|
||||
/// Meal-schedule history as extra fields on the shared `budget` record.
|
||||
///
|
||||
/// Split out of `sync_merge_budget.dart` to keep both under the repo's
|
||||
/// 250-line limit; the two halves are otherwise the same idea and share one
|
||||
/// record.
|
||||
///
|
||||
/// The schedule rides as one `sched:<YYYY-MM-DD>` field per history entry,
|
||||
/// exactly like the budget's `hist:` fields. `mergeRecord` is per-field
|
||||
/// last-writer-wins over the *union* of field names, and both devices push
|
||||
/// the *merged* record rather than their own, so a device that predates meal
|
||||
/// schedules neither clobbers those fields nor blocks them -- it relays them
|
||||
/// untouched. That is what makes this shippable without a coordinated
|
||||
/// release.
|
||||
///
|
||||
/// KEEP IN SYNC WITH `diet_guard/sync_merge/_schedule.py`.
|
||||
library;
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_history.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
|
||||
/// The shared record these fields live on. Declared here rather than imported
|
||||
/// from `sync_merge_budget.dart` so the two modules do not import each other;
|
||||
/// `_schedule.py` declares its own copy for the same reason.
|
||||
const _budgetRecordId = 'budget';
|
||||
|
||||
/// Field-name prefix for the effective-from meal-schedule history.
|
||||
const scheduleFieldPrefix = 'sched:';
|
||||
|
||||
/// Derives a deterministic [Hlc] for one schedule entry from its edit time.
|
||||
///
|
||||
/// Identical inputs always yield the same clock, so re-syncing an unchanged
|
||||
/// history is a no-op. Derived from the *parsed* timestamp rather than the
|
||||
/// raw string, so the two languages agree even though they format the epoch
|
||||
/// fallback differently.
|
||||
Hlc scheduleHlc(ScheduleEntry entry) {
|
||||
final wallTimeMs =
|
||||
DateTime.tryParse(entry.editedAt)?.toUtc().millisecondsSinceEpoch ?? 0;
|
||||
return Hlc.newTick(currentSyncDeviceId, wallTimeMsOverride: wallTimeMs);
|
||||
}
|
||||
|
||||
/// Returns the `sched:` fields contributed by [entries].
|
||||
///
|
||||
/// Empty when this device has no history, so a device that has never edited
|
||||
/// a schedule contributes nothing to the merge rather than pushing the unset
|
||||
/// default over a peer's real value.
|
||||
Map<String, (dynamic, Hlc)> scheduleFields(List<ScheduleEntry> entries) => {
|
||||
for (final entry in entries)
|
||||
'$scheduleFieldPrefix${entry.effectiveFrom}': (
|
||||
{
|
||||
'f': entry.schedule.first,
|
||||
'l': entry.schedule.last,
|
||||
'n': entry.schedule.count,
|
||||
},
|
||||
scheduleHlc(entry),
|
||||
),
|
||||
};
|
||||
|
||||
/// Extracts the meal-schedule history from a merged budget [Log].
|
||||
///
|
||||
/// Each entry's `editedAt` is reconstructed from its field Hlc rather than
|
||||
/// carried separately, so the stored timestamp and the clock the merge
|
||||
/// compared can never drift apart. Malformed values are skipped, so one bad
|
||||
/// field from a peer cannot take out the whole history.
|
||||
List<ScheduleEntry> logToScheduleHistory(Log log) {
|
||||
final record = log[_budgetRecordId];
|
||||
if (record == null) return const [];
|
||||
final entries = <ScheduleEntry>[];
|
||||
for (final field in record.fields.entries) {
|
||||
if (!field.key.startsWith(scheduleFieldPrefix)) continue;
|
||||
final value = field.value.$1;
|
||||
if (value is! Map) continue;
|
||||
final first = value['f'];
|
||||
final last = value['l'];
|
||||
final count = value['n'];
|
||||
if (first is! int || last is! int || count is! int) continue;
|
||||
entries.add(
|
||||
ScheduleEntry(
|
||||
effectiveFrom: field.key.substring(scheduleFieldPrefix.length),
|
||||
// Normalised on the way in, so a peer running a future version with a
|
||||
// wider range cannot hand us a schedule we would derive slots
|
||||
// differently from.
|
||||
schedule: MealSchedule(
|
||||
first: first,
|
||||
last: last,
|
||||
count: count,
|
||||
).normalized(),
|
||||
editedAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
field.value.$2.wallTimeMs,
|
||||
isUtc: true,
|
||||
).toLocal().toIso8601String(),
|
||||
),
|
||||
);
|
||||
}
|
||||
entries.sort((a, b) => a.effectiveFrom.compareTo(b.effectiveFrom));
|
||||
return entries;
|
||||
}
|
||||
@ -18,8 +18,10 @@ import 'package:diet_guard_app/services/app_settings_service.dart';
|
||||
import 'package:diet_guard_app/services/budget_history_service.dart';
|
||||
import 'package:diet_guard_app/services/foodbank_service.dart';
|
||||
import 'package:diet_guard_app/services/log_storage_service.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_service.dart';
|
||||
import 'package:diet_guard_app/services/sync_device_id.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
|
||||
import 'package:diet_guard_app/services/sync_state_factory.dart';
|
||||
|
||||
const _devicesDir = 'diet-guard-sync/devices';
|
||||
@ -113,12 +115,23 @@ Future<void> _syncManualBank(RemoteStore client) async {
|
||||
/// from elsewhere.
|
||||
Future<void> _syncBudget(RemoteStore client) async {
|
||||
final updatedAt = AppSettingsService.dailyKcalGoalUpdatedAt;
|
||||
final localRecord = updatedAt == null
|
||||
final scheduleEntries = MealScheduleService.history.entries;
|
||||
// `budgetToLog` returns an empty Log for a null record, which would also
|
||||
// drop the `sched:` fields. A device that has edited its schedule but never
|
||||
// its budget still has something to contribute, so synthesise a record from
|
||||
// the schedule's own edit time in that case. Its `value` carries the
|
||||
// unset-default budget, but that loses every LWW race against a device that
|
||||
// has actually set one, because this stamp is older than any real edit.
|
||||
final scheduleStamp = MealScheduleService.updatedAt;
|
||||
final recordStamp =
|
||||
updatedAt ?? (scheduleEntries.isEmpty ? null : scheduleStamp);
|
||||
final localRecord = recordStamp == null
|
||||
? null
|
||||
: <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': AppSettingsService.dailyKcalGoal,
|
||||
't': updatedAt.toIso8601String(),
|
||||
't': (updatedAt ?? DateTime.fromMillisecondsSinceEpoch(0))
|
||||
.toIso8601String(),
|
||||
};
|
||||
|
||||
final mergedBudgetLog = await syncLog(
|
||||
@ -129,6 +142,7 @@ Future<void> _syncBudget(RemoteStore client) async {
|
||||
localLog: budgetToLog(
|
||||
localRecord,
|
||||
BudgetHistoryService.schedule.entries,
|
||||
scheduleEntries,
|
||||
),
|
||||
encode: encodeBudgetForPush,
|
||||
decode: parseRemoteBudget,
|
||||
@ -141,6 +155,14 @@ Future<void> _syncBudget(RemoteStore client) async {
|
||||
await BudgetHistoryService.instance.applyMerged(
|
||||
logToHistory(mergedBudgetLog),
|
||||
);
|
||||
// Same reasoning for the meal schedule: field-union merge, so applied
|
||||
// regardless of how the scalar budget resolved.
|
||||
if (MealScheduleService.isInitialized) {
|
||||
await MealScheduleService.instance.applyMerged(
|
||||
logToScheduleHistory(mergedBudgetLog),
|
||||
updatedAt: scheduleStamp,
|
||||
);
|
||||
}
|
||||
|
||||
final merged = logToBudget(mergedBudgetLog);
|
||||
final mergedKcal = merged?['b'];
|
||||
|
||||
@ -2,7 +2,7 @@ name: diet_guard_app
|
||||
description: "Companion phone app for diet_guard: log meals on the go."
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.33+1
|
||||
version: 1.0.34+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
171
app/test/services/sync_merge_schedule_test.dart
Normal file
171
app/test/services/sync_merge_schedule_test.dart
Normal file
@ -0,0 +1,171 @@
|
||||
/// Tests for the meal-schedule half of the shared `budget` CRDT record.
|
||||
///
|
||||
/// Mirrors `diet_guard/tests/test_sync_merge_schedule.py`.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crdt_sync/crdt_sync.dart';
|
||||
import 'package:diet_guard_app/models/meal_schedule.dart';
|
||||
import 'package:diet_guard_app/services/meal_schedule_history.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_budget.dart';
|
||||
import 'package:diet_guard_app/services/sync_merge_schedule.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
const _record = <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': 2200,
|
||||
't': '2026-08-16T10:00:00+02:00',
|
||||
};
|
||||
|
||||
ScheduleEntry _entry(String day, MealSchedule schedule, String when) =>
|
||||
ScheduleEntry(effectiveFrom: day, schedule: schedule, editedAt: when);
|
||||
|
||||
String _wire(Log log) => jsonEncode({
|
||||
for (final entry in log.entries) entry.key: entry.value.toJson(),
|
||||
});
|
||||
|
||||
void main() {
|
||||
group('scheduleFields', () {
|
||||
test('no entries contributes nothing', () {
|
||||
// A device that never edited a schedule cannot outrank a peer.
|
||||
expect(scheduleFields(const []), isEmpty);
|
||||
});
|
||||
|
||||
test('one field per entry', () {
|
||||
final fields = scheduleFields([
|
||||
_entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'2026-08-16T10:00:00+02:00',
|
||||
),
|
||||
]);
|
||||
expect(fields.keys, ['${scheduleFieldPrefix}2026-08-16']);
|
||||
expect(fields['${scheduleFieldPrefix}2026-08-16']!.$1, {
|
||||
'f': 8,
|
||||
'l': 20,
|
||||
'n': 5,
|
||||
});
|
||||
});
|
||||
|
||||
test('the Hlc is deterministic', () {
|
||||
final entry = _entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'2026-08-16T10:00:00+02:00',
|
||||
);
|
||||
expect(
|
||||
scheduleHlc(entry).wallTimeMs,
|
||||
scheduleHlc(entry).wallTimeMs,
|
||||
);
|
||||
});
|
||||
|
||||
test('an unparsable timestamp falls back to the epoch', () {
|
||||
final entry = _entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'not-a-timestamp',
|
||||
);
|
||||
expect(scheduleHlc(entry).wallTimeMs, 0);
|
||||
});
|
||||
});
|
||||
|
||||
group('logToScheduleHistory', () {
|
||||
test('an absent record yields nothing', () {
|
||||
expect(logToScheduleHistory(const {}), isEmpty);
|
||||
});
|
||||
|
||||
test('round trips', () {
|
||||
final back = logToScheduleHistory(
|
||||
budgetToLog(_record, const [], [
|
||||
_entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'2026-08-16T10:00:00+02:00',
|
||||
),
|
||||
]),
|
||||
);
|
||||
expect(back, hasLength(1));
|
||||
expect(back.single.effectiveFrom, '2026-08-16');
|
||||
expect(
|
||||
back.single.schedule,
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizes a peer's out-of-range schedule", () {
|
||||
final log = budgetToLog(_record);
|
||||
final record = log[budgetRecordId]!;
|
||||
final hlc = record.fields.values.first.$2;
|
||||
record.fields['${scheduleFieldPrefix}2026-08-16'] = (
|
||||
{'f': 8, 'l': 20, 'n': 99},
|
||||
hlc,
|
||||
);
|
||||
expect(
|
||||
logToScheduleHistory(log).single.schedule,
|
||||
const MealSchedule(first: 8, last: 20, count: 6),
|
||||
);
|
||||
});
|
||||
|
||||
test('skips malformed values', () {
|
||||
final log = budgetToLog(_record);
|
||||
final record = log[budgetRecordId]!;
|
||||
final hlc = record.fields.values.first.$2;
|
||||
record.fields['${scheduleFieldPrefix}2026-01-01'] = (7, hlc);
|
||||
record.fields['${scheduleFieldPrefix}2026-02-01'] = ({'f': '8'}, hlc);
|
||||
expect(logToScheduleHistory(log), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
group('cross-device merge', () {
|
||||
test('a pre-feature peer relays the schedule untouched', () {
|
||||
// `mergeRecord` is per-field LWW over the *union* of field names, and
|
||||
// both devices push the merged record, so a budget-only push merges the
|
||||
// fields in rather than clobbering them. This is what makes the feature
|
||||
// shippable without a coordinated release.
|
||||
final ours = budgetToLog(_record, const [], [
|
||||
_entry('1970-01-01', kDefaultSchedule, '1970-01-01T00:00:00.000Z'),
|
||||
_entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'2026-08-16T10:00:00+02:00',
|
||||
),
|
||||
]);
|
||||
final peer = budgetToLog(const <String, dynamic>{
|
||||
'v': 2,
|
||||
'b': 1900,
|
||||
't': '2026-08-16T11:00:00+02:00',
|
||||
});
|
||||
|
||||
final merged = mergeLogs(parseRemoteBudget(_wire(ours)), peer);
|
||||
|
||||
expect(logToScheduleHistory(merged).map((e) => e.schedule), [
|
||||
kDefaultSchedule,
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
]);
|
||||
});
|
||||
|
||||
test('the newer edit wins per day', () {
|
||||
final older = budgetToLog(_record, const [], [
|
||||
_entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 8, last: 20, count: 5),
|
||||
'2026-08-16T10:00:00+02:00',
|
||||
),
|
||||
]);
|
||||
final newer = budgetToLog(_record, const [], [
|
||||
_entry(
|
||||
'2026-08-16',
|
||||
const MealSchedule(first: 7, last: 21, count: 3),
|
||||
'2026-08-16T18:00:00+02:00',
|
||||
),
|
||||
]);
|
||||
|
||||
final merged = mergeLogs(parseRemoteBudget(_wire(older)), newer);
|
||||
expect(
|
||||
logToScheduleHistory(merged).single.schedule,
|
||||
const MealSchedule(first: 7, last: 21, count: 3),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -28,6 +28,15 @@ from diet_guard._budget_history import (
|
||||
from diet_guard._device import device_identity
|
||||
from diet_guard._foodbank import read_food_bank, write_food_bank
|
||||
from diet_guard._foodbank_manual import read_manual_bank, write_manual_bank
|
||||
from diet_guard._meal_schedule_store import (
|
||||
history_to_json as schedule_history_to_json,
|
||||
)
|
||||
from diet_guard._meal_schedule_store import (
|
||||
load_entries as load_schedule_entries,
|
||||
)
|
||||
from diet_guard._meal_schedule_store import (
|
||||
write_raw_history as write_raw_schedule,
|
||||
)
|
||||
from diet_guard._sync_paths import (
|
||||
_DEVICES_DIR,
|
||||
_device_budget_path,
|
||||
@ -41,6 +50,7 @@ from diet_guard.sync_merge import (
|
||||
log_to_food_bank,
|
||||
log_to_history,
|
||||
log_to_manual_bank,
|
||||
log_to_schedule_history,
|
||||
manual_bank_to_log,
|
||||
parse_remote_budget,
|
||||
parse_remote_food_bank,
|
||||
@ -161,7 +171,7 @@ def _sync_budget(client: GitHubSyncClient) -> None:
|
||||
*no* device has ever set one, nothing is written or pushed.
|
||||
"""
|
||||
identity = device_identity()
|
||||
merged = budget_to_log(read_raw_record(), load_entries())
|
||||
merged = budget_to_log(read_raw_record(), load_entries(), load_schedule_entries())
|
||||
for device_id in client.list_directory(_DEVICES_DIR):
|
||||
if identity.is_own(device_id):
|
||||
continue
|
||||
@ -187,6 +197,12 @@ def _sync_budget(client: GitHubSyncClient) -> None:
|
||||
# seed from ever running.
|
||||
if merged_history:
|
||||
write_raw_history(history_to_json(merged_history))
|
||||
# Same guard for the meal schedule: a pre-feature peer contributes no
|
||||
# `sched:` fields, and writing the empty document back would discard this
|
||||
# device's own history.
|
||||
merged_schedule = log_to_schedule_history(merged)
|
||||
if merged_schedule:
|
||||
write_raw_schedule(schedule_history_to_json(merged_schedule))
|
||||
|
||||
push_json = json.dumps(
|
||||
{record_id: record.to_dict() for record_id, record in merged.items()},
|
||||
|
||||
@ -45,11 +45,17 @@ from diet_guard.sync_merge._daylog import (
|
||||
parse_remote_log,
|
||||
record_to_entry,
|
||||
)
|
||||
from diet_guard.sync_merge._schedule import (
|
||||
SCHEDULE_FIELD_PREFIX,
|
||||
log_to_schedule_history,
|
||||
schedule_fields,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Private helpers the tests reach for directly. Re-exported deliberately:
|
||||
# they were importable from the flat module, and dropping them here would
|
||||
# break those tests for a naming reason rather than a behavioural one.
|
||||
"SCHEDULE_FIELD_PREFIX",
|
||||
"_budget_hlc",
|
||||
"_entry_hlc",
|
||||
"_legacy_entry_id",
|
||||
@ -62,10 +68,12 @@ __all__ = [
|
||||
"log_to_food_bank",
|
||||
"log_to_history",
|
||||
"log_to_manual_bank",
|
||||
"log_to_schedule_history",
|
||||
"manual_bank_to_log",
|
||||
"parse_remote_budget",
|
||||
"parse_remote_food_bank",
|
||||
"parse_remote_log",
|
||||
"parse_remote_manual_bank",
|
||||
"record_to_entry",
|
||||
"schedule_fields",
|
||||
]
|
||||
|
||||
@ -23,10 +23,13 @@ from crdt_sync import Hlc, Record
|
||||
from diet_guard._budget_history import BudgetEntry
|
||||
from diet_guard._device import device_id
|
||||
from diet_guard.sync_merge._clock import _EPOCH
|
||||
from diet_guard.sync_merge._schedule import schedule_fields
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crdt_sync import Log
|
||||
|
||||
from diet_guard._meal_schedule_store import ScheduleEntry
|
||||
|
||||
|
||||
_BUDGET_RECORD_ID = "budget"
|
||||
# Field-name prefix for the effective-from budget history, one field per date.
|
||||
@ -77,6 +80,7 @@ def _history_hlc(entry: BudgetEntry) -> Hlc:
|
||||
def budget_to_log(
|
||||
record: dict[str, object] | None,
|
||||
entries: tuple[BudgetEntry, ...] = (),
|
||||
schedule_entries: tuple[ScheduleEntry, ...] = (),
|
||||
) -> Log:
|
||||
"""Convert a raw local budget record plus its history into a Log.
|
||||
|
||||
@ -112,6 +116,9 @@ def budget_to_log(
|
||||
entry.kcal,
|
||||
_history_hlc(entry),
|
||||
)
|
||||
# The meal-schedule history rides the same record as its own `sched:`
|
||||
# fields; see :mod:`diet_guard.sync_merge._schedule`.
|
||||
fields.update(schedule_fields(schedule_entries))
|
||||
rec = Record(id=_BUDGET_RECORD_ID, fields=fields)
|
||||
return {rec.id: rec}
|
||||
|
||||
|
||||
111
diet_guard/sync_merge/_schedule.py
Normal file
111
diet_guard/sync_merge/_schedule.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""Meal-schedule history as extra fields on the shared ``budget`` record.
|
||||
|
||||
Split out of :mod:`._budget` to keep both modules under the repo's 250-line
|
||||
limit; the two halves are otherwise the same idea and share one record.
|
||||
|
||||
The schedule rides as one ``sched:<YYYY-MM-DD>`` field per history entry,
|
||||
exactly like the budget's ``hist:`` fields. ``crdt_sync``'s ``merge_record``
|
||||
is per-field last-writer-wins over the *union* of field names, and both
|
||||
devices push the *merged* record rather than their own, so a device that
|
||||
predates meal schedules neither clobbers those fields nor blocks them -- it
|
||||
relays them untouched. That is what makes this shippable without a
|
||||
coordinated release.
|
||||
|
||||
The value is a small map (``{"f": 8, "l": 20, "n": 5}``) rather than a scalar.
|
||||
``Record.to_dict``/``from_dict`` round-trip nested values unchanged, and a
|
||||
malformed one is skipped exactly as a non-int ``hist:`` value is.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from crdt_sync import Hlc
|
||||
|
||||
from diet_guard._device import device_id
|
||||
from diet_guard._meal_schedule import MealSchedule
|
||||
from diet_guard._meal_schedule_store import ScheduleEntry
|
||||
from diet_guard.sync_merge._clock import _EPOCH
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from crdt_sync import Log
|
||||
|
||||
_BUDGET_RECORD_ID = "budget"
|
||||
# Field-name prefix for the effective-from meal-schedule history, one field
|
||||
# per date. A separate *field* rather than a separate document, for the
|
||||
# reason spelled out in this module's docstring.
|
||||
SCHEDULE_FIELD_PREFIX = "sched:"
|
||||
|
||||
|
||||
def schedule_hlc(entry: ScheduleEntry) -> Hlc:
|
||||
"""Derive a deterministic Hlc for one schedule entry from its edit time.
|
||||
|
||||
Same trick as the budget's ``_history_hlc``: identical inputs always yield
|
||||
the same clock, so re-syncing an unchanged history is a no-op. Derived
|
||||
from the parsed timestamp rather than the raw string, so the two languages
|
||||
agree even though they format the epoch fallback differently.
|
||||
"""
|
||||
try:
|
||||
moment = datetime.fromisoformat(entry.edited_at)
|
||||
except ValueError:
|
||||
moment = _EPOCH
|
||||
return Hlc.new_tick(device_id(), wall_time_ms=int(moment.timestamp() * 1000))
|
||||
|
||||
|
||||
def schedule_fields(
|
||||
entries: tuple[ScheduleEntry, ...],
|
||||
) -> dict[str, tuple[object, Hlc]]:
|
||||
"""Return the ``sched:`` fields contributed by ``entries``.
|
||||
|
||||
Empty when this device has no history, so a device that has never edited
|
||||
a schedule contributes nothing to the merge rather than pushing the unset
|
||||
default over a peer's real value.
|
||||
"""
|
||||
return {
|
||||
f"{SCHEDULE_FIELD_PREFIX}{entry.effective_from}": (
|
||||
{
|
||||
"f": entry.schedule.first,
|
||||
"l": entry.schedule.last,
|
||||
"n": entry.schedule.count,
|
||||
},
|
||||
schedule_hlc(entry),
|
||||
)
|
||||
for entry in entries
|
||||
}
|
||||
|
||||
|
||||
def log_to_schedule_history(log: Log) -> tuple[ScheduleEntry, ...]:
|
||||
"""Extract the meal-schedule history from a merged budget ``Log``.
|
||||
|
||||
Each entry's ``edited_at`` is reconstructed from its field Hlc rather than
|
||||
carried separately, so the stored timestamp and the clock the merge
|
||||
compared can never drift apart. Malformed values are skipped, so one bad
|
||||
field from a peer cannot take out the whole history.
|
||||
"""
|
||||
record = log.get(_BUDGET_RECORD_ID)
|
||||
if record is None:
|
||||
return ()
|
||||
entries = []
|
||||
for name, (value, hlc) in record.fields.items():
|
||||
if not name.startswith(SCHEDULE_FIELD_PREFIX):
|
||||
continue
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
first, last, count = value.get("f"), value.get("l"), value.get("n")
|
||||
if not (
|
||||
isinstance(first, int) and isinstance(last, int) and isinstance(count, int)
|
||||
) or any(isinstance(part, bool) for part in (first, last, count)):
|
||||
continue
|
||||
edited = datetime.fromtimestamp(hlc.wall_time_ms / 1000, tz=timezone.utc)
|
||||
entries.append(
|
||||
ScheduleEntry(
|
||||
effective_from=name[len(SCHEDULE_FIELD_PREFIX) :],
|
||||
# Normalised on the way in, so a peer running a future version
|
||||
# with a wider range cannot hand us a schedule we would derive
|
||||
# slots differently from.
|
||||
schedule=MealSchedule(first, last, count).normalized(),
|
||||
edited_at=edited.astimezone().isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
return tuple(sorted(entries, key=lambda entry: entry.effective_from))
|
||||
@ -14,6 +14,11 @@ from diet_guard._budget import daily_budget, write_budget
|
||||
from diet_guard._budget_derived import budget_weight
|
||||
from diet_guard._budget_history import load_entries
|
||||
from diet_guard._device import device_id
|
||||
from diet_guard._meal_schedule import MealSchedule
|
||||
from diet_guard._meal_schedule_store import (
|
||||
load_entries as load_schedule_entries,
|
||||
)
|
||||
from diet_guard._meal_schedule_store import record_schedule_change
|
||||
from diet_guard.tests._sync_fakes import (
|
||||
_mock_client,
|
||||
_remote_budget_json,
|
||||
@ -113,6 +118,21 @@ class TestSyncBudget:
|
||||
_sync.run_sync()
|
||||
assert load_entries()[-1].kcal == 2000
|
||||
|
||||
def test_meal_schedule_survives_a_round_trip(self) -> None:
|
||||
"""The meal schedule is written back locally after every merge.
|
||||
|
||||
Without this the schedule would be device-local: the phone could set
|
||||
five meals while the PC still derived four, and the PC's gate would
|
||||
nag for a checkpoint the phone never offered.
|
||||
"""
|
||||
_write_token()
|
||||
write_budget(2000)
|
||||
record_schedule_change(MealSchedule(8, 20, 5))
|
||||
client = _mock_client(devices=())
|
||||
with patch.object(_sync_client, "GitHubSyncClient", return_value=client):
|
||||
_sync.run_sync()
|
||||
assert load_schedule_entries()[-1].schedule == MealSchedule(8, 20, 5)
|
||||
|
||||
def test_malformed_remote_budget_is_skipped(self) -> None:
|
||||
"""A corrupt remote budget.json is skipped, not a crash."""
|
||||
_write_token()
|
||||
|
||||
158
diet_guard/tests/test_sync_merge_schedule.py
Normal file
158
diet_guard/tests/test_sync_merge_schedule.py
Normal file
@ -0,0 +1,158 @@
|
||||
"""Tests for the meal-schedule half of the shared ``budget`` CRDT record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from crdt_sync import Log, merge_logs
|
||||
|
||||
from diet_guard._meal_schedule import DEFAULT_SCHEDULE, MealSchedule
|
||||
from diet_guard._meal_schedule_store import ScheduleEntry
|
||||
from diet_guard.sync_merge import (
|
||||
SCHEDULE_FIELD_PREFIX,
|
||||
budget_to_log,
|
||||
log_to_schedule_history,
|
||||
parse_remote_budget,
|
||||
schedule_fields,
|
||||
)
|
||||
|
||||
_RECORD = {"v": 2, "b": 2200, "t": "2026-08-16T10:00:00+02:00"}
|
||||
|
||||
|
||||
def _entry(day: str, schedule: MealSchedule, when: str) -> ScheduleEntry:
|
||||
"""Return a schedule entry effective from ``day``."""
|
||||
return ScheduleEntry(effective_from=day, schedule=schedule, edited_at=when)
|
||||
|
||||
|
||||
def _wire(log: Log) -> str:
|
||||
"""Return the JSON one device would push for ``log``."""
|
||||
return json.dumps(
|
||||
{rid: rec.to_dict() for rid, rec in log.items()},
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
class TestScheduleFields:
|
||||
"""The per-entry fields a device contributes."""
|
||||
|
||||
def test_no_entries_contributes_nothing(self) -> None:
|
||||
"""A device that never edited a schedule cannot outrank a peer."""
|
||||
assert schedule_fields(()) == {}
|
||||
|
||||
def test_one_field_per_entry(self) -> None:
|
||||
"""Each entry becomes its own ``sched:<date>`` field."""
|
||||
fields = schedule_fields(
|
||||
(_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),)
|
||||
)
|
||||
assert set(fields) == {f"{SCHEDULE_FIELD_PREFIX}2026-08-16"}
|
||||
value, _ = fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"]
|
||||
assert value == {"f": 8, "l": 20, "n": 5}
|
||||
|
||||
def test_hlc_is_deterministic(self) -> None:
|
||||
"""Re-syncing an unchanged history is a no-op, not a fresh write."""
|
||||
entry = _entry(
|
||||
"2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"
|
||||
)
|
||||
first = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
|
||||
second = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
|
||||
assert first.wall_time_ms == second.wall_time_ms
|
||||
|
||||
def test_unparsable_timestamp_falls_back_to_the_epoch(self) -> None:
|
||||
"""A malformed stamp still yields a clock, so the entry still merges."""
|
||||
entry = _entry("2026-08-16", MealSchedule(8, 20, 5), "not-a-timestamp")
|
||||
hlc = schedule_fields((entry,))[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"][1]
|
||||
assert hlc.wall_time_ms == 0
|
||||
|
||||
|
||||
class TestLogToScheduleHistory:
|
||||
"""Extracting the history back out of a merged log."""
|
||||
|
||||
def test_absent_record_yields_nothing(self) -> None:
|
||||
"""No budget record at all means no schedule history."""
|
||||
assert log_to_schedule_history({}) == ()
|
||||
|
||||
def test_round_trips(self) -> None:
|
||||
"""What one device pushes is what the other reads back."""
|
||||
entries = (
|
||||
_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),
|
||||
)
|
||||
back = log_to_schedule_history(budget_to_log(_RECORD, (), entries))
|
||||
assert len(back) == 1
|
||||
assert back[0].effective_from == "2026-08-16"
|
||||
assert back[0].schedule == MealSchedule(8, 20, 5)
|
||||
|
||||
def test_normalizes_a_peers_out_of_range_schedule(self) -> None:
|
||||
"""A future peer cannot make this device derive different slots."""
|
||||
log = budget_to_log(_RECORD, ())
|
||||
record = log["budget"]
|
||||
hlc = next(iter(record.fields.values()))[1]
|
||||
record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-08-16"] = (
|
||||
{"f": 8, "l": 20, "n": 99},
|
||||
hlc,
|
||||
)
|
||||
assert log_to_schedule_history(log)[0].schedule == MealSchedule(8, 20, 6)
|
||||
|
||||
def test_skips_malformed_values(self) -> None:
|
||||
"""One bad field from a peer cannot take out the whole history."""
|
||||
log = budget_to_log(_RECORD, ())
|
||||
record = log["budget"]
|
||||
hlc = next(iter(record.fields.values()))[1]
|
||||
record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-01-01"] = (7, hlc)
|
||||
record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-02-01"] = ({"f": "8"}, hlc)
|
||||
record.fields[f"{SCHEDULE_FIELD_PREFIX}2026-03-01"] = (
|
||||
{"f": True, "l": 20, "n": 4},
|
||||
hlc,
|
||||
)
|
||||
assert log_to_schedule_history(log) == ()
|
||||
|
||||
|
||||
class TestCrossDeviceMerge:
|
||||
"""The property that makes this shippable without a coordinated release."""
|
||||
|
||||
def test_a_pre_feature_peer_relays_the_schedule_untouched(self) -> None:
|
||||
"""A device that knows nothing about schedules cannot drop them.
|
||||
|
||||
``merge_record`` is per-field LWW over the *union* of field names, and
|
||||
both devices push the merged record, so the peer's budget-only push
|
||||
merges the fields in rather than clobbering them.
|
||||
"""
|
||||
entries = (
|
||||
_entry("1970-01-01", DEFAULT_SCHEDULE, "1970-01-01T00:00:00+00:00"),
|
||||
_entry("2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"),
|
||||
)
|
||||
ours = budget_to_log(_RECORD, (), entries)
|
||||
peer = budget_to_log(
|
||||
{"v": 2, "b": 1900, "t": "2026-08-16T11:00:00+02:00"}, (), ()
|
||||
)
|
||||
|
||||
merged = merge_logs(parse_remote_budget(_wire(ours)), peer)
|
||||
back = log_to_schedule_history(merged)
|
||||
|
||||
assert [entry.schedule for entry in back] == [
|
||||
DEFAULT_SCHEDULE,
|
||||
MealSchedule(8, 20, 5),
|
||||
]
|
||||
|
||||
def test_the_newer_edit_wins_per_day(self) -> None:
|
||||
"""Two devices editing the same day converge on the later edit."""
|
||||
older = budget_to_log(
|
||||
_RECORD,
|
||||
(),
|
||||
(
|
||||
_entry(
|
||||
"2026-08-16", MealSchedule(8, 20, 5), "2026-08-16T10:00:00+02:00"
|
||||
),
|
||||
),
|
||||
)
|
||||
newer = budget_to_log(
|
||||
_RECORD,
|
||||
(),
|
||||
(
|
||||
_entry(
|
||||
"2026-08-16", MealSchedule(7, 21, 3), "2026-08-16T18:00:00+02:00"
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
merged = merge_logs(parse_remote_budget(_wire(older)), newer)
|
||||
assert log_to_schedule_history(merged)[0].schedule == MealSchedule(7, 21, 3)
|
||||
Loading…
Reference in New Issue
Block a user