Compare commits

..

No commits in common. "main" and "0.7.1" have entirely different histories.
main ... 0.7.1

16 changed files with 68 additions and 275 deletions

View file

@ -1,26 +0,0 @@
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"]

View file

@ -1,12 +1,3 @@
# 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
* Reduce the scope for toggling a checkbox on/off when not clicking precisely on it (must be to the left of the first letter)

View file

@ -92,7 +92,6 @@ class DBConfig:
idle_minutes: int = 15 # 0 = never lock
theme: str = "system"
move_todos: bool = False
move_todos_include_weekends: bool = False
tags: bool = True
time_log: bool = True
reminders: bool = True
@ -1352,7 +1351,7 @@ class DBManager:
project_id: int,
start_date_iso: str,
end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
) -> list[tuple[str, str, str, int]]:
"""
Return (time_period, activity_name, total_minutes) tuples between start and end
@ -1361,8 +1360,7 @@ class DBManager:
- 'YYYY-MM-DD' for day
- 'YYYY-WW' for week
- 'YYYY-MM' for month
For 'activity' granularity, results are grouped by activity only (no time bucket).
For 'none' granularity, each individual time log entry becomes a row.
For 'none' granularity, each individual time log entry becomes a row.
"""
cur = self.conn.cursor()
@ -1389,26 +1387,6 @@ class DBManager:
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":
bucket_expr = "page_date"
elif granularity == "week":
@ -1439,14 +1417,11 @@ class DBManager:
self,
start_date_iso: str,
end_date_iso: str,
granularity: str = "day", # 'day' | 'week' | 'month' | 'activity' | 'none'
granularity: str = "day", # 'day' | 'week' | 'month' | 'none'
) -> list[tuple[str, str, str, str, int]]:
"""
Return (project_name, time_period, activity_name, note, total_minutes)
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.
across *all* projects between start and end, grouped by project + period + activity.
"""
cur = self.conn.cursor()
@ -1480,34 +1455,6 @@ class DBManager:
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":
bucket_expr = "page_date"
elif granularity == "week":

View file

@ -103,7 +103,6 @@
"autosave": "autosave",
"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_todos_include_weekends": "Allow moving unchecked TODOs to a weekend\nrather than next weekday",
"insert_images": "Insert images",
"images": "Images",
"reopen_failed": "Re-open failed",
@ -210,7 +209,6 @@
"add_time_entry": "Add time entry",
"time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",

View file

