Initial work on time logging
Some checks failed
CI / test (push) Has been cancelled
Lint / test (push) Has been cancelled
Trivy / test (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2025-11-18 21:51:04 +11:00
parent 83f25405db
commit 55b78833ac
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 1199 additions and 10 deletions

View file

@ -1,4 +1,4 @@
# 0.3.3
# 0.4
* Remove screenshot tool
* Improve width of bug report dialog
@ -6,6 +6,7 @@
* Improve size of checkboxes
* Convert bullet - to actual unicode bullets
* Add alarm option to set reminders
* Add time logging and reporting
# 0.3.2

View file

@ -48,6 +48,7 @@ report from within the app.
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
* English, French and Italian locales provided
* Ability to set reminder alarms in the app against the current line of text on today's date
* Ability to log time per day and run timesheet reports
## How to install

View file

@ -17,6 +17,18 @@ from . import strings
Entry = Tuple[str, str]
TagRow = Tuple[int, str, str]
ProjectRow = Tuple[int, str] # (id, name)
ActivityRow = Tuple[int, str] # (id, name)
TimeLogRow = Tuple[
int, # id
str, # page_date (yyyy-MM-dd)
int,
str, # project_id, project_name
int,
str, # activity_id, activity_name
int, # minutes
str | None, # note
]
_TAG_COLORS = [
"#FFB3BA", # soft red
@ -148,6 +160,38 @@ class DBManager:
);
CREATE INDEX IF NOT EXISTS ix_page_tags_tag_id ON page_tags(tag_id);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS time_log (
id INTEGER PRIMARY KEY,
page_date TEXT NOT NULL, -- FK to pages.date (yyyy-MM-dd)
project_id INTEGER NOT NULL, -- FK to projects.id
activity_id INTEGER NOT NULL, -- FK to activities.id
minutes INTEGER NOT NULL, -- duration in minutes
note TEXT,
created_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
FOREIGN KEY(page_date) REFERENCES pages(date) ON DELETE CASCADE,
FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE RESTRICT,
FOREIGN KEY(activity_id) REFERENCES activities(id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS ix_time_log_date
ON time_log(page_date);
CREATE INDEX IF NOT EXISTS ix_time_log_project
ON time_log(project_id);
CREATE INDEX IF NOT EXISTS ix_time_log_activity
ON time_log(activity_id);
"""
)
self.conn.commit()
@ -789,6 +833,216 @@ class DBManager:
edges = [(r["tag1"], r["tag2"], r["c"]) for r in rows]
return tags_by_id, edges, tag_page_counts
# -------- Time logging: projects & activities ---------------------
def list_projects(self) -> list[ProjectRow]:
cur = self.conn.cursor()
rows = cur.execute(
"SELECT id, name FROM projects ORDER BY LOWER(name);"
).fetchall()
return [(r["id"], r["name"]) for r in rows]
def add_project(self, name: str) -> int:
name = name.strip()
if not name:
raise ValueError("empty project name")
with self.conn:
cur = self.conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO projects(name) VALUES (?);",
(name,),
)
row = cur.execute(
"SELECT id, name FROM projects WHERE name = ?;",
(name,),
).fetchone()
return row["id"]
def rename_project(self, project_id: int, new_name: str) -> None:
new_name = new_name.strip()
if not new_name:
return
with self.conn:
self.conn.execute(
"UPDATE projects SET name = ? WHERE id = ?;",
(new_name, project_id),
)
def delete_project(self, project_id: int) -> None:
with self.conn:
self.conn.execute(
"DELETE FROM projects WHERE id = ?;",
(project_id,),
)
def list_activities(self) -> list[ActivityRow]:
cur = self.conn.cursor()
rows = cur.execute(
"SELECT id, name FROM activities ORDER BY LOWER(name);"
).fetchall()
return [(r["id"], r["name"]) for r in rows]
def add_activity(self, name: str) -> int:
name = name.strip()
if not name:
raise ValueError("empty activity name")
with self.conn:
cur = self.conn.cursor()
cur.execute(
"INSERT OR IGNORE INTO activities(name) VALUES (?);",
(name,),
)
row = cur.execute(
"SELECT id, name FROM activities WHERE name = ?;",
(name,),
).fetchone()
return row["id"]
def rename_activity(self, activity_id: int, new_name: str) -> None:
new_name = new_name.strip()
if not new_name:
return
with self.conn:
self.conn.execute(
"UPDATE activities SET name = ? WHERE id = ?;",
(new_name, activity_id),
)
def delete_activity(self, activity_id: int) -> None:
with self.conn:
self.conn.execute(
"DELETE FROM activities WHERE id = ?;",
(activity_id,),
)
# -------- Time logging: entries -----------------------------------
def add_time_log(
self,
date_iso: str,
project_id: int,
activity_id: int,
minutes: int,
note: str | None = None,
) -> int:
with self.conn:
cur = self.conn.cursor()
# Ensure a page row exists even if there is no text content yet
cur.execute("INSERT OR IGNORE INTO pages(date) VALUES (?);", (date_iso,))
cur.execute(
"""
INSERT INTO time_log(page_date, project_id, activity_id, minutes, note)
VALUES (?, ?, ?, ?, ?);
""",
(date_iso, project_id, activity_id, minutes, note),
)
return cur.lastrowid
def update_time_log(
self,
entry_id: int,
project_id: int,
activity_id: int,
minutes: int,
note: str | None = None,
) -> None:
with self.conn:
self.conn.execute(
"""
UPDATE time_log
SET project_id = ?, activity_id = ?, minutes = ?, note = ?
WHERE id = ?;
""",
(project_id, activity_id, minutes, note, entry_id),
)
def delete_time_log(self, entry_id: int) -> None:
with self.conn:
self.conn.execute(
"DELETE FROM time_log WHERE id = ?;",
(entry_id,),
)
def time_log_for_date(self, date_iso: str) -> list[TimeLogRow]:
cur = self.conn.cursor()
rows = cur.execute(
"""
SELECT
t.id,
t.page_date,
t.project_id,
p.name AS project_name,
t.activity_id,
a.name AS activity_name,
t.minutes,
t.note
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 = ?
ORDER BY LOWER(p.name), LOWER(a.name), t.id;
""",
(date_iso,),
).fetchall()
result: list[TimeLogRow] = []
for r in rows:
result.append(
(
r["id"],
r["page_date"],
r["project_id"],
r["project_name"],
r["activity_id"],
r["activity_name"],
r["minutes"],
r["note"],
)
)
return result
def time_report(
self,
project_id: int,
start_date_iso: str,
end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month'
) -> list[tuple[str, str, int]]:
"""
Return (time_period, activity_name, total_minutes) tuples between start and end
for a project, grouped by period and activity.
time_period is:
- 'YYYY-MM-DD' for day
- 'YYYY-WW' for week
- 'YYYY-MM' for month
"""
if granularity == "day":
bucket_expr = "page_date"
elif granularity == "week":
# ISO-like year-week; SQLite weeks start at 00
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
{bucket_expr} AS bucket,
a.name AS activity_name,
SUM(t.minutes) AS total_minutes
FROM time_log t
JOIN activities a ON a.id = t.activity_id
WHERE t.project_id = ?
AND t.page_date BETWEEN ? AND ?
GROUP BY bucket, activity_name
ORDER BY bucket, LOWER(activity_name);
""", # nosec B608: bucket_expr comes from a fixed internal list
(project_id, start_date_iso, end_date_iso),
).fetchall()
return [(r["bucket"], r["activity_name"], r["total_minutes"]) for r in rows]
def close(self) -> None:
if self.conn is not None:
self.conn.close()

View file

@ -162,5 +162,71 @@
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm"
"toolbar_alarm": "Set reminder alarm",
"activities": "Activities",
"activity": "Activity",
"activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required",
"add_activity": "Add activity",
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
"by_day": "By day",
"by_month": "By month",
"by_week": "By week",
"date_range": "Date range",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
"delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours_decimal": "Hours (in decimal format)",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
"label_key": "Label",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log",
"project": "Project",
"project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required",
"project_required_title": "Project required",
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"run_report": "Run report",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a projecT",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"time_log_date_label": "Time log date: {date}",
"time_log_for": "Time log for {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
"time_log_total_hours": "Total time spent",
"title_key": "title",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:2f} hours",
"export_csv": "Export CSV",
"no_report_title": "No report",
"no_report_message": "Please run a report before exporting.",
"total": "Total",
"export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}"
}

