Skip to content

Tai/fable bump/dev - #69

Draft
tai271828 wants to merge 28 commits into
zztin:masterfrom
tai271828:tai/fable-bump/dev
Draft

Tai/fable bump/dev#69
tai271828 wants to merge 28 commits into
zztin:masterfrom
tai271828:tai/fable-bump/dev

Conversation

@tai271828

@tai271828 tai271828 commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Revamp the code stack with modern stack for:

  1. portable between Debian, macOS, and potential phone app.
  2. more modern tool chain such as uv.

Also, validate the algorithm by GenAI.

Smoke test ok Debian looks good. Still need to careful validation for:

  1. database compatibility
  2. full use cases

After comprehensive validation, I will convert this pull request from a draft to an official PR.

tai271828 and others added 28 commits August 6, 2026 10:27
- switch the revamp guidance and implementation plan to uv-managed
  Python (env, lockfile, CI, build/publish, uv tool install)
- append correctness audit (guidance §9): Debian startup crash via
  macOS-only say, duplicate/junk session rows, start-time reset on
  resume, tick drift and dialog freeze, stats over-counting
- append fix specification (plan Appendix A + Phase 0 task T0.4):
  hotfixes now, corrected engine/store/stats semantics and legacy-DB
  cleaning rules for Phase 1+

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delete setup.py/setup.cfg/MANIFEST.in/tox.ini/requirements*.txt and the
generated egg-info, build/ and dist/ trees. Packaging is now a hatchling
pyproject.toml named pomodoro-ctimer at 3.0.0a1, with a PEP 735 dev group
(pytest, ruff) that uv installs on `uv sync`.

- .python-version pins 3.12; uv.lock is committed.
- ctimer.__version__ reads the installed metadata, and `ctimer --version`
  reports it instead of the hardcoded 1.0.0.
- bokeh and the git-URL calmap dependency are dropped now; ctimer/visual is
  imported lazily so --stats/--overall print a "being revamped" message
  rather than crashing at import time. pandas/matplotlib stay until Phase 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Configure ruff in pyproject.toml (default rule set plus `I` for import
sorting, target py311), then apply `ruff check --fix` and `ruff format`
across the tree. No hand edits in this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
- Ignore the DTZ ruleset: ctimer is a local-time desktop app, so a clock's
  date is deliberately the user's naive local date.
- Per-file-ignore B008/C408/F841 for ctimer/visual, which is deleted in
  Phase 4 and whose deps are no longer installed.
- cli.py: log through a module logger instead of the root logger.
- ctimer_db.py: drop the unused clock_duration, and narrow the blanket
  `except Exception` in get_clock_count to (sqlite3.Error, IndexError).
- model.py: `int(round(x))` -> `round(x)` (round already returns int).
- docs/conf.py: `pomodoro - ctimer.__version__` was a NameError waiting to
  happen; use ctimer.__version__.
- tests/test_model.py: delete the dead mock_midnight helper (never called,
  referenced an undefined db_file).

utils.py and view.py findings are left for T0.4b/T0.4a, which rewrite them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
minimal.yaml (ubuntu, py3.7, pip + `python setup.py install` + a
calmap-from-git clone + `inv test`) is replaced by ci.yaml:

  os: ubuntu-latest, macos-latest x python: 3.11, 3.12, 3.13
  checkout -> astral-sh/setup-uv (cached) -> uv sync -> ruff -> pytest

Xvfb stays on the Linux legs only, purely for the legacy Tk tests; it goes
away with the Tk UI in Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
playback_voice_message shelled out to `say` unconditionally, so on Debian
the very first call from CtimerClockView.__init__ ("Welcome") raised
FileNotFoundError and the app could not start without --silence.

Look the binary up with shutil.which first, skip the cue when it is absent,
and wrap the subprocess.run in try/except OSError so a broken `say` can
never take the app down. Also pass argv as a list instead of shlex-splitting
the whole command line.

