Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 886b809bd3 | |||
| e6010969cb | |||
| 492633df9f | |||
| dcb62d34af | |||
| 13b1ad7373 | |||
| 7abd99fe24 | |||
| 2112de39b8 |
16 changed files with 273 additions and 66 deletions
26
.pre-commit-config.yaml
Normal file
26
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/pycqa/flake8
|
||||||
|
rev: 7.3.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
args: ["--select=F"]
|
||||||
|
types: [python]
|
||||||
|
|
||||||
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
|
rev: 25.11.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.4.0
|
||||||
|
hooks:
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/bandit
|
||||||
|
rev: 1.9.2
|
||||||
|
hooks:
|
||||||
|
- id: bandit
|
||||||
|
files: ^bouquin/
|
||||||
|
args: ["-s", "B110"]
|
||||||
|
|
@ -1,3 +1,12 @@
|
||||||
|
# 0.7.3
|
||||||
|
|
||||||
|
* Allow optionally moving unchecked TODOs to the next day (even if it's the weekend) rather than next weekday.
|
||||||
|
* Add 'group by activity' in timesheet/invoice reports, rather than just by time period.
|
||||||
|
|
||||||
|
# 0.7.2
|
||||||
|
|
||||||
|
* Fix Manage Reminders dialog (the actions column was missing, to edit/delete reminders)
|
||||||
|
|
||||||
# 0.7.1
|
# 0.7.1
|
||||||
|
|
||||||
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
|
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ class DBConfig:
|
||||||
idle_minutes: int = 15 # 0 = never lock
|
idle_minutes: int = 15 # 0 = never lock
|
||||||
theme: str = "system"
|
theme: str = "system"
|
||||||
move_todos: bool = False
|
move_todos: bool = False
|
||||||
|
move_todos_include_weekends: bool = False
|
||||||
tags: bool = True
|
tags: bool = True
|
||||||
time_log: bool = True
|
time_log: bool = True
|
||||||
reminders: bool = True
|
reminders: bool = True
|
||||||
|
|
@ -1351,7 +1352,7 @@ class DBManager:
|
||||||
project_id: int,
|
project_id: int,
|
||||||
start_date_iso: str,
|
start_date_iso: str,
|
||||||
end_date_iso: str,
|
end_date_iso: str,
|
||||||
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
|
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
|
||||||
) -> list[tuple[str, str, str, int]]:
|
) -> list[tuple[str, str, str, int]]:
|
||||||
"""
|
"""
|
||||||
Return (time_period, activity_name, total_minutes) tuples between start and end
|
Return (time_period, activity_name, total_minutes) tuples between start and end
|
||||||
|
|
@ -1360,7 +1361,8 @@ class DBManager:
|
||||||
- 'YYYY-MM-DD' for day
|
- 'YYYY-MM-DD' for day
|
||||||
- 'YYYY-WW' for week
|
- 'YYYY-WW' for week
|
||||||
- 'YYYY-MM' for month
|
- 'YYYY-MM' for month
|
||||||
For 'none' granularity, each individual time log entry becomes a row.
|
For 'activity' granularity, results are grouped by activity only (no time bucket).
|
||||||
|
For 'none' granularity, each individual time log entry becomes a row.
|
||||||
"""
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
|
@ -1387,6 +1389,26 @@ class DBManager:
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if granularity == "activity":
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
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 activity_name
|
||||||
|
ORDER BY LOWER(activity_name);
|
||||||
|
""",
|
||||||
|
(project_id, start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# period column is unused for activity grouping in the UI, but we keep
|
||||||
|
# the tuple shape consistent.
|
||||||
|
return [("", r["activity_name"], "", r["total_minutes"]) for r in rows]
|
||||||
|
|
||||||
if granularity == "day":
|
if granularity == "day":
|
||||||
bucket_expr = "page_date"
|
bucket_expr = "page_date"
|
||||||
elif granularity == "week":
|
elif granularity == "week":
|
||||||
|
|
@ -1417,11 +1439,14 @@ class DBManager:
|
||||||
self,
|
self,
|
||||||
start_date_iso: str,
|
start_date_iso: str,
|
||||||
end_date_iso: str,
|
end_date_iso: str,
|
||||||
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
|
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
|
||||||
) -> list[tuple[str, str, str, str, int]]:
|
) -> list[tuple[str, str, str, str, int]]:
|
||||||
"""
|
"""
|
||||||
Return (project_name, time_period, activity_name, note, total_minutes)
|
Return (project_name, time_period, activity_name, note, total_minutes)
|
||||||
across *all* projects between start and end, grouped by project + period + activity.
|
across *all* projects between start and end.
|
||||||
|
- For 'day'/'week'/'month', grouped by project + period + activity.
|
||||||
|
- For 'activity', grouped by project + activity.
|
||||||
|
- For 'none', one row per time_log entry.
|
||||||
"""
|
"""
|
||||||
cur = self.conn.cursor()
|
cur = self.conn.cursor()
|
||||||
|
|
||||||
|
|
@ -1455,6 +1480,34 @@ class DBManager:
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if granularity == "activity":
|
||||||
|
rows = cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
p.name AS project_name,
|
||||||
|
a.name AS activity_name,
|
||||||
|
SUM(t.minutes) AS total_minutes
|
||||||
|
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 BETWEEN ? AND ?
|
||||||
|
GROUP BY p.id, activity_name
|
||||||
|
ORDER BY LOWER(p.name), LOWER(activity_name);
|
||||||
|
""",
|
||||||
|
(start_date_iso, end_date_iso),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
r["project_name"],
|
||||||
|
"",
|
||||||
|
r["activity_name"],
|
||||||
|
"",
|
||||||
|
r["total_minutes"],
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
if granularity == "day":
|
if granularity == "day":
|
||||||
bucket_expr = "page_date"
|
bucket_expr = "page_date"
|
||||||
elif granularity == "week":
|
elif granularity == "week":
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Fonts, only if the fonts are renamed to names not containing either
|
||||||
the words "Tavmjong Bah" or the word "Arev".
|
the words "Tavmjong Bah" or the word "Arev".
|
||||||
|
|
||||||
This License becomes null and void to the extent applicable to Fonts
|
This License becomes null and void to the extent applicable to Fonts
|
||||||
or Font Software that has been modified and is distributed under the
|
or Font Software that has been modified and is distributed under the
|
||||||
"Tavmjong Bah Arev" names.
|
"Tavmjong Bah Arev" names.
|
||||||
|
|
||||||
The Font Software may be sold as part of a larger software package but
|
The Font Software may be sold as part of a larger software package but
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ with others.
|
||||||
|
|
||||||
The OFL allows the licensed fonts to be used, studied, modified and
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
redistributed freely as long as they are not sold by themselves. The
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
fonts, including any derivative works, can be bundled, embedded,
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
redistributed and/or sold with any software provided that any reserved
|
redistributed and/or sold with any software provided that any reserved
|
||||||
names are not used by derivative works. The fonts and derivatives,
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
however, cannot be released under any other type of license. The
|
however, cannot be released under any other type of license. The
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@
|
||||||
"autosave": "autosave",
|
"autosave": "autosave",
|
||||||
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
|
"unchecked_checkbox_items_moved_to_next_day": "Unchecked checkbox items moved to next day",
|
||||||
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
"move_unchecked_todos_to_today_on_startup": "Automatically move unchecked TODOs\nfrom the last 7 days to next weekday",
|
||||||
|
"move_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
|
||||||
"insert_images": "Insert images",
|
"insert_images": "Insert images",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
"reopen_failed": "Re-open failed",
|
"reopen_failed": "Re-open failed",
|
||||||
|
|
@ -171,7 +172,7 @@
|
||||||
"stats_metric_revisions": "Revisions",
|
"stats_metric_revisions": "Revisions",
|
||||||
"stats_metric_documents": "Documents",
|
"stats_metric_documents": "Documents",
|
||||||
"stats_total_documents": "Total documents",
|
"stats_total_documents": "Total documents",
|
||||||
"stats_date_most_documents": "Date with most documents",
|
"stats_date_most_documents": "Date with most documents",
|
||||||
"stats_no_data": "No statistics available yet.",
|
"stats_no_data": "No statistics available yet.",
|
||||||
"stats_time_total_hours": "Total hours logged",
|
"stats_time_total_hours": "Total hours logged",
|
||||||
"stats_time_day_most_hours": "Day with most hours logged",
|
"stats_time_day_most_hours": "Day with most hours logged",
|
||||||
|
|
@ -209,6 +210,7 @@
|
||||||
"add_time_entry": "Add time entry",
|
"add_time_entry": "Add time entry",
|
||||||
"time_period": "Time period",
|
"time_period": "Time period",
|
||||||
"dont_group": "Don't group",
|
"dont_group": "Don't group",
|
||||||
|
"by_activity": "by activity",
|
||||||
"by_day": "by day",
|
"by_day": "by day",
|
||||||
"by_month": "by month",
|
"by_month": "by month",
|
||||||
"by_week": "by week",
|
"by_week": "by week",
|
||||||
|
|
@ -375,7 +377,7 @@
|
||||||
"documents_missing_file": "The file does not exist:\n{path}",
|
"documents_missing_file": "The file does not exist:\n{path}",
|
||||||
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
|
||||||
"documents_search_label": "Search",
|
"documents_search_label": "Search",
|
||||||
"documents_search_placeholder": "Type to search documents (all projects)",
|
"documents_search_placeholder": "Type to search documents (all projects)",
|
||||||
"todays_documents": "Documents from this day",
|
"todays_documents": "Documents from this day",
|
||||||
"todays_documents_none": "No documents yet.",
|
"todays_documents_none": "No documents yet.",
|
||||||
"manage_invoices": "Manage Invoices",
|
"manage_invoices": "Manage Invoices",
|
||||||
|
|
|
||||||
|
|
@ -822,9 +822,13 @@ class MainWindow(QMainWindow):
|
||||||
Given a 'new day' (system date), return the date we should move
|
Given a 'new day' (system date), return the date we should move
|
||||||
unfinished todos *to*.
|
unfinished todos *to*.
|
||||||
|
|
||||||
If the new day is Saturday or Sunday, we skip ahead to the next Monday.
|
By default, if the new day is Saturday or Sunday we skip ahead to the
|
||||||
Otherwise we just return the same day.
|
next Monday (i.e., "next available weekday"). If the optional setting
|
||||||
|
`move_todos_include_weekends` is enabled, we move to the very next day
|
||||||
|
even if it's a weekend.
|
||||||
"""
|
"""
|
||||||
|
if getattr(self.cfg, "move_todos_include_weekends", False):
|
||||||
|
return day
|
||||||
# Qt: Monday=1 ... Sunday=7
|
# Qt: Monday=1 ... Sunday=7
|
||||||
dow = day.dayOfWeek()
|
dow = day.dayOfWeek()
|
||||||
if dow >= 6: # Saturday (6) or Sunday (7)
|
if dow >= 6: # Saturday (6) or Sunday (7)
|
||||||
|
|
@ -1566,6 +1570,11 @@ class MainWindow(QMainWindow):
|
||||||
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
||||||
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
|
self.cfg.theme = getattr(new_cfg, "theme", self.cfg.theme)
|
||||||
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
self.cfg.move_todos = getattr(new_cfg, "move_todos", self.cfg.move_todos)
|
||||||
|
self.cfg.move_todos_include_weekends = getattr(
|
||||||
|
new_cfg,
|
||||||
|
"move_todos_include_weekends",
|
||||||
|
getattr(self.cfg, "move_todos_include_weekends", False),
|
||||||
|
)
|
||||||
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
self.cfg.tags = getattr(new_cfg, "tags", self.cfg.tags)
|
||||||
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
self.cfg.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
|
||||||
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)
|
||||||
|
|
|
||||||
|
|
@ -706,7 +706,7 @@ class ManageRemindersDialog(QDialog):
|
||||||
|
|
||||||
# Reminder list table
|
# Reminder list table
|
||||||
self.table = QTableWidget()
|
self.table = QTableWidget()
|
||||||
self.table.setColumnCount(5)
|
self.table.setColumnCount(6)
|
||||||
self.table.setHorizontalHeaderLabels(
|
self.table.setHorizontalHeaderLabels(
|
||||||
[
|
[
|
||||||
strings._("text"),
|
strings._("text"),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ def load_db_config() -> DBConfig:
|
||||||
idle = s.value("ui/idle_minutes", 15, type=int)
|
idle = s.value("ui/idle_minutes", 15, type=int)
|
||||||
theme = s.value("ui/theme", "system", type=str)
|
theme = s.value("ui/theme", "system", type=str)
|
||||||
move_todos = s.value("ui/move_todos", False, type=bool)
|
move_todos = s.value("ui/move_todos", False, type=bool)
|
||||||
|
move_todos_include_weekends = s.value(
|
||||||
|
"ui/move_todos_include_weekends", False, type=bool
|
||||||
|
)
|
||||||
tags = s.value("ui/tags", True, type=bool)
|
tags = s.value("ui/tags", True, type=bool)
|
||||||
time_log = s.value("ui/time_log", True, type=bool)
|
time_log = s.value("ui/time_log", True, type=bool)
|
||||||
reminders = s.value("ui/reminders", True, type=bool)
|
reminders = s.value("ui/reminders", True, type=bool)
|
||||||
|
|
@ -57,6 +60,7 @@ def load_db_config() -> DBConfig:
|
||||||
idle_minutes=idle,
|
idle_minutes=idle,
|
||||||
theme=theme,
|
theme=theme,
|
||||||
move_todos=move_todos,
|
move_todos=move_todos,
|
||||||
|
move_todos_include_weekends=move_todos_include_weekends,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
time_log=time_log,
|
time_log=time_log,
|
||||||
reminders=reminders,
|
reminders=reminders,
|
||||||
|
|
@ -76,6 +80,7 @@ def save_db_config(cfg: DBConfig) -> None:
|
||||||
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
|
||||||
s.setValue("ui/theme", str(cfg.theme))
|
s.setValue("ui/theme", str(cfg.theme))
|
||||||
s.setValue("ui/move_todos", str(cfg.move_todos))
|
s.setValue("ui/move_todos", str(cfg.move_todos))
|
||||||
|
s.setValue("ui/move_todos_include_weekends", str(cfg.move_todos_include_weekends))
|
||||||
s.setValue("ui/tags", str(cfg.tags))
|
s.setValue("ui/tags", str(cfg.tags))
|
||||||
s.setValue("ui/time_log", str(cfg.time_log))
|
s.setValue("ui/time_log", str(cfg.time_log))
|
||||||
s.setValue("ui/reminders", str(cfg.reminders))
|
s.setValue("ui/reminders", str(cfg.reminders))
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,25 @@ class SettingsDialog(QDialog):
|
||||||
self.move_todos.setCursor(Qt.PointingHandCursor)
|
self.move_todos.setCursor(Qt.PointingHandCursor)
|
||||||
features_layout.addWidget(self.move_todos)
|
features_layout.addWidget(self.move_todos)
|
||||||
|
|
||||||
|
# Optional: allow moving to the very next day even if it is a weekend.
|
||||||
|
self.move_todos_include_weekends = QCheckBox(
|
||||||
|
strings._("move_todos_include_weekends")
|
||||||
|
)
|
||||||
|
self.move_todos_include_weekends.setChecked(
|
||||||
|
getattr(self.current_settings, "move_todos_include_weekends", False)
|
||||||
|
)
|
||||||
|
self.move_todos_include_weekends.setCursor(Qt.PointingHandCursor)
|
||||||
|
self.move_todos_include_weekends.setEnabled(self.move_todos.isChecked())
|
||||||
|
|
||||||
|
move_todos_opts = QWidget()
|
||||||
|
move_todos_opts_layout = QVBoxLayout(move_todos_opts)
|
||||||
|
move_todos_opts_layout.setContentsMargins(24, 0, 0, 0)
|
||||||
|
move_todos_opts_layout.setSpacing(4)
|
||||||
|
move_todos_opts_layout.addWidget(self.move_todos_include_weekends)
|
||||||
|
features_layout.addWidget(move_todos_opts)
|
||||||
|
|
||||||
|
self.move_todos.toggled.connect(self.move_todos_include_weekends.setEnabled)
|
||||||
|
|
||||||
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
self.tags = QCheckBox(strings._("enable_tags_feature"))
|
||||||
self.tags.setChecked(self.current_settings.tags)
|
self.tags.setChecked(self.current_settings.tags)
|
||||||
self.tags.setCursor(Qt.PointingHandCursor)
|
self.tags.setCursor(Qt.PointingHandCursor)
|
||||||
|
|
@ -441,6 +460,7 @@ class SettingsDialog(QDialog):
|
||||||
idle_minutes=self.idle_spin.value(),
|
idle_minutes=self.idle_spin.value(),
|
||||||
theme=selected_theme.value,
|
theme=selected_theme.value,
|
||||||
move_todos=self.move_todos.isChecked(),
|
move_todos=self.move_todos.isChecked(),
|
||||||
|
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
|
||||||
tags=self.tags.isChecked(),
|
tags=self.tags.isChecked(),
|
||||||
time_log=self.time_log.isChecked(),
|
time_log=self.time_log.isChecked(),
|
||||||
reminders=self.reminders.isChecked(),
|
reminders=self.reminders.isChecked(),
|
||||||
|
|
|
||||||
|
|
@ -1083,6 +1083,7 @@ class TimeReportDialog(QDialog):
|
||||||
self.granularity.addItem(strings._("by_day"), "day")
|
self.granularity.addItem(strings._("by_day"), "day")
|
||||||
self.granularity.addItem(strings._("by_week"), "week")
|
self.granularity.addItem(strings._("by_week"), "week")
|
||||||
self.granularity.addItem(strings._("by_month"), "month")
|
self.granularity.addItem(strings._("by_month"), "month")
|
||||||
|
self.granularity.addItem(strings._("by_activity"), "activity")
|
||||||
form.addRow(strings._("group_by"), self.granularity)
|
form.addRow(strings._("group_by"), self.granularity)
|
||||||
|
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
@ -1161,6 +1162,20 @@ class TimeReportDialog(QDialog):
|
||||||
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
header.setSectionResizeMode(2, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
header.setSectionResizeMode(3, QHeaderView.Stretch)
|
||||||
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||||
|
elif granularity == "activity":
|
||||||
|
# Grouped by activity only: no time period, no note column
|
||||||
|
self.table.setColumnCount(3)
|
||||||
|
self.table.setHorizontalHeaderLabels(
|
||||||
|
[
|
||||||
|
strings._("project"),
|
||||||
|
strings._("activity"),
|
||||||
|
strings._("hours"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
header = self.table.horizontalHeader()
|
||||||
|
header.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
|
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||||
else:
|
else:
|
||||||
# Grouped: no note column
|
# Grouped: no note column
|
||||||
self.table.setColumnCount(4)
|
self.table.setColumnCount(4)
|
||||||
|
|
@ -1272,16 +1287,21 @@ class TimeReportDialog(QDialog):
|
||||||
rows_for_table
|
rows_for_table
|
||||||
):
|
):
|
||||||
hrs = minutes / 60.0
|
hrs = minutes / 60.0
|
||||||
self.table.setItem(i, 0, QTableWidgetItem(project))
|
if self._last_gran == "activity":
|
||||||
self.table.setItem(i, 1, QTableWidgetItem(time_period))
|
self.table.setItem(i, 0, QTableWidgetItem(project))
|
||||||
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
|
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
|
||||||
|
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
if self._last_gran == "none":
|
|
||||||
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
|
|
||||||
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
|
|
||||||
else:
|
else:
|
||||||
# no note column
|
self.table.setItem(i, 0, QTableWidgetItem(project))
|
||||||
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
|
self.table.setItem(i, 1, QTableWidgetItem(time_period))
|
||||||
|
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
|
||||||
|
|
||||||
|
if self._last_gran == "none":
|
||||||
|
self.table.setItem(i, 3, QTableWidgetItem(note or ""))
|
||||||
|
self.table.setItem(i, 4, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
|
else:
|
||||||
|
# no note column
|
||||||
|
self.table.setItem(i, 3, QTableWidgetItem(f"{hrs:.2f}"))
|
||||||
|
|
||||||
# Summary label - include per-project totals when in "all projects" mode
|
# Summary label - include per-project totals when in "all projects" mode
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
|
|
@ -1325,14 +1345,15 @@ class TimeReportDialog(QDialog):
|
||||||
with open(filename, "w", newline="", encoding="utf-8") as f:
|
with open(filename, "w", newline="", encoding="utf-8") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
|
|
||||||
show_note = getattr(self, "_last_gran", "day") == "none"
|
gran = getattr(self, "_last_gran", "day")
|
||||||
|
show_note = gran == "none"
|
||||||
|
show_period = gran != "activity"
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
header = [
|
header: list[str] = [strings._("project")]
|
||||||
strings._("project"),
|
if show_period:
|
||||||
strings._("time_period"),
|
header.append(strings._("time_period"))
|
||||||
strings._("activity"),
|
header.append(strings._("activity"))
|
||||||
]
|
|
||||||
if show_note:
|
if show_note:
|
||||||
header.append(strings._("note"))
|
header.append(strings._("note"))
|
||||||
header.append(strings._("hours"))
|
header.append(strings._("hours"))
|
||||||
|
|
@ -1347,16 +1368,22 @@ class TimeReportDialog(QDialog):
|
||||||
minutes,
|
minutes,
|
||||||
) in self._last_rows:
|
) in self._last_rows:
|
||||||
hours = minutes / 60.0
|
hours = minutes / 60.0
|
||||||
row = [project, time_period, activity_name]
|
row: list[str] = [project]
|
||||||
|
if show_period:
|
||||||
|
row.append(time_period)
|
||||||
|
row.append(activity_name)
|
||||||
if show_note:
|
if show_note:
|
||||||
row.append(note)
|
row.append(note or "")
|
||||||
row.append(f"{hours:.2f}")
|
row.append(f"{hours:.2f}")
|
||||||
writer.writerow(row)
|
writer.writerow(row)
|
||||||
|
|
||||||
# Blank line + total
|
# Blank line + total
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
writer.writerow([])
|
writer.writerow([])
|
||||||
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
|
total_row = [""] * len(header)
|
||||||
|
total_row[0] = strings._("total")
|
||||||
|
total_row[-1] = f"{total_hours:.2f}"
|
||||||
|
writer.writerow(total_row)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
QMessageBox.warning(
|
QMessageBox.warning(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1384,17 +1411,20 @@ class TimeReportDialog(QDialog):
|
||||||
if not filename.endswith(".pdf"):
|
if not filename.endswith(".pdf"):
|
||||||
filename = f"{filename}.pdf"
|
filename = f"{filename}.pdf"
|
||||||
|
|
||||||
# ---------- Build chart image (hours per period) ----------
|
# ---------- Build chart image ----------
|
||||||
per_period_minutes: dict[str, int] = defaultdict(int)
|
# Default: hours per time period. If grouped by activity: hours per activity.
|
||||||
for _project, period, _activity, note, minutes in self._last_rows:
|
gran = getattr(self, "_last_gran", "day")
|
||||||
per_period_minutes[period] += minutes
|
per_bucket_minutes: dict[str, int] = defaultdict(int)
|
||||||
|
for _project, period, activity, _note, minutes in self._last_rows:
|
||||||
|
bucket = activity if gran == "activity" else period
|
||||||
|
per_bucket_minutes[bucket] += minutes
|
||||||
|
|
||||||
periods = sorted(per_period_minutes.keys())
|
buckets = sorted(per_bucket_minutes.keys())
|
||||||
chart_w, chart_h = 800, 220
|
chart_w, chart_h = 800, 220
|
||||||
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
|
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
|
||||||
chart.fill(Qt.white)
|
chart.fill(Qt.white)
|
||||||
|
|
||||||
if periods:
|
if buckets:
|
||||||
painter = QPainter(chart)
|
painter = QPainter(chart)
|
||||||
try:
|
try:
|
||||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||||
|
|
@ -1422,9 +1452,9 @@ class TimeReportDialog(QDialog):
|
||||||
# Border
|
# Border
|
||||||
painter.drawRect(left, top, width, height)
|
painter.drawRect(left, top, width, height)
|
||||||
|
|
||||||
max_hours = max(per_period_minutes[p] for p in periods) / 60.0
|
max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
|
||||||
if max_hours > 0:
|
if max_hours > 0:
|
||||||
n = len(periods)
|
n = len(buckets)
|
||||||
bar_spacing = width / max(1, n)
|
bar_spacing = width / max(1, n)
|
||||||
bar_width = bar_spacing * 0.6
|
bar_width = bar_spacing * 0.6
|
||||||
|
|
||||||
|
|
@ -1449,8 +1479,8 @@ class TimeReportDialog(QDialog):
|
||||||
painter.setBrush(QColor(80, 140, 200))
|
painter.setBrush(QColor(80, 140, 200))
|
||||||
painter.setPen(Qt.NoPen)
|
painter.setPen(Qt.NoPen)
|
||||||
|
|
||||||
for i, period in enumerate(periods):
|
for i, label in enumerate(buckets):
|
||||||
hours = per_period_minutes[period] / 60.0
|
hours = per_bucket_minutes[label] / 60.0
|
||||||
bar_h = int((hours / max_hours) * (height - 10))
|
bar_h = int((hours / max_hours) * (height - 10))
|
||||||
if bar_h <= 0:
|
if bar_h <= 0:
|
||||||
continue # pragma: no cover
|
continue # pragma: no cover
|
||||||
|
|
@ -1463,7 +1493,7 @@ class TimeReportDialog(QDialog):
|
||||||
|
|
||||||
# X labels after bars, in black
|
# X labels after bars, in black
|
||||||
painter.setPen(Qt.black)
|
painter.setPen(Qt.black)
|
||||||
for i, period in enumerate(periods):
|
for i, label in enumerate(buckets):
|
||||||
x_center = left + bar_spacing * (i + 0.5)
|
x_center = left + bar_spacing * (i + 0.5)
|
||||||
x = int(x_center - bar_width / 2)
|
x = int(x_center - bar_width / 2)
|
||||||
painter.drawText(
|
painter.drawText(
|
||||||
|
|
@ -1472,7 +1502,7 @@ class TimeReportDialog(QDialog):
|
||||||
int(bar_width),
|
int(bar_width),
|
||||||
20,
|
20,
|
||||||
Qt.AlignHCenter | Qt.AlignTop,
|
Qt.AlignHCenter | Qt.AlignTop,
|
||||||
period,
|
label,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
painter.end()
|
painter.end()
|
||||||
|
|
@ -1481,23 +1511,53 @@ class TimeReportDialog(QDialog):
|
||||||
project = html.escape(self._last_project_name or "")
|
project = html.escape(self._last_project_name or "")
|
||||||
start = html.escape(self._last_start or "")
|
start = html.escape(self._last_start or "")
|
||||||
end = html.escape(self._last_end or "")
|
end = html.escape(self._last_end or "")
|
||||||
gran = html.escape(self._last_gran_label or "")
|
gran_key = getattr(self, "_last_gran", "day")
|
||||||
|
gran_label = html.escape(self._last_gran_label or "")
|
||||||
|
|
||||||
total_hours = self._last_total_minutes / 60.0
|
total_hours = self._last_total_minutes / 60.0
|
||||||
|
|
||||||
# Table rows (period, activity, hours)
|
# Table rows
|
||||||
row_html_parts: list[str] = []
|
row_html_parts: list[str] = []
|
||||||
for project, period, activity, note, minutes in self._last_rows:
|
if gran_key == "activity":
|
||||||
hours = minutes / 60.0
|
for project, _period, activity, _note, minutes in self._last_rows:
|
||||||
row_html_parts.append(
|
hours = minutes / 60.0
|
||||||
|
row_html_parts.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(project)}</td>"
|
||||||
|
f"<td>{html.escape(activity)}</td>"
|
||||||
|
f"<td style='text-align:right'>{hours:.2f}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for project, period, activity, _note, minutes in self._last_rows:
|
||||||
|
hours = minutes / 60.0
|
||||||
|
row_html_parts.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{html.escape(project)}</td>"
|
||||||
|
f"<td>{html.escape(period)}</td>"
|
||||||
|
f"<td>{html.escape(activity)}</td>"
|
||||||
|
f"<td style='text-align:right'>{hours:.2f}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
rows_html = "\n".join(row_html_parts)
|
||||||
|
|
||||||
|
if gran_key == "activity":
|
||||||
|
table_header_html = (
|
||||||
"<tr>"
|
"<tr>"
|
||||||
f"<td>{html.escape(project)}</td>"
|
f"<th>{html.escape(strings._('project'))}</th>"
|
||||||
f"<td>{html.escape(period)}</td>"
|
f"<th>{html.escape(strings._('activity'))}</th>"
|
||||||
f"<td>{html.escape(activity)}</td>"
|
f"<th>{html.escape(strings._('hours'))}</th>"
|
||||||
f"<td style='text-align:right'>{hours:.2f}</td>"
|
"</tr>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
table_header_html = (
|
||||||
|
"<tr>"
|
||||||
|
f"<th>{html.escape(strings._('project'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('time_period'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('activity'))}</th>"
|
||||||
|
f"<th>{html.escape(strings._('hours'))}</th>"
|
||||||
"</tr>"
|
"</tr>"
|
||||||
)
|
)
|
||||||
rows_html = "\n".join(row_html_parts)
|
|
||||||
|
|
||||||
html_doc = f"""
|
html_doc = f"""
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
@ -1544,16 +1604,11 @@ class TimeReportDialog(QDialog):
|
||||||
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
|
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
|
||||||
<p class="meta">
|
<p class="meta">
|
||||||
{html.escape(strings._("time_log_report_meta").format(
|
{html.escape(strings._("time_log_report_meta").format(
|
||||||
start=start, end=end, granularity=gran))}
|
start=start, end=end, granularity=gran_label))}
|
||||||
</p>
|
</p>
|
||||||
<p><img src="chart" class="chart" /></p>
|
<p><img src="chart" class="chart" /></p>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
{table_header_html}
|
||||||
<th>{html.escape(strings._("project"))}</th>
|
|
||||||
<th>{html.escape(strings._("time_period"))}</th>
|
|
||||||
<th>{html.escape(strings._("activity"))}</th>
|
|
||||||
<th>{html.escape(strings._("hours"))}</th>
|
|
||||||
</tr>
|
|
||||||
{rows_html}
|
{rows_html}
|
||||||
</table>
|
</table>
|
||||||
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
|
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>
|
||||||
|
|
|
||||||
6
poetry.lock
generated
6
poetry.lock
generated
|
|
@ -747,13 +747,13 @@ files = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.1"
|
version = "2.6.2"
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
files = [
|
files = [
|
||||||
{file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
|
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
|
||||||
{file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
|
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.7.1"
|
version = "0.7.3"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
32
release.sh
32
release.sh
|
|
@ -2,6 +2,34 @@
|
||||||
|
|
||||||
set -eo pipefail
|
set -eo pipefail
|
||||||
|
|
||||||
|
# Parse the args
|
||||||
|
while getopts "v:" OPTION
|
||||||
|
do
|
||||||
|
case $OPTION in
|
||||||
|
v)
|
||||||
|
VERSION=$OPTARG
|
||||||
|
;;
|
||||||
|
?)
|
||||||
|
usage
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
echo "You forgot to pass -v [version]!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
set +e
|
||||||
|
sed -i s/version.*$/version\ =\ \"${VERSION}\"/g pyproject.toml
|
||||||
|
|
||||||
|
git add pyproject.toml
|
||||||
|
git commit -m "Bump to ${VERSION}"
|
||||||
|
git push origin main
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
# Clean caches etc
|
# Clean caches etc
|
||||||
filedust -y .
|
filedust -y .
|
||||||
|
|
||||||
|
|
@ -10,11 +38,11 @@ poetry build
|
||||||
poetry publish
|
poetry publish
|
||||||
|
|
||||||
# Make AppImage
|
# Make AppImage
|
||||||
sudo apt-get install libfuse-dev
|
sudo apt-get -y install libfuse-dev
|
||||||
poetry run pyproject-appimage
|
poetry run pyproject-appimage
|
||||||
mv Bouquin.AppImage dist/
|
mv Bouquin.AppImage dist/
|
||||||
|
|
||||||
# Sign packages
|
# Sign packages
|
||||||
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
|
||||||
|
|
||||||
echo "Don't forget to update version string on remote server."
|
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
|
||||||
|
|
|
||||||
|
|
@ -1574,7 +1574,7 @@ def test_markdown_highlighter_special_characters(qtbot, app):
|
||||||
highlighter = MarkdownHighlighter(doc, theme_manager)
|
highlighter = MarkdownHighlighter(doc, theme_manager)
|
||||||
|
|
||||||
text = """
|
text = """
|
||||||
Special chars: < > & " '
|
Special chars: < > & " '
|
||||||
Escaped: \\* \\_ \\`
|
Escaped: \\* \\_ \\`
|
||||||
Unicode: 你好 café résumé
|
Unicode: 你好 café résumé
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1185,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
|
||||||
qtbot.addWidget(dialog)
|
qtbot.addWidget(dialog)
|
||||||
|
|
||||||
assert dialog.project_combo.count() == 1
|
assert dialog.project_combo.count() == 1
|
||||||
assert dialog.granularity.count() == 4
|
assert dialog.granularity.count() == 5
|
||||||
|
|
||||||
|
|
||||||
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
def test_time_report_dialog_loads_projects(qtbot, fresh_db):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue