Reminders improvements

* Fix Reminders to fire right on the minute after adding them during runtime
 * It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings
This commit is contained in:
Miguel Jacq 2025-12-12 16:38:45 +11:00
parent d809244cf8
commit 3106d408ab
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 200 additions and 54 deletions

View file

@ -3,6 +3,8 @@
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter) * Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
* Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present * Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
* Invoicing should not be enabled by default * Invoicing should not be enabled by default
* Fix Reminders to fire right on the minute after adding them during runtime
* It is now possible to set up Webhooks for Reminders! A URL and a secret value (sent as X-Bouquin-Header) can be set in the Settings.
# 0.7.0 # 0.7.0

View file

@ -277,6 +277,9 @@
"enable_tags_feature": "Enable Tags", "enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging", "enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders", "enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label":"Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents", "enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session", "pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer", "toolbar_pomodoro_timer": "Time-logging timer",

View file

@ -58,7 +58,7 @@ from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget from .reminders import UpcomingRemindersWidget, ReminderWebHook
from .save_dialog import SaveDialog from .save_dialog import SaveDialog
from .search import Search from .search import Search
from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config from .settings import APP_NAME, APP_ORG, load_db_config, save_db_config
@ -115,6 +115,7 @@ class MainWindow(QMainWindow):
self.tags.tagAdded.connect(self._on_tag_added) self.tags.tagAdded.connect(self._on_tag_added)
self.upcoming_reminders = UpcomingRemindersWidget(self.db) self.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder) self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
# When invoices change reminders (e.g. invoice paid), refresh the Reminders widget # When invoices change reminders (e.g. invoice paid), refresh the Reminders widget
@ -1335,6 +1336,11 @@ class MainWindow(QMainWindow):
# Turned off -> cancel any running timer and remove the widget # Turned off -> cancel any running timer and remove the widget
self.pomodoro_manager.cancel_timer() self.pomodoro_manager.cancel_timer()
def _send_reminder_webhook(self, text: str):
if self.cfg.reminders and self.cfg.reminders_webhook_url:
reminder_webhook = ReminderWebHook(text)
reminder_webhook._send()
def _show_flashing_reminder(self, text: str): def _show_flashing_reminder(self, text: str):
""" """
Show a small flashing dialog and request attention from the OS. Show a small flashing dialog and request attention from the OS.
@ -1563,6 +1569,12 @@ class MainWindow(QMainWindow):
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags) self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
self.cfg.reminders_webhook_url = getattr(
new_cfg, "reminders_webhook_url", self.cfg.reminders_webhook_url
)
self.cfg.reminders_webhook_secret = getattr(
new_cfg, "reminders_webhook_secret", self.cfg.reminders_webhook_secret
)
self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents) self.cfg.documents = getattr(new_cfg, "documents", self.cfg.documents)
self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing) self.cfg.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale) self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)

View file