@ -822,13 +822,9 @@ class MainWindow(QMainWindow):
Given a 'new day' (system date), return the date we should move
unfinished todos *to*.
By default, if the new day is Saturday or Sunday we skip ahead to the
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 the new day is Saturday or Sunday, we skip ahead to the next Monday.
Otherwise we just return the same day.
"""
if getattr(self.cfg, "move_todos_include_weekends", False):
return day
# Qt: Monday=1 ... Sunday=7
dow = day.dayOfWeek()
if dow >= 6: # Saturday (6) or Sunday (7)
@ -1570,11 +1566,6 @@ class MainWindow(QMainWindow):
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.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.time_log = getattr(new_cfg, "time_log", self.cfg.time_log)
self.cfg.reminders = getattr(new_cfg, "reminders", self.cfg.reminders)

View file

@ -706,7 +706,7 @@ class ManageRemindersDialog(QDialog):
# Reminder list table
self.table = QTableWidget()
self.table.setColumnCount(6)
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(
[
strings._("text"),

View file

@ -42,9 +42,6 @@ def load_db_config() -> DBConfig:
idle = s.value("ui/idle_minutes", 15, type=int)
theme = s.value("ui/theme", "system", type=str)
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)
time_log = s.value("ui/time_log", True, type=bool)
reminders = s.value("ui/reminders", True, type=bool)
@ -60,7 +57,6 @@ def load_db_config() -> DBConfig:
idle_minutes=idle,
theme=theme,
move_todos=move_todos,
move_todos_include_weekends=move_todos_include_weekends,
tags=tags,
time_log=time_log,
reminders=reminders,
@ -80,7 +76,6 @@ def save_db_config(cfg: DBConfig) -> None:
s.setValue("ui/idle_minutes", str(cfg.idle_minutes))
s.setValue("ui/theme", str(cfg.theme))
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/time_log", str(cfg.time_log))
s.setValue("ui/reminders", str(cfg.reminders))

View file

@ -169,25 +169,6 @@ class SettingsDialog(QDialog):
self.move_todos.setCursor(Qt.PointingHandCursor)
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.setChecked(self.current_settings.tags)
self.tags.setCursor(Qt.PointingHandCursor)
@ -460,7 +441,6 @@ class SettingsDialog(QDialog):
idle_minutes=self.idle_spin.value(),
theme=selected_theme.value,
move_todos=self.move_todos.isChecked(),
move_todos_include_weekends=self.move_todos_include_weekends.isChecked(),
tags=self.tags.isChecked(),
time_log=self.time_log.isChecked(),
reminders=self.reminders.isChecked(),

View file

@ -1083,7 +1083,6 @@ class TimeReportDialog(QDialog):
self.granularity.addItem(strings._("by_day"), "day")
self.granularity.addItem(strings._("by_week"), "week")
self.granularity.addItem(strings._("by_month"), "month")
self.granularity.addItem(strings._("by_activity"), "activity")
form.addRow(strings._("group_by"), self.granularity)
root.addLayout(form)
@ -1162,20 +1161,6 @@ class TimeReportDialog(QDialog):
header.setSectionResizeMode(2, QHeaderView.Stretch)
header.setSectionResizeMode(3, QHeaderView.Stretch)
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:
# Grouped: no note column
self.table.setColumnCount(4)
@ -1287,21 +1272,16 @@ class TimeReportDialog(QDialog):
rows_for_table
):
hrs = minutes / 60.0
if self._last_gran == "activity":
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(activity_name))
self.table.setItem(i, 2, QTableWidgetItem(f"{hrs:.2f}"))
else:
self.table.setItem(i, 0, QTableWidgetItem(project))
self.table.setItem(i, 1, QTableWidgetItem(time_period))
self.table.setItem(i, 2, QTableWidgetItem(activity_name))
self.table.setItem(i, 0, QTableWidgetItem(project))
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}"))
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
total_hours = self._last_total_minutes / 60.0
@ -1345,15 +1325,14 @@ class TimeReportDialog(QDialog):
with open(filename, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
gran = getattr(self, "_last_gran", "day")
show_note = gran == "none"
show_period = gran != "activity"
show_note = getattr(self, "_last_gran", "day") == "none"
# Header
header: list[str] = [strings._("project")]
if show_period:
header.append(strings._("time_period"))
header.append(strings._("activity"))
header = [
strings._("project"),
strings._("time_period"),
strings._("activity"),
]
if show_note:
header.append(strings._("note"))
header.append(strings._("hours"))
@ -1368,22 +1347,16 @@ class TimeReportDialog(QDialog):
minutes,
) in self._last_rows:
hours = minutes / 60.0
row: list[str] = [project]
if show_period:
row.append(time_period)
row.append(activity_name)
row = [project, time_period, activity_name]
if show_note:
row.append(note or "")
row.append(note)
row.append(f"{hours:.2f}")
writer.writerow(row)
# Blank line + total
total_hours = self._last_total_minutes / 60.0
writer.writerow([])
total_row = [""] * len(header)
total_row[0] = strings._("total")
total_row[-1] = f"{total_hours:.2f}"
writer.writerow(total_row)
writer.writerow([strings._("total"), "", f"{total_hours:.2f}"])
except OSError as exc:
QMessageBox.warning(
self,
@ -1411,20 +1384,17 @@ class TimeReportDialog(QDialog):
if not filename.endswith(".pdf"):
filename = f"{filename}.pdf"
# ---------- Build chart image ----------
# Default: hours per time period. If grouped by activity: hours per activity.
gran = getattr(self, "_last_gran", "day")
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
# ---------- Build chart image (hours per period) ----------
per_period_minutes: dict[str, int] = defaultdict(int)
for _project, period, _activity, note, minutes in self._last_rows:
per_period_minutes[period] += minutes
buckets = sorted(per_bucket_minutes.keys())
periods = sorted(per_period_minutes.keys())
chart_w, chart_h = 800, 220
chart = QImage(chart_w, chart_h, QImage.Format_ARGB32)
chart.fill(Qt.white)
if buckets:
if periods:
painter = QPainter(chart)
try:
painter.setRenderHint(QPainter.Antialiasing, True)
@ -1452,9 +1422,9 @@ class TimeReportDialog(QDialog):
# Border
painter.drawRect(left, top, width, height)
max_hours = max(per_bucket_minutes[p] for p in buckets) / 60.0
max_hours = max(per_period_minutes[p] for p in periods) / 60.0
if max_hours > 0:
n = len(buckets)
n = len(periods)
bar_spacing = width / max(1, n)
bar_width = bar_spacing * 0.6
@ -1479,8 +1449,8 @@ class TimeReportDialog(QDialog):
painter.setBrush(QColor(80, 140, 200))
painter.setPen(Qt.NoPen)
for i, label in enumerate(buckets):
hours = per_bucket_minutes[label] / 60.0
for i, period in enumerate(periods):
hours = per_period_minutes[period] / 60.0
bar_h = int((hours / max_hours) * (height - 10))
if bar_h <= 0:
continue # pragma: no cover
@ -1493,7 +1463,7 @@ class TimeReportDialog(QDialog):
# X labels after bars, in black
painter.setPen(Qt.black)
for i, label in enumerate(buckets):
for i, period in enumerate(periods):
x_center = left + bar_spacing * (i + 0.5)
x = int(x_center - bar_width / 2)
painter.drawText(
@ -1502,7 +1472,7 @@ class TimeReportDialog(QDialog):
int(bar_width),
20,
Qt.AlignHCenter | Qt.AlignTop,
label,
period,
)
finally:
painter.end()
@ -1511,54 +1481,24 @@ class TimeReportDialog(QDialog):
project = html.escape(self._last_project_name or "")
start = html.escape(self._last_start or "")
end = html.escape(self._last_end or "")
gran_key = getattr(self, "_last_gran", "day")
gran_label = html.escape(self._last_gran_label or "")
gran = html.escape(self._last_gran_label or "")
total_hours = self._last_total_minutes / 60.0
# Table rows
# Table rows (period, activity, hours)
row_html_parts: list[str] = []
if gran_key == "activity":
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(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>"
)
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>"
f"<th>{html.escape(strings._('project'))}</th>"
f"<th>{html.escape(strings._('activity'))}</th>"
f"<th>{html.escape(strings._('hours'))}</th>"
"</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>"
)
html_doc = f"""
<!DOCTYPE html>
<html>
@ -1604,11 +1544,16 @@ class TimeReportDialog(QDialog):
<h1>{html.escape(strings._("time_log_report_title").format(project=project))}</h1>
<p class="meta">
{html.escape(strings._("time_log_report_meta").format(
start=start, end=end, granularity=gran_label))}
start=start, end=end, granularity=gran))}
</p>
<p><img src="chart" class="chart" /></p>
<table>
{table_header_html}
<tr>
<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}
</table>
<p><b>{html.escape(strings._("time_report_total").format(hours=total_hours))}</b></p>

