From 9dc0a620beca20ed48aa9161dad7fbd6e6790036 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 4 Dec 2025 13:40:04 +1100 Subject: [PATCH] Timesheet report tweaks * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. * Default date range to start from this month. * Allow 'All Projects' for timesheet reports. --- CHANGELOG.md | 5 ++ bouquin/db.py | 47 +++++++++++++ bouquin/locales/en.json | 5 ++ bouquin/time_log.py | 146 ++++++++++++++++++++++++++++++++++------ tests/test_time_log.py | 24 +++---- 5 files changed, 193 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6a1b5..2ceffab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.6.3 + + * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month. + * Allow 'All Projects' for timesheet reports. + # 0.6.2 * Ensure that adding a document whilst on an older date page, uses that date as its upload date diff --git a/bouquin/db.py b/bouquin/db.py index 9baad95..b341e72 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1149,6 +1149,53 @@ class DBManager: for r in rows ] + def time_report_all( + self, + start_date_iso: str, + end_date_iso: str, + granularity: str = "day", # 'day' | 'week' | 'month' + ) -> 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. + """ + if granularity == "day": + bucket_expr = "page_date" + elif granularity == "week": + bucket_expr = "strftime('%Y-%W', page_date)" + else: # month + bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM + + cur = self.conn.cursor() + rows = cur.execute( + f""" + SELECT + p.name AS project_name, + {bucket_expr} AS bucket, + a.name AS activity_name, + t.note AS note, + 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, bucket, activity_name + ORDER BY LOWER(p.name), bucket, LOWER(activity_name); + """, # nosec + (start_date_iso, end_date_iso), + ).fetchall() + + return [ + ( + r["project_name"], + r["bucket"], + r["activity_name"], + r["note"], + r["total_minutes"], + ) + for r in rows + ] + def close(self) -> None: if self.conn is not None: self.conn.close() diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 8d0a04f..b60e9b0 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -197,6 +197,11 @@ "by_month": "by month", "by_week": "by week", "date_range": "Date range", + "custom_range": "Custom", + "this_week": "This week", + "this_month": "This month", + "this_year": "This year", + "all_projects": "All projects", "delete_activity": "Delete activity", "delete_activity_confirm": "Are you sure you want to delete this activity?", "delete_activity_title": "Delete activity - are you sure?", diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 78c17ed..d97059b 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -1001,23 +1001,41 @@ class TimeReportDialog(QDialog): form = QFormLayout() # Project self.project_combo = QComboBox() + self.project_combo.addItem(strings._("all_projects"), None) for proj_id, name in self._db.list_projects(): self.project_combo.addItem(name, proj_id) form.addRow(strings._("project"), self.project_combo) # Date range today = QDate.currentDate() - self.from_date = QDateEdit(today.addDays(-7)) + start_of_month = QDate(today.year(), today.month(), 1) + + self.range_preset = QComboBox() + self.range_preset.addItem(strings._("custom_range"), "custom") + self.range_preset.addItem(strings._("today"), "today") + self.range_preset.addItem(strings._("this_week"), "this_week") + self.range_preset.addItem(strings._("this_month"), "this_month") + self.range_preset.addItem(strings._("this_year"), "this_year") + self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed) + + self.from_date = QDateEdit(start_of_month) self.from_date.setCalendarPopup(True) self.to_date = QDateEdit(today) self.to_date.setCalendarPopup(True) range_row = QHBoxLayout() + range_row.addWidget(self.range_preset) range_row.addWidget(self.from_date) range_row.addWidget(QLabel("—")) range_row.addWidget(self.to_date) + form.addRow(strings._("date_range"), range_row) + # After widgets are created, choose default preset + idx = self.range_preset.findData("this_month") + if idx != -1: + self.range_preset.setCurrentIndex(idx) + # Granularity self.granularity = QComboBox() self.granularity.addItem(strings._("by_day"), "day") @@ -1046,9 +1064,10 @@ class TimeReportDialog(QDialog): # Table self.table = QTableWidget() - self.table.setColumnCount(4) + self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels( [ + strings._("project"), strings._("time_period"), strings._("activity"), strings._("note"), @@ -1058,8 +1077,9 @@ class TimeReportDialog(QDialog): self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode( - 3, QHeaderView.ResizeToContents + 4, QHeaderView.ResizeToContents ) root.addWidget(self.table, 1) @@ -1075,39 +1095,110 @@ class TimeReportDialog(QDialog): close_row.addWidget(close_btn) root.addLayout(close_row) + def _on_range_preset_changed(self, index: int) -> None: + preset = self.range_preset.currentData() + today = QDate.currentDate() + + if preset == "today": + start = end = today + + elif preset == "this_week": + # Monday-based week, clamp end to today + # dayOfWeek(): Monday=1, Sunday=7 + start = today.addDays(1 - today.dayOfWeek()) + end = today + + elif preset == "this_month": + start = QDate(today.year(), today.month(), 1) + end = today + + elif preset == "this_year": + start = QDate(today.year(), 1, 1) + end = today + + else: # "custom" – leave fields as user-set + return + + # Update date edits without triggering anything else + self.from_date.blockSignals(True) + self.to_date.blockSignals(True) + self.from_date.setDate(start) + self.to_date.setDate(end) + self.from_date.blockSignals(False) + self.to_date.blockSignals(False) + def _run_report(self): idx = self.project_combo.currentIndex() if idx < 0: return - proj_id = int(self.project_combo.itemData(idx)) + proj_data = self.project_combo.itemData(idx) start = self.from_date.date().toString("yyyy-MM-dd") end = self.to_date.date().toString("yyyy-MM-dd") gran = self.granularity.currentData() - # Keep human-friendly copies for PDF header - self._last_project_name = self.project_combo.currentText() self._last_start = start self._last_end = end self._last_gran_label = self.granularity.currentText() - rows = self._db.time_report(proj_id, start, end, gran) + rows_for_table: list[tuple[str, str, str, str, int]] = [] - self._last_rows = rows - self._last_total_minutes = sum(r[3] for r in rows) + if proj_data is None: + # All projects + self._last_all_projects = True + self._last_project_name = strings._("all_projects") + rows_for_table = self._db.time_report_all(start, end, gran) + else: + self._last_all_projects = False + proj_id = int(proj_data) + project_name = self.project_combo.currentText() + self._last_project_name = project_name - self.table.setRowCount(len(rows)) - for i, (time_period, activity_name, note, minutes) in enumerate(rows): + per_project_rows = self._db.time_report(proj_id, start, end, gran) + # Adapt DB rows (period, activity, note, minutes) → include project + rows_for_table = [ + (project_name, period, activity, note, minutes) + for (period, activity, note, minutes) in per_project_rows + ] + + # Store for export + self._last_rows = rows_for_table + self._last_total_minutes = sum(r[4] for r in rows_for_table) + + # Per-project totals + self._last_project_totals = defaultdict(int) + for project, _period, _activity, _note, minutes in rows_for_table: + self._last_project_totals[project] += minutes + + # Populate table + self.table.setRowCount(len(rows_for_table)) + for i, (project, time_period, activity_name, note, minutes) in enumerate( + rows_for_table + ): hrs = minutes / 60.0 - self.table.setItem(i, 0, QTableWidgetItem(time_period)) - self.table.setItem(i, 1, QTableWidgetItem(activity_name)) - self.table.setItem(i, 2, QTableWidgetItem(note)) - 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)) + self.table.setItem(i, 3, QTableWidgetItem(note)) + self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}")) + # Summary label – include per-project totals when in "all projects" mode total_hours = self._last_total_minutes / 60.0 - self.total_label.setText( - strings._("time_report_total").format(hours=total_hours) - ) + if self._last_all_projects: + per_project_bits = [ + f"{proj}: {mins/60.0:.2f}h" + for proj, mins in sorted(self._last_project_totals.items()) + ] + self.total_label.setText( + strings._("time_report_total").format(hours=total_hours) + + " (" + + ", ".join(per_project_bits) + + ")" + ) + else: + self.total_label.setText( + strings._("time_report_total").format(hours=total_hours) + ) def _export_csv(self): if not self._last_rows: @@ -1136,6 +1227,7 @@ class TimeReportDialog(QDialog): # Header writer.writerow( [ + strings._("project"), strings._("time_period"), strings._("activity"), strings._("note"), @@ -1144,9 +1236,17 @@ class TimeReportDialog(QDialog): ) # Data rows - for time_period, activity_name, note, minutes in self._last_rows: + for ( + project, + time_period, + activity_name, + note, + minutes, + ) in self._last_rows: hours = minutes / 60.0 - writer.writerow([time_period, activity_name, note, f"{hours:.2f}"]) + writer.writerow( + [project, time_period, activity_name, note, f"{hours:.2f}"] + ) # Blank line + total total_hours = self._last_total_minutes / 60.0 @@ -1181,7 +1281,7 @@ class TimeReportDialog(QDialog): # ---------- Build chart image (hours per period) ---------- per_period_minutes: dict[str, int] = defaultdict(int) - for period, _activity, note, minutes in self._last_rows: + for _project, period, _activity, note, minutes in self._last_rows: per_period_minutes[period] += minutes periods = sorted(per_period_minutes.keys()) @@ -1282,10 +1382,11 @@ class TimeReportDialog(QDialog): # Table rows (period, activity, hours) row_html_parts: list[str] = [] - for period, activity, note, minutes in self._last_rows: + 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}" @@ -1343,6 +1444,7 @@ class TimeReportDialog(QDialog):

+ diff --git a/tests/test_time_log.py b/tests/test_time_log.py index e994826..7796a51 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1190,7 +1190,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - assert dialog.project_combo.count() == 0 + assert dialog.project_combo.count() == 1 assert dialog.granularity.count() == 3 # day, week, month @@ -1202,18 +1202,18 @@ def test_time_report_dialog_loads_projects(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - assert dialog.project_combo.count() == 2 + assert dialog.project_combo.count() == 3 def test_time_report_dialog_default_date_range(qtbot, fresh_db): - """Dialog defaults to last 7 days.""" + """Dialog defaults to start of month.""" dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) today = QDate.currentDate() - week_ago = today.addDays(-7) + start_of_month = QDate(today.year(), today.month(), 1) - assert dialog.from_date.date() == week_ago + assert dialog.from_date.date() == start_of_month assert dialog.to_date.date() == today @@ -1235,7 +1235,7 @@ def test_time_report_dialog_run_report(qtbot, fresh_db): dialog._run_report() assert dialog.table.rowCount() == 1 - assert "Activity" in dialog.table.item(0, 1).text() + assert "Activity" in dialog.table.item(0, 2).text() assert "1.5" in dialog.total_label.text() or "1.50" in dialog.total_label.text() @@ -1423,7 +1423,7 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db): # Should aggregate to single week assert dialog.table.rowCount() == 1 - hours_text = dialog.table.item(0, 3).text() + hours_text = dialog.table.item(0, 4).text() assert "2.5" in hours_text or "2.50" in hours_text @@ -1451,7 +1451,7 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db): # Should aggregate to single month assert dialog.table.rowCount() == 1 - hours_text = dialog.table.item(0, 3).text() + hours_text = dialog.table.item(0, 4).text() assert "2.5" in hours_text or "2.50" in hours_text @@ -1937,7 +1937,7 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db): dialog = TimeReportDialog(fresh_db) qtbot.addWidget(dialog) - dialog.project_combo.setCurrentIndex(0) + dialog.project_combo.setCurrentIndex(1) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.granularity.setCurrentIndex(1) # week @@ -2154,10 +2154,10 @@ def test_full_workflow_add_project_activity_log_report( # Verify report assert report_dialog.table.rowCount() == 1 - assert "Test Activity" in report_dialog.table.item(0, 1).text() + assert "Test Activity" in report_dialog.table.item(0, 2).text() assert ( - "2.5" in report_dialog.table.item(0, 3).text() - or "2.50" in report_dialog.table.item(0, 3).text() + "2.5" in report_dialog.table.item(0, 4).text() + or "2.50" in report_dialog.table.item(0, 4).text() ) # 5. Export CSV
{html.escape(strings._("project"))} {html.escape(strings._("time_period"))} {html.escape(strings._("activity"))} {html.escape(strings._("hours"))}