Compare commits

..

13 commits

Author SHA1 Message Date
886b809bd3
Add pre-commit, fix trailing whitespace
All checks were successful
CI / test (push) Successful in 8m57s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 23s
2025-12-18 13:48:42 +11:00
e6010969cb
Don't block on pyproject modification if the version has already been bumped
All checks were successful
CI / test (push) Successful in 8m44s
Lint / test (push) Successful in 38s
Trivy / test (push) Successful in 19s
2025-12-16 15:28:24 +11:00
492633df9f
Update urllib3
All checks were successful
CI / test (push) Successful in 8m52s
Lint / test (push) Successful in 39s
Trivy / test (push) Successful in 20s
2025-12-16 15:17:22 +11:00
dcb62d34af
Allow carrying unchecked TODOs to weekends. Add 'group by activity' in time log reports
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-16 15:15:38 +11:00
13b1ad7373
Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
All checks were successful
CI / test (push) Successful in 8m56s
Lint / test (push) Successful in 40s
Trivy / test (push) Successful in 19s
2025-12-13 10:48:10 +11:00
7abd99fe24
Bump to 0.7.2 2025-12-13 10:45:10 +11:00
2112de39b8
Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) 2025-12-13 10:39:49 +11:00
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
d809244cf8
Invoicing should not be enabled by default
All checks were successful
CI / test (push) Successful in 8m52s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 21s
2025-12-12 14:36:27 +11:00
28446340f8
Moving unchecked TODO items to the next weekday now brings across the header that was above it, if present
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled
2025-12-12 14:32:23 +11:00
c1c95ca0ca
Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
All checks were successful
CI / test (push) Successful in 9m34s
Lint / test (push) Successful in 41s
Trivy / test (push) Successful in 23s
2025-12-12 14:10:43 +11:00
7a75d33bb0
0.7.0
All checks were successful
CI / test (push) Successful in 8m43s
Lint / test (push) Successful in 37s
Trivy / test (push) Successful in 21s
2025-12-11 16:17:22 +11:00
21 changed files with 1170 additions and 321 deletions

26
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,26 @@
repos:
- repo: https://github.com/pycqa/flake8
rev: 7.3.0
hooks:
- id: flake8
args: ["--select=F"]
types: [python]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.11.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- repo: https://github.com/PyCQA/bandit
rev: 1.9.2
hooks:
- id: bandit
files: ^bouquin/
args: ["-s", "B110"]

View file

@ -1,3 +1,21 @@
# 0.7.3
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
# 0.7.2
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
# 0.7.1
* 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 # 0.7.0
* New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features. * New Invoicing feature! This is tied to time logging and (optionally) documents and reminders features.

View file