Covered by tests/test_voice_cue.py, which drives the method off a stub self
and needs no display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
get_cache_filepath referenced an undefined `root_path` (NameError on every
--db path), swallowed it in a blanket `except Exception` that then re-ran the
first-time-user branch, and had a fall-through returning None when --db
matched the stored path.

Rewritten as small pure functions over pathlib:
  cache_filename / read_stored_db_dir / remember_db_dir / get_cache_filepath

Every branch now returns a Path, only FileNotFoundError (plus an empty-file
IndexError) is caught, and the four cases are explicit: nothing remembered,
remembered + no --db, --db equal to stored, --db different (prompt). dir_path
is pathlib too and keeps its behavior: relative paths resolve under $HOME,
absolute paths are used as-is.

tests/test_utils_paths.py covers all of it with Path.home monkeypatched to
tmp_path and the y/n prompt stubbed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
The module built a Tk root and called mainloop() at import time, so merely
importing it hung the process (or crashed without a display). Nothing
imported it — it was a copy-pasted forum snippet kept as a reference — so it
just goes. The one reference to it, a comment in view.flash_window, is
retargeted, and its stale .gitignore entry is dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
view.terminate wrote a clock_details row unconditionally, so pressing Stop on
a freshly launched (or freshly reset) app inserted a junk row with
start_clock=0 and a 1970 timestamp, which every stats view then counted.

Return early when tm.fresh_new: no dialog, no DB write, no state churn — the
app is already in the reset state. This is a stopgap; the Phase 1 engine
makes it structural by never emitting a record for a session that never
started.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
First module of the UI-free ctimer.core package: a frozen TimerSettings
dataclass (focus/break/long-break seconds, clocks per long break, daily aim)
plus load()/save() against a JSON file, destined for ~/.ctimer/settings.json.

clocks_per_long_break is clamped to >= 2 and all fields are rounded to int,
preserving legacy Meta's normalisation. TimerSettings.debug() reproduces the
current --debug clock lengths (5s/5s/7s, long break every 2, aim 8).

load() degrades to defaults for a missing, corrupt, or partially stale file
rather than raising, and unknown keys from a future version are ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
ctimer.core.records defines the single SessionRecord the whole app revolves
around; ctimer.core.engine defines Phase, the eight events, and TimerEngine
with start/pause/resume/stop/tick and read-only phase, remaining_seconds,
completed_today and current_goal.

The engine has no wall-clock reads to drive time, no sleeps and no I/O: time
moves only through tick(), and the injectable `now` is used solely to stamp
records. Appendix A.2 semantics are built in rather than transcribed from the
legacy code:

- started_at is stamped once per session; resume never moves it (B1).
- pause()/resume() accumulate pause_seconds and emit no record at all (E1);
  a paused focus clock is not "complete".
- exactly one record per session, carried on the terminal event
  FocusCompleted / BreakCompleted / Stopped (A1).
- breaks are their own sessions with their own timestamps, kind and no goal
  fields (A2).
- stop() while idle is a no-op: no record, no event (A3).
- a single tick may span several phases, so a slow event loop cannot lose
  time (B2), and completed_today is re-read from an injected provider on
  every start() instead of being cached across midnight (B3, E2).

tests/core/test_engine.py covers the cycle, long-break cadence, pausing,
early stops, tick carry-over and one test per Appendix A.5 bullet — all off a
fake clock, with no sleeps and no display.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
ctimer.core.store replaces the all-text `clock_details` table with a properly
typed `sessions` table at PRAGMA user_version = 2: REAL timestamps, an
INTEGER is_complete, a CHECKed `kind` of focus/break/long_break, a nullable
typed `reason` (fixes E3), a `pause_seconds` column that finally persists
paused time (fixes E1), and a `uuid` column for a possible future sync.

The file is a single ~/.ctimer/ctimer.db with no year in the name
(ctimer_debug.db in debug mode); `--db DIR` still chooses the directory.

Store is a context manager exposing add / completed_count /
sessions_between / all_sessions / last_completed_at / close.
completed_count is a COUNT(*) over kind='focus' — never the "read the last
row and trust its counter" heuristic (fixes E2) and never counting breaks
(fixes D1). clock_index is derived on insert: a focus row takes the next
index of its day, a break carries the index of the clock it follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
…3, part 2)

