Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886b809bd3 | |||
| e6010969cb | |||
| 492633df9f | |||
| dcb62d34af | |||
| 13b1ad7373 | |||
| 7abd99fe24 | |||
| 2112de39b8 | |||
| 206670454f | |||
| 3106d408ab | |||
| d809244cf8 | |||
| 28446340f8 | |||
| c1c95ca0ca |
21 changed files with 1075 additions and 226 deletions
26
.pre-commit-config.yaml
Normal file
26
.pre-commit-config.yaml
Normal 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"]
|
||||||
18
CHANGELOG.md
18
CHANGELOG.md
|
|
@ -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.
|
||||||
|
|
|
||||||
191
bouquin/db.py
191
bouquin/db.py
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Fonts, only if the fonts are renamed to names not containing either
|
||||||
the words "Tavmjong Bah" or the word "Arev".
|
the words "Tavmjong Bah" or the word "Arev".
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts
|
This License becomes null and void to the extent applicable to Fonts
|
||||||
or Font Software that has been modified and is distributed under the
|
or Font Software that has been modified and is distributed under the
|
||||||
"Tavmjong Bah Arev" names.
|
"Tavmjong Bah Arev" names.
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ with others.
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
redistributed and/or sold with any software provided that any reserved
|
redistributed and/or sold with any software provided that any reserved
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
however, cannot be released under any other type of license. The
|
however, cannot be released under any other type of license. The
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
@ -166,8 +172,16 @@
|
||||||
"stats_metric_revisions": "Revisions",
|
"stats_metric_revisions": "Revisions",
|
||||||
"stats_metric_documents": "Documents",
|
"stats_metric_documents": "Documents",
|
||||||
"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",
|
||||||
|
|
@ -359,7 +377,7 @@
|
||||||
"documents_missing_file": "The file does not exist:\n{path}",
|
"documents_missing_file": "The file does not exist:\n{path}",
|
||||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||||
"documents_search_label": "Search",
|
"documents_search_label": "Search",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
"todays_documents": "Documents from this day",
|
"todays_documents": "Documents from this day",
|
||||||
"todays_documents_none": "No documents yet.",
|
"todays_documents_none": "No documents yet.",
|
||||||
"manage_invoices": "Manage Invoices",
|
"manage_invoices": "Manage Invoices",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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'.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
6
poetry.lock
generated
6
poetry.lock
generated
|
|
@ -747,13 +747,13 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.1"
|
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.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
|
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||||
{file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
|
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.7.0"
|
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"
|
||||||
|
|
|
||||||
32
release.sh
32
release.sh
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1574,7 +1574,7 @@ def test_markdown_highlighter_special_characters(qtbot, app):
|
||||||
highlighter = MarkdownHighlighter(doc, theme_manager)
|
highlighter = MarkdownHighlighter(doc, theme_manager)
|
||||||
|
|
||||||
text = """
|
text = """
|
||||||
Special chars: < > & " '
|
Special chars: < > & " '
|
||||||
Escaped: \\* \\_ \\`
|
Escaped: \\* \\_ \\`
|
||||||
Unicode: 你好 café résumé
|
Unicode: 你好 café résumé
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue