Timesheet report tweaks
All checks were successful
CI / test (push) Successful in 5m55s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s

* 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.
This commit is contained in:
Miguel Jacq 2025-12-04 13:40:04 +11:00
parent 1e12cae78e
commit 9dc0a620be
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
5 changed files with 193 additions and 34 deletions

View file

@ -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 # 0.6.2
* Ensure that adding a document whilst on an older date page, uses that date as its upload date * Ensure that adding a document whilst on an older date page, uses that date as its upload date

View file

@ -1149,6 +1149,53 @@ class DBManager:
for r in rows 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: def close(self) -> None:
if self.conn is not None: if self.conn is not None:
self.conn.close() self.conn.close()

View file

@ -197,6 +197,11 @@
"by_month": "by month", "by_month": "by month",
"by_week": "by week", "by_week": "by week",
"date_range": "Date range", "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": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?", "delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?", "delete_activity_title": "Delete activity - are you sure?",

View file

@ -1001,23 +1001,41 @@ class TimeReportDialog(QDialog):
form = QFormLayout() form = QFormLayout()
# Project # Project
self.project_combo = QComboBox() self.project_combo = QComboBox()
self.project_combo.addItem(strings._("all_projects"), None)
for proj_id, name in self._db.list_projects(): for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id) self.project_combo.addItem(name, proj_id)
form.addRow(strings._("project"), self.project_combo) form.addRow(strings._("project"), self.project_combo)
# Date range # Date range
today = QDate.currentDate() 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.from_date.setCalendarPopup(True)
self.to_date = QDateEdit(today) self.to_date = QDateEdit(today)
self.to_date.setCalendarPopup(True) self.to_date.setCalendarPopup(True)
range_row = QHBoxLayout() range_row = QHBoxLayout()
range_row.addWidget(self.range_preset)
range_row.addWidget(self.from_date) range_row.addWidget(self.from_date)
range_row.addWidget(QLabel("")) range_row.addWidget(QLabel(""))
range_row.addWidget(self.to_date) range_row.addWidget(self.to_date)
form.addRow(strings._("date_range"), range_row) 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 # Granularity
self.granularity = QComboBox() self.granularity = QComboBox()
self.granularity.addItem(strings._("by_day"), "day") self.granularity.addItem(strings._("by_day"), "day")
@ -1046,9 +1064,10 @@ class TimeReportDialog(QDialog):
# Table # Table
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(4) self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("project"),
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("note"), strings._("note"),
@ -1058,8 +1077,9 @@ class TimeReportDialog(QDialog):
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode( self.table.horizontalHeader().setSectionResizeMode(
3, QHeaderView.ResizeToContents 4, QHeaderView.ResizeToContents
) )
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
@ -1075,39 +1095,110 @@ class TimeReportDialog(QDialog):
close_row.addWidget(close_btn) close_row.addWidget(close_btn)
root.addLayout(close_row) 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): def _run_report(self):
idx = self.project_combo.currentIndex() idx = self.project_combo.currentIndex()
if idx < 0: if idx < 0:
return 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") start = self.from_date.date().toString("yyyy-MM-dd")
end = self.to_date.date().toString("yyyy-MM-dd") end = self.to_date.date().toString("yyyy-MM-dd")
gran = self.granularity.currentData() 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_start = start
self._last_end = end self._last_end = end
self._last_gran_label = self.granularity.currentText() 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 if proj_data is None:
self._last_total_minutes = sum(r[3] for r in rows) # 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)) per_project_rows = self._db.time_report(proj_id, start, end, gran)
for i, (time_period, activity_name, note, minutes) in enumerate(rows): # 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 hrs = minutes / 60.0
self.table.setItem(i, 0, QTableWidgetItem(time_period)) self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(activity_name)) self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(note)) self.table.setItem(i, 2, QTableWidgetItem(activity_name))
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}")) 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 total_hours = self._last_total_minutes / 60.0
self.total_label.setText( if self._last_all_projects:
strings._("time_report_total").format(hours=total_hours) 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): def _export_csv(self):
if not self._last_rows: if not self._last_rows:
@ -1136,6 +1227,7 @@ class TimeReportDialog(QDialog):
# Header # Header
writer.writerow( writer.writerow(
[ [
strings._("project"),
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("note"), strings._("note"),
@ -1144,9 +1236,17 @@ class TimeReportDialog(QDialog):
) )
# Data rows # 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 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 # Blank line + total
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
@ -1181,7 +1281,7 @@ class TimeReportDialog(QDialog):
# ---------- Build chart image (hours per period) ---------- # ---------- Build chart image (hours per period) ----------
per_period_minutes: dict[str, int] = defaultdict(int) 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 per_period_minutes[period] += minutes
periods = sorted(per_period_minutes.keys()) periods = sorted(per_period_minutes.keys())
@ -1282,10 +1382,11 @@ class TimeReportDialog(QDialog):
# Table rows (period, activity, hours) # Table rows (period, activity, hours)
row_html_parts: list[str] = [] 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 hours = minutes / 60.0
row_html_parts.append( row_html_parts.append(
"<tr>" "<tr>"
f"<td>{html.escape(project)}</td>"
f"<td>{html.escape(period)}</td>" f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>" f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>" f"<td style='text-align:right'>{hours:.2f}</td>"
@ -1343,6 +1444,7 @@ class TimeReportDialog(QDialog):
<p><img src="chart" class="chart" /></p> <p><img src="chart" class="chart" /></p>
<table> <table>
<tr> <tr>
<th>{html.escape(strings._("project"))}</th>
<th>{html.escape(strings._("time_period"))}</th> <th>{html.escape(strings._("time_period"))}</th>
<th>{html.escape(strings._("activity"))}</th> <th>{html.escape(strings._("activity"))}</th>
<th>{html.escape(strings._("hours"))}</th> <th>{html.escape(strings._("hours"))}</th>

View file

@ -1190,7 +1190,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
dialog = TimeReportDialog(fresh_db) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 0 assert dialog.project_combo.count() == 1
assert dialog.granularity.count() == 3 # day, week, month 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) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) 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): 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) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
today = QDate.currentDate() 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 assert dialog.to_date.date() == today
@ -1235,7 +1235,7 @@ def test_time_report_dialog_run_report(qtbot, fresh_db):
dialog._run_report() dialog._run_report()
assert dialog.table.rowCount() == 1 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() 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 # Should aggregate to single week
assert dialog.table.rowCount() == 1 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 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 # Should aggregate to single month
assert dialog.table.rowCount() == 1 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 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) dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog) qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(0) dialog.project_combo.setCurrentIndex(1)
dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.from_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd")) dialog.to_date.setDate(QDate.fromString(_today(), "yyyy-MM-dd"))
dialog.granularity.setCurrentIndex(1) # week dialog.granularity.setCurrentIndex(1) # week
@ -2154,10 +2154,10 @@ def test_full_workflow_add_project_activity_log_report(
# Verify report # Verify report
assert report_dialog.table.rowCount() == 1 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 ( assert (
"2.5" 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, 3).text() or "2.50" in report_dialog.table.item(0, 4).text()
) )
# 5. Export CSV # 5. Export CSV