gate: stand down on a sick day (sickdays 0.1.0)

due_slots() returns nothing on a sick day, like a free day: no lock, no debt;
logging still works. Unreadable pool reads as not sick (fail closed). Pins
sickdays-v0.1.0 and freedays-v0.4.0 (pyproject, requirements, pylint hook env).
Phone gap documented in docs/DOCS-sick-days.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
This commit is contained in:
Krzysztof Rudnicki 2026-10-16 08:36:27 +02:00
parent f2606fe0ca
commit 419ada929a
9 changed files with 104 additions and 13 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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)

21
docs/DOCS-sick-days.md Normal file
View File

@ -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.

View File

@ -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",
]

View File

@ -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