diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 45f8582..2d524d7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -107,8 +107,9 @@ repos: # the manifest becomes an unresolvable hook environment rather # than merely an old one. - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock - - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync - - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays + - 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.1.0#subdirectory=sickdays - mcp>=2,<3 - pydantic - defusedxml==0.7.1 diff --git a/AGENTS.md b/AGENTS.md index a2ccec4..ecda46f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,8 @@ catch-up rule). `diet-guard-gate.timer` is wall-clock (`OnCalendar=*-*-* *:00/15:00`, `Persistent=true`), not boot-relative — a boot-relative timer collided with fullscreen games grabbing input. `diet-guard-gate.service` is `Type=oneshot` -and exits 0 immediately when no lock is due. It needs `DISPLAY`/`XAUTHORITY`; -see the unit file's comments and `wait_for_display()`. +and exits 0 at once when no lock is due (free/sick day: [docs/DOCS-sick-days.md](docs/DOCS-sick-days.md)). +Needs `DISPLAY`/`XAUTHORITY`; see the unit file and `wait_for_display()`. ## Cross-device sync diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 0fcc2ed..2fa083e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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.111+1 +version: 1.0.112+1 environment: sdk: ^3.12.2 diff --git a/diet_guard/_gate.py b/diet_guard/_gate.py index eae0223..2263af8 100644 --- a/diet_guard/_gate.py +++ b/diet_guard/_gate.py @@ -18,6 +18,7 @@ from __future__ import annotations from typing import TYPE_CHECKING import freedays +import sickdays from diet_guard._meal_schedule_store import current_schedule from diet_guard._slots import missing_slots, slot_label @@ -41,13 +42,16 @@ def due_slots(now: datetime | None = None) -> tuple[int, ...]: Returns: The slot minutes that still need a meal logged (empty == nothing due). - Empty on a shared free day, whatever the schedule says. The pool is read - from a single local file and never the network, so this cannot hang the - systemd-fired gate, and an unreadable pool reads as "not free" -- the - normal rules apply rather than the gate switching itself off. + Empty on a shared free day or a sick day, whatever the schedule says: the + gate stands down, so no lock opens and nothing accrues -- unlogged meals + leave no debt, and logging a meal still works as on any day. Each pool is + read from a single local file and never the network, so this cannot hang + the systemd-fired gate, and an unreadable pool reads as "not free" / "not + sick" -- the normal rules apply rather than the gate switching itself off. """ reference = now if now is not None else now_local() - if freedays.is_free_day(reference.date()): + day = reference.date() + if freedays.is_free_day(day) or sickdays.is_sick_day(day): return () # One schedule for both halves: snapping the log onto a different schedule # than the one deriving the due slots would leave a logged meal covering a diff --git a/diet_guard/tests/_state_redirects.py b/diet_guard/tests/_state_redirects.py index 39378e9..e03d78c 100644 --- a/diet_guard/tests/_state_redirects.py +++ b/diet_guard/tests/_state_redirects.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING from unittest.mock import patch import freedays +import sickdays if TYPE_CHECKING: from pathlib import Path @@ -28,6 +29,12 @@ def state_redirects(tmp_path: Path) -> list[_patch[object]]: "freedays._api.resolve_paths", lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"), ), + # Same for the sick-day pool: a real sick day on this machine must not + # silently stand the gate down for the whole suite. + patch( + "sickdays._api.resolve_paths", + lambda paths: paths or sickdays.Paths.under(tmp_path / "sickdays"), + ), patch( "diet_guard._budget.BUDGET_FILE", tmp_path / ".budget", diff --git a/diet_guard/tests/test_gate.py b/diet_guard/tests/test_gate.py index 7958f3d..eef8a18 100644 --- a/diet_guard/tests/test_gate.py +++ b/diet_guard/tests/test_gate.py @@ -6,13 +6,17 @@ here the logged set is mocked and ``now`` is injected to drive each decision. from __future__ import annotations -from datetime import UTC, date, datetime +from datetime import UTC, date, datetime, time, timedelta from typing import TYPE_CHECKING from unittest.mock import patch import freedays +import sickdays +from diet_guard._estimator import Nutrition from diet_guard._gate import due_slots, gate_is_due, gate_message +from diet_guard._state import log_meal, now_local +from diet_guard._state_today import logged_slots_today, today_total_kcal if TYPE_CHECKING: from pathlib import Path @@ -116,3 +120,55 @@ class TestFreeDays: (pool_dir / "free_days.json").write_text("{ not json", encoding="utf-8") with _logged(set()): assert gate_is_due(_at(20)) + + +def _mark_sick(tmp_path: Path, day: date) -> None: + """Put ``day`` in the (temp) sick-day pool; the redirect lives in conftest.""" + sickdays.add_sick_day( + day, + is_free=lambda _day: False, + paths=sickdays.Paths.under(tmp_path / "sickdays"), + now=day, + ) + + +class TestSickDays: + """The sick-day pool stands the gate down, like a free day.""" + + def test_a_sick_day_leaves_nothing_due(self, tmp_path: Path) -> None: + with _logged(set()): + assert due_slots(_at(20)), "precondition: slots are due without a sick day" + _mark_sick(tmp_path, _at(20).date()) + assert due_slots(_at(20)) == () + assert not gate_is_due(_at(20)) + assert gate_message(_at(20)) == "All meals are logged. You're up to date." + + def test_a_sick_day_on_another_date_changes_nothing(self, tmp_path: Path) -> None: + with _logged(set()): + _mark_sick(tmp_path, date(2026, 12, 24)) + assert due_slots(_at(20)) + assert gate_is_due(_at(20)) + + def test_the_day_after_a_sick_day_is_armed_again(self, tmp_path: Path) -> None: + _mark_sick(tmp_path, _at(20).date()) + tomorrow = _at(20) + timedelta(days=1) + with _logged(set()): + assert gate_is_due(tomorrow) + + def test_an_unreadable_pool_leaves_the_gate_armed(self, tmp_path: Path) -> None: + """Fail closed: a corrupt pool must not switch the gate off.""" + pool_dir = tmp_path / "sickdays" + pool_dir.mkdir(exist_ok=True) + (pool_dir / "sick_days.json").write_text("{ not json", encoding="utf-8") + with _logged(set()): + assert gate_is_due(_at(20)) + + def test_logging_still_works_on_a_sick_day(self, tmp_path: Path) -> None: + """The gate stands down; the log does not: a meal is still recorded.""" + today = now_local().date() + _mark_sick(tmp_path, today) + log_meal("soup", Nutrition(300, 10, 30, 5, 250, "manual"), slot=480) + assert logged_slots_today() == {480} + assert today_total_kcal() == 300 + late = datetime.combine(today, time(23, 0), tzinfo=now_local().tzinfo) + assert not gate_is_due(late) diff --git a/docs/DOCS-sick-days.md b/docs/DOCS-sick-days.md new file mode 100644 index 0000000..f6a8be8 --- /dev/null +++ b/docs/DOCS-sick-days.md @@ -0,0 +1,21 @@ +# Free days and sick days + +`_gate.due_slots` returns `()` on a shared free day (`freedays`) or a sick day +(`sickdays` 0.1.0, one pool for every gate). Nothing is due, so no lock opens, +nothing is said about missing meals and no debt accrues. Logging a meal works +as on any other day (the log path never consults the gate); diet-guard earns +nothing, so there is nothing to credit. + +- Each pool is one local file, never the network, so the systemd-fired gate + cannot hang on it. +- **Fail closed:** an unreadable pool reads as "not free" / "not sick", so the + gate stays armed. `sickdays.is_sick_day` never raises. +- Only the calendar day of `now` is asked (the last slot is at most 22:00, so + the gate never runs on yesterday's slots after midnight). +- Tests: `tests/_state_redirects.py` points both pools into `tmp_path`; a real + sick day on the dev machine must not stand the suite's gate down. +- **Phone gap:** the companion app does not read either pool. + `due_slot_check.dart` (`checkAndNotify`) still posts "log your meal" + notifications on a free or sick day. It would need `crdt_sync_dart` syncing + `sickdays-sync/devices/*/sick_days.json` (see the sickdays README) and an + early return before `_reconcileNotifications` that cancels pending ones. diff --git a/pyproject.toml b/pyproject.toml index a63aa28..5f9d08c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,8 @@ requires-python = ">=3.14" dependencies = [ "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.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.3.1#subdirectory=freedays", + "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", + "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays", "defusedxml==0.7.1", "requests==2.34.2", ] diff --git a/requirements.txt b/requirements.txt index eade505..fd4f243 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ codespell==2.4.3 coverage==7.16.2 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync defusedxml==0.7.1 -freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays +freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock mcp==2.3.0 mypy==2.4.0 @@ -20,3 +20,4 @@ pytest-xdist==3.8.0 pytest-xvfb==3.1.1 requests==2.34.2 ruff==0.17.0 +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.1.0#subdirectory=sickdays