diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe960b..45edf09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 * Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders) diff --git a/bouquin/db.py b/bouquin/db.py index 3e4c388..f0d5b5f 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -92,6 +92,7 @@ class DBConfig: idle_minutes: int = 15 # 0 = never lock theme: str = "system" move_todos: bool = False + move_todos_include_weekends: bool = False tags: bool = True time_log: bool = True reminders: bool = True @@ -1351,7 +1352,7 @@ class DBManager: project_id: int, start_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]]: """ Return (time_period, activity_name, total_minutes) tuples between start and end @@ -1360,7 +1361,8 @@ class DBManager: - 'YYYY-MM-DD' for day - 'YYYY-WW' for week - '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() @@ -1387,6 +1389,26 @@ class DBManager: 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": bucket_expr = "page_date" elif granularity == "week": @@ -1417,11 +1439,14 @@ class DBManager: self, start_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]]: """ 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() @@ -1455,6 +1480,34 @@ class DBManager: 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": bucket_expr = "page_date" elif granularity == "week": diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index f0aed54..e8e0864 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -103,6 +103,7 @@ "autosave": "autosave", "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_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday", "insert_images": "Insert images", "images": "Images", "reopen_failed": "Re-open failed", @@ -209,6 +210,7 @@ "add_time_entry": "Add time entry", "time_period": "Time period", "dont_group": "Don't group", + "by_activity": "by activity", "by_day": "by day", "by_month": "by month", "by_week": "by week", diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 89bc9a9..9b812b4 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -822,9 +822,13 @@ class MainWindow(QMainWindow): Given a 'new day' (system date), return the date we should move unfinished todos *to*. - If the new day is Saturday or Sunday, we skip ahead to the next Monday. - Otherwise we just return the same day. + By default, if the new day is Saturday or Sunday we skip ahead to the + 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 dow = day.dayOfWeek() 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.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_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.time_log = getattr(new_cfg, "time_log", self.cfg.time_log) self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders) diff --git a/bouquin/settings.py b/bouquin/settings.py index 0c5b614..fde863d 100644 --- a/bouquin/settings.py +++ b/bouquin/settings.py @@ -42,6 +42,9 @@ def load_db_config() -> DBConfig: idle = s.value("ui/idle_minutes", 15, type=int) theme = s.value("ui/theme", "system", type=str) 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) time_log = s.value("ui/time_log", True, type=bool) reminders = s.value("ui/reminders", True, type=bool) @@ -57,6 +60,7 @@ def load_db_config() -> DBConfig: idle_minutes=idle, theme=theme, move_todos=move_todos, + move_todos_include_weekends=move_todos_include_weekends, tags=tags, time_log=time_log, 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/theme", str(cfg.theme)) 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/time_log", str(cfg.time_log)) s.setValue("ui/reminders", str(cfg.reminders)) diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 8835493..bec0627 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -169,6 +169,25 @@ class SettingsDialog(QDialog): self.move_todos.setCursor(Qt.PointingHandCursor) 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.setChecked(self.current_settings.tags) self.tags.setCursor(Qt.PointingHandCursor) @@ -441,6 +460,7 @@ class SettingsDialog(QDialog): idle_minutes=self.idle_spin.value(), theme=selected_theme.value, move_todos=self.move_todos.isChecked(), + move_todos_include_weekends=self.move_todos_include_weekends.isChecked(), tags=self.tags.isChecked(), time_log=self.time_log.isChecked(), reminders=self.reminders.isChecked(), diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 1adf3c3..05d7e98 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -1083,6 +1083,7 @@ class TimeReportDialog(QDialog): self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_month"), "month") + self.granularity.addItem(strings._("by_activity"), "activity") form.addRow(strings._("group_by"), self.granularity) root.addLayout(form) @@ -1161,6 +1162,20 @@ class TimeReportDialog(QDialog): header.setSectionResizeMode(2, QHeaderView.Stretch) header.setSectionResizeMode(3, QHeaderView.Stretch) 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: # Grouped: no note column self.table.setColumnCount(4) @@ -1272,16 +1287,21 @@ class TimeReportDialog(QDialog): rows_for_table ): hrs = minutes / 60.0 - self.table.setItem(i, 0, QTableWidgetItem(project)) - 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}")) + 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: - # no note column - self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) + self.table.setItem(i, 0, QTableWidgetItem(project)) + 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 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: 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 = [ - strings._("project"), - strings._("time_period"), - strings._("activity"), - ] + header: list[str] = [strings._("project")] + if show_period: + header.append(strings._("time_period")) + header.append(strings._("activity")) if show_note: header.append(strings._("note")) header.append(strings._("hours")) @@ -1347,16 +1368,22 @@ class TimeReportDialog(QDialog): minutes, ) in self._last_rows: 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: - row.append(note) + row.append(note or "") row.append(f"{hours:.2f}") writer.writerow(row) # Blank line + total total_hours = self._last_total_minutes / 60.0 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: QMessageBox.warning( self, @@ -1384,17 +1411,20 @@ class TimeReportDialog(QDialog): if not filename.endswith(".pdf"): filename = f"{filename}.pdf" - # ---------- Build chart image (hours per period) ---------- - per_period_minutes: dict[str, int] = defaultdict(int) - for _project, period, _activity, note, minutes in self._last_rows: - per_period_minutes[period] += minutes + # ---------- Build chart image ---------- + # Default: hours per time period. If grouped by activity: hours per activity. + gran = getattr(self, "_last_gran", "day") + 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 = QImage(chart_w, chart_h, QImage.Format_ARGB32) chart.fill(Qt.white) - if periods: + if buckets: painter = QPainter(chart) try: painter.setRenderHint(QPainter.Antialiasing, True) @@ -1422,9 +1452,9 @@ class TimeReportDialog(QDialog): # Border 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: - n = len(periods) + n = len(buckets) bar_spacing = width / max(1, n) bar_width = bar_spacing * 0.6 @@ -1449,8 +1479,8 @@ class TimeReportDialog(QDialog): painter.setBrush(QColor(80, 140, 200)) painter.setPen(Qt.NoPen) - for i, period in enumerate(periods): - hours = per_period_minutes[period] / 60.0 + for i, label in enumerate(buckets): + hours = per_bucket_minutes[label] / 60.0 bar_h = int((hours / max_hours) * (height - 10)) if bar_h <= 0: continue # pragma: no cover @@ -1463,7 +1493,7 @@ class TimeReportDialog(QDialog): # X labels after bars, in 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 = int(x_center - bar_width / 2) painter.drawText( @@ -1472,7 +1502,7 @@ class TimeReportDialog(QDialog): int(bar_width), 20, Qt.AlignHCenter | Qt.AlignTop, - period, + label, ) finally: painter.end() @@ -1481,23 +1511,53 @@ class TimeReportDialog(QDialog): project = html.escape(self._last_project_name or "") start = html.escape(self._last_start 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 - # Table rows (period, activity, hours) + # Table rows row_html_parts: list[str] = [] - for project, period, activity, note, minutes in self._last_rows: - hours = minutes / 60.0 - row_html_parts.append( + if gran_key == "activity": + for project, _period, activity, _note, minutes in self._last_rows: + hours = minutes / 60.0 + row_html_parts.append( + "" + f"{html.escape(project)}" + f"{html.escape(activity)}" + f"{hours:.2f}" + "" + ) + else: + for project, period, activity, _note, minutes in self._last_rows: + hours = minutes / 60.0 + row_html_parts.append( + "" + f"{html.escape(project)}" + f"{html.escape(period)}" + f"{html.escape(activity)}" + f"{hours:.2f}" + "" + ) + rows_html = "\n".join(row_html_parts) + + if gran_key == "activity": + table_header_html = ( "" - f"{html.escape(project)}" - f"{html.escape(period)}" - f"{html.escape(activity)}" - f"{hours:.2f}" + f"{html.escape(strings._('project'))}" + f"{html.escape(strings._('activity'))}" + f"{html.escape(strings._('hours'))}" + "" + ) + else: + table_header_html = ( + "" + f"{html.escape(strings._('project'))}" + f"{html.escape(strings._('time_period'))}" + f"{html.escape(strings._('activity'))}" + f"{html.escape(strings._('hours'))}" "" ) - rows_html = "\n".join(row_html_parts) html_doc = f""" @@ -1544,16 +1604,11 @@ class TimeReportDialog(QDialog):

{html.escape(strings._("time_log_report_title").format(project=project))}

{html.escape(strings._("time_log_report_meta").format( - start=start, end=end, granularity=gran))} + start=start, end=end, granularity=gran_label))}

- - - - - - + {table_header_html} {rows_html}
{html.escape(strings._("project"))}{html.escape(strings._("time_period"))}{html.escape(strings._("activity"))}{html.escape(strings._("hours"))}

{html.escape(strings._("time_report_total").format(hours=total_hours))}

diff --git a/pyproject.toml b/pyproject.toml index d75bd29..521e3d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "bouquin" -version = "0.7.2" +version = "0.7.3" description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher." authors = ["Miguel Jacq "] readme = "README.md" diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 0a6797c..ff1d159 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1185,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db): qtbot.addWidget(dialog) 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):