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.
Files changed (44) hide show
  1. {bouquin-0.6.2 → bouquin-0.6.4}/PKG-INFO +1 -1
  2. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/db.py +103 -5
  3. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/en.json +39 -2
  4. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/pomodoro_timer.py +7 -4
  5. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/reminders.py +229 -36
  6. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/time_log.py +184 -32
  7. {bouquin-0.6.2 → bouquin-0.6.4}/pyproject.toml +1 -1
  8. {bouquin-0.6.2 → bouquin-0.6.4}/LICENSE +0 -0
  9. {bouquin-0.6.2 → bouquin-0.6.4}/README.md +0 -0
  10. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/__init__.py +0 -0
  11. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/__main__.py +0 -0
  12. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/bug_report_dialog.py +0 -0
  13. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/code_block_editor_dialog.py +0 -0
  14. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/code_highlighter.py +0 -0
  15. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/document_utils.py +0 -0
  16. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/documents.py +0 -0
  17. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/find_bar.py +0 -0
  18. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/flow_layout.py +0 -0
  19. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/DejaVu.license +0 -0
  20. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/DejaVuSans.ttf +0 -0
  21. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/Noto.license +0 -0
  22. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/fonts/NotoSansSymbols2-Regular.ttf +0 -0
  23. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/history_dialog.py +0 -0
  24. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/icons/bouquin.svg +0 -0
  25. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/key_prompt.py +0 -0
  26. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/keys/mig5.asc +0 -0
  27. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/fr.json +0 -0
  28. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/locales/it.json +0 -0
  29. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/lock_overlay.py +0 -0
  30. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/main.py +0 -0
  31. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/main_window.py +0 -0
  32. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/markdown_editor.py +0 -0
  33. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/markdown_highlighter.py +0 -0
  34. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/save_dialog.py +0 -0
  35. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/search.py +0 -0
  36. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/settings.py +0 -0
  37. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/settings_dialog.py +0 -0
  38. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/statistics_dialog.py +0 -0
  39. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/strings.py +0 -0
  40. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/tag_browser.py +0 -0
  41. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/tags_widget.py +0 -0
  42. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/theme.py +0 -0
  43. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/toolbar.py +0 -0
  44. {bouquin-0.6.2 → bouquin-0.6.4}/bouquin/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bouquin
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher.
5
5
  Home-page: https://git.mig5.net/mig5/bouquin
6
6
  License: GPL-3.0-or-later
@@ -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
- (r["bucket"], r["activity_name"], r["note"], r["total_minutes"])
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
- "set_reminder": "Set reminder",
285
- "edit_reminder": "Edit reminder",
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, QSignalBlocker
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
- blocker = QSignalBlocker(tool_bar.actTimer)
171
- tool_bar.actTimer.setChecked(False)
172
- del blocker
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
- self.time_edit.setTime(QTime.currentTime())
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(QDate.currentDate().dayOfWeek() - 1)
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 selector based on reminder type."""
191
+ """Show/hide weekday / nth selectors based on reminder type."""
145
192
  reminder_type = self.type_combo.currentData()
146
- self.weekday_combo.setVisible(reminder_type == ReminderType.WEEKLY)
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(reminder_type == ReminderType.WEEKLY)
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 == ReminderType.WEEKLY:
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
- # Right now this just means "today at the chosen time".
163
- date_iso = QDate.currentDate().toString("yyyy-MM-dd")
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("Upcoming Reminders")
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("Add Reminder")
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("Manage All Reminders")
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("No upcoming reminders")
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
- if reminder.reminder_type == ReminderType.ONCE:
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
- elif reminder.reminder_type == ReminderType.DAILY:
436
+
437
+ if rtype == ReminderType.DAILY:
342
438
  return True
343
- elif reminder.reminder_type == ReminderType.WEEKDAYS:
439
+
440
+ if rtype == ReminderType.WEEKDAYS:
344
441
  # Monday=1, Sunday=7
345
442
  return 1 <= date.dayOfWeek() <= 5
346
- elif reminder.reminder_type == ReminderType.WEEKLY:
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("Edit", self)
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 = "Delete"
586
+ delete_text = strings._("delete")
443
587
  else:
444
- delete_text = f"Delete {len(selected_items)} Reminders"
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 = f"Delete reminder '{reminder.text}'?"
619
+ msg = (
620
+ strings._("delete")
621
+ + " "
622
+ + strings._("reminder")
623
+ + f" '{reminder.text}'?"
624
+ )
472
625
  if reminder.reminder_type != ReminderType.ONCE:
473
- msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
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 = f"Delete {len(unique_reminders)} reminders?\n\nNote: This will delete the actual reminders, not just individual occurrences."
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
- "Delete Reminders",
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 = f"Delete reminder '{reminder.text}'?"
656
+ msg = strings._("delete") + " " + strings._("reminder") + f" '{reminder.text}'?"
493
657
  if reminder.reminder_type != ReminderType.ONCE:
494
- msg += f"\n\nNote: This is a {reminder.reminder_type.value} reminder. Deleting it will remove all future occurrences."
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
- "Delete Reminder",
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("Manage Reminders")
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
- ["Text", "Time", "Type", "Active", "Actions"]
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("Add Reminder")
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("Close")
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 == ReminderType.WEEKLY
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 = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
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("Edit")
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("Delete")
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
- "Delete Reminder",
636
- f"Delete reminder '{reminder.text}'?",
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
- self.from_date = QDateEdit(today.addDays(-7))
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(4)
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
- 3, QHeaderView.ResizeToContents
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
- rows = self._db.time_report(proj_id, start, end, gran)
1183
+ self._configure_table_columns(gran)
1095
1184
 
1096
- self._last_rows = rows
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
- self.table.setRowCount(len(rows))
1100
- for i, (time_period, activity_name, note, minutes) in enumerate(rows):
1101
- hrs = minutes / 60.0
1102
- self.table.setItem(i, 0, QTableWidgetItem(time_period))
1103
- self.table.setItem(i, 1, QTableWidgetItem(activity_name))
1104
- self.table.setItem(i, 2, QTableWidgetItem(note))
1105
- self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
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.total_label.setText(
1109
- strings._("time_report_total").format(hours=total_hours)
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
- writer.writerow(
1138
- [
1139
- strings._("time_period"),
1140
- strings._("activity"),
1141
- strings._("note"),
1142
- strings._("hours"),
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 time_period, activity_name, note, minutes in self._last_rows:
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
- writer.writerow([time_period, activity_name, note, f"{hours:.2f}"])
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>
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "bouquin"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
5
5
  authors = ["Miguel Jacq <mig@mig5.net>"]
6
6
  readme = "README.md"
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