sickdays 0.3.0: desktop sick-day picker and embeddable KeepPanel

`sickdays gui` opens a Tk window modelled on the phone's Sick days screen:
a Monday-first month calendar (cat-2 fill, a dot for kept lockers, past
days faded and inert), a five-switch panel for the clicked day, and the
"Marked sick" list with "Kept: ..." lines. A free day is refused with the
phone's wording. Each write starts sync_quietly() on a non-daemon thread.
The window class is "sickdays" and Escape closes it.

sickdays.gui.KeepPanel is the panel on its own, a plain tk.Frame with no
root, mainloop, grab or wm_* call, so screen-locker can embed it under its
lock-screen grab. `import sickdays` and the rest of the CLI never import
tkinter: the CLI loads the gui module by name only for `gui`, and a
subprocess test checks this.

The tests drive real Tk on a withdrawn root, and CI now runs them under
xvfb-run, as gatelock's CI does. Adds a sickdays.desktop launcher, which
install.sh installs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
This commit is contained in:
Krzysztof Rudnicki 2026-10-17 13:47:26 +02:00
parent 0a51c9998d
commit bd64b27109
19 changed files with 1406 additions and 6 deletions

View File

@ -38,7 +38,12 @@ jobs:
- name: Install dependencies
run: pip install -r requirements.txt && pip install --no-deps -e .
# No display needed: sickdays draws nothing. It is policy, a JSON file
# and a CLI.
# The library draws nothing, but sickdays.gui (0.3.0) does, and its
# tests drive real Tk: option names and Checkbutton/variable wiring are
# Tk's own behaviour. Headless they skip, taking coverage with them --
# so the runner gets a virtual display, as gatelock's does.
- name: Install a virtual X display
run: sudo apt-get update && sudo apt-get install -y xvfb
- name: Run tests with coverage
run: python -m pytest -v
run: xvfb-run -a -s "-screen 0 1600x1200x24" python -m pytest -v

View File

@ -67,6 +67,26 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
`2` input not understood (a bad date, or a locker id not in `LOCKERS` --
`--keep`, `keep` and `--locker` only accept the five ids).
## The picker window (0.3.0)
`sickdays gui` (or the "Sick days" launcher): the phone's Sick days screen on
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
inert; a free day is refused; each write starts `sync_quietly()` on a
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
just the panel (`import sickdays` never imports tkinter):
```python
from sickdays.gui import KeepPanel # a plain tk.Frame: no root, grab or wm_* call
KeepPanel(parent, day, *, on_saved, on_cancel=None, paths=None).pack()
```
Normal day: five switches, all off (off = stands down), "Mark sick"; sick
day: its switches, "Save", "Clear sick day". Past/free/uncheckable days show
why and offer no write; a refused write shows in the panel, never raised.
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
on an unchanged Save (nothing written). The panel never destroys itself.
## API
| call | does |
@ -216,7 +236,7 @@ Don't hand-roll this on Dart: use `crdt_sync_dart` (`syncLog`, `logToJson`
## Install
```console
$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, 15-min sync timer
$ ./install.sh # own venv (crdt-sync + freedays + sickdays), CLI on PATH, launcher, 15-min sync timer
$ ./install.sh --no-timer
```
@ -225,6 +245,6 @@ $ ./install.sh --no-timer
```console
$ pip install ../crdt-sync && pip install --no-deps ../freedays
$ pip install -r requirements.txt && pip install --no-deps -e .
$ python -m pytest # 100% branch coverage is enforced
$ xvfb-run -a python -m pytest # 100% branch coverage; the GUI tests need a display
$ pre-commit run --config sickdays/.pre-commit-config.yaml --all-files # from the repo root
```

View File

