Compare commits

..

2 commits

Author SHA1 Message Date
206670454f
Improvements to StatisticsDialog
All checks were successful
CI / test (push) Successful in 9m33s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 21s
It now shows statistics about logged time, reminders, etc.
Sections are grouped for better readability.

Improvements to Manage Reminders dialog to show date of alarm
2025-12-12 18:41:05 +11:00
3106d408ab
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
2025-12-12 16:38:45 +11:00
12 changed files with 635 additions and 142 deletions

View file

@ -3,6 +3,9 @@
* 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
* 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.
* Improvements to StatisticsDialog: it now shows statistics about logged time, reminders, etc. Sections are grouped for better readability
# 0.7.0

View file

@ -95,6 +95,8 @@ class DBConfig:
tags: bool = True
time_log: bool = True
reminders: bool = True
reminders_webhook_url: str = (None,)
reminders_webhook_secret: str = (None,)
documents: bool = True
invoicing: bool = False
locale: str = "en"
@ -971,7 +973,7 @@ class DBManager:
# 2 & 3) total revisions + page with most revisions + per-date counts
total_revisions = 0
page_most_revisions = None
page_most_revisions: str | None = None
page_most_revisions_count = 0
revisions_by_date: Dict[_dt.date, int] = {}
@ -1008,7 +1010,6 @@ class DBManager:
words_by_date[d] = wc
# tags + page with most tags
rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
unique_tags = int(rows[0]["total_unique"]) if rows else 0
@ -1029,6 +1030,119 @@ class DBManager:
page_most_tags = None
page_most_tags_count = 0
# 5) Time logging stats (minutes / hours)
time_minutes_by_date: Dict[_dt.date, int] = {}
total_time_minutes = 0
day_most_time: str | None = None
day_most_time_minutes = 0
try:
rows = cur.execute(
"""
SELECT page_date, SUM(minutes) AS total_minutes
FROM time_log
GROUP BY page_date
ORDER BY page_date;
"""
).fetchall()
except Exception:
rows = []
for r in rows:
date_iso = r["page_date"]
if not date_iso:
continue
m = int(r["total_minutes"] or 0)
total_time_minutes += m
if m > day_most_time_minutes:
day_most_time_minutes = m
day_most_time = date_iso
try:
d = _dt.date.fromisoformat(date_iso)
except Exception: # nosec B112
continue
time_minutes_by_date[d] = m
# Project with most logged time
project_most_minutes_name: str | None = None
project_most_minutes = 0
try:
rows = cur.execute(
"""
SELECT p.name AS project_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN projects p ON p.id = t.project_id
GROUP BY t.project_id, p.name
ORDER BY total_minutes DESC, LOWER(project_name) ASC
LIMIT 1;
"""
).fetchall()
except Exception:
rows = []
if rows:
project_most_minutes_name = rows[0]["project_name"]
project_most_minutes = int(rows[0]["total_minutes"] or 0)
# Activity with most logged time
activity_most_minutes_name: str | None = None
activity_most_minutes = 0
try:
rows = cur.execute(
"""
SELECT a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN activities a ON a.id = t.activity_id
GROUP BY t.activity_id, a.name
ORDER BY total_minutes DESC, LOWER(activity_name) ASC
LIMIT 1;
"""
).fetchall()
except Exception:
rows = []
if rows:
activity_most_minutes_name = rows[0]["activity_name"]
activity_most_minutes = int(rows[0]["total_minutes"] or 0)
# 6) Reminder stats
reminders_by_date: Dict[_dt.date, int] = {}
total_reminders = 0
day_most_reminders: str | None = None
day_most_reminders_count = 0
try:
rows = cur.execute(
"""
SELECT substr(created_at, 1, 10) AS date_iso,
COUNT(*) AS c
FROM reminders
GROUP BY date_iso
ORDER BY date_iso;
"""
).fetchall()
except Exception:
rows = []
for r in rows:
date_iso = r["date_iso"]
if not date_iso:
continue
c = int(r["c"] or 0)
total_reminders += c
if c > day_most_reminders_count:
day_most_reminders_count = c
day_most_reminders = date_iso
try:
d = _dt.date.fromisoformat(date_iso)
except Exception: # nosec B112
continue
reminders_by_date[d] = c
return (
pages_with_content,
total_revisions,
@ -1040,6 +1154,18 @@ class DBManager:
page_most_tags,
page_most_tags_count,
revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
)
# -------- Time logging: projects & activities ---------------------

