Time Log Report fixes
All checks were successful
CI / test (push) Successful in 6m33s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 21s

* 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)
This commit is contained in:
Miguel Jacq 2025-12-05 18:33:50 +11:00
parent 2464147a59
commit 778d988ebd
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 225 additions and 35 deletions

View file

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

View file

@ -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",

View file

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