Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwtowrowDXx6ccmWVPkCaF
3.7 KiB
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
Or on the phone: the calendar icon in Wake Alarm Sync is the same pool
(~/src/wake-alarm/DOCS-free-days.md). A day marked there stands the phone's
own alarm down immediately; a day marked here reaches the phone at its next
app launch.
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 ../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