diff --git a/CHANGELOG.md b/CHANGELOG.md index 7982285..f2da81b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 0.6.4 + + * Time reports: Fix report 'group by' logic to not show ambiguous 'note' data. + * Time reports: Add default option to 'don't group'. This gives every individual time log row (and so the 'note' is shown in this case) + # 0.6.3 * Allow 'this week', 'this month', 'this year' granularity in Timesheet reports. Default date range to start from this month. diff --git a/bouquin/db.py b/bouquin/db.py index b341e72..2ebfa4c 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -1108,8 +1108,8 @@ class DBManager: project_id: int, start_date_iso: str, end_date_iso: str, - granularity: str = "day", # 'day' | 'week' | 'month' - ) -> list[tuple[str, str, int]]: + granularity: str = "day", # 'day' | 'week' | 'month' | 'none' + ) -> list[tuple[str, str, str, int]]: """ Return (time_period, activity_name, total_minutes) tuples between start and end for a project, grouped by period and activity. @@ -1117,7 +1117,33 @@ 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. """ + cur = self.conn.cursor() + + if granularity == "none": + # No grouping: one row per entry + rows = cur.execute( + """ + SELECT + t.page_date AS period, + a.name AS activity_name, + t.note AS note, + 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 ? + ORDER BY period, LOWER(a.name), t.id; + """, + (project_id, start_date_iso, end_date_iso), + ).fetchall() + + return [ + (r["period"], r["activity_name"], r["note"], r["total_minutes"]) + for r in rows + ] + if granularity == "day": bucket_expr = "page_date" elif granularity == "week": @@ -1126,13 +1152,11 @@ class DBManager: else: # month bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM - cur = self.conn.cursor() rows = cur.execute( f""" SELECT {bucket_expr} AS bucket, a.name AS activity_name, - t.note AS note, SUM(t.minutes) AS total_minutes FROM time_log t JOIN activities a ON a.id = t.activity_id @@ -1144,21 +1168,50 @@ class DBManager: (project_id, start_date_iso, end_date_iso), ).fetchall() - return [ - (r["bucket"], r["activity_name"], r["note"], r["total_minutes"]) - for r in rows - ] + return [(r["bucket"], r["activity_name"], "", r["total_minutes"]) for r in rows] def time_report_all( self, start_date_iso: str, end_date_iso: str, - granularity: str = "day", # 'day' | 'week' | 'month' + granularity: str = "day", # 'day' | 'week' | 'month' | '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. """ + cur = self.conn.cursor() + + if granularity == "none": + # No grouping – one row per time_log record + rows = cur.execute( + """ + SELECT + p.name AS project_name, + t.page_date AS period, + a.name AS activity_name, + t.note AS note, + 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 ? + ORDER BY LOWER(p.name), period, LOWER(activity_name), t.id; + """, + (start_date_iso, end_date_iso), + ).fetchall() + + return [ + ( + r["project_name"], + r["period"], + r["activity_name"], + r["note"], + r["total_minutes"], + ) + for r in rows + ] + if granularity == "day": bucket_expr = "page_date" elif granularity == "week": @@ -1166,14 +1219,12 @@ class DBManager: 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 @@ -1190,7 +1241,7 @@ class DBManager: r["project_name"], r["bucket"], r["activity_name"], - r["note"], + "", r["total_minutes"], ) for r in rows diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 5bc7f95..b8c56f5 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -196,6 +196,7 @@ "add_project": "Add project", "add_time_entry": "Add time entry", "time_period": "Time period", + "dont_group": "Don't group", "by_day": "by day", "by_month": "by month", "by_week": "by week", diff --git a/bouquin/time_log.py b/bouquin/time_log.py index d97059b..e5e9b64 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -986,7 +986,7 @@ class TimeReportDialog(QDialog): self._db = db # state for last run - self._last_rows: list[tuple[str, str, int]] = [] + self._last_rows: list[tuple[str, str, str, str, int]] = [] self._last_total_minutes: int = 0 self._last_project_name: str = "" self._last_start: str = "" @@ -1038,6 +1038,7 @@ class TimeReportDialog(QDialog): # Granularity self.granularity = QComboBox() + self.granularity.addItem(strings._("dont_group"), "none") self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_week"), "week") self.granularity.addItem(strings._("by_month"), "month") @@ -1095,6 +1096,43 @@ class TimeReportDialog(QDialog): close_row.addWidget(close_btn) root.addLayout(close_row) + def _configure_table_columns(self, granularity: str) -> None: + if granularity == "none": + # Show notes + self.table.setColumnCount(5) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("time_period"), + strings._("activity"), + strings._("note"), + strings._("hours"), + ] + ) + # project, period, activity, note stretch; hours shrink + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.Stretch) + header.setSectionResizeMode(4, QHeaderView.ResizeToContents) + else: + # Grouped: no note column + self.table.setColumnCount(4) + self.table.setHorizontalHeaderLabels( + [ + strings._("project"), + strings._("time_period"), + strings._("activity"), + strings._("hours"), + ] + ) + header = self.table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Stretch) + header.setSectionResizeMode(1, QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.Stretch) + header.setSectionResizeMode(3, QHeaderView.ResizeToContents) + def _on_range_preset_changed(self, index: int) -> None: preset = self.range_preset.currentData() today = QDate.currentDate() @@ -1140,6 +1178,9 @@ class TimeReportDialog(QDialog): self._last_start = start self._last_end = end self._last_gran_label = self.granularity.currentText() + self._last_gran = gran # remember which grouping was used + + self._configure_table_columns(gran) rows_for_table: list[tuple[str, str, str, str, int]] = [] @@ -1179,8 +1220,13 @@ class TimeReportDialog(QDialog): 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}")) + + 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 @@ -1224,16 +1270,18 @@ 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" + # Header - writer.writerow( - [ - strings._("project"), - strings._("time_period"), - strings._("activity"), - strings._("note"), - strings._("hours"), - ] - ) + header = [ + strings._("project"), + strings._("time_period"), + strings._("activity"), + ] + if show_note: + header.append(strings._("note")) + header.append(strings._("hours")) + writer.writerow(header) # Data rows for ( @@ -1244,9 +1292,11 @@ class TimeReportDialog(QDialog): minutes, ) in self._last_rows: hours = minutes / 60.0 - writer.writerow( - [project, time_period, activity_name, note, f"{hours:.2f}"] - ) + row = [project, time_period, activity_name] + if show_note: + row.append(note) + row.append(f"{hours:.2f}") + writer.writerow(row) # Blank line + total total_hours = self._last_total_minutes / 60.0 diff --git a/tests/test_time_log.py b/tests/test_time_log.py index 7796a51..6a997ed 100644 --- a/tests/test_time_log.py +++ b/tests/test_time_log.py @@ -1191,7 +1191,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db): qtbot.addWidget(dialog) assert dialog.project_combo.count() == 1 - assert dialog.granularity.count() == 3 # day, week, month + assert dialog.granularity.count() == 4 def test_time_report_dialog_loads_projects(qtbot, fresh_db): @@ -1230,7 +1230,9 @@ def test_time_report_dialog_run_report(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(0) # day + idx_day = dialog.granularity.findData("day") + assert idx_day != -1 + dialog.granularity.setCurrentIndex(idx_day) dialog._run_report() @@ -1417,13 +1419,18 @@ def test_time_report_dialog_granularity_week(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(1) # week + + idx_week = dialog.granularity.findData("week") + assert idx_week != -1 + dialog.granularity.setCurrentIndex(idx_week) dialog._run_report() # Should aggregate to single week assert dialog.table.rowCount() == 1 - hours_text = dialog.table.item(0, 4).text() + # In grouped modes the Note column is hidden → hours are in column 3 + hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text @@ -1445,13 +1452,17 @@ def test_time_report_dialog_granularity_month(qtbot, fresh_db): dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date2, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(2) # month + + idx_month = dialog.granularity.findData("month") + assert idx_month != -1 + dialog.granularity.setCurrentIndex(idx_month) dialog._run_report() # Should aggregate to single month assert dialog.table.rowCount() == 1 - hours_text = dialog.table.item(0, 4).text() + hours_text = dialog.table.item(0, 3).text() + assert "2.5" in hours_text or "2.50" in hours_text @@ -1940,7 +1951,10 @@ def test_time_report_dialog_stores_report_state(qtbot, fresh_db): 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 + + idx_week = dialog.granularity.findData("week") + assert idx_week != -1 + dialog.granularity.setCurrentIndex(idx_week) dialog._run_report() @@ -1976,7 +1990,10 @@ def test_time_report_dialog_pdf_export_with_multiple_periods( dialog.project_combo.setCurrentIndex(0) dialog.from_date.setDate(QDate.fromString(date1, "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(date3, "yyyy-MM-dd")) - dialog.granularity.setCurrentIndex(0) # day + + idx_day = dialog.granularity.findData("day") + assert idx_day != -1 + dialog.granularity.setCurrentIndex(idx_day) dialog._run_report() @@ -2933,3 +2950,69 @@ def test_time_code_manager_dialog_with_focus_tab(qtbot, app, fresh_db): dialog2 = TimeCodeManagerDialog(fresh_db, focus_tab="activities") qtbot.addWidget(dialog2) assert dialog2.tabs.currentIndex() == 1 + + +def test_time_report_no_grouping_returns_each_entry_and_note(fresh_db): + """Granularity 'none' returns one row per entry and includes notes.""" + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + date = _today() + + fresh_db.add_time_log(date, proj_id, act_id, 60, note="First") + fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second") + + report = fresh_db.time_report(proj_id, date, date, "none") + + # Two separate rows, not aggregated. + assert len(report) == 2 + + # Each row is (period, activity_name, note, total_minutes) + periods = {r[0] for r in report} + activities = {r[1] for r in report} + notes = {r[2] for r in report} + minutes = sorted(r[3] for r in report) + + assert periods == {date} + assert activities == {"Activity"} + assert notes == {"First", "Second"} + assert minutes == [30, 60] + + +def test_time_report_dialog_granularity_none_shows_each_entry_and_notes( + qtbot, fresh_db +): + """'Don't group' granularity shows one row per log entry and includes notes.""" + strings.load_strings("en") + proj_id = fresh_db.add_project("Project") + act_id = fresh_db.add_activity("Activity") + date = _today() + + fresh_db.add_time_log(date, proj_id, act_id, 60, note="First") + fresh_db.add_time_log(date, proj_id, act_id, 30, note="Second") + + dialog = TimeReportDialog(fresh_db) + qtbot.addWidget(dialog) + + # Select the concrete project (index 0 is "All projects") + dialog.project_combo.setCurrentIndex(1) + dialog.from_date.setDate(QDate.fromString(date, "yyyy-MM-dd")) + dialog.to_date.setDate(QDate.fromString(date, "yyyy-MM-dd")) + + idx_none = dialog.granularity.findData("none") + assert idx_none != -1 + dialog.granularity.setCurrentIndex(idx_none) + + dialog._run_report() + + # Two rows, not aggregated + assert dialog.table.rowCount() == 2 + + # Notes in column 3 + notes = {dialog.table.item(row, 3).text() for row in range(dialog.table.rowCount())} + assert "First" in notes + assert "Second" in notes + + # Hours in last column (index 4) when not grouped + hours = [dialog.table.item(row, 4).text() for row in range(dialog.table.rowCount())] + assert any("1.00" in h or "1.0" in h for h in hours) + assert any("0.50" in h or "0.5" in h for h in hours)