@ -4,7 +4,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot from PySide6.QtCore import QDate, QDateTime, Qt, QTime, QTimer, Signal, Slot, QObject
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QComboBox, QComboBox,
@ -32,6 +32,9 @@ from PySide6.QtWidgets import (
from . import strings from . import strings
from .db import DBManager from .db import DBManager
from .settings import load_db_config
import requests
class ReminderType(Enum): class ReminderType(Enum):
@ -332,43 +335,36 @@ class UpcomingRemindersWidget(QFrame):
main.addWidget(self.body) main.addWidget(self.body)
# Timer to check and fire reminders # Timer to check and fire reminders
# Start by syncing to the next minute boundary #
self._check_timer = QTimer(self) # We tick once per second, but only hit the DB when the clock is
self._check_timer.timeout.connect(self._check_reminders) # exactly on a :00 second. That way a reminder for HH:MM fires at
# HH:MM:00, independent of when it was created.
self._tick_timer = QTimer(self)
self._tick_timer.setInterval(1000) # 1 second
self._tick_timer.timeout.connect(self._on_tick)
self._tick_timer.start()
# Calculate milliseconds until next minute (HH:MM:00) # Also check once on startup so we don't miss reminders that
# should have fired a moment ago when the app wasn't running.
QTimer.singleShot(0, self._check_reminders)
def _on_tick(self) -> None:
"""Called every second; run reminder check only on exact minute boundaries."""
now = QDateTime.currentDateTime() now = QDateTime.currentDateTime()
current_second = now.time().second() if now.time().second() == 0:
current_msec = now.time().msec() # Only do the heavier DB work once per minute, at HH:MM:00,
# so reminders are aligned to the clock and not to when they
# Milliseconds until next minute # were created.
ms_until_next_minute = (60 - current_second) * 1000 - current_msec self._check_reminders(now)
# Start with a single-shot to sync to the minute
self._sync_timer = QTimer(self)
self._sync_timer.setSingleShot(True)
self._sync_timer.timeout.connect(self._start_regular_timer)
self._sync_timer.start(ms_until_next_minute)
# Also check immediately in case there are pending reminders
QTimer.singleShot(1000, self._check_reminders)
def __del__(self): def __del__(self):
"""Cleanup timers when widget is destroyed.""" """Cleanup timers when widget is destroyed."""
try: try:
if hasattr(self, "_check_timer") and self._check_timer: if hasattr(self, "_tick_timer") and self._tick_timer:
self._check_timer.stop() self._tick_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer: except Exception:
self._sync_timer.stop()
except:
pass # Ignore any cleanup errors pass # Ignore any cleanup errors
def _start_regular_timer(self):
"""Start the regular check timer after initial sync."""
# Now we're at a minute boundary, check and start regular timer
self._check_reminders()
self._check_timer.start(60000) # Check every minute
def _on_toggle(self, checked: bool): def _on_toggle(self, checked: bool):
"""Toggle visibility of reminder list.""" """Toggle visibility of reminder list."""
self.body.setVisible(checked) self.body.setVisible(checked)
@ -492,21 +488,28 @@ class UpcomingRemindersWidget(QFrame):
return False return False
def _check_reminders(self): def _check_reminders(self, now: QDateTime | None = None):
"""Check if any reminders should fire now.""" """
Check and trigger due reminders.
This uses absolute clock time, so a reminder for HH:MM will fire
when the system clock reaches HH:MM:00, independent of when the
reminder was created.
"""
# Guard: Check if database connection is valid # Guard: Check if database connection is valid
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None: if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return return
now = QDateTime.currentDateTime() if now is None:
today = QDate.currentDate() now = QDateTime.currentDateTime()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
today = now.date()
reminders = self._db.get_all_reminders() reminders = self._db.get_all_reminders()
# Small grace window (in seconds) so we still fire reminders if
# the app was just opened or the event loop was briefly busy.
GRACE_WINDOW_SECS = 120 # 2 minutes
for reminder in reminders: for reminder in reminders:
if not reminder.active: if not reminder.active:
continue continue
@ -514,28 +517,35 @@ class UpcomingRemindersWidget(QFrame):
if not self._should_fire_on_date(reminder, today): if not self._should_fire_on_date(reminder, today):
continue continue
# Parse time # Parse time: stored as "HH:MM", we treat that as HH:MM:00
hour, minute = map(int, reminder.time_str.split(":")) hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0)) target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones) # Skip if this reminder is still in the future
seconds_diff = current_minute.secsTo(target) if now < target:
if -120 <= seconds_diff <= 0: continue
# Check if we haven't already fired this one
# How long ago should this reminder have fired?
seconds_late = target.secsTo(now) # target -> now
if 0 <= seconds_late <= GRACE_WINDOW_SECS:
# Check if we haven't already fired this occurrence
if not hasattr(self, "_fired_reminders"): if not hasattr(self, "_fired_reminders"):
self._fired_reminders = {} self._fired_reminders = {}
reminder_key = (reminder.id, target.toString()) reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time if reminder_key in self._fired_reminders:
if reminder_key not in self._fired_reminders: continue
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
# For ONCE reminders, deactivate after firing # Mark as fired and emit
if reminder.reminder_type == ReminderType.ONCE: self._fired_reminders[reminder_key] = now
self._db.update_reminder_active(reminder.id, False) self.reminderTriggered.emit(reminder.text)
self.refresh() # Refresh the list to show deactivated reminder
# For ONCE reminders, deactivate after firing
if reminder.reminder_type == ReminderType.ONCE:
self._db.update_reminder_active(reminder.id, False)
self.refresh() # Refresh the list to show deactivated reminder
@Slot() @Slot()
def _add_reminder(self): def _add_reminder(self):
@ -834,3 +844,33 @@ class ManageRemindersDialog(QDialog):
if reply == QMessageBox.Yes: if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id) self._db.delete_reminder(reminder.id)
self._load_reminders() self._load_reminders()
class ReminderWebHook:
def __init__(self, text):
self.text = text
self.cfg = load_db_config()
def _send(self):
payload: dict[str, str] = {
"reminder": self.text,
}
url = self.cfg.reminders_webhook_url
secret = self.cfg.reminders_webhook_secret
_headers = {}
if secret:
_headers["X-Bouquin-Secret"] = secret
if url:
try:
resp = requests.post(
url,
json=payload,
timeout=10,
headers=_headers,
)
except Exception:
# We did our best
pass

