From 02627c4d8f30862a30ad7aae491a5185c2d32e36 Mon Sep 17 00:00:00 2001 From: "Leonardo B." Date: Mon, 16 Mar 2026 23:48:48 -0300 Subject: [PATCH 1/3] Add scheduler notification toasts and toggle Introduce scheduler system notifications: - Add a new notifications.py module that implements queued, styled toast windows for scheduler events (start, finished_exited, finished_killed, error) - Integrate notifications into the GUI by adding a Notification button with an on/off toggle, building notification payloads, and emitting toasts at schedule start/finish/kill/error points - Persist the scheduler_notification_enabled flag via new load/save functions in config.py - Add QSS styles for notification widgets and ensure theme updates propagate to visible notifications --- src/config.py | 15 ++++ src/gui.py | 148 ++++++++++++++++++++++++++++++- src/notifications.py | 204 +++++++++++++++++++++++++++++++++++++++++++ src/theme.py | 24 +++++ 4 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/notifications.py diff --git a/src/config.py b/src/config.py index 2236175..d40d590 100644 --- a/src/config.py +++ b/src/config.py @@ -119,3 +119,18 @@ def toggle_favorite(script_path: str) -> bool: fav.add(script_path) save_favorites(fav) return True + + +def load_scheduler_notification_enabled() -> bool: + """Returns True when scheduler system notifications are enabled.""" + raw = _load_all().get("scheduler_notification_enabled") + if isinstance(raw, bool): + return raw + return False + + +def save_scheduler_notification_enabled(enabled: bool) -> None: + """Persists the scheduler system notification enabled flag.""" + data = _load_all() + data["scheduler_notification_enabled"] = bool(enabled) + _save_all(data) diff --git a/src/gui.py b/src/gui.py index 3b889ca..6f62c98 100644 --- a/src/gui.py +++ b/src/gui.py @@ -26,15 +26,19 @@ QWidget, ) +from datetime import datetime + import utils from config import ( load_favorites, load_project_path, + load_scheduler_notification_enabled, load_script_categories, load_terminal_path, load_theme, load_venv_activate_path, save_project_path, + save_scheduler_notification_enabled, save_script_category, save_terminal_path, save_theme, @@ -48,17 +52,19 @@ from utils import get_process_tree_after_spawn, kill_script_process, run_script_in_gitbash, run_script_in_gitbash_captured from scheduler_data import create_history_entry, now_iso -from scheduler_engine import get_due_schedules, validate_trigger +from scheduler_engine import get_due_schedules, get_next_run, validate_trigger from scheduler_storage import ( append_history_entry, append_log, get_run_log_file_path, + load_history, load_schedules, replace_log, save_schedules, update_history_entry, ) from scheduler_ui import SchedulerContentWidget +from notifications import show_notification, update_notification_theme CATEGORY_OPTIONS = ("None", "backend", "frontend") CATEGORY_FILTER_OPTIONS = ("All", "Backend", "Frontend", "Running") @@ -219,6 +225,56 @@ def __init__(self): def _palette(self) -> dict: return DARK_PALETTE if self._theme == "dark" else LIGHT_PALETTE + def _format_next_run_for_notification(self, schedule: dict) -> str: + if not schedule.get("enabled", False): + return "—" + next_run = get_next_run(schedule) + if next_run is None: + return "—" + now = datetime.now(next_run.tzinfo) if next_run.tzinfo is not None else datetime.now() + delta = next_run - now + total_seconds = int(delta.total_seconds()) + if schedule.get("rule_type") == "time": + return next_run.strftime("%H:%M %d/%m/%y") + if total_seconds <= 0: + return "in 0m" + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + if hours and minutes: + return f"in {hours}h {minutes}m" + if hours: + return f"in {hours}h" + return f"in {minutes}m" + + def _build_notification_payload( + self, + schedule: dict, + script_name: Optional[str] = None, + error_message: Optional[str] = None, + ) -> dict: + schedule_name = schedule.get("name", "—") + script_path = schedule.get("script_path") or "" + if script_name is None: + script_name = os.path.basename(script_path) if script_path else "—" + rule_type = "Time" if schedule.get("rule_type") == "time" else "Interval" + next_run_text = self._format_next_run_for_notification(schedule) + return { + "schedule_name": schedule_name, + "script_name": script_name, + "rule_type": rule_type, + "next_run": next_run_text, + "error_message": error_message, + } + + def _get_schedule_and_history_for_id(self, history_id: str) -> tuple[Optional[dict], Optional[dict]]: + runs = load_history() + history = next((r for r in runs if r.get("id") == history_id), None) + if history is None: + return None, None + schedules = load_schedules() + schedule = next((s for s in schedules if s.get("id") == history.get("schedule_id")), None) + return schedule, history + def _build_top_bar(self) -> QWidget: bar = QWidget() bar.setObjectName("topBar") @@ -264,6 +320,21 @@ def _build_top_bar(self) -> QWidget: ) row.addWidget(venv_btn) + notification_btn = QPushButton("Notification") + notification_btn.setObjectName("topBarBtn") + notification_btn.clicked.connect( + lambda: self._show_button_menu( + notification_btn, + [ + ( + f"Scheduled: {'On' if load_scheduler_notification_enabled() else 'Off'}", + self._toggle_scheduler_notifications, + ), + ], + ) + ) + row.addWidget(notification_btn) + row.addStretch() theme_label = "☀ Light" if self._theme == "dark" else "🌙 Dark" @@ -274,6 +345,10 @@ def _build_top_bar(self) -> QWidget: return bar + def _toggle_scheduler_notifications(self) -> None: + enabled = load_scheduler_notification_enabled() + save_scheduler_notification_enabled(not enabled) + def _toggle_theme(self) -> None: self._theme = "light" if self._theme == "dark" else "dark" save_theme(self._theme) @@ -284,6 +359,7 @@ def _toggle_theme(self) -> None: if hasattr(self, "_scheduler_widget"): self._scheduler_widget.update_log_highlighter_palette(self._palette) self._scheduler_widget.refresh_current_view() + update_notification_theme(self._palette) def _build_paths_row(self) -> QWidget: row_widget = QWidget() @@ -1045,6 +1121,18 @@ def _kill_script_row(self, row: dict) -> None: return history_id = row.get("scheduler_history_id") if history_id: + if load_scheduler_notification_enabled(): + schedule, history = self._get_schedule_and_history_for_id(history_id) + if schedule: + payload = self._build_notification_payload(schedule) + show_notification( + event_type="finished_killed", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + ) update_history_entry(history_id, { "status": "killed", "finished_at": now_iso(), @@ -1073,6 +1161,18 @@ def check_processes(self) -> None: continue history_id = row.get("scheduler_history_id") if history_id: + if load_scheduler_notification_enabled(): + schedule, history = self._get_schedule_and_history_for_id(history_id) + if schedule: + payload = self._build_notification_payload(schedule) + show_notification( + event_type="finished_exited", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + ) update_history_entry(history_id, { "status": "exited", "finished_at": now_iso(), @@ -1219,6 +1319,19 @@ def _execute_scheduled_run(self, schedule: dict) -> None: ) append_history_entry(entry) self._mark_schedule_triggered(schedule) + if load_scheduler_notification_enabled(): + payload = self._build_notification_payload(schedule, error_message=error) + if not script_path: + payload["script_name"] = error + show_notification( + event_type="error", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + error_message=payload["error_message"], + ) return row = self._get_row(script_path) @@ -1226,6 +1339,18 @@ def _execute_scheduled_run(self, schedule: dict) -> None: if row and self._is_row_running(row): history_id = row.get("scheduler_history_id") if history_id: + if load_scheduler_notification_enabled(): + schedule_row, history = self._get_schedule_and_history_for_id(history_id) + if schedule_row: + payload = self._build_notification_payload(schedule_row) + show_notification( + event_type="finished_killed", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + ) update_history_entry(history_id, { "status": "killed", "finished_at": now_iso(), @@ -1290,6 +1415,16 @@ def _execute_scheduled_run(self, schedule: dict) -> None: if script_path == self._selected_script_path: self._render_detail_panel() + if load_scheduler_notification_enabled(): + payload = self._build_notification_payload(schedule) + show_notification( + event_type="start", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + ) except Exception as exc: update_history_entry(entry["id"], { "status": "failed", @@ -1297,6 +1432,17 @@ def _execute_scheduled_run(self, schedule: dict) -> None: "error_message": str(exc), }) self._mark_schedule_triggered(schedule) + if load_scheduler_notification_enabled(): + payload = self._build_notification_payload(schedule, error_message=str(exc)) + show_notification( + event_type="error", + schedule_name=payload["schedule_name"], + script_name=payload["script_name"], + rule_type=payload["rule_type"], + next_run=payload["next_run"], + palette=self._palette, + error_message=payload["error_message"], + ) def _mark_schedule_triggered(self, schedule: dict) -> None: schedule["last_triggered_at"] = now_iso() diff --git a/src/notifications.py b/src/notifications.py new file mode 100644 index 0000000..623632d --- /dev/null +++ b/src/notifications.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import os +from collections import deque +from typing import Any, Deque, Dict, Optional + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget + +NOTIFICATION_DURATION_MS = 4000 + +EVENT_START = "start" +EVENT_FINISHED_EXITED = "finished_exited" +EVENT_FINISHED_KILLED = "finished_killed" +EVENT_ERROR = "error" + + +_queue: Deque[Dict[str, Any]] = deque() +_current_toast: Optional["_NotificationToast"] = None +_current_palette: Optional[dict] = None + + +class _NotificationToast(QWidget): + def __init__(self, payload: Dict[str, Any], palette: dict, on_closed: callable): + super().__init__(None, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) + self.setObjectName("notificationToast") + self.setAttribute(Qt.WA_StyledBackground, True) + self._payload = payload + self._palette = palette + self._on_closed = on_closed + + self._build_ui() + self._apply_palette() + self._position_on_screen() + + QTimer.singleShot(NOTIFICATION_DURATION_MS, self._handle_timeout) + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 10, 12, 10) + layout.setSpacing(6) + + header_row = QHBoxLayout() + header_row.setContentsMargins(0, 0, 0, 0) + header_row.setSpacing(6) + + self._title_label = QLabel(self._build_title()) + self._title_label.setObjectName("notificationTitle") + header_row.addWidget(self._title_label) + header_row.addStretch() + + close_btn = QPushButton("×") + close_btn.setObjectName("notificationCloseBtn") + close_btn.setFixedSize(18, 18) + close_btn.clicked.connect(self._handle_close_clicked) + header_row.addWidget(close_btn) + + layout.addLayout(header_row) + + # Main body + schedule_name = self._payload.get("schedule_name", "—") + script_name = self._payload.get("script_name", "—") + rule_type = self._payload.get("rule_type", "—") + next_run = self._payload.get("next_run", "—") + error_message = self._payload.get("error_message") + + body_label = QLabel( + f"Schedule: {schedule_name}\n" + f"Script: {script_name}\n" + f"Rule: {rule_type}\n" + f"Next run: {next_run}" + ) + body_label.setObjectName("notificationBody") + body_label.setWordWrap(True) + layout.addWidget(body_label) + + if self._payload.get("event_type") == EVENT_ERROR and error_message: + error_label = QLabel(str(error_message)) + error_label.setObjectName("notificationError") + error_label.setWordWrap(True) + layout.addWidget(error_label) + + self.setMinimumWidth(260) + self.adjustSize() + + def _build_title(self) -> str: + event_type = self._payload.get("event_type") + if event_type == EVENT_START: + return "Scheduled run started" + if event_type == EVENT_FINISHED_EXITED: + return "Scheduled run finished" + if event_type == EVENT_FINISHED_KILLED: + return "Scheduled run killed" + if event_type == EVENT_ERROR: + return "Scheduled run error" + return "Scheduled run" + + def _apply_palette(self) -> None: + # The main styling is done via QSS (theme.py) using objectName. + # This method exists so we can trigger a refresh when palette changes. + self.style().unpolish(self) + self.style().polish(self) + self.update() + + def update_palette(self, palette: dict) -> None: + self._palette = palette + self._apply_palette() + + def _position_on_screen(self) -> None: + app = QApplication.instance() + if app is None: + return + + # Determine screen: prefer the active window's screen, fallback to primary. + screen = None + active_window = app.activeWindow() + if active_window and active_window.windowHandle(): + screen = active_window.windowHandle().screen() + if screen is None: + screen = QGuiApplication.primaryScreen() + if screen is None: + return + + geom = screen.availableGeometry() + self.adjustSize() + x = geom.right() - self.width() - 16 + y = geom.bottom() - self.height() - 16 + self.move(x, y) + + def _handle_timeout(self) -> None: + self._finalize_close() + + def _handle_close_clicked(self) -> None: + self._finalize_close() + + def _finalize_close(self) -> None: + try: + self.close() + finally: + if self._on_closed: + self._on_closed() + + +def _show_next_from_queue() -> None: + global _current_toast + if _current_toast is not None: + return + if not _queue: + return + + payload = _queue.popleft() + + def _on_closed() -> None: + global _current_toast + _current_toast = None + # Show next notification in queue, if any. + if _queue: + QTimer.singleShot(50, _show_next_from_queue) + + palette = _current_palette or {} + _current_toast = _NotificationToast(payload, palette, _on_closed) + _current_toast.show() + + +def show_notification( + event_type: str, + schedule_name: str, + script_name: str, + rule_type: str, + next_run: str, + palette: dict, + error_message: Optional[str] = None, +) -> None: + """Queue a system notification for scheduler-related events. + + All text is preformatted by gui.py; this module only displays it. + """ + # Optional no-op on non-Windows platforms (spec allows degrade). + if os.name != "nt": + return + + global _current_palette + _current_palette = palette + + payload = { + "event_type": event_type, + "schedule_name": schedule_name, + "script_name": script_name, + "rule_type": rule_type, + "next_run": next_run, + "error_message": error_message, + } + _queue.append(payload) + if _current_toast is None: + _show_next_from_queue() + + +def update_notification_theme(palette: dict) -> None: + """Update palette for currently visible notification and future ones.""" + global _current_palette + _current_palette = palette + if _current_toast is not None: + _current_toast.update_palette(palette) diff --git a/src/theme.py b/src/theme.py index 1013e1e..6c19e93 100644 --- a/src/theme.py +++ b/src/theme.py @@ -518,4 +518,28 @@ def get_stylesheet(theme: str = "dark") -> str: QPushButton#historyLogCloseBtn:pressed {{ background-color: rgba(185, 28, 28, 0.2); }} +QWidget#notificationToast {{ + background-color: {p["bg_card"]}; + border: 1px solid {p["border"]}; + border-radius: 8px; +}} +QLabel#notificationTitle {{ + color: {p["text_title"]}; + font-weight: 600; +}} +QLabel#notificationBody {{ + color: {p["text_primary"]}; +}} +QLabel#notificationError {{ + color: {p["kill_btn_bg"]}; +}} +QPushButton#notificationCloseBtn {{ + background-color: transparent; + border: none; + color: {p["text_muted"]}; + padding: 0; +}} +QPushButton#notificationCloseBtn:hover {{ + color: {p["text_primary"]}; +}} """ From 5f8f57f4b90b034533f4ffbd79a8e578e86c99dd Mon Sep 17 00:00:00 2001 From: "Leonardo B." Date: Tue, 17 Mar 2026 00:23:15 -0300 Subject: [PATCH 2/3] Improve notifications UI - Replace textual close button with a styled icon button (drawn QIcon) and add hover tooltip/hover-state via an event filter; adjust notification layout margins and spacing. - Update stylesheet for notification close button (border, sizing, hover/pressed colors). - Show seconds in next-run formatting (return "in 0s" and "in Ns" for <60s) and use format_rule_display for schedule rule labels in the GUI. Also change trash SVG stroke to red. --- assets/trash.svg | 2 +- src/gui.py | 8 +++--- src/notifications.py | 63 ++++++++++++++++++++++++++++++++++++++------ src/theme.py | 19 ++++++++++--- 4 files changed, 77 insertions(+), 15 deletions(-) diff --git a/assets/trash.svg b/assets/trash.svg index 20fb5cb..13cadde 100644 --- a/assets/trash.svg +++ b/assets/trash.svg @@ -1,4 +1,4 @@ - + diff --git a/src/gui.py b/src/gui.py index 6f62c98..a16f4b6 100644 --- a/src/gui.py +++ b/src/gui.py @@ -52,7 +52,7 @@ from utils import get_process_tree_after_spawn, kill_script_process, run_script_in_gitbash, run_script_in_gitbash_captured from scheduler_data import create_history_entry, now_iso -from scheduler_engine import get_due_schedules, get_next_run, validate_trigger +from scheduler_engine import format_rule_display, get_due_schedules, get_next_run, validate_trigger from scheduler_storage import ( append_history_entry, append_log, @@ -237,7 +237,9 @@ def _format_next_run_for_notification(self, schedule: dict) -> str: if schedule.get("rule_type") == "time": return next_run.strftime("%H:%M %d/%m/%y") if total_seconds <= 0: - return "in 0m" + return "in 0s" + if total_seconds < 60: + return f"in {total_seconds}s" hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 if hours and minutes: @@ -256,7 +258,7 @@ def _build_notification_payload( script_path = schedule.get("script_path") or "" if script_name is None: script_name = os.path.basename(script_path) if script_path else "—" - rule_type = "Time" if schedule.get("rule_type") == "time" else "Interval" + rule_type = format_rule_display(schedule) next_run_text = self._format_next_run_for_notification(schedule) return { "schedule_name": schedule_name, diff --git a/src/notifications.py b/src/notifications.py index 623632d..ca0f453 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -4,9 +4,27 @@ from collections import deque from typing import Any, Deque, Dict, Optional -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget +from PySide6.QtCore import QEvent, QObject, QSize, Qt, QTimer +from PySide6.QtGui import QCursor, QColor, QGuiApplication, QIcon, QPainter, QPixmap +from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QToolTip, QVBoxLayout, QWidget + +CLOSE_ICON_SIZE = 10 +CLOSE_BTN_SIZE = 18 + + +def _make_close_icon(color_hex: str, size: int = CLOSE_ICON_SIZE) -> QIcon: + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + painter.setPen(QColor(color_hex)) + painter.setBrush(Qt.NoBrush) + w, h = size, size + margin = 1 if size <= 12 else 2 + painter.drawLine(margin, margin, w - margin, h - margin) + painter.drawLine(w - margin, margin, margin, h - margin) + painter.end() + return QIcon(pixmap) NOTIFICATION_DURATION_MS = 4000 @@ -21,6 +39,28 @@ _current_palette: Optional[dict] = None +CLOSE_BTN_TOOLTIP = "Close notification" + + +class _CloseButtonHoverFilter(QObject): + def __init__(self, button: QPushButton, icon_normal: QIcon, icon_hover: QIcon): + super().__init__(button) + self._button = button + self._icon_normal = icon_normal + self._icon_hover = icon_hover + + def eventFilter(self, obj: QObject, event: QEvent) -> bool: + if obj is self._button: + if event.type() == QEvent.Type.Enter: + self._button.setIcon(self._icon_hover) + pos = QCursor.pos() + QToolTip.showText(pos, CLOSE_BTN_TOOLTIP, self._button) + elif event.type() == QEvent.Type.Leave: + self._button.setIcon(self._icon_normal) + QToolTip.hideText() + return super().eventFilter(obj, event) + + class _NotificationToast(QWidget): def __init__(self, payload: Dict[str, Any], palette: dict, on_closed: callable): super().__init__(None, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) @@ -38,23 +78,30 @@ def __init__(self, payload: Dict[str, Any], palette: dict, on_closed: callable): def _build_ui(self) -> None: layout = QVBoxLayout(self) - layout.setContentsMargins(12, 10, 12, 10) + layout.setContentsMargins(14, 12, 8, 12) layout.setSpacing(6) header_row = QHBoxLayout() header_row.setContentsMargins(0, 0, 0, 0) - header_row.setSpacing(6) + header_row.setSpacing(8) self._title_label = QLabel(self._build_title()) self._title_label.setObjectName("notificationTitle") header_row.addWidget(self._title_label) header_row.addStretch() - close_btn = QPushButton("×") + close_btn = QPushButton() close_btn.setObjectName("notificationCloseBtn") - close_btn.setFixedSize(18, 18) + close_btn.setFixedSize(CLOSE_BTN_SIZE, CLOSE_BTN_SIZE) + icon_red = _make_close_icon(self._palette.get("kill_btn_bg", "#b91c1c")) + icon_white = _make_close_icon(self._palette.get("btn_text", "#ffffff")) + close_btn.setIcon(icon_red) + close_btn.setIconSize(QSize(CLOSE_ICON_SIZE, CLOSE_ICON_SIZE)) + close_btn.setToolTip(CLOSE_BTN_TOOLTIP) + close_btn.setCursor(Qt.PointingHandCursor) close_btn.clicked.connect(self._handle_close_clicked) - header_row.addWidget(close_btn) + close_btn.installEventFilter(_CloseButtonHoverFilter(close_btn, icon_red, icon_white)) + header_row.addWidget(close_btn, 0, Qt.AlignRight | Qt.AlignTop) layout.addLayout(header_row) diff --git a/src/theme.py b/src/theme.py index 6c19e93..8cd8693 100644 --- a/src/theme.py +++ b/src/theme.py @@ -535,11 +535,24 @@ def get_stylesheet(theme: str = "dark") -> str: }} QPushButton#notificationCloseBtn {{ background-color: transparent; - border: none; - color: {p["text_muted"]}; + border: 1px solid {p["kill_btn_bg"]}; + border-radius: 3px; + color: {p["kill_btn_bg"]}; + font-size: 13px; + font-weight: 700; + min-width: 18px; + min-height: 18px; + max-width: 18px; + max-height: 18px; padding: 0; }} QPushButton#notificationCloseBtn:hover {{ - color: {p["text_primary"]}; + background-color: {p["kill_btn_bg"]}; + color: {p["btn_text"]}; +}} +QPushButton#notificationCloseBtn:pressed {{ + background-color: {p["kill_btn_pressed"]}; + border-color: {p["kill_btn_pressed"]}; + color: {p["btn_text"]}; }} """ From ad6d8e4209c6652328834ecec974e075b2667f0b Mon Sep 17 00:00:00 2001 From: "Leonardo B." Date: Tue, 17 Mar 2026 00:27:55 -0300 Subject: [PATCH 3/3] Update README --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index daab12f..8d2d003 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Compact toolbar at the top of the window: - **Project** - Set project path | Refresh to rescan scripts - **Terminal** - Set Git Bash path - **Venv** - Set venv activate path for scripts that interact with Python | Clear venv path to revert to auto-detect +- **Notification** - Dropdown to turn scheduler notifications **On** or **Off** (persisted in config). When On, a toast is shown for each scheduler-related event. - **Theme toggle** - Switch between dark and light themes (persisted across restarts) - **Page selector** - Switch between **Home** (script detail panel) and **Scheduler** (schedules and run history). The sidebar remains visible in both views. @@ -124,6 +125,17 @@ A dedicated **Scheduler** page (via the Home | Scheduler selector) lets you run Schedules and run history are stored in the `Scheduler` folder: `Scheduler/schedules.json`, `Scheduler/scheduler_history.json`, `Scheduler/history_logs.json`. Run logs are written to `Scheduler/logs/`. +### System notifications + +When notifications are enabled (Toolbar → **Notification** → **Scheduled: On**), the app shows a small toast in the bottom-right of the screen for scheduler events only: + +- **Scheduled run started** - The script process was started by the scheduler. +- **Scheduled run finished** - The run exited on its own. +- **Scheduled run killed** - The run was stopped (by you or by the scheduler before the next run). +- **Scheduled run error** - The run failed to start (e.g. script not found, not under project path, or launch exception). + +Each toast shows the schedule name, script name, rule (e.g. interval and duration), and time until the next run. Toasts use the same theme as the app (dark or light), stay on top of other windows, auto-close after a few seconds, and can be closed early with the close button. Multiple toasts are shown one after another. + ## How scripts are discovered The app scans the **selected folder** recursively and lists every `.sh` file. Names are shown relative to the project root (e.g. `backend/run.sh`, `scripts/docker-up.sh`). Scripts **run with CWD = their own folder**, not the project root.