View file

@ -51,22 +51,23 @@ from PySide6.QtWidgets import (
QApplication,
)
from .bug_report_dialog import BugReportDialog
from .db import DBManager
from .markdown_editor import MarkdownEditor
from .find_bar import FindBar
from .history_dialog import HistoryDialog
from .key_prompt import KeyPrompt
from .lock_overlay import LockOverlay
from .markdown_editor import MarkdownEditor
from .save_dialog import SaveDialog
from .search import Search
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
from .settings_dialog import SettingsDialog
from .statistics_dialog import StatisticsDialog
from .bug_report_dialog import BugReportDialog
from . import strings
from .tags_widget import PageTagsWidget
from .toolbar import ToolBar
from .theme import ThemeManager
from .time_log import TimeLogWidget, TimeReportDialog
from .toolbar import ToolBar
class MainWindow(QMainWindow):
@ -102,6 +103,8 @@ class MainWindow(QMainWindow):
self.search.openDateRequested.connect(self._load_selected_date)
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
self.time_log = TimeLogWidget(self.db)
self.tags = PageTagsWidget(self.db)
self.tags.tagActivated.connect(self._on_tag_activated)
self.tags.tagAdded.connect(self._on_tag_added)
@ -113,6 +116,7 @@ class MainWindow(QMainWindow):
left_layout.setContentsMargins(8, 8, 8, 8)
left_layout.addWidget(self.calendar)
left_layout.addWidget(self.search)
left_layout.addWidget(self.time_log)
left_layout.addWidget(self.tags)
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
@ -223,6 +227,9 @@ class MainWindow(QMainWindow):
act_stats.setShortcut("Shift+Ctrl+S")
act_stats.triggered.connect(self._open_statistics)
file_menu.addAction(act_stats)
act_time_report = QAction(strings._("time_log_report"), self)
act_time_report.triggered.connect(self._open_time_report)
file_menu.addAction(act_time_report)
file_menu.addSeparator()
act_quit = QAction("&" + strings._("quit"), self)
act_quit.setShortcut("Ctrl+Q")
@ -1042,10 +1049,10 @@ class MainWindow(QMainWindow):
# Use the current line in the markdown editor as the reminder text
try:
line_text = editor.get_current_line_text().strip()
editor.get_current_line_text().strip()
except AttributeError:
c = editor.textCursor()
line_text = c.block().text().strip()
c.block().text().strip()
# Ask user for a time today in HH:MM format
time_str, ok = QInputDialog.getText(
@ -1066,9 +1073,6 @@ class MainWindow(QMainWindow):
)
return
now = QDateTime.currentDateTime()
target = QDateTime(now.date(), QTime(hour, minute))
t = QTime(hour, minute)
if not t.isValid():
QMessageBox.warning(
@ -1236,6 +1240,8 @@ class MainWindow(QMainWindow):
def _update_tag_views_for_date(self, date_iso: str):
if hasattr(self, "tags"):
self.tags.set_current_date(date_iso)
if hasattr(self, "time_log"):
self.time_log.set_current_date(date_iso)
def _on_tag_added(self):
"""Called when a tag is added - trigger autosave for current page"""
@ -1338,6 +1344,11 @@ class MainWindow(QMainWindow):
dlg._heatmap.date_clicked.connect(on_date_clicked)
dlg.exec()
# ------------ Timesheet report handler --------------- #
def _open_time_report(self):
dlg = TimeReportDialog(self.db, self)
dlg.exec()
# ------------ Window positioning --------------- #
def _restore_window_position(self):
geom = self.settings.value("main/geometry", None)

856
bouquin/time_log.py Normal file
View file

@ -0,0 +1,856 @@
from __future__ import annotations
import csv
from typing import Optional
from sqlcipher3.dbapi2 import IntegrityError
from PySide6.QtCore import Qt, QDate
from PySide6.QtWidgets import (
QDialog,
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 . 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, parent: QWidget | None = None):
super().__init__(parent)
self._db = db
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.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.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)
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
if self.toggle_btn.isChecked():
self._reload_summary()
else:
self.summary_label.setText(strings._("time_log_collapsed_hint"))
# ----- 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 _reload_summary(self) -> None:
if not self._current_date:
self.summary_label.setText(strings._("time_log_no_date"))
return
rows = self._db.time_log_for_date(self._current_date)
if not rows:
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
# 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)
dlg.exec()
# Always refresh summary when the dialog closes; the user may have changed entries
if self.toggle_btn.isChecked():
self._reload_summary()
class TimeLogDialog(QDialog):
"""
Per-day time log dialog.
Lets you:
1) choose a project
2) enter an activity (free-text with autocomplete)
3) enter time in decimal hours (0.25 = 15 min, 0.17 10 min)
4) manage entries for this date
"""
def __init__(self, db: DBManager, date_iso: str, parent=None):
super().__init__(parent)
self._db = db
self._date_iso = date_iso
self._current_entry_id: Optional[int] = None
self.setWindowTitle(strings._("time_log_for").format(date=date_iso))
self.resize(600, 400)
root = QVBoxLayout(self)
# --- Top: date label
root.addWidget(QLabel(strings._("time_log_date_label").format(date=date_iso)))
# --- 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)
# 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)
form.addRow(strings._("hours_decimal"), 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)
btn_row.addStretch(1)
btn_row.addWidget(self.add_update_btn)
btn_row.addWidget(self.delete_btn)
root.addLayout(btn_row)
# --- Table of entries for this date
self.table = QTableWidget()
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("activity"),
strings._("hours_decimal"),
]
)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(
2, QHeaderView.ResizeToContents
)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.itemSelectionChanged.connect(self._on_row_selected)
root.addWidget(self.table, 1)
# --- Close button
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)
# Init data
self._reload_projects()
self._reload_activities()
self._reload_entries()
# ----- 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:
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]
minutes = r[6]
hours = minutes / 60.0
item_proj = QTableWidgetItem(project_name)
item_act = QTableWidgetItem(activity_name)
item_hours = QTableWidgetItem(f"{hours:.2f}")
# 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_hours)
self._current_entry_id = None
self.delete_btn.setEnabled(False)
self.add_update_btn.setText(strings._("add_time_entry"))
# ----- Actions -----------------------------------------------------
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
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)
else:
# Update existing
self._db.update_time_log(
self._current_entry_id,
proj_id,
activity_id,
minutes,
)
self._reload_entries()
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)
hours_item = self.table.item(row, 2)
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()
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.hours_spin.setValue(hours)
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()
# ----- 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.setWindowTitle(strings._("time_log_report"))
self.resize(600, 400)
root = QVBoxLayout(self)
form = QFormLayout()
# Project
self.project_combo = QComboBox()
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))
self.from_date.setCalendarPopup(True)
self.to_date = QDateEdit(today)
self.to_date.setCalendarPopup(True)
range_row = QHBoxLayout()
range_row.addWidget(self.from_date)
range_row.addWidget(QLabel(""))
range_row.addWidget(self.to_date)
form.addRow(strings._("date_range"), range_row)
# 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)
run_row.addStretch(1)
run_row.addWidget(run_btn)
run_row.addWidget(export_btn)
root.addLayout(run_row)
# Table
self.table = QTableWidget()
self.table.setColumnCount(3)
self.table.setHorizontalHeaderLabels(
[
strings._("time_period"),
strings._("activity"),
strings._("hours_decimal"),
]
)
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(
2, 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 _run_report(self):
idx = self.project_combo.currentIndex()
if idx < 0:
return
proj_id = int(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()
rows = self._db.time_report(proj_id, start, end, gran)
# rows: (time_period, activity_name, minutes)
self._last_rows = rows
self._last_total_minutes = sum(r[2] for r in rows)
self.table.setRowCount(len(rows))
for i, (time_period, activity_name, minutes) in enumerate(rows):
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(f"{hrs:.2f}"))
total_hours = self._last_total_minutes / 60.0
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
try:
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
# Header
writer.writerow(
[
strings._("time_period"),
strings._("activity"),
strings._("hours_decimal"),
]
)
# Data rows
for time_period, activity_name, minutes in self._last_rows:
hours = minutes / 60.0
writer.writerow([time_period, activity_name, 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)),
)