|
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> |
||
|---|---|---|
| .. | ||
| freedays | ||
| .pre-commit-config.yaml | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
freedays
One shared pool of pre-declared days on which every gate app stands down.
Mark a day. Nothing locks, nothing rings, nothing asks you for a photo, and nothing mentions it afterwards.
For a gate app
The entire integration is one call:
import freedays
if freedays.is_free_day():
return # no lock, no dialog, no alarm -- and say nothing
is_free_day() reads one local JSON file. It never touches the network, so
it cannot block a systemd-fired gate, and it returns False for anything it
cannot parse — an unreadable pool means the normal rules apply, never that
everything is switched off.
For a person
$ freedays status
2026: 33 of 35 free days left
booked ahead: 2026-12-24
$ freedays mark 2026-12-24 --reason "travelling"
$ freedays mark today
$ freedays release 2026-12-24
$ freedays check tomorrow # exit 0 if free, 1 if not -- for scripts
$ freedays sync # pull and push now
The rules
| Budget | 35 per calendar year, resetting Jan 1. Unused days do not carry over. |
| One pool | A free day is free for every app at once. There is nothing per-app to mark, and nothing per-app to forget. |
| Declared, not backdated | Today or later. A past day cannot be marked, so a gate you already failed cannot be erased afterwards. |
| Consecutive is fine | Take a fortnight in a row if you want. Nothing here rate-limits you. |
| No reason required | --reason is stored if given and never asked for. |
| Releasing refunds only the future | Give back a day that has not arrived and it returns to the pool. Give back today, and it does not: those hours were already unguarded. |
| Silent | No notification, no low-budget warning, no prompt when a gate fires. You find out by asking. |
Free days are not sick days
screen-locker's sick days stay exactly where they are, with their rolling windows, their 120-character justifications and their escalating countdown. They are designed to be hard to take.
These are the opposite, and merging the two would have to ruin one of them: rolling windows forbid consecutive days, which is the entire point of a holiday. So the policies stay separate. Only the storage is shared.
How it syncs
Firebase RTDB, one directory per device under freedays-sync/devices/, the
same shape every other app in this fleet uses. No two devices write the same
node, so nothing can conflict; convergence is the CRDT layer's job.
Marking is a last-writer-wins flip of a state field rather than a create
and a delete, because crdt-sync tombstones are monotonic — deleting the
record would burn that date permanently, and a released day has to be
markable again.
consumed is a deliberately sticky flag: once a day has arrived while free
it stays spent, even if two devices disagree about what "today" is across a
midnight boundary.
Timezone
Local, everywhere, decided in _day.today() and nowhere else.
This package exists partly because the apps disagreed about it: screen-locker computed the day in UTC in its lock chain while the tool that wrote its skip file used local time, so a skip added near midnight could name a date the chain never matched. A free day is a human calendar day — if it is Tuesday where you are standing, it is Tuesday.
Development
$ pip install ../gatelock ../crdt-sync
$ pip install -r requirements.txt && pip install --no-deps -e .
$ python -m pytest # 100% branch coverage is enforced
$ pre-commit run --config .pre-commit-config.yaml --all-files