Compare commits

..

No commits in common. "main" and "focus-project-v0.10.0" have entirely different histories.

31 changed files with 491 additions and 1838 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.2
version: 0.2.1
environment:
sdk: ^3.12.2
dependencies:
file_picker: 13.3.3
file_picker: 13.3.2
file_selector: 1.1.0
flutter:
sdk: flutter

View File

@ -1,88 +0,0 @@
# 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

@ -9,7 +9,7 @@ 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
Unbacked minutes (0.8.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.
"""

View File

@ -1,131 +0,0 @@
# 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

@ -93,14 +93,6 @@ 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,7 +25,6 @@ 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
@ -65,7 +64,6 @@ 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):
@ -74,7 +72,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) + plan_gms(repo)
return todo + plan_web(repo)
def install(repo: Path) -> list[str]:
@ -94,7 +92,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) + install_gms(repo)
return done + install_web(repo)
def bootstrap_commands(repo: Path) -> list[str]:

View File

@ -1,62 +0,0 @@
"""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

@ -1,27 +0,0 @@
#!/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

@ -1,12 +0,0 @@
# 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

@ -1,77 +0,0 @@
"""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

View File

@ -1,193 +0,0 @@
#!/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,7 +9,6 @@
# 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

@ -1,195 +0,0 @@
"""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,35 +70,22 @@ 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 six-switch panel per day, the days ahead. Past days are
Tk -- calendar, a five-switch panel per day, the days ahead. Past days are
inert; a free day is refused; each write starts `sync_quietly()` on a
non-daemon thread. `WM_CLASS "sickdays", "Sickdays"`; `Escape` closes. Embed
just the panel (`import sickdays` never imports tkinter):
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: 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`.
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.
`on_saved(SickDay | None)` fires after the write and sync start: the entry on
mark / a changed save (`is_sick=True`) or clear (`is_sick=False`), `None`
on an unchanged save (nothing written). The panel never destroys itself.
Mark sick / a changed Save (`is_sick=True`) or Clear (`is_sick=False`), `None`
on an unchanged Save (nothing written). The panel never destroys itself.
## API

View File

@ -120,9 +120,6 @@ 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,33 +1,29 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""The month grid: Monday-first, one button per day, prev/next month chevrons.
"""The month grid: Monday-first, one button per day, prev/next month arrows.
The phone's ``DayCalendar``/``DayCell``: a sick day is filled ``cat-2``, a
sick day that keeps a locker carries a dot, Tue-Thu are shaded, and past
days keep their mark but faded and inert. Today stays clickable -- a sick
day can be marked or cleared for today. Deciding what a click *means* stays
with the window; this only reports the date. No words: the month reads
``2026-11`` and the weekday row is dots, with rings on Saturday and Sunday.
with the window; this only reports the date.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING
from sickdays._day import to_iso
from sickdays.gui import _dates
from sickdays.gui import _text
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
#: 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
_PREV = "\N{SINGLE LEFT-POINTING ANGLE QUOTATION MARK}"
_NEXT = "\N{SINGLE RIGHT-POINTING ANGLE QUOTATION MARK}"
def _cell_colours(*, sick: bool, past: bool, workday: bool) -> tuple[str, str]:
@ -60,7 +56,7 @@ class MonthCalendar(tk.Frame):
self._is_sick = is_sick
self._has_dot = has_dot
self._on_day = on_day
self._first, self._last = _dates.month_bounds(today)
self._first, self._last = _text.month_bounds(today)
self.month = today.replace(day=1)
self._header = tk.Frame(self, name="header", bg=self.cget("bg"))
self._header.pack(fill="x")
@ -70,7 +66,7 @@ class MonthCalendar(tk.Frame):
def step(self, months: int) -> None:
"""Move ``months`` months (within the phone's range) and redraw."""
target = _dates.shift_month(self.month, months)
target = _text.shift_month(self.month, months)
if self._first <= target <= self._last:
self.month = target
self.redraw()
@ -81,12 +77,11 @@ class MonthCalendar(tk.Frame):
for child in frame.winfo_children():
child.destroy()
self._draw_header()
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 column, initial in enumerate(_text.WEEKDAY_INITIALS):
ui.label(self._grid, initial, "caption", fg=tok.MUTED_ON_DARK).grid(
row=0, column=column
)
for row, week in enumerate(_dates.month_weeks(self.month), start=1):
for row, week in enumerate(_text.month_weeks(self.month), start=1):
for column, day in enumerate(week):
if day is not None:
self._cell(day).grid(
@ -94,20 +89,20 @@ class MonthCalendar(tk.Frame):
)
def _draw_header(self) -> None:
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 = ui.button(self._header, _PREV, lambda: self.step(-1), name="prev")
forward = ui.button(self._header, _NEXT, lambda: self.step(1), name="next")
back.configure(state="normal" if self.month > self._first else "disabled")
forward.configure(state="normal" if self.month < self._last else "disabled")
back.pack(side="left")
forward.pack(side="right")
ui.label(self._header, _dates.month_title(self.month), "subtitle").pack(
ui.label(self._header, _text.month_title(self.month), "subtitle").pack(
side="top", pady=tok.XS
)
def _cell(self, day: date) -> tk.Button:
sick = self._is_sick(day)
past = day < self._today
fill, text = _cell_colours(sick=sick, past=past, workday=_dates.is_workday(day))
fill, text = _cell_colours(sick=sick, past=past, workday=_text.is_workday(day))
cell = tk.Button(
self._grid,
name=f"day-{to_iso(day)}",
@ -129,6 +124,7 @@ class MonthCalendar(tk.Frame):
cursor="arrow" if past else "hand2",
)
if sick and self._has_dot(day):
dot = IconSlot(cell, name="dot", size=_DOT_PX).show("dot", text)
dot.place(relx=1.0, x=-2, y=2, anchor="ne")
dot = tk.Label(cell, name="dot", text="●", fg=text, bg=fill)
dot.configure(font=tok.font(ui.family(self), "caption"), padx=0, pady=0)
dot.place(relx=1.0, x=-2, y=0, anchor="ne")
return cell

View File

@ -1,171 +0,0 @@
# 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

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

@ -1,235 +0,0 @@
# 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: a switch per locker for one day, and the buttons that write them.
"""KeepPanel: the five switches for one day, and the buttons that write them.
The embeddable half of the picker. It is a plain ``tk.Frame``: no root, no
mainloop, no ``bind_all``, no grab, no focus stealing and no ``wm_*`` call,
@ -7,34 +7,29 @@ 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" (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.
-- the phone's ``SickDaySheet``. A normal day offers "Mark sick"; a sick day
offers "Save" (a keep-only edit) and "Clear sick day". A past day, a free
day, or a free-day pool that cannot be checked shows why and offers no
write. A refused write is shown in the panel, never raised: an exception in a
Tk callback only reaches stderr, which nobody sees under a lock.
"""
from __future__ import annotations
import logging
import threading
import tkinter as tk
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING
from sickdays._api import add_sick_day, is_sick_day, remove_sick_day
from sickdays._constants import LOCKERS
from sickdays._day import to_iso, today
from sickdays._day import today
from sickdays._errors import SickDayError
from sickdays._keep_api import kept_lockers, set_kept_lockers
from sickdays._paths import resolve_paths
from sickdays._sync import sync_quietly
from sickdays.gui import _dates
from sickdays.gui import _text
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
@ -44,17 +39,6 @@ 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
@ -89,17 +73,16 @@ class KeepPanel(tk.Frame):
``on_saved`` is called once per successful click, after the write and
after the background sync has started:
* ``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``).
* "Mark sick" -> the new :class:`~sickdays.SickDay` (``is_sick=True``);
* "Save" with a changed set -> the stored entry (``is_sick=True``);
* "Save" with nothing changed -> ``None``: nothing written, no sync;
* "Clear sick day" -> the cleared entry (``is_sick=False``).
A refused write (:class:`~sickdays.SickDayError`, or a click while the
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.
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.
"""
def __init__(
@ -124,15 +107,12 @@ 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 = IconSlot(self, name="status")
self._status.grid(
row=len(LOCKERS) + 1, column=0, columnspan=2, sticky="w", pady=(tok.SM, 0)
self._status = ui.label(
self, blocked or "", "label", fg=tok.DANGER, name="status"
)
self._show(blocked)
self._status.grid(row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="w")
self._draw_actions(writable=blocked is None, on_cancel=on_cancel)
def kept(self) -> frozenset[str]:
@ -141,54 +121,49 @@ class KeepPanel(tk.Frame):
def _blocked_reason(self) -> str | None:
if self._day < today():
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)
return _text.PAST_NOTE
return None if self._sick else _text.free_day_refusal(self._day)
def _draw_switches(self, *, editable: bool) -> None:
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(
title = _text.panel_title(self._day, sick=self._sick)
ui.label(self, title, "subtitle", name="title").grid(
row=0, column=0, columnspan=2, sticky="w"
)
ui.label(self, _text.SHEET_HELP, "caption", fg=tok.MUTED_ON_DARK).grid(
row=1, column=0, columnspan=2, sticky="w", pady=(tok.XS, tok.SM)
)
for row, locker in enumerate(LOCKERS, start=2):
ui.label(self, _text.LOCKER_LABELS[locker], "body").grid(
row=row, column=0, sticky="w", pady=tok.XS
)
switch = Toggle(self, self._vars[locker], name=f"keep-{locker}")
switch = ui.toggle(self, self._vars[locker], name=f"keep-{locker}")
switch.grid(row=row, column=1, sticky="e", padx=(tok.LG, 0), pady=tok.XS)
if not editable:
switch.set_enabled(False)
switch.configure(state="disabled")
def _draw_actions(
self, *, writable: bool, on_cancel: Callable[[], None] | None
) -> None:
actions = tk.Frame(self, name="actions", bg=self.cget("bg"))
actions.grid(
row=len(LOCKERS) + 2, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
row=len(LOCKERS) + 3, column=0, columnspan=2, sticky="we", pady=(tok.MD, 0)
)
buttons: list[IconButton] = []
buttons: list[tk.Button] = []
if writable and self._sick:
buttons.append(
IconButton(actions, "check", self._save, kind="primary", name="save")
ui.button(actions, "Save", self._save, kind="primary", name="save")
)
buttons.append(
IconButton(actions, "trash", self._clear, kind="danger", name="clear")
ui.button(
actions, "Clear sick day", self._clear, kind="danger", name="clear"
)
)
elif writable:
buttons.append(
IconButton(actions, "check", self._mark, kind="primary", name="mark")
ui.button(actions, "Mark sick", self._mark, kind="primary", name="mark")
)
if on_cancel is not None:
buttons.append(IconButton(actions, "cross", on_cancel, name="cancel"))
buttons.append(ui.button(actions, "Cancel", on_cancel, name="cancel"))
for button in buttons:
button.pack(side="left", padx=(0, tok.SM))
@ -212,13 +187,12 @@ 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._show("busy")
self._status.configure(text=_text.BUSY_NOTE, fg=tok.DANGER)
return
try:
entry = action()
except SickDayError as exc:
_LOG.warning("sick-day write refused: %s", exc)
self._show("refused")
self._status.configure(text=str(exc), fg=tok.DANGER)
return
finally:
_SYNC_LOCK.release()

View File

@ -0,0 +1,125 @@
# 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,20 +1,33 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Token-styled Tk labels for the numerals the picker shows (dates, months).
"""Token-styled Tk building blocks shared by the panel and the window.
Every colour comes from :mod:`sickdays.gui._tokens`. Buttons, switches and
icons are word-free Canvases in :mod:`sickdays.gui._controls`.
Every colour comes from :mod:`sickdays.gui._tokens`, and the text colour of a
filled control is picked from its fill (``on-fill`` on the accent, never the
near-white body text), the bug the design system's README warns about.
"""
from __future__ import annotations
import tkinter as tk
from tkinter import font as tkfont
from typing import Final
from typing import TYPE_CHECKING, Final, Literal
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
from collections.abc import Callable
ButtonKind = Literal["primary", "danger", "plain"]
_BOLD_ROLES: Final = frozenset({"title", "subtitle"})
#: (background, foreground) per button kind. Danger is a text button, as on
#: the phone, so its red sits on the dark surface rather than under ink.
_BUTTON_COLOURS: Final[dict[ButtonKind, tuple[str, str]]] = {
"primary": (tok.ACCENT, tok.ON_FILL),
"danger": (tok.INK_RAISED_2, tok.DANGER),
"plain": (tok.INK_RAISED_2, tok.TEXT_ON_DARK),
}
def family(widget: tk.Misc) -> str:
"""The family Tk's default font actually resolves to on this display."""
@ -29,7 +42,10 @@ def label(
fg: str = tok.TEXT_ON_DARK,
name: str = "",
) -> tk.Label:
"""A label on ``parent``'s own background; titles and subtitles are bold."""
"""A label on ``parent``'s own background; prose wraps at :data:`tok.WRAP`.
Titles and subtitles are bold, everything else regular.
"""
return tk.Label(
parent,
name=name,
@ -39,4 +55,71 @@ 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,33 +2,26 @@
"""``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 (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=``.
the clicked day, the sick days still ahead underneath. The window class is
``sickdays`` (Tk reports ``WM_CLASS "sickdays", "Sickdays"``) so an i3
``for_window`` rule can float it; ``Escape`` closes it.
"""
from __future__ import annotations
import tkinter as tk
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING
from sickdays._api import is_sick_day, load
from sickdays._day import to_iso, today
from sickdays._day import today
from sickdays._keep_api import kept_lockers
from sickdays._model import sick_entries
from sickdays._paths import resolve_paths
from sickdays.gui import _dates
from sickdays.gui import _text
from sickdays.gui import _tokens as tok
from sickdays.gui import _widgets as ui
from sickdays.gui._calendar import MonthCalendar
from sickdays.gui._controls import IconSlot
from sickdays.gui._icons import LOCKER_ICONS
from sickdays.gui._panel import STATUS_ICONS, KeepPanel
from sickdays.gui._panel import KeepPanel
if TYPE_CHECKING:
from datetime import date
@ -37,18 +30,19 @@ 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_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"
def saved_message(entry: SickDay | None, day: date) -> str:
"""The status line after the panel closed with ``entry``."""
label = _text.day_label(day)
if entry is None:
return f"{label}: nothing changed"
if not entry.is_sick:
return f"{label} is a normal day again"
kept = _text.kept_label(entry.keep)
return f"{label} is a sick day — " + (
f"kept: {kept}" if kept else "every locker stands down"
)
class SickDaysApp:
@ -58,7 +52,7 @@ class SickDaysApp:
"""Style ``root`` and draw the current pool."""
self._root = root
self._paths = resolve_paths(paths)
root.title(WINDOW_TITLE)
root.title("Sick days")
root.configure(bg=tok.INK, padx=tok.LG, pady=tok.LG)
root.bind("<Escape>", self.close)
left = tk.Frame(root, bg=tok.INK)
@ -66,9 +60,13 @@ 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))
#: The last outcome's mark: a check, or the panel's refusal icons.
self.status = IconSlot(left, name="status")
self.status.pack(anchor="w")
ui.label(left, "Sick days", "title").pack(anchor="w")
ui.label(left, _text.INTRO, "body").pack(anchor="w", pady=(tok.SM, 0))
ui.label(left, _text.INTRO_DETAIL, "caption", fg=tok.MUTED_ON_DARK).pack(
anchor="w", pady=(tok.SM, 0)
)
self.status = ui.label(left, "", "label", name="status")
self.status.pack(anchor="w", pady=(tok.SM, 0))
log = self._paths.log
self.calendar = MonthCalendar(
left,
@ -77,8 +75,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.SM, 0))
IconSlot(left, name="ahead").show("sick", tok.SICK).pack(
self.calendar.pack(anchor="w", pady=(tok.MD, 0))
ui.label(left, "Marked sick", "subtitle").pack(
anchor="w", pady=(tok.LG, tok.SM)
)
self._list = tk.Frame(left, name="upcoming", bg=tok.INK)
@ -90,25 +88,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 mark."""
"""Open the panel for ``day``, or refuse a free day in the status line."""
self._close_panel()
if not is_sick_day(day, log_path=self._paths.log):
block = _dates.free_day_block(day)
if block is not None:
self.status.show(*STATUS_ICONS[block])
refusal = _text.free_day_refusal(day)
if refusal is not None:
self._set_status(refusal, failed=True)
return
self.status.show(None)
self._set_status("", failed=False)
KeepPanel(
self.host,
day,
on_saved=self._saved,
on_saved=lambda entry: self._saved(entry, day),
on_cancel=self._close_panel,
paths=self._paths,
).pack(anchor="n")
def _saved(self, entry: SickDay | None) -> None:
def _saved(self, entry: SickDay | None, day: date) -> None:
self._close_panel()
self.status.show(saved_icon(entry), tok.SUCCESS)
self._set_status(saved_message(entry, day), failed=False)
self.calendar.redraw()
self._draw_upcoming()
@ -116,20 +114,29 @@ 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()
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))
upcoming = [
entry
for entry in sick_entries(load(self._paths.log))
if entry.day >= reference
]
if not upcoming:
ui.label(self._list, _text.NONE_AHEAD, "body", fg=tok.MUTED_ON_DARK).pack(
anchor="w"
)
for entry in upcoming:
ui.label(self._list, _text.day_label(entry.day), "body").pack(anchor="w")
kept = _text.kept_label(entry.keep)
if kept:
ui.label(
self._list, f"Kept: {kept}", "label", fg=tok.MUTED_ON_DARK
).pack(anchor="w", pady=(0, tok.XS))
def run(*, paths: Paths | None = None) -> int:

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

View File

@ -1,70 +0,0 @@
# 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

@ -1,87 +0,0 @@
# 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

@ -1,113 +0,0 @@
# 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,27 +1,26 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""KeepPanel: the six switches for one day, against a real (unmapped) Tk."""
"""KeepPanel: the five 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 to_iso, today
from sickdays._day import 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
from sickdays.gui._controls import IconButton, IconSlot, Toggle
from sickdays.gui import KeepPanel, _text
from sickdays.gui import _tokens as tok
if TYPE_CHECKING:
from pathlib import Path
import tkinter as tk
from sickdays._model import SickDay
@ -41,22 +40,20 @@ def synced(monkeypatch: pytest.MonkeyPatch) -> list[Paths | None]:
return calls
def _button(panel: KeepPanel, name: str) -> IconButton:
def _button(panel: KeepPanel, name: str) -> tk.Button:
widget = panel.nametowidget(f"actions.{name}")
assert isinstance(widget, IconButton)
assert isinstance(widget, tk.Button)
return widget
def _toggle(panel: KeepPanel, locker: str) -> Toggle:
def _toggle(panel: KeepPanel, locker: str) -> tk.Checkbutton:
widget = panel.nametowidget(f"keep-{locker}")
assert isinstance(widget, Toggle)
assert isinstance(widget, tk.Checkbutton)
return widget
def _status(panel: KeepPanel) -> str | None:
slot = panel.nametowidget("status")
assert isinstance(slot, IconSlot)
return slot.icon
def _status(panel: KeepPanel) -> str:
return str(panel.nametowidget("status").cget("text"))
def _actions(panel: KeepPanel) -> set[str]:
@ -75,11 +72,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 not toggle.is_on()
assert toggle.cget("text") == "Off"
toggle.invoke()
assert toggle.is_on()
assert _button(panel, "mark").icon == "check"
assert _status(panel) is None
assert toggle.cget("text") == "On"
assert str(toggle.cget("bg")).upper() == tok.ACCENT
assert str(toggle.cget("fg")).upper() == tok.ON_FILL
_button(panel, "mark").invoke()
_join(panel)
assert is_sick_day(FUTURE)
@ -96,14 +93,8 @@ 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"}
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"
title = panel.nametowidget("title").cget("text")
assert title == f"Sick day: {_text.day_label(FUTURE)}"
def test_save_replaces_the_kept_set(tk_root: tk.Tk, synced: list[Paths | None]) -> None:
@ -148,27 +139,20 @@ 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 panel.reason == "past"
assert _status(panel) == "padlock"
assert _status(panel) == _text.PAST_NOTE
assert _actions(panel) == set()
diet = _toggle(panel, "diet")
assert str(diet.cget("state")) == "disabled"
diet.invoke()
assert not diet.is_on()
assert str(_toggle(panel, "diet").cget("state")) == "disabled"
def test_a_free_day_is_refused_with_the_phones_message(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert panel.reason == "free"
assert _status(panel) == "no_entry"
state = panel.nametowidget("head.state")
assert isinstance(state, IconSlot)
assert state.icon == "normal"
expected = "is a free day — release it in Free days first."
assert _status(panel).endswith(expected)
assert _actions(panel) == set()
@ -179,24 +163,20 @@ def test_an_uncheckable_free_pool_refuses(
msg = "cannot check the free-day pool"
raise ConflictCheckUnavailableError(msg)
monkeypatch.setattr("sickdays.gui._dates.free_day_predicate", unavailable)
monkeypatch.setattr("sickdays.gui._text.free_day_predicate", unavailable)
panel = KeepPanel(tk_root, FUTURE, on_saved=lambda _e: None)
assert panel.reason == "unavailable"
assert _status(panel) == "warning"
assert _status(panel) == "cannot check the free-day pool"
assert _actions(panel) == set()
def test_a_refused_write_is_shown_not_raised(
tk_root: tk.Tk, synced: list[Paths | None], caplog: pytest.LogCaptureFixture
tk_root: tk.Tk, synced: list[Paths | None]
) -> None:
saved: list[SickDay | None] = []
panel = KeepPanel(tk_root, FUTURE, on_saved=saved.append)
add_sick_day(FUTURE) # marked elsewhere while the panel was open
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
_button(panel, "mark").invoke()
assert "already a sick day" in _status(panel)
assert saved == []
assert synced == []
@ -209,9 +189,7 @@ def test_cancel_calls_back(tk_root: tk.Tk) -> None:
on_saved=lambda _e: None,
on_cancel=lambda: cancelled.append(True),
)
cancel = _button(panel, "cancel")
assert cancel.icon == "cross"
cancel.invoke()
_button(panel, "cancel").invoke()
assert cancelled == [True]
@ -264,5 +242,4 @@ 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 second.reason == "busy"
assert _status(second) == "hourglass"
assert _status(second) == _text.BUSY_NOTE

View File

@ -0,0 +1,86 @@
# 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,9 +10,8 @@ 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, _dates, _window
from sickdays.gui import KeepPanel, SickDaysApp, _text, _window
from sickdays.gui import _tokens as tok
from sickdays.gui._controls import IconButton, IconSlot
if TYPE_CHECKING:
import pytest
@ -20,19 +19,13 @@ if TYPE_CHECKING:
FUTURE = today() + timedelta(days=3)
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 _texts(widget: tk.Misc) -> list[str]:
found: list[str] = []
for child in widget.winfo_children():
if isinstance(child, tk.Label):
found.append(str(child.cget("text")))
found.extend(_texts(child))
return found
def _cell(app: SickDaysApp, day: date) -> tk.Button:
@ -47,17 +40,14 @@ def _panel(app: SickDaysApp) -> KeepPanel:
return panel
def test_an_empty_pool_lists_nothing_ahead(tk_root: tk.Tk) -> None:
def test_an_empty_pool_says_none_ahead(tk_root: tk.Tk) -> None:
app = SickDaysApp(tk_root)
assert tk_root.title() == "Sick days"
assert _upcoming(app) == {}
assert app.status.icon is None
assert _text.NONE_AHEAD in _texts(tk_root)
assert app.host.winfo_children() == []
def test_mark_a_day_from_the_calendar(tk_root: tk.Tk) -> None:
later = FUTURE + timedelta(days=1)
add_sick_day(later)
app = SickDaysApp(tk_root)
app.calendar.month = FUTURE.replace(day=1)
app.calendar.redraw()
@ -68,8 +58,10 @@ 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.icon == "check"
assert _upcoming(app) == {to_iso(FUTURE): ["leetcode"], to_iso(later): []}
assert app.status.cget("text") == _window.saved_message(
SickDay(FUTURE, is_sick=True, keep=frozenset({"leetcode"})), FUTURE
)
assert "Kept: LeetCode" in _texts(tk_root)
cell = _cell(app, FUTURE)
assert str(cell.cget("bg")).upper() == tok.SICK
assert cell.nametowidget("dot").winfo_exists()
@ -86,12 +78,13 @@ def test_a_free_day_is_refused_before_the_panel_opens(
tk_root: tk.Tk, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
app = SickDaysApp(tk_root)
app.select(FUTURE)
assert app.host.winfo_children() == []
assert app.status.icon == "no_entry"
assert str(app.status.cget("text")).endswith("release it in Free days first.")
assert str(app.status.cget("fg")).upper() == tok.DANGER
def test_a_sick_day_on_a_free_day_still_opens(
@ -99,7 +92,7 @@ def test_a_sick_day_on_a_free_day_still_opens(
) -> None:
add_sick_day(FUTURE)
monkeypatch.setattr(
"sickdays.gui._dates.free_day_predicate", lambda: lambda _d: True
"sickdays.gui._text.free_day_predicate", lambda: lambda _d: True
)
app = SickDaysApp(tk_root)
app.select(FUTURE)
@ -109,31 +102,19 @@ 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))
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)): [],
}
texts = _texts(SickDaysApp(tk_root).calendar.master)
assert _text.day_label(FUTURE) in texts
assert "Kept: Workout lock, Wake alarm" in texts
assert _text.NONE_AHEAD not in texts
def test_saved_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_saved_message_wording() -> None:
sick = SickDay(FUTURE, is_sick=True)
label = _text.day_label(FUTURE)
assert _window.saved_message(None, FUTURE) == f"{label}: nothing changed"
assert _window.saved_message(sick, FUTURE).endswith("every locker stands down")
cleared = SickDay(FUTURE, is_sick=False)
assert _window.saved_message(cleared, FUTURE) == f"{label} is a normal day again"
def test_past_days_are_inert_and_faded(tk_root: tk.Tk) -> None:
@ -147,7 +128,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 = _dates.month_bounds(today())
first, last = _text.month_bounds(today())
for _ in range(30):
app.calendar.step(1)
assert app.calendar.month == last
@ -155,8 +136,6 @@ 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.3
version: 0.6.2
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.2
ref: data_transfer_flutter-v0.2.1
path: data_transfer_flutter
flutter:
sdk: flutter