Add PDF reporting for time log tool, improve totals in widget
This commit is contained in:
parent
ef10e0aab7
commit
85e2a93199
2 changed files with 273 additions and 16 deletions
|
|
@ -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_decimal": "Hours (in decimal format)",
|
"hours": "Hours",
|
||||||
"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,14 +219,21 @@
|
||||||
"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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
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
|
from PySide6.QtCore import Qt, QDate, QUrl
|
||||||
|
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,
|
||||||
|
|
@ -97,9 +100,8 @@ 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
|
||||||
if self.toggle_btn.isChecked():
|
|
||||||
self._reload_summary()
|
self._reload_summary()
|
||||||
else:
|
if not self.toggle_btn.isChecked():
|
||||||
self.summary_label.setText(strings._("time_log_collapsed_hint"))
|
self.summary_label.setText(strings._("time_log_collapsed_hint"))
|
||||||
|
|
||||||
# ----- internals ---------------------------------------------------
|
# ----- internals ---------------------------------------------------
|
||||||
|
|
@ -110,19 +112,33 @@ 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:
|
||||||
|
|
@ -198,7 +214,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_decimal"), self.hours_spin)
|
form.addRow(strings._("hours"), self.hours_spin)
|
||||||
|
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
||||||
|
|
@ -211,9 +227,13 @@ 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
|
||||||
|
|
@ -223,7 +243,7 @@ class TimeLogDialog(QDialog):
|
||||||
[
|
[
|
||||||
strings._("project"),
|
strings._("project"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
strings._("hours_decimal"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
|
@ -373,6 +393,10 @@ 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:
|
||||||
|
|
@ -703,6 +727,10 @@ 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)
|
||||||
|
|
@ -746,9 +774,13 @@ 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
|
||||||
|
|
@ -758,7 +790,7 @@ class TimeReportDialog(QDialog):
|
||||||
[
|
[
|
||||||
strings._("time_period"),
|
strings._("time_period"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
strings._("hours_decimal"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
|
@ -790,8 +822,13 @@ 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)
|
||||||
|
|
@ -835,7 +872,7 @@ class TimeReportDialog(QDialog):
|
||||||
[
|
[
|
||||||
strings._("time_period"),
|
strings._("time_period"),
|
||||||
strings._("activity"),
|
strings._("activity"),
|
||||||
strings._("hours_decimal"),
|
strings._("hours"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -854,3 +891,216 @@ 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)),
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue