bouquin 0.6.2__tar.gz → 0.6.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bouquin-0.6.2 → bouquin-0.6.4}/PKG-INFO +1 -1
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/db.py +103 -5
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/en.json +39 -2
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/pomodoro_timer.py +7 -4
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/reminders.py +229 -36
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/time_log.py +184 -32
- {bouquin-0.6.2 → bouquin-0.6.4}/pyproject.toml +1 -1
- {bouquin-0.6.2 → bouquin-0.6.4}/LICENSE +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/README.md +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/__init__.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/__main__.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/bug_report_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/code_block_editor_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/code_highlighter.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/document_utils.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/documents.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/find_bar.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/flow_layout.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/DejaVu.license +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/DejaVuSans.ttf +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/Noto.license +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/NotoSansSymbols2-Regular.ttf +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/history_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/icons/bouquin.svg +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/key_prompt.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/keys/mig5.asc +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/fr.json +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/it.json +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/lock_overlay.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/main.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/main_window.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/markdown_editor.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/markdown_highlighter.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/save_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/search.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/settings.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/settings_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/statistics_dialog.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/strings.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/tag_browser.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/tags_widget.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/theme.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/toolbar.py +0 -0
- {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/version_check.py +0 -0
|
@@ -1108,8 +1108,8 @@ class DBManager:
|
|
|
1108
1108
|
project_id: int,
|
|
1109
1109
|
start_date_iso: str,
|
|
1110
1110
|
end_date_iso: str,
|
|
1111
|
-
granularity: str = "day", # 'day' | 'week' | 'month'
|
|
1112
|
-
) -> list[tuple[str, str, int]]:
|
|
1111
|
+
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
|
|
1112
|
+
) -> list[tuple[str, str, str, int]]:
|
|
1113
1113
|
"""
|
|
1114
1114
|
Return (time_period, activity_name, total_minutes) tuples between start and end
|
|
1115
1115
|
for a project, grouped by period and activity.
|
|
@@ -1117,7 +1117,33 @@ class DBManager:
|
|
|
1117
1117
|
- 'YYYY-MM-DD' for day
|
|
1118
1118
|
- 'YYYY-WW' for week
|
|
1119
1119
|
- 'YYYY-MM' for month
|
|
1120
|
+
For 'none' granularity, each individual time log entry becomes a row.
|
|
1120
1121
|
"""
|
|
1122
|
+
cur = self.conn.cursor()
|
|
1123
|
+
|
|
1124
|
+
if granularity == "none":
|
|
1125
|
+
# No grouping: one row per entry
|
|
1126
|
+
rows = cur.execute(
|
|
1127
|
+
"""
|
|
1128
|
+
SELECT
|
|
1129
|
+
t.page_date AS period,
|
|
1130
|
+
a.name AS activity_name,
|
|
1131
|
+
t.note AS note,
|
|
1132
|
+
t.minutes AS total_minutes
|
|
1133
|
+
FROM time_log t
|
|
1134
|
+
JOIN activities a ON a.id = t.activity_id
|
|
1135
|
+
WHERE t.project_id = ?
|
|
1136
|
+
AND t.page_date BETWEEN ? AND ?
|
|
1137
|
+
ORDER BY period, LOWER(a.name), t.id;
|
|
1138
|
+
""",
|
|
1139
|
+
(project_id, start_date_iso, end_date_iso),
|
|
1140
|
+
).fetchall()
|
|
1141
|
+
|
|
1142
|
+
return [
|
|
1143
|
+
(r["period"], r["activity_name"], r["note"], r["total_minutes"])
|
|
1144
|
+
for r in rows
|
|
1145
|
+
]
|
|
1146
|
+
|
|
1121
1147
|
if granularity == "day":
|
|
1122
1148
|
bucket_expr = "page_date"
|
|
1123
1149
|
elif granularity == "week":
|
|
@@ -1126,13 +1152,11 @@ class DBManager:
|
|
|
1126
1152
|
else: # month
|
|
1127
1153
|
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
|
1128
1154
|
|
|
1129
|
-
cur = self.conn.cursor()
|
|
1130
1155
|
rows = cur.execute(
|
|
1131
1156
|
f"""
|
|
1132
1157
|
SELECT
|
|
1133
1158
|
{bucket_expr} AS bucket,
|
|
1134
1159
|
a.name AS activity_name,
|
|
1135
|
-
t.note AS note,
|
|
1136
1160
|
SUM(t.minutes) AS total_minutes
|
|
1137
1161
|
FROM time_log t
|
|
1138
1162
|
JOIN activities a ON a.id = t.activity_id
|
|
@@ -1144,8 +1168,82 @@ class DBManager:
|
|
|
1144
1168
|
(project_id, start_date_iso, end_date_iso),
|
|
1145
1169
|
).fetchall()
|
|
1146
1170
|
|
|
1171
|
+
return [(r["bucket"], r["activity_name"], "", r["total_minutes"]) for r in rows]
|
|
1172
|
+
|
|
1173
|
+
def time_report_all(
|
|
1174
|
+
self,
|
|
1175
|
+
start_date_iso: str,
|
|
1176
|
+
end_date_iso: str,
|
|
1177
|
+
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
|
|
1178
|
+
) -> list[tuple[str, str, str, str, int]]:
|
|
1179
|
+
"""
|
|
1180
|
+
Return (project_name, time_period, activity_name, note, total_minutes)
|
|
1181
|
+
across *all* projects between start and end, grouped by project + period + activity.
|
|
1182
|
+
"""
|
|
1183
|
+
cur = self.conn.cursor()
|
|
1184
|
+
|
|
1185
|
+
if granularity == "none":
|
|
1186
|
+
# No grouping – one row per time_log record
|
|
1187
|
+
rows = cur.execute(
|
|
1188
|
+
"""
|
|
1189
|
+
SELECT
|
|
1190
|
+
p.name AS project_name,
|
|
1191
|
+
t.page_date AS period,
|
|
1192
|
+
a.name AS activity_name,
|
|
1193
|
+
t.note AS note,
|
|
1194
|
+
t.minutes AS total_minutes
|
|
1195
|
+
FROM time_log t
|
|
1196
|
+
JOIN projects p ON p.id = t.project_id
|
|
1197
|
+
JOIN activities a ON a.id = t.activity_id
|
|
1198
|
+
WHERE t.page_date BETWEEN ? AND ?
|
|
1199
|
+
ORDER BY LOWER(p.name), period, LOWER(activity_name), t.id;
|
|
1200
|
+
""",
|
|
1201
|
+
(start_date_iso, end_date_iso),
|
|
1202
|
+
).fetchall()
|
|
1203
|
+
|
|
1204
|
+
return [
|
|
1205
|
+
(
|
|
1206
|
+
r["project_name"],
|
|
1207
|
+
r["period"],
|
|
1208
|
+
r["activity_name"],
|
|
1209
|
+
r["note"],
|
|
1210
|
+
r["total_minutes"],
|
|
1211
|
+
)
|
|
1212
|
+
for r in rows
|
|
1213
|
+
]
|
|
1214
|
+
|
|
1215
|
+
if granularity == "day":
|
|
1216
|
+
bucket_expr = "page_date"
|
|
1217
|
+
elif granularity == "week":
|
|
1218
|
+
bucket_expr = "strftime('%Y-%W', page_date)"
|
|
1219
|
+
else: # month
|
|
1220
|
+
bucket_expr = "substr(page_date, 1, 7)" # YYYY-MM
|
|
1221
|
+
|
|
1222
|
+
rows = cur.execute(
|
|
1223
|
+
f"""
|
|
1224
|
+
SELECT
|
|
1225
|
+
p.name AS project_name,
|
|
1226
|
+
{bucket_expr} AS bucket,
|
|
1227
|
+
a.name AS activity_name,
|
|
1228
|
+
SUM(t.minutes) AS total_minutes
|
|
1229
|
+
FROM time_log t
|
|
1230
|
+
JOIN projects p ON p.id = t.project_id
|
|
1231
|
+
JOIN activities a ON a.id = t.activity_id
|
|
1232
|
+
WHERE t.page_date BETWEEN ? AND ?
|
|
1233
|
+
GROUP BY p.id, bucket, activity_name
|
|
1234
|
+
ORDER BY LOWER(p.name), bucket, LOWER(activity_name);
|
|
1235
|
+
""", # nosec
|
|
1236
|
+
(start_date_iso, end_date_iso),
|
|
1237
|
+
).fetchall()
|
|
1238
|
+
|
|
1147
1239
|
return [
|
|
1148
|
-
(
|
|
1240
|
+
(
|
|
1241
|
+
r["project_name"],
|
|
1242
|
+
r["bucket"],
|
|
1243
|
+
r["activity_name"],
|
|
1244
|
+
"",
|
|
1245
|
+
r["total_minutes"],
|
|
1246
|
+
)
|
|
1149
1247
|
for r in rows
|
|
1150
1248
|
]
|
|
1151
1249
|
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"next_day": "Next day",
|
|
41
41
|
"today": "Today",
|
|
42
42
|
"show": "Show",
|
|
43
|
+
"edit": "Edit",
|
|
44
|
+
"delete": "Delete",
|
|
43
45
|
"history": "History",
|
|
44
46
|
"export_accessible_flag": "&Export",
|
|
45
47
|
"export_entries": "Export entries",
|
|
@@ -50,6 +52,7 @@
|
|
|
50
52
|
"backup_failed": "Backup failed",
|
|
51
53
|
"quit": "Quit",
|
|
52
54
|
"cancel": "Cancel",
|
|
55
|
+
"close": "Close",
|
|
53
56
|
"save": "Save",
|
|
54
57
|
"help": "Help",
|
|
55
58
|
"saved": "Saved",
|
|
@@ -193,10 +196,16 @@
|
|
|
193
196
|
"add_project": "Add project",
|
|
194
197
|
"add_time_entry": "Add time entry",
|
|
195
198
|
"time_period": "Time period",
|
|
199
|
+
"dont_group": "Don't group",
|
|
196
200
|
"by_day": "by day",
|
|
197
201
|
"by_month": "by month",
|
|
198
202
|
"by_week": "by week",
|
|
199
203
|
"date_range": "Date range",
|
|
204
|
+
"custom_range": "Custom",
|
|
205
|
+
"this_week": "This week",
|
|
206
|
+
"this_month": "This month",
|
|
207
|
+
"this_year": "This year",
|
|
208
|
+
"all_projects": "All projects",
|
|
200
209
|
"delete_activity": "Delete activity",
|
|
201
210
|
"delete_activity_confirm": "Are you sure you want to delete this activity?",
|
|
202
211
|
"delete_activity_title": "Delete activity - are you sure?",
|
|
@@ -277,18 +286,35 @@
|
|
|
277
286
|
"pause": "Pause",
|
|
278
287
|
"resume": "Resume",
|
|
279
288
|
"stop_and_log": "Stop and log",
|
|
289
|
+
"manage_reminders": "Manage Reminders",
|
|
290
|
+
"upcoming_reminders": "Upcoming Reminders",
|
|
291
|
+
"no_upcoming_reminders": "No upcoming reminders",
|
|
280
292
|
"once": "once",
|
|
281
293
|
"daily": "daily",
|
|
282
294
|
"weekdays": "weekdays",
|
|
283
295
|
"weekly": "weekly",
|
|
284
|
-
"
|
|
285
|
-
"
|
|
296
|
+
"add_reminder": "Add Reminder",
|
|
297
|
+
"set_reminder": "Set Reminder",
|
|
298
|
+
"edit_reminder": "Edit Reminder",
|
|
299
|
+
"delete_reminder": "Delete Reminder",
|
|
300
|
+
"delete_reminders": "Delete Reminders",
|
|
301
|
+
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
|
|
302
|
+
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
|
|
303
|
+
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
|
|
286
304
|
"reminder": "Reminder",
|
|
305
|
+
"reminders": "Reminders",
|
|
287
306
|
"time": "Time",
|
|
288
307
|
"once_today": "Once (today)",
|
|
289
308
|
"every_day": "Every day",
|
|
290
309
|
"every_weekday": "Every weekday (Mon-Fri)",
|
|
291
310
|
"every_week": "Every week",
|
|
311
|
+
"every_fortnight": "Every 2 weeks",
|
|
312
|
+
"every_month": "Every month (same date)",
|
|
313
|
+
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
|
|
314
|
+
"week_in_month": "Week in month",
|
|
315
|
+
"fortnightly": "Fortnightly",
|
|
316
|
+
"monthly_same_date": "Monthly (same date)",
|
|
317
|
+
"monthly_nth_weekday": "Monthly (nth weekday)",
|
|
292
318
|
"repeat": "Repeat",
|
|
293
319
|
"monday": "Monday",
|
|
294
320
|
"tuesday": "Tuesday",
|
|
@@ -297,7 +323,18 @@
|
|
|
297
323
|
"friday": "Friday",
|
|
298
324
|
"saturday": "Saturday",
|
|
299
325
|
"sunday": "Sunday",
|
|
326
|
+
"monday_short": "Mon",
|
|
327
|
+
"tuesday_short": "Tue",
|
|
328
|
+
"wednesday_short": "Wed",
|
|
329
|
+
"thursday_short": "Thu",
|
|
330
|
+
"friday_short": "Fri",
|
|
331
|
+
"saturday_short": "Sat",
|
|
332
|
+
"sunday_short": "Sun",
|
|
300
333
|
"day": "Day",
|
|
334
|
+
"text": "Text",
|
|
335
|
+
"type": "Type",
|
|
336
|
+
"active": "Active",
|
|
337
|
+
"actions": "Actions",
|
|
301
338
|
"edit_code_block": "Edit code block",
|
|
302
339
|
"delete_code_block": "Delete code block",
|
|
303
340
|
"search_result_heading_document": "Document",
|
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import math
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
-
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
|
6
|
+
from PySide6.QtCore import Qt, QTimer, Signal, Slot
|
|
7
7
|
from PySide6.QtWidgets import (
|
|
8
8
|
QFrame,
|
|
9
9
|
QVBoxLayout,
|
|
@@ -167,9 +167,12 @@ class PomodoroManager:
|
|
|
167
167
|
# Untoggle the toolbar button without retriggering the slot
|
|
168
168
|
tool_bar = getattr(self._parent, "toolBar", None)
|
|
169
169
|
if tool_bar is not None and hasattr(tool_bar, "actTimer"):
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
170
|
+
action = tool_bar.actTimer
|
|
171
|
+
was_blocked = action.blockSignals(True)
|
|
172
|
+
try:
|
|
173
|
+
action.setChecked(False)
|
|
174
|
+
finally:
|
|
175
|
+
action.blockSignals(was_blocked)
|
|
173
176
|
|
|
174
177
|
# Remove the embedded widget
|
|
175
178
|
self.cancel_timer()
|
|
@@ -26,6 +26,8 @@ from PySide6.QtWidgets import (
|
|
|
26
26
|
QTableWidgetItem,
|
|
27
27
|
QAbstractItemView,
|
|
28
28
|
QHeaderView,
|
|
29
|
+
QSpinBox,
|
|
30
|
+
QDateEdit,
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
from . import strings
|
|
@@ -37,6 +39,9 @@ class ReminderType(Enum):
|
|
|
37
39
|
DAILY = strings._("daily")
|
|
38
40
|
WEEKDAYS = strings._("weekdays") # Mon-Fri
|
|
39
41
|
WEEKLY = strings._("weekly") # specific day of week
|
|
42
|
+
FORTNIGHTLY = strings._("fortnightly") # every 2 weeks
|
|
43
|
+
MONTHLY_DATE = strings._("monthly_same_date") # same calendar date
|
|
44
|
+
MONTHLY_NTH_WEEKDAY = strings._("monthly_nth_weekday") # e.g. 3rd Monday
|
|
40
45
|
|
|
41
46
|
|
|
42
47
|
@dataclass
|
|
@@ -72,6 +77,22 @@ class ReminderDialog(QDialog):
|
|
|
72
77
|
self.text_edit.setText(reminder.text)
|
|
73
78
|
self.form.addRow("&" + strings._("reminder") + ":", self.text_edit)
|
|
74
79
|
|
|
80
|
+
# Date
|
|
81
|
+
self.date_edit = QDateEdit()
|
|
82
|
+
self.date_edit.setCalendarPopup(True)
|
|
83
|
+
self.date_edit.setDisplayFormat("yyyy-MM-dd")
|
|
84
|
+
|
|
85
|
+
if reminder and reminder.date_iso:
|
|
86
|
+
d = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
|
87
|
+
if d.isValid():
|
|
88
|
+
self.date_edit.setDate(d)
|
|
89
|
+
else:
|
|
90
|
+
self.date_edit.setDate(QDate.currentDate())
|
|
91
|
+
else:
|
|
92
|
+
self.date_edit.setDate(QDate.currentDate())
|
|
93
|
+
|
|
94
|
+
self.form.addRow("&" + strings._("date") + ":", self.date_edit)
|
|
95
|
+
|
|
75
96
|
# Time
|
|
76
97
|
self.time_edit = QTimeEdit()
|
|
77
98
|
self.time_edit.setDisplayFormat("HH:mm")
|
|
@@ -79,7 +100,9 @@ class ReminderDialog(QDialog):
|
|
|
79
100
|
parts = reminder.time_str.split(":")
|
|
80
101
|
self.time_edit.setTime(QTime(int(parts[0]), int(parts[1])))
|
|
81
102
|
else:
|
|
82
|
-
|
|
103
|
+
# Default to 5 minutes in the future
|
|
104
|
+
future = QTime.currentTime().addSecs(5 * 60)
|
|
105
|
+
self.time_edit.setTime(future)
|
|
83
106
|
self.form.addRow("&" + strings._("time") + ":", self.time_edit)
|
|
84
107
|
|
|
85
108
|
# Recurrence type
|
|
@@ -88,6 +111,11 @@ class ReminderDialog(QDialog):
|
|
|
88
111
|
self.type_combo.addItem(strings._("every_day"), ReminderType.DAILY)
|
|
89
112
|
self.type_combo.addItem(strings._("every_weekday"), ReminderType.WEEKDAYS)
|
|
90
113
|
self.type_combo.addItem(strings._("every_week"), ReminderType.WEEKLY)
|
|
114
|
+
self.type_combo.addItem(strings._("every_fortnight"), ReminderType.FORTNIGHTLY)
|
|
115
|
+
self.type_combo.addItem(strings._("every_month"), ReminderType.MONTHLY_DATE)
|
|
116
|
+
self.type_combo.addItem(
|
|
117
|
+
strings._("every_month_nth_weekday"), ReminderType.MONTHLY_NTH_WEEKDAY
|
|
118
|
+
)
|
|
91
119
|
|
|
92
120
|
if reminder:
|
|
93
121
|
for i in range(self.type_combo.count()):
|
|
@@ -115,12 +143,31 @@ class ReminderDialog(QDialog):
|
|
|
115
143
|
if reminder and reminder.weekday is not None:
|
|
116
144
|
self.weekday_combo.setCurrentIndex(reminder.weekday)
|
|
117
145
|
else:
|
|
118
|
-
self.weekday_combo.setCurrentIndex(
|
|
146
|
+
self.weekday_combo.setCurrentIndex(self.date_edit.date().dayOfWeek() - 1)
|
|
119
147
|
|
|
120
148
|
self.form.addRow("&" + strings._("day") + ":", self.weekday_combo)
|
|
121
149
|
day_label = self.form.labelForField(self.weekday_combo)
|
|
122
150
|
day_label.setVisible(False)
|
|
123
151
|
|
|
152
|
+
self.nth_spin = QSpinBox()
|
|
153
|
+
self.nth_spin.setRange(1, 5) # up to 5th Monday, etc.
|
|
154
|
+
self.nth_spin.setValue(1)
|
|
155
|
+
# If editing an existing MONTHLY_NTH_WEEKDAY reminder, derive the nth from date_iso
|
|
156
|
+
if (
|
|
157
|
+
reminder
|
|
158
|
+
and reminder.reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
|
159
|
+
and reminder.date_iso
|
|
160
|
+
):
|
|
161
|
+
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
|
162
|
+
if anchor.isValid():
|
|
163
|
+
nth_index = (anchor.day() - 1) // 7 # 0-based
|
|
164
|
+
self.nth_spin.setValue(nth_index + 1)
|
|
165
|
+
|
|
166
|
+
self.form.addRow("&" + strings._("week_in_month") + ":", self.nth_spin)
|
|
167
|
+
nth_label = self.form.labelForField(self.nth_spin)
|
|
168
|
+
nth_label.setVisible(False)
|
|
169
|
+
self.nth_spin.setVisible(False)
|
|
170
|
+
|
|
124
171
|
layout.addLayout(self.form)
|
|
125
172
|
|
|
126
173
|
# Buttons
|
|
@@ -141,11 +188,31 @@ class ReminderDialog(QDialog):
|
|
|
141
188
|
self._on_type_changed()
|
|
142
189
|
|
|
143
190
|
def _on_type_changed(self):
|
|
144
|
-
"""Show/hide weekday
|
|
191
|
+
"""Show/hide weekday / nth selectors based on reminder type."""
|
|
145
192
|
reminder_type = self.type_combo.currentData()
|
|
146
|
-
|
|
193
|
+
|
|
194
|
+
show_weekday = reminder_type in (
|
|
195
|
+
ReminderType.WEEKLY,
|
|
196
|
+
ReminderType.MONTHLY_NTH_WEEKDAY,
|
|
197
|
+
)
|
|
198
|
+
self.weekday_combo.setVisible(show_weekday)
|
|
147
199
|
day_label = self.form.labelForField(self.weekday_combo)
|
|
148
|
-
day_label.setVisible(
|
|
200
|
+
day_label.setVisible(show_weekday)
|
|
201
|
+
|
|
202
|
+
show_nth = reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY
|
|
203
|
+
nth_label = self.form.labelForField(self.nth_spin)
|
|
204
|
+
self.nth_spin.setVisible(show_nth)
|
|
205
|
+
nth_label.setVisible(show_nth)
|
|
206
|
+
|
|
207
|
+
# For new reminders, when switching to a type that uses a weekday,
|
|
208
|
+
# snap the weekday to match the currently selected date.
|
|
209
|
+
if reminder_type in (
|
|
210
|
+
ReminderType.WEEKLY,
|
|
211
|
+
ReminderType.MONTHLY_NTH_WEEKDAY,
|
|
212
|
+
) and (self._reminder is None or self._reminder.reminder_type != reminder_type):
|
|
213
|
+
dow = self.date_edit.date().dayOfWeek() - 1 # 0..6 (Mon..Sun)
|
|
214
|
+
if 0 <= dow < self.weekday_combo.count():
|
|
215
|
+
self.weekday_combo.setCurrentIndex(dow)
|
|
149
216
|
|
|
150
217
|
def get_reminder(self) -> Reminder:
|
|
151
218
|
"""Get the configured reminder."""
|
|
@@ -154,13 +221,39 @@ class ReminderDialog(QDialog):
|
|
|
154
221
|
time_str = f"{time_obj.hour():02d}:{time_obj.minute():02d}"
|
|
155
222
|
|
|
156
223
|
weekday = None
|
|
157
|
-
if reminder_type
|
|
224
|
+
if reminder_type in (ReminderType.WEEKLY, ReminderType.MONTHLY_NTH_WEEKDAY):
|
|
158
225
|
weekday = self.weekday_combo.currentData()
|
|
159
226
|
|
|
160
227
|
date_iso = None
|
|
228
|
+
anchor_date = self.date_edit.date()
|
|
229
|
+
|
|
161
230
|
if reminder_type == ReminderType.ONCE:
|
|
162
|
-
#
|
|
163
|
-
date_iso =
|
|
231
|
+
# Fire once, on the chosen calendar date at the chosen time
|
|
232
|
+
date_iso = anchor_date.toString("yyyy-MM-dd")
|
|
233
|
+
|
|
234
|
+
elif reminder_type == ReminderType.FORTNIGHTLY:
|
|
235
|
+
# Anchor: the chosen calendar date. Every 14 days from this date.
|
|
236
|
+
date_iso = anchor_date.toString("yyyy-MM-dd")
|
|
237
|
+
|
|
238
|
+
elif reminder_type == ReminderType.MONTHLY_DATE:
|
|
239
|
+
# Anchor: the chosen calendar date. "Same date each month"
|
|
240
|
+
date_iso = anchor_date.toString("yyyy-MM-dd")
|
|
241
|
+
|
|
242
|
+
elif reminder_type == ReminderType.MONTHLY_NTH_WEEKDAY:
|
|
243
|
+
# Anchor: the nth weekday for the chosen month (gives us “3rd Monday” etc.)
|
|
244
|
+
weekday = self.weekday_combo.currentData()
|
|
245
|
+
nth_index = self.nth_spin.value() - 1 # 0-based
|
|
246
|
+
|
|
247
|
+
first = QDate(anchor_date.year(), anchor_date.month(), 1)
|
|
248
|
+
target_dow = weekday + 1 # Qt: Monday=1
|
|
249
|
+
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
|
250
|
+
anchor = first.addDays(offset + nth_index * 7)
|
|
251
|
+
|
|
252
|
+
# If nth weekday doesn't exist in this month, fall back to the last such weekday
|
|
253
|
+
if anchor.month() != anchor_date.month():
|
|
254
|
+
anchor = anchor.addDays(-7)
|
|
255
|
+
|
|
256
|
+
date_iso = anchor.toString("yyyy-MM-dd")
|
|
164
257
|
|
|
165
258
|
return Reminder(
|
|
166
259
|
id=self._reminder.id if self._reminder else None,
|
|
@@ -187,7 +280,7 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
187
280
|
|
|
188
281
|
# Header with toggle button
|
|
189
282
|
self.toggle_btn = QToolButton()
|
|
190
|
-
self.toggle_btn.setText("
|
|
283
|
+
self.toggle_btn.setText(strings._("upcoming_reminders"))
|
|
191
284
|
self.toggle_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
|
|
192
285
|
self.toggle_btn.setCheckable(True)
|
|
193
286
|
self.toggle_btn.setChecked(False)
|
|
@@ -196,7 +289,7 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
196
289
|
|
|
197
290
|
self.add_btn = QToolButton()
|
|
198
291
|
self.add_btn.setText("⏰")
|
|
199
|
-
self.add_btn.setToolTip("
|
|
292
|
+
self.add_btn.setToolTip(strings._("add_reminder"))
|
|
200
293
|
self.add_btn.setAutoRaise(True)
|
|
201
294
|
self.add_btn.clicked.connect(self._add_reminder)
|
|
202
295
|
|
|
@@ -204,7 +297,7 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
204
297
|
self.manage_btn.setIcon(
|
|
205
298
|
self.style().standardIcon(QStyle.SP_FileDialogDetailedView)
|
|
206
299
|
)
|
|
207
|
-
self.manage_btn.setToolTip("
|
|
300
|
+
self.manage_btn.setToolTip(strings._("manage_reminders"))
|
|
208
301
|
self.manage_btn.setAutoRaise(True)
|
|
209
302
|
self.manage_btn.clicked.connect(self._manage_reminders)
|
|
210
303
|
|
|
@@ -328,24 +421,75 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
328
421
|
self.reminder_list.addItem(item)
|
|
329
422
|
|
|
330
423
|
if not upcoming:
|
|
331
|
-
item = QListWidgetItem("
|
|
424
|
+
item = QListWidgetItem(strings._("no_upcoming_reminders"))
|
|
332
425
|
item.setFlags(Qt.NoItemFlags)
|
|
333
426
|
self.reminder_list.addItem(item)
|
|
334
427
|
|
|
335
428
|
def _should_fire_on_date(self, reminder: Reminder, date: QDate) -> bool:
|
|
336
429
|
"""Check if a reminder should fire on a given date."""
|
|
337
|
-
|
|
430
|
+
rtype = reminder.reminder_type
|
|
431
|
+
|
|
432
|
+
if rtype == ReminderType.ONCE:
|
|
338
433
|
if reminder.date_iso:
|
|
339
434
|
return date.toString("yyyy-MM-dd") == reminder.date_iso
|
|
340
435
|
return False
|
|
341
|
-
|
|
436
|
+
|
|
437
|
+
if rtype == ReminderType.DAILY:
|
|
342
438
|
return True
|
|
343
|
-
|
|
439
|
+
|
|
440
|
+
if rtype == ReminderType.WEEKDAYS:
|
|
344
441
|
# Monday=1, Sunday=7
|
|
345
442
|
return 1 <= date.dayOfWeek() <= 5
|
|
346
|
-
|
|
443
|
+
|
|
444
|
+
if rtype == ReminderType.WEEKLY:
|
|
347
445
|
# Qt: Monday=1, reminder: Monday=0
|
|
348
446
|
return date.dayOfWeek() - 1 == reminder.weekday
|
|
447
|
+
|
|
448
|
+
if rtype == ReminderType.FORTNIGHTLY:
|
|
449
|
+
if not reminder.date_iso:
|
|
450
|
+
return False
|
|
451
|
+
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
|
452
|
+
if not anchor.isValid() or date < anchor:
|
|
453
|
+
return False
|
|
454
|
+
days = anchor.daysTo(date)
|
|
455
|
+
return days % 14 == 0
|
|
456
|
+
|
|
457
|
+
if rtype == ReminderType.MONTHLY_DATE:
|
|
458
|
+
if not reminder.date_iso:
|
|
459
|
+
return False
|
|
460
|
+
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
|
461
|
+
if not anchor.isValid():
|
|
462
|
+
return False
|
|
463
|
+
anchor_day = anchor.day()
|
|
464
|
+
# Clamp to the last day of this month (for 29/30/31)
|
|
465
|
+
first_of_month = QDate(date.year(), date.month(), 1)
|
|
466
|
+
last_of_month = first_of_month.addMonths(1).addDays(-1)
|
|
467
|
+
target_day = min(anchor_day, last_of_month.day())
|
|
468
|
+
return date.day() == target_day
|
|
469
|
+
|
|
470
|
+
if rtype == ReminderType.MONTHLY_NTH_WEEKDAY:
|
|
471
|
+
if not reminder.date_iso or reminder.weekday is None:
|
|
472
|
+
return False
|
|
473
|
+
|
|
474
|
+
anchor = QDate.fromString(reminder.date_iso, "yyyy-MM-dd")
|
|
475
|
+
if not anchor.isValid():
|
|
476
|
+
return False
|
|
477
|
+
|
|
478
|
+
# Which "nth" weekday is the anchor? (0=1st, 1=2nd, etc.)
|
|
479
|
+
anchor_n = (anchor.day() - 1) // 7
|
|
480
|
+
target_dow = reminder.weekday + 1 # Qt dayOfWeek (1..7)
|
|
481
|
+
|
|
482
|
+
# Compute the anchor_n-th target weekday in this month
|
|
483
|
+
first = QDate(date.year(), date.month(), 1)
|
|
484
|
+
offset = (target_dow - first.dayOfWeek() + 7) % 7
|
|
485
|
+
candidate = first.addDays(offset + anchor_n * 7)
|
|
486
|
+
|
|
487
|
+
# If that nth weekday doesn’t exist this month (e.g. 5th Monday), skip
|
|
488
|
+
if candidate.month() != date.month():
|
|
489
|
+
return False
|
|
490
|
+
|
|
491
|
+
return date == candidate
|
|
492
|
+
|
|
349
493
|
return False
|
|
350
494
|
|
|
351
495
|
def _check_reminders(self):
|
|
@@ -431,7 +575,7 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
431
575
|
if len(selected_items) == 1:
|
|
432
576
|
reminder = selected_items[0].data(Qt.UserRole)
|
|
433
577
|
if reminder:
|
|
434
|
-
edit_action = QAction("
|
|
578
|
+
edit_action = QAction(strings._("edit"), self)
|
|
435
579
|
edit_action.triggered.connect(
|
|
436
580
|
lambda: self._edit_reminder(selected_items[0])
|
|
437
581
|
)
|
|
@@ -439,9 +583,13 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
439
583
|
|
|
440
584
|
# Delete option for any selection
|
|
441
585
|
if len(selected_items) == 1:
|
|
442
|
-
delete_text = "
|
|
586
|
+
delete_text = strings._("delete")
|
|
443
587
|
else:
|
|
444
|
-
delete_text =
|
|
588
|
+
delete_text = (
|
|
589
|
+
strings._("delete")
|
|
590
|
+
+ f" {len(selected_items)} "
|
|
591
|
+
+ strings._("reminders")
|
|
592
|
+
)
|
|
445
593
|
|
|
446
594
|
delete_action = QAction(delete_text, self)
|
|
447
595
|
delete_action.triggered.connect(lambda: self._delete_selected_reminders())
|
|
@@ -468,15 +616,31 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
468
616
|
# Confirmation message
|
|
469
617
|
if len(unique_reminders) == 1:
|
|
470
618
|
reminder = list(unique_reminders.values())[0]
|
|
471
|
-
msg =
|
|
619
|
+
msg = (
|
|
620
|
+
strings._("delete")
|
|
621
|
+
+ " "
|
|
622
|
+
+ strings._("reminder")
|
|
623
|
+
+ f" '{reminder.text}'?"
|
|
624
|
+
)
|
|
472
625
|
if reminder.reminder_type != ReminderType.ONCE:
|
|
473
|
-
msg +=
|
|
626
|
+
msg += (
|
|
627
|
+
"\n\n"
|
|
628
|
+
+ strings._("this_is_a_reminder_of_type")
|
|
629
|
+
+ f" '{reminder.reminder_type.value}'. "
|
|
630
|
+
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
|
631
|
+
)
|
|
474
632
|
else:
|
|
475
|
-
msg =
|
|
633
|
+
msg = (
|
|
634
|
+
strings._("delete")
|
|
635
|
+
+ f"{len(unique_reminders)} "
|
|
636
|
+
+ strings._("reminders")
|
|
637
|
+
+ " ?\n\n"
|
|
638
|
+
+ strings._("this_will_delete_the_actual_reminders")
|
|
639
|
+
)
|
|
476
640
|
|
|
477
641
|
reply = QMessageBox.question(
|
|
478
642
|
self,
|
|
479
|
-
"
|
|
643
|
+
strings._("delete_reminders"),
|
|
480
644
|
msg,
|
|
481
645
|
QMessageBox.Yes | QMessageBox.No,
|
|
482
646
|
QMessageBox.No,
|
|
@@ -489,13 +653,18 @@ class UpcomingRemindersWidget(QFrame):
|
|
|
489
653
|
|
|
490
654
|
def _delete_reminder(self, reminder):
|
|
491
655
|
"""Delete a single reminder after confirmation."""
|
|
492
|
-
msg =
|
|
656
|
+
msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
|
|
493
657
|
if reminder.reminder_type != ReminderType.ONCE:
|
|
494
|
-
msg +=
|
|
658
|
+
msg += (
|
|
659
|
+
"\n\n"
|
|
660
|
+
+ strings._("this_is_a_reminder_of_type")
|
|
661
|
+
+ f" '{reminder.reminder_type.value}'. "
|
|
662
|
+
+ strings._("deleting_it_will_remove_all_future_occurrences")
|
|
663
|
+
)
|
|
495
664
|
|
|
496
665
|
reply = QMessageBox.question(
|
|
497
666
|
self,
|
|
498
|
-
"
|
|
667
|
+
strings._("delete_reminder"),
|
|
499
668
|
msg,
|
|
500
669
|
QMessageBox.Yes | QMessageBox.No,
|
|
501
670
|
QMessageBox.No,
|
|
@@ -520,7 +689,7 @@ class ManageRemindersDialog(QDialog):
|
|
|
520
689
|
super().__init__(parent)
|
|
521
690
|
self._db = db
|
|
522
691
|
|
|
523
|
-
self.setWindowTitle("
|
|
692
|
+
self.setWindowTitle(strings._("manage_reminders"))
|
|
524
693
|
self.setMinimumSize(700, 500)
|
|
525
694
|
|
|
526
695
|
layout = QVBoxLayout(self)
|
|
@@ -529,23 +698,30 @@ class ManageRemindersDialog(QDialog):
|
|
|
529
698
|
self.table = QTableWidget()
|
|
530
699
|
self.table.setColumnCount(5)
|
|
531
700
|
self.table.setHorizontalHeaderLabels(
|
|
532
|
-
[
|
|
701
|
+
[
|
|
702
|
+
strings._("text"),
|
|
703
|
+
strings._("time"),
|
|
704
|
+
strings._("type"),
|
|
705
|
+
strings._("active"),
|
|
706
|
+
strings._("actions"),
|
|
707
|
+
]
|
|
533
708
|
)
|
|
534
709
|
self.table.horizontalHeader().setStretchLastSection(False)
|
|
535
710
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
|
536
711
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
712
|
+
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
537
713
|
layout.addWidget(self.table)
|
|
538
714
|
|
|
539
715
|
# Buttons
|
|
540
716
|
btn_layout = QHBoxLayout()
|
|
541
717
|
|
|
542
|
-
add_btn = QPushButton("
|
|
718
|
+
add_btn = QPushButton(strings._("add_reminder"))
|
|
543
719
|
add_btn.clicked.connect(self._add_reminder)
|
|
544
720
|
btn_layout.addWidget(add_btn)
|
|
545
721
|
|
|
546
722
|
btn_layout.addStretch()
|
|
547
723
|
|
|
548
|
-
close_btn = QPushButton("
|
|
724
|
+
close_btn = QPushButton(strings._("close"))
|
|
549
725
|
close_btn.clicked.connect(self.accept)
|
|
550
726
|
btn_layout.addWidget(close_btn)
|
|
551
727
|
|
|
@@ -579,13 +755,30 @@ class ManageRemindersDialog(QDialog):
|
|
|
579
755
|
ReminderType.DAILY: "Daily",
|
|
580
756
|
ReminderType.WEEKDAYS: "Weekdays",
|
|
581
757
|
ReminderType.WEEKLY: "Weekly",
|
|
758
|
+
ReminderType.FORTNIGHTLY: "Fortnightly",
|
|
759
|
+
ReminderType.MONTHLY_DATE: "Monthly (date)",
|
|
760
|
+
ReminderType.MONTHLY_NTH_WEEKDAY: "Monthly (nth weekday)",
|
|
582
761
|
}.get(reminder.reminder_type, "Unknown")
|
|
583
762
|
|
|
763
|
+
# Add day-of-week annotation where it makes sense
|
|
584
764
|
if (
|
|
585
|
-
reminder.reminder_type
|
|
765
|
+
reminder.reminder_type
|
|
766
|
+
in (
|
|
767
|
+
ReminderType.WEEKLY,
|
|
768
|
+
ReminderType.FORTNIGHTLY,
|
|
769
|
+
ReminderType.MONTHLY_NTH_WEEKDAY,
|
|
770
|
+
)
|
|
586
771
|
and reminder.weekday is not None
|
|
587
772
|
):
|
|
588
|
-
days = [
|
|
773
|
+
days = [
|
|
774
|
+
strings._("monday_short"),
|
|
775
|
+
strings._("tuesday_short"),
|
|
776
|
+
strings._("wednesday_short"),
|
|
777
|
+
strings._("thursday_short"),
|
|
778
|
+
strings._("friday_short"),
|
|
779
|
+
strings._("saturday_short"),
|
|
780
|
+
strings._("sunday_short"),
|
|
781
|
+
]
|
|
589
782
|
type_str += f" ({days[reminder.weekday]})"
|
|
590
783
|
|
|
591
784
|
type_item = QTableWidgetItem(type_str)
|
|
@@ -600,11 +793,11 @@ class ManageRemindersDialog(QDialog):
|
|
|
600
793
|
actions_layout = QHBoxLayout(actions_widget)
|
|
601
794
|
actions_layout.setContentsMargins(2, 2, 2, 2)
|
|
602
795
|
|
|
603
|
-
edit_btn = QPushButton("
|
|
796
|
+
edit_btn = QPushButton(strings._("edit"))
|
|
604
797
|
edit_btn.clicked.connect(lambda checked, r=reminder: self._edit_reminder(r))
|
|
605
798
|
actions_layout.addWidget(edit_btn)
|
|
606
799
|
|
|
607
|
-
delete_btn = QPushButton("
|
|
800
|
+
delete_btn = QPushButton(strings._("delete"))
|
|
608
801
|
delete_btn.clicked.connect(
|
|
609
802
|
lambda checked, r=reminder: self._delete_reminder(r)
|
|
610
803
|
)
|
|
@@ -632,8 +825,8 @@ class ManageRemindersDialog(QDialog):
|
|
|
632
825
|
"""Delete a reminder."""
|
|
633
826
|
reply = QMessageBox.question(
|
|
634
827
|
self,
|
|
635
|
-
"
|
|
636
|
-
|
|
828
|
+
strings._("delete_reminder"),
|
|
829
|
+
strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?",
|
|
637
830
|
QMessageBox.Yes | QMessageBox.No,
|
|
638
831
|
QMessageBox.No,
|
|
639
832
|
)
|
|
@@ -986,7 +986,7 @@ class TimeReportDialog(QDialog):
|
|
|
986
986
|
self._db = db
|
|
987
987
|
|
|
988
988
|
# state for last run
|
|
989
|
-
self._last_rows: list[tuple[str, str, int]] = []
|
|
989
|
+
self._last_rows: list[tuple[str, str, str, str, int]] = []
|
|
990
990
|
self._last_total_minutes: int = 0
|
|
991
991
|
self._last_project_name: str = ""
|
|
992
992
|
self._last_start: str = ""
|
|
@@ -1001,25 +1001,44 @@ class TimeReportDialog(QDialog):
|
|
|
1001
1001
|
form = QFormLayout()
|
|
1002
1002
|
# Project
|
|
1003
1003
|
self.project_combo = QComboBox()
|
|
1004
|
+
self.project_combo.addItem(strings._("all_projects"), None)
|
|
1004
1005
|
for proj_id, name in self._db.list_projects():
|
|
1005
1006
|
self.project_combo.addItem(name, proj_id)
|
|
1006
1007
|
form.addRow(strings._("project"), self.project_combo)
|
|
1007
1008
|
|
|
1008
1009
|
# Date range
|
|
1009
1010
|
today = QDate.currentDate()
|
|
1010
|
-
|
|
1011
|
+
start_of_month = QDate(today.year(), today.month(), 1)
|
|
1012
|
+
|
|
1013
|
+
self.range_preset = QComboBox()
|
|
1014
|
+
self.range_preset.addItem(strings._("custom_range"), "custom")
|
|
1015
|
+
self.range_preset.addItem(strings._("today"), "today")
|
|
1016
|
+
self.range_preset.addItem(strings._("this_week"), "this_week")
|
|
1017
|
+
self.range_preset.addItem(strings._("this_month"), "this_month")
|
|
1018
|
+
self.range_preset.addItem(strings._("this_year"), "this_year")
|
|
1019
|
+
self.range_preset.currentIndexChanged.connect(self._on_range_preset_changed)
|
|
1020
|
+
|
|
1021
|
+
self.from_date = QDateEdit(start_of_month)
|
|
1011
1022
|
self.from_date.setCalendarPopup(True)
|
|
1012
1023
|
self.to_date = QDateEdit(today)
|
|
1013
1024
|
self.to_date.setCalendarPopup(True)
|
|
1014
1025
|
|
|
1015
1026
|
range_row = QHBoxLayout()
|
|
1027
|
+
range_row.addWidget(self.range_preset)
|
|
1016
1028
|
range_row.addWidget(self.from_date)
|
|
1017
1029
|
range_row.addWidget(QLabel("—"))
|
|
1018
1030
|
range_row.addWidget(self.to_date)
|
|
1031
|
+
|
|
1019
1032
|
form.addRow(strings._("date_range"), range_row)
|
|
1020
1033
|
|
|
1034
|
+
# After widgets are created, choose default preset
|
|
1035
|
+
idx = self.range_preset.findData("this_month")
|
|
1036
|
+
if idx != -1:
|
|
1037
|
+
self.range_preset.setCurrentIndex(idx)
|
|
1038
|
+
|
|
1021
1039
|
# Granularity
|
|
1022
1040
|
self.granularity = QComboBox()
|
|
1041
|
+
self.granularity.addItem(strings._("dont_group"), "none")
|
|
1023
1042
|
self.granularity.addItem(strings._("by_day"), "day")
|
|
1024
1043
|
self.granularity.addItem(strings._("by_week"), "week")
|
|
1025
1044
|
self.granularity.addItem(strings._("by_month"), "month")
|
|
@@ -1046,9 +1065,10 @@ class TimeReportDialog(QDialog):
|
|
|
1046
1065
|
|
|
1047
1066
|
# Table
|
|
1048
1067
|
self.table = QTableWidget()
|
|
1049
|
-
self.table.setColumnCount(
|
|
1068
|
+
self.table.setColumnCount(5)
|
|
1050
1069
|
self.table.setHorizontalHeaderLabels(
|
|
1051
1070
|
[
|
|
1071
|
+
strings._("project"),
|
|
1052
1072
|
strings._("time_period"),
|
|
1053
1073
|
strings._("activity"),
|
|
1054
1074
|
strings._("note"),
|
|
@@ -1058,8 +1078,9 @@ class TimeReportDialog(QDialog):
|
|
|
1058
1078
|
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
|
1059
1079
|
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
|
1060
1080
|
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
|
1081
|
+
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
|
|
1061
1082
|
self.table.horizontalHeader().setSectionResizeMode(
|
|
1062
|
-
|
|
1083
|
+
4, QHeaderView.ResizeToContents
|
|
1063
1084
|
)
|
|
1064
1085
|
root.addWidget(self.table, 1)
|
|
1065
1086
|
|
|
@@ -1075,39 +1096,155 @@ class TimeReportDialog(QDialog):
|
|
|
1075
1096
|
close_row.addWidget(close_btn)
|
|
1076
1097
|
root.addLayout(close_row)
|
|
1077
1098
|
|
|
1099
|
+
def _configure_table_columns(self, granularity: str) -> None:
|
|
1100
|
+
if granularity == "none":
|
|
1101
|
+
# Show notes
|
|
1102
|
+
self.table.setColumnCount(5)
|
|
1103
|
+
self.table.setHorizontalHeaderLabels(
|
|
1104
|
+
[
|
|
1105
|
+
strings._("project"),
|
|
1106
|
+
strings._("time_period"),
|
|
1107
|
+
strings._("activity"),
|
|
1108
|
+
strings._("note"),
|
|
1109
|
+
strings._("hours"),
|
|
1110
|
+
]
|
|
1111
|
+
)
|
|
1112
|
+
# project, period, activity, note stretch; hours shrink
|
|
1113
|
+
header = self.table.horizontalHeader()
|
|
1114
|
+
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
|
1115
|
+
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
|
1116
|
+
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
|
1117
|
+
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
|
1118
|
+
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
|
1119
|
+
else:
|
|
1120
|
+
# Grouped: no note column
|
|
1121
|
+
self.table.setColumnCount(4)
|
|
1122
|
+
self.table.setHorizontalHeaderLabels(
|
|
1123
|
+
[
|
|
1124
|
+
strings._("project"),
|
|
1125
|
+
strings._("time_period"),
|
|
1126
|
+
strings._("activity"),
|
|
1127
|
+
strings._("hours"),
|
|
1128
|
+
]
|
|
1129
|
+
)
|
|
1130
|
+
header = self.table.horizontalHeader()
|
|
1131
|
+
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
|
1132
|
+
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
|
1133
|
+
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
|
1134
|
+
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
|
1135
|
+
|
|
1136
|
+
def _on_range_preset_changed(self, index: int) -> None:
|
|
1137
|
+
preset = self.range_preset.currentData()
|
|
1138
|
+
today = QDate.currentDate()
|
|
1139
|
+
|
|
1140
|
+
if preset == "today":
|
|
1141
|
+
start = end = today
|
|
1142
|
+
|
|
1143
|
+
elif preset == "this_week":
|
|
1144
|
+
# Monday-based week, clamp end to today
|
|
1145
|
+
# dayOfWeek(): Monday=1, Sunday=7
|
|
1146
|
+
start = today.addDays(1 - today.dayOfWeek())
|
|
1147
|
+
end = today
|
|
1148
|
+
|
|
1149
|
+
elif preset == "this_month":
|
|
1150
|
+
start = QDate(today.year(), today.month(), 1)
|
|
1151
|
+
end = today
|
|
1152
|
+
|
|
1153
|
+
elif preset == "this_year":
|
|
1154
|
+
start = QDate(today.year(), 1, 1)
|
|
1155
|
+
end = today
|
|
1156
|
+
|
|
1157
|
+
else: # "custom" – leave fields as user-set
|
|
1158
|
+
return
|
|
1159
|
+
|
|
1160
|
+
# Update date edits without triggering anything else
|
|
1161
|
+
self.from_date.blockSignals(True)
|
|
1162
|
+
self.to_date.blockSignals(True)
|
|
1163
|
+
self.from_date.setDate(start)
|
|
1164
|
+
self.to_date.setDate(end)
|
|
1165
|
+
self.from_date.blockSignals(False)
|
|
1166
|
+
self.to_date.blockSignals(False)
|
|
1167
|
+
|
|
1078
1168
|
def _run_report(self):
|
|
1079
1169
|
idx = self.project_combo.currentIndex()
|
|
1080
1170
|
if idx < 0:
|
|
1081
1171
|
return
|
|
1082
|
-
proj_id = int(self.project_combo.itemData(idx))
|
|
1083
1172
|
|
|
1173
|
+
proj_data = self.project_combo.itemData(idx)
|
|
1084
1174
|
start = self.from_date.date().toString("yyyy-MM-dd")
|
|
1085
1175
|
end = self.to_date.date().toString("yyyy-MM-dd")
|
|
1086
1176
|
gran = self.granularity.currentData()
|
|
1087
1177
|
|
|
1088
|
-
# Keep human-friendly copies for PDF header
|
|
1089
|
-
self._last_project_name = self.project_combo.currentText()
|
|
1090
1178
|
self._last_start = start
|
|
1091
1179
|
self._last_end = end
|
|
1092
1180
|
self._last_gran_label = self.granularity.currentText()
|
|
1181
|
+
self._last_gran = gran # remember which grouping was used
|
|
1093
1182
|
|
|
1094
|
-
|
|
1183
|
+
self._configure_table_columns(gran)
|
|
1095
1184
|
|
|
1096
|
-
|
|
1097
|
-
self._last_total_minutes = sum(r[3] for r in rows)
|
|
1185
|
+
rows_for_table: list[tuple[str, str, str, str, int]] = []
|
|
1098
1186
|
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
self.
|
|
1103
|
-
self.
|
|
1104
|
-
|
|
1105
|
-
self.
|
|
1187
|
+
if proj_data is None:
|
|
1188
|
+
# All projects
|
|
1189
|
+
self._last_all_projects = True
|
|
1190
|
+
self._last_project_name = strings._("all_projects")
|
|
1191
|
+
rows_for_table = self._db.time_report_all(start, end, gran)
|
|
1192
|
+
else:
|
|
1193
|
+
self._last_all_projects = False
|
|
1194
|
+
proj_id = int(proj_data)
|
|
1195
|
+
project_name = self.project_combo.currentText()
|
|
1196
|
+
self._last_project_name = project_name
|
|
1197
|
+
|
|
1198
|
+
per_project_rows = self._db.time_report(proj_id, start, end, gran)
|
|
1199
|
+
# Adapt DB rows (period, activity, note, minutes) → include project
|
|
1200
|
+
rows_for_table = [
|
|
1201
|
+
(project_name, period, activity, note, minutes)
|
|
1202
|
+
for (period, activity, note, minutes) in per_project_rows
|
|
1203
|
+
]
|
|
1204
|
+
|
|
1205
|
+
# Store for export
|
|
1206
|
+
self._last_rows = rows_for_table
|
|
1207
|
+
self._last_total_minutes = sum(r[4] for r in rows_for_table)
|
|
1106
1208
|
|
|
1209
|
+
# Per-project totals
|
|
1210
|
+
self._last_project_totals = defaultdict(int)
|
|
1211
|
+
for project, _period, _activity, _note, minutes in rows_for_table:
|
|
1212
|
+
self._last_project_totals[project] += minutes
|
|
1213
|
+
|
|
1214
|
+
# Populate table
|
|
1215
|
+
self.table.setRowCount(len(rows_for_table))
|
|
1216
|
+
for i, (project, time_period, activity_name, note, minutes) in enumerate(
|
|
1217
|
+
rows_for_table
|
|
1218
|
+
):
|
|
1219
|
+
hrs = minutes / 60.0
|
|
1220
|
+
self.table.setItem(i, 0, QTableWidgetItem(project))
|
|
1221
|
+
self.table.setItem(i, 1, QTableWidgetItem(time_period))
|
|
1222
|
+
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
|
|
1223
|
+
|
|
1224
|
+
if self._last_gran == "none":
|
|
1225
|
+
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
|
|
1226
|
+
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
|
|
1227
|
+
else:
|
|
1228
|
+
# no note column
|
|
1229
|
+
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
|
|
1230
|
+
|
|
1231
|
+
# Summary label – include per-project totals when in "all projects" mode
|
|
1107
1232
|
total_hours = self._last_total_minutes / 60.0
|
|
1108
|
-
self.
|
|
1109
|
-
|
|
1110
|
-
|
|
1233
|
+
if self._last_all_projects:
|
|
1234
|
+
per_project_bits = [
|
|
1235
|
+
f"{proj}: {mins/60.0:.2f}h"
|
|
1236
|
+
for proj, mins in sorted(self._last_project_totals.items())
|
|
1237
|
+
]
|
|
1238
|
+
self.total_label.setText(
|
|
1239
|
+
strings._("time_report_total").format(hours=total_hours)
|
|
1240
|
+
+ " ("
|
|
1241
|
+
+ ", ".join(per_project_bits)
|
|
1242
|
+
+ ")"
|
|
1243
|
+
)
|
|
1244
|
+
else:
|
|
1245
|
+
self.total_label.setText(
|
|
1246
|
+
strings._("time_report_total").format(hours=total_hours)
|
|
1247
|
+
)
|
|
1111
1248
|
|
|
1112
1249
|
def _export_csv(self):
|
|
1113
1250
|
if not self._last_rows:
|
|
@@ -1133,20 +1270,33 @@ class TimeReportDialog(QDialog):
|
|
|
1133
1270
|
with open(filename, "w", newline="", encoding="utf-8") as f:
|
|
1134
1271
|
writer = csv.writer(f)
|
|
1135
1272
|
|
|
1273
|
+
show_note = getattr(self, "_last_gran", "day") == "none"
|
|
1274
|
+
|
|
1136
1275
|
# Header
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
)
|
|
1276
|
+
header = [
|
|
1277
|
+
strings._("project"),
|
|
1278
|
+
strings._("time_period"),
|
|
1279
|
+
strings._("activity"),
|
|
1280
|
+
]
|
|
1281
|
+
if show_note:
|
|
1282
|
+
header.append(strings._("note"))
|
|
1283
|
+
header.append(strings._("hours"))
|
|
1284
|
+
writer.writerow(header)
|
|
1145
1285
|
|
|
1146
1286
|
# Data rows
|
|
1147
|
-
for
|
|
1287
|
+
for (
|
|
1288
|
+
project,
|
|
1289
|
+
time_period,
|
|
1290
|
+
activity_name,
|
|
1291
|
+
note,
|
|
1292
|
+
minutes,
|
|
1293
|
+
) in self._last_rows:
|
|
1148
1294
|
hours = minutes / 60.0
|
|
1149
|
-
|
|
1295
|
+
row = [project, time_period, activity_name]
|
|
1296
|
+
if show_note:
|
|
1297
|
+
row.append(note)
|
|
1298
|
+
row.append(f"{hours:.2f}")
|
|
1299
|
+
writer.writerow(row)
|
|
1150
1300
|
|
|
1151
1301
|
# Blank line + total
|
|
1152
1302
|
total_hours = self._last_total_minutes / 60.0
|
|
@@ -1181,7 +1331,7 @@ class TimeReportDialog(QDialog):
|
|
|
1181
1331
|
|
|
1182
1332
|
# ---------- Build chart image (hours per period) ----------
|
|
1183
1333
|
per_period_minutes: dict[str, int] = defaultdict(int)
|
|
1184
|
-
for period, _activity, note, minutes in self._last_rows:
|
|
1334
|
+
for _project, period, _activity, note, minutes in self._last_rows:
|
|
1185
1335
|
per_period_minutes[period] += minutes
|
|
1186
1336
|
|
|
1187
1337
|
periods = sorted(per_period_minutes.keys())
|
|
@@ -1282,10 +1432,11 @@ class TimeReportDialog(QDialog):
|
|
|
1282
1432
|
|
|
1283
1433
|
# Table rows (period, activity, hours)
|
|
1284
1434
|
row_html_parts: list[str] = []
|
|
1285
|
-
for period, activity, note, minutes in self._last_rows:
|
|
1435
|
+
for project, period, activity, note, minutes in self._last_rows:
|
|
1286
1436
|
hours = minutes / 60.0
|
|
1287
1437
|
row_html_parts.append(
|
|
1288
1438
|
"<tr>"
|
|
1439
|
+
f"<td>{html.escape(project)}</td>"
|
|
1289
1440
|
f"<td>{html.escape(period)}</td>"
|
|
1290
1441
|
f"<td>{html.escape(activity)}</td>"
|
|
1291
1442
|
f"<td style='text-align:right'>{hours:.2f}</td>"
|
|
@@ -1343,6 +1494,7 @@ class TimeReportDialog(QDialog):
|
|
|
1343
1494
|
<p><img src="chart" class="chart" /></p>
|
|
1344
1495
|
<table>
|
|
1345
1496
|
<tr>
|
|
1497
|
+
<th>{html.escape(strings._("project"))}</th>
|
|
1346
1498
|
<th>{html.escape(strings._("time_period"))}</th>
|
|
1347
1499
|
<th>{html.escape(strings._("activity"))}</th>
|
|
1348
1500
|
<th>{html.escape(strings._("hours"))}</th>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|