3939PID_PATH = PID_DIR / "tray.pid"
4040LEGACY_PID_PATH = LEGACY_PID_DIR / "tray.pid"
4141NOTIFICATION_ACTIONS_DIR = PID_DIR / "notification_actions" / "inbox"
42+ NOTIFICATION_HISTORY_PATH = ROOT_DIR / "user" / "data" / "notification_history.json"
43+ NOTIFICATION_HISTORY_LIMIT = 200
4244
4345from modules .timer import main as Timer
4446from 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