event-tracker/CLAUDE.md
AI_Assistant d7e5569dda
Add GPL-3.0 LICENSE, port update checker from android-app-template
LICENSE: verbatim copy of android-app-template's (official SPDX text,
not retyped) — GPL-3.0 as previously decided.

Update checker: check_latest_release()/download_and_install()/
MessagePopup ported from the template, wired to a new "Check for
Updates" button next to Settings. Requires certifi added to
p4a-recipes/kivy's python_depends (HTTPS on Android needs an explicit
CA bundle) and INTERNET/REQUEST_INSTALL_PACKAGES added to
buildozer.spec. FORGEJO_TOKEN at the top of main.py ships blank —
update checking is inert until it's set to a repo-scoped read-only
token. download_and_install() is the standard p4a pattern but not yet
verified on real hardware, same caveat as the template it came from.

Dropped the previously-agreed config.json split (event list vs. data
path) — decided it's not needed after all.
2026-08-22 13:31:03 +00:00

20 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/) works end-to-end AND is confirmed running on-device./build.sh android debug produces android/bin/eventtracker-0.1-arm64-v8a-debug.apk, installs, and launches clean (adb logcat shows Start application main loop, no crash). Five real, distinct bugs had to be fixed to get here (all now baked into build.sh/buildozer.spec/main.py, nothing left to redo):
    1. p4a's kivy recipe pulling in unused requests/charset-normalizer that couldn't actually install — android/p4a-recipes/kivy.
    2. p4a's own internal pymodules venv corrupting itself on every re-build — build.sh deletes it before each run.
    3. Gradle's build-script compiler rejecting this machine's default JDK (java-26) — build.sh pins JAVA_HOME to java-17-openjdk for just this build.
    4. Fix #1 above was applied too aggressively and dropped filetype too, which kivy/core/image genuinely needs — on-device startup crash (ModuleNotFoundError: No module named 'filetype'). Restored just filetype.
    5. p4a caches a built dist by recipe-name match and never re-derives a recipe's python_depends for an existing dist, so fix #4 above silently had zero effect until the stale dist directory (.buildozer/android/platform/build-<arch>/dists/eventtracker) was deleted to force a re-derive. Traced end-to-end through pythonforandroid/distribution.py/graph.py, not guessed — see the dedicated entry below. See "Android companion — design decisions" below for the full root-cause writeup on each.
  • LICENSE added — GPL-3.0, verbatim text copied from android-app-template's copy (itself fetched from the official SPDX corpus, not reproduced from memory). Matches the distribution plan: free via F-Droid/Aurora, GPL permits charging for build/convenience on the Play Store without restricting what a recipient can do with the source.
  • In-app update checker ported from android-app-templatecheck_latest_release()/download_and_install()/MessagePopup in android/main.py, wired to a "Check for Updates" button next to Settings. Needs FORGEJO_TOKEN (top of main.py) set to a read-only token scoped to this repo before it'll do anything — ships blank on purpose, never bake in a real token. Same on-device caveat as the template's original: the download/install half (download_and_install()) is the standard, documented p4a pattern but genuinely NOT YET VERIFIED on real hardware — test it before relying on it. Needed two build-tooling additions to support it: certifi in p4a-recipes/kivy's python_depends (HTTPS on Android's cross-compiled OpenSSL needs an explicit CA bundle — see the recipe file's comments) and INTERNET/REQUEST_INSTALL_PACKAGES added to buildozer.spec's android.permissions.
  • Repo is now public (was private) — the "outstanding" config.json split idea (event list vs. data-path file) was discussed and dropped; not needed, don't reintroduce it without being asked again.
  • 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.
  • Wrapping Labels bind text_size to their own width, never set it once from self.width in a Popup.__init__SettingsPopup's info label originally did text_size=(self.width, None) at construction time, but the Popup hasn't been laid out yet at that point, so self.width is Kivy's ~100px pre-layout default rather than the popup's real size. That locked the label into wrapping at ~100px forever — rendered on-device as a 1-character-wide vertical column of text. Fixed by binding instead: label.bind(width=lambda inst, w: setattr(inst, "text_size", (w, None))) after construction. Same bug, same fix, also applied to android-app-template's SettingsPopup/LegalPopup (that repo had it in four places) — see that repo's CLAUDE.md.
  • 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'] (most of that list only 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) keeping only filetypekivy/core/image/__init__.py imports it directly for image format sniffing, confirmed by an on-device startup crash (ModuleNotFoundError: No module named 'filetype') the one time it got dropped along with the rest. filetype is pure Python with zero dependencies of its own, so it doesn't reintroduce the requests/charset-normalizer chain. buildozer.spec points at the override via p4a.local_recipes = p4a-recipes. If a future Kivy release actually needs one of the other four back, re-diff against p4a's current pythonforandroid/recipes/kivy/__init__.py before blindly re-adding them.
  • Editing p4a-recipes/kivy/__init__.py alone is not enough — p4a caches the built dist and may never re-read it. Distribution.get_ distribution() (pythonforandroid/distribution.py) matches an existing dist purely by name (eventtracker) and recipe-name list (python3, kivy, etc. — from buildozer.spec's requirements = line). python_depends isn't a recipe name, so it's never consulted during that match; if a dist with the same name+recipes already exists, it's reused wholesale with needs_build = False, and build_dist_from_args() — the only function that reads recipe.python_depends and pip-installs pymodules (graph.py:334run_pymodules_install) — never runs at all. This is exactly what happened restoring filetype: the recipe file was correct on disk, but the next build reused the stale dist and the APK still crashed identically. Confirmed two ways: unpacked the built APK's libpybundle.so and found no filetype anywhere in it, then traced the caching logic in distribution.py/graph.py line by line. Fix: delete the stale dist directory so Distribution.get_distributions()'s glob finds nothing and needs_build falls back to Truerm -rf android/.buildozer/android/platform/build-<arch>/dists/<package.name> — then rebuild. This only redoes packaging (pymodule install + Gradle assembly), not the expensive native recipe compilation, so it's fast (single-digit seconds of Gradle work in practice). Anytime a recipe's python_depends changes (not buildozer.spec's requirements = line, which p4a does check), delete the dist before the next build or the change silently won't take effect.
  • 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.
  • build.sh deletes p4a's internal pymodules venv before every run — separate from android/build/venv (ours, for buildozer/cython) and separate from the p4a-recipes bug above. p4a creates its own throwaway venv per arch at .buildozer/android/platform/build-<arch>/build/venv to pip-install pure-Python deps, and unconditionally re-runs python -m venv venv over it on every build without --clear. First build: ensurepip bootstraps whatever pip version shipped with this system's Python (25.3). p4a then runs pip install -U pip inside it (26.2.1). Next build: python -m venv re-invokes ensurepip, which unpacks its older bundled pip back over the newer one without removing the newer version's now-orphaned files — confirmed by hand, both pip-25.3.dist-info and pip-26.2.1.dist-info present in the same site-packages afterward, producing exactly the kind of mismatched-file crash you'd expect (ImportError: cannot import name 'BuildDependencyInstallError' from 'pip._internal.exceptions' — a newer file expecting a class only the newer exceptions.py defines). build.sh just deletes that venv before every buildozer invocation; costs a few seconds (pip + Cython reinstall in a tiny throwaway venv), leaves the expensive SDK/NDK/recipe build state untouched.
  • build.sh pins JAVA_HOME to java-17-openjdk for the build only — this machine's archlinux-java default is java-26 (rolling release), and Gradle 8.14.3 (pulled in by this p4a version) ships a Groovy/ASM build-script compiler that can't parse that new a class file: BUG! ... Unsupported class file major version 70. java-17 is installed alongside the default (archlinux-java status lists both); JAVA_HOME_FOR_BUILD at the top of build.sh points at it without touching the system-wide default JDK used by everything else. If this ever needs to change (different machine, JDK package renamed), that's the one line to edit — build.sh fails loudly with a clear message if the path doesn't exist rather than silently falling through to whatever java resolves to on PATH.

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.