mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 14:07:46 +01:00
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
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>
70 lines
3.0 KiB
Python
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"
|