event-tracker/CLAUDE.md
AI_Assistant cd9f9456e9
Initial commit: EventTracker desktop app + Android companion
Desktop: GTK3 + matplotlib app tracking arbitrary events (button ->
timestamp -> CSV), with per-event stats, trend charts, settings
(rename/add events, relocate data file), and a reset-with-backup flow.
Portable desktop launcher via a .desktop.in template + install script,
no machine-specific paths baked in.

Android: minimal Kivy v1 companion reading/writing the same CSV format,
plus buildozer build tooling isolated in a venv and a local p4a recipe
override for kivy (see CLAUDE.md for why).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 07:52:54 +00:00

14 KiB

CLAUDE.md

Context for future Claude sessions working on this repo.

Current status (2026-08-22)

  • Desktop app (event_tracker.py) is feature-complete and working on the user's machine: record buttons, stats popup, settings popup, reset, relocatable data path, taskbar icon, "Time Since" column.
  • Repo is pushed to repo.tas-tech.net/tas-tech.net/event-tracker (private — will go public once there's a tagged release).
  • Android build (android/) is in progress. Native recipe compilation (SDL2, OpenSSL, sqlite3, pyjnius, hostpython3) succeeds. The last blocker was a p4a upstream bug in the kivy recipe's python_depends — see "Android companion — design decisions" below for the fix (android/p4a-recipes/kivy). Not yet confirmed working end-to-end — next session should check whether the user has run ./build.sh android debug since that fix landed and, if not, ask for the latest log tail before touching anything else Android-related.
  • Outstanding, agreed but not implemented: split config.json into two files — event list (safe to sync between machines) vs. data file path (must stay local per-device, same principle as the Android app's own config). User explicitly agreed to this; it just hasn't been built yet. Don't re-ask, just do it when picking this up.
  • Outstanding: LICENSE file. User has decided on GPL-3.0 (want to give simple apps away free via F-Droid/Aurora, charge for the same thing on Play Store — GPL permits charging for build/distribution, doesn't force pricing). Not yet added as a file; add it before the first public/tagged release if nobody's done it by then.
  • Standing rule, unchanged: never git push without asking the user first, even though this repo now has a remote and history — treat every future push as needing fresh confirmation, not a standing yes.

What this is

Single-file GTK3 + matplotlib desktop app (event_tracker.py, despite the filename it's now multi-event and the event list is user editable). One button per entry in self.events records a timestamp to a CSV file; the main window shows a total/avg-hour/avg-day row per event and a line graph of today's counts by hour. A stats popup adds streaks/trends and by-hour/week/month charts; a settings popup renames or adds events and relocates the data file.

Design decisions

  • CSV, not sqlite — dataset is tiny (a handful of rows/day), and a flat file lets the user hand-edit a bad entry without tooling.
  • No inline magic numbers — all environment-specific values (DEFAULT_EVENTS, COLOR_PALETTE, DEFAULT_DATA_PATH, CONFIG_PATH, WEEKLY_WEEKS, MONTHLY_MONTHS, TIMESTAMP_FORMAT) live in a config block at the top of the file. Change those, not the logic below.
  • GTK3 over Tkinter/Qt — native widget set on the target desktop (EndeavourOS/GNOME-ish), no pip-installed GUI runtime to drag around.
  • Malformed CSV rows are skipped, not fatalload_entries() tolerates a hand-edited file rather than crashing the app on startup.
  • One CSV, event + timestamp columns — chosen over one file per event so stats/graph code doesn't duplicate per event. ensure_csv() auto-migrates the original single-column format on first run (existing rows relabelled to the first configured event).
  • Reset backs up, never deletes outrightreset_log() copies the current CSV to a timestamped .bak-* file before truncating. The confirmation dialog in TrackerWindow.on_reset is the only path to this, and always warns before calling it.
  • Integer y-axis ticksMaxNLocator(integer=True) on every graph; event counts are always whole numbers, so fractional ticks (e.g. 0.2) would be misleading.
  • compute_stats()/weekly_counts()/monthly_counts()/format_elapsed() are pure — take a sorted list of datetimes (or a timedelta) for one event, return plain dicts/tuples/strings, no GTK/matplotlib references. Keeps the date math testable without a display.
  • "Time Since" ticks live, not just on actionGLib.timeout_add_seconds(60, ...) in TrackerWindow.__init__ calls refresh() every 60s so the elapsed time keeps counting up without a new event or button click. This is the one thing in the UI that changes purely with wall-clock time.
  • Event list is config, not data — stored separately in config.json (via load_config()/save_config()), not in the CSV. self.events on TrackerWindow is the live, mutable copy; renaming in Settings calls rename_event_in_csv() so historical rows follow the rename rather than becoming orphaned. Colors are derived from an event's position in the list (event_color()), not stored per-event, so there's nothing to keep in sync when the list changes.
  • Data file location is also config, and is itself relocatable — every CSV function (ensure_csv, load_entries, append_entry, rename_event_in_csv, reset_log) takes csv_path as an explicit argument rather than reading a module constant, because it can change at runtime via Settings. self.data_path on TrackerWindow is the live value; CONFIG_PATH itself (where that value is recorded) stays fixed — it's a small pointer file, not the user's data, so it isn't exposed as something to relocate. move_data_file() backs up whatever's already at a destination before overwriting it, same belt-and-suspenders approach as reset_log().
  • Widget rebuild over dynamic-only displayrebuild_event_widgets() destroys and recreates the record buttons and stats-grid rows when the event list changes, rather than trying to diff them. Simpler and this only runs on an explicit Settings save, not per-frame.
  • App icon requires both a window icon and WM_CLASS matching — GTK's window icon alone isn't enough for KDE/GNOME taskbars to pick the right icon; without a WM_CLASS matching the .desktop file's StartupWMClass, the taskbar can fall back to an unrelated running app's icon. main() calls GLib.set_prgname(WM_CLASS) before creating any window, and WM_CLASS must stay identical to StartupWMClass in event-tracker.desktop.in.
  • Icon is PNG, not SVG, on purposeevent-tracker.svg still ships (source of truth for the design, regenerate the PNG from it if the icon changes), but ICON_FILE points at event-tracker.png and the .desktop file's Icon= does too. SVG decoding depends on the system's gdk-pixbuf loader modules (librsvg's loader specifically), which isn't guaranteed present/registered even when librsvg itself is installed — confirmed missing on the dev machine via gdk-pixbuf-query-loaders | grep svg returning nothing despite librsvg being installed. PNG decoding is core gdk-pixbuf, no optional loader involved, so it isn't a system-dependent single point of failure. Both files are loaded by absolute path — no icon-theme install/cache-refresh step required either way.

Repo layout

event_tracker.py           # the desktop GTK app
event-tracker.desktop.in   # launcher template (no machine-specific path baked in)
install-launcher.sh        # generates event-tracker.desktop for this clone's actual path and installs it
event-tracker.svg          # icon source (edit this if the design changes)
event-tracker.png          # icon actually referenced by the app + launcher, regenerate from the .svg
README.md                  # install/run instructions for the desktop app
CLAUDE.md                  # this file
android/
  main.py                  # minimal Kivy touchscreen app, v1 — see its own README for scope
  buildozer.spec           # Android build config
  README.md                # build/install/permission instructions specific to Android
  setup-build-env.sh       # creates the isolated venv for buildozer/cython
  build.sh                 # runs buildozer through that venv (sets PATH + VIRTUAL_ENV by hand)
  p4a-recipes/kivy/        # local override of p4a's built-in kivy recipe — see design decisions below

Android companion — design decisions

  • Same CSV format, deliberately zero coupling otherwise — the Android app only knows the event,timestamp column format and TIMESTAMP_FORMAT; it has no code path that talks to the desktop app or assumes anything about it. The two apps stay in sync purely because they read/write the same file, which some third-party sync tool (FolderSync/DAVx5) mirrors between devices — that sync is out of scope for both apps and is the user's own infrastructure.
  • v1 is intentionally thin (record buttons, today's totals, one bar) — Android build tooling (buildozer/SDK/NDK) is the actual risk on a first build, not app logic. Shipping the smallest thing that proves the pipeline works, before porting the stats popup / settings / rename / charts from the desktop app, was a deliberate choice — see android/README.md.
  • No matplotlib on Android — unreliable through Kivy's garden packaging. BarRow in main.py hand-draws a proportional bar with Kivy's own Color/Rectangle canvas primitives instead.
  • Config path is local-only, same principle as desktop's CONFIG_PATH — each device's app-private storage holds only that device's CSV path; it is never itself synced. Only the CSV — the actual data — goes through the user's sync tool.
  • MANAGE_EXTERNAL_STORAGE permission — the CSV can live in an arbitrary user-chosen folder (wherever the sync tool puts it), so scoped storage / SAF alone isn't enough; broad file access is required and must additionally be granted manually in Android settings post-install (see android/README.md step 5) — Android doesn't allow silently requesting this one like the legacy permissions.
  • The APK is built by the user, on their own machine — this was never attempted in the cloud sandbox that authored the code: no reliable way to fetch a multi-GB Android SDK/NDK there, and "run buildozer android debug on your own box" is the correct place for this regardless.
  • Known gap, not silently glossed over: whole-file sync tools don't merge concurrent CSV writes from two devices — logging on both within the same sync window risks a conflicted duplicate file rather than merged rows. Documented in android/README.md, not solved in code.
  • Build tooling lives in a venv, never --break-system-packagessetup-build-env.sh creates a plain python3 -m venv (no --system-site-packages — that was tried and reverted, see next point) at android/build/venv and installs buildozer/cython into it. build.sh runs buildozer by absolute path rather than source-ing the venv, so it manually replicates the two things activation would do: puts venv/bin on PATH (buildozer shells out to find cython) and sets VIRTUAL_ENV (buildozer's own targets/android.py checks this env var to decide whether its bootstrap pip install needs --user; without it, it wrongly assumes it's not in a venv, uses --user, and pip refuses since there's no user site-packages inside an isolated venv).
  • p4a-recipes/kivy — local override, not a workaround — p4a's own built-in kivy recipe declares python_depends = ['certifi', 'chardet', 'idna', 'requests', 'urllib3', 'filetype'] (for kivy.network.UrlRequest, which this app never calls). requests transitively needs charset-normalizer, whose newest release is the first to ship a prebuilt Android-tagged wheel (new, tied to CPython 3.14's official Android build target). p4a's own dependency resolver (process_python_modules in pythonforandroid/build.py) finds that wheel correctly during its pip --dry-run --platform=android_24_arm64_v8a pass and pins the exact URL into a generated requirements.txt — but the next stage (run_pymodules_install) installs from that file with a plain host pip and no --platform override, so it rejects its own pinned wheel as "not a supported wheel on this platform." That's a real upstream p4a bug, confirmed by reading pythonforandroid/build.py directly (not guessed) — and python_depends isn't something buildozer.spec can override, so p4a-recipes/kivy/__init__.py is a full copy of the upstream recipe (patches included) with just python_depends = []. buildozer.spec points at it via p4a.local_recipes = p4a-recipes. If a future Kivy release actually needs one of those network extras, re-diff against p4a's current pythonforandroid/recipes/kivy/__init__.py before blindly re-adding them.
  • Previously-tried and reverted: --system-site-packages on the build venv. It fixed the --user/pip-in-venv error above, but leaked the real system Python's installed packages into what p4a decided the app needed to bundle (confirmed via pip show requests showing a system-wide install), which is a worse, harder-to-diagnose failure mode than the one it fixed. Don't reintroduce it as a quick fix for a future build error without checking whether it's actually leaking something first.

Conventions for changes

  • Keep the desktop app single-file unless it grows enough to justify splitting (e.g. a separate stats module) — ask before restructuring.
  • Any new tunable value goes in the config block at the top of whichever file it belongs to, never buried in a function body.
  • Keep the CSV format (event,timestamp, TIMESTAMP_FORMAT) identical across event_tracker.py and android/main.py — changing one without the other breaks cross-device sync silently.
  • Git remote is repo.tas-tech.net (Forgejo), auth via ~/.netrc, user AI_Assistantnever push without asking the user first.