View file

@ -154,6 +154,11 @@
"tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages",
"stats_group_tags": "Tags",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions",
@ -168,6 +173,14 @@
"stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
@ -277,6 +290,9 @@
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging",
"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",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",

View file

@ -58,7 +58,7 @@ from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor
from .pomodoro_timer import PomodoroManager
from .reminders import UpcomingRemindersWidget
from .reminders import UpcomingRemindersWidget, ReminderWebHook
from .save_dialog import SaveDialog
from .search import Search
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.upcoming_reminders = UpcomingRemindersWidget(self.db)
self.upcoming_reminders.reminderTriggered.connect(self._send_reminder_webhook)
self.upcoming_reminders.reminderTriggered.connect(self._show_flashing_reminder)
# 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
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):
"""
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.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
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.invoicing = getattr(new_cfg, "invoicing", self.cfg.invoicing)
self.cfg.locale = getattr(new_cfg, "locale", self.cfg.locale)

View file

@ -32,6 +32,9 @@ from PySide6.QtWidgets import (
from . import strings
from .db import DBManager
from .settings import load_db_config
import requests
class ReminderType(Enum):
@ -332,43 +335,36 @@ class UpcomingRemindersWidget(QFrame):
main.addWidget(self.body)
# Timer to check and fire reminders
# Start by syncing to the next minute boundary
self._check_timer = QTimer(self)
self._check_timer.timeout.connect(self._check_reminders)
#
# We tick once per second, but only hit the DB when the clock is
# 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()
current_second = now.time().second()
current_msec = now.time().msec()
# Milliseconds until next minute
ms_until_next_minute = (60 - current_second) * 1000 - current_msec
# 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)
if now.time().second() == 0:
# 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
# were created.
self._check_reminders(now)
def __del__(self):
"""Cleanup timers when widget is destroyed."""
try:
if hasattr(self, "_check_timer") and self._check_timer:
self._check_timer.stop()
if hasattr(self, "_sync_timer") and self._sync_timer:
self._sync_timer.stop()
except:
if hasattr(self, "_tick_timer") and self._tick_timer:
self._tick_timer.stop()
except Exception:
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):
"""Toggle visibility of reminder list."""
self.body.setVisible(checked)
@ -492,21 +488,28 @@ class UpcomingRemindersWidget(QFrame):
return False
def _check_reminders(self):
"""Check if any reminders should fire now."""
def _check_reminders(self, now: QDateTime | None = None):
"""
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
if not self._db or not hasattr(self._db, "conn") or self._db.conn is None:
return
now = QDateTime.currentDateTime()
today = QDate.currentDate()
# Round current time to the minute (set seconds to 0)
current_minute = QDateTime(
today, QTime(now.time().hour(), now.time().minute(), 0)
)
if now is None:
now = QDateTime.currentDateTime()
today = now.date()
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:
if not reminder.active:
continue
@ -514,28 +517,35 @@ class UpcomingRemindersWidget(QFrame):
if not self._should_fire_on_date(reminder, today):
continue
# Parse time
# Parse time: stored as "HH:MM", we treat that as HH:MM:00
hour, minute = map(int, reminder.time_str.split(":"))
target = QDateTime(today, QTime(hour, minute, 0))
# Fire if we've passed the target minute (within last 2 minutes to catch missed ones)
seconds_diff = current_minute.secsTo(target)
if -120 <= seconds_diff <= 0:
# Check if we haven't already fired this one
# Skip if this reminder is still in the future
if now < target:
continue
# 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"):
self._fired_reminders = {}
reminder_key = (reminder.id, target.toString())
# Only fire once per reminder per target time
if reminder_key not in self._fired_reminders:
self._fired_reminders[reminder_key] = current_minute
self.reminderTriggered.emit(reminder.text)
if reminder_key in self._fired_reminders:
continue
# 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
# Mark as fired and emit
self._fired_reminders[reminder_key] = now
self.reminderTriggered.emit(reminder.text)
# 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()
def _add_reminder(self):
@ -700,6 +710,7 @@ class ManageRemindersDialog(QDialog):
self.table.setHorizontalHeaderLabels(
[
strings._("text"),
strings._("date"),
strings._("time"),
strings._("type"),
strings._("active"),
@ -745,12 +756,24 @@ class ManageRemindersDialog(QDialog):
text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item)
# Date
date_display = ""
if reminder.reminder_type == ReminderType.ONCE and reminder.date_iso:
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if d.isValid():
date_display = d.toString("yyyy-MM-dd")
else:
date_display = reminder.date_iso
date_item = QTableWidgetItem(date_display)
self.table.setItem(row, 1, date_item)
# Time
time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item)
self.table.setItem(row, 2, time_item)
# Type
type_str = {
base_type_strs = {
ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays",
@ -758,35 +781,63 @@ class ManageRemindersDialog(QDialog):
ReminderType.FORTNIGHTLY: "Fortnightly",
ReminderType.MONTHLY_DATE: "Monthly (date)",
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
}.get(reminder.reminder_type, "Unknown")
}
type_str = base_type_strs.get(reminder.reminder_type, "Unknown")
# Add day-of-week annotation where it makes sense
if (
reminder.reminder_type
in (
ReminderType.WEEKLY,
ReminderType.FORTNIGHTLY,
ReminderType.MONTHLY_NTH_WEEKDAY,
)
and reminder.weekday is not None
):
days = [
strings._("monday_short"),
strings._("tuesday_short"),
strings._("wednesday_short"),
strings._("thursday_short"),
strings._("friday_short"),
strings._("saturday_short"),
strings._("sunday_short"),
]
type_str += f" ({days[reminder.weekday]})"
# Short day names we can reuse
days_short = [
strings._("monday_short"),
strings._("tuesday_short"),
strings._("wednesday_short"),
strings._("thursday_short"),
strings._("friday_short"),
strings._("saturday_short"),
strings._("sunday_short"),
]
if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
# Show something like: Monthly (3rd Mon)
day_name = ""
if reminder.weekday is not None and 0 <= reminder.weekday < len(
days_short
):
day_name = days_short[reminder.weekday]
nth_label = ""
if reminder.date_iso:
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
if anchor.isValid():
nth_index = (anchor.day() - 1) // 7 # 0-based (0..4)
ordinals = ["1st", "2nd", "3rd", "4th", "5th"]
if 0 <= nth_index < len(ordinals):
nth_label = ordinals[nth_index]
parts = []
if nth_label:
parts.append(nth_label)
if day_name:
parts.append(day_name)
if parts:
type_str = f"Monthly ({' '.join(parts)})"
# else: fall back to the generic "Monthly (nth weekday)"
else:
# For weekly / fortnightly types, still append the day name
if (
reminder.reminder_type
in (ReminderType.WEEKLY, ReminderType.FORTNIGHTLY)
and reminder.weekday is not None
and 0 <= reminder.weekday < len(days_short)
):
type_str += f" ({days_short[reminder.weekday]})"
type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item)
self.table.setItem(row, 3, type_item)
# Active
active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item)
self.table.setItem(row, 4, active_item)
# Actions
actions_widget = QWidget()
@ -803,7 +854,7 @@ class ManageRemindersDialog(QDialog):
)
actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget)
self.table.setCellWidget(row, 5, actions_widget)
def _add_reminder(self):
"""Add a new reminder."""
@ -834,3 +885,33 @@ class ManageRemindersDialog(QDialog):
if reply == QMessageBox.Yes:
self._db.delete_reminder(reminder.id)
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:
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)
time_log = s.value("ui/time_log", 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)
invoicing = s.value("ui/invoicing", False, type=bool)
locale = s.value("ui/locale", "en", type=str)
@ -58,6 +60,8 @@ def load_db_config() -> DBConfig:
tags=tags,
time_log=time_log,
reminders=reminders,
reminders_webhook_url=reminders_webhook_url,
reminders_webhook_secret=reminders_webhook_secret,
documents=documents,
invoicing=invoicing,
locale=locale,
@ -75,6 +79,8 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/tags", str(cfg.tags))
s.setValue("ui/time_log", str(cfg.time_log))
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/invoicing", str(cfg.invoicing))
s.setValue("ui/locale", str(cfg.locale))