migrate_legacy(legacy_path, store) imports the old all-text clock_details
table into `sessions`. The legacy file is opened read-only (`mode=ro`) and is
never modified; the migration only runs when the destination store is still
empty, and logs a one-line summary:

    migrated N focus, M breaks; dropped J junk, K duplicates

The legacy rows are not trustworthy, so Appendix A.3's rules are applied in
order: drop rows whose start_clock is 0/""/None (A3); coerce the text
booleans and text floats, turning a numeric-zero `reason` — the old
written-on-pause marker — into NULL (E3); collapse each (date, clock_count)
group of focus rows to the one with the greatest end_clock, so the rows the
old app wrote on every pause stop counting as separate clocks (A1); and
re-anchor break rows to the end of the focus clock they follow instead of the
stale focus start they inherited, dropping the goal fields they also
inherited (A2).

find_legacy_db() locates ctimer_<year>.db / ctimer_debug_<year>.db in the db
directory. The code lives in ctimer/core/migration.py rather than inside
store.py as the plan sketched — the cleaning rules are substantial enough to
want their own module, and store.py stays about the schema.

tests/core/test_migration.py migrates a fixture containing exactly the
defects A.3 describes and asserts each rule, plus that the legacy bytes are
unchanged afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
ctimer.core.stats provides daily_counts / weekly_summary / yearly_counts /
total_focus_minutes as pure functions over Store — no pandas, no matplotlib,
no rendering.

Per Appendix A.4 every aggregate consumes only kind='focus' sessions, one
count per session, so break rows and the old pause partials can no longer
inflate the numbers (fixes D1); focus minutes are computed per session from
its own started_at/ended_at minus pause_seconds (fixes D2, B1).

tests/core/test_stats.py uses the fixture A.4 prescribes — 2 completed focus
sessions, 2 breaks, 1 early-stopped focus — and asserts the hand-computed
counts and minutes, plus that a migrated legacy fixture reports 2 clocks
rather than its 5 rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Imports every module of ctimer.core in a fresh interpreter and fails if
flet, tkinter or another toolkit ends up in sys.modules. Verified by
temporarily adding `import tkinter` to a core module: the test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
The Tkinter UI now runs on the core engine and store. It is a shim, not a
rewrite — the widgets are untouched; what changed is who owns the state.

- controller.py is the shim: its after() loop calls engine.tick(1) once a
  second and dispatches engine events to the existing view methods. It is
  also the only place that writes to the database — on FocusCompleted /
  Stopped it asks the view for reached/reason and calls store.add() exactly
  once, and on BreakCompleted it stores the break as its own row.
- model.py keeps only a view-model: clock_ticking / fresh_new /
  remaining_time / set_time are now read-only views onto the engine's phase
  and countdown, so nothing can desynchronise them.
- view.toggle_start_pause and view.terminate just drive the engine
  (start / pause / resume / stop). The view no longer imports the database,
  no longer writes a row on pause, and no longer resets timestamps.
- ctimer.py wires store -> engine -> model -> controller and persists an
  in-flight session on window close without opening a dialog.
- cli.py opens a Store at ~/.ctimer/ctimer.db, migrates a legacy
  ctimer_<year>.db into it on first run, and loads/saves TimerSettings at
  ~/.ctimer/settings.json — which is what --cus now writes.
- ctimer_db.py is deleted; core.store replaces all of it. Meta is deleted;
  TimerSettings replaces it, so tests/test_model.py is deleted in favour of
  tests/core/test_settings.py, which covers the same clamping and rounding.
- --stats/--overall print the "being revamped" notice: the legacy plotting
  reads the old clock_details schema and its deps are already gone. Phase 4
  brings stats back in-app.

The legacy Tk tests are rewired to the new wiring and now assert the fixed
behaviour (one row per session, pause writes nothing, a break follows the
clock automatically, every 2nd break is long in debug mode) against a
tmp_path store instead of the user's real ~/.ctimer.

Known gap, fixed in Phase 2: Tk's modal dialogs still block the after()
loop, so the break countdown does not advance while the "did you reach your
goal?" dialog is open (finding B2). Flet's non-blocking dialogs settle this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
…T2.2)

`ctimer` now opens a Flet desktop window; `ctimer --legacy-tk` still opens
the Tk one until T2.5 deletes it. Every other flag (--debug, --silence,
--hide, --db, --cus, --stats, --overall, --version) behaves as before.

ctimer/ui/presenter.py is the whole translation layer and imports no Flet:
it consumes engine events and produces a frozen ViewModel of strings and
flags. ctimer/ui/app.py does nothing but copy those onto controls, which is
why the UI is testable without a display.

The window is ~320x420 and resizable, showing top to bottom: the date, a
gear icon, "Done: N / Aim: M", the big MM:SS countdown (green during
breaks), Start/Pause + Stop, the current goal, and "Completed at: HH:MM:SS"
of the last finished clock. --hide keeps it out of always-on-top and brings
it to the front when a clock completes.

Two behaviours the Tk app could not manage:

- The countdown is one async loop that derives elapsed *whole seconds* from
  time.monotonic() instead of counting callbacks, so event-loop latency can
  no longer make the clock drift (Appendix A.2.1).
- The goal and "did you reach your goal?" dialogs are awaited coroutines, so
  the break countdown runs while they are open — the legacy modal froze it
  (finding B2).

flet[desktop] is added as a runtime dependency; the desktop extra matters,
because plain `flet` otherwise pip-installs flet-desktop behind uv's back on
first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
ctimer/ui/cues.py replaces the macOS-only `say` call with something that
works everywhere and never crashes:

- Three short cue sounds are bundled in ctimer/ui/assets/sounds and played
  through flet-audio. They are synthesised sine tones generated by
  tools/make_sounds.py, so nothing third-party is vendored; regenerate them
  with `uv run python tools/make_sounds.py`.
- Desktop notifications on focus-complete and break-over via notify-send on
  Linux and osascript on macOS. A missing tool, a missing dbus, a failing
  subprocess: all logged at debug and swallowed.
- macOS `say` stays as an optional nicety, guarded by shutil.which.
- --silence mutes sound and speech and blinks the countdown instead, which
  is what the legacy flash_window never got around to doing.

Also fixes a bug from the previous commit: BreakCompleted had no handler in
the Flet app, so break sessions were never written to the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
The gear icon opens a dialog editing the five TimerSettings values (clock,
break and long-break length in minutes, clocks per long break, daily aim).
Saving applies them to the running engine — mid-clock changes take effect on
the next countdown — and writes ~/.ctimer/settings.json, the same file the
--cus prompts write.

The form logic is pure and lives in presenter.py: settings_form_values() and
parse_settings_form(), which reports per-field errors and keeps the current
value for any field that failed, so a typo cannot wipe a setting. Both are
unit-tested; the dialog itself is only wiring.

In --debug mode no settings path is passed, so the 5-second debug clocks can
never overwrite the real settings file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Removes view.py, controller.py, model.py, ctimer.py (maintk), the --legacy-tk
flag, the Tk test modules, and the Xvfb step from CI. The Flet window is now
the only UI, and the test suite runs headless on both runners.

`grep -rn tkinter ctimer tests` returns only the comment in
ctimer/core/__init__.py that explains why the core may not import one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
tests/ui/test_app_wiring.py drives CtimerApp through a stub page that records
what the app asked the page to do. It pins down the persistence contract that
the Tk app kept getting wrong:

- a finished clock stores nothing until the outcome question is answered, and
  then stores exactly one row;
- the break has already started while that question is still open (B2);
- finished and stopped breaks are stored without asking anything (A2);
- Stop while idle and pause/resume store nothing at all (A3, A1);
- closing the window persists an in-flight clock, unanswered;
- the day count and last completion time are read back from the store.

