utils/freedays
Krzysztof kuhy Rudnicki aeac9caaa1
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
file length / file-length (push) Has been cancelled
freedays pre-commit / pre-commit (push) Has been cancelled
freedays tests / test (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
palette drift / palette (push) Has been cancelled
shared gate tests / gate unit tests (push) Has been cancelled
ts_core tests / test (push) Has been cancelled
web_ui tests / test (push) Has been cancelled
freedays tests satisfy ruff FBT001/DTZ011; bump @types/node 26.6.2 -> 26.6.3 (newest stable gate)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 20:08:06 +02:00
..
freedays freedays tests satisfy ruff FBT001/DTZ011; bump @types/node 26.6.2 -> 26.6.3 (newest stable gate) 2026-09-27 20:08:06 +02:00
systemd Re-point paths for the ~ reorganisation (home-tidy) 2026-09-11 18:45:55 +02:00
.pre-commit-config.yaml freedays: pylint --fail-under=10, scoped to source, on pylint 4.0.8 2026-09-12 19:32:20 +02:00
install.sh Drop the gatelock dependency, and actually sync the pool 2026-09-07 15:56:42 +02:00
pyproject.toml freedays 0.3.0: WORKDAYS (Tue/Wed/Thu) as the one weekly-workday definition for every gate app 2026-09-27 20:06:32 +02:00
README.md freedays 0.3.0: WORKDAYS (Tue/Wed/Thu) as the one weekly-workday definition for every gate app 2026-09-27 20:06:32 +02:00
requirements.txt Take today's stable bumps: pylint 4.0.9, ruff 0.16.9, vitest 5.0.2, jsdom 30.1.1, eslint-react 5.20.8, perfectionist 5.12.1, pnpm 12.6.0 2026-09-26 16:50: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

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.

Weekly workdays

freedays.WORKDAYS ({1, 2, 3}, Tue/Wed/Thu in date.weekday() numbering), NON_WORKDAYS and is_workday(day=None) are the one definition of the weekly workdays every gate app reads — screen-locker's relaxed days, leetcode-guard's cheap days, wake-alarm's ramp hold and workday stick. Import it; never write the set out again. Dart/Kotlin code that cannot import it keeps a literal copy guarded by a drift test against this package.

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