View file

@ -23,6 +23,7 @@ from PySide6.QtWidgets import (
QSpinBox,
QTabWidget,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
@ -44,7 +45,7 @@ class SettingsDialog(QDialog):
self.current_settings = load_db_config()
self.setMinimumWidth(480)
self.setMinimumWidth(600)
self.setSizeGripEnabled(True)
# --- Tabs ----------------------------------------------------------
@ -189,11 +190,66 @@ class SettingsDialog(QDialog):
self.invoicing.setEnabled(False)
self.time_log.toggled.connect(self._on_time_log_toggled)
# --- Reminders feature + webhook options -------------------------
self.reminders = QCheckBox(strings._("enable_reminders_feature"))
self.reminders.setChecked(self.current_settings.reminders)
self.reminders.toggled.connect(self._on_reminders_toggled)
self.reminders.setCursor(Qt.PointingHandCursor)
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.setChecked(self.current_settings.documents)
self.documents.setCursor(Qt.PointingHandCursor)
@ -388,6 +444,9 @@ class SettingsDialog(QDialog):
tags=self.tags.isChecked(),
time_log=self.time_log.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(),
invoicing=(
self.invoicing.isChecked() if self.time_log.isChecked() else False
@ -414,6 +473,30 @@ class SettingsDialog(QDialog):
self.parent().themes.set(selected_theme)
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:
"""
Enforce 'invoicing depends on time logging'.

View file

@ -248,8 +248,9 @@ class StatisticsDialog(QDialog):
self._db = db
self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(600)
self.setMinimumHeight(400)
self.setMinimumWidth(650)
self.setMinimumHeight(650)
root = QVBoxLayout(self)
(
@ -263,12 +264,23 @@ class StatisticsDialog(QDialog):
page_most_tags,
page_most_tags_count,
revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = self._gather_stats()
# Optional: per-date document counts for the heatmap.
# This uses project_documents.uploaded_at aggregated by day, if the
# Documents feature is enabled.
self.cfg = load_db_config()
# Optional: per-date document counts for the heatmap.
documents_by_date: Dict[_dt.date, int] = {}
total_documents = 0
date_most_documents: _dt.date | None = None
@ -280,76 +292,184 @@ class StatisticsDialog(QDialog):
except Exception:
documents_by_date = {}
if documents_by_date:
total_documents = sum(documents_by_date.values())
# Choose the date with the highest count, tie-breaking by earliest date.
date_most_documents, date_most_documents_count = sorted(
documents_by_date.items(),
key=lambda item: (-item[1], item[0]),
)[0]
if documents_by_date:
total_documents = sum(documents_by_date.values())
# Choose the date with the highest count, tie-breaking by earliest date.
date_most_documents, date_most_documents_count = sorted(
documents_by_date.items(),
key=lambda item: (-item[1], item[0]),
)[0]
# for the heatmap
# For the heatmap
self._documents_by_date = documents_by_date
self._time_by_date = time_minutes_by_date
self._reminders_by_date = reminders_by_date
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
# --- Numeric summary at the top ----------------------------------
form = QFormLayout()
root.addLayout(form)
# ------------------------------------------------------------------
# Feature groups
# ------------------------------------------------------------------
form.addRow(
# --- Pages / words / revisions -----------------------------------
pages_group = QGroupBox(strings._("stats_group_pages"))
pages_form = QFormLayout(pages_group)
pages_form.addRow(
strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)),
)
form.addRow(
pages_form.addRow(
strings._("stats_total_revisions"),
QLabel(str(total_revisions)),
)
if page_most_revisions:
form.addRow(
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
)
else:
form.addRow(strings._("stats_page_most_revisions"), QLabel(""))
pages_form.addRow(
strings._("stats_page_most_revisions"),
QLabel(""),
)
form.addRow(
pages_form.addRow(
strings._("stats_total_words"),
QLabel(str(total_words)),
)
# Tags
root.addWidget(pages_group)
# --- Tags ---------------------------------------------------------
if self.cfg.tags:
form.addRow(
tags_group = QGroupBox(strings._("stats_group_tags"))
tags_form = QFormLayout(tags_group)
tags_form.addRow(
strings._("stats_unique_tags"),
QLabel(str(unique_tags)),
)
if page_most_tags:
form.addRow(
tags_form.addRow(
strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"),
)
else:
form.addRow(strings._("stats_page_most_tags"), QLabel(""))
tags_form.addRow(
strings._("stats_page_most_tags"),
QLabel(""),
)
# Documents
if date_most_documents:
form.addRow(
root.addWidget(tags_group)
# --- Documents ----------------------------------------------------
if self.cfg.documents:
docs_group = QGroupBox(strings._("stats_group_documents"))
docs_form = QFormLayout(docs_group)
docs_form.addRow(
strings._("stats_total_documents"),
QLabel(str(total_documents)),
)
doc_most_label = (
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
)
if date_most_documents:
doc_most_label = (
f"{date_most_documents.isoformat()} ({date_most_documents_count})"
)
else:
doc_most_label = ""
form.addRow(
docs_form.addRow(
strings._("stats_date_most_documents"),
QLabel(doc_most_label),
)
# --- Heatmap with switcher ---------------------------------------
if words_by_date or revisions_by_date or documents_by_date:
root.addWidget(docs_group)
# --- Time logging -------------------------------------------------
if self.cfg.time_log:
time_group = QGroupBox(strings._("stats_group_time_logging"))
time_form = QFormLayout(time_group)
total_hours = total_time_minutes / 60.0 if total_time_minutes else 0.0
time_form.addRow(
strings._("stats_time_total_hours"),
QLabel(f"{total_hours:.2f}h"),
)
if day_most_time:
day_hours = (
day_most_time_minutes / 60.0 if day_most_time_minutes else 0.0
)
day_label = f"{day_most_time} ({day_hours:.2f}h)"
else:
day_label = ""
time_form.addRow(
strings._("stats_time_day_most_hours"),
QLabel(day_label),
)
if project_most_minutes_name:
proj_hours = (
project_most_minutes / 60.0 if project_most_minutes else 0.0
)
proj_label = f"{project_most_minutes_name} ({proj_hours:.2f}h)"
else:
proj_label = ""
time_form.addRow(
strings._("stats_time_project_most_hours"),
QLabel(proj_label),
)
if activity_most_minutes_name:
act_hours = (
activity_most_minutes / 60.0 if activity_most_minutes else 0.0
)
act_label = f"{activity_most_minutes_name} ({act_hours:.2f}h)"
else:
act_label = ""
time_form.addRow(
strings._("stats_time_activity_most_hours"),
QLabel(act_label),
)
root.addWidget(time_group)
# --- Reminders ----------------------------------------------------
if self.cfg.reminders:
rem_group = QGroupBox(strings._("stats_group_reminders"))
rem_form = QFormLayout(rem_group)
rem_form.addRow(
strings._("stats_total_reminders"),
QLabel(str(total_reminders)),
)
if day_most_reminders:
rem_label = f"{day_most_reminders} ({day_most_reminders_count})"
else:
rem_label = ""
rem_form.addRow(
strings._("stats_date_most_reminders"),
QLabel(rem_label),
)
root.addWidget(rem_group)
# ------------------------------------------------------------------
# Heatmap with metric switcher
# ------------------------------------------------------------------
if (
words_by_date
or revisions_by_date
or documents_by_date
or time_minutes_by_date
or reminders_by_date
):
group = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group)
@ -358,18 +478,30 @@ class StatisticsDialog(QDialog):
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
self.metric_combo = QComboBox()
self.metric_combo.addItem(strings._("stats_metric_words"), "words")
self.metric_combo.addItem(strings._("stats_metric_revisions"), "revisions")
self.metric_combo.addItem(
strings._("stats_metric_revisions"),
"revisions",
)
if documents_by_date:
self.metric_combo.addItem(
strings._("stats_metric_documents"), "documents"
strings._("stats_metric_documents"),
"documents",
)
if self.cfg.time_log and time_minutes_by_date:
self.metric_combo.addItem(
strings._("stats_metric_hours"),
"hours",
)
if self.cfg.reminders and reminders_by_date:
self.metric_combo.addItem(
strings._("stats_metric_reminders"),
"reminders",
)
combo_row.addWidget(self.metric_combo)
combo_row.addStretch(1)
group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea()
scroll.setWidgetResizable(True)
@ -386,6 +518,8 @@ class StatisticsDialog(QDialog):
else:
root.addWidget(QLabel(strings._("stats_no_data")))
self.resize(self.sizeHint().width(), self.sizeHint().height())
# ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None:
@ -393,6 +527,10 @@ class StatisticsDialog(QDialog):
self._heatmap.set_data(self._revisions_by_date)
elif metric == "documents":
self._heatmap.set_data(self._documents_by_date)
elif metric == "hours":
self._heatmap.set_data(self._time_by_date)
elif metric == "reminders":
self._heatmap.set_data(self._reminders_by_date)
else:
self._heatmap.set_data(self._words_by_date)

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.7.0"
version = "0.7.1"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"

View file

@ -373,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database."""
stats = fresh_db.gather_stats()
assert len(stats) == 10
assert len(stats) == 22
(
pages_with_content,
total_revisions,
@ -385,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db):
page_most_tags,
page_most_tags_count,
revisions_by_date,
time_minutes_by_date,
total_time_minutes,
day_most_time,
day_most_time_minutes,
project_most_minutes_name,
project_most_minutes,
activity_most_minutes_name,
activity_most_minutes,
reminders_by_date,
total_reminders,
day_most_reminders,
day_most_reminders_count,
) = stats
assert pages_with_content == 0
@ -421,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db):
page_most_tags,
page_most_tags_count,
revisions_by_date,
*_rest,
) = stats
assert pages_with_content == 2
@ -437,7 +450,7 @@ def test_db_gather_stats_word_counting(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
assert total_words == 5
@ -463,7 +476,7 @@ def test_db_gather_stats_with_tags(fresh_db):
fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
assert unique_tags == 3
assert page_most_tags == "2024-01-01"
@ -479,7 +492,7 @@ def test_db_gather_stats_revisions_by_date(fresh_db):
fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3
@ -494,7 +507,7 @@ def test_db_gather_stats_handles_malformed_dates(fresh_db):
fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats
_, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
# Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date
@ -507,7 +520,7 @@ def test_db_gather_stats_current_version_only(fresh_db):
fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats
_, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
# Should count words from current version (5 words), not old version
assert total_words == 5
@ -519,7 +532,7 @@ def test_db_gather_stats_no_tags(fresh_db):
fresh_db.save_new_version("2024-01-01", "No tags here", "test")
stats = fresh_db.gather_stats()
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, _ = stats
_, _, _, _, _, _, unique_tags, page_most_tags, page_most_tags_count, *_rest = stats
assert unique_tags == 0
assert page_most_tags is None

View file

@ -414,17 +414,6 @@ def test_upcoming_reminders_widget_check_reminders_no_db(qtbot, app):
widget._check_reminders()
def test_upcoming_reminders_widget_start_regular_timer(qtbot, app, fresh_db):
"""Test starting the regular check timer."""
widget = UpcomingRemindersWidget(fresh_db)
qtbot.addWidget(widget)
widget._start_regular_timer()
# Timer should be running
assert widget._check_timer.isActive()
def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db)
@ -586,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
qtbot.addWidget(dialog)
# Check that the type column shows the day
type_item = dialog.table.item(0, 2)
type_item = dialog.table.item(0, 3)
assert "Wed" in type_item.text()