Together with tests/ui/test_presenter.py and test_cues.py, the whole UI seam
is covered without a display; test_app_import.py additionally imports
ctimer.ui.app with DISPLAY unset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Point the launcher at the Flet app: add StartupWMClass so the window matches
its launcher, better Categories/Keywords/GenericName, and two launcher
actions (--hide, --silence) in the current Actions= form rather than the
Unity-only X-Ayatana shortcut groups.

The --stats/--overall shortcuts are dropped; those views become a page
inside the window in Phase 4, not separate commands.

xdg/README.md documents installing the .desktop file and the icon, for a
plain install and for the Linux bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Pushing a v* tag now runs four jobs:

- dist: `uv build` -> wheel + sdist artifact;
- bundles: `flet build linux` on ubuntu and `flet build macos` on macOS,
  each packed into a tarball (the Linux one also carries the .desktop file
  and icon). The Flutter SDK is a build-time-only requirement, fetched and
  cached inside the job by subosito/flutter-action;
- pypi: `uv publish --trusted-publishing always` from a `pypi` environment
  with id-token: write, so no API token is stored anywhere;
- github-release: `gh release create` with generated notes and every
  artifact attached.

main.py at the project root is the module `flet build` runs inside a bundle;
it only calls ctimer.cli:main, which is the same entry point the wheel
installs. [tool.flet] carries the bundle metadata (product, org, copyright).

Verified locally: `uv build` produces both artifacts, `uv tool install .`
installs a working `ctimer` executable and launching it opens the Flet
window. The bundle jobs are not verifiable here — they need the Flutter SDK
and a macOS runner — so they are written from the flet build CLI contract
and want a first real tag to shake out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
The README described `python setup.py develop`, a Tkinter window and
`--overall` calmap plots, none of which exist any more. It now covers:
install via `uv tool install` / pipx / the release bundles, what the window
does, every flag, where the data lives (including that a 1.x/2.x database is
imported automatically and the old file left untouched), the dev loop
(`uv sync`, `uv run pytest`, `uv run ruff check .`) and the package layout.

CONTRIBUTING.rst and docs/installation.rst lose their setup.py/tox/flake8
instructions for the same uv commands, and the supported versions become
3.11-3.13.

Also deletes the Makefile and the invoke `tasks/` package: both drove
setup.py and `inv test`, and CI has run `uv run pytest` since Phase 0.

No screenshot yet — one wants a real desktop session to capture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
A bar-chart icon in the window opens a stats page and a close icon goes
back. It shows this week's clocks per day as a flet-charts BarChart with the
daily aim drawn behind each bar (today highlighted), a one-line summary of
clocks and focus time, and a calmap-style year heatmap built from plain
coloured Containers — no matplotlib, no browser, no files in /tmp.

The numbers live in ctimer/ui/stats_presenter.py: pure functions over
ctimer.core.stats producing WeekView and YearView, including the weekday
padding of the first heatmap column, the month labels, the heat levels
scaled against the daily aim, and the best day of the year. All of it is
unit-tested, including a leap year and the year boundary; stats_page.py is
rendering only.

Per Appendix A.4 the aggregates come from focus sessions only and focus
minutes exclude paused time, so breaks and pause partials cannot inflate the
chart the way they inflated the old calmap plot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Both flags are kept, both now open the window on the stats page, and both
print a one-line note saying the views moved in-app instead of writing HTML
into /tmp and opening a browser. Their --help text is updated to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
Deletes ctimer/visual (the bokeh/calmap/matplotlib plotting that wrote HTML
into /tmp) now that the stats live in the window, and removes pandas and
matplotlib from the runtime dependencies along with the ruff per-file-ignore
that existed only for that package.

`uv tree --no-dev` no longer mentions pandas, matplotlib, bokeh or calmap:
the runtime tree is flet[desktop], flet-audio and flet-charts.

data/ is left alone: it is gitignored, and the ctimer.db in it may be
somebody's real history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vcqggYJjg15WUpQBQUBqj
@tai271828 tai271828 self-assigned this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant