Compare commits

..

No commits in common. "4db40e6b4b4eb833288d1974e9d8c6f0071201e4" and "ef10e0aab7dfeda39230e743ae65649e8ff92f5d" have entirely different histories.

3 changed files with 18 additions and 275 deletions

View file

@ -175,9 +175,9 @@
"add_project": "Add project", "add_project": "Add project",
"add_time_entry": "Add time entry", "add_time_entry": "Add time entry",
"time_period": "Time period", "time_period": "Time period",
"by_day": "by day", "by_day": "By day",
"by_month": "by month", "by_month": "By month",
"by_week": "by week", "by_week": "By week",
"date_range": "Date range", "date_range": "Date range",
"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?",
@ -187,7 +187,7 @@
"delete_project_title": "Delete project - are you sure?", "delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry", "delete_time_entry": "Delete time entry",
"group_by": "Group by", "group_by": "Group by",
"hours": "Hours", "hours_decimal": "Hours (in decimal format)",
"invalid_activity_message": "The activity is invalid", "invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity", "invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid", "invalid_project_message": "The project is invalid",
@ -219,21 +219,14 @@
"time_log_no_date": "Time log", "time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet", "time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report", "time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total time spent", "time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"title_key": "title", "title_key": "title",
"update_time_entry": "Update time entry", "update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:2f} hours", "time_report_total": "Total: {hours:2f} hours",
"export_csv": "Export CSV",
"no_report_title": "No report", "no_report_title": "No report",
"no_report_message": "Please run a report before exporting.", "no_report_message": "Please run a report before exporting.",
"total": "Total", "total": "Total",
"export_csv": "Export CSV",
"export_csv_error_title": "Export failed", "export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}", "export_csv_error_message": "Could not write CSV file:\n{error}"
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}"
} }

View file

@ -1,15 +1,12 @@
from __future__ import annotations from __future__ import annotations
import csv import csv
import html
from collections import defaultdict
from typing import Optional from typing import Optional
from sqlcipher3.dbapi2 import IntegrityError from sqlcipher3.dbapi2 import IntegrityError
from PySide6.QtCore import Qt, QDate, QUrl from PySide6.QtCore import Qt, QDate
from PySide6.QtGui import QPainter, QColor, QImage, QTextDocument, QPageLayout
from PySide6.QtPrintSupport import QPrinter
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
@ -100,8 +97,9 @@ class TimeLogWidget(QFrame):
def set_current_date(self, date_iso: str) -> None: def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso self._current_date = date_iso
self._reload_summary() if self.toggle_btn.isChecked():
if not self.toggle_btn.isChecked(): self._reload_summary()
else:
self.summary_label.setText(strings._("time_log_collapsed_hint")) self.summary_label.setText(strings._("time_log_collapsed_hint"))
# ----- internals --------------------------------------------------- # ----- internals ---------------------------------------------------
@ -112,33 +110,19 @@ class TimeLogWidget(QFrame):
if checked and self._current_date: if checked and self._current_date:
self._reload_summary() self._reload_summary()
def _update_title(self, total_hours: Optional[float]) -> None:
"""Update the header text, optionally including total hours."""
if total_hours is None:
self.toggle_btn.setText(strings._("time_log"))
else:
self.toggle_btn.setText(
strings._("time_log_with_total").format(hours=total_hours)
)
def _reload_summary(self) -> None: def _reload_summary(self) -> None:
if not self._current_date: if not self._current_date:
self._update_title(None)
self.summary_label.setText(strings._("time_log_no_date")) self.summary_label.setText(strings._("time_log_no_date"))
return return
rows = self._db.time_log_for_date(self._current_date) rows = self._db.time_log_for_date(self._current_date)
if not rows: if not rows:
self._update_title(None)
self.summary_label.setText(strings._("time_log_no_entries")) self.summary_label.setText(strings._("time_log_no_entries"))
return return
total_minutes = sum(r[6] for r in rows) # index 6 = minutes total_minutes = sum(r[6] for r in rows) # index 6 = minutes
total_hours = total_minutes / 60.0 total_hours = total_minutes / 60.0
# Update header with running total (visible even when collapsed)
self._update_title(total_hours)
# Per-project totals # Per-project totals
per_project: dict[str, int] = {} per_project: dict[str, int] = {}
for _, _, _, project_name, *_rest in rows: for _, _, _, project_name, *_rest in rows:
@ -214,7 +198,7 @@ class TimeLogDialog(QDialog):
self.hours_spin.setRange(0.0, 24.0) self.hours_spin.setRange(0.0, 24.0)
self.hours_spin.setDecimals(2) self.hours_spin.setDecimals(2)
self.hours_spin.setSingleStep(0.25) self.hours_spin.setSingleStep(0.25)
form.addRow(strings._("hours"), self.hours_spin) form.addRow(strings._("hours_decimal"), self.hours_spin)
root.addLayout(form) root.addLayout(form)
@ -227,13 +211,9 @@ class TimeLogDialog(QDialog):
self.delete_btn.clicked.connect(self._on_delete_entry) self.delete_btn.clicked.connect(self._on_delete_entry)
self.delete_btn.setEnabled(False) self.delete_btn.setEnabled(False)
self.report_btn = QPushButton(strings._("run_report"))
self.report_btn.clicked.connect(self._on_run_report)
btn_row.addStretch(1) btn_row.addStretch(1)
btn_row.addWidget(self.add_update_btn) btn_row.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn) btn_row.addWidget(self.delete_btn)
btn_row.addWidget(self.report_btn)
root.addLayout(btn_row) root.addLayout(btn_row)
# --- Table of entries for this date # --- Table of entries for this date
@ -243,7 +223,7 @@ class TimeLogDialog(QDialog):
[ [
strings._("project"), strings._("project"),
strings._("activity"), strings._("activity"),
strings._("hours"), strings._("hours_decimal"),
] ]
) )
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
@ -393,10 +373,6 @@ class TimeLogDialog(QDialog):
self._db.delete_time_log(self._current_entry_id) self._db.delete_time_log(self._current_entry_id)
self._reload_entries() self._reload_entries()
def _on_run_report(self) -> None:
dlg = TimeReportDialog(self._db, self)
dlg.exec()
# ----- Project / activity management ------------------------------- # ----- Project / activity management -------------------------------
def _manage_projects(self) -> None: def _manage_projects(self) -> None:
@ -727,10 +703,6 @@ class TimeReportDialog(QDialog):
# state for last run # state for last run
self._last_rows: list[tuple[str, str, int]] = [] self._last_rows: list[tuple[str, str, int]] = []
self._last_total_minutes: int = 0 self._last_total_minutes: int = 0
self._last_project_name: str = ""
self._last_start: str = ""
self._last_end: str = ""
self._last_gran_label: str = ""
self.setWindowTitle(strings._("time_log_report")) self.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400) self.resize(600, 400)
@ -774,13 +746,9 @@ class TimeReportDialog(QDialog):
export_btn = QPushButton(strings._("export_csv")) export_btn = QPushButton(strings._("export_csv"))
export_btn.clicked.connect(self._export_csv) export_btn.clicked.connect(self._export_csv)
pdf_btn = QPushButton(strings._("export_pdf"))
pdf_btn.clicked.connect(self._export_pdf)
run_row.addStretch(1) run_row.addStretch(1)
run_row.addWidget(run_btn) run_row.addWidget(run_btn)
run_row.addWidget(export_btn) run_row.addWidget(export_btn)
run_row.addWidget(pdf_btn)
root.addLayout(run_row) root.addLayout(run_row)
# Table # Table
@ -790,7 +758,7 @@ class TimeReportDialog(QDialog):
[ [
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("hours"), strings._("hours_decimal"),
] ]
) )
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
@ -822,13 +790,8 @@ class TimeReportDialog(QDialog):
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_end = end
self._last_gran_label = self.granularity.currentText()
rows = self._db.time_report(proj_id, start, end, gran) rows = self._db.time_report(proj_id, start, end, gran)
# rows: (time_period, activity_name, minutes)
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[2] for r in rows)
@ -872,7 +835,7 @@ class TimeReportDialog(QDialog):
[ [
strings._("time_period"), strings._("time_period"),
strings._("activity"), strings._("activity"),
strings._("hours"), strings._("hours_decimal"),
] ]
) )
@ -891,216 +854,3 @@ class TimeReportDialog(QDialog):
strings._("export_csv_error_title"), strings._("export_csv_error_title"),
strings._("export_csv_error_message").format(error=str(exc)), strings._("export_csv_error_message").format(error=str(exc)),
) )
def _export_pdf(self):
if not self._last_rows:
QMessageBox.information(
self,
strings._("no_report_title"),
strings._("no_report_message"),
)
return
filename, _ = QFileDialog.getSaveFileName(
self,
strings._("export_pdf"),
"",
"PDF Files (*.pdf);;All Files (*)",
)
if not filename:
return
# ---------- Build chart image (hours per period) ----------
per_period_minutes: dict[str, int] = defaultdict(int)
for period, _activity, minutes in self._last_rows:
per_period_minutes[period] += minutes
periods = sorted(per_period_minutes.keys())
chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white)
if periods:
painter = QPainter(chart)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
margin = 50
left = margin + 30 # extra space for Y labels
top = margin
right = chart_w - margin
bottom = chart_h - margin - 20 # room for X labels
width = right - left
height = bottom - top
painter.setPen(Qt.black)
# Y-axis label "Hours" above the axis
painter.drawText(
left - 50, # left of the axis
top - 30, # higher up so it doesn't touch the axis line
50,
20,
Qt.AlignRight | Qt.AlignVCenter,
strings._("hours"),
)
# Border
painter.drawRect(left, top, width, height)
max_hours = max(per_period_minutes[p] for p in periods) / 60.0
if max_hours > 0:
n = len(periods)
bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6
# Y-axis ticks (0, 1/3, 2/3, max)
num_ticks = 3
for i in range(num_ticks + 1):
val = max_hours * i / num_ticks
y_tick = bottom - int((val / max_hours) * height)
# small tick mark
painter.drawLine(left - 5, y_tick, left, y_tick)
# label to the left
painter.drawText(
left - 40,
y_tick - 7,
35,
14,
Qt.AlignRight | Qt.AlignVCenter,
f"{val:.1f}",
)
# Bars
painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen)
for i, period in enumerate(periods):
hours = per_period_minutes[period] / 60.0
bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0:
continue
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
y_top_bar = bottom - bar_h
painter.drawRect(x, y_top_bar, int(bar_width), bar_h)
# X labels after bars, in black
painter.setPen(Qt.black)
for i, period in enumerate(periods):
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
painter.drawText(
x,
bottom + 5,
int(bar_width),
20,
Qt.AlignHCenter | Qt.AlignTop,
period,
)
finally:
painter.end()
# ---------- Build HTML report ----------
project = html.escape(self._last_project_name or "")
start = html.escape(self._last_start or "")
end = html.escape(self._last_end or "")
gran = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0
# Table rows (period, activity, hours)
row_html_parts: list[str] = []
for period, activity, minutes in self._last_rows:
hours = minutes / 60.0
row_html_parts.append(
"<tr>"
f"<td>{html.escape(period)}</td>"
f"<td>{html.escape(activity)}</td>"
f"<td style='text-align:right'>{hours:.2f}</td>"
"</tr>"
)
rows_html = "\n".join(row_html_parts)
html_doc = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<style>
body {{
font-family: sans-serif;
font-size: 10pt;
}}
h1 {{
font-size: 16pt;
margin-bottom: 4pt;
}}
p.meta {{
margin-top: 0;
color: #555;
}}
img.chart {{
display: block;
margin: 12pt 0;
max-width: 100%;
height: auto;
}}
table {{
border-collapse: collapse;
width: 100%;
margin-top: 12pt;
}}
th, td {{
border: 1px solid #ccc;
padding: 4pt 6pt;
}}
th {{
background-color: #f0f0f0;
}}
td.hours {{
text-align: right;
}}
</style>
</head>
<body>
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
<p class="meta">
{html.escape(strings._("time_log_report_meta").format(
start=start, end=end, granularity=gran))}
</p>
<p><img src="chart" class="chart" /></p>
<table>
<tr>
<th>{html.escape(strings._("time_period"))}</th>
<th>{html.escape(strings._("activity"))}</th>
<th>{html.escape(strings._("hours"))}</th>
</tr>
{rows_html}
</table>
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
</body>
</html>
"""
# ---------- Render HTML to PDF ----------
printer = QPrinter(QPrinter.HighResolution)
printer.setOutputFormat(QPrinter.PdfFormat)
printer.setOutputFileName(filename)
printer.setPageOrientation(QPageLayout.Orientation.Landscape)
doc = QTextDocument()
# attach the chart image as a resource
doc.addResource(QTextDocument.ImageResource, QUrl("chart"), chart)
doc.setHtml(html_doc)
try:
doc.print_(printer)
except Exception as exc: # very defensive
QMessageBox.warning(
self,
strings._("export_pdf_error_title"),
strings._("export_pdf_error_message").format(error=str(exc)),
)

View file

@ -320,10 +320,10 @@ def test_tag_graph_dialog_on_positions_changed_updates_labels_and_halo(
pos = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=float) pos = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], dtype=float)
dlg._on_positions_changed(pos) dlg._on_positions_changed(pos)
# Each label should be slightly below its node (y + 0.15) # Each label should be slightly below its node (y + 0.30)
for i, label in enumerate(dlg._label_items): for i, label in enumerate(dlg._label_items):
assert label.pos().x() == pytest.approx(pos[i, 0]) assert label.pos().x() == pytest.approx(pos[i, 0])
assert label.pos().y() == pytest.approx(pos[i, 1] + 0.15) assert label.pos().y() == pytest.approx(pos[i, 1] + 0.30)
# Halo layer should receive the updated coordinates and our sizes/brushes # Halo layer should receive the updated coordinates and our sizes/brushes
assert captured["x"] == [1.0, 3.0, 5.0] assert captured["x"] == [1.0, 3.0, 5.0]