Expose note in time log dialog and reports
All checks were successful
CI / test (push) Successful in 3m58s
Lint / test (push) Successful in 27s
Trivy / test (push) Successful in 22s

This commit is contained in:
Miguel Jacq 2025-11-19 20:50:04 +11:00
parent f41ec9a5a9
commit 119d326eea
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
4 changed files with 64 additions and 39 deletions

View file

@ -987,6 +987,7 @@ class DBManager:
SELECT SELECT
{bucket_expr} AS bucket, {bucket_expr} AS bucket,
a.name AS activity_name, a.name AS activity_name,
t.note AS note,
SUM(t.minutes) AS total_minutes SUM(t.minutes) AS total_minutes
FROM time_log t FROM time_log t
JOIN activities a ON a.id = t.activity_id JOIN activities a ON a.id = t.activity_id
@ -998,7 +999,10 @@ class DBManager:
(project_id, start_date_iso, end_date_iso), (project_id, start_date_iso, end_date_iso),
).fetchall() ).fetchall()
return [(r["bucket"], r["activity_name"], r["total_minutes"]) for r in rows] return [
(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:

View file

@ -164,6 +164,7 @@
"toolbar_alarm": "Set reminder alarm", "toolbar_alarm": "Set reminder alarm",
"activities": "Activities", "activities": "Activities",
"activity": "Activity", "activity": "Activity",
"note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity", "activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity", "activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity", "activity_rename_error_message": "A problem occurred renaming the activity",

View file

@ -183,7 +183,7 @@ class TimeLogDialog(QDialog):
self._current_entry_id: Optional[int] = None self._current_entry_id: Optional[int] = None
self.setWindowTitle(strings._("time_log_for").format(date=date_iso)) self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.resize(600, 400) self.resize(900, 600)
root = QVBoxLayout(self) root = QVBoxLayout(self)
@ -211,6 +211,12 @@ class TimeLogDialog(QDialog):
act_row.addWidget(self.manage_activities_btn) act_row.addWidget(self.manage_activities_btn)
form.addRow(strings._("activity"), act_row) form.addRow(strings._("activity"), act_row)
# Optional Note
note_row = QHBoxLayout()
self.note = QLineEdit()
note_row.addWidget(self.note, 1)
form.addRow(strings._("note"), note_row)
# Hours (decimal) # Hours (decimal)
self.hours_spin = QDoubleSpinBox() self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.0, 24.0) self.hours_spin.setRange(0.0, 24.0)
@ -240,18 +246,20 @@ class TimeLogDialog(QDialog):
# --- Table of entries for this date # --- Table of entries for this date
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(3) self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("project"), strings._("project"),
strings._("activity"), strings._("activity"),
strings._("note"),
strings._("hours"), strings._("hours"),
] ]
) )
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( self.table.horizontalHeader().setSectionResizeMode(
2, QHeaderView.ResizeToContents 3, QHeaderView.ResizeToContents
) )
self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setSelectionMode(QAbstractItemView.SingleSelection)
@ -291,11 +299,13 @@ class TimeLogDialog(QDialog):
entry_id = r[0] entry_id = r[0]
project_name = r[3] project_name = r[3]
activity_name = r[5] activity_name = r[5]
note = r[7] or ""
minutes = r[6] minutes = r[6]
hours = minutes / 60.0 hours = minutes / 60.0
item_proj = QTableWidgetItem(project_name) item_proj = QTableWidgetItem(project_name)
item_act = QTableWidgetItem(activity_name) item_act = QTableWidgetItem(activity_name)
item_note = QTableWidgetItem(note)
item_hours = QTableWidgetItem(f"{hours:.2f}") item_hours = QTableWidgetItem(f"{hours:.2f}")
# store the entry id on the first column # store the entry id on the first column
@ -303,7 +313,8 @@ class TimeLogDialog(QDialog):
self.table.setItem(row_idx, 0, item_proj) self.table.setItem(row_idx, 0, item_proj)
self.table.setItem(row_idx, 1, item_act) self.table.setItem(row_idx, 1, item_act)
self.table.setItem(row_idx, 2, item_hours) self.table.setItem(row_idx, 2, item_note)
self.table.setItem(row_idx, 3, item_hours)
self._current_entry_id = None self._current_entry_id = None
self.delete_btn.setEnabled(False) self.delete_btn.setEnabled(False)
@ -338,6 +349,10 @@ class TimeLogDialog(QDialog):
) )
return return
note = self.note.text().strip()
if not note:
note = None
hours = float(self.hours_spin.value()) hours = float(self.hours_spin.value())
minutes = int(round(hours * 60)) minutes = int(round(hours * 60))
@ -346,16 +361,14 @@ class TimeLogDialog(QDialog):
if self._current_entry_id is None: if self._current_entry_id is None:
# New entry # New entry
self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes) self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes, note)
else: else:
# Update existing # Update existing
self._db.update_time_log( self._db.update_time_log(
self._current_entry_id, self._current_entry_id, proj_id, activity_id, minutes, note
proj_id,
activity_id,
minutes,
) )
self.note.setText("")
self._reload_entries() self._reload_entries()
def _on_row_selected(self) -> None: def _on_row_selected(self) -> None:
@ -369,7 +382,8 @@ class TimeLogDialog(QDialog):
row = items[0].row() row = items[0].row()
proj_item = self.table.item(row, 0) proj_item = self.table.item(row, 0)
act_item = self.table.item(row, 1) act_item = self.table.item(row, 1)
hours_item = self.table.item(row, 2) note_item = self.table.item(row, 2)
hours_item = self.table.item(row, 3)
entry_id = proj_item.data(Qt.ItemDataRole.UserRole) entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
self._current_entry_id = int(entry_id) self._current_entry_id = int(entry_id)
@ -379,6 +393,7 @@ class TimeLogDialog(QDialog):
# push values into the editors # push values into the editors
proj_name = proj_item.text() proj_name = proj_item.text()
act_name = act_item.text() act_name = act_item.text()
note = note_item.text()
hours = float(hours_item.text()) hours = float(hours_item.text())
# Set project combo by name # Set project combo by name
@ -387,6 +402,7 @@ class TimeLogDialog(QDialog):
self.project_combo.setCurrentIndex(idx) self.project_combo.setCurrentIndex(idx)
self.activity_edit.setText(act_name) self.activity_edit.setText(act_name)
self.note.setText(note)
self.hours_spin.setValue(hours) self.hours_spin.setValue(hours)
def _on_delete_entry(self) -> None: def _on_delete_entry(self) -> None:
@ -787,18 +803,20 @@ class TimeReportDialog(QDialog):
# Table # Table
self.table = QTableWidget() self.table = QTableWidget()
self.table.setColumnCount(3) self.table.setColumnCount(4)
self.table.setHorizontalHeaderLabels( self.table.setHorizontalHeaderLabels(
[ [
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("note"),
strings._("hours"), strings._("hours"),
] ]
) )
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( self.table.horizontalHeader().setSectionResizeMode(
2, QHeaderView.ResizeToContents 3, QHeaderView.ResizeToContents
) )
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
@ -833,14 +851,15 @@ class TimeReportDialog(QDialog):
rows = self._db.time_report(proj_id, start, end, gran) rows = self._db.time_report(proj_id, start, end, gran)
self._last_rows = rows self._last_rows = rows
self._last_total_minutes = sum(r[2] for r in rows) self._last_total_minutes = sum(r[3] for r in rows)
self.table.setRowCount(len(rows)) self.table.setRowCount(len(rows))
for i, (time_period, activity_name, minutes) in enumerate(rows): for i, (time_period, activity_name, note, minutes) in enumerate(rows):
hrs = minutes / 60.0 hrs = minutes / 60.0
self.table.setItem(i, 0, QTableWidgetItem(time_period)) self.table.setItem(i, 0, QTableWidgetItem(time_period))
self.table.setItem(i, 1, QTableWidgetItem(activity_name)) self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}")) self.table.setItem(i, 2, QTableWidgetItem(note))
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
total_hours = self._last_total_minutes / 60.0 total_hours = self._last_total_minutes / 60.0
self.total_label.setText( self.total_label.setText(
@ -874,14 +893,15 @@ class TimeReportDialog(QDialog):
[ [
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("note"),
strings._("hours"), strings._("hours"),
] ]
) )
# Data rows # Data rows
for time_period, activity_name, minutes in self._last_rows: for time_period, activity_name, note, minutes in self._last_rows:
hours = minutes / 60.0 hours = minutes / 60.0
writer.writerow([time_period, activity_name, f"{hours:.2f}"]) writer.writerow([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
@ -914,7 +934,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, minutes in self._last_rows: for 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())
@ -1015,7 +1035,7 @@ 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, minutes in self._last_rows: for 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>"

View file

@ -370,10 +370,10 @@ def test_time_report_by_day(fresh_db):
yesterday_act1 = next( yesterday_act1 = next(
r for r in report if r[0] == _yesterday() and r[1] == "Activity 1" r for r in report if r[0] == _yesterday() and r[1] == "Activity 1"
) )
assert yesterday_act1[2] == 60 assert yesterday_act1[3] == 60
today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1") today_act1 = next(r for r in report if r[0] == _today() and r[1] == "Activity 1")
assert today_act1[2] == 90 assert today_act1[3] == 90
def test_time_report_by_week(fresh_db): def test_time_report_by_week(fresh_db):
@ -396,9 +396,9 @@ def test_time_report_by_week(fresh_db):
assert len(report) == 2 assert len(report) == 2
# First week total # First week total
assert report[0][2] == 90 # 60 + 30 assert report[0][3] == 90 # 60 + 30
# Second week total # Second week total
assert report[1][2] == 45 assert report[1][3] == 45
def test_time_report_by_month(fresh_db): def test_time_report_by_month(fresh_db):
@ -422,11 +422,11 @@ def test_time_report_by_month(fresh_db):
# January total # January total
jan_row = next(r for r in report if r[0] == "2024-01") jan_row = next(r for r in report if r[0] == "2024-01")
assert jan_row[2] == 90 # 60 + 30 assert jan_row[3] == 90 # 60 + 30
# February total # February total
feb_row = next(r for r in report if r[0] == "2024-02") feb_row = next(r for r in report if r[0] == "2024-02")
assert feb_row[2] == 45 assert feb_row[3] == 45
def test_time_report_multiple_activities(fresh_db): def test_time_report_multiple_activities(fresh_db):
@ -443,10 +443,10 @@ def test_time_report_multiple_activities(fresh_db):
assert len(report) == 2 assert len(report) == 2
act1_row = next(r for r in report if r[1] == "Activity 1") act1_row = next(r for r in report if r[1] == "Activity 1")
assert act1_row[2] == 90 # 60 + 30 aggregated assert act1_row[3] == 90 # 60 + 30 aggregated
act2_row = next(r for r in report if r[1] == "Activity 2") act2_row = next(r for r in report if r[1] == "Activity 2")
assert act2_row[2] == 45 assert act2_row[3] == 45
def test_time_report_filters_by_project(fresh_db): def test_time_report_filters_by_project(fresh_db):
@ -461,7 +461,7 @@ def test_time_report_filters_by_project(fresh_db):
report = fresh_db.time_report(proj1_id, _today(), _today(), "day") report = fresh_db.time_report(proj1_id, _today(), _today(), "day")
assert len(report) == 1 assert len(report) == 1
assert report[0][2] == 60 assert report[0][3] == 60
def test_time_report_empty(fresh_db): def test_time_report_empty(fresh_db):
@ -658,8 +658,8 @@ def test_time_log_dialog_loads_existing_entries(qtbot, fresh_db):
assert "Project" in dialog.table.item(0, 0).text() assert "Project" in dialog.table.item(0, 0).text()
assert "Activity" in dialog.table.item(0, 1).text() assert "Activity" in dialog.table.item(0, 1).text()
assert ( assert (
"1.5" in dialog.table.item(0, 2).text() "1.5" in dialog.table.item(0, 3).text()
or "1.50" in dialog.table.item(0, 2).text() or "1.50" in dialog.table.item(0, 3).text()
) )
@ -725,8 +725,8 @@ def test_time_log_dialog_add_entry_success(qtbot, fresh_db):
assert dialog.table.rowCount() == 1 assert dialog.table.rowCount() == 1
assert "New Activity" in dialog.table.item(0, 1).text() assert "New Activity" in dialog.table.item(0, 1).text()
assert ( assert (
"2.5" in dialog.table.item(0, 2).text() "2.5" in dialog.table.item(0, 3).text()
or "2.50" in dialog.table.item(0, 2).text() or "2.50" in dialog.table.item(0, 3).text()
) )
@ -774,8 +774,8 @@ def test_time_log_dialog_update_entry(qtbot, fresh_db):
assert dialog.table.rowCount() == 1 assert dialog.table.rowCount() == 1
assert "Project 2" in dialog.table.item(0, 0).text() assert "Project 2" in dialog.table.item(0, 0).text()
assert ( assert (
"2.0" in dialog.table.item(0, 2).text() "2.0" in dialog.table.item(0, 3).text()
or "2.00" in dialog.table.item(0, 2).text() or "2.00" in dialog.table.item(0, 3).text()
) )
@ -1429,7 +1429,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, 2).text() hours_text = dialog.table.item(0, 3).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
@ -1457,7 +1457,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, 2).text() hours_text = dialog.table.item(0, 3).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
@ -2196,8 +2196,8 @@ def test_full_workflow_add_project_activity_log_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, 1).text()
assert ( assert (
"2.5" in report_dialog.table.item(0, 2).text() "2.5" in report_dialog.table.item(0, 3).text()
or "2.50" in report_dialog.table.item(0, 2).text() or "2.50" in report_dialog.table.item(0, 3).text()
) )
# 5. Export CSV # 5. Export CSV