From cd9f9456e938ac723e6741d8688cba2dcf73d05a Mon Sep 17 00:00:00 2001 From: AI_Assistant Date: Sat, 22 Aug 2026 07:52:54 +0000 Subject: [PATCH] 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 --- .gitignore | 9 + CLAUDE.md | 226 ++++++ README.md | 120 +++ android/README.md | 114 +++ android/build.sh | 26 + android/buildozer.spec | 37 + android/main.py | 250 +++++++ android/p4a-recipes/kivy/__init__.py | 110 +++ android/p4a-recipes/kivy/no-ast-str.patch | 16 + .../kivy/sdl-gl-swapwindow-nogil.patch | 32 + android/p4a-recipes/kivy/use_cython.patch | 11 + android/setup-build-env.sh | 33 + event-tracker.desktop.in | 10 + event-tracker.png | Bin 0 -> 1765 bytes event-tracker.svg | 8 + event_tracker.py | 701 ++++++++++++++++++ install-launcher.sh | 19 + 17 files changed, 1722 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 android/README.md create mode 100644 android/build.sh create mode 100644 android/buildozer.spec create mode 100644 android/main.py create mode 100644 android/p4a-recipes/kivy/__init__.py create mode 100644 android/p4a-recipes/kivy/no-ast-str.patch create mode 100644 android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch create mode 100644 android/p4a-recipes/kivy/use_cython.patch create mode 100644 android/setup-build-env.sh create mode 100644 event-tracker.desktop.in create mode 100644 event-tracker.png create mode 100644 event-tracker.svg create mode 100644 event_tracker.py create mode 100644 install-launcher.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a8f01b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Android build tooling — regenerated by setup-build-env.sh / buildozer, +# never something to commit (venv + multi-GB SDK/NDK cache + build output) +android/build/ +android/.buildozer/ +android/bin/ + +# Python bytecode cache +__pycache__/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1abc90f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,226 @@ +# 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 fatal** — `load_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 outright** — `reset_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 ticks** — `MaxNLocator(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 action** — `GLib.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 display** — `rebuild_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 purpose** — `event-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-packages`** — + `setup-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_Assistant` — **never push without asking the user first.** diff --git a/README.md b/README.md new file mode 100644 index 0000000..730cd5e --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Event Tracker + +Single-window GTK3 app. One button per event logs a timestamp to a CSV +file; the window shows total count, avg/hour, avg/day, last logged +time, and time since last event (to the minute, ticks forward live) per +event, plus a line graph of **today's** counts by hour (whole-number +y-axis). + +**View Stats & Trends** opens a per-event breakdown: today / this week / +this month, last 7 and 30 days with daily average, trend vs the prior 7 +days, current/longest day streaks, busiest hour and day, a weekday +breakdown, and three trend charts (by hour, by week, by month). + +**Settings** lets you rename an existing event (renaming migrates its +past log entries to the new name, nothing is lost), add a new event — +each gets its own button and its own color, cycling through 6 colors if +you add more than that — and relocate the data file itself (Browse to +pick a new location; the existing log is moved there on Save, and +anything already at the destination is backed up first). + +**Reset Stats** clears the log after a confirmation dialog. The current +log is copied to a timestamped `.bak-YYYYMMDD-HHMMSS` file first, so a +mis-click doesn't destroy history — restore it by copying that file back +over `event_log.csv`. + +## 1. Check what's already installed (read-only) + +Run this before installing anything — it tells you exactly what's missing: + +```sh +python3 -c "import gi; gi.require_version('Gtk','3.0'); from gi.repository import Gtk; print('PyGObject/GTK3: OK')" 2>&1 +python3 -c "import matplotlib; print('matplotlib:', matplotlib.__version__)" 2>&1 +python3 -c "import matplotlib; matplotlib.use('GTK3Agg'); from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg; print('GTK3Agg backend: OK')" 2>&1 +``` + +Any line printing a Traceback instead of "OK" / a version number tells you +which piece is missing. + +## 2. Install missing pieces + +**Arch / EndeavourOS:** + +```sh +sudo pacman -S --needed python-gobject gtk3 python-matplotlib +``` + +**Debian / derivatives:** + +```sh +sudo apt install python3-gi gir1.2-gtk-3.0 python3-matplotlib +``` + +Don't `pip install PyGObject` — it needs the system GTK3 dev headers and +is far more reliable installed as a distro package. + +## 3. Run + +```sh +chmod +x event_tracker.py +./event_tracker.py +``` + +## 4. Optional: desktop launcher + +```sh +./install-launcher.sh +``` + +Generates `event-tracker.desktop` for wherever you actually cloned this +repo (`event-tracker.desktop.in` is a template with no machine-specific +path baked in) and installs it to `~/.local/share/applications/`. +Re-run it if you move the clone. + +## Data + +Log file: `~/.local/share/event-tracker/event_log.csv` by default — +columns `event,timestamp`, plain CSV, edit by hand if you need to +correct an entry. Created automatically on first run. Move it anywhere +via **Settings → Data file location**; the app always reads its current +location from `config.json` rather than a hardcoded path. + +App settings (event list + current data file location): +`~/.local/share/event-tracker/config.json` — always lives here, written +the first time you Save changes in Settings. This one small file isn't +user-relocatable (it's the pointer to where your real data is, not the +data itself); until it exists the app uses `DEFAULT_EVENTS` / +`DEFAULT_DATA_PATH` from the script. + +If you're upgrading from the single-event version, the old +`cigarette_log.csv` (header: `timestamp` only) is auto-migrated the +first time this version runs — its rows are relabelled `Cigarette` and +written into `event_log.csv` in the new two-column format. The old file +is left untouched on disk. + +## Configuration + +All tunables sit at the top of `event_tracker.py`: + +| Variable | Meaning | +|---|---| +| `DEFAULT_EVENTS` | Starting event list, used only if `config.json` doesn't exist yet | +| `COLOR_PALETTE` | Colors assigned to events by list position, cycles if you have more events than colors | +| `DEFAULT_DATA_PATH` | Starting data file location, used only if `config.json` doesn't exist yet | +| `CONFIG_PATH` | Where app settings (event list + current data file location) are saved — fixed, not relocatable | +| `WEEKLY_WEEKS` | Trailing weeks shown in the stats popup's weekly chart | +| `MONTHLY_MONTHS` | Trailing months shown in the stats popup's monthly chart | +| `TIMESTAMP_FORMAT` | Format used to read/write timestamps in the CSV | + +Renaming/adding events or moving the data file day-to-day: use the +in-app **Settings** button, not this file — it persists to +`config.json`, migrates existing log rows on rename, and moves the CSV +on relocation. + +## Android companion + +`android/` has a minimal Kivy app that reads/writes this same CSV +format — point it at whatever local folder your phone's sync tool +(FolderSync, DAVx5, etc.) mirrors from wherever this app's data file +lives. See `android/README.md` for the build (you build it yourself on +a Linux machine — no signing keys or accounts of ours involved). diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..88acecb --- /dev/null +++ b/android/README.md @@ -0,0 +1,114 @@ +# Event Tracker — Android (v1, minimal) + +Touchscreen companion to the desktop GTK app. Reads/writes the exact +same `event,timestamp` CSV format, so whatever syncs that file between +your Linux box and your phone (FolderSync, DAVx5, etc.) is all the +"syncing" this app needs to know about — it just reads a local file path +on the phone. + +**v1 scope, on purpose:** record buttons, today's totals, one simple +bar. No stats popup, no renaming/adding events, no weekly/monthly +charts — that's the desktop app's job. Once this actually builds and +runs reliably, ask for v2 to bring those over. + +**Known limitation, be aware of it:** FolderSync/DAVx5-style tools sync +whole files, they don't merge CSV rows. If you log an event on both +devices inside the same sync window, you'll likely get a conflicted +duplicate file instead of one merged log — sync frequently, or avoid +logging on both devices back-to-back. + +## 1. Check what's already installed (read-only) + +Run on the Linux machine you're building from (not the phone): + +```sh +command -v buildozer && buildozer version 2>&1 +command -v java && java -version 2>&1 +python3 -c "import cython; print('cython:', cython.__version__)" 2>&1 +``` + +## 2. Install missing pieces + +**Arch / EndeavourOS:** + +```sh +sudo pacman -S --needed jdk-openjdk +``` + +buildozer and cython go in an isolated venv, not system Python — see +next step. (No `pip install --break-system-packages`; that pollutes the +system Python site-packages for no reason when a venv does the job.) + +## 3. Build + +```sh +cd android +./setup-build-env.sh # creates build/venv/, installs buildozer + cython into it — safe to re-run +./build.sh android debug +``` + +`build.sh` is a thin wrapper that just runs buildozer through the venv, +so you don't have to remember to activate it each time. First run +downloads the Android SDK/NDK (~1-2GB) automatically and can take +20-40+ minutes. Expect to troubleshoot at least once — this is normal +for a first Android build, not a sign something's broken in this project +specifically. Output lands at +`android/bin/eventtracker-0.1-arm64-v8a-debug.apk`. + +The Android SDK/NDK buildozer downloads land in `~/.buildozer` +regardless of the venv — that's normal, it's not Python tooling and +isn't meant to be isolated the same way. + +## 4. Install on the phone + +Copy the `.apk` over (same sync tool, USB, whatever), enable "Install +unknown apps" for the file manager you open it with, tap to install. No +Play Store, no signing required for your own sideloaded debug build. + +## 5. Grant storage permission + +The permission prompt on first launch only covers legacy storage access. +On Android 11+, you also need to manually grant broad file access, since +the app needs to reach whatever arbitrary folder your sync tool uses: + +Settings → Apps → Event Tracker → Permissions → Files and media → Allow +management of all files. + +(Exact wording varies by Android version/OEM.) + +## 6. First launch + +Tap **Settings**, enter the full path to `event_log.csv` on this device +— check your sync app's own configuration for its local destination +folder, since that's device- and setup-specific. Save. The record +buttons work once a valid path is set. + +## Configuration + +Tunables at the top of `main.py`: + +| Variable | Meaning | +|---|---| +| `DEFAULT_EVENTS` | Event list — must match the desktop app's `config.json` events to line up, since this v1 has no rename/add UI yet | +| `COLOR_PALETTE` | Bar colors per event, cycles by index | +| `TIMESTAMP_FORMAT` | Must match the desktop app's format exactly — same file, same parser | + +## Planned (v2) + +Not built yet, decided but deferred until this v1 is confirmed working +on-device: + +- Stats popup, event rename/add, weekly/monthly charts — same features + as the desktop app's Settings/Stats & Trends. +- **Auto-update check.** On launch, hit Forgejo's + `GET /api/v1/repos/{owner}/{repo}/releases/latest`, compare the tag to + this app's version, and if newer, download the `.apk` asset and hand + it to the system installer (`REQUEST_INSTALL_PACKAGES` + a FileProvider + + a pyjnius-fired install intent — you still tap "Install" once, + Android doesn't allow a sideloaded app to silently replace itself). + Decided: repo/releases stay **private**, so the app ships with a + read-only token embedded to call the API. Acceptable for a personal, + single-user tool — just worth remembering the token lives inside the + APK if it's ever shared or the phone is lost. Use a token scoped to + read-only access on this repo specifically, not a general account + token. diff --git a/android/build.sh b/android/build.sh new file mode 100644 index 0000000..4375458 --- /dev/null +++ b/android/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Thin wrapper so you don't have to remember the venv path — runs +# buildozer through it directly. Usage: ./build.sh android debug +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VENV_BIN="$SCRIPT_DIR/build/venv/bin" +BUILDOZER="$VENV_BIN/buildozer" + +if [ ! -x "$BUILDOZER" ]; then + echo "Build venv not found — run ./setup-build-env.sh first." >&2 + exit 1 +fi + +cd "$SCRIPT_DIR" +# Running buildozer by absolute path skips everything `source venv/bin/activate` +# normally sets up, so it's replicated by hand: +# - PATH: buildozer shells out to find tools like `cython`, which only +# resolves if the venv's bin/ is on PATH. +# - VIRTUAL_ENV: buildozer's own venv-detection (targets/android.py) checks +# this to decide whether to `pip install --user` its own build deps or +# install normally. Without it set, buildozer wrongly assumes it's not in +# a venv, uses --user, and pip refuses (no user site-packages in a venv). +export PATH="$VENV_BIN:$PATH" +export VIRTUAL_ENV="$SCRIPT_DIR/build/venv" +exec "$BUILDOZER" "$@" diff --git a/android/buildozer.spec b/android/buildozer.spec new file mode 100644 index 0000000..99ad8aa --- /dev/null +++ b/android/buildozer.spec @@ -0,0 +1,37 @@ +[app] +title = Event Tracker +package.name = eventtracker +package.domain = net.tastech + +source.dir = . +source.include_exts = py +version = 0.1 + +requirements = python3,kivy + +# Upstream p4a's kivy recipe declares python_depends on requests/certifi/ +# chardet/idna/urllib3/filetype — kivy.network.UrlRequest extras we never +# call. p4a-recipes/kivy is a copy of that recipe with python_depends +# emptied out. It's not just cleanup: requests pulls in charset-normalizer, +# whose newest release ships an Android-tagged wheel; p4a's dependency +# resolver pins that exact wheel into requirements.txt, then installs it +# with a plain host pip that has no idea it's an Android wheel, and the +# build dies on "not a supported wheel on this platform". Dropping the +# unused chain avoids the bug entirely instead of chasing version pins. +p4a.local_recipes = p4a-recipes + +orientation = portrait +fullscreen = 0 + +# broad file access is required to reach whatever folder FolderSync/DAVx5 +# is syncing into — that folder is picked by the user at runtime (Settings), +# not fixed at build time, so scoped storage APIs alone aren't enough +android.permissions = READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE,MANAGE_EXTERNAL_STORAGE + +android.api = 33 +android.minapi = 24 +android.archs = arm64-v8a + +[buildozer] +log_level = 2 +warn_on_root = 1 diff --git a/android/main.py b/android/main.py new file mode 100644 index 0000000..55030fc --- /dev/null +++ b/android/main.py @@ -0,0 +1,250 @@ +""" +event-tracker-android: minimal Kivy touchscreen companion to the desktop +GTK app. Reads/writes the exact same CSV format (event,timestamp), so a +file kept in sync by FolderSync/DAVx5/etc. between devices is one shared, +consistent log — this app never needs to know about the desktop app at +all, only about the same file format. + +v1 scope, deliberately: record buttons + today's totals + a simple +proportional bar. No stats popup, no event rename/add, no charts beyond +the one bar row. That's the desktop app's job — see ../README.md. This +keeps the first Android build small enough to actually get working +before piling on features. + +No matplotlib: unreliable on Android through Kivy's garden packaging, so +"today's totals" render as a hand-drawn bar using Kivy's own canvas. +""" + +import csv +import os +from collections import Counter +from datetime import datetime + +from kivy.app import App +from kivy.uix.boxlayout import BoxLayout +from kivy.uix.gridlayout import GridLayout +from kivy.uix.button import Button +from kivy.uix.label import Label +from kivy.uix.popup import Popup +from kivy.uix.textinput import TextInput +from kivy.uix.widget import Widget +from kivy.graphics import Color, Rectangle +from kivy.clock import Clock + +try: + from android.permissions import request_permissions, Permission # noqa: F401 + ON_ANDROID = True +except ImportError: + ON_ANDROID = False + +# ---- Configuration — everything environment/event-specific lives here ---- +DEFAULT_EVENTS = ["Cigarette", "B-event"] # must match the desktop app's config.json events to line up +COLOR_PALETTE = [(0.30, 0.45, 0.69, 1), (0.77, 0.31, 0.32, 1), (0.33, 0.66, 0.41, 1)] # RGBA, cycles per event index +TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" # must match the desktop app's format — same file, same parser +APP_TITLE = "Event Tracker" + + +def event_color(index): + return COLOR_PALETTE[index % len(COLOR_PALETTE)] + + +# ---- local app settings: just the CSV path, stored in this app's own +# private storage (never synced — every device points at its own local +# mirror of the shared file, same design as the desktop app's CONFIG_PATH) ---- + +def config_path(user_data_dir): + return os.path.join(user_data_dir, "config.json") + + +def load_data_path(user_data_dir): + path = config_path(user_data_dir) + if os.path.exists(path): + import json + with open(path) as f: + return json.load(f).get("data_path", "") + return "" + + +def save_data_path(user_data_dir, data_path): + import json + os.makedirs(user_data_dir, exist_ok=True) + with open(config_path(user_data_dir), "w") as f: + json.dump({"data_path": data_path}, f, indent=2) + + +# ---- CSV log — same format/columns as the desktop app, on purpose ---- + +def ensure_csv(csv_path): + os.makedirs(os.path.dirname(csv_path), exist_ok=True) + if not os.path.exists(csv_path): + with open(csv_path, "w", newline="") as f: + csv.writer(f).writerow(["event", "timestamp"]) + + +def load_entries(csv_path): + if not csv_path or not os.path.exists(csv_path): + return [] + entries = [] + with open(csv_path, newline="") as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) < 2: + continue + try: + entries.append((row[0], datetime.strptime(row[1], TIMESTAMP_FORMAT))) + except ValueError: + continue # tolerate a malformed row (e.g. a half-written sync) rather than crashing + return sorted(entries, key=lambda e: e[1]) + + +def append_entry(csv_path, event, ts): + ensure_csv(csv_path) + with open(csv_path, "a", newline="") as f: + csv.writer(f).writerow([event, ts.strftime(TIMESTAMP_FORMAT)]) + + +class BarRow(Widget): + """One proportional bar per event — today's count relative to + whichever event has the highest count today. No chart library.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.values = [] # list of (count, color) + self.bind(size=self.redraw, pos=self.redraw) + + def set_values(self, values): + self.values = values + self.redraw() + + def redraw(self, *_args): + self.canvas.clear() + if not self.values: + return + max_count = max((count for count, _ in self.values), default=0) or 1 + n = len(self.values) + bar_width = self.width / (n * 2) + gap = bar_width + x = self.x + gap / 2 + with self.canvas: + for count, color in self.values: + Color(*color) + bar_height = (count / max_count) * self.height + Rectangle(pos=(x, self.y), size=(bar_width, bar_height)) + x += bar_width + gap + + +class SettingsPopup(Popup): + def __init__(self, current_path, on_save, **kwargs): + super().__init__(title="Settings", size_hint=(0.9, 0.5), **kwargs) + self.on_save = on_save + + box = BoxLayout(orientation="vertical", spacing=10, padding=10) + box.add_widget(Label( + text="Path to the synced event_log.csv on this device " + "(check your sync app's configured destination folder):", + size_hint_y=None, height=80, halign="left", valign="top", + text_size=(self.width, None), + )) + self.path_input = TextInput(text=current_path, multiline=False, size_hint_y=None, height=48) + box.add_widget(self.path_input) + + save_button = Button(text="Save", size_hint_y=None, height=48) + save_button.bind(on_release=self.do_save) + box.add_widget(save_button) + + self.add_widget(box) + + def do_save(self, *_args): + self.on_save(self.path_input.text.strip()) + self.dismiss() + + +class TrackerRoot(BoxLayout): + def __init__(self, **kwargs): + super().__init__(orientation="vertical", spacing=10, padding=10, **kwargs) + app = App.get_running_app() + self.user_data_dir = app.user_data_dir + self.events = list(DEFAULT_EVENTS) + self.data_path = load_data_path(self.user_data_dir) + + self.button_row = BoxLayout(orientation="horizontal", spacing=10, size_hint_y=0.25) + self.add_widget(self.button_row) + + self.stats_grid = GridLayout(cols=4, size_hint_y=0.2) + self.add_widget(self.stats_grid) + + self.bar_row = BarRow(size_hint_y=0.35) + self.add_widget(self.bar_row) + + settings_button = Button(text="Settings", size_hint_y=0.1) + settings_button.bind(on_release=self.open_settings) + self.add_widget(settings_button) + + self.build_buttons() + self.refresh() + + def build_buttons(self): + self.button_row.clear_widgets() + for event in self.events: + button = Button(text=f"Record\n{event}") + button.bind(on_release=lambda _w, ev=event: self.record(ev)) + self.button_row.add_widget(button) + + def record(self, event): + if not self.data_path: + self.open_settings() + return + append_entry(self.data_path, event, datetime.now()) + self.refresh() + + def open_settings(self, *_args): + SettingsPopup(self.data_path, self.on_settings_saved).open() + + def on_settings_saved(self, new_path): + self.data_path = new_path + save_data_path(self.user_data_dir, new_path) + self.refresh() + + def refresh(self): + entries = load_entries(self.data_path) + today = datetime.now().date() + + self.stats_grid.clear_widgets() + for header in ("Event", "Total", "Avg/hr", "Today"): + self.stats_grid.add_widget(Label(text=header, bold=True)) + + bar_values = [] + for index, event in enumerate(self.events): + stamps = sorted(ts for ev, ts in entries if ev == event) + total = len(stamps) + today_count = sum(1 for ts in stamps if ts.date() == today) + if total: + span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1) + avg_hour = f"{total / span_hours:.2f}" + else: + avg_hour = "-" + + self.stats_grid.add_widget(Label(text=event)) + self.stats_grid.add_widget(Label(text=str(total))) + self.stats_grid.add_widget(Label(text=avg_hour)) + self.stats_grid.add_widget(Label(text=str(today_count))) + + bar_values.append((today_count, event_color(index))) + + self.bar_row.set_values(bar_values) + + +class EventTrackerApp(App): + def build(self): + self.title = APP_TITLE + if ON_ANDROID: + request_permissions([ + Permission.READ_EXTERNAL_STORAGE, + Permission.WRITE_EXTERNAL_STORAGE, + ]) + return TrackerRoot() + + +if __name__ == "__main__": + EventTrackerApp().run() diff --git a/android/p4a-recipes/kivy/__init__.py b/android/p4a-recipes/kivy/__init__.py new file mode 100644 index 0000000..184248a --- /dev/null +++ b/android/p4a-recipes/kivy/__init__.py @@ -0,0 +1,110 @@ +# Local override of p4a's built-in kivy recipe — identical except +# python_depends is emptied. See buildozer.spec for why: upstream's +# ['certifi', 'chardet', 'idna', 'requests', 'urllib3', 'filetype'] pulls +# in requests -> charset-normalizer, whose newest release ships an +# Android-tagged wheel that p4a's own install step can't actually install +# (see comment in buildozer.spec). We never call kivy.network.UrlRequest, +# so dropping the whole chain is correct, not just a workaround. +from os.path import join +import sys +import packaging.version + +import sh +from pythonforandroid.recipe import PyProjectRecipe +from pythonforandroid.toolchain import current_directory, shprint + + +def get_kivy_version(recipe, arch): + with current_directory(join(recipe.get_build_dir(arch.arch), "kivy")): + return shprint( + sh.Command(sys.executable), + "-c", + "import _version; print(_version.__version__)", + ) + + +def is_kivy_affected_by_deadlock_issue(recipe=None, arch=None): + return packaging.version.parse( + str(get_kivy_version(recipe, arch)) + ) < packaging.version.Version("2.2.0.dev0") + + +def is_kivy_less_than_3(recipe=None, arch=None): + return packaging.version.parse( + str(get_kivy_version(recipe, arch)) + ) < packaging.version.Version("3.0.0.dev0") + + +class KivyRecipe(PyProjectRecipe): + version = '2.3.1' + url = 'https://github.com/kivy/kivy/archive/{version}.zip' + name = 'kivy' + + depends = [('sdl2', 'sdl3'), 'pyjnius', 'setuptools', 'android'] + python_depends = [] # upstream: ['certifi', 'chardet', 'idna', 'requests', 'urllib3', 'filetype'] — unused, see buildozer.spec + hostpython_prerequisites = ["cython>=0.29.1,<=3.0.12"] + + # sdl-gl-swapwindow-nogil.patch is needed to avoid a deadlock. + # See: https://github.com/kivy/kivy/pull/8025 + # WARNING: Remove this patch when a new Kivy version is released. + patches = [ + ("sdl-gl-swapwindow-nogil.patch", is_kivy_affected_by_deadlock_issue), + ("use_cython.patch", is_kivy_less_than_3), + "no-ast-str.patch" + ] + + @property + def need_stl_shared(self): + if "sdl3" in self.ctx.recipe_build_order: + return True + else: + return False + + def get_recipe_env(self, arch, **kwargs): + env = super().get_recipe_env(arch, **kwargs) + + # Taken from CythonRecipe + env['LDFLAGS'] = env['LDFLAGS'] + ' -L{} '.format( + self.ctx.get_libs_dir(arch.arch) + + ' -L{} '.format(self.ctx.libs_dir) + + ' -L{}'.format(join(self.ctx.bootstrap.build_dir, 'obj', 'local', + arch.arch))) + env['LDSHARED'] = env['CC'] + ' -shared' + env['LIBLINK'] = 'NOTNONE' + + # NDKPLATFORM is our switch for detecting Android platform, so can't be None + env['NDKPLATFORM'] = "NOTNONE" + if not is_kivy_less_than_3(self, arch): + env['KIVY_CROSS_PLATFORM'] = 'android' + + if 'sdl2' in self.ctx.recipe_build_order: + env['USE_SDL2'] = '1' + env['KIVY_SPLIT_EXAMPLES'] = '1' + sdl2_mixer_recipe = self.get_recipe('sdl2_mixer', self.ctx) + sdl2_image_recipe = self.get_recipe('sdl2_image', self.ctx) + env['KIVY_SDL2_PATH'] = ':'.join([ + join(self.ctx.bootstrap.build_dir, 'jni', 'SDL', 'include'), + *sdl2_image_recipe.get_include_dirs(arch), + *sdl2_mixer_recipe.get_include_dirs(arch), + join(self.ctx.bootstrap.build_dir, 'jni', 'SDL2_ttf'), + ]) + if "sdl3" in self.ctx.recipe_build_order: + sdl3_mixer_recipe = self.get_recipe("sdl3_mixer", self.ctx) + sdl3_image_recipe = self.get_recipe("sdl3_image", self.ctx) + sdl3_ttf_recipe = self.get_recipe("sdl3_ttf", self.ctx) + sdl3_recipe = self.get_recipe("sdl3", self.ctx) + env["USE_SDL3"] = "1" + env["KIVY_SPLIT_EXAMPLES"] = "1" + env["KIVY_SDL3_PATH"] = ":".join( + [ + *sdl3_mixer_recipe.get_include_dirs(arch), + *sdl3_image_recipe.get_include_dirs(arch), + *sdl3_ttf_recipe.get_include_dirs(arch), + *sdl3_recipe.get_include_dirs(arch), + ] + ) + + return env + + +recipe = KivyRecipe() diff --git a/android/p4a-recipes/kivy/no-ast-str.patch b/android/p4a-recipes/kivy/no-ast-str.patch new file mode 100644 index 0000000..09ac0ef --- /dev/null +++ b/android/p4a-recipes/kivy/no-ast-str.patch @@ -0,0 +1,16 @@ +diff -ur kivy-2.3.1b/kivy/lang/parser.py kivy-2.3.1/kivy/lang/parser.py +--- kivy-2.3.1b/kivy/lang/parser.py 2025-10-19 13:04:51.542798827 +1300 ++++ kivy-2.3.1/kivy/lang/parser.py 2025-10-19 13:05:16.007104601 +1300 +@@ -230,11 +230,7 @@ + + if isinstance(node, (ast.JoinedStr, ast.BoolOp)): + for n in node.values: +- if isinstance(n, ast.Str): +- # NOTE: required for python3.6 +- yield from cls.get_names_from_expression(n.s) +- else: +- yield from cls.get_names_from_expression(n.value) ++ yield from cls.get_names_from_expression(n.value) + + if isinstance(node, ast.BinOp): + yield from cls.get_names_from_expression(node.right) diff --git a/android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch b/android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch new file mode 100644 index 0000000..8a7c33a --- /dev/null +++ b/android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch @@ -0,0 +1,32 @@ +diff --git a/kivy/core/window/_window_sdl2.pyx b/kivy/core/window/_window_sdl2.pyx +index 46e15ec63..5002cd0f9 100644 +--- a/kivy/core/window/_window_sdl2.pyx ++++ b/kivy/core/window/_window_sdl2.pyx +@@ -746,7 +746,13 @@ cdef class _WindowSDL2Storage: + pass + + def flip(self): +- SDL_GL_SwapWindow(self.win) ++ # On Android (and potentially other platforms), SDL_GL_SwapWindow may ++ # lock the thread waiting for a mutex from another thread to be ++ # released. Calling SDL_GL_SwapWindow with the GIL released allow the ++ # other thread to run (e.g. to process the event filter callback) and ++ # release the mutex SDL_GL_SwapWindow is waiting for. ++ with nogil: ++ SDL_GL_SwapWindow(self.win) + + def save_bytes_in_png(self, filename, data, int width, int height): + cdef SDL_Surface *surface = SDL_CreateRGBSurfaceFrom( +diff --git a/kivy/lib/sdl2.pxi b/kivy/lib/sdl2.pxi +index 6a539de6d..3a5a69d23 100644 +--- a/kivy/lib/sdl2.pxi ++++ b/kivy/lib/sdl2.pxi +@@ -627,7 +627,7 @@ cdef extern from "SDL.h": + cdef SDL_GLContext SDL_GL_GetCurrentContext() + cdef int SDL_GL_SetSwapInterval(int interval) + cdef int SDL_GL_GetSwapInterval() +- cdef void SDL_GL_SwapWindow(SDL_Window * window) ++ cdef void SDL_GL_SwapWindow(SDL_Window * window) nogil + cdef void SDL_GL_DeleteContext(SDL_GLContext context) + + cdef int SDL_NumJoysticks() diff --git a/android/p4a-recipes/kivy/use_cython.patch b/android/p4a-recipes/kivy/use_cython.patch new file mode 100644 index 0000000..2a0d207 --- /dev/null +++ b/android/p4a-recipes/kivy/use_cython.patch @@ -0,0 +1,11 @@ +--- kivy-master/setup.py 2025-02-25 03:08:18.000000000 +0530 ++++ kivy-master.mod/setup.py 2025-03-01 13:10:24.227808612 +0530 +@@ -249,7 +249,7 @@ + # This determines whether Cython specific functionality may be used. + can_use_cython = True + +-if platform in ('ios', 'android'): ++if platform in ('ios'): + # NEVER use or declare cython on these platforms + print('Not using cython on %s' % platform) + can_use_cython = False diff --git a/android/setup-build-env.sh b/android/setup-build-env.sh new file mode 100644 index 0000000..58ee8eb --- /dev/null +++ b/android/setup-build-env.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Creates an isolated venv for buildozer/cython so the Android build +# tooling never touches Arch's externally-managed system Python. +# Safe to re-run — skips venv creation if it already exists, and +# pip install is idempotent. +set -euo pipefail + +# ---- Configuration ---- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD_DIR="$SCRIPT_DIR/build" +VENV_DIR="$BUILD_DIR/venv" + +mkdir -p "$BUILD_DIR" + +if [ ! -d "$VENV_DIR" ]; then + echo "Creating venv at $VENV_DIR" + # Plain isolated venv — no --system-site-packages. buildozer's own + # `pip install --user ...` bootstrap step (for p4a's build deps) is + # skipped automatically when it sees $VIRTUAL_ENV set, which build.sh + # sets — see the comment there. --system-site-packages was tried + # briefly to work around that instead, but it leaks the real system + # Python's installed packages into what buildozer decides the app + # needs to bundle, which is worse. + python3 -m venv "$VENV_DIR" +fi + +"$VENV_DIR/bin/pip" install --upgrade pip +"$VENV_DIR/bin/pip" install buildozer cython + +echo +echo "Build environment ready." +echo "Run the build with: $SCRIPT_DIR/build.sh android debug" +echo "(or activate it directly: source $VENV_DIR/bin/activate)" diff --git a/event-tracker.desktop.in b/event-tracker.desktop.in new file mode 100644 index 0000000..259de15 --- /dev/null +++ b/event-tracker.desktop.in @@ -0,0 +1,10 @@ +#!/usr/bin/env xdg-open +[Desktop Entry] +Type=Application +Name=Event Tracker +Comment=Log an event and see hourly/daily averages +Exec=__EXEC_PATH__ +Icon=__ICON_PATH__ +StartupWMClass=event-tracker +Terminal=false +Categories=Utility; diff --git a/event-tracker.png b/event-tracker.png new file mode 100644 index 0000000000000000000000000000000000000000..677ffd16a444eb2a4e0d928586815ba6425a5907 GIT binary patch literal 1765 zcmcIlSyYop6rMjs2%$(=R0Jd_M^Vudq$v9$7!(m?5mXuyH=?M7QX$BeA4IHe0P#?z ztXjl?Ve=r!Ruk$%0Wq?O3%k&R5<=tgaAernZAuI2?ap4?0~y#Kun%spd(E$2wd&_eUl#56-zXENXJ!u zhZ)Q^Y4SLE+RsE|Jb7KbHNGp@RVTbD1Y9g_e(!!E#PWsD?1|gbg9jtf1?${M-V3FF1Pq|j{ftF#I)W&T^)F{*Elx#!G_%0?c zd1?=3@g9GDGC7tM)5pZumJ3%mWVMJ!E|>eLbohW5T90c6J-UIubN|~&vqR~Isah>P ziJMCjQG`UBiiN}ns@%nqrX2eRYr*KX$S^^vCt%{+*bmts@dSP6qQ%i64qeg6kW1p8 z+izo!sa@HpLRp@h`(s$R-Vsy0N!0Mn0_FI#k$k?=HWu@0do)Tk$Eq=S$~p54mu0g` zPaU4__G=#@8}39?O(P_9h*@|mHl2~eC)+o2Di}uk#+Gf1#QF>!?+thd*|`&A&1o~9 z5pzd|Me5u|WIIjkvk}uTWrRTby<0~csJEo?ukL~v$gc+$U29U4kF z8)G*5UN+!XAnyqnEnVsoRldQv3WYlTvja)qV8?jo2wAb`oEZv_>LRtW6q(bvmxYX8 zX*KMKhRHkrMRJ$`#J%0pq~0!*?2q2_~d z!aRPh=w>4%f=piW2NUy^b{7H!Q&5NxNLB~_Y#B=&sOU~o8jv8%ubIbvKI?G8g?Maa zhVm{&z1Rn0bEISpXy0o6A#0kJ3kdK{VJo#IIC8m&qoC#Dv2Dp;n@-96lc>XJE-uVr zCx;`ApudZg=bMJcNNBmb0P-uGinDM3}xdpJO}y3*)$igVV$JRCwHj}BV;6Enyx1aT+h*=-Ic(rq{X~B>?tP#MLCWT|IJdnYtRNotu0gq8<-^zRvMHe#nc%K zOkMY{U$%xQ$4ZM5Q+WR(2Z4y$0&rd{IG8=vyl$397Cs=43RtX(w|6wn;H!`H8SVVT zg$SJ*~C3WstY(|xM-qvFU304jX@mOdOYd-4zD6D2kKbK0Z@1&II zy8JZ#xM_9X8eO=zNDm6dX)7^7#K-aA3#O6H?P{Rz(Ekl=h3x+4;@j1R5&DZOg?u(! zJbyme3H0>}87J9%iKMq7{2*OLB$$;3?F_$H+pDl5@#cvUG>*0ztl?8uU(wBPK;BaZ z$wxfolqmC+65*LV0Ould5n#F7@#wub9UG%i|42x%8s<^g2%zQiO zi^{arxaR?ruj~&lRc=rO7M?bGU#ttz_C|mO^jkU|3+$jDz#KjFS_owF?lZ7i<>E{g z2aTakP_IL-53Gq>g@ewrdIsZFLGzEQ+-+dOyuSLQ8PJAfJ%#Kxa~R`t**f}&zzVS_ zTj|6#coP_Z)9*V!gm@~uZBBYOc^6jtC^llZ%n7idy2?Mja~P&KFx-pf;?5{$i25+& zH)AG=v*z77Aeo|MLw1@sLQ~WzV#B&E=(6yX5%O2xoAR_(0xi2fF?Np~BI%++ttUhl ysZZ*I(Mi5(PK)RL2PO}?C-sIJ+W-B*;6|Lw=UZQ<(a8o%!-0L??iG6}$$tSudee3Q literal 0 HcmV?d00001 diff --git a/event-tracker.svg b/event-tracker.svg new file mode 100644 index 0000000..806f18e --- /dev/null +++ b/event-tracker.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/event_tracker.py b/event_tracker.py new file mode 100644 index 0000000..6b96304 --- /dev/null +++ b/event_tracker.py @@ -0,0 +1,701 @@ +#!/usr/bin/env python3 +""" +event-tracker: minimal GTK3 app that logs button-press events to a CSV +file. Main window: today's hour-by-hour counts per event. Stats popup: +per-event totals, streaks, and hour/week/month trend charts. Settings +popup: rename events, add new ones, and relocate the data file. + +Why CSV instead of sqlite: the log is small by nature (a handful of rows +a day), and a plain file means the user can open it in a text editor or +spreadsheet and fix a bad row by hand without needing any tooling. +""" + +import csv +import json +import os +import shutil +from collections import Counter +from datetime import datetime, timedelta + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib + +import matplotlib + +matplotlib.use("GTK3Agg") +from matplotlib.backends.backend_gtk3agg import FigureCanvasGTK3Agg as FigureCanvas +from matplotlib.figure import Figure +from matplotlib.ticker import MaxNLocator + +# ---- Configuration — everything environment/event-specific lives here ---- +DEFAULT_EVENTS = ["Cigarette", "B-event"] # starting event list on first run only +COLOR_PALETTE = ["#4c72b0", "#c44e52", "#55a868", "#8172b2", "#ccb974", "#64b5cd"] # cycles if more events than colors +DEFAULT_DATA_PATH = os.path.expanduser("~/.local/share/event-tracker/event_log.csv") # used until changed in Settings +CONFIG_PATH = os.path.expanduser("~/.local/share/event-tracker/config.json") # app settings — event list + data path +WEEKLY_WEEKS = 8 # trailing weeks shown in the stats popup's weekly chart +MONTHLY_MONTHS = 6 # trailing months shown in the stats popup's monthly chart +TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S" # sortable, human-readable, DST-safe (local time) +WEEKDAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + +WINDOW_TITLE = "Event Tracker" +WM_CLASS = "event-tracker" # must match the .desktop file's StartupWMClass +ICON_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "event-tracker.png") + + +def event_color(index): + return COLOR_PALETTE[index % len(COLOR_PALETTE)] + + +# ---- app settings persistence: event list + data file location ---- +# Kept separate from the CSV on purpose — config.json is small app state +# that always lives at CONFIG_PATH; the CSV is the actual tracked data, +# and its location (data_path, inside this same file) is what moves. + +def load_config(): + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH) as f: + data = json.load(f) + events = data.get("events") or list(DEFAULT_EVENTS) + data_path = os.path.expanduser(data.get("data_path") or DEFAULT_DATA_PATH) + return events, data_path + return list(DEFAULT_EVENTS), DEFAULT_DATA_PATH + + +def save_config(events, data_path): + os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) + with open(CONFIG_PATH, "w") as f: + json.dump({"events": events, "data_path": data_path}, f, indent=2) + + +def move_data_file(old_path, new_path): + """Relocate the CSV to a new path. If a file already exists at the + destination, back it up first (same convention as Reset) instead of + silently overwriting whatever was already there.""" + old_path, new_path = os.path.abspath(old_path), os.path.abspath(new_path) + if old_path == new_path: + return + os.makedirs(os.path.dirname(new_path), exist_ok=True) + if os.path.exists(new_path): + backup_path = f"{new_path}.bak-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + shutil.copy2(new_path, backup_path) + if os.path.exists(old_path): + shutil.move(old_path, new_path) + + +# ---- CSV log ---- + +def ensure_csv(csv_path, default_event_name): + os.makedirs(os.path.dirname(csv_path), exist_ok=True) + + if not os.path.exists(csv_path): + with open(csv_path, "w", newline="") as f: + csv.writer(f).writerow(["event", "timestamp"]) + return + + # migrate the original single-event format (header: ["timestamp"], no + # event column) so existing logs aren't orphaned by the multi-event switch + with open(csv_path, newline="") as f: + rows = list(csv.reader(f)) + if rows and rows[0] == ["timestamp"]: + migrated = [["event", "timestamp"]] + [[default_event_name, r[0]] for r in rows[1:] if r] + with open(csv_path, "w", newline="") as f: + csv.writer(f).writerows(migrated) + + +def load_entries(csv_path, default_event_name): + ensure_csv(csv_path, default_event_name) + entries = [] + with open(csv_path, newline="") as f: + reader = csv.reader(f) + next(reader, None) # skip header + for row in reader: + if len(row) < 2: + continue + event, ts_raw = row[0], row[1] + try: + entries.append((event, datetime.strptime(ts_raw, TIMESTAMP_FORMAT))) + except ValueError: + continue # tolerate a hand-edited/malformed row instead of crashing + return sorted(entries, key=lambda e: e[1]) + + +def append_entry(csv_path, event, ts): + with open(csv_path, "a", newline="") as f: + csv.writer(f).writerow([event, ts.strftime(TIMESTAMP_FORMAT)]) + + +def rename_event_in_csv(csv_path, old_name, new_name): + if not os.path.exists(csv_path): + return + with open(csv_path, newline="") as f: + rows = list(csv.reader(f)) + if not rows: + return + header, data_rows = rows[0], rows[1:] + changed = False + for row in data_rows: + if row and row[0] == old_name: + row[0] = new_name + changed = True + if changed: + with open(csv_path, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(header) + writer.writerows(data_rows) + + +def reset_log(csv_path): + """Back up the current log with a timestamped copy, then start a fresh + one. The backup means a mis-click doesn't actually destroy history.""" + os.makedirs(os.path.dirname(csv_path), exist_ok=True) + if os.path.exists(csv_path): + backup_path = f"{csv_path}.bak-{datetime.now().strftime('%Y%m%d-%H%M%S')}" + shutil.copy2(csv_path, backup_path) + with open(csv_path, "w", newline="") as f: + csv.writer(f).writerow(["event", "timestamp"]) + + +# ---- stats ---- + +def compute_stats(stamps): + """stamps: sorted list of datetimes for one event. Returns a summary + dict, or None if there's nothing logged yet.""" + if not stamps: + return None + + now = datetime.now() + total = len(stamps) + span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1) + span_days = max(span_hours / 24, 1) + + def count_since(cutoff): + return sum(1 for ts in stamps if ts >= cutoff) + + last_7 = count_since(now - timedelta(days=7)) + prev_7 = sum(1 for ts in stamps if now - timedelta(days=14) <= ts < now - timedelta(days=7)) + last_30 = count_since(now - timedelta(days=30)) + + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + week_start = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0) + + dates = sorted(set(ts.date() for ts in stamps)) + date_set = set(dates) + longest_streak = run = 1 + for i in range(1, len(dates)): + run = run + 1 if (dates[i] - dates[i - 1]).days == 1 else 1 + longest_streak = max(longest_streak, run) + + current_streak = 0 + day = now.date() + while day in date_set: + current_streak += 1 + day -= timedelta(days=1) + + hour_counts = Counter(ts.hour for ts in stamps) + day_counts = Counter(ts.date() for ts in stamps) + weekday_counts = Counter(ts.weekday() for ts in stamps) # 0 = Monday + busiest_hour, busiest_hour_count = hour_counts.most_common(1)[0] + busiest_day, busiest_day_count = max(day_counts.items(), key=lambda kv: kv[1]) + + trend_pct = (last_7 - prev_7) / prev_7 * 100 if prev_7 > 0 else None + + return { + "total": total, + "avg_per_hour": total / span_hours, + "avg_per_day": total / span_days, + "today": count_since(now.replace(hour=0, minute=0, second=0, microsecond=0)), + "this_week": count_since(week_start), + "this_month": count_since(month_start), + "last_7_days": last_7, + "last_7_days_avg": last_7 / 7, + "prev_7_days": prev_7, + "trend_pct": trend_pct, + "last_30_days": last_30, + "last_30_days_avg": last_30 / 30, + "longest_streak": longest_streak, + "current_streak": current_streak, + "days_since_last": (now.date() - stamps[-1].date()).days, + "busiest_hour": busiest_hour, + "busiest_hour_count": busiest_hour_count, + "busiest_day": busiest_day, + "busiest_day_count": busiest_day_count, + "first_logged": stamps[0], + "last_logged": stamps[-1], + "hour_counts": hour_counts, + "weekday_counts": weekday_counts, + } + + +def weekly_counts(stamps, weeks): + """Total events per calendar week (Mon-start) for the trailing `weeks` + weeks, including the current partial week. Returns (labels, values).""" + this_week_start = (datetime.now().date() - timedelta(days=datetime.now().weekday())) + starts = [this_week_start - timedelta(weeks=i) for i in range(weeks - 1, -1, -1)] + buckets = Counter(ts.date() - timedelta(days=ts.weekday()) for ts in stamps) + values = [buckets.get(s, 0) for s in starts] + labels = [s.strftime("%d %b") for s in starts] + return labels, values + + +def monthly_counts(stamps, months): + """Total events per calendar month for the trailing `months` months, + including the current partial month. Returns (labels, values).""" + now = datetime.now() + keys = [] + y, m = now.year, now.month + for i in range(months - 1, -1, -1): + km, ky = m - i, y + while km <= 0: + km += 12 + ky -= 1 + keys.append((ky, km)) + buckets = Counter((ts.year, ts.month) for ts in stamps) + values = [buckets.get(k, 0) for k in keys] + labels = [datetime(k[0], k[1], 1).strftime("%b %Y") for k in keys] + return labels, values + + +def format_elapsed(delta): + """Elapsed time to the minute, e.g. '3d 2h 5m' or '45m'. Larger units + are omitted when zero, but minutes always show (including '0m').""" + total_minutes = max(int(delta.total_seconds() // 60), 0) + days, remainder = divmod(total_minutes, 1440) + hours, minutes = divmod(remainder, 60) + parts = [] + if days: + parts.append(f"{days}d") + if days or hours: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + + +def format_trend(pct): + if pct is None: + return "not enough data yet (need a prior 7-day window)" + if pct > 0: + return f"up {pct:.0f}% vs prior 7 days" + if pct < 0: + return f"down {abs(pct):.0f}% vs prior 7 days" + return "flat vs prior 7 days" + + +# ---- stats & trends popup ---- + +class StatsDialog(Gtk.Dialog): + def __init__(self, parent, events, entries): + super().__init__(title="Stats & Trends", transient_for=parent, flags=0) + self.set_default_size(560, 620) + self.add_button("Close", Gtk.ResponseType.CLOSE) + + notebook = Gtk.Notebook() + content = self.get_content_area() + content.set_border_width(8) + content.pack_start(notebook, True, True, 0) + + for index, event in enumerate(events): + stamps = sorted(ts for ev, ts in entries if ev == event) + page = self.build_event_page(stamps, event_color(index)) + notebook.append_page(page, Gtk.Label(label=event)) + + self.show_all() + + def build_event_page(self, stamps, color): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_border_width(10) + + stats = compute_stats(stamps) + if stats is None: + box.pack_start(Gtk.Label(label="No events logged yet."), False, False, 0) + return box + + weekday_str = " ".join( + f"{WEEKDAY_NAMES[d]}:{stats['weekday_counts'].get(d, 0)}" for d in range(7) + ) + rows = [ + ("Total logged (all-time)", str(stats["total"])), + ("Avg / hour (all-time)", f"{stats['avg_per_hour']:.2f}"), + ("Avg / day (all-time)", f"{stats['avg_per_day']:.2f}"), + ("Today", str(stats["today"])), + ("This calendar week", str(stats["this_week"])), + ("This calendar month", str(stats["this_month"])), + ("Last 7 days", f"{stats['last_7_days']} total, {stats['last_7_days_avg']:.2f}/day"), + ("Last 30 days", f"{stats['last_30_days']} total, {stats['last_30_days_avg']:.2f}/day"), + ("Trend", format_trend(stats["trend_pct"])), + ("Current streak", f"{stats['current_streak']} consecutive day(s)"), + ("Longest streak", f"{stats['longest_streak']} consecutive day(s)"), + ("Days since last event", str(stats["days_since_last"])), + ("Busiest hour (all-time)", f"{stats['busiest_hour']:02d}:00 — {stats['busiest_hour_count']} events"), + ("Busiest day (all-time)", f"{stats['busiest_day'].strftime('%d %b %Y')} — {stats['busiest_day_count']} events"), + ("By weekday (all-time)", weekday_str), + ("First logged", stats["first_logged"].strftime("%d %b %Y %H:%M")), + ("Last logged", stats["last_logged"].strftime("%d %b %Y %H:%M")), + ] + + grid = Gtk.Grid(column_spacing=16, row_spacing=4) + for row, (label_text, value_text) in enumerate(rows): + grid.attach(Gtk.Label(label=label_text, xalign=0), 0, row, 1, 1) + grid.attach(Gtk.Label(label=value_text, xalign=0), 1, row, 1, 1) + box.pack_start(grid, False, False, 0) + + # three angles on the same data: hour of day, week, month + chart_tabs = Gtk.Notebook() + chart_tabs.append_page( + self.bar_chart(range(24), [stats["hour_counts"].get(h, 0) for h in range(24)], + [f"{h:02d}:00" for h in range(0, 24, 3)], list(range(0, 24, 3)), + "hour of day", color), + Gtk.Label(label="By Hour"), + ) + week_labels, week_values = weekly_counts(stamps, WEEKLY_WEEKS) + chart_tabs.append_page( + self.bar_chart(range(len(week_labels)), week_values, week_labels, range(len(week_labels)), + "week starting", color, rotate=45), + Gtk.Label(label="By Week"), + ) + month_labels, month_values = monthly_counts(stamps, MONTHLY_MONTHS) + chart_tabs.append_page( + self.bar_chart(range(len(month_labels)), month_values, month_labels, range(len(month_labels)), + "month", color, rotate=45), + Gtk.Label(label="By Month"), + ) + box.pack_start(chart_tabs, True, True, 0) + + return box + + @staticmethod + def bar_chart(x_positions, values, tick_labels, tick_positions, xlabel, color, rotate=0): + figure = Figure(figsize=(4.8, 2.6)) + axes = figure.add_subplot(111) + axes.bar(list(x_positions), values, color=color) + axes.set_xlabel(xlabel) + axes.set_ylabel("events") + axes.set_xticks(list(tick_positions)) + axes.set_xticklabels(list(tick_labels), rotation=rotate, ha="right" if rotate else "center") + axes.yaxis.set_major_locator(MaxNLocator(integer=True)) # event counts are whole numbers + figure.tight_layout() + return FigureCanvas(figure) + + +# ---- settings popup ---- + +class SettingsDialog(Gtk.Dialog): + def __init__(self, parent, events, data_path): + super().__init__(title="Settings", transient_for=parent, flags=0) + self.set_default_size(460, 380) + self.add_button("Cancel", Gtk.ResponseType.CANCEL) + self.add_button("Save", Gtk.ResponseType.OK) + + self.entries = [] + + content = self.get_content_area() + content.set_border_width(10) + + content.pack_start( + Gtk.Label(label="Rename an event by editing its text, or add a new one below.", xalign=0), + False, False, 4, + ) + + self.rows_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + content.pack_start(self.rows_box, True, True, 0) + + for name in events: + self.add_row(name) + + add_button = Gtk.Button(label="+ Add Event") + add_button.connect("clicked", lambda _w: self.add_row("")) + content.pack_start(add_button, False, False, 6) + + content.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 4) + + content.pack_start(Gtk.Label(label="Data file location:", xalign=0), False, False, 0) + path_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + self.path_entry = Gtk.Entry() + self.path_entry.set_text(data_path) + self.path_entry.set_hexpand(True) + browse_button = Gtk.Button(label="Browse…") + browse_button.connect("clicked", self.on_browse) + path_row.pack_start(self.path_entry, True, True, 0) + path_row.pack_start(browse_button, False, False, 0) + content.pack_start(path_row, False, False, 0) + + note = Gtk.Label( + label="Changing this moves the existing log there on Save " + "(any file already at the destination is backed up first).", + xalign=0, + ) + note.set_line_wrap(True) + content.pack_start(note, False, False, 4) + + self.show_all() + + def add_row(self, name): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + entry = Gtk.Entry() + entry.set_text(name) + entry.set_hexpand(True) + row.pack_start(entry, True, True, 0) + self.entries.append(entry) + self.rows_box.pack_start(row, False, False, 0) + row.show_all() + + def on_browse(self, _widget): + chooser = Gtk.FileChooserDialog( + title="Choose data file location", transient_for=self, action=Gtk.FileChooserAction.SAVE, + ) + chooser.add_buttons( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, + Gtk.STOCK_SAVE, Gtk.ResponseType.OK, + ) + chooser.set_do_overwrite_confirmation(False) # we do our own backup-on-conflict below + + current = self.path_entry.get_text().strip() or DEFAULT_DATA_PATH + current_dir = os.path.dirname(current) or os.path.expanduser("~") + os.makedirs(current_dir, exist_ok=True) + chooser.set_current_folder(current_dir) + chooser.set_current_name(os.path.basename(current) or "event_log.csv") + + if chooser.run() == Gtk.ResponseType.OK: + self.path_entry.set_text(chooser.get_filename()) + chooser.destroy() + + def get_event_names(self): + return [e.get_text().strip() for e in self.entries] + + def get_data_path(self): + return os.path.expanduser(self.path_entry.get_text().strip()) + + +class TrackerWindow(Gtk.Window): + def __init__(self): + super().__init__(title=WINDOW_TITLE) + self.set_default_size(560, 640) + self.set_border_width(12) + self.events, self.data_path = load_config() + + root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + self.add(root) + + # -- record buttons (rebuilt whenever the event list changes) -- + self.button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + root.pack_start(self.button_box, False, False, 0) + + # -- stats grid: one row per event, columns = total / avg-hour / avg-day -- + self.stats_grid = Gtk.Grid(column_spacing=20, row_spacing=4) + self.stats_grid.set_halign(Gtk.Align.CENTER) + for col, header in enumerate(["Event", "Total", "Avg/hour", "Avg/day", "Last Event", "Time Since"]): + self.stats_grid.attach(Gtk.Label(label=header), col, 0, 1, 1) + self.stat_labels = {} + self._event_row_widgets = [] + root.pack_start(self.stats_grid, False, False, 0) + + # -- secondary actions -- + action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + action_box.set_halign(Gtk.Align.CENTER) + + stats_button = Gtk.Button(label="View Stats & Trends") + stats_button.connect("clicked", self.on_view_stats) + action_box.pack_start(stats_button, False, False, 0) + + settings_button = Gtk.Button(label="Settings") + settings_button.connect("clicked", self.on_settings) + action_box.pack_start(settings_button, False, False, 0) + + reset_button = Gtk.Button(label="Reset Stats") + reset_button.get_style_context().add_class("destructive-action") + reset_button.connect("clicked", self.on_reset) + action_box.pack_start(reset_button, False, False, 0) + + root.pack_start(action_box, False, False, 0) + + # -- today's graph -- + self.figure = Figure(figsize=(5, 3.5)) + self.axes = self.figure.add_subplot(111) + self.canvas = FigureCanvas(self.figure) + root.pack_start(self.canvas, True, True, 0) + + self.rebuild_event_widgets() + self.refresh() + + # "Time Since" ticks forward even with no new events, so keep it + # live rather than only updating on user action + GLib.timeout_add_seconds(60, self.on_tick) + + def on_tick(self): + self.refresh() + return True # keep the timeout running + + def rebuild_event_widgets(self): + for child in self.button_box.get_children(): + child.destroy() + for event in self.events: + button = Gtk.Button(label=f"Record {event}") + button.set_size_request(-1, 60) + button.connect("clicked", self.on_record, event) + self.button_box.pack_start(button, True, True, 0) + self.button_box.show_all() + + for widget in self._event_row_widgets: + widget.destroy() + self._event_row_widgets = [] + self.stat_labels = {} + for row, event in enumerate(self.events, start=1): + label = Gtk.Label(label=event) + self.stats_grid.attach(label, 0, row, 1, 1) + self._event_row_widgets.append(label) + cells = [Gtk.Label(), Gtk.Label(), Gtk.Label(), Gtk.Label(), Gtk.Label()] + for col, cell in enumerate(cells, start=1): + self.stats_grid.attach(cell, col, row, 1, 1) + self._event_row_widgets.append(cell) + self.stat_labels[event] = cells + self.stats_grid.show_all() + + def on_record(self, _widget, event): + append_entry(self.data_path, event, datetime.now()) + self.refresh() + + def on_view_stats(self, _widget): + dialog = StatsDialog(self, self.events, load_entries(self.data_path, self.events[0])) + dialog.run() + dialog.destroy() + + def on_settings(self, _widget): + dialog = SettingsDialog(self, self.events, self.data_path) + while True: + response = dialog.run() + if response != Gtk.ResponseType.OK: + break + names = dialog.get_event_names() + new_data_path = dialog.get_data_path() + error = self.validate_settings(names, new_data_path) + if error: + self.show_message(Gtk.MessageType.ERROR, error) + continue + self.apply_settings(names, new_data_path) + break + dialog.destroy() + self.rebuild_event_widgets() + self.refresh() + + @staticmethod + def validate_settings(names, data_path): + if any(not n for n in names): + return "Event names can't be empty." + if len(set(names)) != len(names): + return "Event names must be unique." + if not data_path: + return "Data file location can't be empty." + return None + + def apply_settings(self, names, new_data_path): + for old, new in zip(self.events, names[: len(self.events)]): + if old != new: + rename_event_in_csv(self.data_path, old, new) + self.events = names + + if new_data_path != self.data_path: + move_data_file(self.data_path, new_data_path) + self.data_path = new_data_path + + save_config(self.events, self.data_path) + + def on_reset(self, _widget): + dialog = Gtk.MessageDialog( + transient_for=self, + flags=0, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, + text="Reset all logged events?", + ) + dialog.format_secondary_text( + "This clears every recorded timestamp for every event. " + "A timestamped backup of the current log is saved alongside it " + "first, but this cannot be undone from inside the app." + ) + dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) + confirm_button = dialog.add_button("Reset", Gtk.ResponseType.OK) + confirm_button.get_style_context().add_class("destructive-action") + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.OK: + reset_log(self.data_path) + self.refresh() + + def show_message(self, message_type, text): + dialog = Gtk.MessageDialog( + transient_for=self, flags=0, message_type=message_type, + buttons=Gtk.ButtonsType.OK, text=text, + ) + dialog.run() + dialog.destroy() + + def refresh(self): + entries = load_entries(self.data_path, self.events[0]) + self.update_stats(entries) + self.update_graph(entries) + + def update_stats(self, entries): + for event in self.events: + stamps = sorted(ts for ev, ts in entries if ev == event) + total_cell, hour_cell, day_cell, last_cell, since_cell = self.stat_labels[event] + total = len(stamps) + if total == 0: + total_cell.set_text("0") + hour_cell.set_text("-") + day_cell.set_text("-") + last_cell.set_text("-") + since_cell.set_text("-") + continue + + # average rate = total events / time elapsed since the first + # logged event, floored at 1h/1d so one press doesn't divide by ~0 + span_hours = max((stamps[-1] - stamps[0]).total_seconds() / 3600, 1) + span_days = max(span_hours / 24, 1) + + total_cell.set_text(str(total)) + hour_cell.set_text(f"{total / span_hours:.2f}") + day_cell.set_text(f"{total / span_days:.2f}") + last_cell.set_text(stamps[-1].strftime("%d %b %H:%M")) + since_cell.set_text(format_elapsed(datetime.now() - stamps[-1])) + + def update_graph(self, entries): + self.axes.clear() + + today = datetime.now().date() + hours = list(range(24)) + + for index, event in enumerate(self.events): + counts = Counter(ts.hour for ev, ts in entries if ev == event and ts.date() == today) + values = [counts.get(h, 0) for h in hours] + self.axes.plot(hours, values, marker="o", label=event, color=event_color(index)) + + self.axes.set_ylabel("events") + self.axes.set_xlabel("hour") + self.axes.set_title(f"Today — {today.strftime('%d %b %Y')}") + self.axes.set_xticks(range(0, 24, 2)) + self.axes.set_xticklabels([f"{h:02d}:00" for h in range(0, 24, 2)], rotation=45, ha="right") + self.axes.set_xlim(-0.5, 23.5) + self.axes.yaxis.set_major_locator(MaxNLocator(integer=True)) # event counts are whole numbers + self.axes.legend() + self.figure.tight_layout() + self.canvas.draw() + + +def main(): + # sets the window's WM_CLASS so KDE/GNOME taskbars match this window to + # the .desktop entry's StartupWMClass instead of falling back to some + # unrelated running app's icon + GLib.set_prgname(WM_CLASS) + + if os.path.exists(ICON_FILE): + Gtk.Window.set_default_icon_from_file(ICON_FILE) # applies to the main window and every dialog/popup + + win = TrackerWindow() + win.connect("destroy", Gtk.main_quit) + win.show_all() + Gtk.main() + + +if __name__ == "__main__": + main() diff --git a/install-launcher.sh b/install-launcher.sh new file mode 100644 index 0000000..d8b0453 --- /dev/null +++ b/install-launcher.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Generates event-tracker.desktop for wherever this repo is actually +# cloned (event-tracker.desktop.in is a template, not a working launcher +# — it has no machine-specific path baked in) and installs it. Re-run +# any time after moving the clone; safe to re-run otherwise. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEST="$HOME/.local/share/applications/event-tracker.desktop" + +mkdir -p "$(dirname "$DEST")" +sed \ + -e "s#__EXEC_PATH__#$SCRIPT_DIR/event_tracker.py#" \ + -e "s#__ICON_PATH__#$SCRIPT_DIR/event-tracker.png#" \ + "$SCRIPT_DIR/event-tracker.desktop.in" > "$DEST" +chmod +x "$SCRIPT_DIR/event_tracker.py" "$DEST" +update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true + +echo "Installed launcher: $DEST"