View file

@ -45,6 +45,8 @@ def load_db_config() -> DBConfig:
tags = s.value("ui/tags", True, type=bool) tags = s.value("ui/tags", True, type=bool)
time_log = s.value("ui/time_log", True, type=bool) time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool) reminders = s.value("ui/reminders", True, type=bool)
reminders_webhook_url = s.value("ui/reminders_webhook_url", None, type=str)
reminders_webhook_secret = s.value("ui/reminders_webhook_secret", None, type=str)
documents = s.value("ui/documents", True, type=bool) documents = s.value("ui/documents", True, type=bool)
invoicing = s.value("ui/invoicing", False, type=bool) invoicing = s.value("ui/invoicing", False, type=bool)
locale = s.value("ui/locale", "en", type=str) locale = s.value("ui/locale", "en", type=str)
@ -58,6 +60,8 @@ def load_db_config() -> DBConfig:
tags=tags, tags=tags,
time_log=time_log, time_log=time_log,
reminders=reminders, reminders=reminders,
reminders_webhook_url=reminders_webhook_url,
reminders_webhook_secret=reminders_webhook_secret,
documents=documents, documents=documents,
invoicing=invoicing, invoicing=invoicing,
locale=locale, locale=locale,
@ -75,6 +79,8 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/tags", str(cfg.tags)) s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log)) s.setValue("ui/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders)) s.setValue("ui/reminders", str(cfg.reminders))
s.setValue("ui/reminders_webhook_url", str(cfg.reminders_webhook_url))
s.setValue("ui/reminders_webhook_secret", str(cfg.reminders_webhook_secret))
s.setValue("ui/documents", str(cfg.documents)) s.setValue("ui/documents", str(cfg.documents))
s.setValue("ui/invoicing", str(cfg.invoicing)) s.setValue("ui/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale)) s.setValue("ui/locale", str(cfg.locale))

View file

@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
QSpinBox, QSpinBox,
QTabWidget, QTabWidget,
QTextEdit, QTextEdit,
QToolButton,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@ -44,7 +45,7 @@ class SettingsDialog(QDialog):
self.current_settings = load_db_config() self.current_settings = load_db_config()
self.setMinimumWidth(480) self.setMinimumWidth(600)
self.setSizeGripEnabled(True) self.setSizeGripEnabled(True)
# --- Tabs ---------------------------------------------------------- # --- Tabs ----------------------------------------------------------
@ -189,11 +190,66 @@ class SettingsDialog(QDialog):
self.invoicing.setEnabled(False) self.invoicing.setEnabled(False)
self.time_log.toggled.connect(self._on_time_log_toggled) self.time_log.toggled.connect(self._on_time_log_toggled)
# --- Reminders feature + webhook options -------------------------
self.reminders = QCheckBox(strings._("enable_reminders_feature")) self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders) self.reminders.setChecked(self.current_settings.reminders)
self.reminders.toggled.connect(self._on_reminders_toggled)
self.reminders.setCursor(Qt.PointingHandCursor) self.reminders.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.reminders) features_layout.addWidget(self.reminders)
# Container for reminder-specific options, indented under the checkbox
self.reminders_options_container = QWidget()
reminders_options_layout = QVBoxLayout(self.reminders_options_container)
reminders_options_layout.setContentsMargins(24, 0, 0, 0)
reminders_options_layout.setSpacing(4)
self.reminders_options_toggle = QToolButton()
self.reminders_options_toggle.setText(
strings._("reminders_webhook_section_title")
)
self.reminders_options_toggle.setCheckable(True)
self.reminders_options_toggle.setChecked(False)
self.reminders_options_toggle.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_toggle.clicked.connect(
self._on_reminders_options_toggled
)
toggle_row = QHBoxLayout()
toggle_row.addWidget(self.reminders_options_toggle)
toggle_row.addStretch()
reminders_options_layout.addLayout(toggle_row)
# Actual options (labels + QLineEdits)
self.reminders_options_widget = QWidget()
options_form = QFormLayout(self.reminders_options_widget)
options_form.setContentsMargins(0, 0, 0, 0)
options_form.setSpacing(4)
self.reminders_webhook_url = QLineEdit(
self.current_settings.reminders_webhook_url or ""
)
self.reminders_webhook_secret = QLineEdit(
self.current_settings.reminders_webhook_secret or ""
)
self.reminders_webhook_secret.setEchoMode(QLineEdit.Password)
options_form.addRow(
strings._("reminders_webhook_url_label") + ":",
self.reminders_webhook_url,
)
options_form.addRow(
strings._("reminders_webhook_secret_label") + ":",
self.reminders_webhook_secret,
)
reminders_options_layout.addWidget(self.reminders_options_widget)
features_layout.addWidget(self.reminders_options_container)
self.reminders_options_container.setVisible(self.reminders.isChecked())
self.reminders_options_widget.setVisible(False)
self.documents = QCheckBox(strings._("enable_documents_feature")) self.documents = QCheckBox(strings._("enable_documents_feature"))
self.documents.setChecked(self.current_settings.documents) self.documents.setChecked(self.current_settings.documents)
self.documents.setCursor(Qt.PointingHandCursor) self.documents.setCursor(Qt.PointingHandCursor)
@ -388,6 +444,9 @@ class SettingsDialog(QDialog):
tags=self.tags.isChecked(), tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(), time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(), reminders=self.reminders.isChecked(),
reminders_webhook_url=self.reminders_webhook_url.text().strip() or None,
reminders_webhook_secret=self.reminders_webhook_secret.text().strip()
or None,
documents=self.documents.isChecked(), documents=self.documents.isChecked(),
invoicing=( invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False self.invoicing.isChecked() if self.time_log.isChecked() else False
@ -414,6 +473,30 @@ class SettingsDialog(QDialog):
self.parent().themes.set(selected_theme) self.parent().themes.set(selected_theme)
self.accept() self.accept()
def _on_reminders_options_toggled(self, checked: bool) -> None:
"""
Expand/collapse the advanced reminders options (webhook URL/secret).
"""
if checked:
self.reminders_options_toggle.setArrowType(Qt.DownArrow)
self.reminders_options_widget.show()
else:
self.reminders_options_toggle.setArrowType(Qt.RightArrow)
self.reminders_options_widget.hide()
def _on_reminders_toggled(self, checked: bool) -> None:
"""
Conditionally show reminder webhook options depending
on if the reminders feature is toggled on or off.
"""
if hasattr(self, "reminders_options_container"):
self.reminders_options_container.setVisible(checked)
# When turning reminders off, also collapse the section
if not checked and hasattr(self, "reminders_options_toggle"):
self.reminders_options_toggle.setChecked(False)
self._on_reminders_options_toggled(False)
def _on_time_log_toggled(self, checked: bool) -> None: def _on_time_log_toggled(self, checked: bool) -> None:
""" """
Enforce 'invoicing depends on time logging'. Enforce 'invoicing depends on time logging'.