@ -20,6 +20,7 @@ readonly STATE_DIR="${HOME}/.local/share/sickdays"
readonly VENV_DIR="${STATE_DIR}/venv"
readonly BIN_DIR="${HOME}/.local/bin"
readonly UNIT_DIR="${HOME}/.config/systemd/user"
readonly APPS_DIR="${HOME}/.local/share/applications"
readonly SYSTEM_PYTHON="/usr/bin/python3"
log() { printf 'install: %s\n' "$1" >&2; }
@ -57,6 +58,16 @@ link_cli() {
ln -sf "$VENV_DIR/bin/sickdays" "$BIN_DIR/sickdays"
}
install_launcher() {
# Exec is the bare command, so the launcher follows the $BIN_DIR symlink.
log "installing the 'Sick days' launcher into $APPS_DIR"
mkdir -p "$APPS_DIR"
install -m 644 "$REPO_DIR/sickdays.desktop" "$APPS_DIR/"
if command -v update-desktop-database >/dev/null; then
update-desktop-database "$APPS_DIR" || log "NOTE: update-desktop-database failed"
fi
}
install_timer() {
log "installing the sync timer"
mkdir -p "$UNIT_DIR"
@ -73,6 +84,8 @@ verify() {
"$BIN_DIR/sickdays" list >/dev/null || fail "the sickdays CLI does not run"
"$VENV_DIR/bin/python" -c "import freedays" \
|| fail "freedays is missing from the venv; adding a sick day would refuse"
"$VENV_DIR/bin/python" -c "import tkinter" \
|| fail "the venv's python has no tkinter; 'sickdays gui' would not open"
"$SYSTEM_PYTHON" -c "import sickdays" 2>/dev/null || {
log "NOTE: the system python cannot import sickdays yet."
log " The gate apps need it there; their own install.sh does that."
@ -86,6 +99,7 @@ verify() {
main() {
build_venv
link_cli
install_launcher
if [[ "$INSTALL_TIMER" -eq 1 ]]; then
install_timer
fi

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "sickdays"
version = "0.2.0"
version = "0.3.0"
description = "Shared sick-day pool: per-date, uncapped, synced; every gate respects it"
requires-python = ">=3.14"
# Deliberately NOT gatelock (its __init__ imports tkinter); the HMAC lives in

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Sick days
Comment=Mark sick days and choose which lockers still apply
Exec=sickdays gui
Icon=face-sick
Terminal=false
Categories=Utility;
StartupWMClass=sickdays

View File

@ -9,6 +9,10 @@ A sick day stands every locker down unless it *keeps* some (``--keep`` on
``add``, or ``keep`` afterwards). Locker ids are argparse ``choices``, so a
typo exits 2 instead of silently keeping nothing.
``gui`` opens the Tk picker. It is imported only when asked for, so every
other subcommand -- and every gate importing the library -- stays free of
tkinter.
``sync-quiet`` is what the timer runs: it reports success even with no
network, because a unit that goes red whenever the laptop is offline is a
unit you learn to ignore.
@ -17,6 +21,7 @@ unit you learn to ignore.
from __future__ import annotations
import argparse
import importlib
import sys
from typing import TYPE_CHECKING
@ -91,6 +96,7 @@ def _build_parser() -> argparse.ArgumentParser:
metavar=_LOCKER_METAVAR,
help="instead: exit 0 iff this locker stands down on the day, 1 if not",
)
sub.add_parser("gui", help="open the sick-day picker window")
sub.add_parser("sync", help="pull and push the pool now")
sub.add_parser(
"sync-quiet",
@ -143,6 +149,12 @@ def _do_check(raw_day: str, locker: str | None) -> int:
return _EXIT_OK if sick else _EXIT_REFUSED
def _do_gui() -> int:
# By name, at call time: the library and the rest of the CLI never load Tk.
gui = importlib.import_module("sickdays.gui")
return int(gui.run())
def _do_sync() -> int:
sync()
emit("pool synced")
@ -160,6 +172,7 @@ _HANDLERS: dict[str, Callable[[argparse.Namespace], int]] = {
"remove": lambda args: _do_remove(args.day),
"list": lambda _args: _do_list(),
"check": lambda args: _do_check(args.day, args.locker),
"gui": lambda _args: _do_gui(),
"sync": lambda _args: _do_sync(),
"sync-quiet": lambda _args: _do_sync_quiet(),
}

View File

@ -0,0 +1,24 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The Tk sick-day picker, and the switch panel other apps embed.
Kept out of :mod:`sickdays` on purpose: every headless gate imports
``sickdays`` once a minute, and none of them may pull in tkinter. Import
this module only from the ``sickdays gui`` command or an app that already
draws with Tk::
from sickdays.gui import KeepPanel
panel = KeepPanel(surface, today, on_saved=lambda entry: panel.destroy())
panel.pack()
:class:`KeepPanel` is a plain ``tk.Frame`` (no root, no mainloop, no grab),
so it works in-process under another window's X grab. See its docstring for
what ``on_saved`` receives. :func:`run` opens the whole window.
"""
from __future__ import annotations
from sickdays.gui._panel import KeepPanel
from sickdays.gui._window import SickDaysApp, run
__all__ = ["KeepPanel", "SickDaysApp", "run"]

View File

@ -0,0 +1,130 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The month grid: Monday-first, one button per day, prev/next month arrows.
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
days keep their mark but faded and inert. Today stays clickable -- a sick
day can be marked or cleared for today. Deciding what a click *means* stays
with the window; this only reports the date.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._day import to_iso
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import date
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
"""(fill, text) for one day, faded when past, as ``DayCell`` draws it."""
if sick:
fill = tok.blend(tok.SICK, tok.INK, tok.PAST_ALPHA) if past else tok.SICK
return fill, tok.ON_FILL
fill = tok.INK_RAISED_1 if workday else tok.INK
text = (
tok.blend(tok.TEXT_ON_DARK, fill, tok.PAST_ALPHA) if past else tok.TEXT_ON_DARK
)
return fill, text
class MonthCalendar(tk.Frame):
"""One month of one pool; :meth:`redraw` repaints it from the callbacks."""
def __init__(
self,
parent: tk.Misc,
*,
today: date,
is_sick: Callable[[date], bool],
has_dot: Callable[[date], bool],
on_day: Callable[[date], None],
) -> None:
"""Show the month holding ``today``."""
super().__init__(parent, bg=parent.cget("bg"))
self._today = today
self._is_sick = is_sick
self._has_dot = has_dot
self._on_day = on_day
self._first, self._last = _text.month_bounds(today)
self.month = today.replace(day=1)
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
self._header.pack(fill="x")
self._grid = tk.Frame(self, name="days", bg=self.cget("bg"))
self._grid.pack(pady=(tok.XS, 0))
self.redraw()
def step(self, months: int) -> None:
"""Move ``months`` months (within the phone's range) and redraw."""
target = _text.shift_month(self.month, months)
if self._first <= target <= self._last:
self.month = target
self.redraw()
def redraw(self) -> None:
"""Rebuild the header and every day from the current pool."""
for frame in (self._header, self._grid):
for child in frame.winfo_children():
child.destroy()
self._draw_header()
for column, initial in enumerate(_text.WEEKDAY_INITIALS):
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
row=0, column=column
)
for row, week in enumerate(_text.month_weeks(self.month), start=1):
for column, day in enumerate(week):
if day is not None:
self._cell(day).grid(
row=row, column=column, padx=tok.XS // 2, pady=tok.XS // 2
)
def _draw_header(self) -> None:
back = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
back.configure(state="normal" if self.month > self._first else "disabled")
forward.configure(state="normal" if self.month < self._last else "disabled")
back.pack(side="left")
forward.pack(side="right")
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
side="top", pady=tok.XS
)
def _cell(self, day: date) -> tk.Button:
sick = self._is_sick(day)
past = day < self._today
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
cell = tk.Button(
self._grid,
name=f"day-{to_iso(day)}",
text=str(day.day),
command=lambda: self._on_day(day),
state="disabled" if past else "normal",
bg=fill,
fg=text,
activebackground=fill,
activeforeground=text,
disabledforeground=text,
font=tok.font(ui.family(self), "body", bold=sick),
width=3,
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=tok.SM,
pady=tok.SM,
cursor="arrow" if past else "hand2",
)
if sick and self._has_dot(day):
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
return cell

View File

@ -0,0 +1,181 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the five switches for one day, and the buttons that write them.
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
so screen-locker can drop it onto its fullscreen surface in-process, under
its own X grab (a separate window could not get input there).
On is "this locker still applies", off is "stands down", all off by default
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
day, or a free-day pool that cannot be checked shows why and offers no
write. A refused write is shown in the panel, never raised: an exception in a
Tk callback only reaches stderr, which nobody sees under a lock.
"""
from __future__ import annotations
import threading
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
from sickdays._constants import LOCKERS
from sickdays._day import today
from sickdays._errors import SickDayError
from sickdays._keep_api import kept_lockers, set_kept_lockers
from sickdays._paths import resolve_paths
from sickdays._sync import sync_quietly
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
if TYPE_CHECKING:
from collections.abc import Callable
from datetime import date
from sickdays._model import SickDay
from sickdays._paths import Paths
def start_sync(paths: Paths) -> threading.Thread:
"""Push the write in the background so the phone sees it; never raises.
Not a daemon: a window closed right after "Mark sick" must not kill the
push half-way. The thread touches no widget.
"""
thread = threading.Thread(
target=sync_quietly,
kwargs={"paths": paths},
name="sickdays-sync",
daemon=False,
)
thread.start()
return thread
class KeepPanel(tk.Frame):
"""Mark ``day`` sick with some lockers kept, or edit / clear a sick day.
``on_saved`` is called once per successful click, after the write and
after the background sync has started:
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
* "Save" with a changed set -> the stored entry (``is_sick=True``);
* "Save" with nothing changed -> ``None``: nothing written, no sync;
* "Clear sick day" -> the cleared entry (``is_sick=False``).
A refused write (:class:`~sickdays.SickDayError`) is shown in the panel
and ``on_saved`` is not called. ``on_cancel``, when given, adds a
"Cancel" button that calls it. The panel never destroys itself; the
callbacks decide what happens to it.
"""
def __init__(
self,
parent: tk.Misc,
day: date,
*,
on_saved: Callable[[SickDay | None], None],
on_cancel: Callable[[], None] | None = None,
paths: Paths | None = None,
) -> None:
"""Read ``day``'s current state from the pool and draw the panel."""
super().__init__(parent, bg=tok.INK_RAISED_1, padx=tok.MD, pady=tok.MD)
self._day = day
self._paths = resolve_paths(paths)
self._on_saved = on_saved
self._sick = is_sick_day(day, log_path=self._paths.log)
self._kept = kept_lockers(day, log_path=self._paths.log)
self._vars = {
locker: tk.BooleanVar(self, value=locker in self._kept)
for locker in LOCKERS
}
#: The last background sync, for callers (and tests) that wait on it.
self.sync_thread: threading.Thread | None = None
blocked = self._blocked_reason()
self._draw_switches(editable=blocked is None)
self._status = ui.label(
self, blocked or "", "label", fg=tok.DANGER, name="status"
)
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
def kept(self) -> frozenset[str]:
"""The lockers currently switched on."""
return frozenset(locker for locker, var in self._vars.items() if var.get())
def _blocked_reason(self) -> str | None:
if self._day < today():
return _text.PAST_NOTE
return None if self._sick else _text.free_day_refusal(self._day)
def _draw_switches(self, *, editable: bool) -> None:
title = _text.panel_title(self._day, sick=self._sick)
ui.label(self, title, "subtitle", name="title").grid(
row=0, column=0, columnspan=2, sticky="w"
)
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
)
for row, locker in enumerate(LOCKERS, start=2):
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
row=row, column=0, sticky="w", pady=tok.XS
)
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
if not editable:
switch.configure(state="disabled")
def _draw_actions(
self, *, writable: bool, on_cancel: Callable[[], None] | None
) -> None:
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
actions.grid(
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
)
buttons: list[tk.Button] = []
if writable and self._sick:
buttons.append(
ui.button(actions, "Save", self._save, kind="primary", name="save")
)
buttons.append(
ui.button(
actions, "Clear sick day", self._clear, kind="danger", name="clear"
)
)
elif writable:
buttons.append(
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
)
if on_cancel is not None:
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
for button in buttons:
button.pack(side="left", padx=(0, tok.SM))
def _mark(self) -> None:
self._write(
lambda: add_sick_day(self._day, keep=self.kept(), paths=self._paths)
)
def _save(self) -> None:
keep = self.kept()
if keep == self._kept:
# A fresh tick on an unchanged set would only churn the sync.
self._on_saved(None)
return
self._write(lambda: set_kept_lockers(self._day, keep, paths=self._paths))
def _clear(self) -> None:
self._write(lambda: remove_sick_day(self._day, paths=self._paths))
def _write(self, action: Callable[[], SickDay]) -> None:
try:
entry = action()
except SickDayError as exc:
self._status.configure(text=str(exc), fg=tok.DANGER)
return
self.sync_thread = start_sync(self._paths)
# Last: the callback may destroy this panel.
self._on_saved(entry)

View File

@ -0,0 +1,122 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Wording and calendar arithmetic for the picker -- no Tk in here.
Every string is the phone's (``wake-alarm/phone_app/lib/screens/
sick_day_sheet.dart`` and ``sick_days_body.dart``), so the two pickers read
the same. Kept here, apart from the widgets, so it is testable without a
display.
"""
from __future__ import annotations
import calendar
from datetime import date
from typing import TYPE_CHECKING, Final
from sickdays._conflict import free_day_predicate
from sickdays._constants import (
LOCKER_ALARM,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_LEETCODE,
LOCKER_WORKOUT,
)
from sickdays._day import to_iso
from sickdays._errors import ConflictCheckUnavailableError
if TYPE_CHECKING:
from collections.abc import Iterable
#: What each locker is called on screen, in ``sickdays.LOCKERS`` order.
LOCKER_LABELS: Final = {
LOCKER_WORKOUT: "Workout lock",
LOCKER_LEETCODE: "LeetCode",
LOCKER_BOOK: "Book",
LOCKER_DIET: "Diet",
LOCKER_ALARM: "Wake alarm",
}
SHEET_HELP: Final = (
"Off = stands down for the day. Switch on any locker that should still "
"apply; it then behaves exactly as on a normal day."
)
INTRO: Final = (
"Click a day to mark it sick and choose which lockers still apply; click "
"a sick day to change them or clear it. Today or later only. No limit and "
"no reason needed."
)
INTRO_DETAIL: Final = (
"On a sick day every locker left off stands down: no alarm, no lock, and "
"work left undone is dropped rather than carried forward. A locker "
"switched on behaves as on a normal day; a dot marks a sick day that "
"keeps one."
)
PAST_NOTE: Final = "This day has passed; it can no longer be changed."
NONE_AHEAD: Final = "None ahead."
WEEKDAY_INITIALS: Final = ("M", "T", "W", "T", "F", "S", "S")
_WEEKDAYS: Final = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
_WORKDAYS: Final = frozenset({1, 2, 3})
def day_label(day: date) -> str:
"""``"Tue 2026-10-20"``."""
return f"{_WEEKDAYS[day.weekday()]} {to_iso(day)}"
def kept_label(kept: Iterable[str]) -> str:
"""The kept lockers as one line, in :data:`LOCKER_LABELS` order."""
chosen = set(kept)
return ", ".join(label for key, label in LOCKER_LABELS.items() if key in chosen)
def panel_title(day: date, *, sick: bool) -> str:
"""``"Mark sick: Tue 2026-10-20"``, or ``"Sick day: ..."`` when editing."""
return f"{'Sick day' if sick else 'Mark sick'}: {day_label(day)}"
def free_day_refusal(day: date) -> str | None:
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
"Could not check" refuses too, with the library's own message: it must
never count as "not a free day".
"""
try:
is_free = free_day_predicate()
except ConflictCheckUnavailableError as exc:
return str(exc)
if not is_free(day):
return None
return f"{to_iso(day)} is a free day — release it in Free days first."
def is_workday(day: date) -> bool:
"""Whether ``day`` is a weekly workday (Tue/Wed/Thu), shaded on the grid."""
return day.weekday() in _WORKDAYS
def month_title(month: date) -> str:
"""``"October 2026"``."""
return f"{calendar.month_name[month.month]} {month.year}"
def month_weeks(month: date) -> list[list[date | None]]:
"""The month as Monday-first weeks, ``None`` padding either end."""
return [
[None if not cell else date(month.year, month.month, cell) for cell in week]
for week in calendar.Calendar(firstweekday=0).monthdayscalendar(
month.year, month.month
)
]
def shift_month(month: date, step: int) -> date:
"""The first of the month ``step`` months after ``month``."""
index = month.year * 12 + month.month - 1 + step
return date(index // 12, index % 12 + 1, 1)
def month_bounds(reference: date) -> tuple[date, date]:
"""January this year to December next year, the phone's range."""
return date(reference.year, 1, 1), date(reference.year + 1, 12, 1)

View File

@ -0,0 +1,70 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Design tokens for the Tk picker, copied from the unified design system.
Source: ``~/src/utils/unified-design-system/DOCS-tokens.md`` -- the same
values as daily-limits' ``_tokens.py`` and gatelock's ``LockPalette``.
gatelock is not imported: its ``__init__`` pulls in the lock machinery, and
this package must stay importable by every headless gate. The hexes are the
frozen shared palette, so a copy cannot drift unnoticed.
A sick day is filled ``cat-2``, as on the phone (``DayMarkColors.sick``):
the categorical ramp leaves the warm wedge, so a sick day can never be read
as the free days' accent or as success/warning/danger.
Font sizes are **pixels**, so :func:`font` returns Tk's negative form (a
positive size means points and renders about a third larger).
"""
from __future__ import annotations
from typing import Final, Literal
# Neutrals (dark theme) + semantic roles.
INK: Final = "#211D1B"
INK_RAISED_1: Final = "#2B2624"
INK_RAISED_2: Final = "#38312E"
LINE_DARK: Final = "#463E3A"
TEXT_ON_DARK: Final = "#ECEAE9"
MUTED_ON_DARK: Final = "#AAA09A"
ACCENT: Final = "#B8862E"
SUCCESS: Final = "#8A9A3C"
DANGER: Final = "#E2585F"
ON_FILL: Final = INK
#: ``cat-2``: a sick day's fill.
SICK: Final = "#398FC0"
#: The phone fades a past day to 38 % opacity; Tk has no alpha, so blend.
PAST_ALPHA: Final = 0.38
# Spacing scale (4px base).
XS: Final = 4
SM: Final = 8
MD: Final = 16
LG: Final = 24
#: Width, in px, that wrapped prose lines break at.
WRAP: Final = 420
TypeRole = Literal["title", "subtitle", "body", "label", "caption"]
_TYPE_PX: Final[dict[TypeRole, int]] = {
"title": 24,
"subtitle": 20,
"body": 16,
"label": 14,
"caption": 12,
}
def font(family: str, role: TypeRole, *, bold: bool = False) -> tuple[str, int, str]:
"""A Tk font tuple for ``role`` in ``family``, sized in pixels (negative)."""
return (family, -_TYPE_PX[role], "bold" if bold else "normal")
def blend(colour: str, onto: str, alpha: float) -> str:
"""``colour`` at ``alpha`` opacity over ``onto``, as an opaque ``#RRGGBB``."""
top = [int(colour[i : i + 2], 16) for i in (1, 3, 5)]
under = [int(onto[i : i + 2], 16) for i in (1, 3, 5)]
mixed = (
round(t * alpha + u * (1 - alpha)) for t, u in zip(top, under, strict=True)
)
return "#" + "".join(f"{channel:02X}" for channel in mixed)

View File

@ -0,0 +1,125 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Token-styled Tk building blocks shared by the panel and the window.
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
filled control is picked from its fill (``on-fill`` on the accent, never the
near-white body text), the bug the design system's README warns about.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import font as tkfont
from typing import TYPE_CHECKING, Final, Literal
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
from collections.abc import Callable
ButtonKind = Literal["primary", "danger", "plain"]
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
#: (background, foreground) per button kind. Danger is a text button, as on
#: the phone, so its red sits on the dark surface rather than under ink.
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
"primary": (tok.ACCENT, tok.ON_FILL),
"danger": (tok.INK_RAISED_2, tok.DANGER),
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
}
def family(widget: tk.Misc) -> str:
"""The family Tk's default font actually resolves to on this display."""
return str(tkfont.nametofont("TkDefaultFont", root=widget).actual("family"))
def label(
parent: tk.Misc,
text: str,
role: tok.TypeRole,
*,
fg: str = tok.TEXT_ON_DARK,
name: str = "",
) -> tk.Label:
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
Titles and subtitles are bold, everything else regular.
"""
return tk.Label(
parent,
name=name,
text=text,
fg=fg,
bg=parent.cget("bg"),
font=tok.font(family(parent), role, bold=role in _BOLD_ROLES),
justify="left",
anchor="w",
wraplength=tok.WRAP,
)
def button(
parent: tk.Misc,
text: str,
command: Callable[[], object],
*,
kind: ButtonKind = "plain",
name: str = "",
) -> tk.Button:
"""A flat button whose text colour follows its fill."""
bg, fg = _BUTTON_COLOURS[kind]
return tk.Button(
parent,
name=name,
text=text,
command=command,
bg=bg,
fg=fg,
activebackground=bg,
activeforeground=fg,
font=tok.font(family(parent), "body", bold=kind == "primary"),
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=tok.MD,
pady=tok.SM,
cursor="hand2",
)
def paint_toggle(switch: tk.Checkbutton, *, on: bool) -> None:
"""Draw ``switch`` as a toggle: accent fill reading "On", or a dark "Off"."""
bg, fg = (tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.TEXT_ON_DARK)
switch.configure(
text="On" if on else "Off",
bg=bg,
selectcolor=bg,
activebackground=bg,
fg=fg,
activeforeground=fg,
)
def toggle(parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> tk.Checkbutton:
"""A Checkbutton drawn as an On/Off switch, repainted on every change."""
switch = tk.Checkbutton(
parent,
name=name,
variable=variable,
onvalue=True,
offvalue=False,
indicatoron=False,
width=4,
font=tok.font(family(parent), "label", bold=True),
relief="flat",
borderwidth=0,
highlightthickness=0,
padx=tok.SM,
pady=tok.XS,
disabledforeground=tok.MUTED_ON_DARK,
cursor="hand2",
)
switch.configure(command=lambda: paint_toggle(switch, on=variable.get()))
paint_toggle(switch, on=variable.get())
return switch

View File

@ -0,0 +1,147 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""``sickdays gui``: the desktop picker, the phone's Sick days screen on Tk.
The calendar on the left, :class:`~sickdays.gui.KeepPanel` on the right for
the clicked day, the sick days still ahead underneath. The window class is
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
``for_window`` rule can float it; ``Escape`` closes it.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._api import is_sick_day, load
from sickdays._day import today
from sickdays._keep_api import kept_lockers
from sickdays._model import sick_entries
from sickdays._paths import resolve_paths
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
from sickdays.gui._calendar import MonthCalendar
from sickdays.gui._panel import KeepPanel
if TYPE_CHECKING:
from datetime import date
from sickdays._model import SickDay
from sickdays._paths import Paths
WINDOW_CLASS = "sickdays"
def saved_message(entry: SickDay | None, day: date) -> str:
"""The status line after the panel closed with ``entry``."""
label = _text.day_label(day)
if entry is None:
return f"{label}: nothing changed"
if not entry.is_sick:
return f"{label} is a normal day again"
kept = _text.kept_label(entry.keep)
return f"{label} is a sick day — " + (
f"kept: {kept}" if kept else "every locker stands down"
)
class SickDaysApp:
"""The whole window, drawn onto ``root``."""
def __init__(self, root: tk.Tk, *, paths: Paths | None = None) -> None:
"""Style ``root`` and draw the current pool."""
self._root = root
self._paths = resolve_paths(paths)
root.title("Sick days")
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
root.bind("<Escape>", self.close)
left = tk.Frame(root, bg=tok.INK)
left.grid(row=0, column=0, sticky="n")
#: Where the clicked day's panel goes.
self.host = tk.Frame(root, name="host", bg=tok.INK)
self.host.grid(row=0, column=1, sticky="n", padx=(tok.LG, 0))
ui.label(left, "Sick days", "title").pack(anchor="w")
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
anchor="w", pady=(tok.SM, 0)
)
self.status = ui.label(left, "", "label", name="status")
self.status.pack(anchor="w", pady=(tok.SM, 0))
log = self._paths.log
self.calendar = MonthCalendar(
left,
today=today(),
is_sick=lambda day: is_sick_day(day, log_path=log),
has_dot=lambda day: bool(kept_lockers(day, log_path=log)),
on_day=self.select,
)
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
ui.label(left, "Marked sick", "subtitle").pack(
anchor="w", pady=(tok.LG, tok.SM)
)
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
self._list.pack(anchor="w", fill="x")
self._draw_upcoming()
def close(self, _event: object = None) -> None:
"""Close the window (bound to ``Escape``)."""
self._root.destroy()
def select(self, day: date) -> None:
"""Open the panel for ``day``, or refuse a free day in the status line."""
self._close_panel()
if not is_sick_day(day, log_path=self._paths.log):
refusal = _text.free_day_refusal(day)
if refusal is not None:
self._set_status(refusal, failed=True)
return
self._set_status("", failed=False)
KeepPanel(
self.host,
day,
on_saved=lambda entry: self._saved(entry, day),
on_cancel=self._close_panel,
paths=self._paths,
).pack(anchor="n")
def _saved(self, entry: SickDay | None, day: date) -> None:
self._close_panel()
self._set_status(saved_message(entry, day), failed=False)
self.calendar.redraw()
self._draw_upcoming()
def _close_panel(self) -> None:
for child in self.host.winfo_children():
child.destroy()
def _set_status(self, text: str, *, failed: bool) -> None:
self.status.configure(text=text, fg=tok.DANGER if failed else tok.SUCCESS)
def _draw_upcoming(self) -> None:
for child in self._list.winfo_children():
child.destroy()
reference = today()
upcoming = [
entry
for entry in sick_entries(load(self._paths.log))
if entry.day >= reference
]
if not upcoming:
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
anchor="w"
)
for entry in upcoming:
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
kept = _text.kept_label(entry.keep)
if kept:
ui.label(
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
).pack(anchor="w", pady=(0, tok.XS))
def run(*, paths: Paths | None = None) -> int:
"""Open the window and block until it is closed."""
root = tk.Tk(className=WINDOW_CLASS)
SickDaysApp(root, paths=paths)
root.mainloop()
return 0

View File

@ -5,6 +5,9 @@ Every writer resolves its paths through :func:`sickdays._paths.resolve_paths`,
so redirecting that one function in each module that imports it moves all of
them, including any file added later.
The GUI modules are in the list too, and their background sync is stubbed:
a test thread must never find the real crdt-sync credential.
The cross-pool check would otherwise import the real freedays and read the
real ``~/.local/share/freedays`` pool, so it is stubbed to "nothing is free"
for every test; tests of the check itself pass their own predicate.
@ -19,8 +22,10 @@ import pytest
from sickdays._paths import Paths
if TYPE_CHECKING:
from collections.abc import Iterator
from datetime import date
from pathlib import Path
import tkinter as tk
_MODULES = (
"sickdays._api",
@ -28,6 +33,8 @@ _MODULES = (
"sickdays._conflict",
"sickdays._keep_api",
"sickdays._sync",
"sickdays.gui._panel",
"sickdays.gui._window",
)
@ -35,6 +42,12 @@ def _never_free(_day: date) -> bool:
return False
def _no_sync(*, paths: Paths | None = None) -> bool:
"""The GUI's background sync, stubbed: no test may reach Firebase."""
del paths
return False
@pytest.fixture(autouse=True)
def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Point every default path at ``tmp_path`` before any test runs."""
@ -48,9 +61,29 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
for module in _MODULES:
monkeypatch.setattr(f"{module}.resolve_paths", _resolve)
monkeypatch.setattr("sickdays._api.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)
@pytest.fixture
def state_dir(tmp_path: Path) -> Path:
"""The redirected state directory, for tests that assert on files."""
return tmp_path / "state"
@pytest.fixture
def tk_root() -> Iterator[tk.Tk]:
"""A real, never-mapped Tk root; skips when there is no X display.
Real Tk rather than a fake: option names and Checkbutton/variable wiring
are Tk's own behaviour. CI runs the suite under ``xvfb-run``.
"""
import tkinter as tk
try:
root = tk.Tk(className="sickdays-test")
except tk.TclError as exc:
pytest.skip(f"no X display ({exc}); run the suite under xvfb-run")
root.withdraw()
yield root
root.destroy()

View File

@ -5,6 +5,9 @@ from __future__ import annotations
from datetime import timedelta
import runpy
import subprocess
import sys
import types
import pytest
@ -168,3 +171,28 @@ def test_check_with_a_locker_on_a_normal_day_exits_one(
) -> None:
assert main(["check", "--locker", "alarm"]) == 1
assert "alarm applies" in capsys.readouterr().out
def test_gui_opens_the_window_through_a_lazy_import(
monkeypatch: pytest.MonkeyPatch,
) -> None:
opened: list[bool] = []
fake = types.ModuleType("sickdays.gui")
def run() -> int:
opened.append(True)
return 0
fake.__dict__["run"] = run
monkeypatch.setitem(sys.modules, "sickdays.gui", fake)
assert main(["gui"]) == 0
assert opened == [True]
def test_importing_the_library_and_cli_never_imports_tkinter() -> None:
probe = (
"import sys, sickdays, sickdays._cli; "
"sys.exit(1 if 'tkinter' in sys.modules else 0)"
)
done = subprocess.run([sys.executable, "-c", probe], check=False)
assert done.returncode == 0

View File

@ -0,0 +1,205 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
from __future__ import annotations
from datetime import timedelta
import tkinter as tk
from typing import TYPE_CHECKING
import pytest
from sickdays._api import add_sick_day, is_sick_day
from sickdays._day import today
from sickdays._errors import ConflictCheckUnavailableError
from sickdays._keep_api import kept_lockers
from sickdays._paths import Paths
from sickdays.gui import KeepPanel, _text
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
from pathlib import Path
from sickdays._model import SickDay
FUTURE = today() + timedelta(days=3)
@pytest.fixture
def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
"""Record every background sync the panel starts."""
calls: list[Paths | None] = []
def record(*, paths: Paths | None = None) -> bool:
calls.append(paths)
return True
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", record)
return calls
def _button(panel: KeepPanel, name: str) -> tk.Button:
widget = panel.nametowidget(f"actions.{name}")
assert isinstance(widget, tk.Button)
return widget
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
widget = panel.nametowidget(f"keep-{locker}")
assert isinstance(widget, tk.Checkbutton)
return widget
def _status(panel: KeepPanel) -> str:
return str(panel.nametowidget("status").cget("text"))
def _actions(panel: KeepPanel) -> set[str]:
actions = panel.nametowidget("actions")
return {child.winfo_name() for child in actions.winfo_children()}
def _join(panel: KeepPanel) -> None:
assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5)
def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
assert panel.kept() == frozenset()
assert _actions(panel) == {"mark"}
toggle = _toggle(panel, "leetcode")
assert toggle.cget("text") == "Off"
toggle.invoke()
assert toggle.cget("text") == "On"
assert str(toggle.cget("bg")).upper() == tok.ACCENT
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
_button(panel, "mark").invoke()
_join(panel)
assert is_sick_day(FUTURE)
assert kept_lockers(FUTURE) == frozenset({"leetcode"})
assert len(saved) == 1
assert saved[0] is not None
assert saved[0].is_sick
assert saved[0].keep == frozenset({"leetcode"})
assert len(synced) == 1
def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
add_sick_day(FUTURE, keep=["workout"])
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _entry: None)
assert panel.kept() == frozenset({"workout"})
assert _actions(panel) == {"save", "clear"}
title = panel.nametowidget("title").cget("text")
assert title == f"Sick day: {_text.day_label(FUTURE)}"
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
add_sick_day(FUTURE, keep=["workout"])
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
_toggle(panel, "workout").invoke()
_toggle(panel, "diet").invoke()
_button(panel, "save").invoke()
_join(panel)
assert kept_lockers(FUTURE) == frozenset({"diet"})
assert saved[0] is not None
assert saved[0].keep == frozenset({"diet"})
assert len(synced) == 1
def test_an_unchanged_save_writes_nothing(
tk_root: tk.Tk, synced: list[Paths | None], state_dir: Path
) -> None:
add_sick_day(FUTURE, keep=["book"])
before = (state_dir / "audit.jsonl").read_text(encoding="utf-8")
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
_button(panel, "save").invoke()
assert saved == [None]
assert panel.sync_thread is None
assert synced == []
assert (state_dir / "audit.jsonl").read_text(encoding="utf-8") == before
def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
add_sick_day(FUTURE, keep=["alarm"])
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
_button(panel, "clear").invoke()
_join(panel)
assert not is_sick_day(FUTURE)
assert saved[0] is not None
assert not saved[0].is_sick
assert len(synced) == 1
def test_a_past_day_is_shown_but_not_editable(tk_root: tk.Tk) -> None:
panel = KeepPanel(tk_root, today() - timedelta(days=1), on_saved=lambda _e: None)
assert _status(panel) == _text.PAST_NOTE
assert _actions(panel) == set()
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
def test_a_free_day_is_refused_with_the_phones_message(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
expected = "is a free day — release it in Free days first."
assert _status(panel).endswith(expected)
assert _actions(panel) == set()
def test_an_uncheckable_free_pool_refuses(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
def unavailable() -> None:
msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert _status(panel) == "cannot check the free-day pool"
assert _actions(panel) == set()
def test_a_refused_write_is_shown_not_raised(
tk_root: tk.Tk, synced: list[Paths | None]
) -> None:
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
add_sick_day(FUTURE) # marked elsewhere while the panel was open
_button(panel, "mark").invoke()
assert "already a sick day" in _status(panel)
assert saved == []
assert synced == []
def test_cancel_calls_back(tk_root: tk.Tk) -> None:
cancelled: list[bool] = []
panel = KeepPanel(
tk_root,
FUTURE,
on_saved=lambda _e: None,
on_cancel=lambda: cancelled.append(True),
)
_button(panel, "cancel").invoke()
assert cancelled == [True]
def test_explicit_paths_are_used_for_every_read_write_and_sync(
tk_root: tk.Tk, synced: list[Paths | None], tmp_path: Path
) -> None:
other = Paths.under(tmp_path / "other")
add_sick_day(FUTURE, keep=["book"], paths=other)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None, paths=other)
assert panel.kept() == frozenset({"book"})
_button(panel, "clear").invoke()
_join(panel)
assert not is_sick_day(FUTURE, log_path=other.log)
assert synced == [other]
assert not (tmp_path / "state" / "sick_days.json").exists()

View File

@ -0,0 +1,85 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The picker's wording, calendar arithmetic and tokens -- no display needed."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
import pytest
DAY = date(2026, 10, 20)
def test_labels_match_the_phone() -> None:
assert _text.day_label(DAY) == "Tue 2026-10-20"
assert _text.panel_title(DAY, sick=False) == "Mark sick: Tue 2026-10-20"
assert _text.panel_title(DAY, sick=True) == "Sick day: Tue 2026-10-20"
assert list(_text.LOCKER_LABELS.values()) == [
"Workout lock",
"LeetCode",
"Book",
"Diet",
"Wake alarm",
]
def test_kept_label_follows_locker_order_not_the_alphabet() -> None:
assert _text.kept_label({"alarm", "book", "workout"}) == (
"Workout lock, Book, Wake alarm"
)
assert _text.kept_label(frozenset()) == ""
def test_free_day_refusal(monkeypatch: pytest.MonkeyPatch) -> None:
assert _text.free_day_refusal(DAY) is None
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
assert _text.free_day_refusal(DAY) == (
"2026-10-20 is a free day — release it in Free days first."
)
def unavailable() -> None:
msg = "freedays is not installed"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
assert _text.free_day_refusal(DAY) == "freedays is not installed"
def test_month_grid_is_monday_first() -> None:
weeks = _text.month_weeks(date(2026, 10, 1))
assert weeks[0] == [None, None, None, *(date(2026, 10, d) for d in (1, 2, 3, 4))]
assert weeks[-1][-1] is None
assert _text.month_title(date(2026, 10, 1)) == "October 2026"
def test_month_arithmetic_and_range() -> None:
assert _text.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
assert _text.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
assert _text.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
def test_workdays_are_tuesday_to_thursday() -> None:
assert [_text.is_workday(date(2026, 10, d)) for d in range(19, 26)] == [
False,
True,
True,
True,
False,
False,
False,
]
def test_tokens() -> None:
assert tok.font("Fake Sans", "body") == ("Fake Sans", -16, "normal")
assert tok.font("Fake Sans", "title", bold=True) == ("Fake Sans", -24, "bold")
assert tok.blend("#FFFFFF", "#000000", 0.5) == "#808080"
assert tok.blend(tok.SICK, tok.INK, 1.0) == tok.SICK

View File

@ -0,0 +1,167 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The picker window and its month grid, against a real (unmapped) Tk."""
from __future__ import annotations
from datetime import date, timedelta
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day
from sickdays._day import to_iso, today
from sickdays._model import SickDay
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
import pytest
FUTURE = today() + timedelta(days=3)
def _texts(widget: tk.Misc) -> list[str]:
found: list[str] = []
for child in widget.winfo_children():
if isinstance(child, tk.Label):
found.append(str(child.cget("text")))
found.extend(_texts(child))
return found
def _cell(app: SickDaysApp, day: date) -> tk.Button:
widget = app.calendar.nametowidget(f"days.day-{to_iso(day)}")
assert isinstance(widget, tk.Button)
return widget
def _panel(app: SickDaysApp) -> KeepPanel:
(panel,) = app.host.winfo_children()
assert isinstance(panel, KeepPanel)
return panel
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
assert tk_root.title() == "Sick days"
assert _text.NONE_AHEAD in _texts(tk_root)
assert app.host.winfo_children() == []
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
app.calendar.month = FUTURE.replace(day=1)
app.calendar.redraw()
_cell(app, FUTURE).invoke()
panel = _panel(app)
panel.nametowidget("keep-leetcode").invoke()
panel.nametowidget("actions.mark").invoke()
assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5)
assert app.host.winfo_children() == []
assert app.status.cget("text") == _window.saved_message(
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
)
assert "Kept: LeetCode" in _texts(tk_root)
cell = _cell(app, FUTURE)
assert str(cell.cget("bg")).upper() == tok.SICK
assert cell.nametowidget("dot").winfo_exists()
def test_cancel_closes_the_panel(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
app.select(FUTURE)
_panel(app).nametowidget("actions.cancel").invoke()
assert app.host.winfo_children() == []
def test_a_free_day_is_refused_before_the_panel_opens(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
app = SickDaysApp(tk_root)
app.select(FUTURE)
assert app.host.winfo_children() == []
assert str(app.status.cget("text")).endswith("release it in Free days first.")
assert str(app.status.cget("fg")).upper() == tok.DANGER
def test_a_sick_day_on_a_free_day_still_opens(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
add_sick_day(FUTURE)
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
app = SickDaysApp(tk_root)
app.select(FUTURE)
assert _panel(app).nametowidget("actions.clear").winfo_exists()
def test_the_list_shows_upcoming_days_with_kept_lockers(tk_root: tk.Tk) -> None:
add_sick_day(FUTURE, keep=["alarm", "workout"])
add_sick_day(FUTURE + timedelta(days=1))
texts = _texts(SickDaysApp(tk_root).calendar.master)
assert _text.day_label(FUTURE) in texts
assert "Kept: Workout lock, Wake alarm" in texts
assert _text.NONE_AHEAD not in texts
def test_saved_message_wording() -> None:
sick = SickDay(FUTURE, is_sick=True)
label = _text.day_label(FUTURE)
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
cleared = SickDay(FUTURE, is_sick=False)
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
first = today().replace(day=1)
app.calendar.month = first - timedelta(days=1)
app.calendar.redraw()
cell = _cell(app, app.calendar.month.replace(day=1))
assert str(cell.cget("state")) == "disabled"
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
first, last = _text.month_bounds(today())
for _ in range(30):
app.calendar.step(1)
assert app.calendar.month == last
for _ in range(30):
app.calendar.step(-1)
assert app.calendar.month == first
back = app.calendar.nametowidget("header.prev")
assert str(back.cget("state")) == "disabled"
def test_escape_closes_the_window(monkeypatch: pytest.MonkeyPatch) -> None:
closed: list[bool] = []
root = tk.Tk(className="sickdays-test")
root.withdraw()
app = SickDaysApp(root)
assert root.bind("<Escape>")
real_destroy = root.destroy
monkeypatch.setattr(root, "destroy", lambda: closed.append(True))
app.close()
assert closed == [True]
real_destroy()
def test_run_opens_the_window_and_blocks(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
classes: list[str] = []
def make_root(**options: str) -> tk.Tk:
classes.append(options["className"])
return tk_root
monkeypatch.setattr(tk, "Tk", make_root)
monkeypatch.setattr(tk_root, "mainloop", lambda: None)
assert _window.run() == 0
assert classes == ["sickdays"]

View File

@ -58,3 +58,25 @@ def test_resolve_returns_the_bundle_it_was_given(tmp_path: Path) -> None:
def test_resolve_falls_back_to_the_defaults() -> None:
assert resolve_paths(None) == Paths.default()
def test_every_module_resolving_paths_is_redirected_by_conftest() -> None:
"""A module that resolves the real paths but is not redirected would let
a test write the real pool."""
import importlib
import pkgutil
import sickdays
from sickdays.tests import conftest
names = [
info.name
for info in pkgutil.walk_packages(sickdays.__path__, "sickdays.")
if ".tests" not in info.name and info.name != "sickdays.__main__"
]
resolving = {
name
for name in names
if hasattr(importlib.import_module(name), "resolve_paths")
} - {"sickdays._paths"}
assert resolving == set(conftest._MODULES)