Initial work on time logging
This commit is contained in:
parent
83f25405db
commit
55b78833ac
6 changed files with 1199 additions and 10 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
# 0.3.3
|
# 0.4
|
||||||
|
|
||||||
* Remove screenshot tool
|
* Remove screenshot tool
|
||||||
* Improve width of bug report dialog
|
* Improve width of bug report dialog
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
* Improve size of checkboxes
|
* Improve size of checkboxes
|
||||||
* Convert bullet - to actual unicode bullets
|
* Convert bullet - to actual unicode bullets
|
||||||
* Add alarm option to set reminders
|
* Add alarm option to set reminders
|
||||||
|
* Add time logging and reporting
|
||||||
|
|
||||||
# 0.3.2
|
# 0.3.2
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ report from within the app.
|
||||||
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
* It is possible to automatically move unchecked checkboxes from yesterday to today, on startup
|
||||||
* English, French and Italian locales provided
|
* 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 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
|
## How to install
|
||||||
|
|
|
||||||
254
bouquin/db.py
254
bouquin/db.py
|
|
@ -17,6 +17,18 @@ from . import strings
|
||||||
|
|
||||||
Entry = Tuple[str, str]
|
Entry = Tuple[str, str]
|
||||||
TagRow = Tuple[int, 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 = [
|
_TAG_COLORS = [
|
||||||
"#FFB3BA", # soft red
|
"#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 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()
|
self.conn.commit()
|
||||||
|
|
@ -789,6 +833,216 @@ class DBManager:
|
||||||
edges = [(r["tag1"], r["tag2"], r["c"]) for r in rows]
|
edges = [(r["tag1"], r["tag2"], r["c"]) for r in rows]
|
||||||
return tags_by_id, edges, tag_page_counts
|
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:
|
def close(self) -> None:
|
||||||
if self.conn is not None:
|
if self.conn is not None:
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
|
||||||
|
|
@ -162,5 +162,71 @@
|
||||||
"invalid_time_title": "Invalid time",
|
"invalid_time_title": "Invalid time",
|
||||||
"invalid_time_message": "Please enter a time in the format HH:MM",
|
"invalid_time_message": "Please enter a time in the format HH:MM",
|
||||||
"dismiss": "Dismiss",
|
"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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,22 +51,23 @@ from PySide6.QtWidgets import (
|
||||||
QApplication,
|
QApplication,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .bug_report_dialog import BugReportDialog
|
||||||
from .db import DBManager
|
from .db import DBManager
|
||||||
from .markdown_editor import MarkdownEditor
|
|
||||||
from .find_bar import FindBar
|
from .find_bar import FindBar
|
||||||
from .history_dialog import HistoryDialog
|
from .history_dialog import HistoryDialog
|
||||||
from .key_prompt import KeyPrompt
|
from .key_prompt import KeyPrompt
|
||||||
from .lock_overlay import LockOverlay
|
from .lock_overlay import LockOverlay
|
||||||
|
from .markdown_editor import MarkdownEditor
|
||||||
from .save_dialog import SaveDialog
|
from .save_dialog import SaveDialog
|
||||||
from .search import Search
|
from .search import Search
|
||||||
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
from .settings import APP_ORG, APP_NAME, load_db_config, save_db_config
|
||||||
from .settings_dialog import SettingsDialog
|
from .settings_dialog import SettingsDialog
|
||||||
from .statistics_dialog import StatisticsDialog
|
from .statistics_dialog import StatisticsDialog
|
||||||
from .bug_report_dialog import BugReportDialog
|
|
||||||
from . import strings
|
from . import strings
|
||||||
from .tags_widget import PageTagsWidget
|
from .tags_widget import PageTagsWidget
|
||||||
from .toolbar import ToolBar
|
|
||||||
from .theme import ThemeManager
|
from .theme import ThemeManager
|
||||||
|
from .time_log import TimeLogWidget, TimeReportDialog
|
||||||
|
from .toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
|
|
@ -102,6 +103,8 @@ class MainWindow(QMainWindow):
|
||||||
self.search.openDateRequested.connect(self._load_selected_date)
|
self.search.openDateRequested.connect(self._load_selected_date)
|
||||||
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
self.search.resultDatesChanged.connect(self._on_search_dates_changed)
|
||||||
|
|
||||||
|
self.time_log = TimeLogWidget(self.db)
|
||||||
|
|
||||||
self.tags = PageTagsWidget(self.db)
|
self.tags = PageTagsWidget(self.db)
|
||||||
self.tags.tagActivated.connect(self._on_tag_activated)
|
self.tags.tagActivated.connect(self._on_tag_activated)
|
||||||
self.tags.tagAdded.connect(self._on_tag_added)
|
self.tags.tagAdded.connect(self._on_tag_added)
|
||||||
|
|
@ -113,6 +116,7 @@ class MainWindow(QMainWindow):
|
||||||
left_layout.setContentsMargins(8, 8, 8, 8)
|
left_layout.setContentsMargins(8, 8, 8, 8)
|
||||||
left_layout.addWidget(self.calendar)
|
left_layout.addWidget(self.calendar)
|
||||||
left_layout.addWidget(self.search)
|
left_layout.addWidget(self.search)
|
||||||
|
left_layout.addWidget(self.time_log)
|
||||||
left_layout.addWidget(self.tags)
|
left_layout.addWidget(self.tags)
|
||||||
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
left_panel.setFixedWidth(self.calendar.sizeHint().width() + 16)
|
||||||
|
|
||||||
|
|
@ -223,6 +227,9 @@ class MainWindow(QMainWindow):
|
||||||
act_stats.setShortcut("Shift+Ctrl+S")
|
act_stats.setShortcut("Shift+Ctrl+S")
|
||||||
act_stats.triggered.connect(self._open_statistics)
|
act_stats.triggered.connect(self._open_statistics)
|
||||||
file_menu.addAction(act_stats)
|
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()
|
file_menu.addSeparator()
|
||||||
act_quit = QAction("&" + strings._("quit"), self)
|
act_quit = QAction("&" + strings._("quit"), self)
|
||||||
act_quit.setShortcut("Ctrl+Q")
|
act_quit.setShortcut("Ctrl+Q")
|
||||||
|
|
@ -1042,10 +1049,10 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
# Use the current line in the markdown editor as the reminder text
|
# Use the current line in the markdown editor as the reminder text
|
||||||
try:
|
try:
|
||||||
line_text = editor.get_current_line_text().strip()
|
editor.get_current_line_text().strip()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
c = editor.textCursor()
|
c = editor.textCursor()
|
||||||
line_text = c.block().text().strip()
|
c.block().text().strip()
|
||||||
|
|
||||||
# Ask user for a time today in HH:MM format
|
# Ask user for a time today in HH:MM format
|
||||||
time_str, ok = QInputDialog.getText(
|
time_str, ok = QInputDialog.getText(
|
||||||
|
|
@ -1066,9 +1073,6 @@ class MainWindow(QMainWindow):
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
now = QDateTime.currentDateTime()
|
|
||||||
target = QDateTime(now.date(), QTime(hour, minute))
|
|
||||||
|
|
||||||
t = QTime(hour, minute)
|
t = QTime(hour, minute)
|
||||||
if not t.isValid():
|
if not t.isValid():
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
|
|
@ -1236,6 +1240,8 @@ class MainWindow(QMainWindow):
|
||||||
def _update_tag_views_for_date(self, date_iso: str):
|
def _update_tag_views_for_date(self, date_iso: str):
|
||||||
if hasattr(self, "tags"):
|
if hasattr(self, "tags"):
|
||||||
self.tags.set_current_date(date_iso)
|
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):
|
def _on_tag_added(self):
|
||||||
"""Called when a tag is added - trigger autosave for current page"""
|
"""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._heatmap.date_clicked.connect(on_date_clicked)
|
||||||
dlg.exec()
|
dlg.exec()
|
||||||
|
|
||||||
|
# ------------ Timesheet report handler --------------- #
|
||||||
|
def _open_time_report(self):
|
||||||
|
dlg = TimeReportDialog(self.db, self)
|
||||||
|
dlg.exec()
|
||||||
|
|
||||||
# ------------ Window positioning --------------- #
|
# ------------ Window positioning --------------- #
|
||||||
def _restore_window_position(self):
|
def _restore_window_position(self):
|
||||||
geom = self.settings.value("main/geometry", None)
|
geom = self.settings.value("main/geometry", None)
|
||||||
|
|
|
||||||
856
bouquin/time_log.py
Normal file
856
bouquin/time_log.py
Normal 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)),
|
||||||
|
)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue