Skip to content

Commit b027e9c

Browse files
committed
Add tray notification history UI
1 parent 6a41d82 commit b027e9c

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

utilities/tray/app.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
PID_PATH = PID_DIR / "tray.pid"
4040
LEGACY_PID_PATH = LEGACY_PID_DIR / "tray.pid"
4141
NOTIFICATION_ACTIONS_DIR = PID_DIR / "notification_actions" / "inbox"
42+
NOTIFICATION_HISTORY_PATH = ROOT_DIR / "user" / "data" / "notification_history.json"
43+
NOTIFICATION_HISTORY_LIMIT = 200
4244

4345
from modules.timer import main as Timer
4446
from modules.scheduler import get_flattened_schedule, schedule_path_for_date, status_current_path
@@ -97,6 +99,7 @@ def __init__(self):
9799
self.root.protocol("WM_DELETE_WINDOW", self.hide_window)
98100
self.window = None
99101
self.notification_window = None
102+
self.notification_history_window = None
100103
self._theme_ready = False
101104
self._window_icon_path = self._resolve_window_icon_path()
102105

@@ -118,6 +121,9 @@ def __init__(self):
118121
self.status_schema = self._load_status_schema()
119122
self._latest_timer_state = {}
120123
self._notification_history = {}
124+
self._notification_log = self._load_notification_log()
125+
self._notification_history_listbox = None
126+
self._notification_history_details_var = tk.StringVar(value="Select a notification to inspect it.")
121127
self._last_icon_key = None
122128
self._tick_job = None
123129
self._quitting = False
@@ -200,6 +206,33 @@ def _write_yaml(self, path, data):
200206
except Exception:
201207
return False
202208

209+
def _load_notification_log(self):
210+
try:
211+
if NOTIFICATION_HISTORY_PATH.exists():
212+
rows = json.loads(NOTIFICATION_HISTORY_PATH.read_text(encoding="utf-8"))
213+
if isinstance(rows, list):
214+
return [row for row in rows if isinstance(row, dict)][-NOTIFICATION_HISTORY_LIMIT:]
215+
except Exception:
216+
return []
217+
return []
218+
219+
def _save_notification_log(self):
220+
try:
221+
NOTIFICATION_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
222+
rows = list(self._notification_log or [])[-NOTIFICATION_HISTORY_LIMIT:]
223+
NOTIFICATION_HISTORY_PATH.write_text(json.dumps(rows, indent=2), encoding="utf-8")
224+
return True
225+
except Exception:
226+
return False
227+
228+
def _append_notification_log(self, entry):
229+
if not isinstance(entry, dict):
230+
return
231+
self._notification_log.append(entry)
232+
self._notification_log = self._notification_log[-NOTIFICATION_HISTORY_LIMIT:]
233+
self._save_notification_log()
234+
self._refresh_notification_history_list()
235+
203236
def _notification_settings(self):
204237
raw = self._read_yaml(self._settings_path("notification_settings.yml"))
205238
settings = dict(DEFAULT_NOTIFICATION_SETTINGS)
@@ -743,6 +776,17 @@ def _notify_event(self, event_type, title, message, *, severity="attention", ded
743776
sent = self._emit_desktop_notification(title, message)
744777
if sent:
745778
self._notification_history[key] = now
779+
self._append_notification_log(
780+
{
781+
"created_at": now.isoformat(timespec="seconds"),
782+
"event_type": str(event_type or ""),
783+
"title": str(title or ""),
784+
"message": str(message or ""),
785+
"severity": severity_key,
786+
"dedupe_key": key,
787+
"actions": [str(action.get("label") or "").strip() for action in toast_actions if isinstance(action, dict)],
788+
}
789+
)
746790
return sent
747791

748792
def _process_notification_actions(self):
@@ -822,6 +866,20 @@ def _display_time_label(self, value):
822866
mm = mins % 60
823867
return f"{hh:02d}:{mm:02d}"
824868

869+
def _history_time_label(self, value):
870+
txt = str(value or "").strip()
871+
if not txt:
872+
return "--:--"
873+
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
874+
try:
875+
return datetime.strptime(txt, fmt).strftime("%m-%d %H:%M")
876+
except Exception:
877+
continue
878+
try:
879+
return datetime.fromisoformat(txt.replace("Z", "+00:00")).replace(tzinfo=None).strftime("%m-%d %H:%M")
880+
except Exception:
881+
return txt
882+
825883
def _parse_date_value(self, value):
826884
txt = str(value or "").strip()
827885
if not txt:
@@ -1242,6 +1300,7 @@ def _build_menu(self):
12421300
Item("Open Dashboard", lambda: self.open_dashboard()),
12431301
Item("Open Topos", lambda: self.open_topos()),
12441302
pystray.Menu.SEPARATOR,
1303+
Item("Notification History", lambda: self.open_notification_history()),
12451304
Item("Notification Settings", lambda: self.open_notification_settings()),
12461305
Item("Quit", self.request_quit),
12471306
)
@@ -1268,6 +1327,18 @@ def open_notification_settings(self):
12681327
self.notification_window.lift()
12691328
self.notification_window.focus_force()
12701329

