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
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()
|
||||
Loading…
Reference in a new issue