utils/freedays
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
..
freedays Add freedays: one shared pool of days when every gate stands down 2026-09-07 15:08:22 +02:00
.pre-commit-config.yaml Add freedays: one shared pool of days when every gate stands down 2026-09-07 15:08:22 +02:00
pyproject.toml Add freedays: one shared pool of days when every gate stands down 2026-09-07 15:08:22 +02:00
README.md Add freedays: one shared pool of days when every gate stands down 2026-09-07 15:08:22 +02:00
requirements.txt Add freedays: one shared pool of days when every gate stands down 2026-09-07 15:08:22 +02:00

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