utils/freedays/freedays/_constants.py
Krzysztof kuhy Rudnicki ef69619597
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
freedays pre-commit / pre-commit (push) Waiting to run
freedays tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Add freedays: one shared pool of days when every gate stands down
Five apps (diet-guard, wake-alarm, screen-locker, leetcode-guard,
home-guard) each enforce something daily, and none of them had a way to
say "not today". screen-locker was the only one with any notion of a day
off, and it had two overlapping ones: a budgeted sick day designed to be
hard to take, and an undocumented scheduled_skips.json that no other app
could see.

This is the second thing generalised, not the first. Free days are
pre-declared, carry no justification, allow consecutive runs, and are
capped only by an annual budget -- 35 per calendar year, resetting Jan 1.
Sick days stay in screen-locker exactly as they are: their rolling
windows (1/7d, 3/30d, 10/90d) exist precisely to forbid the consecutive
runs a holiday is made of, so merging the two policies would have had to
ruin one of them.

One global pool: a free day is free for every app at once, so there is
nothing per-app to mark and nothing per-app to forget.

The integration is one call, `freedays.is_free_day()`, which reads a
single local JSON file and never the network -- a gate fires from a
systemd timer and a hung sync must not be able to hold one open or shut.
It returns False for anything it cannot parse, so an unreadable pool
means the normal rules apply rather than everything switching off.

Two design points worth keeping:

- Un-marking flips a `state` field rather than tombstoning the record.
  crdt-sync deletes are monotonic, so a tombstoned date could never be
  marked free again.
- `consumed` is a sticky flag. Releasing a day that has not arrived
  refunds it; releasing today does not, because those hours were already
  unguarded. It is OR-ed against the stored value on every write so two
  devices either side of midnight cannot hand a spent day back.

Timezone is local, decided in _day.today() and nowhere else. The apps
disagreed about this before: screen-locker's lock chain computes the day
in UTC while add_scheduled_skip.py uses local time, so a skip added near
midnight could name a date the chain never matched.

94 tests, 100% branch coverage, no suppressions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:08:22 +02:00

70 lines
3.0 KiB
Python

"""On-disk locations, remote paths and the one policy number that matters.
Every path here is a *default*. Public functions take the corresponding
argument and only fall back to these when it is ``None``, so tests can point
the whole package at a tmp_path and a bug in this package can never write
into real user state. See ``freedays/tests/conftest.py``, which redirects
these as a second line of defence.
"""
from __future__ import annotations
from pathlib import Path
from typing import Final
APP_NAME: Final = "freedays"
#: How many free days a calendar year grants. Resets on Jan 1; unused days do
#: not carry over. 35 sits in the middle of the 30-40 band this was specced
#: against -- roughly a week off per quarter plus a scattering of single days.
DEFAULT_ANNUAL_BUDGET: Final = 35
STATE_DIR: Final = Path.home() / ".local" / "share" / "freedays"
#: The CRDT log itself: date -> Record(state=free|cleared).
LOG_PATH: Final = STATE_DIR / "free_days.json"
#: crdt-sync's per-device push/pull bookkeeping. Never merged, purely local.
SYNC_STATE_PATH: Final = STATE_DIR / "sync_state.json"
#: This install's persisted uuid. Never a fixed "pc"/"phone" constant, so two
#: machines can never collide on one device directory.
DEVICE_ID_PATH: Final = STATE_DIR / "device_id"
#: Append-only HMAC-signed record of every mark/unmark, for after-the-fact
#: review. Not consulted by any decision -- the log above is the truth.
AUDIT_PATH: Final = STATE_DIR / "audit.jsonl"
#: Signing key for the audit trail, generated on first use.
#:
#: User-owned, unlike screen-locker's root-owned key under /etc. That key
#: defends the sick-day budget *against the person using the machine*, so it
#: has to live somewhere they cannot rewrite. Free days are the opposite:
#: they are theirs to take, no justification, no gatekeeper. The signature
#: here only answers "was this line edited after the fact", for their own
#: review -- so a per-user key is the right strength, and it means signing
#: works without root instead of warning on every single mark.
HMAC_KEY_FILE: Final = STATE_DIR / "hmac.key"
#: Remote layout, mirroring every other app: one directory per device under
#: a prefix, each device writing only its own file.
SYNC_PATH_PREFIX: Final = "freedays-sync/devices"
SYNC_FILENAME: Final = "free_days.json"
#: Field names inside a Record.
FIELD_STATE: Final = "state"
FIELD_ACTOR: Final = "actor"
FIELD_REASON: Final = "reason"
#: Sticky "this day arrived while it was free, so it is spent". Releasing a
#: *future* day refunds it -- it never happened. Releasing today or a past
#: day does not: the gates already stood down. Nothing ever writes this back
#: to false, which is what makes it monotonic under last-writer-wins merges.
FIELD_CONSUMED: Final = "consumed"
#: Values of FIELD_STATE. Un-marking flips the field rather than tombstoning
#: the record: crdt-sync deletes are deliberately monotonic, so a tombstoned
#: date could never be marked free again.
STATE_FREE: Final = "free"
STATE_CLEARED: Final = "cleared"