1330+
def open_notification_history(self):
1331+
if self.notification_history_window and self.notification_history_window.winfo_exists():
1332+
self._refresh_notification_history_list()
1333+
self.notification_history_window.deiconify()
1334+
self.notification_history_window.lift()
1335+
self.notification_history_window.focus_force()
1336+
return
1337+
self.notification_history_window = self._build_notification_history_window()
1338+
self.notification_history_window.deiconify()
1339+
self.notification_history_window.lift()
1340+
self.notification_history_window.focus_force()
1341+
12711342
def open_topos(self):
12721343
subprocess.Popen(["cmd", "/c", "topos_launcher.bat"], cwd=str(ROOT_DIR))
12731344

@@ -1357,6 +1428,150 @@ def _hide_notification_settings(self):
13571428
if self.notification_window and self.notification_window.winfo_exists():
13581429
self.notification_window.withdraw()
13591430

1431+
def _hide_notification_history(self):
1432+
if self.notification_history_window and self.notification_history_window.winfo_exists():
1433+
self.notification_history_window.withdraw()
1434+
1435+
def _refresh_notification_history_details(self, index=None):
1436+
rows = list(self._notification_log or [])
1437+
if not rows:
1438+
self._notification_history_details_var.set("No notifications yet.")
1439+
return
1440+
if index is None:
1441+
if not self._notification_history_listbox:
1442+
self._notification_history_details_var.set("Select a notification to inspect it.")
1443+
return
1444+
try:
1445+
selection = self._notification_history_listbox.curselection()
1446+
index = int(selection[0]) if selection else 0
1447+
except Exception:
1448+
index = 0
1449+
display_rows = list(reversed(rows))
1450+
if index < 0 or index >= len(display_rows):
1451+
self._notification_history_details_var.set("Select a notification to inspect it.")
1452+
return
1453+
row = display_rows[index]
1454+
actions = ", ".join([label for label in (row.get("actions") or []) if str(label).strip()]) or "-"
1455+
details = (
1456+
f"{row.get('title') or '(untitled)'}\n"
1457+
f"{row.get('message') or '-'}\n\n"
1458+
f"Time: {row.get('created_at') or '-'}\n"
1459+
f"Type: {row.get('event_type') or '-'}\n"
1460+
f"Severity: {row.get('severity') or '-'}\n"
1461+
f"Actions: {actions}"
1462+
)
1463+
self._notification_history_details_var.set(details)
1464+
1465+
def _on_notification_history_select(self, _event=None):
1466+
self._refresh_notification_history_details()
1467+
1468+
def _refresh_notification_history_list(self):
1469+
if not self._notification_history_listbox:
1470+
return
1471+
listbox = self._notification_history_listbox
1472+
listbox.delete(0, tk.END)
1473+
rows = list(reversed(self._notification_log or []))
1474+
if not rows:
1475+
listbox.insert(tk.END, "No notifications yet.")
1476+
listbox.itemconfig(0, fg="#6f809b")
1477+
self._notification_history_details_var.set("No notifications yet.")
1478+
return
1479+
for row in rows:
1480+
timestamp = self._history_time_label(row.get("created_at"))
1481+
event_type = self._nice_label(row.get("event_type") or "notification")
1482+
title = str(row.get("title") or "(untitled)").strip() or "(untitled)"
1483+
if len(title) > 42:
1484+
title = title[:39] + "..."
1485+
listbox.insert(tk.END, f"{timestamp} {event_type} {title}")
1486+
try:
1487+
listbox.selection_clear(0, tk.END)
1488+
listbox.selection_set(0)
1489+
listbox.activate(0)
1490+
except Exception:
1491+
pass
1492+
self._refresh_notification_history_details(0)
1493+
1494+
def _clear_notification_history(self):
1495+
self._notification_log = []
1496+
self._save_notification_log()
1497+
self._refresh_notification_history_list()
1498+
1499+
def _build_notification_history_window(self):
1500+
self._apply_theme()
1501+
win = tk.Toplevel(self.root)
1502+
win.title("Chronos Notification History")
1503+
win.geometry("700x420")
1504+
win.minsize(700, 420)
1505+
win.protocol("WM_DELETE_WINDOW", self._hide_notification_history)
1506+
win.attributes("-topmost", True)
1507+
win.configure(bg="#0b1220")
1508+
self._apply_window_icon(win)
1509+
1510+
frame = ttk.Frame(win, padding=10, style="Chronos.TFrame")
1511+
frame.pack(fill=tk.BOTH, expand=True)
1512+
1513+
header = ttk.Frame(frame, style="Chronos.TFrame")
1514+
header.pack(fill=tk.X, pady=(0, 8))
1515+
ttk.Label(header, text="Notification History", style="Chronos.Header.TLabel").pack(side=tk.LEFT)
1516+
ttk.Button(header, text="Clear", command=self._clear_notification_history, style="Chronos.TButton").pack(side=tk.RIGHT)
1517+
1518+
ttk.Label(
1519+
frame,
1520+
text="Recent Chronos tray notifications, newest first.",
1521+
style="Chronos.TLabel",
1522+
wraplength=660,
1523+
justify=tk.LEFT,
1524+
).pack(anchor=tk.W, pady=(0, 8))
1525+
1526+
content = ttk.Frame(frame, style="Chronos.TFrame")
1527+
content.pack(fill=tk.BOTH, expand=True)
1528+
1529+
list_card = ttk.Frame(content, padding=8, style="Chronos.TFrame")
1530+
list_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 8))
1531+
ttk.Label(list_card, text="Recent", style="Chronos.Header.TLabel").pack(anchor=tk.W, pady=(0, 6))
1532+
1533+
list_frame = ttk.Frame(list_card, style="Chronos.TFrame")
1534+
list_frame.pack(fill=tk.BOTH, expand=True)
1535+
list_scroll = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, style="Chronos.Vertical.TScrollbar")
1536+
self._notification_history_listbox = tk.Listbox(
1537+
list_frame,
1538+
bg="#0e1728",
1539+
fg="#d5e2f7",
1540+
selectbackground="#1e3a5f",
1541+
selectforeground="#ecf3ff",
1542+
highlightthickness=1,
1543+
highlightbackground="#22314f",
1544+
highlightcolor="#5aa9ff",
1545+
borderwidth=0,
1546+
relief="flat",
1547+
activestyle="none",
1548+
font=("Consolas", 8),
1549+
yscrollcommand=list_scroll.set,
1550+
)
1551+
list_scroll.config(command=self._notification_history_listbox.yview)
1552+
self._notification_history_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
1553+
list_scroll.pack(side=tk.RIGHT, fill=tk.Y, padx=(8, 0))
1554+
self._notification_history_listbox.bind("<<ListboxSelect>>", self._on_notification_history_select)
1555+
1556+
detail_card = ttk.Frame(content, padding=8, style="Chronos.TFrame")
1557+
detail_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
1558+
ttk.Label(detail_card, text="Details", style="Chronos.Header.TLabel").pack(anchor=tk.W, pady=(0, 6))
1559+
tk.Label(
1560+
detail_card,
1561+
textvariable=self._notification_history_details_var,
1562+
bg="#0e1728",
1563+
fg="#d5e2f7",
1564+
justify=tk.LEFT,
1565+
anchor="nw",
1566+
padx=10,
1567+
pady=10,
1568+
wraplength=280,
1569+
font=("Segoe UI", 8),
1570+
).pack(fill=tk.BOTH, expand=True)
1571+
1572+
self._refresh_notification_history_list()
1573+
return win
1574+
13601575
def _build_notification_settings_window(self):
13611576
self._apply_theme()
13621577
win = tk.Toplevel(self.root)

0 commit comments

Comments
 (0)