@ -92,9 +92,12 @@ class DBConfig:
idle_minutes: int = 15 # 0 = never lock idle_minutes: int = 15 # 0 = never lock
theme: str = "system" theme: str = "system"
move_todos: bool = False move_todos: bool = False
move_todos_include_weekends: bool = False
tags: bool = True tags: bool = True
time_log: bool = True time_log: bool = True
reminders: bool = True reminders: bool = True
reminders_webhook_url: str = (None,)
reminders_webhook_secret: str = (None,)
documents: bool = True documents: bool = True
invoicing: bool = False invoicing: bool = False
locale: str = "en" locale: str = "en"
@ -971,7 +974,7 @@ class DBManager:
# 2 & 3) total revisions + page with most revisions + per-date counts # 2 & 3) total revisions + page with most revisions + per-date counts
total_revisions = 0 total_revisions = 0
page_most_revisions = None page_most_revisions: str | None = None
page_most_revisions_count = 0 page_most_revisions_count = 0
revisions_by_date: Dict[_dt.date, int] = {} revisions_by_date: Dict[_dt.date, int] = {}
@ -1008,7 +1011,6 @@ class DBManager:
words_by_date[d] = wc words_by_date[d] = wc
# tags + page with most tags # tags + page with most tags
rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall() rows = cur.execute("SELECT COUNT(*) AS total_unique FROM tags;").fetchall()
unique_tags = int(rows[0]["total_unique"]) if rows else 0 unique_tags = int(rows[0]["total_unique"]) if rows else 0
@ -1029,6 +1031,119 @@ class DBManager:
page_most_tags = None page_most_tags = None
page_most_tags_count = 0 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 ( return (
pages_with_content, pages_with_content,
total_revisions, total_revisions,
@ -1040,6 +1155,18 @@ class DBManager:
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, 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 --------------------- # -------- Time logging: projects & activities ---------------------
@ -1225,7 +1352,7 @@ class DBManager:
project_id: int, project_id: int,
start_date_iso: str, start_date_iso: str,
end_date_iso: str, end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'none' granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
) -> list[tuple[str, str, str, int]]: ) -> list[tuple[str, str, str, int]]:
""" """
Return (time_period, activity_name, total_minutes) tuples between start and end Return (time_period, activity_name, total_minutes) tuples between start and end
@ -1234,7 +1361,8 @@ class DBManager:
- 'YYYY-MM-DD' for day - 'YYYY-MM-DD' for day
- 'YYYY-WW' for week - 'YYYY-WW' for week
- 'YYYY-MM' for month - 'YYYY-MM' for month
For 'none' granularity, each individual time log entry becomes a row. For 'activity' granularity, results are grouped by activity only (no time bucket).
For 'none' granularity, each individual time log entry becomes a row.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
@ -1261,6 +1389,26 @@ class DBManager:
for r in rows for r in rows
] ]
if granularity == "activity":
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
WHERE t.project_id = ?
AND t.page_date BETWEEN ? AND ?
GROUP BY activity_name
ORDER BY LOWER(activity_name);
""",
(project_id, start_date_iso, end_date_iso),
).fetchall()
# period column is unused for activity grouping in the UI, but we keep
# the tuple shape consistent.
return [("", r["activity_name"], "", r["total_minutes"]) for r in rows]
if granularity == "day": if granularity == "day":
bucket_expr = "page_date" bucket_expr = "page_date"
elif granularity == "week": elif granularity == "week":
@ -1291,11 +1439,14 @@ class DBManager:
self, self,
start_date_iso: str, start_date_iso: str,
end_date_iso: str, end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'none' granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
) -> list[tuple[str, str, str, str, int]]: ) -> list[tuple[str, str, str, str, int]]:
""" """
Return (project_name, time_period, activity_name, note, total_minutes) Return (project_name, time_period, activity_name, note, total_minutes)
across *all* projects between start and end, grouped by project + period + activity. across *all* projects between start and end.
- For 'day'/'week'/'month', grouped by project + period + activity.
- For 'activity', grouped by project + activity.
- For 'none', one row per time_log entry.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
@ -1329,6 +1480,34 @@ class DBManager:
for r in rows for r in rows
] ]
if granularity == "activity":
rows = cur.execute(
"""
SELECT
p.name AS project_name,
a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN projects p ON p.id = t.project_id
JOIN activities a ON a.id = t.activity_id
WHERE t.page_date BETWEEN ? AND ?
GROUP BY p.id, activity_name
ORDER BY LOWER(p.name), LOWER(activity_name);
""",
(start_date_iso, end_date_iso),
).fetchall()
return [
(
r["project_name"],
"",
r["activity_name"],
"",
r["total_minutes"],
)
for r in rows
]
if granularity == "day": if granularity == "day":
bucket_expr = "page_date" bucket_expr = "page_date"
elif granularity == "week": elif granularity == "week":

View file

@ -103,6 +103,7 @@
"autosave": "autosave", "autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images", "insert_images": "Insert images",
"images": "Images", "images": "Images",
"reopen_failed": "Re-open failed", "reopen_failed": "Re-open failed",
@ -154,6 +155,11 @@
"tag_already_exists_with_that_name": "A tag already exists with that name", "tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics", "statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics", "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_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions", "stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions", "stats_page_most_revisions": "Page with most revisions",
@ -168,6 +174,14 @@
"stats_total_documents": "Total documents", "stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents", "stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.", "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", "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_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", "bug_report_placeholder": "Type your bug report here",
@ -196,6 +210,7 @@
"add_time_entry": "Add time entry", "add_time_entry": "Add time entry",
"time_period": "Time period", "time_period": "Time period",
"dont_group": "Don't group", "dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day", "by_day": "by day",
"by_month": "by month", "by_month": "by month",
"by_week": "by week", "by_week": "by week",
@ -277,6 +292,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
@ -821,9 +822,13 @@ class MainWindow(QMainWindow):
Given a 'new day' (system date), return the date we should move Given a 'new day' (system date), return the date we should move
unfinished todos *to*. unfinished todos *to*.
If the new day is Saturday or Sunday, we skip ahead to the next Monday. By default, if the new day is Saturday or Sunday we skip ahead to the
Otherwise we just return the same day. next Monday (i.e., "next available weekday"). If the optional setting
`move_todos_include_weekends` is enabled, we move to the very next day
even if it's a weekend.
""" """
if getattr(self.cfg, "move_todos_include_weekends", False):
return day
# Qt: Monday=1 ... Sunday=7 # Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek() dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7) if dow >= 6: # Saturday (6) or Sunday (7)
@ -878,7 +883,74 @@ class MainWindow(QMainWindow):
target_date = self._rollover_target_date(today) target_date = self._rollover_target_date(today)
target_iso = target_date.toString("yyyy-MM-dd") target_iso = target_date.toString("yyyy-MM-dd")
all_unchecked: list[str] = [] # Regexes for markdown headings and checkboxes
heading_re = re.compile(r"^\s{0,3}(#+)\s+(.*)$")
unchecked_re = re.compile(r"^\s*-\s*\[[\s☐]\]\s+")
def _normalize_heading(text: str) -> str:
"""
Strip trailing closing hashes and whitespace, e.g.
"## Foo ###" -> "Foo"
"""
text = text.strip()
text = re.sub(r"\s+#+\s*$", "", text)
return text.strip()
def _insert_todos_under_heading(
target_lines: list[str],
heading_level: int,
heading_text: str,
todos: list[str],
) -> list[str]:
"""Ensure a heading exists and append todos to the end of its section."""
normalized = _normalize_heading(heading_text)
# 1) Find existing heading with same text (any level)
start_idx = None
effective_level = None
for idx, line in enumerate(target_lines):
m = heading_re.match(line)
if not m:
continue
level = len(m.group(1))
text = _normalize_heading(m.group(2))
if text == normalized:
start_idx = idx
effective_level = level
break
# 2) If not found, create a new heading at the end
if start_idx is None:
if target_lines and target_lines[-1].strip():
target_lines.append("") # blank line before new heading
target_lines.append(f"{'#' * heading_level} {heading_text}")
start_idx = len(target_lines) - 1
effective_level = heading_level
# 3) Find the end of this heading's section
end_idx = len(target_lines)
for i in range(start_idx + 1, len(target_lines)):
m = heading_re.match(target_lines[i])
if m and len(m.group(1)) <= effective_level:
end_idx = i
break
# 4) Insert before any trailing blank lines in the section
insert_at = end_idx
while (
insert_at > start_idx + 1 and target_lines[insert_at - 1].strip() == ""
):
insert_at -= 1
for todo in todos:
target_lines.insert(insert_at, todo)
insert_at += 1
return target_lines
# Collect moved todos as (heading_info, item_text)
# heading_info is either None or (level, heading_text)
moved_items: list[tuple[tuple[int, str] | None, str]] = []
any_moved = False any_moved = False
# Look back N days (yesterday = 1, up to `days_back`) # Look back N days (yesterday = 1, up to `days_back`)
@ -892,14 +964,24 @@ class MainWindow(QMainWindow):
lines = text.split("\n") lines = text.split("\n")
remaining_lines: list[str] = [] remaining_lines: list[str] = []
moved_from_this_day = False moved_from_this_day = False
current_heading: tuple[int, str] | None = None
for line in lines: for line in lines:
# Track the last seen heading (# / ## / ###)
m_head = heading_re.match(line)
if m_head:
level = len(m_head.group(1))
heading_text = _normalize_heading(m_head.group(2))
if level <= 3:
current_heading = (level, heading_text)
# Keep headings in the original day
remaining_lines.append(line)
continue
# Unchecked markdown checkboxes: "- [ ] " or "- [☐] " # Unchecked markdown checkboxes: "- [ ] " or "- [☐] "
if re.match(r"^\s*-\s*\[\s*\]\s+", line) or re.match( if unchecked_re.match(line):
r"^\s*-\s*\[☐\]\s+", line item_text = unchecked_re.sub("", line)
): moved_items.append((current_heading, item_text))
item_text = re.sub(r"^\s*-\s*\[[\s☐]\]\s+", "", line)
all_unchecked.append(f"- [ ] {item_text}")
moved_from_this_day = True moved_from_this_day = True
any_moved = True any_moved = True
else: else:
@ -917,9 +999,45 @@ class MainWindow(QMainWindow):
if not any_moved: if not any_moved:
return False return False
# Append everything we collected to the *target* date # --- Merge all moved items into the *target* date ---
unchecked_str = "\n".join(all_unchecked) + "\n"
self._load_selected_date(target_iso, unchecked_str) target_text = self.db.get_entry(target_iso) or ""
target_lines = target_text.split("\n") if target_text else []
by_heading: dict[tuple[int, str], list[str]] = {}
plain_items: list[str] = []
for heading_info, item_text in moved_items:
todo_line = f"- [ ] {item_text}"
if heading_info is None:
# No heading above this checkbox in the source: behave as before
plain_items.append(todo_line)
else:
by_heading.setdefault(heading_info, []).append(todo_line)
# First insert all items that have headings
for (level, heading_text), todos in by_heading.items():
target_lines = _insert_todos_under_heading(
target_lines, level, heading_text, todos
)
# Then append all items without headings at the end, like before
if plain_items:
if target_lines and target_lines[-1].strip():
target_lines.append("") # one blank line before the "unsectioned" todos
target_lines.extend(plain_items)
new_target_text = "\n".join(target_lines)
if not new_target_text.endswith("\n"):
new_target_text += "\n"
# Save the updated target date and load it into the editor
self.db.save_new_version(
target_iso,
new_target_text,
strings._("unchecked_checkbox_items_moved_to_next_day"),
)
self._load_selected_date(target_iso)
return True return True
def _on_date_changed(self): def _on_date_changed(self):
@ -1222,6 +1340,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.
@ -1447,9 +1570,20 @@ class MainWindow(QMainWindow):
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes) self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme) self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos) self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
self.cfg.move_todos_include_weekends = getattr(
new_cfg,
"move_todos_include_weekends",
getattr(self.cfg, "move_todos_include_weekends", False),
)
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

@ -1317,15 +1317,43 @@ class MarkdownEditor(QTextEdit):
if icon: if icon:
# absolute document position of the icon # absolute document position of the icon
doc_pos = block.position() + i doc_pos = block.position() + i
r = char_rect_at(doc_pos, icon) r_icon = char_rect_at(doc_pos, icon)
# ---------- Relax the hit area here ---------- # --- Find where the first non-space "real text" starts ---
# Expand the clickable area horizontally so you don't have to first_idx = i + len(icon) + 1 # skip icon + trailing space
# land exactly on the glyph. This makes the "checkbox zone" while first_idx < len(text) and text[first_idx].isspace():
# roughly 3× the glyph width, centered on it. first_idx += 1
pad = r.width() # one glyph width on each side
hit_rect = r.adjusted(-pad, 0, pad, 0) # Start with some padding around the icon itself
# --------------------------------------------- left_pad = r_icon.width() // 2
right_pad = r_icon.width() // 2
hit_left = r_icon.left() - left_pad
# If there's actual text after the checkbox, clamp the
# clickable area so it stops *before* the first letter.
if first_idx < len(text):
first_doc_pos = block.position() + first_idx
c_first = QTextCursor(self.document())
c_first.setPosition(first_doc_pos)
first_x = self.cursorRect(c_first).x()
expanded_right = r_icon.right() + right_pad
hit_right = min(expanded_right, first_x)
else:
# No text after the checkbox on this line
hit_right = r_icon.right() + right_pad
# Make sure the rect is at least 1px wide
if hit_right <= hit_left:
hit_right = r_icon.right()
hit_rect = QRect(
hit_left,
r_icon.top(),
max(1, hit_right - hit_left),
r_icon.height(),
)
if hit_rect.contains(pt): if hit_rect.contains(pt):
# Build the replacement: swap ☐ <-> ☑ (keep trailing space) # Build the replacement: swap ☐ <-> ☑ (keep trailing space)
@ -1339,7 +1367,9 @@ class MarkdownEditor(QTextEdit):
edit.setPosition(doc_pos) edit.setPosition(doc_pos)
# icon + space # icon + space
edit.movePosition( edit.movePosition(
QTextCursor.Right, QTextCursor.KeepAnchor, len(icon) + 1 QTextCursor.Right,
QTextCursor.KeepAnchor,
len(icon) + 1,
) )
edit.insertText(f"{new_icon} ") edit.insertText(f"{new_icon} ")
edit.endEditBlock() edit.endEditBlock()

View file

@ -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):
@ -696,10 +706,11 @@ class ManageRemindersDialog(QDialog):
# Reminder list table # Reminder list table
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(5) self.table.setColumnCount(6)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("text"), strings._("text"),
strings._("date"),
strings._("time"), strings._("time"),
strings._("type"), strings._("type"),
strings._("active"), strings._("active"),
@ -745,12 +756,24 @@ class ManageRemindersDialog(QDialog):
text_item.setData(Qt.UserRole, reminder) text_item.setData(Qt.UserRole, reminder)
self.table.setItem(row, 0, text_item) 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
time_item = QTableWidgetItem(reminder.time_str) time_item = QTableWidgetItem(reminder.time_str)
self.table.setItem(row, 1, time_item) self.table.setItem(row, 2, time_item)
# Type # Type
type_str = { base_type_strs = {
ReminderType.ONCE: "Once", ReminderType.ONCE: "Once",
ReminderType.DAILY: "Daily", ReminderType.DAILY: "Daily",
ReminderType.WEEKDAYS: "Weekdays", ReminderType.WEEKDAYS: "Weekdays",
@ -758,35 +781,63 @@ class ManageRemindersDialog(QDialog):
ReminderType.FORTNIGHTLY: "Fortnightly", ReminderType.FORTNIGHTLY: "Fortnightly",
ReminderType.MONTHLY_DATE: "Monthly (date)", ReminderType.MONTHLY_DATE: "Monthly (date)",
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)", 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 # Short day names we can reuse
if ( days_short = [
reminder.reminder_type strings._("monday_short"),
in ( strings._("tuesday_short"),
ReminderType.WEEKLY, strings._("wednesday_short"),
ReminderType.FORTNIGHTLY, strings._("thursday_short"),
ReminderType.MONTHLY_NTH_WEEKDAY, strings._("friday_short"),
) strings._("saturday_short"),
and reminder.weekday is not None strings._("sunday_short"),
): ]
days = [
strings._("monday_short"), if reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
strings._("tuesday_short"), # Show something like: Monthly (3rd Mon)
strings._("wednesday_short"), day_name = ""
strings._("thursday_short"), if reminder.weekday is not None and 0 <= reminder.weekday < len(
strings._("friday_short"), days_short
strings._("saturday_short"), ):
strings._("sunday_short"), day_name = days_short[reminder.weekday]
]
type_str += f" ({days[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) type_item = QTableWidgetItem(type_str)
self.table.setItem(row, 2, type_item) self.table.setItem(row, 3, type_item)
# Active # Active
active_item = QTableWidgetItem("" if reminder.active else "") active_item = QTableWidgetItem("" if reminder.active else "")
self.table.setItem(row, 3, active_item) self.table.setItem(row, 4, active_item)
# Actions # Actions
actions_widget = QWidget() actions_widget = QWidget()
@ -803,7 +854,7 @@ class ManageRemindersDialog(QDialog):
) )
actions_layout.addWidget(delete_btn) actions_layout.addWidget(delete_btn)
self.table.setCellWidget(row, 4, actions_widget) self.table.setCellWidget(row, 5, actions_widget)
def _add_reminder(self): def _add_reminder(self):
"""Add a new reminder.""" """Add a new reminder."""
@ -834,3 +885,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:
requests.post(
url,
json=payload,
timeout=10,
headers=_headers,
)
except Exception:
# We did our best
pass

View file

@ -42,11 +42,16 @@ def load_db_config() -> DBConfig:
idle = s.value("ui/idle_minutes", 15, type=int) idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str) theme = s.value("ui/theme", "system", type=str)
move_todos = s.value("ui/move_todos", False, type=bool) move_todos = s.value("ui/move_todos", False, type=bool)
move_todos_include_weekends = s.value(
"ui/move_todos_include_weekends", False, type=bool
)
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", True, 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)
font_size = s.value("ui/font_size", 11, type=int) font_size = s.value("ui/font_size", 11, type=int)
return DBConfig( return DBConfig(
@ -55,9 +60,12 @@ def load_db_config() -> DBConfig:
idle_minutes=idle, idle_minutes=idle,
theme=theme, theme=theme,
move_todos=move_todos, move_todos=move_todos,
move_todos_include_weekends=move_todos_include_weekends,
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,
@ -72,9 +80,12 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/idle_minutes", str(cfg.idle_minutes)) s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme)) s.setValue("ui/theme", str(cfg.theme))
s.setValue("ui/move_todos", str(cfg.move_todos)) s.setValue("ui/move_todos", str(cfg.move_todos))
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
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 ----------------------------------------------------------
@ -168,6 +169,25 @@ class SettingsDialog(QDialog):
self.move_todos.setCursor(Qt.PointingHandCursor) self.move_todos.setCursor(Qt.PointingHandCursor)
features_layout.addWidget(self.move_todos) features_layout.addWidget(self.move_todos)
# Optional: allow moving to the very next day even if it is a weekend.
self.move_todos_include_weekends = QCheckBox(
strings._("move_todos_include_weekends")
)
self.move_todos_include_weekends.setChecked(
getattr(self.current_settings, "move_todos_include_weekends", False)
)
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
move_todos_opts = QWidget()
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
move_todos_opts_layout.setSpacing(4)
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
features_layout.addWidget(move_todos_opts)
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
self.tags = QCheckBox(strings._("enable_tags_feature")) self.tags = QCheckBox(strings._("enable_tags_feature"))
self.tags.setChecked(self.current_settings.tags) self.tags.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor) self.tags.setCursor(Qt.PointingHandCursor)
@ -189,11 +209,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)
@ -385,9 +460,13 @@ class SettingsDialog(QDialog):
idle_minutes=self.idle_spin.value(), idle_minutes=self.idle_spin.value(),
theme=selected_theme.value, theme=selected_theme.value,
move_todos=self.move_todos.isChecked(), move_todos=self.move_todos.isChecked(),
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
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 +493,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'.

View file

@ -248,8 +248,9 @@ class StatisticsDialog(QDialog):
self._db = db self._db = db
self.setWindowTitle(strings._("statistics")) self.setWindowTitle(strings._("statistics"))
self.setMinimumWidth(600) self.setMinimumWidth(650)
self.setMinimumHeight(400) self.setMinimumHeight(650)
root = QVBoxLayout(self) root = QVBoxLayout(self)
( (
@ -263,12 +264,23 @@ class StatisticsDialog(QDialog):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, 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() ) = 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() self.cfg = load_db_config()
# Optional: per-date document counts for the heatmap.
documents_by_date: Dict[_dt.date, int] = {} documents_by_date: Dict[_dt.date, int] = {}
total_documents = 0 total_documents = 0
date_most_documents: _dt.date | None = None date_most_documents: _dt.date | None = None
@ -280,76 +292,184 @@ class StatisticsDialog(QDialog):
except Exception: except Exception:
documents_by_date = {} documents_by_date = {}
if documents_by_date: if documents_by_date:
total_documents = sum(documents_by_date.values()) total_documents = sum(documents_by_date.values())
# Choose the date with the highest count, tie-breaking by earliest date. # Choose the date with the highest count, tie-breaking by earliest date.
date_most_documents, date_most_documents_count = sorted( date_most_documents, date_most_documents_count = sorted(
documents_by_date.items(), documents_by_date.items(),
key=lambda item: (-item[1], item[0]), key=lambda item: (-item[1], item[0]),
)[0] )[0]
# for the heatmap # For the heatmap
self._documents_by_date = documents_by_date 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() # Feature groups
root.addLayout(form) # ------------------------------------------------------------------
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"), strings._("stats_pages_with_content"),
QLabel(str(pages_with_content)), QLabel(str(pages_with_content)),
) )
form.addRow( pages_form.addRow(
strings._("stats_total_revisions"), strings._("stats_total_revisions"),
QLabel(str(total_revisions)), QLabel(str(total_revisions)),
) )
if page_most_revisions: if page_most_revisions:
form.addRow( pages_form.addRow(
strings._("stats_page_most_revisions"), strings._("stats_page_most_revisions"),
QLabel(f"{page_most_revisions} ({page_most_revisions_count})"), QLabel(f"{page_most_revisions} ({page_most_revisions_count})"),
) )
else: 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"), strings._("stats_total_words"),
QLabel(str(total_words)), QLabel(str(total_words)),
) )
# Tags root.addWidget(pages_group)
# --- Tags ---------------------------------------------------------
if self.cfg.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"), strings._("stats_unique_tags"),
QLabel(str(unique_tags)), QLabel(str(unique_tags)),
) )
if page_most_tags: if page_most_tags:
form.addRow( tags_form.addRow(
strings._("stats_page_most_tags"), strings._("stats_page_most_tags"),
QLabel(f"{page_most_tags} ({page_most_tags_count})"), QLabel(f"{page_most_tags} ({page_most_tags_count})"),
) )
else: else:
form.addRow(strings._("stats_page_most_tags"), QLabel("")) tags_form.addRow(
strings._("stats_page_most_tags"),
QLabel(""),
)
# Documents root.addWidget(tags_group)
if date_most_documents:
form.addRow( # --- Documents ----------------------------------------------------
if self.cfg.documents:
docs_group = QGroupBox(strings._("stats_group_documents"))
docs_form = QFormLayout(docs_group)
docs_form.addRow(
strings._("stats_total_documents"), strings._("stats_total_documents"),
QLabel(str(total_documents)), QLabel(str(total_documents)),
) )
doc_most_label = ( if date_most_documents:
f"{date_most_documents.isoformat()} ({date_most_documents_count})" 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"), strings._("stats_date_most_documents"),
QLabel(doc_most_label), QLabel(doc_most_label),
) )
# --- Heatmap with switcher --------------------------------------- root.addWidget(docs_group)
if words_by_date or revisions_by_date or documents_by_date:
# --- 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 = QGroupBox(strings._("stats_activity_heatmap"))
group_layout = QVBoxLayout(group) group_layout = QVBoxLayout(group)
@ -358,18 +478,30 @@ class StatisticsDialog(QDialog):
combo_row.addWidget(QLabel(strings._("stats_heatmap_metric"))) combo_row.addWidget(QLabel(strings._("stats_heatmap_metric")))
self.metric_combo = QComboBox() self.metric_combo = QComboBox()
self.metric_combo.addItem(strings._("stats_metric_words"), "words") 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: if documents_by_date:
self.metric_combo.addItem( 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.addWidget(self.metric_combo)
combo_row.addStretch(1) combo_row.addStretch(1)
group_layout.addLayout(combo_row) group_layout.addLayout(combo_row)
self._heatmap = DateHeatmap() self._heatmap = DateHeatmap()
self._words_by_date = words_by_date
self._revisions_by_date = revisions_by_date
scroll = QScrollArea() scroll = QScrollArea()
scroll.setWidgetResizable(True) scroll.setWidgetResizable(True)
@ -386,6 +518,8 @@ class StatisticsDialog(QDialog):
else: else:
root.addWidget(QLabel(strings._("stats_no_data"))) root.addWidget(QLabel(strings._("stats_no_data")))
self.resize(self.sizeHint().width(), self.sizeHint().height())
# ---------- internal helpers ---------- # ---------- internal helpers ----------
def _apply_metric(self, metric: str) -> None: def _apply_metric(self, metric: str) -> None:
@ -393,6 +527,10 @@ class StatisticsDialog(QDialog):
self._heatmap.set_data(self._revisions_by_date) self._heatmap.set_data(self._revisions_by_date)
elif metric == "documents": elif metric == "documents":
self._heatmap.set_data(self._documents_by_date) 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: else:
self._heatmap.set_data(self._words_by_date) self._heatmap.set_data(self._words_by_date)

View file

@ -1083,6 +1083,7 @@ class TimeReportDialog(QDialog):
self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_day"), "day")
self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month") self.granularity.addItem(strings._("by_month"), "month")
self.granularity.addItem(strings._("by_activity"), "activity")
form.addRow(strings._("group_by"), self.granularity) form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form) root.addLayout(form)
@ -1161,6 +1162,20 @@ class TimeReportDialog(QDialog):
header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
elif granularity == "activity":
# Grouped by activity only: no time period, no note column
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("hours"),
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
else: else:
# Grouped: no note column # Grouped: no note column
self.table.setColumnCount(4) self.table.setColumnCount(4)
@ -1272,16 +1287,21 @@ class TimeReportDialog(QDialog):
rows_for_table rows_for_table
): ):
hrs = minutes / 60.0 hrs = minutes / 60.0
self.table.setItem(i, 0, QTableWidgetItem(project)) if self._last_gran == "activity":
self.table.setItem(i, 1, QTableWidgetItem(time_period)) self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 2, QTableWidgetItem(activity_name)) self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
if self._last_gran == "none":
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
else: else:
# no note column self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
if self._last_gran == "none":
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
else:
# no note column
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
# Summary label - include per-project totals when in "all projects" mode # Summary label - include per-project totals when in "all projects" mode
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
@ -1325,14 +1345,15 @@ class TimeReportDialog(QDialog):
with open(filename, "w", newline="", encoding="utf-8") as f: with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f) writer = csv.writer(f)
show_note = getattr(self, "_last_gran", "day") == "none" gran = getattr(self, "_last_gran", "day")
show_note = gran == "none"
show_period = gran != "activity"
# Header # Header
header = [ header: list[str] = [strings._("project")]
strings._("project"), if show_period:
strings._("time_period"), header.append(strings._("time_period"))
strings._("activity"), header.append(strings._("activity"))
]
if show_note: if show_note:
header.append(strings._("note")) header.append(strings._("note"))
header.append(strings._("hours")) header.append(strings._("hours"))
@ -1347,16 +1368,22 @@ class TimeReportDialog(QDialog):
minutes, minutes,
) in self._last_rows: ) in self._last_rows:
hours = minutes / 60.0 hours = minutes / 60.0
row = [project, time_period, activity_name] row: list[str] = [project]
if show_period:
row.append(time_period)
row.append(activity_name)
if show_note: if show_note:
row.append(note) row.append(note or "")
row.append(f"{hours:.2f}") row.append(f"{hours:.2f}")
writer.writerow(row) writer.writerow(row)
# Blank line + total # Blank line + total
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
writer.writerow([]) writer.writerow([])
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"]) total_row = [""] * len(header)
total_row[0] = strings._("total")
total_row[-1] = f"{total_hours:.2f}"
writer.writerow(total_row)
except OSError as exc: except OSError as exc:
QMessageBox.warning( QMessageBox.warning(
self, self,
@ -1384,17 +1411,20 @@ class TimeReportDialog(QDialog):
if not filename.endswith(".pdf"): if not filename.endswith(".pdf"):
filename = f"{filename}.pdf" filename = f"{filename}.pdf"
# ---------- Build chart image (hours per period) ---------- # ---------- Build chart image ----------
per_period_minutes: dict[str, int] = defaultdict(int) # Default: hours per time period. If grouped by activity: hours per activity.
for _project, period, _activity, note, minutes in self._last_rows: gran = getattr(self, "_last_gran", "day")
per_period_minutes[period] += minutes per_bucket_minutes: dict[str, int] = defaultdict(int)
for _project, period, activity, _note, minutes in self._last_rows:
bucket = activity if gran == "activity" else period
per_bucket_minutes[bucket] += minutes
periods = sorted(per_period_minutes.keys()) buckets = sorted(per_bucket_minutes.keys())
chart_w, chart_h = 800, 220 chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32) chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white) chart.fill(Qt.white)
if periods: if buckets:
painter = QPainter(chart) painter = QPainter(chart)
try: try:
painter.setRenderHint(QPainter.Antialiasing, True) painter.setRenderHint(QPainter.Antialiasing, True)
@ -1422,9 +1452,9 @@ class TimeReportDialog(QDialog):
# Border # Border
painter.drawRect(left, top, width, height) painter.drawRect(left, top, width, height)
max_hours = max(per_period_minutes[p] for p in periods) / 60.0 max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
if max_hours > 0: if max_hours > 0:
n = len(periods) n = len(buckets)
bar_spacing = width / max(1, n) bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6 bar_width = bar_spacing * 0.6
@ -1449,8 +1479,8 @@ class TimeReportDialog(QDialog):
painter.setBrush(QColor(80, 140, 200)) painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen) painter.setPen(Qt.NoPen)
for i, period in enumerate(periods): for i, label in enumerate(buckets):
hours = per_period_minutes[period] / 60.0 hours = per_bucket_minutes[label] / 60.0
bar_h = int((hours / max_hours) * (height - 10)) bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0: if bar_h <= 0:
continue # pragma: no cover continue # pragma: no cover
@ -1463,7 +1493,7 @@ class TimeReportDialog(QDialog):
# X labels after bars, in black # X labels after bars, in black
painter.setPen(Qt.black) painter.setPen(Qt.black)
for i, period in enumerate(periods): for i, label in enumerate(buckets):
x_center = left + bar_spacing * (i + 0.5) x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2) x = int(x_center - bar_width / 2)
painter.drawText( painter.drawText(
@ -1472,7 +1502,7 @@ class TimeReportDialog(QDialog):
int(bar_width), int(bar_width),
20, 20,
Qt.AlignHCenter | Qt.AlignTop, Qt.AlignHCenter | Qt.AlignTop,
period, label,
) )
finally: finally:
painter.end() painter.end()
@ -1481,23 +1511,53 @@ class TimeReportDialog(QDialog):
project = html.escape(self._last_project_name or "") project = html.escape(self._last_project_name or "")
start = html.escape(self._last_start or "") start = html.escape(self._last_start or "")
end = html.escape(self._last_end or "") end = html.escape(self._last_end or "")
gran = html.escape(self._last_gran_label or "") gran_key = getattr(self, "_last_gran", "day")
gran_label = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
# Table rows (period, activity, hours) # Table rows
row_html_parts: list[str] = [] row_html_parts: list[str] = []
for project, period, activity, note, minutes in self._last_rows: if gran_key == "activity":
hours = minutes / 60.0 for project, _period, activity, _note, minutes in self._last_rows:
row_html_parts.append( hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
else:
for project, period, activity, _note, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
rows_html = "\n".join(row_html_parts)
if gran_key == "activity":
table_header_html = (
"<tr>" "<tr>"
f"<td>{html.escape(project)}</td>" f"<th>{html.escape(strings._('project'))}</th>"
f"<td>{html.escape(period)}</td>" f"<th>{html.escape(strings._('activity'))}</th>"
f"<td>{html.escape(activity)}</td>" f"<th>{html.escape(strings._('hours'))}</th>"
f"<td style='text-align:right'>{hours:.2f}</td>" "</tr>"
)
else:
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('time_period'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</tr>" "</tr>"
) )
rows_html = "\n".join(row_html_parts)
html_doc = f""" html_doc = f"""
<!DOCTYPE html> <!DOCTYPE html>
@ -1544,16 +1604,11 @@ class TimeReportDialog(QDialog):
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1> <h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
<p class="meta"> <p class="meta">
{html.escape(strings._("time_log_report_meta").format( {html.escape(strings._("time_log_report_meta").format(
start=start, end=end, granularity=gran))} start=start, end=end, granularity=gran_label))}
</p> </p>
<p><img src="chart" class="chart" /></p> <p><img src="chart" class="chart" /></p>
<table> <table>
<tr> {table_header_html}
<th>{html.escape(strings._("project"))}</th>
<th>{html.escape(strings._("time_period"))}</th>
<th>{html.escape(strings._("activity"))}</th>
<th>{html.escape(strings._("hours"))}</th>
</tr>
{rows_html} {rows_html}
</table> </table>
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p> <p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>

196
poetry.lock generated
View file

@ -146,103 +146,103 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.12.0" version = "7.13.0"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
files = [ files = [
{file = "coverage-7.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:32b75c2ba3f324ee37af3ccee5b30458038c50b349ad9b88cee85096132a575b"}, {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
{file = "coverage-7.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cb2a1b6ab9fe833714a483a915de350abc624a37149649297624c8d57add089c"}, {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5734b5d913c3755e72f70bf6cc37a0518d4f4745cde760c5d8e12005e62f9832"}, {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
{file = "coverage-7.12.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b527a08cdf15753279b7afb2339a12073620b761d79b81cbe2cdebdb43d90daa"}, {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
{file = "coverage-7.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9bb44c889fb68004e94cab71f6a021ec83eac9aeabdbb5a5a88821ec46e1da73"}, {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
{file = "coverage-7.12.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b59b501455535e2e5dde5881739897967b272ba25988c89145c12d772810ccb"}, {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8842f17095b9868a05837b7b1b73495293091bed870e099521ada176aa3e00e"}, {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5a6f20bf48b8866095c6820641e7ffbe23f2ac84a2efc218d91235e404c7777"}, {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5f3738279524e988d9da2893f307c2093815c623f8d05a8f79e3eff3a7a9e553"}, {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
{file = "coverage-7.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0d68c1f7eabbc8abe582d11fa393ea483caf4f44b0af86881174769f185c94d"}, {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
{file = "coverage-7.12.0-cp310-cp310-win32.whl", hash = "sha256:7670d860e18b1e3ee5930b17a7d55ae6287ec6e55d9799982aa103a2cc1fa2ef"}, {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
{file = "coverage-7.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:f999813dddeb2a56aab5841e687b68169da0d3f6fc78ccf50952fa2463746022"}, {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
{file = "coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f"}, {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
{file = "coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3"}, {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e"}, {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
{file = "coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7"}, {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
{file = "coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245"}, {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
{file = "coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b"}, {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64"}, {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742"}, {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c"}, {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
{file = "coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984"}, {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
{file = "coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6"}, {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
{file = "coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4"}, {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
{file = "coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc"}, {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
{file = "coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647"}, {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
{file = "coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736"}, {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60"}, {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
{file = "coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8"}, {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
{file = "coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f"}, {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
{file = "coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70"}, {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0"}, {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068"}, {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b"}, {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
{file = "coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937"}, {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
{file = "coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa"}, {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
{file = "coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a"}, {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
{file = "coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c"}, {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
{file = "coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941"}, {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
{file = "coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a"}, {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d"}, {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
{file = "coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211"}, {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
{file = "coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d"}, {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
{file = "coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c"}, {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9"}, {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0"}, {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508"}, {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
{file = "coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc"}, {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
{file = "coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8"}, {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
{file = "coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07"}, {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
{file = "coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc"}, {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87"}, {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
{file = "coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6"}, {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7"}, {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560"}, {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12"}, {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
{file = "coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296"}, {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507"}, {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d"}, {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2"}, {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
{file = "coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455"}, {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
{file = "coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d"}, {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
{file = "coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c"}, {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
{file = "coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d"}, {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
{file = "coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92"}, {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
{file = "coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360"}, {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac"}, {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
{file = "coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d"}, {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
{file = "coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c"}, {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
{file = "coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434"}, {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc"}, {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc"}, {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e"}, {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
{file = "coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17"}, {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
{file = "coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933"}, {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
{file = "coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe"}, {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
{file = "coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d"}, {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d"}, {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
{file = "coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03"}, {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9"}, {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6"}, {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339"}, {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
{file = "coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e"}, {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13"}, {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f"}, {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1"}, {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
{file = "coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b"}, {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
{file = "coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a"}, {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
{file = "coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291"}, {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
{file = "coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384"}, {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
{file = "coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a"}, {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
{file = "coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c"}, {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
] ]
[package.dependencies] [package.dependencies]
@ -747,20 +747,20 @@ files = [
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.5.0" version = "2.6.2"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
] ]
[package.extras] [package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
h2 = ["h2 (>=4,<5)"] h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"] zstd = ["backports-zstd (>=1.0.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"

View file

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

View file

@ -2,6 +2,34 @@
set -eo pipefail set -eo pipefail
# Parse the args
while getopts "v:" OPTION
do
case $OPTION in
v)
VERSION=$OPTARG
;;
?)
usage
exit
;;
esac
done
if [[ -z "${VERSION}" ]]; then
echo "You forgot to pass -v [version]!"
exit 1
fi
set +e
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
git add pyproject.toml
git commit -m "Bump to ${VERSION}"
git push origin main
set -e
# Clean caches etc # Clean caches etc
filedust -y . filedust -y .
@ -10,11 +38,11 @@ poetry build
poetry publish poetry publish
# Make AppImage # Make AppImage
sudo apt-get install libfuse-dev sudo apt-get -y install libfuse-dev
poetry run pyproject-appimage poetry run pyproject-appimage
mv Bouquin.AppImage dist/ mv Bouquin.AppImage dist/
# Sign packages # Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
echo "Don't forget to update version string on remote server." ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"

View file

@ -373,7 +373,7 @@ def test_db_gather_stats_empty_database(fresh_db):
"""Test gather_stats on empty database.""" """Test gather_stats on empty database."""
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
assert len(stats) == 10 assert len(stats) == 22
( (
pages_with_content, pages_with_content,
total_revisions, total_revisions,
@ -385,6 +385,18 @@ def test_db_gather_stats_empty_database(fresh_db):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, 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 ) = stats
assert pages_with_content == 0 assert pages_with_content == 0
@ -421,6 +433,7 @@ def test_db_gather_stats_with_content(fresh_db):
page_most_tags, page_most_tags,
page_most_tags_count, page_most_tags_count,
revisions_by_date, revisions_by_date,
*_rest,
) = stats ) = stats
assert pages_with_content == 2 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") fresh_db.save_new_version("2024-01-01", "one two three four five", "test")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, words_by_date, total_words, _, _, _, _ = stats _, _, _, _, words_by_date, total_words, _, _, _, *_rest = stats
assert total_words == 5 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 fresh_db.set_tags_for_page("2024-01-02", ["tag1"]) # Page 2 has 1 tag
stats = fresh_db.gather_stats() 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 unique_tags == 3
assert page_most_tags == "2024-01-01" 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") fresh_db.save_new_version("2024-01-02", "Fourth", "v1")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
assert date(2024, 1, 1) in revisions_by_date assert date(2024, 1, 1) in revisions_by_date
assert revisions_by_date[date(2024, 1, 1)] == 3 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") fresh_db.save_new_version("2024-01-15", "Test", "v1")
stats = fresh_db.gather_stats() stats = fresh_db.gather_stats()
_, _, _, _, _, _, _, _, _, revisions_by_date = stats _, _, _, _, _, _, _, _, _, revisions_by_date, *_rest = stats
# Should have parsed the date correctly # Should have parsed the date correctly
assert date(2024, 1, 15) in revisions_by_date 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") fresh_db.save_new_version("2024-01-01", "one two three four five", "v2")
stats = fresh_db.gather_stats() 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 # Should count words from current version (5 words), not old version
assert total_words == 5 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") fresh_db.save_new_version("2024-01-01", "No tags here", "test")
stats = fresh_db.gather_stats() 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 unique_tags == 0
assert page_most_tags is None 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() 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): def test_manage_reminders_dialog_init(qtbot, app, fresh_db):
"""Test ManageRemindersDialog initialization.""" """Test ManageRemindersDialog initialization."""
dialog = ManageRemindersDialog(fresh_db) dialog = ManageRemindersDialog(fresh_db)
@ -586,7 +575,7 @@ def test_manage_reminders_dialog_weekly_reminder_display(qtbot, app, fresh_db):
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
# Check that the type column shows the day # 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() assert "Wed" in type_item.text()

View file

@ -14,6 +14,7 @@ class FakeStatsDB:
def __init__(self): def __init__(self):
d1 = _dt.date(2024, 1, 1) d1 = _dt.date(2024, 1, 1)
d2 = _dt.date(2024, 1, 2) d2 = _dt.date(2024, 1, 2)
self.stats = ( self.stats = (
2, # pages_with_content 2, # pages_with_content
5, # total_revisions 5, # total_revisions
@ -25,7 +26,20 @@ class FakeStatsDB:
"2024-01-02", # page_most_tags "2024-01-02", # page_most_tags
2, # page_most_tags_count 2, # page_most_tags_count
{d1: 1, d2: 2}, # revisions_by_date {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 self.called = False
def gather_stats(self): 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 # Heatmap is created and uses "words" by default
words_by_date = db.stats[4] words_by_date = db.stats[4]
revisions_by_date = db.stats[-1] revisions_by_date = db.stats[9]
assert hasattr(dlg, "_heatmap") assert hasattr(dlg, "_heatmap")
assert dlg._heatmap._data == words_by_date assert dlg._heatmap._data == words_by_date
@ -80,13 +94,25 @@ class EmptyStatsDB:
0, # pages_with_content 0, # pages_with_content
0, # total_revisions 0, # total_revisions
None, # page_most_revisions None, # page_most_revisions
0, 0, # page_most_revisions_count
{}, # words_by_date {}, # words_by_date
0, # total_words 0, # total_words
0, # unique_tags 0, # unique_tags
None, # page_most_tags None, # page_most_tags
0, 0, # page_most_tags_count
{}, # revisions_by_date {}, # 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
) )

View file

@ -1185,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 1 assert dialog.project_combo.count() == 1
assert dialog.granularity.count() == 4 assert dialog.granularity.count() == 5
def test_time_report_dialog_loads_projects(qtbot, fresh_db): def test_time_report_dialog_loads_projects(qtbot, fresh_db):