Allow carrying unchecked TODOs to weekends. Add 'group by activity' in time log reports
Some checks failed
Lint / test (push) Waiting to run
Trivy / test (push) Waiting to run
CI / test (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2025-12-16 15:15:38 +11:00
parent 13b1ad7373
commit dcb62d34af
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
9 changed files with 204 additions and 55 deletions

View file

@ -1,3 +1,8 @@
# 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 # 0.7.2
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)

View file

@ -92,6 +92,7 @@ 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
@ -1351,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
@ -1360,6 +1361,7 @@ 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 'activity' granularity, results are grouped by activity only (no time bucket).
For 'none' granularity, each individual time log entry becomes a row. For 'none' granularity, each individual time log entry becomes a row.
""" """
cur = self.conn.cursor() cur = self.conn.cursor()
@ -1387,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":
@ -1417,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()
@ -1455,6 +1480,34 @@ class DBManager:
for r in rows for r in rows
] ]
if granularity == "activity":
rows = cur.execute(
"""
SELECT
p.name AS project_name,
a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN projects p ON p.id = t.project_id
JOIN activities a ON a.id = t.activity_id
WHERE t.page_date BETWEEN ? AND ?
GROUP BY p.id, activity_name
ORDER BY LOWER(p.name), LOWER(activity_name);
""",
(start_date_iso, end_date_iso),
).fetchall()
return [
(
r["project_name"],
"",
r["activity_name"],
"",
r["total_minutes"],
)
for r in rows
]
if granularity == "day": if granularity == "day":
bucket_expr = "page_date" bucket_expr = "page_date"
elif granularity == "week": elif granularity == "week":

View file

@ -103,6 +103,7 @@
"autosave": "autosave", "autosave": "autosave",
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day", "unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday", "move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images", "insert_images": "Insert images",
"images": "Images", "images": "Images",
"reopen_failed": "Re-open failed", "reopen_failed": "Re-open failed",
@ -209,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",

View file

@ -822,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)
@ -1566,6 +1570,11 @@ 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)

View file

@ -42,6 +42,9 @@ 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)
@ -57,6 +60,7 @@ 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,
@ -76,6 +80,7 @@ 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))

View file

@ -169,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)
@ -441,6 +460,7 @@ 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(),

View file

@ -1083,6 +1083,7 @@ class TimeReportDialog(QDialog):
self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_day"), "day")
self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month") self.granularity.addItem(strings._("by_month"), "month")
self.granularity.addItem(strings._("by_activity"), "activity")
form.addRow(strings._("group_by"), self.granularity) form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form) root.addLayout(form)
@ -1161,6 +1162,20 @@ class TimeReportDialog(QDialog):
header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.Stretch) header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
elif granularity == "activity":
# Grouped by activity only: no time period, no note column
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("hours"),
]
)
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
else: else:
# Grouped: no note column # Grouped: no note column
self.table.setColumnCount(4) self.table.setColumnCount(4)
@ -1272,6 +1287,11 @@ class TimeReportDialog(QDialog):
rows_for_table rows_for_table
): ):
hrs = minutes / 60.0 hrs = minutes / 60.0
if self._last_gran == "activity":
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
else:
self.table.setItem(i, 0, QTableWidgetItem(project)) self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(time_period)) self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name)) self.table.setItem(i, 2, QTableWidgetItem(activity_name))
@ -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,13 +1511,25 @@ 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":
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(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 hours = minutes / 60.0
row_html_parts.append( row_html_parts.append(
"<tr>" "<tr>"
@ -1499,6 +1541,24 @@ class TimeReportDialog(QDialog):
) )
rows_html = "\n".join(row_html_parts) rows_html = "\n".join(row_html_parts)
if gran_key == "activity":
table_header_html = (
"<tr>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</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>"
)
html_doc = f""" html_doc = f"""
<!DOCTYPE html> <!DOCTYPE html>
<html> <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>

View file

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

View file

@ -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):