bouquin/bouquin/time_log.py
Miguel Jacq 9dc0a620be
All checks were successful
CI / test (push) Successful in 5m55s
Lint / test (push) Successful in 33s
Trivy / test (push) Successful in 23s
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.
2025-12-04 13:40:04 +11:00

1477 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import csv
import html
from collections import defaultdict
from datetime import datetime
from sqlcipher3.dbapi2 import IntegrityError
from typing import Optional
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 (
QCalendarWidget,
QDialog,
QDialogButtonBox,
QFrame,
QVBoxLayout,
QHBoxLayout,
QWidget,
QFileDialog,
QFormLayout,
QLabel,
QComboBox,
QLineEdit,
QDoubleSpinBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QAbstractItemView,
QHeaderView,
QTabWidget,
QListWidget,
QListWidgetItem,
QDateEdit,
QMessageBox,
QCompleter,
QToolButton,
QSizePolicy,
QStyle,
QInputDialog,
)
from .db import DBManager
from .theme import ThemeManager
from . import strings
class TimeLogWidget(QFrame):
"""
Collapsible per-page time log summary + button to open the full dialog.
Shown in the left sidebar above the Tags widget.
"""
def __init__(
self,
db: DBManager,
themes: ThemeManager | None = None,
parent: QWidget | None = None,
):
super().__init__(parent)
self._db = db
self._themes = themes
self._current_date: Optional[str] = None
self.setFrameShape(QFrame.StyledPanel)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
# Header (toggle + open dialog button)
self.toggle_btn = QToolButton()
self.toggle_btn.setText(strings._("time_log"))
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
self.toggle_btn.setCheckable(True)
self.toggle_btn.setChecked(False)
self.toggle_btn.setArrowType(Qt.RightArrow)
self.toggle_btn.clicked.connect(self._on_toggle)
self.log_btn = QToolButton()
self.log_btn.setText("")
self.log_btn.setToolTip(strings._("add_time_entry"))
self.log_btn.setAutoRaise(True)
self.log_btn.clicked.connect(self._open_dialog_log_only)
self.open_btn = QToolButton()
self.open_btn.setIcon(
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
)
self.open_btn.setToolTip(strings._("open_time_log"))
self.open_btn.setAutoRaise(True)
self.open_btn.clicked.connect(self._open_dialog)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.addWidget(self.toggle_btn)
header.addStretch(1)
header.addWidget(self.log_btn)
header.addWidget(self.open_btn)
# Body: simple summary label for the day
self.body = QWidget()
self.body_layout = QVBoxLayout(self.body)
self.body_layout.setContentsMargins(0, 4, 0, 0)
self.body_layout.setSpacing(4)
self.summary_label = QLabel(strings._("time_log_no_entries"))
self.summary_label.setWordWrap(True)
self.body_layout.addWidget(self.summary_label)
# Optional embedded Pomodoro timer widget lives underneath the summary.
self._pomodoro_widget: Optional[QWidget] = None
self.body.setVisible(False)
main = QVBoxLayout(self)
main.setContentsMargins(0, 0, 0, 0)
main.addLayout(header)
main.addWidget(self.body)
# ----- external API ------------------------------------------------
def set_current_date(self, date_iso: str) -> None:
self._current_date = date_iso
self._reload_summary()
if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint"))
def show_pomodoro_widget(self, widget: QWidget) -> None:
"""Embed Pomodoro timer widget in the body area."""
if self._pomodoro_widget is not None:
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = widget
self.body_layout.addWidget(widget)
widget.show()
# Ensure the body is visible so the timer is obvious
self.body.setVisible(True)
self.toggle_btn.setChecked(True)
self.toggle_btn.setArrowType(Qt.DownArrow)
def clear_pomodoro_widget(self) -> None:
"""Remove any embedded Pomodoro timer widget."""
if self._pomodoro_widget is None:
return
self.body_layout.removeWidget(self._pomodoro_widget)
self._pomodoro_widget.deleteLater()
self._pomodoro_widget = None
# ----- internals ---------------------------------------------------
def _on_toggle(self, checked: bool) -> None:
self.body.setVisible(checked)
self.toggle_btn.setArrowType(Qt.DownArrow if checked else Qt.RightArrow)
if checked and self._current_date:
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:
if not self._current_date:
self._update_title(None)
self.summary_label.setText(strings._("time_log_no_date"))
return
rows = self._db.time_log_for_date(self._current_date)
if not rows:
self._update_title(None)
self.summary_label.setText(strings._("time_log_no_entries"))
return
total_minutes = sum(r[6] for r in rows) # index 6 = minutes
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: dict[str, int] = {}
for _, _, _, project_name, *_rest in rows:
minutes = _rest[2] # activity_id, activity_name, minutes, note
per_project[project_name] = per_project.get(project_name, 0) + minutes
lines = [strings._("time_log_total_hours").format(hours=total_hours)]
for pname, mins in sorted(per_project.items()):
lines.append(f"- {pname}: {mins/60:.2f}h")
self.summary_label.setText("\n".join(lines))
def _open_dialog(self) -> None:
if not self._current_date:
return
dlg = TimeLogDialog(self._db, self._current_date, self, themes=self._themes)
dlg.exec()
# Always refresh summary + header totals
self._reload_summary()
if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint"))
def _open_dialog_log_only(self) -> None:
if not self._current_date:
return
dlg = TimeLogDialog(
self._db,
self._current_date,
self,
True,
themes=self._themes,
close_after_add=True,
)
dlg.exec()
# Always refresh summary + header totals
self._reload_summary()
if not self.toggle_btn.isChecked():
self.summary_label.setText(strings._("time_log_collapsed_hint"))
class TimeLogDialog(QDialog):
"""
Per-day time log dialog.
"""
def __init__(
self,
db: DBManager,
date_iso: str,
parent=None,
log_entry_only: bool | None = False,
themes: ThemeManager | None = None,
close_after_add: bool | None = False,
):
super().__init__(parent)
self._db = db
self._themes = themes
self._date_iso = date_iso
self._current_entry_id: Optional[int] = None
# Guard flag used when repopulating the table so we dont treat
# programmatic item changes as user edits.
self._reloading_entries: bool = False
self.total_hours = 0
self.close_after_add = close_after_add
self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.resize(900, 600)
root = QVBoxLayout(self)
# --- Top: date label + change-date button
date_row = QHBoxLayout()
self.date_label = QLabel(strings._("time_log_date_label").format(date=date_iso))
date_row.addWidget(self.date_label)
date_row.addStretch(1)
self.change_date_btn = QPushButton(strings._("time_log_change_date"))
self.change_date_btn.clicked.connect(self._on_change_date_clicked)
date_row.addWidget(self.change_date_btn)
root.addLayout(date_row)
# --- Project / activity / hours row
form = QFormLayout()
# Project
proj_row = QHBoxLayout()
self.project_combo = QComboBox()
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
self.manage_projects_btn.clicked.connect(self._manage_projects)
proj_row.addWidget(self.project_combo, 1)
proj_row.addWidget(self.manage_projects_btn)
form.addRow(strings._("project"), proj_row)
# Activity (free text with autocomplete)
act_row = QHBoxLayout()
self.activity_edit = QLineEdit()
self.manage_activities_btn = QPushButton(strings._("manage_activities"))
self.manage_activities_btn.clicked.connect(self._manage_activities)
act_row.addWidget(self.activity_edit, 1)
act_row.addWidget(self.manage_activities_btn)
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)
self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.0, 24.0)
self.hours_spin.setDecimals(2)
self.hours_spin.setSingleStep(0.25)
self.hours_spin.setValue(0.25)
form.addRow(strings._("hours"), self.hours_spin)
root.addLayout(form)
# --- Buttons for entry
btn_row = QHBoxLayout()
self.add_update_btn = QPushButton("&" + strings._("add_time_entry"))
self.add_update_btn.clicked.connect(self._on_add_or_update)
self.delete_btn = QPushButton("&" + strings._("delete_time_entry"))
self.delete_btn.clicked.connect(self._on_delete_entry)
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.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn)
btn_row.addWidget(self.report_btn)
root.addLayout(btn_row)
# --- Table of entries for this date
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("note"),
strings._("hours"),
strings._("created_at"),
]
)
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.ResizeToContents
)
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.itemSelectionChanged.connect(self._on_row_selected)
# When a cell is edited inline, commit the change back to the DB.
self.table.itemChanged.connect(self._on_table_item_changed)
root.addWidget(self.table, 1)
# --- Total time and Close button
close_row = QHBoxLayout()
self.total_label = QLabel(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
close_row.addWidget(self.total_label)
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
# Init data
self._reload_projects()
self._reload_activities()
self._reload_entries()
if log_entry_only:
self.delete_btn.hide()
self.report_btn.hide()
self.table.hide()
self.resize(self.sizeHint().width(), self.sizeHint().height())
# ----- Data loading ------------------------------------------------
def _reload_projects(self) -> None:
self.project_combo.clear()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
def _reload_activities(self) -> None:
activities = [name for _, name in self._db.list_activities()]
completer = QCompleter(activities, self.activity_edit)
completer.setCaseSensitivity(Qt.CaseInsensitive)
self.activity_edit.setCompleter(completer)
def _reload_entries(self) -> None:
"""Reload the table from the database.
While we are repopulating the QTableWidget we temporarily disable the
itemChanged handler so that programmatic changes do not get written
back to the database.
"""
self._reloading_entries = True
try:
rows = self._db.time_log_for_date(self._date_iso)
self.table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
entry_id = r[0]
project_name = r[3]
activity_name = r[5]
note = r[7] or ""
minutes = r[6]
hours = minutes / 60.0
created_at = r[8]
ca_utc = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
ca_local = ca_utc.astimezone()
created = f"{ca_local.day} {ca_local.strftime('%b %Y, %H:%M%p')}"
item_proj = QTableWidgetItem(project_name)
item_act = QTableWidgetItem(activity_name)
item_note = QTableWidgetItem(note)
item_hours = QTableWidgetItem(f"{hours:.2f}")
item_created_at = QTableWidgetItem(created)
# store the entry id on the first column
item_proj.setData(Qt.ItemDataRole.UserRole, entry_id)
self.table.setItem(row_idx, 0, item_proj)
self.table.setItem(row_idx, 1, item_act)
self.table.setItem(row_idx, 2, item_note)
self.table.setItem(row_idx, 3, item_hours)
self.table.setItem(row_idx, 4, item_created_at)
finally:
self._reloading_entries = False
total_minutes = sum(r[6] for r in rows)
self.total_hours = total_minutes / 60.0
self.total_label.setText(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
self._current_entry_id = None
self.delete_btn.setEnabled(False)
self.add_update_btn.setText("&" + strings._("add_time_entry"))
# ----- Actions -----------------------------------------------------
def _on_change_date_clicked(self) -> None:
"""Let the user choose a different date and reload entries."""
# Start from current dialog date; fall back to today if invalid
current_qdate = QDate.fromString(self._date_iso, Qt.ISODate)
if not current_qdate.isValid():
current_qdate = QDate.currentDate()
dlg = QDialog(self)
dlg.setWindowTitle(strings._("time_log_select_date_title"))
layout = QVBoxLayout(dlg)
calendar = QCalendarWidget(dlg)
calendar.setSelectedDate(current_qdate)
layout.addWidget(calendar)
# Apply the same theming as the main sidebar calendar
if self._themes is not None:
self._themes.register_calendar(calendar)
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel, parent=dlg
)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec() != QDialog.Accepted:
return
new_qdate = calendar.selectedDate()
new_iso = new_qdate.toString(Qt.ISODate)
if new_iso == self._date_iso:
# No change
return
# Update state
self._date_iso = new_iso
# Update window title and header label
self.setWindowTitle(strings._("time_log_for").format(date=new_iso))
self.date_label.setText(strings._("time_log_date_label").format(date=new_iso))
# Reload entries for the newly selected date
self._reload_entries()
def _ensure_project_id(self) -> Optional[int]:
"""Get selected project_id from combo."""
idx = self.project_combo.currentIndex()
if idx < 0:
return None
proj_id = self.project_combo.itemData(idx)
return int(proj_id) if proj_id is not None else None
def _on_add_or_update(self) -> None:
proj_id = self._ensure_project_id()
if proj_id is None:
QMessageBox.warning(
self,
strings._("project_required_title"),
strings._("project_required_message"),
)
return
activity_name = self.activity_edit.text().strip()
if not activity_name:
QMessageBox.warning(
self,
strings._("activity_required_title"),
strings._("activity_required_message"),
)
return
note = self.note.text().strip()
if not note:
note = None
hours = float(self.hours_spin.value())
minutes = int(round(hours * 60))
# Create activity if needed
activity_id = self._db.add_activity(activity_name)
if self._current_entry_id is None:
# New entry
self._db.add_time_log(self._date_iso, proj_id, activity_id, minutes, note)
else:
# Update existing
self._db.update_time_log(
self._current_entry_id, proj_id, activity_id, minutes, note
)
self._reload_entries()
if self.close_after_add:
self.close()
def _on_row_selected(self) -> None:
items = self.table.selectedItems()
if not items:
self._current_entry_id = None
self.delete_btn.setEnabled(False)
self.add_update_btn.setText("&" + strings._("add_time_entry"))
return
row = items[0].row()
proj_item = self.table.item(row, 0)
act_item = self.table.item(row, 1)
note_item = self.table.item(row, 2)
hours_item = self.table.item(row, 3)
entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
self._current_entry_id = int(entry_id)
self.delete_btn.setEnabled(True)
self.add_update_btn.setText("&" + strings._("update_time_entry"))
# push values into the editors
proj_name = proj_item.text()
act_name = act_item.text()
note = note_item.text()
hours = float(hours_item.text())
# Set project combo by name
idx = self.project_combo.findText(proj_name, Qt.MatchFixedString)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
self.activity_edit.setText(act_name)
self.note.setText(note)
self.hours_spin.setValue(hours)
def _on_table_item_changed(self, item: QTableWidgetItem) -> None:
"""Commit inline edits in the table back to the database.
Editing a cell should behave like selecting that row and pressing
the Add/Update button, so we reuse the same validation and DB logic.
"""
if self._reloading_entries:
# Ignore changes that come from _reload_entries().
return
if item is None: # pragma: no cover
return
row = item.row()
proj_item = self.table.item(row, 0)
act_item = self.table.item(row, 1)
note_item = self.table.item(row, 2)
hours_item = self.table.item(row, 3)
if proj_item is None or act_item is None or hours_item is None:
# Incomplete row nothing to do.
return
# Recover the entry id from the hidden UserRole on the project cell
entry_id = proj_item.data(Qt.ItemDataRole.UserRole)
self._current_entry_id = int(entry_id) if entry_id is not None else None
# Push values into the editors (similar to _on_row_selected).
proj_name = proj_item.text()
act_name = act_item.text()
note_text = note_item.text() if note_item is not None else ""
hours_text = hours_item.text()
# Set project combo by name, creating a project on the fly if needed.
idx = self.project_combo.findText(proj_name, Qt.MatchFixedString)
if idx < 0 and proj_name:
# Allow creating a new project directly from the table.
proj_id = self._db.add_project(proj_name)
self._reload_projects()
idx = self.project_combo.findData(proj_id)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
else:
self.project_combo.setCurrentIndex(-1)
self.activity_edit.setText(act_name)
self.note.setText(note_text)
# Parse hours; if invalid, show the same style of warning as elsewhere.
try:
hours = float(hours_text)
except ValueError:
QMessageBox.warning(
self,
strings._("invalid_time_title"),
strings._("invalid_time_message"),
)
# Reset table back to the last known-good state.
self._reload_entries()
return
self.hours_spin.setValue(hours)
# Mirror button state to reflect whether we're updating or adding.
if self._current_entry_id is None:
self.delete_btn.setEnabled(False)
self.add_update_btn.setText(strings._("add_time_entry"))
else:
self.delete_btn.setEnabled(True)
self.add_update_btn.setText(strings._("update_time_entry"))
# Finally, reuse the existing validation + DB logic.
self._on_add_or_update()
def _on_delete_entry(self) -> None:
if self._current_entry_id is None:
return
self._db.delete_time_log(self._current_entry_id)
self._reload_entries()
def _on_run_report(self) -> None:
dlg = TimeReportDialog(self._db, self)
dlg.exec()
# ----- Project / activity management -------------------------------
def _manage_projects(self) -> None:
dlg = TimeCodeManagerDialog(self._db, focus_tab="projects", parent=self)
dlg.exec()
self._reload_projects()
def _manage_activities(self) -> None:
dlg = TimeCodeManagerDialog(self._db, focus_tab="activities", parent=self)
dlg.exec()
self._reload_activities()
class TimeCodeManagerDialog(QDialog):
"""
Dialog to manage projects and activities, similar spirit to Tag Browser:
Add / rename / delete without having to log time.
"""
def __init__(self, db: DBManager, focus_tab: str = "projects", parent=None):
super().__init__(parent)
self._db = db
self.setWindowTitle(strings._("manage_projects_activities"))
self.resize(500, 400)
root = QVBoxLayout(self)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
# Projects tab
proj_tab = QWidget()
proj_layout = QVBoxLayout(proj_tab)
self.project_list = QListWidget()
proj_layout.addWidget(self.project_list, 1)
proj_btn_row = QHBoxLayout()
self.proj_add_btn = QPushButton("&" + strings._("add_project"))
self.proj_rename_btn = QPushButton("&" + strings._("rename_project"))
self.proj_delete_btn = QPushButton("&" + strings._("delete_project"))
proj_btn_row.addWidget(self.proj_add_btn)
proj_btn_row.addWidget(self.proj_rename_btn)
proj_btn_row.addWidget(self.proj_delete_btn)
proj_layout.addLayout(proj_btn_row)
self.tabs.addTab(proj_tab, "&" + strings._("projects"))
# Activities tab
act_tab = QWidget()
act_layout = QVBoxLayout(act_tab)
self.activity_list = QListWidget()
act_layout.addWidget(self.activity_list, 1)
act_btn_row = QHBoxLayout()
self.act_add_btn = QPushButton("&" + strings._("add_activity"))
self.act_rename_btn = QPushButton("&" + strings._("rename_activity"))
self.act_delete_btn = QPushButton("&" + strings._("delete_activity"))
act_btn_row.addWidget(self.act_add_btn)
act_btn_row.addWidget(self.act_rename_btn)
act_btn_row.addWidget(self.act_delete_btn)
act_layout.addLayout(act_btn_row)
self.tabs.addTab(act_tab, strings._("activities"))
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
close_row.addWidget(close_btn)
root.addLayout(close_row)
# Wire
self.proj_add_btn.clicked.connect(self._add_project)
self.proj_rename_btn.clicked.connect(self._rename_project)
self.proj_delete_btn.clicked.connect(self._delete_project)
self.act_add_btn.clicked.connect(self._add_activity)
self.act_rename_btn.clicked.connect(self._rename_activity)
self.act_delete_btn.clicked.connect(self._delete_activity)
# Initial data
self._reload_projects()
self._reload_activities()
if focus_tab == "activities":
self.tabs.setCurrentIndex(1)
def _reload_projects(self):
self.project_list.clear()
for proj_id, name in self._db.list_projects():
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, proj_id)
self.project_list.addItem(item)
def _reload_activities(self):
self.activity_list.clear()
for act_id, name in self._db.list_activities():
item = QListWidgetItem(name)
item.setData(Qt.ItemDataRole.UserRole, act_id)
self.activity_list.addItem(item)
# ---------- helpers ------------------------------------------------
def _prompt_name(
self,
title_key: str,
label_key: str,
default: str = "",
) -> tuple[str, bool]:
"""Wrapper around QInputDialog.getText with i18n keys."""
title = strings._(title_key)
label = strings._(label_key)
text, ok = QInputDialog.getText(
self,
title,
label,
QLineEdit.EchoMode.Normal,
default,
)
return text.strip(), ok
def _selected_project_item(self) -> QListWidgetItem | None:
items = self.project_list.selectedItems()
return items[0] if items else None
def _selected_activity_item(self) -> QListWidgetItem | None:
items = self.activity_list.selectedItems()
return items[0] if items else None
# ---------- projects -----------------------------------------------
def _add_project(self) -> None:
name, ok = self._prompt_name(
"add_project_title",
"add_project_label",
"",
)
if not ok or not name:
return
try:
self._db.add_project(name)
except ValueError:
# Empty / invalid name nothing to do, but be defensive
QMessageBox.warning(
self,
strings._("invalid_project_title"),
strings._("invalid_project_message"),
)
return
self._reload_projects()
def _rename_project(self) -> None:
item = self._selected_project_item()
if item is None:
QMessageBox.information(
self,
strings._("select_project_title"),
strings._("select_project_message"),
)
return
old_name = item.text()
proj_id = int(item.data(Qt.ItemDataRole.UserRole))
new_name, ok = self._prompt_name(
"rename_project_title",
"rename_project_label",
old_name,
)
if not ok or not new_name or new_name == old_name:
return
try:
self._db.rename_project(proj_id, new_name)
except IntegrityError as exc:
QMessageBox.warning(
self,
strings._("project_rename_error_title"),
strings._("project_rename_error_message").format(error=str(exc)),
)
return
self._reload_projects()
def _delete_project(self) -> None:
item = self._selected_project_item()
if item is None:
QMessageBox.information(
self,
strings._("select_project_title"),
strings._("select_project_message"),
)
return
proj_id = int(item.data(Qt.ItemDataRole.UserRole))
name = item.text()
resp = QMessageBox.question(
self,
strings._("delete_project_title"),
strings._("delete_project_confirm").format(name=name),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
return
try:
self._db.delete_project(proj_id)
except IntegrityError:
# Likely FK constraint: project has time entries
QMessageBox.warning(
self,
strings._("project_delete_error_title"),
strings._("project_delete_error_message"),
)
return
self._reload_projects()
# ---------- activities ---------------------------------------------
def _add_activity(self) -> None:
name, ok = self._prompt_name(
"add_activity_title",
"add_activity_label",
"",
)
if not ok or not name:
return
try:
self._db.add_activity(name)
except ValueError:
QMessageBox.warning(
self,
strings._("invalid_activity_title"),
strings._("invalid_activity_message"),
)
return
self._reload_activities()
def _rename_activity(self) -> None:
item = self._selected_activity_item()
if item is None:
QMessageBox.information(
self,
strings._("select_activity_title"),
strings._("select_activity_message"),
)
return
old_name = item.text()
act_id = int(item.data(Qt.ItemDataRole.UserRole))
new_name, ok = self._prompt_name(
"rename_activity_title",
"rename_activity_label",
old_name,
)
if not ok or not new_name or new_name == old_name:
return
try:
self._db.rename_activity(act_id, new_name)
except IntegrityError as exc:
QMessageBox.warning(
self,
strings._("activity_rename_error_title"),
strings._("activity_rename_error_message").format(error=str(exc)),
)
return
self._reload_activities()
def _delete_activity(self) -> None:
item = self._selected_activity_item()
if item is None:
QMessageBox.information(
self,
strings._("select_activity_title"),
strings._("select_activity_message"),
)
return
act_id = int(item.data(Qt.ItemDataRole.UserRole))
name = item.text()
resp = QMessageBox.question(
self,
strings._("delete_activity_title"),
strings._("delete_activity_confirm").format(name=name),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if resp != QMessageBox.StandardButton.Yes:
return
try:
self._db.delete_activity(act_id)
except IntegrityError:
# Activity is referenced by time_log
QMessageBox.warning(
self,
strings._("activity_delete_error_title"),
strings._("activity_delete_error_message"),
)
return
self._reload_activities()
class TimeReportDialog(QDialog):
"""
Simple report: choose project + date range + granularity (day/week/month).
Shows decimal hours per time period.
"""
def __init__(self, db: DBManager, parent=None):
super().__init__(parent)
self._db = db
# state for last run
self._last_rows: list[tuple[str, str, int]] = []
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.resize(600, 400)
root = QVBoxLayout(self)
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()
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")
self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month")
form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form)
# Run and + export buttons
run_row = QHBoxLayout()
run_btn = QPushButton(strings._("run_report"))
run_btn.clicked.connect(self._run_report)
export_btn = QPushButton(strings._("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.addWidget(run_btn)
run_row.addWidget(export_btn)
run_row.addWidget(pdf_btn)
root.addLayout(run_row)
# Table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("time_period"),
strings._("activity"),
strings._("note"),
strings._("hours"),
]
)
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(
4, QHeaderView.ResizeToContents
)
root.addWidget(self.table, 1)
# Total label
self.total_label = QLabel("")
root.addWidget(self.total_label)
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
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_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()
self._last_start = start
self._last_end = end
self._last_gran_label = self.granularity.currentText()
rows_for_table: list[tuple[str, str, str, str, int]] = []
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
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(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
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:
QMessageBox.information(
self,
strings._("no_report_title"),
strings._("no_report_message"),
)
return
filename, _ = QFileDialog.getSaveFileName(
self,
strings._("export_csv"),
"",
"CSV Files (*.csv);;All Files (*)",
)
if not filename:
return
if not filename.endswith(".csv"):
filename = f"{filename}.csv"
try:
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
# Header
writer.writerow(
[
strings._("project"),
strings._("time_period"),
strings._("activity"),
strings._("note"),
strings._("hours"),
]
)
# Data rows
for (
project,
time_period,
activity_name,
note,
minutes,
) in self._last_rows:
hours = minutes / 60.0
writer.writerow(
[project, time_period, activity_name, note, f"{hours:.2f}"]
)
# Blank line + total
total_hours = self._last_total_minutes / 60.0
writer.writerow([])
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
except OSError as exc:
QMessageBox.warning(
self,
strings._("export_csv_error_title"),
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
if not filename.endswith(".pdf"):
filename = f"{filename}.pdf"
# ---------- Build chart image (hours per period) ----------
per_period_minutes: dict[str, int] = defaultdict(int)
for _project, period, _activity, note, 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 # pragma: no cover
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 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>"
"</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._("project"))}</th>
<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)),
)