6
poetry.lock generated
View file

@ -747,13 +747,13 @@ files = [
[[package]]
name = "urllib3"
version = "2.6.2"
version = "2.6.1"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
{file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
{file = "urllib3-2.6.1-py3-none-any.whl", hash = "sha256:e67d06fe947c36a7ca39f4994b08d73922d40e6cca949907be05efa6fd75110b"},
{file = "urllib3-2.6.1.tar.gz", hash = "sha256:5379eb6e1aba4088bae84f8242960017ec8d8e3decf30480b3a1abdaa9671a3f"},
]
[package.extras]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.7.3"
version = "0.7.1"
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
authors = ["Miguel Jacq <mig@mig5.net>"]
readme = "README.md"

View file

@ -2,34 +2,6 @@
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
filedust -y .
@ -38,11 +10,11 @@ poetry build
poetry publish
# Make AppImage
sudo apt-get -y install libfuse-dev
sudo apt-get install libfuse-dev
poetry run pyproject-appimage
mv Bouquin.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
ssh wolverine.mig5.net "echo ${VERSION} | tee /opt/www/mig5.net/bouquin/version.txt"
echo "Don't forget to update version string on remote server."

View file

@ -1185,7 +1185,7 @@ def test_time_report_dialog_creation(qtbot, fresh_db):
qtbot.addWidget(dialog)
assert dialog.project_combo.count() == 1
assert dialog.granularity.count() == 5
assert dialog.granularity.count() == 4
def test_time_report_dialog_loads_projects(qtbot, fresh_db):