View file

@ -14,6 +14,7 @@ class FakeStatsDB:
def __init__(self):
d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2)
self.stats = (
2, # pages_with_content
5, # total_revisions
@ -25,7 +26,20 @@ class FakeStatsDB:
"2024-01-02", # page_most_tags
2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date
{d1: 60, d2: 120}, # time_minutes_by_date
180, # total_time_minutes
"2024-01-02", # day_most_time
120, # day_most_time_minutes
"Project A", # project_most_minutes_name
120, # project_most_minutes
"Activity A", # activity_most_minutes_name
120, # activity_most_minutes
{d1: 1, d2: 3}, # reminders_by_date
4, # total_reminders
"2024-01-02", # day_most_reminders
3, # day_most_reminders_count
)
self.called = False
def gather_stats(self):
@ -57,7 +71,7 @@ def test_statistics_dialog_populates_fields_and_heatmap(qtbot):
# Heatmap is created and uses "words" by default
words_by_date = db.stats[4]
revisions_by_date = db.stats[-1]
revisions_by_date = db.stats[9]
assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date
@ -80,13 +94,25 @@ class EmptyStatsDB:
0, # pages_with_content
0, # total_revisions
None, # page_most_revisions
0,
0, # page_most_revisions_count
{}, # words_by_date
0, # total_words
0, # unique_tags
None, # page_most_tags
0,
0, # page_most_tags_count
{}, # revisions_by_date
{}, # time_minutes_by_date
0, # total_time_minutes
None, # day_most_time
0, # day_most_time_minutes
None, # project_most_minutes_name
0, # project_most_minutes
None, # activity_most_minutes_name
0, # activity_most_minutes
{}, # reminders_by_date
0, # total_reminders
None, # day_most_reminders
0, # day_most_reminders_count
)