event-tracker/event_tracker.py

701 lines
28 KiB
Python
Raw Permalink Normal View History

#!/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()