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