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:
AI_Assistant 2026-08-22 07:52:54 +00:00
commit cd9f9456e9
No known key found for this signature in database
17 changed files with 1722 additions and 0 deletions

9
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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()

View 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)

View 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()

View 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

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

8
event-tracker.svg Normal file
View 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
View 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
View 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"