Initial commit: EventTracker desktop app + Android companion
Desktop: GTK3 + matplotlib app tracking arbitrary events (button -> timestamp -> CSV), with per-event stats, trend charts, settings (rename/add events, relocate data file), and a reset-with-backup flow. Portable desktop launcher via a .desktop.in template + install script, no machine-specific paths baked in. Android: minimal Kivy v1 companion reading/writing the same CSV format, plus buildozer build tooling isolated in a venv and a local p4a recipe override for kivy (see CLAUDE.md for why). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
cd9f9456e9
17 changed files with 1722 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
226
CLAUDE.md
Normal file
226
CLAUDE.md
Normal file
|
|
@ -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.**
|
||||||
120
README.md
Normal file
120
README.md
Normal file
|
|
@ -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).
|
||||||
114
android/README.md
Normal file
114
android/README.md
Normal file
|
|
@ -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.
|
||||||
26
android/build.sh
Normal file
26
android/build.sh
Normal file
|
|
@ -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" "$@"
|
||||||
37
android/buildozer.spec
Normal file
37
android/buildozer.spec
Normal file
|
|
@ -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
|
||||||
250
android/main.py
Normal file
250
android/main.py
Normal file
|
|
@ -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()
|
||||||
110
android/p4a-recipes/kivy/__init__.py
Normal file
110
android/p4a-recipes/kivy/__init__.py
Normal file
|
|
@ -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()
|
||||||
16
android/p4a-recipes/kivy/no-ast-str.patch
Normal file
16
android/p4a-recipes/kivy/no-ast-str.patch
Normal file
|
|
@ -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)
|
||||||
32
android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch
Normal file
32
android/p4a-recipes/kivy/sdl-gl-swapwindow-nogil.patch
Normal file
|
|
@ -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()
|
||||||
11
android/p4a-recipes/kivy/use_cython.patch
Normal file
11
android/p4a-recipes/kivy/use_cython.patch
Normal file
|
|
@ -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
|
||||||
33
android/setup-build-env.sh
Normal file
33
android/setup-build-env.sh
Normal file
|
|
@ -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)"
|
||||||
10
event-tracker.desktop.in
Normal file
10
event-tracker.desktop.in
Normal file
|
|
@ -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;
|
||||||
BIN
event-tracker.png
Normal file
BIN
event-tracker.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
8
event-tracker.svg
Normal file
8
event-tracker.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<rect x="4" y="4" width="120" height="120" rx="24" fill="#4c72b0"/>
|
||||||
|
<rect x="28" y="70" width="16" height="34" rx="3" fill="#ffffff"/>
|
||||||
|
<rect x="56" y="52" width="16" height="52" rx="3" fill="#ffffff"/>
|
||||||
|
<rect x="84" y="34" width="16" height="70" rx="3" fill="#ffffff"/>
|
||||||
|
<circle cx="94" cy="26" r="14" fill="#c44e52"/>
|
||||||
|
<circle cx="94" cy="26" r="6" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 446 B |
701
event_tracker.py
Normal file
701
event_tracker.py
Normal file
|
|
@ -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()
|
||||||
19
install-launcher.sh
Normal file
19
install-launcher.sh
Normal file
|
|
@ -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"
|
||||||
Loading…
Reference in a new issue