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.
This commit is contained in:
parent
1e12cae78e
commit
9dc0a620be
5 changed files with 193 additions and 34 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"<tr>"
|
||||
f"<td>{html.escape(project)}</td>"
|
||||
f"<td>{html.escape(period)}</td>"
|
||||
f"<td>{html.escape(activity)}</td>"
|
||||
f"<td style='text-align:right'>{hours:.2f}</td>"
|
||||
|
|
@ -1343,6 +1444,7 @@ class TimeReportDialog(QDialog):
|
|||
<p><img src="chart" class="chart" /></p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>{html.escape(strings._("project"))}</th>
|
||||
<th>{html.escape(strings._("time_period"))}</th>
|
||||
<th>{html.escape(strings._("activity"))}</th>
|
||||
<th>{html.escape(strings._("hours"))}</th>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue