Compare commits

..

7 Commits

Author SHA1 Message Date
9d6b17fd28 Make sickdays (in kuhyx/utils) text-free (textgat…
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sickdays pre-commit / pre-commit (push) Waiting to run
sickdays tests / test (push) Waiting to run
Todo: 2d0704cd
2026-11-10 19:07:37 +01:00
ae56037e41 Fix GMS gate APK mode false positive
Some checks are pending
data_transfer_flutter tests / test (push) Waiting to run
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
Decide by AGP library marker files instead of dex strings:
play-services-base/basement reference GoogleSignInAccount without
linking play-services-auth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 18:00:23 +01:00
eef7af4c86 Bump file_picker 13.3.3 (dtf 0.2.2, ssui 0.6.3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 17:53:50 +01:00
9daa376254 Gate: no Google non-free sign-in in apps
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
scripts/check_no_gms_signin.sh fails when a pubspec.lock or android
gradle file pulls google_sign_in / play-services-auth / googleid, or a
built APK (--apk) carries GMS auth classes, and names
google_account_token as the fix. install_repo_contract_gate.sh now
installs its shim and an always-run hook in Flutter Android repos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWVQE5pYiASpRGgA3Kg
2026-11-10 16:52:44 +01:00
17e300af0a focus_project: pin backing edge cases and pay
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Tests only: sql_drills/leetcode beats back, other-project and browser
beats don't, submissions back only leetcode projects, the cutover
midnight, a tutor chain leaves earlier reading unbacked; earned_time
pays 0 units for a timer-only post-cutover day (real focus_project).

Todo: fda71ed1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:38:48 +01:00
afcc1a47c7 focus_project 0.10.0: timer needs backing beats
A timer span earns only if a same-project tutor/sql_drills/leetcode
beat backs it (days from 2026-11-08). status() gains unbacked_minutes.
The 0.8.0 tag shipped only the tutor-chain half.

Todo: fda71ed1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 16:35:46 +01:00
0527bc680d focus_project 0.9.1: mock needs 90 min
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
focus-project pre-commit / pre-commit (push) Waiting to run
focus-project tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
A score row completes mock only after a contiguous >= 90 min
focus session (600 s gap rule) that ended by the score row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZUTC9Zzdzsx7VW7dm
2026-11-10 16:19:31 +01:00
45 changed files with 2272 additions and 517 deletions

View File

@ -4,13 +4,13 @@ description: >-
Flutter apps: system Save-as on Android/iOS (never cloud-only), a save
dialog on desktop, a download on web, plus a versioned export envelope.
publish_to: 'none'
version: 0.2.1
version: 0.2.2
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.2
file_picker: 13.3.3
file_selector: 1.1.0
flutter:
sdk: flutter

View File

@ -199,6 +199,7 @@ their `max_gaming_minutes` + pure bonuses again (240/120 min = 2/min; full day =
from the start day, cutting all of it, 180 with LeetCode's formerly free hour (0.20.0); maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
Unbacked timer time pays 0 from 2026-11-08 (focus_project 0.10.0).
## Adding a gate

View File

@ -0,0 +1,88 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Regression: a timer-only focus day after the anti-cheat cutover pays 0.
Runs the REAL ``focus_project`` (the sibling package of this monorepo):
timer rows -> harvester -> signed ledger -> ``credit_units``. earned_time
itself does not change; this pins that the gate reaches pay.
"""
from __future__ import annotations
from datetime import date, datetime
import importlib
import json
from pathlib import Path
import sys
from typing import TYPE_CHECKING
import pytest
from earned_time import credit_units, earners_for
if TYPE_CHECKING:
from types import ModuleType
DAY = date(2026, 11, 9) # after focus_project.BACKING_FROM
_SIBLING = Path(__file__).resolve().parents[3] / "focus_project"
def at(hour: int, minute: int) -> float:
return datetime(2026, 11, 9, hour, minute).astimezone().timestamp()
@pytest.fixture
def real_fp(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
) -> ModuleType:
"""The real focus_project on redirected state (HOME, data dir, key)."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("FOCUS_PROJECT_DATA", str(tmp_path / "data"))
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(key_file))
monkeypatch.setenv("FOCUS_PROJECT_LEETCODE_LEDGER", str(tmp_path / "lc.json"))
monkeypatch.delitem(sys.modules, "focus_project") # the conftest's fake
monkeypatch.syspath_prepend(str(_SIBLING))
fp = importlib.import_module("focus_project")
fp.create_project(
"hr",
name="HR",
start=DAY,
end=DAY,
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
return fp
def pay(fp: ModuleType, tmp_path: Path, key_file: Path, *rows: object) -> int | None:
log = tmp_path / "data" / "activity.jsonl"
log.write_text("".join(json.dumps(r) + "\n" for r in rows), encoding="utf-8")
harvest = importlib.import_module("focus_project.harvest")
assert harvest.harvest(now=at(23, 0)) == 0
(item,) = (e for e in earners_for(DAY) if e.name == "focus")
return credit_units(item, tmp_path / "data" / "ledger.json", key_file, DAY)
def row(source: str, kind: str, ts: float) -> dict[str, object]:
return {"ts": ts, "source": source, "kind": kind, "project": "hr"}
TIMER = (
row("timer", "start", at(9, 0)),
row("timer", "checkin", at(9, 5)),
row("timer", "stop", at(9, 10)),
)
def test_timer_only_day_pays_zero(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
assert date(2026, 11, 8) == real_fp.BACKING_FROM
assert pay(real_fp, tmp_path, key_file, *TIMER) == 0
def test_backed_timer_day_still_pays(
real_fp: ModuleType, tmp_path: Path, key_file: Path
) -> None:
beat = row("tutor", "heartbeat", at(9, 5))
assert pay(real_fp, tmp_path, key_file, *TIMER, beat) == 10

View File

@ -36,6 +36,10 @@ uncovers today. A NEW project starting today may replace gates and set any targe
new obligation, not a reduction (but see the floor).
Floor (0.7.1, coordinator decision for kuhy): `daily_minutes >= 30 x replaced gates` (min 30; `book` = `reading`),
enforced on create and update (`daily_floor(replaces)`), so a day cannot be made cheap.
Backed timer (0.10.0, days from `BACKING_FROM` = 2026-11-08): a timer span earns only if a
same-project `tutor`/`sql_drills`/`leetcode` beat (or accepted submission) is in it, 5 min
before it, or bridges it (beats <= 20 min apart). `unbacked_minutes_for(day)` /
`status()["unbacked_minutes"]` show the rest. A beat backs only the spans it touches (the tutor chain makes a graded card back the whole stretch). Known gap: any coach turn backs.
Open the work: `python3 -m focus_project.open [--no-site]` (tutor focus screen, `work_url`).
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).

View File

@ -31,10 +31,12 @@ from focus_project._update import Update, apply_update, update_project
from focus_project.activity import record_activity
from focus_project.checks import register_source, run_checks
from focus_project.evidence import Evidence
from focus_project.minutes import minutes_for
from focus_project.minutes import minutes_for, unbacked_minutes_for
from focus_project.sources.activity import BACKING_FROM
from focus_project.status import status
__all__ = [
"BACKING_FROM",
"Evidence",
"Limits",
"Milestone",
@ -59,5 +61,6 @@ __all__ = [
"run_checks",
"stands_down",
"status",
"unbacked_minutes_for",
"update_project",
]

View File

@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, Literal
from focus_project import _api, _interview, evidence
from focus_project._errors import RegistryError
from focus_project._kind import INTERVIEW
from focus_project.sources import activity
if TYPE_CHECKING:
from collections.abc import Callable
@ -30,6 +31,7 @@ if TYPE_CHECKING:
_LOG = logging.getLogger(__name__)
State = Literal["pending", "done", "missed", "late_done", "unknown"]
PHONE_LEAD = timedelta(hours=12)
MOCK_SESSION_S = 90 * 60
_RED = frozenset({"missed", "unknown"})
@ -134,6 +136,21 @@ def _by_evidence(kind: str) -> Callable[[str, Rows, Project], Completion]:
return judge
def _mock(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the first score row that follows a >= 90 min focus session.
The session (contiguous beats, :func:`activity.sessions`) must END at or
before the score row; a score with no such session completes nothing.
"""
ends = [hi for lo, hi in activity.sessions(project.id) if hi - lo >= MOCK_SESSION_S]
for row in rows:
if (row.milestone, row.kind) == (mid, "score") and any(
end <= row.ms / 1000 for end in ends
):
return row.at, False
return None, False
def _trip_ask(mid: str, rows: Rows, project: Project) -> Completion:
"""Done at the answer row; an answer with no row was given at creation."""
if date.fromisoformat(mid.partition(":")[2]) not in dict(project.weekend_answers):
@ -164,7 +181,7 @@ def _phone(mid: str, rows: Rows, _project: Project) -> Completion:
_JUDGES = {
"rules": _by_evidence("file"),
"mock": _by_evidence("score"),
"mock": _mock,
"trip_ask": _trip_ask,
"slot": _slot,
"phone": _phone,

View File

@ -8,6 +8,10 @@ minutes (floor). Time not yet lived is never credited: every interval is
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
earns only once the real clock reaches it. An unreadable registry reads as 0
(logged): fail closed.
Unbacked minutes (0.10.0): timer time the anti-cheat gate refused
(:mod:`focus_project.sources.activity`) and no other source covers, on the
local day up to "now"; carry and the cap play no part. Shown, never paid.
"""
from __future__ import annotations
@ -15,9 +19,10 @@ from __future__ import annotations
from datetime import date # noqa: TC003
import logging
from focus_project import _api, _clock, carry
from focus_project import _api, _clock, carry, sources
from focus_project._errors import RegistryError
from focus_project._own import union_seconds
from focus_project.sources import activity
_LOG = logging.getLogger(__name__)
@ -38,4 +43,26 @@ def minutes_for(day: date, now: float | None = None) -> int:
return carry.balance(day, moment).counted(project.daily_on(day))
__all__ = ["minutes_for", "union_seconds"]
def unbacked_minutes_for(day: date, now: float | None = None) -> int:
"""Whole minutes of timer time on ``day`` that the gate did not count.
0 with no project, before :data:`~focus_project.sources.activity.BACKING_FROM`,
or with an unreadable registry (logged).
"""
try:
project = _api.active(day)
if project is None:
return 0
lo, hi = sources.day_bounds(day)
bounds = (lo, min(hi, _clock.now() if now is None else now))
unbacked = sources.clip(activity.split_timer(day, project)[1], bounds)
counted = sources.clip(sources.collect(day, project), bounds)
except (RegistryError, OSError) as exc:
_LOG.warning("focus_project registry unreadable, 0 unbacked: %s", exc)
return 0
# |unbacked minus counted| = |unbacked union counted| - |counted|.
gap = union_seconds(unbacked + counted) - union_seconds(counted)
return int(gap // 60)
__all__ = ["minutes_for", "unbacked_minutes_for", "union_seconds"]

View File

@ -13,23 +13,36 @@ heartbeat covering the five minutes before it. Only events recorded for
the open segment, like a ``checkin``: within :data:`MAX_GAP_S` of the last
beat it extends the segment; later it ends the segment at that last beat. It
never opens a segment. Other heartbeat sources never chain the timer.
0.10.0 anti-cheat (days from :data:`BACKING_FROM`): a timer span earns only
if real activity of the same project backs it, whole span or nothing. A
backing event is a ``tutor``/``sql_drills``/``leetcode`` heartbeat or an
accepted LeetCode submission (leetcode projects only); ``browser`` never
backs. A span ``(a, b)`` is backed if a backing event lies in
``[a - 300, b]``, or between two consecutive backing events at most
:data:`BRIDGE_S` apart. Check-ins alone earn 0. Earlier days keep the old
rule (every span counts), so paid days never change.
"""
from __future__ import annotations
from datetime import date
import importlib
from itertools import pairwise
import json
import logging
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Final
from focus_project._paths import activity_path
from focus_project.sources import Interval, heartbeat_interval
from focus_project.sources import HEARTBEAT_WINDOW_S, Interval, heartbeat_interval
if TYPE_CHECKING:
from datetime import date
from focus_project._model import Project
MAX_GAP_S = 600.0
BACKING_FROM: Final = date(2026, 11, 8)
BACKING_SOURCES: Final = frozenset({"tutor", "sql_drills", "leetcode"})
BRIDGE_S: Final = 1200.0
_LOG = logging.getLogger(__name__)
@ -84,12 +97,68 @@ def _is_beat(event: dict[str, Any]) -> bool:
return event.get("source") == "tutor" and event.get("kind") == "heartbeat"
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""Timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
def sessions(project_id: str) -> list[Interval]:
"""Contiguous focus sessions of ``project_id``, time-ordered, as (start, end).
Built from the same timer/tutor-beat segments (and so the same
:data:`MAX_GAP_S` rule) that credit minutes; spans that touch are one
session, which ends at its last beat.
"""
events = read_events(project_id)
timer = [e for e in events if e.get("source") == "timer" or _is_beat(e)]
merged: list[Interval] = []
for lo, hi in timer_intervals(timer):
if merged and lo <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(hi, merged[-1][1]))
else:
merged.append((lo, hi))
return merged
def backing_times(events: list[dict[str, Any]], project: Project) -> list[float]:
"""Sorted times of the events that may back a timer span.
The LeetCode ledger is read in its own guard: if it fails, only the
submissions are lost, never the heartbeats (fail closed, narrow).
"""
times = [float(e["ts"]) for e in events if e.get("source") in BACKING_SOURCES]
try:
# Imported lazily: it pulls in earned_time, and the timer must keep
# working (heartbeat backing only) where that is not installed.
leetcode = importlib.import_module("focus_project.sources.leetcode")
times += leetcode.submission_times(project)
except Exception: # noqa: BLE001 (lose the submissions, keep the beats)
_LOG.exception("leetcode backing unavailable, heartbeats only")
return sorted(times)
def is_backed(span: Interval, times: list[float]) -> bool:
"""Whether a backing event lies in or just before ``span``, or bridges it."""
lo, hi = span
if any(lo - HEARTBEAT_WINDOW_S <= t <= hi for t in times):
return True
return any(a <= lo and hi <= b and b - a <= BRIDGE_S for a, b in pairwise(times))
def split_timer(
day: date, project: Project, events: list[dict[str, Any]] | None = None
) -> tuple[list[Interval], list[Interval]]:
"""``(backed, unbacked)`` timer spans of ``project``, judged for ``day``."""
found = read_events(project.id) if events is None else events
chain = [e for e in found if e.get("source") == "timer" or _is_beat(e)]
spans = timer_intervals(chain)
if day < BACKING_FROM:
return spans, []
times = backing_times(found, project)
backed = [s for s in spans if is_backed(s, times)]
return backed, [s for s in spans if s not in backed]
def intervals(day: date, project: Project) -> list[Interval]:
"""Backed timer segments plus heartbeats of ``project`` (the registry clips)."""
events = read_events(project.id)
beats = [e for e in events if e.get("source") != "timer"]
return [
*timer_intervals(timer),
*split_timer(day, project, events)[0],
*(heartbeat_interval(float(e["ts"])) for e in beats),
]

View File

@ -55,8 +55,8 @@ def _submitted_at(row: object, key: bytes) -> float | None:
return None
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
def submission_times(project: Project) -> list[float]:
"""Times of verified accepted submissions; [] for a non-leetcode project."""
if not any("leetcode" in pattern.lower() for pattern in project.site_patterns):
return []
key = ledger.read_key()
@ -64,4 +64,9 @@ def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
_LOG.warning("leetcode source: no signing key, contributes 0")
return []
stamps = (_submitted_at(row, key) for row in ledger.read_rows(ledger_file()))
return [heartbeat_interval(ts) for ts in stamps if ts is not None]
return [ts for ts in stamps if ts is not None]
def intervals(day: date, project: Project) -> list[Interval]: # noqa: ARG001
"""One heartbeat per accepted submission (the registry clips to the day)."""
return [heartbeat_interval(ts) for ts in submission_times(project)]

View File

@ -6,7 +6,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from focus_project import _api, _milestones, carry, evidence
from focus_project import _api, _milestones, carry, evidence, minutes
if TYPE_CHECKING:
from datetime import date
@ -36,7 +36,8 @@ def status(day: date) -> dict[str, Any]:
``minutes`` is what was really worked plus what the day took from the
bank, so it may exceed ``daily_minutes`` (121/120); ``counted_minutes``
is the capped figure that pays.
is the capped figure that pays. ``unbacked_minutes`` is timer time the
anti-cheat gate refused (shown as "not counted", never paid).
"""
project = _api.active(day)
if project is None:
@ -52,6 +53,7 @@ def status(day: date) -> dict[str, Any]:
"unlock_minutes": None,
"ends": None,
"replaces": [],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
@ -72,6 +74,7 @@ def status(day: date) -> dict[str, Any]:
"unlock_minutes": project.unlock_on(day),
"ends": project.last_day.isoformat(),
"replaces": list(project.replaces_on(day)),
"unbacked_minutes": minutes.unbacked_minutes_for(day),
# Interview projects: [{id, due ISO, state}] and whether any is red.
"milestones": rows,
"missed": missed,

View File

@ -3,10 +3,12 @@
from __future__ import annotations
from datetime import date, datetime, time
from datetime import date, datetime, time, timedelta
import json
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project._paths import activity_path
if TYPE_CHECKING:
from pathlib import Path
@ -50,3 +52,14 @@ def shot(tmp_path: Path, name: str = "rules.png") -> Path:
def states(project: fp.Project, now: datetime) -> dict[str, str]:
"""Milestone id -> state."""
return {m.id: m.state for m in fp.milestones(project, now)}
def session(start: datetime, minutes: int, *, step: int = 5, pid: str = "iv") -> None:
"""A timer session start -> check-in every ``step`` min -> stop."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for offset in range(0, minutes + 1, step):
kind = "start" if offset == 0 else "checkin"
when = start + timedelta(minutes=offset)
row = {"ts": when.timestamp(), "source": "timer", "kind": kind}
handle.write(json.dumps({**row, "project": pid, "dev": "pc"}) + "\n")

View File

@ -137,6 +137,7 @@ def test_status_active_reports_the_worked_minutes() -> None:
"unlock_minutes": 30,
"ends": "2026-11-05",
"replaces": ["leetcode"],
"unbacked_minutes": 0,
"milestones": [],
"missed": False,
}
@ -160,6 +161,7 @@ def test_status_inactive() -> None:
"unlock_minutes",
"ends",
"replaces",
"unbacked_minutes",
"milestones",
"missed",
}

View File

@ -0,0 +1,181 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""0.10.0: a timer span earns only if a same-project beat backs it."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import _api, minutes
from focus_project.sources import activity
from focus_project.tests import test_minutes as tm
if TYPE_CHECKING:
import pytest
DAY = date(2026, 11, 8) # BACKING_FROM
def at(hour: int, minute: int = 0) -> float:
return tm.at(hour, minute, day=DAY)
def proj() -> None:
fp.create_project(
"hr",
name="HR prep",
start=DAY,
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
def rows(*items: tuple[str, str, float]) -> None:
tm.events(*items)
def timer_only() -> None:
rows(
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 10)),
)
def test_timer_only_stretch_pays_zero_and_shows_unbacked() -> None:
proj()
timer_only()
assert minutes.minutes_for(DAY) == 0
assert minutes.unbacked_minutes_for(DAY) == 10
assert fp.status(DAY)["unbacked_minutes"] == 10
def test_a_beat_in_the_span_backs_all_of_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 5)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_backs_only_the_spans_it_touches() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 7)))
# 9:00-9:05 stays unbacked, but the beat itself pays 9:02-9:07.
assert minutes.unbacked_minutes_for(DAY) == 2
def test_a_beat_at_the_end_of_the_span_backs_it() -> None:
proj()
rows(
("timer", "start", at(9)),
("timer", "stop", at(9, 10)),
("sql_drills", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_a_beat_just_before_the_span_backs_it() -> None:
proj()
rows(
("sql_drills", "heartbeat", at(8, 56)), # within 300 s before 9:00
("timer", "start", at(9)),
("timer", "stop", at(9, 5)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_a_late_beat_does_not_back_it() -> None:
proj()
timer_only()
rows(("sql_drills", "heartbeat", at(9, 40)))
assert minutes.unbacked_minutes_for(DAY) == 10
def test_beats_up_to_twenty_minutes_apart_bridge_the_span() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 50)),
("timer", "start", at(9)),
("timer", "checkin", at(9, 5)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0
def test_beats_over_twenty_minutes_apart_do_not_bridge() -> None:
proj()
rows(
("tutor", "heartbeat", at(8, 30)),
("timer", "start", at(9)),
("timer", "stop", at(9, 8)),
("tutor", "heartbeat", at(9, 40)),
)
assert minutes.unbacked_minutes_for(DAY) == 8
def test_browser_never_backs() -> None:
proj()
timer_only()
rows(("browser", "heartbeat", at(9, 7)))
# The beat pays its own 9:02-9:07 but backs no timer span.
assert minutes.unbacked_minutes_for(DAY) == 5
assert minutes.minutes_for(DAY) == 5
def test_days_before_backing_from_keep_the_old_rule() -> None:
fp.create_project(
"hr",
name="Old",
start=date(2026, 11, 2),
end=date(2026, 11, 5),
replaces=["leetcode"],
daily_minutes=120,
unlock_minutes=30,
site_patterns=["leetcode.com"],
)
old = date(2026, 11, 2)
rows(("timer", "start", tm.at(9)), ("timer", "stop", tm.at(9, 10)))
assert minutes.minutes_for(old) == 10
assert minutes.unbacked_minutes_for(old) == 0
def test_no_project_is_zero_unbacked() -> None:
assert minutes.unbacked_minutes_for(DAY) == 0
assert fp.status(DAY)["unbacked_minutes"] == 0
def test_unbacked_is_clipped_at_now() -> None:
proj()
timer_only()
assert minutes.unbacked_minutes_for(DAY, now=at(9, 4)) == 4
def test_split_timer_returns_backed_and_unbacked() -> None:
proj()
timer_only()
project = _api.active(DAY)
assert project is not None
backed, unbacked = activity.split_timer(DAY, project)
assert backed == []
assert unbacked == [(at(9), at(9, 5)), (at(9, 5), at(9, 10))]
def test_backing_from_is_exported() -> None:
assert date(2026, 11, 8) == fp.BACKING_FROM
assert fp.unbacked_minutes_for is minutes.unbacked_minutes_for
def test_registry_error_reads_zero(monkeypatch: pytest.MonkeyPatch) -> None:
def boom(_day: date) -> None:
raise OSError
monkeypatch.setattr("focus_project._api.active", boom)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -0,0 +1,131 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""More 0.10.0 backing cases: sources, projects, the cutover midnight, chains.
The core rule lives in ``test_backing.py``; these pin the edges the spec
named (C5-C7) with leetcode-ledger and other-project fixtures.
"""
from __future__ import annotations
from datetime import date
import json
from typing import TYPE_CHECKING
import pytest
import focus_project as fp
from focus_project import minutes
from focus_project._paths import activity_path
from focus_project.tests.test_leetcode_source import ac_row, write
from focus_project.tests.test_minutes import at
if TYPE_CHECKING:
from pathlib import Path
DAY = date(2026, 11, 9) # after the cutover
PRE = date(2026, 11, 7) # before it
@pytest.fixture(autouse=True)
def _home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Nothing may fall back to the real home directory."""
monkeypatch.setenv("HOME", str(tmp_path / "home"))
def make(pid: str = "hr", patterns: tuple[str, ...] = ("leetcode.com",)) -> None:
fp.create_project(
pid,
name=pid,
start=date(2026, 11, 6),
end=date(2026, 11, 12),
replaces=["leetcode"],
daily_minutes=240,
unlock_minutes=30,
site_patterns=list(patterns),
)
def rows(*items: tuple[str, str, float] | tuple[str, str, float, str]) -> None:
"""Append ``(source, kind, ts[, project])`` events (project default hr)."""
activity_path().parent.mkdir(parents=True, exist_ok=True)
with activity_path().open("a", encoding="utf-8") as handle:
for item in items:
source, kind, ts = item[:3]
pid = item[3] if len(item) == 4 else "hr"
row = {"ts": ts, "source": source, "kind": kind, "project": pid}
handle.write(json.dumps(row) + "\n")
def timer(day: date, *marks: tuple[int, int]) -> None:
"""A segment: start at the first mark, check-ins, stop at the last."""
kinds = ["start", *["checkin"] * (len(marks) - 2), "stop"]
rows(
*(
("timer", k, at(h, m, day=day))
for k, (h, m) in zip(kinds, marks, strict=True)
)
)
def beat(source: str, h: int, m: int, day: date = DAY, pid: str = "hr") -> None:
rows((source, "heartbeat", at(h, m, day=day), pid))
@pytest.mark.parametrize("source", ["sql_drills", "leetcode"])
def test_sql_drills_and_leetcode_heartbeats_back_too(source: str) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
beat(source, 9, 5)
assert minutes.minutes_for(DAY) == 10
def test_browser_and_other_project_beats_do_not_back() -> None:
make() # "other" needs no registry entry: rows are filtered by id
timer(DAY, (9, 0), (9, 5), (9, 10))
beat("browser", 9, 4)
beat("tutor", 9, 4, pid="other")
assert minutes.minutes_for(DAY) == 5 # the browser beat's own window only
assert minutes.unbacked_minutes_for(DAY) == 6 # 9:04-9:10
def test_accepted_submission_backs_a_leetcode_project(tmp_path: Path) -> None:
make()
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 11 # 8:59-9:10
def test_submission_does_not_back_a_non_leetcode_project(tmp_path: Path) -> None:
make("hr", ("sqlzoo.net",))
timer(DAY, (9, 0), (9, 5), (9, 10))
write(tmp_path, ac_row(at(9, 4, day=DAY)))
assert minutes.minutes_for(DAY) == 0
def test_span_across_the_cutover_midnight_follows_the_day() -> None:
make()
rows(
("timer", "start", at(23, 58, day=date(2026, 11, 7))),
("timer", "checkin", at(0, 3, day=date(2026, 11, 8))),
("timer", "stop", at(0, 5, day=date(2026, 11, 8))),
)
assert minutes.minutes_for(date(2026, 11, 7)) == 2
assert minutes.minutes_for(date(2026, 11, 8)) == 0
assert minutes.unbacked_minutes_for(date(2026, 11, 8)) == 5
def test_tutor_chained_span_is_backed_but_earlier_reading_is_not() -> None:
"""A tutor beat chains the segment (0.8.0) and backs the span it ends.
start 9:00, checkin 9:08, tutor beat 9:16: the span 9:08-9:16 ends at a
backing beat and earns; 9:00-9:08 has no beat in or 5 min before it and
nothing bridges it, so it shows as unbacked.
"""
make()
rows(
("timer", "start", at(9, 0, day=DAY)),
("timer", "checkin", at(9, 8, day=DAY)),
)
beat("tutor", 9, 16)
assert minutes.minutes_for(DAY) == 8
assert minutes.unbacked_minutes_for(DAY) == 8

View File

@ -14,6 +14,8 @@ import pytest
import focus_project as fp
from focus_project import minutes, sources
from focus_project.sources import leetcode
from focus_project.tests.test_backing import DAY, proj, rows
from focus_project.tests.test_backing import at as day_at
from focus_project.tests.test_minutes import D1, at, project
KEY = b"k" * 32
@ -127,3 +129,29 @@ def test_read_only_replay_of_real_ledger(monkeypatch: pytest.MonkeyPatch) -> Non
assert got
assert all(b - a == sources.HEARTBEAT_WINDOW_S for a, b in got)
assert REAL.stat().st_mtime_ns == before
def test_accepted_submission_backs_a_timer_span(tmp_path: Path) -> None:
"""0.10.0: an accepted submission backs timer time on a backed day."""
proj()
rows(("timer", "start", day_at(9)), ("timer", "stop", day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 10
write(tmp_path, ac_row(day_at(9, 10)))
assert minutes.unbacked_minutes_for(DAY) == 0
assert minutes.minutes_for(DAY) == 10
def test_unreadable_ledger_leaves_heartbeat_backing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def boom(_project: object) -> list[float]:
raise OSError
monkeypatch.setattr(leetcode, "submission_times", boom)
proj()
rows(
("timer", "start", day_at(9)),
("timer", "stop", day_at(9, 10)),
("tutor", "heartbeat", day_at(9, 10)),
)
assert minutes.unbacked_minutes_for(DAY) == 0

View File

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
import focus_project as fp
from focus_project import evidence
from focus_project.tests.helpers import SAT1, SAT2, at, make, shot, states
from focus_project.tests.helpers import SAT1, SAT2, at, make, session, shot, states
if TYPE_CHECKING:
from pathlib import Path
@ -97,6 +97,7 @@ def _clean(tmp_path: Path) -> None:
fp.record_evidence("iv", "rules", "file", file=shot(tmp_path), now=at(10, 9))
fp.answer_weekend("iv", SAT1, trip=False, now=at(11, 9))
fp.answer_weekend("iv", SAT2, trip=False, now=at(11, 9))
session(at(24, 7), 90)
fp.record_evidence("iv", "mock", "score", 70, now=at(24, 9))
@ -147,13 +148,6 @@ def test_on_time_is_done_at_the_boundary(tmp_path: Path) -> None:
assert states(p, at(10, 23, 59))["rules"] == "done"
def test_mock_needs_a_score(tmp_path: Path) -> None:
p = make()
assert states(p, at(26, 8))["mock"] == "missed"
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
def test_slot_needs_entry_and_confirm_tap() -> None:
p = make()
fp.record_evidence("iv", "slot", "slot", "2026-11-26T20:00:00", now=at(25, 9))

View File

@ -0,0 +1,61 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The mock milestone: a score counts only after a >= 90 min focus session."""
from __future__ import annotations
import focus_project as fp
from focus_project.tests.helpers import at, make, session, states
def test_mock_needs_a_score_after_a_long_session() -> None:
p = make()
assert states(p, at(26, 8))["mock"] == "missed"
session(at(25, 20), 90)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 8))
assert states(fp.list_projects()[0], at(26, 9))["mock"] == "late_done"
def test_score_without_a_session_does_not_complete_mock() -> None:
p = make()
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
assert states(p, at(26, 8))["mock"] == "missed"
assert fp.owed_lock(at(26, 8)) is not None
def test_short_session_does_not_complete_mock() -> None:
p = make()
session(at(24, 7), 89)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_broken_by_a_gap_does_not_complete_mock() -> None:
p = make()
session(at(24, 6), 60) # 06:00-07:00, then an 11 min hole (> 600 s)
session(at(24, 7, 11), 60)
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_must_end_before_the_score() -> None:
p = make()
session(at(24, 8), 90) # ends 09:30
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
fp.record_evidence("iv", "mock", "score", 66, now=at(24, 9, 30))
assert states(p, at(24, 10))["mock"] == "done" # ends exactly at the score
def test_other_projects_sessions_do_not_count() -> None:
p = make()
session(at(24, 7), 90, pid="other")
fp.record_evidence("iv", "mock", "score", 64, now=at(24, 9))
assert states(p, at(24, 10))["mock"] == "pending"
def test_session_on_time_but_score_late_is_late_done() -> None:
p = make()
session(at(25, 20), 100)
fp.record_evidence("iv", "mock", "score", 64, now=at(26, 0, 5))
assert states(p, at(26, 1))["mock"] == "late_done"

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "focus_project"
version = "0.9.0"
version = "0.10.0"
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
requires-python = ">=3.14"
# Stdlib only on purpose: guards import it from the system python.

View File

@ -93,6 +93,14 @@ web-relevant file is staged (`web.DEFAULT_FILES`, overridable with
degrades to a pass, and the gate defers to that workflow when run inside
another GitHub Actions job.
## Flutter apps: no Google non-free sign-in
A repo with a `pubspec.yaml` beside an `android/` dir (root or one level
down) also gets `scripts/check_no_gms_signin.sh` (a shim) and the always-run
`no-gms-signin` hook (`install_gms.py`). The shared gate fails on
google_sign_in / play-services-auth / googleid in pubspec.lock or gradle, or
in a built APK with `--apk`; apps use `google_account_token` instead.
## Flutter apps: the launcher rules
Decided by kuhy 2026-10-23 (`launcher.py`). They apply to every tracked

View File

@ -25,6 +25,7 @@ from dep_freshness.install import NO_WORKFLOW_MARKER
from repo_contract import bootstrap
from repo_contract.check import violations
from repo_contract.infer import propose
from repo_contract.install_gms import install_gms, plan_gms
from repo_contract.install_web import install_web, plan_web
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
from repo_contract.stacks import detect_stacks
@ -64,6 +65,7 @@ def plan(repo: Path) -> list[str]:
A website (detected or declared in repo-contract.toml) also gets the
AgentReady gate: config, shim, workflow and hook (see install_web.py).
A Flutter Android app also gets the no-GMS-sign-in gate (install_gms.py).
"""
todo = []
if _needs_shim(repo):
@ -72,7 +74,7 @@ def plan(repo: Path) -> list[str]:
todo.append(str(WORKFLOW))
if needs_hook(repo, HOOK_ID):
todo.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return todo + plan_web(repo)
return todo + plan_web(repo) + plan_gms(repo)
def install(repo: Path) -> list[str]:
@ -92,7 +94,7 @@ def install(repo: Path) -> list[str]:
if needs_hook(repo, HOOK_ID):
write_hook(repo, _template("pre-commit-snippet.yaml"))
done.append(f"{PRECOMMIT} ({HOOK_ID} hook)")
return done + install_web(repo)
return done + install_web(repo) + install_gms(repo)
def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -0,0 +1,62 @@
"""The no-GMS-sign-in half of the installer: Flutter Android apps get the gate.
Why it rides on install_repo_contract_gate.sh: that installer is the one step
every new repo gets after `git init`, so a new Flutter app cannot come out of
scaffolding able to re-add google_sign_in (13 apps were migrated off it onto
utils/google_account_token in November 2026). Pieces: the
scripts/check_no_gms_signin.sh delegate and an always-run pre-commit hook.
Only repos with a pubspec.yaml next to an android/ dir (at the root or one
level down, e.g. tutor/app) get it; utils itself holds the real gate.
"""
from __future__ import annotations
from pathlib import Path
from repo_contract.precommit import PRECOMMIT, needs_hook, write_hook
TEMPLATES = Path(__file__).resolve().parent / "templates"
SHIM = Path("scripts/check_no_gms_signin.sh")
HOOK_ID = "no-gms-signin"
HOOK_PIECE = f"{PRECOMMIT} ({HOOK_ID} hook)"
SHIM_TEMPLATE = "check_no_gms_signin.shim.sh"
def _template(name: str) -> str:
return (TEMPLATES / name).read_text(encoding="utf-8")
def is_flutter_android(repo: Path) -> bool:
"""A pubspec.yaml beside an android/ dir, at the root or one level down."""
if (repo / "repo_contract" / "check.py").is_file():
return False # utils: SHIM is the real gate, never a delegate
pubspecs = [repo / "pubspec.yaml", *repo.glob("*/pubspec.yaml")]
return any(p.is_file() and (p.parent / "android").is_dir() for p in pubspecs)
def plan_gms(repo: Path) -> list[str]:
"""Which no-GMS-sign-in gate pieces are missing or drifted."""
if not is_flutter_android(repo):
return []
todo = []
shim = repo / SHIM
if not shim.is_file() or shim.read_text(encoding="utf-8") != _template(
SHIM_TEMPLATE
):
todo.append(str(SHIM))
if needs_hook(repo, HOOK_ID):
todo.append(HOOK_PIECE)
return todo
def install_gms(repo: Path) -> list[str]:
"""Write every missing or drifted no-GMS-sign-in piece. Returns them."""
todo = plan_gms(repo)
if str(SHIM) in todo:
target = repo / SHIM
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(_template(SHIM_TEMPLATE), encoding="utf-8")
target.chmod(0o755)
if HOOK_PIECE in todo:
write_hook(repo, _template("no-gms-signin-hook.yaml"))
return todo

View File

@ -0,0 +1,27 @@
#!/bin/bash
# ============================================================================
# Fail if this app links Google's non-free sign-in (google_sign_in,
# play-services-auth, googleid) instead of google_account_token.
#
# Thin delegate to the shared gate in ~/src/utils, which owns the package
# list and the fix message. Copy this file to scripts/check_no_gms_signin.sh
# in a repo; never copy the shared gate itself.
# ============================================================================
set -euo pipefail
readonly SHARED_GATE="${UTILS_ROOT:-$HOME/src/utils}/scripts/check_no_gms_signin.sh"
main() {
if [[ ! -x "$SHARED_GATE" ]]; then
echo "Error: shared no-gms-signin gate not found at $SHARED_GATE" >&2
echo " Clone github.com/kuhyx/utils to ~/src/utils, or set" >&2
echo " UTILS_ROOT to where it lives." >&2
exit 1
fi
exec bash "$SHARED_GATE" "$@"
}
main "$@"

View File

@ -0,0 +1,12 @@
# Added under `repos: - repo: local: hooks:` by install_repo_contract_gate.sh
# for any repo with a pubspec.yaml beside an android/ dir (a Flutter app).
# No Google non-free sign-in: google_sign_in links play-services-auth /
# googleid into the APK; apps use utils/google_account_token instead.
# always_run rather than `files:`: a poisoned pubspec.lock can arrive by
# pull or merge, and a staged-only trigger would never look again.
- id: no-gms-signin
name: no google_sign_in / GMS auth (use google_account_token)
entry: scripts/check_no_gms_signin.sh .
language: system
pass_filenames: false
always_run: true

View File

@ -0,0 +1,77 @@
"""A Flutter repo with an Android target gets the no-GMS-sign-in gate."""
from __future__ import annotations
import stat
import subprocess
from pathlib import Path
from repo_contract import install as inst
from repo_contract import install_gms as gms
from .conftest import write
def flutter_android(root: Path, sub: str = "") -> Path:
"""A Flutter project at `root/sub` with an android/ dir."""
base = f"{sub}/" if sub else ""
write(root, f"{base}pubspec.yaml", "name: x\n")
write(root, f"{base}android/app/build.gradle.kts", "plugins {}\n")
return root
def test_no_pubspec_means_no_gate(repo: Path) -> None:
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_pubspec_without_android_means_no_gate(repo: Path) -> None:
write(repo, "pubspec.yaml", "name: x\n")
assert gms.plan_gms(repo) == []
def test_root_flutter_android_gets_shim_and_hook(repo: Path) -> None:
flutter_android(repo)
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
assert inst.install(repo)[-2:] == [str(gms.SHIM), gms.HOOK_PIECE]
shim = repo / gms.SHIM
assert shim.stat().st_mode & stat.S_IXUSR
config = (repo / inst.PRECOMMIT).read_text(encoding="utf-8")
assert f"id: {gms.HOOK_ID}" in config
assert "always_run: true" in config
assert gms.plan_gms(repo) == []
assert gms.install_gms(repo) == []
def test_nested_app_dir_counts(repo: Path) -> None:
flutter_android(repo, "app")
assert gms.plan_gms(repo) == [str(gms.SHIM), gms.HOOK_PIECE]
def test_drifted_shim_is_rewritten_hook_kept(repo: Path) -> None:
flutter_android(repo)
gms.install_gms(repo)
write(repo, gms.SHIM.as_posix(), "#!/bin/bash\nexit 0\n")
assert gms.install_gms(repo) == [str(gms.SHIM)]
def test_utils_itself_is_never_given_a_shim(repo: Path) -> None:
flutter_android(repo, "google_account_token")
write(repo, "repo_contract/check.py")
assert gms.plan_gms(repo) == []
def test_installed_shim_delegates_to_the_shared_gate(repo: Path) -> None:
flutter_android(repo)
write(repo, "pubspec.lock", "packages:\n google_sign_in_android:\n")
gms.install_gms(repo)
utils_root = Path(__file__).resolve().parents[2]
proc = subprocess.run(
["bash", str(repo / gms.SHIM), str(repo)],
capture_output=True,
text=True,
check=False,
env={"UTILS_ROOT": str(utils_root), "PATH": "/usr/bin:/bin"},
)
assert proc.returncode == 1
assert "google_account_token" in proc.stderr

193
scripts/check_no_gms_signin.sh Executable file
View File

@ -0,0 +1,193 @@
#!/bin/bash
# ============================================================================
# Fail if a Flutter/Android app links Google's non-free sign-in stack.
#
# Every app signs in through kuhy's FOSS plugin google_account_token
# (utils/google_account_token, Android AccountManager). google_sign_in pulls
# google_sign_in_android, which links play-services-auth / googleid into the
# APK. Nine apps were migrated off it on 2026-11-02 and four more on
# 2026-11-10; nothing else stops a later session from re-adding it.
#
# Lockfile mode (default) reads pubspec.lock package keys, plus the gradle
# files under each lock's sibling android/ dir. Web-only pieces
# (google_sign_in_web, google_sign_in_platform_interface and their transitive
# google_identity_services_web) are allowed while google_sign_in_android is
# absent: kartka's web build uses them for the GIS button and they never
# reach an APK. APK mode looks for the GMS auth library marker files in the
# built APK, the ground truth when a dependency arrives some other way.
#
# Usage: check_no_gms_signin.sh [pubspec.lock | dir ...]
# check_no_gms_signin.sh --apk app.apk
# A dir is searched for pubspec.lock files (build/, .dart_tool/ pruned).
# With no arguments, checks ./pubspec.lock (passes if there is none).
# Exit: 0 clean | 1 non-free sign-in found | 2 usage error
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
readonly BANNED=(google_sign_in google_sign_in_android google_sign_in_ios)
readonly WEB_ONLY=(google_sign_in_web google_sign_in_platform_interface
google_identity_services_web)
# Maven coordinates (and split-form catalog names) of the GMS sign-in stack.
# Anchored so com.google.gms:google-services (Firebase) and
# play-services-auth-api-phone do not trip it.
readonly GRADLE_PATTERN='com\.google\.android\.gms:play-services-(auth|identity-credentials)([^-[:alnum:]]|$)|com\.google\.android\.libraries\.identity\.googleid|androidx\.credentials:credentials-play-services-auth|name[[:space:]]*=[[:space:]]*"(play-services-auth|play-services-identity-credentials|credentials-play-services-auth|googleid)"'
# APK mode: library marker files AGP copies to the APK root (or META-INF),
# one per linked artifact. Dex strings are not used: play-services-base and
# -basement reference GoogleSignInAccount without linking play-services-auth.
# play-services-fido (passkeys) is deliberately allowed.
readonly APK_MARKERS=(
googleid.properties
play-services-auth.properties
play-services-auth-base.properties
play-services-auth-api-phone.properties
play-services-auth-blockstore.properties
play-services-identity-credentials.properties
META-INF/androidx.credentials_credentials-play-services-auth.version
)
readonly PRUNE=(-name build -o -name .dart_tool -o -name .git -o -name .gradle -o -name .claude
-o -name node_modules)
usage() {
echo "Usage: $SCRIPT_NAME [pubspec.lock | dir ...]"
echo " $SCRIPT_NAME --apk app.apk"
echo " Fails when an app links Google's non-free sign-in (GMS auth)."
exit 0
}
fix_hint() {
echo " Use google_account_token instead (FOSS, AccountManager):" >&2
echo " google_account_token:" >&2
echo " git:" >&2
echo " url: https://github.com/kuhyx/utils" >&2
echo " ref: google_account_token-v0.1.0" >&2
echo " path: google_account_token" >&2
echo " See utils/google_account_token/README.md." >&2
}
# Package keys of a pubspec.lock: the 2-space keys under `packages:`.
lock_packages() {
awk '
/^packages:[[:space:]]*$/ { in_packages = 1; next }
/^[^[:space:]]/ { in_packages = 0; next }
in_packages && /^ [A-Za-z0-9_]+:[[:space:]]*$/ {
sub(/^ /, ""); sub(/:.*/, ""); print
}
' "$1"
}
# Prints the offending packages of one lock, one per line.
lock_offenders() {
local -A present=()
local name
while IFS= read -r name; do
present["$name"]=1
done < <(lock_packages "$1")
for name in "${BANNED[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
if [[ -n "${present[google_sign_in_android]:-}" ]]; then
for name in "${WEB_ONLY[@]}"; do
[[ -n "${present[$name]:-}" ]] && echo "$name"
done
fi
return 0
}
# Prints file:line:text for every non-comment GMS sign-in coordinate under
# the lock's sibling android/ dir.
gradle_offenders() {
local android_dir="$1/android" file
[[ -d "$android_dir" ]] || return 0
while IFS= read -r -d '' file; do
grep -nE "$GRADLE_PATTERN" "$file" \
| grep -vE '^[0-9]+:[[:space:]]*(//|#|\*|/\*)' \
| sed "s|^|$file:|" || true
done < <(find "$android_dir" \( "${PRUNE[@]}" \) -prune -o -type f \
\( -name 'build.gradle*' -o -name '*.versions.toml' \) -print0)
}
indent() {
local line
while IFS= read -r line; do
echo " $line"
done
}
check_lock() {
local lock="$1" offenders gradle
offenders="$(lock_offenders "$lock")"
gradle="$(gradle_offenders "$(dirname "$lock")")"
[[ -z "$offenders" && -z "$gradle" ]] && return 0
if [[ -n "$offenders" ]]; then
echo "Error: $lock links Google's non-free sign-in:" >&2
indent <<<"$offenders" >&2
fi
if [[ -n "$gradle" ]]; then
echo "Error: gradle names the GMS sign-in stack:" >&2
indent <<<"$gradle" >&2
fi
return 1
}
check_apk() {
local apk="$1" entries found=()
local marker
[[ -f "$apk" ]] || { echo "Error: no such APK: $apk" >&2; exit 2; }
entries="$(unzip -Z1 "$apk")"
for marker in "${APK_MARKERS[@]}"; do
grep -qxF -- "$marker" <<<"$entries" && found+=("$marker")
done
[[ "${#found[@]}" -eq 0 ]] && return 0
echo "Error: $apk bundles Google's non-free sign-in libraries:" >&2
printf ' %s\n' "${found[@]}" >&2
fix_hint
return 1
}
# Expands the arguments into pubspec.lock paths, NUL-separated.
collect_locks() {
local arg
for arg in "$@"; do
if [[ -d "$arg" ]]; then
find "$arg" \( "${PRUNE[@]}" \) -prune -o -type f \
-name pubspec.lock -print0
else
printf '%s\0' "$arg"
fi
done
}
main() {
case "${1:-}" in
-h|--help) usage ;;
--apk)
[[ $# -eq 2 ]] || { echo "Usage: $SCRIPT_NAME --apk app.apk" >&2; exit 2; }
check_apk "$2"
return
;;
esac
if [[ $# -eq 0 ]]; then
[[ -f pubspec.lock ]] || return 0
set -- pubspec.lock
fi
local -a locks=()
local arg lock failed=0
# Validated up front: collect_locks runs in a process substitution, whose
# exit status would be lost, and a typo'd path must not pass silently.
for arg in "$@"; do
[[ -e "$arg" ]] || { echo "Error: no such file or directory: $arg" >&2; exit 2; }
done
mapfile -d '' locks < <(collect_locks "$@")
for lock in "${locks[@]}"; do
check_lock "$lock" || failed=1
done
[[ "$failed" -eq 0 ]] || fix_hint
return "$failed"
}
main "$@"

View File

@ -9,6 +9,7 @@
# when the repo has .dep-freshness-no-workflow), Commands section. A website
# (Vite/Astro/index.html build, or [agent_ready] in repo-contract.toml) also
# gets the AgentReady gate: config table, shim, hook, agent-ready workflow.
# A Flutter app with android/ also gets the no-GMS-sign-in gate (shim + hook).
#
# Usage: scripts/install_repo_contract_gate.sh <repo> [--check] [--no-bootstrap]
# Exit: 0 contract satisfied | 1 violations left (e.g. <FILL IN>) | 2 not a git repo

View File

@ -0,0 +1,195 @@
"""check_no_gms_signin.sh: no app may link Google's non-free sign-in stack.
Each test writes a throwaway Flutter-shaped project (pubspec.lock, optional
android/ gradle files) or a fake APK built with zipfile, runs the real
script, and asserts on the exit code and the message.
"""
from __future__ import annotations
import subprocess
import zipfile
from pathlib import Path
SCRIPT = Path(__file__).resolve().parent.parent / "check_no_gms_signin.sh"
# The package keys of kuhy's real kartka lock (web-only GIS button).
KARTKA_KEYS = (
"google_account_token",
"google_identity_services_web",
"google_sign_in_platform_interface",
"google_sign_in_web",
)
def _lock(root: Path, *packages: str) -> Path:
"""Write a pubspec.lock under `root` with the given package keys."""
root.mkdir(parents=True, exist_ok=True)
body = ["# Generated by pub", "packages:"]
for name in ("collection", *packages):
body += [
f" {name}:",
' dependency: "direct main"',
" description:",
f" name: {name}",
" source: hosted",
' version: "1.0.0"',
]
body += ["sdks:", ' dart: ">=3.0.0 <4.0.0"', ""]
lock = root / "pubspec.lock"
lock.write_text("\n".join(body), encoding="utf-8")
return lock
def _run(*args: str | Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(SCRIPT), *map(str, args)],
capture_output=True,
text=True,
check=False,
)
def test_clean_lock_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_account_token", "http")
assert _run(lock).returncode == 0
def test_google_sign_in_android_fails_and_names_the_fix(tmp_path: Path) -> None:
lock = _lock(tmp_path, "google_sign_in_android")
proc = _run(lock)
assert proc.returncode == 1
assert str(lock) in proc.stderr
assert "google_sign_in_android" in proc.stderr
assert "google_account_token-v0.1.0" in proc.stderr
assert "google_account_token/README.md" in proc.stderr
def test_app_facing_google_sign_in_fails(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, "google_sign_in"))
assert proc.returncode == 1
assert "google_sign_in " in proc.stderr or "google_sign_in\n" in proc.stderr
def test_kartka_style_web_only_passes(tmp_path: Path) -> None:
assert _run(_lock(tmp_path, *KARTKA_KEYS)).returncode == 0
def test_web_pieces_fail_once_android_is_linked(tmp_path: Path) -> None:
proc = _run(_lock(tmp_path, *KARTKA_KEYS, "google_sign_in_android"))
assert proc.returncode == 1
assert "google_sign_in_web" in proc.stderr
def test_gradle_play_services_auth_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "app" / "build.gradle.kts"
gradle.parent.mkdir(parents=True)
gradle.write_text(
'dependencies {\n implementation("com.google.gms:google-services:4")\n'
' implementation("com.google.android.gms:play-services-auth:21.3.0")\n}\n',
encoding="utf-8",
)
proc = _run(lock)
assert proc.returncode == 1
assert str(gradle) in proc.stderr
assert "play-services-auth" in proc.stderr
def test_versions_toml_googleid_fails(tmp_path: Path) -> None:
lock = _lock(tmp_path)
toml = tmp_path / "android" / "gradle" / "libs.versions.toml"
toml.parent.mkdir(parents=True)
toml.write_text(
'googleid = "com.google.android.libraries.identity.googleid:googleid:1.1"\n',
encoding="utf-8",
)
assert _run(lock).returncode == 1
def test_firebase_google_services_plugin_passes(tmp_path: Path) -> None:
lock = _lock(tmp_path)
gradle = tmp_path / "android" / "build.gradle"
gradle.parent.mkdir(parents=True)
gradle.write_text(
"// play-services-auth was removed 2026-11-02\n"
"classpath 'com.google.gms:google-services:4.4.2'\n",
encoding="utf-8",
)
assert _run(lock).returncode == 0
def test_directory_mode_finds_nested_locks_and_prunes_build(tmp_path: Path) -> None:
_lock(tmp_path / "app", "google_account_token")
_lock(tmp_path / "app" / "build" / "x", "google_sign_in_android")
_lock(tmp_path / ".dart_tool" / "y", "google_sign_in_android")
# Agent worktrees are other checkouts, judged in their own repo.
_lock(tmp_path / ".claude" / "worktrees" / "z", "google_sign_in_android")
assert _run(tmp_path).returncode == 0
_lock(tmp_path / "other", "google_sign_in_ios")
proc = _run(tmp_path)
assert proc.returncode == 1
assert "google_sign_in_ios" in proc.stderr
def test_missing_explicit_file_is_a_usage_error(tmp_path: Path) -> None:
assert _run(tmp_path / "nope.lock").returncode == 2
CREDENTIALS_MARKER = "META-INF/androidx.credentials_credentials-play-services-auth.version"
DEX_REF = b"\x00Lcom/google/android/gms/auth/api/signin/GoogleSignInAccount;\x00" * 2
def _apk(path: Path, dex: bytes = b"", markers: tuple[str, ...] = ()) -> Path:
"""Fake APK: dex strings plus library marker files AGP copies in."""
with zipfile.ZipFile(path, "w") as apk:
apk.writestr("AndroidManifest.xml", b"\x03\x00")
apk.writestr("classes.dex", b"dex\n035\x00" + dex)
apk.writestr("classes2.dex", b"dex\n035\x00Lkotlin/Unit;\x00")
for name in markers:
apk.writestr(name, b"version=1")
return path
def test_apk_with_only_dex_references_passes(tmp_path: Path) -> None:
"""play-services-base references GoogleSignInAccount without linking auth."""
markers = (
"play-services-base.properties",
"play-services-basement.properties",
"play-services-tasks.properties",
"play-services-fido.properties",
)
proc = _run("--apk", _apk(tmp_path / "ok.apk", DEX_REF, markers))
assert proc.returncode == 0, proc.stderr
def test_apk_without_markers_passes(tmp_path: Path) -> None:
assert _run("--apk", _apk(tmp_path / "clean.apk")).returncode == 0
def test_apk_banned_markers_fail_and_are_named(tmp_path: Path) -> None:
banned = (
"play-services-auth.properties",
"googleid.properties",
"play-services-auth-base.properties",
"play-services-auth-api-phone.properties",
"play-services-auth-blockstore.properties",
"play-services-identity-credentials.properties",
CREDENTIALS_MARKER,
)
for marker in banned:
apk = _apk(tmp_path / "bad.apk", DEX_REF, ("play-services-base.properties", marker))
proc = _run("--apk", apk)
assert proc.returncode == 1, marker
assert marker in proc.stderr
assert "google_account_token" in proc.stderr
def test_apk_nested_marker_name_is_not_a_root_marker(tmp_path: Path) -> None:
"""Only APK-root files count; a same-named file in a subdir is not AGP's."""
apk = _apk(tmp_path / "nested.apk", markers=("assets/play-services-auth.properties",))
assert _run("--apk", apk).returncode == 0
def test_apk_missing_file_is_a_usage_error(tmp_path: Path) -> None:
assert _run("--apk", tmp_path / "nope.apk").returncode == 2

View File

@ -70,22 +70,35 @@ refused (past day, already sick, not sick, a free day, sync unavailable),
## 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
Tk -- calendar, a six-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):
No words inside the window (textgate): dates are ISO numerals (`2026-11`,
`2026-11-27`), each locker is a drawn pictogram (dumbbell = workout, `</>` =
LeetCode, open book, apple = diet, alarm clock, target = focus), switches are
knob-right-on-accent / knob-left-on-dark, buttons are check (mark / save),
red bin (clear) and cross (cancel). A refusal is an icon in the status slot:
padlock (past), no-entry (free day), warning (cannot check, or a refused
write; the detail goes to the log), hourglass (a sync is still running).
Icons are Canvas geometry, not font glyphs: the venv's Tk uses core X fonts,
where non-ASCII glyphs render as nothing. The title bar keeps "Sick days"
(an app's name, textgate settled decision 4). The guard is
`tests/test_gui_no_words.py`: no letter on any widget, in any state.
```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 refusal (or a click mid-sync) shows in the panel.
Normal day: six switches, all off (off = stands down), `actions.mark`; sick
day: its switches, `actions.save`, `actions.clear`. Past/free/uncheckable
days show their icon and offer no write; a refusal (or a click mid-sync)
shows in the panel's `status` slot, named by `panel.reason`.
`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.
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
on an unchanged save (nothing written). The panel never destroys itself.
## API

View File

@ -120,6 +120,9 @@ disable = []
min-public-methods = 0
max-module-lines = 1000
max-attributes = 10
# A tk.Canvas subclass (gui/_controls.py) has 9 ancestors by construction:
# Canvas mixes in XView and YView on top of Widget's chain.
max-parents = 9
[tool.pylint.typecheck]
generated-members = [

View File

@ -1,29 +1,33 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The month grid: Monday-first, one button per day, prev/next month arrows.
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
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.
with the window; this only reports the date. No words: the month reads
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
from sickdays._day import to_iso
from sickdays.gui import _text
from sickdays.gui import _dates
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
from sickdays.gui._controls import IconButton, IconSlot
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}"
#: The weekday marks over the grid, in px.
_MARK_PX: Final = 10
#: The keep-a-locker dot inside a sick day's cell, in px.
_DOT_PX: Final = 8
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
@ -56,7 +60,7 @@ class MonthCalendar(tk.Frame):
self._is_sick = is_sick
self._has_dot = has_dot
self._on_day = on_day
self._first, self._last = _text.month_bounds(today)
self._first, self._last = _dates.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")
@ -66,7 +70,7 @@ class MonthCalendar(tk.Frame):
def step(self, months: int) -> None:
"""Move ``months`` months (within the phone's range) and redraw."""
target = _text.shift_month(self.month, months)
target = _dates.shift_month(self.month, months)
if self._first <= target <= self._last:
self.month = target
self.redraw()
@ -77,11 +81,12 @@ class MonthCalendar(tk.Frame):
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 column in range(7):
mark = "ring" if _dates.is_weekend_column(column) else "dot"
IconSlot(self._grid, size=_MARK_PX).show(mark, tok.MUTED_ON_DARK).grid(
row=0, column=column, pady=(0, tok.XS)
)
for row, week in enumerate(_text.month_weeks(self.month), start=1):
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
for column, day in enumerate(week):
if day is not None:
self._cell(day).grid(
@ -89,20 +94,20 @@ class MonthCalendar(tk.Frame):
)
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 = IconButton(self._header, "prev", lambda: self.step(-1), name="prev")
forward = IconButton(self._header, "next", lambda: self.step(1), name="next")
back.set_enabled(self.month > self._first)
forward.set_enabled(self.month < self._last)
back.pack(side="left")
forward.pack(side="right")
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
ui.label(self._header, _dates.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))
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
cell = tk.Button(
self._grid,
name=f"day-{to_iso(day)}",
@ -124,7 +129,6 @@ class MonthCalendar(tk.Frame):
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")
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
return cell

View File

@ -0,0 +1,171 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Word-free controls: an icon button, an on/off switch and an icon slot.
Canvases, because a Tk Button or Checkbutton can only show its state as a
word or a font glyph (see :mod:`sickdays.gui._icons`). Each keeps Tk's own
``state`` option and offers ``invoke()``, so callers and tests drive them
like the widgets they replace. Every state has two channels: shape (icon,
knob position) plus colour.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING, Final, Literal
from sickdays.gui import _tokens as tok
from sickdays.gui._icons import TAG, draw
if TYPE_CHECKING:
from collections.abc import Callable
ButtonKind = Literal["primary", "danger", "plain"]
#: (fill, icon colour) per button kind; danger keeps its red on a dark fill.
_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),
}
ICON_PX: Final = 20
_TRACK_W: Final = 44
_TRACK_H: Final = 24
def _enabled(widget: tk.Canvas) -> bool:
return str(widget.cget("state")) != "disabled"
class IconButton(tk.Canvas):
"""A flat button showing ``icon``; ``invoke()`` runs ``command`` unless disabled."""
def __init__(
self,
parent: tk.Misc,
icon: str,
command: Callable[[], object],
*,
name: str,
kind: ButtonKind = "plain",
) -> None:
"""Draw the button; a click anywhere on it invokes it."""
fill, self._colour = _BUTTON_COLOURS[kind]
super().__init__(
parent,
name=name,
width=ICON_PX + 2 * tok.MD,
height=ICON_PX + 2 * tok.SM,
bg=fill,
highlightthickness=0,
borderwidth=0,
cursor="hand2",
)
#: Which pictogram this button shows.
self.icon = icon
self._command = command
self.bind("<Button-1>", lambda _event: self.invoke())
self._paint()
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
"""Grey the icon out and ignore clicks while disabled."""
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
self._paint()
def invoke(self) -> None:
"""Run the command, as a click would."""
if _enabled(self):
self._command()
def _paint(self) -> None:
self.delete(TAG)
colour = self._colour if _enabled(self) else tok.MUTED_ON_DARK
draw(self, self.icon, size=ICON_PX, colour=colour, at=(tok.MD, tok.SM))
class Toggle(tk.Canvas):
"""An on/off switch for ``variable``: knob right on accent, left on dark."""
def __init__(self, parent: tk.Misc, variable: tk.BooleanVar, *, name: str) -> None:
"""Draw the switch in ``variable``'s current state."""
super().__init__(
parent,
name=name,
width=_TRACK_W,
height=_TRACK_H,
bg=parent.cget("bg"),
highlightthickness=0,
borderwidth=0,
cursor="hand2",
)
self._variable = variable
self.bind("<Button-1>", lambda _event: self.invoke())
self.paint()
def set_enabled(self, enabled: bool) -> None: # noqa: FBT001
"""Fade the switch and ignore clicks while disabled."""
self.configure(
state="normal" if enabled else "disabled",
cursor="hand2" if enabled else "arrow",
)
self.paint()
def invoke(self) -> None:
"""Flip the switch, as a click would."""
if _enabled(self):
self._variable.set(not self._variable.get())
self.paint()
def is_on(self) -> bool:
"""Whether the locker this switch stands for still applies."""
return bool(self._variable.get())
def paint(self) -> None:
"""Redraw the track and knob from the variable."""
self.delete(TAG)
on = self.is_on()
track, knob = (
(tok.ACCENT, tok.ON_FILL) if on else (tok.INK_RAISED_2, tok.MUTED_ON_DARK)
)
if not _enabled(self):
track = tok.blend(track, tok.INK, tok.PAST_ALPHA)
knob = tok.blend(knob, tok.INK, tok.PAST_ALPHA)
h, w = _TRACK_H, _TRACK_W
self.create_oval(0, 0, h, h, fill=track, outline=track, tags=TAG)
self.create_oval(w - h, 0, w, h, fill=track, outline=track, tags=TAG)
self.create_rectangle(
h // 2, 0, w - h // 2, h, fill=track, outline=track, tags=TAG
)
left = w - h + 3 if on else 3
self.create_oval(
left, 3, left + h - 6, h - 3, fill=knob, outline=knob, tags=TAG
)
class IconSlot(tk.Canvas):
"""One icon, or nothing: a status mark, a locker's pictogram, a marker."""
def __init__(self, parent: tk.Misc, *, name: str = "", size: int = ICON_PX) -> None:
"""An empty slot ``size`` px square on ``parent``'s background."""
super().__init__(
parent,
name=name,
width=size,
height=size,
bg=parent.cget("bg"),
highlightthickness=0,
borderwidth=0,
)
self._size = size
#: The pictogram shown, or ``None`` when the slot is empty.
self.icon: str | None = None
def show(self, icon: str | None, colour: str = tok.TEXT_ON_DARK) -> IconSlot:
"""Show ``icon`` in ``colour`` (``None`` clears the slot)."""
self.delete(TAG)
self.icon = icon
if icon is not None:
draw(self, icon, size=self._size, colour=colour)
return self

View File

@ -0,0 +1,86 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Calendar arithmetic and the free-day check for the picker -- no Tk in here.
The picker shows no words (kuhy's textgate rule): dates are ISO numerals and
a refusal is a reason *code* the widgets turn into an icon. The library's
own message for an unavailable check goes to the log, not the screen.
"""
from __future__ import annotations
import calendar
from datetime import date
import logging
from typing import TYPE_CHECKING, Final, Literal
from sickdays._conflict import free_day_predicate
from sickdays._constants import LOCKERS
from sickdays._errors import ConflictCheckUnavailableError
if TYPE_CHECKING:
from collections.abc import Iterable
_LOG = logging.getLogger(__name__)
#: Why a day cannot be marked sick: it is a free day, or that cannot be checked.
Block = Literal["free", "unavailable"]
#: Tue/Wed/Thu, shaded like the phone's grid (wake-alarm's ``isWorkday``).
_WORKDAYS: Final = frozenset({1, 2, 3})
#: Sat/Sun, marked with a ring instead of a dot over the grid.
_WEEKEND: Final = frozenset({5, 6})
def kept_in_order(kept: Iterable[str]) -> tuple[str, ...]:
"""The kept lockers in ``sickdays.LOCKERS`` order, not the set's."""
chosen = set(kept)
return tuple(locker for locker in LOCKERS if locker in chosen)
def free_day_block(day: date) -> Block | None:
"""Why ``day`` cannot be marked sick for the free-day pool, or None.
"Could not check" refuses too: it must never count as "not a free day".
"""
try:
is_free = free_day_predicate()
except ConflictCheckUnavailableError as exc:
_LOG.warning("free-day check unavailable: %s", exc)
return "unavailable"
return "free" if is_free(day) else None
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 is_weekend_column(column: int) -> bool:
"""Whether grid ``column`` (0 = Monday) is Saturday or Sunday."""
return column in _WEEKEND
def month_title(month: date) -> str:
"""``"2026-10"``: the month as numerals, never a month name."""
return f"{month.year}-{month.month:02d}"
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,235 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Pictograms drawn as Canvas geometry, so the picker needs no words.
Drawn rather than typed: the venv's Tk falls back to core X fonts, where
every non-ASCII glyph (angle quotes, check marks) renders as nothing; the
old month arrows were blank buttons. Each icon is plotted on a unit square,
scaled to ``size`` pixels, and tagged ``icon`` so a repaint can drop it.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from sickdays._constants import (
LOCKER_ALARM,
LOCKER_BOOK,
LOCKER_DIET,
LOCKER_FOCUS,
LOCKER_LEETCODE,
LOCKER_WORKOUT,
)
if TYPE_CHECKING:
from collections.abc import Callable
import tkinter as tk
TAG: Final = "icon"
class Pen:
"""Draws unit-square coordinates onto ``canvas`` at ``(x, y)``, ``size`` wide."""
def __init__(
self, canvas: tk.Canvas, *, size: int, colour: str, at: tuple[int, int]
) -> None:
"""Remember where and how large to draw."""
self._canvas = canvas
self._size = size
self._colour = colour
self._x, self._y = at
self.width = max(2, round(size / 10))
def _xy(self, points: tuple[float, ...]) -> list[float]:
return [
(self._y if i % 2 else self._x) + value * self._size
for i, value in enumerate(points)
]
def line(self, *points: float) -> None:
"""A stroke through ``points`` (x0, y0, x1, y1, ...)."""
self._canvas.create_line(
*self._xy(points),
fill=self._colour,
width=self.width,
capstyle="round",
joinstyle="round",
tags=TAG,
)
def oval(self, *box: float, filled: bool = False) -> None:
"""An ellipse in ``box`` (x0, y0, x1, y1), outlined or filled."""
self._canvas.create_oval(
*self._xy(box),
outline=self._colour,
fill=self._colour if filled else "",
width=self.width,
tags=TAG,
)
def shape(self, *points: float, filled: bool = False) -> None:
"""A closed polygon through ``points``, outlined or filled."""
self._canvas.create_polygon(
*self._xy(points),
outline=self._colour,
fill=self._colour if filled else "",
width=self.width,
joinstyle="round",
tags=TAG,
)
def arch(self, *box: float) -> None:
"""The top half of the ellipse in ``box`` (a padlock's shackle)."""
self._canvas.create_arc(
*self._xy(box),
start=0,
extent=180,
style="arc",
outline=self._colour,
width=self.width,
tags=TAG,
)
def _check(pen: Pen) -> None:
pen.line(0.2, 0.55, 0.42, 0.76, 0.82, 0.28)
def _cross(pen: Pen) -> None:
pen.line(0.25, 0.25, 0.75, 0.75)
pen.line(0.75, 0.25, 0.25, 0.75)
def _trash(pen: Pen) -> None:
pen.line(0.18, 0.26, 0.82, 0.26)
pen.line(0.4, 0.26, 0.42, 0.14, 0.58, 0.14, 0.6, 0.26)
pen.shape(0.27, 0.34, 0.73, 0.34, 0.68, 0.88, 0.32, 0.88)
def _prev(pen: Pen) -> None:
pen.line(0.62, 0.22, 0.36, 0.5, 0.62, 0.78)
def _next(pen: Pen) -> None:
pen.line(0.38, 0.22, 0.64, 0.5, 0.38, 0.78)
def _workout(pen: Pen) -> None:
pen.line(0.2, 0.5, 0.8, 0.5)
pen.shape(0.17, 0.28, 0.29, 0.28, 0.29, 0.72, 0.17, 0.72, filled=True)
pen.shape(0.71, 0.28, 0.83, 0.28, 0.83, 0.72, 0.71, 0.72, filled=True)
pen.line(0.08, 0.5, 0.17, 0.5)
pen.line(0.83, 0.5, 0.92, 0.5)
def _leetcode(pen: Pen) -> None:
pen.line(0.36, 0.28, 0.12, 0.5, 0.36, 0.72)
pen.line(0.64, 0.28, 0.88, 0.5, 0.64, 0.72)
pen.line(0.57, 0.2, 0.43, 0.8)
def _book(pen: Pen) -> None:
pen.shape(0.5, 0.3, 0.1, 0.2, 0.1, 0.76, 0.5, 0.86)
pen.shape(0.5, 0.3, 0.9, 0.2, 0.9, 0.76, 0.5, 0.86)
def _diet(pen: Pen) -> None:
pen.oval(0.2, 0.34, 0.8, 0.9, filled=True)
pen.line(0.5, 0.34, 0.54, 0.14)
pen.oval(0.56, 0.12, 0.78, 0.26, filled=True)
def _alarm(pen: Pen) -> None:
pen.oval(0.18, 0.24, 0.82, 0.88)
pen.line(0.5, 0.38, 0.5, 0.56, 0.63, 0.64)
pen.oval(0.08, 0.1, 0.3, 0.3, filled=True)
pen.oval(0.7, 0.1, 0.92, 0.3, filled=True)
def _focus(pen: Pen) -> None:
pen.oval(0.1, 0.1, 0.9, 0.9)
pen.oval(0.3, 0.3, 0.7, 0.7)
pen.oval(0.44, 0.44, 0.56, 0.56, filled=True)
def _padlock(pen: Pen) -> None:
pen.arch(0.3, 0.12, 0.7, 0.62)
pen.shape(0.22, 0.44, 0.78, 0.44, 0.78, 0.88, 0.22, 0.88, filled=True)
def _no_entry(pen: Pen) -> None:
pen.oval(0.14, 0.14, 0.86, 0.86)
pen.line(0.28, 0.28, 0.72, 0.72)
def _warning(pen: Pen) -> None:
pen.shape(0.5, 0.12, 0.9, 0.86, 0.1, 0.86)
pen.line(0.5, 0.38, 0.5, 0.6)
pen.oval(0.47, 0.71, 0.53, 0.77, filled=True)
def _hourglass(pen: Pen) -> None:
pen.line(0.22, 0.12, 0.78, 0.12)
pen.line(0.22, 0.88, 0.78, 0.88)
pen.shape(0.28, 0.12, 0.72, 0.12, 0.28, 0.88, 0.72, 0.88)
def _sick(pen: Pen) -> None:
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85, filled=True)
def _normal(pen: Pen) -> None:
pen.shape(0.15, 0.15, 0.85, 0.15, 0.85, 0.85, 0.15, 0.85)
def _dot(pen: Pen) -> None:
pen.oval(0.3, 0.3, 0.7, 0.7, filled=True)
def _ring(pen: Pen) -> None:
pen.oval(0.25, 0.25, 0.75, 0.75)
ICONS: Final[dict[str, Callable[[Pen], None]]] = {
"check": _check,
"cross": _cross,
"trash": _trash,
"prev": _prev,
"next": _next,
"workout": _workout,
"leetcode": _leetcode,
"book": _book,
"diet": _diet,
"alarm": _alarm,
"focus": _focus,
"padlock": _padlock,
"no_entry": _no_entry,
"warning": _warning,
"hourglass": _hourglass,
"sick": _sick,
"normal": _normal,
"dot": _dot,
"ring": _ring,
}
#: The pictogram each locker is shown as, in place of its name.
LOCKER_ICONS: Final = {
LOCKER_WORKOUT: "workout",
LOCKER_LEETCODE: "leetcode",
LOCKER_BOOK: "book",
LOCKER_DIET: "diet",
LOCKER_ALARM: "alarm",
LOCKER_FOCUS: "focus",
}
def draw(
canvas: tk.Canvas,
icon: str,
*,
size: int,
colour: str,
at: tuple[int, int] = (0, 0),
) -> None:
"""Draw ``icon`` on ``canvas`` in a ``size`` px square whose corner is ``at``."""
ICONS[icon](Pen(canvas, size=size, colour=colour, at=at))

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the five switches for one day, and the buttons that write them.
"""KeepPanel: a switch per locker 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,
@ -7,29 +7,34 @@ 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.
-- the phone's ``SickDaySheet``. A normal day offers "mark sick" (check); a
sick day offers "save" (check, a keep-only edit) and "clear" (red bin). A
past day (padlock), a free day (no-entry sign) or a free-day pool that cannot
be checked (warning) shows its icon and offers no write. A refused write is
shown in the panel as an icon, never raised: an exception in a Tk callback
only reaches stderr, which nobody sees under a lock. The panel shows no
words (kuhy's textgate rule): a locker is its pictogram, the day its ISO date.
"""
from __future__ import annotations
import logging
import threading
import tkinter as tk
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
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._day import to_iso, 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 _dates
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
from sickdays.gui._controls import IconButton, IconSlot, Toggle
from sickdays.gui._icons import LOCKER_ICONS
if TYPE_CHECKING:
from collections.abc import Callable
@ -39,6 +44,17 @@ if TYPE_CHECKING:
from sickdays._paths import Paths
_LOG = logging.getLogger(__name__)
#: (icon, colour) for each reason the panel shows, keyed by that reason.
STATUS_ICONS: Final = {
"past": ("padlock", tok.MUTED_ON_DARK),
"free": ("no_entry", tok.DANGER),
"unavailable": ("warning", tok.DANGER),
"refused": ("warning", tok.DANGER),
"busy": ("hourglass", tok.DANGER),
}
#: Held by every background sync, and tried by every write. ``sync()`` reads
#: the pool, waits on the network, then writes its merged copy back, so a
#: write landing in between would be silently overwritten. A write that finds
@ -73,16 +89,17 @@ class KeepPanel(tk.Frame):
``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``).
* ``actions.mark`` -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
* ``actions.save`` with a changed set -> the stored entry (``is_sick=True``);
* ``actions.save`` with nothing changed -> ``None``: nothing written, no sync;
* ``actions.clear`` -> the cleared entry (``is_sick=False``).
A refused write (:class:`~sickdays.SickDayError`, or a click while the
previous write's sync is still running) 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.
previous write's sync is still running) is shown in the panel's
``status`` slot and ``on_saved`` is not called; :attr:`reason` names it
(a :data:`STATUS_ICONS` key). ``on_cancel``, when given, adds an
``actions.cancel`` cross that calls it. The panel never destroys itself;
the callbacks decide what happens to it.
"""
def __init__(
@ -107,12 +124,15 @@ class KeepPanel(tk.Frame):
}
#: The last background sync, for callers (and tests) that wait on it.
self.sync_thread: threading.Thread | None = None
#: Why the panel refuses, a :data:`STATUS_ICONS` key, or ``None``.
self.reason: str | 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 = IconSlot(self, name="status")
self._status.grid(
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
)
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
self._show(blocked)
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
def kept(self) -> frozenset[str]:
@ -121,49 +141,54 @@ class KeepPanel(tk.Frame):
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)
return "past"
return None if self._sick else _dates.free_day_block(self._day)
def _show(self, reason: str | None) -> None:
self.reason = reason
if reason is None:
self._status.show(None)
return
icon, colour = STATUS_ICONS[reason]
self._status.show(icon, colour)
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(
head = tk.Frame(self, name="head", bg=self.cget("bg"))
head.grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, tok.SM))
IconSlot(head, name="state").show(
"sick" if self._sick else "normal", tok.SICK
).pack(side="left", padx=(0, tok.SM))
ui.label(head, to_iso(self._day), "subtitle", name="title").pack(side="left")
for row, locker in enumerate(LOCKERS, start=1):
IconSlot(self, name=f"icon-{locker}").show(LOCKER_ICONS[locker]).grid(
row=row, column=0, sticky="w", pady=tok.XS
)
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
switch = 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")
switch.set_enabled(False)
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)
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
)
buttons: list[tk.Button] = []
buttons: list[IconButton] = []
if writable and self._sick:
buttons.append(
ui.button(actions, "Save", self._save, kind="primary", name="save")
IconButton(actions, "check", self._save, kind="primary", name="save")
)
buttons.append(
ui.button(
actions, "Clear sick day", self._clear, kind="danger", name="clear"
)
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
)
elif writable:
buttons.append(
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
IconButton(actions, "check", self._mark, kind="primary", name="mark")
)
if on_cancel is not None:
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
for button in buttons:
button.pack(side="left", padx=(0, tok.SM))
@ -187,12 +212,13 @@ class KeepPanel(tk.Frame):
# Non-blocking on purpose (no ``with``): waiting would freeze Tk for
# the whole network round trip.
if not _SYNC_LOCK.acquire(blocking=False): # pylint: disable=consider-using-with
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
self._show("busy")
return
try:
entry = action()
except SickDayError as exc:
self._status.configure(text=str(exc), fg=tok.DANGER)
_LOG.warning("sick-day write refused: %s", exc)
self._show("refused")
return
finally:
_SYNC_LOCK.release()

View File

@ -1,125 +0,0 @@
# 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_FOCUS,
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",
LOCKER_FOCUS: "Focus",
}
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."
BUSY_NOTE: Final = "Syncing \u2014 try again in a moment."
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

@ -1,33 +1,20 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Token-styled Tk building blocks shared by the panel and the window.
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
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.
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import font as tkfont
from typing import TYPE_CHECKING, Final, Literal
from typing import Final
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."""
@ -42,10 +29,7 @@ def label(
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.
"""
"""A label on ``parent``'s own background; titles and subtitles are bold."""
return tk.Label(
parent,
name=name,
@ -55,71 +39,4 @@ def label(
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

@ -2,26 +2,33 @@
"""``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.
the clicked day, the sick days still ahead underneath (each an ISO date and
the pictograms of the lockers it keeps). The window class is ``sickdays``
(Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3 ``for_window``
rule can float it; ``Escape`` closes it.
No words inside the window (kuhy's textgate rule). The title bar keeps the
app's name: an OS-level app name is a proper noun (textgate settled
decision 4), the same as ``sickdays.desktop``'s ``Name=``.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Final
from sickdays._api import is_sick_day, load
from sickdays._day import today
from sickdays._day import to_iso, 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 _dates
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
from sickdays.gui._controls import IconSlot
from sickdays.gui._icons import LOCKER_ICONS
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
if TYPE_CHECKING:
from datetime import date
@ -30,19 +37,18 @@ if TYPE_CHECKING:
from sickdays._paths import Paths
WINDOW_CLASS = "sickdays"
WINDOW_TITLE = "Sick days"
#: A kept locker's pictogram in the list of days ahead, in px.
_KEPT_PX: Final = 16
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"
)
def saved_icon(entry: SickDay | None) -> str | None:
"""The status mark after the panel closed with ``entry``.
A check for any write (the grid and the list show what it was); nothing
when the panel was saved unchanged.
"""
return None if entry is None else "check"
class SickDaysApp:
@ -52,7 +58,7 @@ class SickDaysApp:
"""Style ``root`` and draw the current pool."""
self._root = root
self._paths = resolve_paths(paths)
root.title("Sick days")
root.title(WINDOW_TITLE)
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
root.bind("<Escape>", self.close)
left = tk.Frame(root, bg=tok.INK)
@ -60,13 +66,9 @@ class SickDaysApp:
#: 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))
#: The last outcome's mark: a check, or the panel's refusal icons.
self.status = IconSlot(left, name="status")
self.status.pack(anchor="w")
log = self._paths.log
self.calendar = MonthCalendar(
left,
@ -75,8 +77,8 @@ class SickDaysApp:
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(
self.calendar.pack(anchor="w", pady=(tok.SM, 0))
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
anchor="w", pady=(tok.LG, tok.SM)
)
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
@ -88,25 +90,25 @@ class SickDaysApp:
self._root.destroy()
def select(self, day: date) -> None:
"""Open the panel for ``day``, or refuse a free day in the status line."""
"""Open the panel for ``day``, or refuse a free day in the status mark."""
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)
block = _dates.free_day_block(day)
if block is not None:
self.status.show(*STATUS_ICONS[block])
return
self._set_status("", failed=False)
self.status.show(None)
KeepPanel(
self.host,
day,
on_saved=lambda entry: self._saved(entry, day),
on_saved=self._saved,
on_cancel=self._close_panel,
paths=self._paths,
).pack(anchor="n")
def _saved(self, entry: SickDay | None, day: date) -> None:
def _saved(self, entry: SickDay | None) -> None:
self._close_panel()
self._set_status(saved_message(entry, day), failed=False)
self.status.show(saved_icon(entry), tok.SUCCESS)
self.calendar.redraw()
self._draw_upcoming()
@ -114,29 +116,20 @@ class SickDaysApp:
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))
for entry in sick_entries(load(self._paths.log)):
if entry.day < reference:
continue
row = tk.Frame(self._list, name=f"day-{to_iso(entry.day)}", bg=tok.INK)
row.pack(anchor="w", pady=(0, tok.XS))
ui.label(row, to_iso(entry.day), "body").pack(side="left")
for locker in _dates.kept_in_order(entry.keep):
IconSlot(row, name=f"kept-{locker}", size=_KEPT_PX).show(
LOCKER_ICONS[locker], tok.MUTED_ON_DARK
).pack(side="left", padx=(tok.SM, 0))
def run(*, paths: Paths | None = None) -> int:

View File

@ -61,7 +61,7 @@ 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._dates.free_day_predicate", lambda: _never_free)
monkeypatch.setattr("sickdays.gui._panel.sync_quietly", _no_sync)

View File

@ -0,0 +1,70 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The word-free controls and the drawn icons, against a real Tk."""
from __future__ import annotations
import tkinter as tk
import pytest
from sickdays._constants import LOCKERS
from sickdays.gui import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot, Toggle
from sickdays.gui._icons import ICONS, LOCKER_ICONS, TAG
@pytest.fixture
def shown(tk_root: tk.Tk) -> tk.Tk:
"""The root mapped, so real clicks reach the canvases."""
tk_root.deiconify()
tk_root.update()
return tk_root
def _click(widget: tk.Canvas) -> None:
widget.pack()
widget.update()
widget.event_generate("<Button-1>", x=2, y=2)
widget.update()
def test_every_icon_draws_something(tk_root: tk.Tk) -> None:
for icon in ICONS:
slot = IconSlot(tk_root, size=32).show(icon, tok.DANGER)
assert slot.icon == icon
assert slot.find_withtag(TAG), icon
assert slot.show(None).icon is None
assert slot.find_withtag(TAG) == ()
def test_every_locker_has_an_icon() -> None:
assert set(LOCKER_ICONS) == set(LOCKERS)
assert set(LOCKER_ICONS.values()) <= set(ICONS)
def test_a_click_invokes_the_button_unless_disabled(shown: tk.Tk) -> None:
calls: list[int] = []
button = IconButton(shown, "check", lambda: calls.append(1), name="ok")
button.set_enabled(False)
assert str(button.cget("cursor")) == "arrow"
_click(button)
button.invoke()
assert calls == []
button.set_enabled(True)
_click(button)
assert calls == [1]
def test_a_click_flips_the_switch_unless_disabled(shown: tk.Tk) -> None:
on = tk.BooleanVar(shown, value=False)
switch = Toggle(shown, on, name="switch")
_click(switch)
assert switch.is_on()
assert on.get()
switch.set_enabled(False)
_click(switch)
switch.invoke()
assert switch.is_on()
switch.set_enabled(True)
switch.invoke()
assert not switch.is_on()

View File

@ -0,0 +1,87 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The picker's calendar arithmetic, free-day check and tokens -- no display."""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import _dates
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
import pytest
DAY = date(2026, 10, 20)
def test_kept_lockers_follow_locker_order_not_the_alphabet() -> None:
assert _dates.kept_in_order({"alarm", "book", "workout"}) == (
"workout",
"book",
"alarm",
)
assert _dates.kept_in_order(frozenset()) == ()
def test_free_day_block(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
assert _dates.free_day_block(DAY) is None
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
)
assert _dates.free_day_block(DAY) == "free"
def unavailable() -> None:
msg = "freedays is not installed"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
with caplog.at_level(logging.WARNING):
assert _dates.free_day_block(DAY) == "unavailable"
assert "freedays is not installed" in caplog.text
def test_month_grid_is_monday_first() -> None:
weeks = _dates.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 _dates.month_title(date(2026, 10, 1)) == "2026-10"
assert _dates.month_title(date(2027, 3, 1)) == "2027-03"
assert [_dates.is_weekend_column(c) for c in range(7)] == [
False,
False,
False,
False,
False,
True,
True,
]
def test_month_arithmetic_and_range() -> None:
assert _dates.shift_month(date(2026, 12, 1), 1) == date(2027, 1, 1)
assert _dates.shift_month(date(2026, 1, 1), -1) == date(2025, 12, 1)
assert _dates.month_bounds(DAY) == (date(2026, 1, 1), date(2027, 12, 1))
def test_workdays_are_tuesday_to_thursday() -> None:
assert [_dates.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,113 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The textgate guard: no letter on any widget of the picker, in any state.
Everything this window shows is chrome except numerals and ISO dates (day
numbers, ``2026-11``, ``2026-11-13``), so the guard bans every letter, not
just words. The window title is the app's proper noun (textgate settled
decision 4) and is checked on its own.
"""
from __future__ import annotations
from datetime import timedelta
import re
import tkinter as tk
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day
from sickdays._day import today
from sickdays._errors import ConflictCheckUnavailableError
from sickdays.gui import KeepPanel, SickDaysApp, _panel
if TYPE_CHECKING:
import pytest
FUTURE = today() + timedelta(days=3)
LETTER = re.compile(r"[^\W\d_]")
def _shown(widget: tk.Misc) -> list[str]:
"""Every string ``widget`` and its descendants paint."""
found: list[str] = []
for child in widget.winfo_children():
if isinstance(child, tk.Canvas):
# Raw Tcl: typeshed types Canvas.type()/itemcget() too loosely.
path = str(child)
found.extend(
str(child.tk.call(path, "itemcget", item, "-text"))
for item in child.find_all()
if str(child.tk.call(path, "type", item)) == "text"
)
elif isinstance(child, (tk.Label, tk.Button, tk.Checkbutton)):
found.append(str(child.cget("text")))
found.extend(_shown(child))
return found
def _assert_no_letters(widget: tk.Misc) -> None:
shown = _shown(widget)
assert shown, "the guard walked no widget"
worded = [text for text in shown if LETTER.search(text)]
assert worded == []
def test_the_window_title_is_the_app_name(tk_root: tk.Tk) -> None:
SickDaysApp(tk_root)
assert tk_root.title() == "Sick days"
def test_the_window_shows_no_letters(tk_root: tk.Tk) -> None:
add_sick_day(FUTURE, keep=["alarm", "workout"])
app = SickDaysApp(tk_root)
_assert_no_letters(tk_root)
app.select(FUTURE + timedelta(days=1))
_assert_no_letters(tk_root)
def test_the_window_after_a_refusal_shows_no_letters(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
)
SickDaysApp(tk_root).select(FUTURE)
_assert_no_letters(tk_root)
def test_the_window_after_a_save_shows_no_letters(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
app.select(FUTURE)
(panel,) = app.host.winfo_children()
panel.nametowidget("actions.mark").invoke()
assert isinstance(panel, KeepPanel)
assert panel.sync_thread is not None
panel.sync_thread.join(timeout=5)
_assert_no_letters(tk_root)
def test_every_panel_state_shows_no_letters(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
add_sick_day(FUTURE, keep=["book"])
for day in (FUTURE, FUTURE + timedelta(days=1), today() - timedelta(days=1)):
_assert_no_letters(
KeepPanel(tk_root, day, on_saved=lambda _e: None, on_cancel=lambda: None)
)
def unavailable() -> None:
msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
_assert_no_letters(KeepPanel(tk_root, FUTURE + timedelta(days=2), on_saved=print))
def test_a_refused_or_busy_write_shows_no_letters(tk_root: tk.Tk) -> None:
refused = KeepPanel(tk_root, FUTURE + timedelta(days=4), on_saved=print)
add_sick_day(FUTURE + timedelta(days=4))
refused.nametowidget("actions.mark").invoke()
_assert_no_letters(refused)
busy = KeepPanel(tk_root, FUTURE + timedelta(days=5), on_saved=print)
with _panel._SYNC_LOCK:
busy.nametowidget("actions.mark").invoke()
_assert_no_letters(busy)

View File

@ -1,26 +1,27 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the five switches for one day, against a real (unmapped) Tk."""
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
from __future__ import annotations
from datetime import timedelta
import logging
import threading
import tkinter as tk
from typing import TYPE_CHECKING
import pytest
from sickdays._api import add_sick_day, is_sick_day, load
from sickdays._day import today
from sickdays._day import to_iso, today
from sickdays._errors import ConflictCheckUnavailableError
from sickdays._keep_api import kept_lockers
from sickdays._paths import Paths
from sickdays._store import write_pool
from sickdays.gui import KeepPanel, _text
from sickdays.gui import _tokens as tok
from sickdays.gui import KeepPanel
from sickdays.gui._controls import IconButton, IconSlot, Toggle
if TYPE_CHECKING:
from pathlib import Path
import tkinter as tk
from sickdays._model import SickDay
@ -40,20 +41,22 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
return calls
def _button(panel: KeepPanel, name: str) -> tk.Button:
def _button(panel: KeepPanel, name: str) -> IconButton:
widget = panel.nametowidget(f"actions.{name}")
assert isinstance(widget, tk.Button)
assert isinstance(widget, IconButton)
return widget
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
widget = panel.nametowidget(f"keep-{locker}")
assert isinstance(widget, tk.Checkbutton)
assert isinstance(widget, Toggle)
return widget
def _status(panel: KeepPanel) -> str:
return str(panel.nametowidget("status").cget("text"))
def _status(panel: KeepPanel) -> str | None:
slot = panel.nametowidget("status")
assert isinstance(slot, IconSlot)
return slot.icon
def _actions(panel: KeepPanel) -> set[str]:
@ -72,11 +75,11 @@ def test_mark_sick_keeping_leetcode(tk_root: tk.Tk, synced: list[Paths | None])
assert panel.kept() == frozenset()
assert _actions(panel) == {"mark"}
toggle = _toggle(panel, "leetcode")
assert toggle.cget("text") == "Off"
assert not toggle.is_on()
toggle.invoke()
assert toggle.cget("text") == "On"
assert str(toggle.cget("bg")).upper() == tok.ACCENT
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
assert toggle.is_on()
assert _button(panel, "mark").icon == "check"
assert _status(panel) is None
_button(panel, "mark").invoke()
_join(panel)
assert is_sick_day(FUTURE)
@ -93,8 +96,14 @@ def test_an_existing_sick_day_opens_with_its_switches(tk_root: tk.Tk) -> None:
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)}"
assert panel.nametowidget("head.title").cget("text") == to_iso(FUTURE)
state = panel.nametowidget("head.state")
assert isinstance(state, IconSlot)
assert state.icon == "sick"
assert _button(panel, "clear").icon == "trash"
icon = panel.nametowidget("icon-workout")
assert isinstance(icon, IconSlot)
assert icon.icon == "workout"
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
@ -139,20 +148,27 @@ def test_clear_makes_the_day_normal(tk_root: tk.Tk, synced: list[Paths | None])
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 panel.reason == "past"
assert _status(panel) == "padlock"
assert _actions(panel) == set()
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
diet = _toggle(panel, "diet")
assert str(diet.cget("state")) == "disabled"
diet.invoke()
assert not diet.is_on()
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
"sickdays.gui._dates.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 panel.reason == "free"
assert _status(panel) == "no_entry"
state = panel.nametowidget("head.state")
assert isinstance(state, IconSlot)
assert state.icon == "normal"
assert _actions(panel) == set()
@ -163,20 +179,24 @@ def test_an_uncheckable_free_pool_refuses(
msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert _status(panel) == "cannot check the free-day pool"
assert panel.reason == "unavailable"
assert _status(panel) == "warning"
assert _actions(panel) == set()
def test_a_refused_write_is_shown_not_raised(
tk_root: tk.Tk, synced: list[Paths | None]
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
) -> 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)
with caplog.at_level(logging.WARNING):
_button(panel, "mark").invoke()
assert panel.reason == "refused"
assert _status(panel) == "warning"
assert "already a sick day" in caplog.text
assert saved == []
assert synced == []
@ -189,7 +209,9 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
on_saved=lambda _e: None,
on_cancel=lambda: cancelled.append(True),
)
_button(panel, "cancel").invoke()
cancel = _button(panel, "cancel")
assert cancel.icon == "cross"
cancel.invoke()
assert cancelled == [True]
@ -242,4 +264,5 @@ def test_a_write_during_a_sync_is_refused_not_lost(
for entry in saved:
assert entry is not None
assert is_sick_day(entry.day), f"{entry.day} was written, then lost"
assert _status(second) == _text.BUSY_NOTE
assert second.reason == "busy"
assert _status(second) == "hourglass"

View File

@ -1,86 +0,0 @@
# 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",
"Focus",
]
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

@ -10,8 +10,9 @@ 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 KeepPanel, SickDaysApp, _dates, _window
from sickdays.gui import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING:
import pytest
@ -19,13 +20,19 @@ if TYPE_CHECKING:
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 _upcoming(app: SickDaysApp) -> dict[str, list[str]]:
"""Each listed day's ISO date -> the locker icons it keeps."""
rows = app.calendar.master.nametowidget("upcoming").winfo_children()
listed: dict[str, list[str]] = {}
for row in rows:
icons = [
str(slot.icon)
for slot in row.winfo_children()
if isinstance(slot, IconSlot)
]
(date_label,) = [c for c in row.winfo_children() if isinstance(c, tk.Label)]
listed[str(date_label.cget("text"))] = icons
return listed
def _cell(app: SickDaysApp, day: date) -> tk.Button:
@ -40,14 +47,17 @@ def _panel(app: SickDaysApp) -> KeepPanel:
return panel
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
def test_an_empty_pool_lists_nothing_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 _upcoming(app) == {}
assert app.status.icon is None
assert app.host.winfo_children() == []
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
later = FUTURE + timedelta(days=1)
add_sick_day(later)
app = SickDaysApp(tk_root)
app.calendar.month = FUTURE.replace(day=1)
app.calendar.redraw()
@ -58,10 +68,8 @@ def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
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)
assert app.status.icon == "check"
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
cell = _cell(app, FUTURE)
assert str(cell.cget("bg")).upper() == tok.SICK
assert cell.nametowidget("dot").winfo_exists()
@ -78,13 +86,12 @@ 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
"sickdays.gui._dates.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
assert app.status.icon == "no_entry"
def test_a_sick_day_on_a_free_day_still_opens(
@ -92,7 +99,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
) -> None:
add_sick_day(FUTURE)
monkeypatch.setattr(
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
)
app = SickDaysApp(tk_root)
app.select(FUTURE)
@ -102,19 +109,31 @@ def test_a_sick_day_on_a_free_day_still_opens(
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
past = today() - timedelta(days=1)
add_sick_day(past, now=past)
assert _upcoming(SickDaysApp(tk_root)) == {
to_iso(FUTURE): ["workout", "alarm"],
to_iso(FUTURE + timedelta(days=1)): [],
}
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_saved_icon() -> None:
assert _window.saved_icon(None) is None
assert _window.saved_icon(SickDay(FUTURE, is_sick=True)) == "check"
assert _window.saved_icon(SickDay(FUTURE, is_sick=False)) == "check"
def test_the_month_reads_as_numerals_with_weekday_marks(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
title = app.calendar.nametowidget("header").winfo_children()[-1]
assert isinstance(title, tk.Label)
assert title.cget("text") == _dates.month_title(today().replace(day=1))
marks = [
child.icon
for child in app.calendar.nametowidget("days").winfo_children()
if isinstance(child, IconSlot)
]
assert marks == ["dot"] * 5 + ["ring"] * 2
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
@ -128,7 +147,7 @@ def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
first, last = _text.month_bounds(today())
first, last = _dates.month_bounds(today())
for _ in range(30):
app.calendar.step(1)
assert app.calendar.month == last
@ -136,6 +155,8 @@ def test_month_arrows_stop_at_the_phones_range(tk_root: tk.Tk) -> None:
app.calendar.step(-1)
assert app.calendar.month == first
back = app.calendar.nametowidget("header.prev")
assert isinstance(back, IconButton)
assert back.icon == "prev"
assert str(back.cget("state")) == "disabled"

View File

@ -5,7 +5,7 @@ description: >-
injected closures -- no keystore or platform plugin access lives in this
package itself.
publish_to: 'none'
version: 0.6.2
version: 0.6.3
environment:
sdk: ^3.12.2
@ -19,7 +19,7 @@ dependencies:
data_transfer_flutter:
git:
url: https://github.com/kuhyx/utils
ref: data_transfer_flutter-v0.2.1
ref: data_transfer_flutter-v0.2.2
path: data_transfer_flutter
flutter:
sdk: flutter