utils/freedays/README.md
Krzysztof kuhy Rudnicki aa032c95e4 Drop the gatelock dependency, and actually sync the pool
Two things the pool needed to be real rather than merely correct.

**freedays no longer depends on gatelock.** It imported
gatelock.log_integrity for ~15 lines of HMAC, but importing gatelock runs
its __init__, which imports tkinter for the lock-window machinery. So a
headless policy library had acquired a hard dependency on a GUI toolkit,
and `import freedays` failed anywhere without Tk:

    ImportError: libtk8.6.so: cannot open shared object file

A vmbox run caught it. The host masked it completely -- Tk is installed
here for the lockers -- and so did CI, whose own comment claimed "no
display needed" while the job would have needed libtk. freedays/_hmac.py
now does the signing with hashlib and hmac.

The format is deliberately unchanged (HMAC-SHA256 over compact
sorted-key JSON), so nothing has to be re-signed: all 8 entries already
in the real audit trail, written by gatelock's implementation, verify
against the new one. That was checked, not assumed.

This also dissolves the version diamond that shaped the app rollout:
freedays now pins only crdt-sync, so it can never conflict with whatever
gatelock tag an app carries.

**The pool now syncs on a schedule.** A `sync-quiet` subcommand plus a
systemd user timer every 15 minutes, because "it syncs" was otherwise
only true if you typed `freedays sync` yourself -- a day marked on the
phone would sit there. sync-quiet exits 0 when there is no network or no
credential, on purpose: a unit that goes red every time the laptop is
offline is a unit you learn to ignore, and a real bug still reaches the
journal through the library's logging.

install.sh gives freedays its own venv rather than borrowing an app's --
the pool is shared by five apps, so binding its CLI to one of them would
make "is freedays installed?" depend on something unrelated, and
uninstalling that app would take the command with it.

Verified in vmbox first (systemd), then on this host: the timer is
enabled and scheduled, and the service *finishes* rather than failing
when there is no remote.

seed_session.py gains freedays, without which the one command that can
give the pool a Firebase session would reject it outright (--app is
choices-validated) and the pool would stay local-only with nothing
saying why. That file was already exactly at the 250-line cap, so the
app list and its rationale move to tool/_seeded_apps.py.

freedays: 109 tests, 100% branch coverage. crdt-sync: 433 pass.

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

3.5 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

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