diff --git a/CHANGELOG.md b/CHANGELOG.md index 5109618..45235b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index b241452..447ddda 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bouquin/db.py b/bouquin/db.py index ff744ba..fe51d31 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -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() diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 69bb0b4..1cfa3a8 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -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}" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 0763f29..4001fdb 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -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) diff --git a/bouquin/time_log.py b/bouquin/time_log.py new file mode 100644 index 0000000..a590b5a --- /dev/null +++ b/bouquin/time_log.py @@ -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)), + )