Compare commits

...

4 commits

14 changed files with 3004 additions and 590 deletions

View file

@ -1,3 +1,10 @@
# 0.9.0
* Add 'Projects' interface for unified time/invoice/docs view.
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
* Update dependencies
# 0.8.4
* Update dependencies

View file

@ -308,6 +308,40 @@ class DBManager:
client_email TEXT
);
CREATE INDEX IF NOT EXISTS ix_project_billing_client_company
ON project_billing(client_company);
CREATE TABLE IF NOT EXISTS project_buckets (
project_id INTEGER PRIMARY KEY
REFERENCES projects(id) ON DELETE CASCADE,
baseline_minutes INTEGER NOT NULL DEFAULT 0,
bucket_ceiling_minutes INTEGER NOT NULL DEFAULT 0,
warn_at_percent REAL NOT NULL DEFAULT 80.0,
updated_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
)
);
CREATE TABLE IF NOT EXISTS project_bucket_ledger (
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL
REFERENCES projects(id) ON DELETE CASCADE,
occurred_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
entry_type TEXT NOT NULL,
baseline_delta_minutes INTEGER NOT NULL DEFAULT 0,
ceiling_delta_minutes INTEGER NOT NULL DEFAULT 0,
description TEXT,
invoice_id INTEGER,
FOREIGN KEY(invoice_id) REFERENCES invoices(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ix_project_bucket_ledger_project
ON project_bucket_ledger(project_id, occurred_at);
CREATE TABLE IF NOT EXISTS company_profile (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
@ -336,6 +370,9 @@ class DBManager:
paid_at TEXT,
payment_note TEXT,
document_id INTEGER,
created_at TEXT NOT NULL DEFAULT (
strftime('%Y-%m-%dT%H:%M:%fZ','now')
),
FOREIGN KEY(document_id) REFERENCES project_documents(id)
ON DELETE SET NULL,
UNIQUE(project_id, invoice_number)
@ -366,8 +403,20 @@ class DBManager:
);
"""
)
self._ensure_column(
"invoices",
"created_at",
"created_at TEXT",
)
self.conn.commit()
def _ensure_column(self, table: str, column: str, definition: str) -> None:
"""Add a simple column during startup schema upgrades if needed."""
rows = self.conn.execute(f"PRAGMA table_info({table})").fetchall()
if any(str(r["name"]) == column for r in rows):
return
self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {definition}")
def rekey(self, new_key: str) -> None:
"""
Change the SQLCipher passphrase in-place, then reopen the connection
@ -1219,6 +1268,496 @@ class DBManager:
(project_id,),
)
# -------- Time logging: project buckets ---------------------------
def _normalise_project_id(self, project_id: int) -> int:
project_id = int(project_id)
if project_id <= 0:
raise ValueError("invalid project id")
return project_id
def _normalise_minutes_delta(self, minutes: int | float | None) -> int:
return int(round(float(minutes or 0)))
def _normalise_bucket_warning(self, warn_at_percent: float | None) -> float:
return min(100.0, max(0.0, float(warn_at_percent or 0.0)))
def _project_bucket_ledger_totals(self, project_id: int) -> tuple[int, int]:
row = self.conn.execute(
"""
SELECT
COALESCE(SUM(baseline_delta_minutes), 0) AS baseline_minutes,
COALESCE(SUM(ceiling_delta_minutes), 0) AS bucket_ceiling_minutes
FROM project_bucket_ledger
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
baseline = max(0, int(row["baseline_minutes"] or 0))
ceiling = max(0, int(row["bucket_ceiling_minutes"] or 0))
return baseline, ceiling
def _sync_project_bucket_cache(self, project_id: int) -> None:
"""Refresh the project_buckets cache from the ledger."""
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
existing = self.conn.execute(
"SELECT warn_at_percent FROM project_buckets WHERE project_id = ?;",
(project_id,),
).fetchone()
warn_at = float(existing["warn_at_percent"] or 80.0) if existing else 80.0
self.conn.execute(
"""
INSERT INTO project_buckets (
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent
)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
baseline_minutes = excluded.baseline_minutes,
bucket_ceiling_minutes = excluded.bucket_ceiling_minutes,
warn_at_percent = project_buckets.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(project_id, baseline, ceiling, warn_at),
)
def add_project_bucket_ledger_entry(
self,
project_id: int,
entry_type: str,
baseline_delta_minutes: int = 0,
ceiling_delta_minutes: int = 0,
description: str | None = None,
invoice_id: int | None = None,
) -> int:
"""Append an auditable project bucket ledger entry.
Positive ``baseline_delta_minutes`` increases the already-spent baseline.
Positive ``ceiling_delta_minutes`` increases the prepaid bucket ceiling.
Negative deltas are used for explicit corrections when the user lowers a
previously saved baseline or ceiling.
"""
project_id = self._normalise_project_id(project_id)
entry_type = str(entry_type or "adjustment").strip() or "adjustment"
baseline_delta_minutes = self._normalise_minutes_delta(baseline_delta_minutes)
ceiling_delta_minutes = self._normalise_minutes_delta(ceiling_delta_minutes)
description = (description or "").strip() or None
if baseline_delta_minutes == 0 and ceiling_delta_minutes == 0:
raise ValueError("bucket ledger entry has no minute delta")
with self.conn:
cur = self.conn.cursor()
cur.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id
)
VALUES (?, ?, ?, ?, ?, ?);
""",
(
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description,
invoice_id,
),
)
ledger_id = cur.lastrowid
self._sync_project_bucket_cache(project_id)
return ledger_id
def upsert_project_bucket(
self,
project_id: int,
baseline_minutes: int,
bucket_ceiling_minutes: int,
warn_at_percent: float = 80.0,
) -> None:
"""Save project bucket settings as an auditable ledger adjustment.
``baseline_minutes`` represents already-spent hours that pre-date
Bouquin time logging. ``bucket_ceiling_minutes`` is the cumulative
prepaid ceiling purchased for this project. The current values are
derived from ``project_bucket_ledger`` rather than silently overwritten.
"""
project_id = self._normalise_project_id(project_id)
baseline_minutes = max(0, int(baseline_minutes or 0))
bucket_ceiling_minutes = max(0, int(bucket_ceiling_minutes or 0))
warn_at_percent = self._normalise_bucket_warning(warn_at_percent)
current_baseline, current_ceiling = self._project_bucket_ledger_totals(
project_id
)
baseline_delta = baseline_minutes - current_baseline
ceiling_delta = bucket_ceiling_minutes - current_ceiling
with self.conn:
self.conn.execute(
"""
INSERT INTO project_buckets (
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent
)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
warn_at_percent = excluded.warn_at_percent,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now');
""",
(
project_id,
current_baseline,
current_ceiling,
warn_at_percent,
),
)
if baseline_delta or ceiling_delta:
self.conn.execute(
"""
INSERT INTO project_bucket_ledger (
project_id,
entry_type,
baseline_delta_minutes,
ceiling_delta_minutes,
description
)
VALUES (?, ?, ?, ?, ?);
""",
(
project_id,
"settings_adjustment",
baseline_delta,
ceiling_delta,
"Bucket settings updated",
),
)
self._sync_project_bucket_cache(project_id)
self.conn.execute(
"""
UPDATE project_buckets
SET warn_at_percent = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE project_id = ?;
""",
(warn_at_percent, project_id),
)
def add_to_project_bucket_ceiling(
self,
project_id: int,
add_minutes: int,
description: str | None = None,
invoice_id: int | None = None,
) -> None:
"""Increase a project's cumulative bucket ceiling by ``add_minutes``."""
project_id = self._normalise_project_id(project_id)
add_minutes = max(0, int(add_minutes or 0))
if add_minutes <= 0:
return
entry_type = "prepaid_invoice" if invoice_id is not None else "manual_topup"
self.add_project_bucket_ledger_entry(
project_id,
entry_type,
ceiling_delta_minutes=add_minutes,
description=description or "Bucket ceiling increased",
invoice_id=invoice_id,
)
def get_project_bucket(self, project_id: int):
"""Return the bucket row for a project, or None if none is configured."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return None
row = self.conn.execute(
"""
SELECT
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent,
updated_at
FROM project_buckets
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
if row is None:
baseline, ceiling = self._project_bucket_ledger_totals(project_id)
if baseline == 0 and ceiling == 0:
return None
self._sync_project_bucket_cache(project_id)
row = self.conn.execute(
"""
SELECT
project_id,
baseline_minutes,
bucket_ceiling_minutes,
warn_at_percent,
updated_at
FROM project_buckets
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
return row
def logged_minutes_for_project(self, project_id: int) -> int:
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return 0
row = self.conn.execute(
"""
SELECT COALESCE(SUM(minutes), 0) AS minutes
FROM time_log
WHERE project_id = ?;
""",
(project_id,),
).fetchone()
return int(row["minutes"] or 0)
def project_bucket_status(self, project_id: int) -> dict[str, object] | None:
"""Return computed bucket state for a project.
States:
- ``unconfigured``: no bucket ceiling has been set
- ``ok``: below warning threshold
- ``warning``: at/above warning threshold but below ceiling
- ``reached``: exactly at ceiling
- ``exceeded``: beyond ceiling
"""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return None
project_name = self.list_projects_by_id(project_id)
if not project_name:
return None
bucket = self.get_project_bucket(project_id)
logged = self.logged_minutes_for_project(project_id)
baseline = int(bucket["baseline_minutes"] or 0) if bucket else 0
ceiling = int(bucket["bucket_ceiling_minutes"] or 0) if bucket else 0
warn_at = float(bucket["warn_at_percent"] or 80.0) if bucket else 80.0
used = baseline + logged
if ceiling <= 0:
state = "unconfigured"
remaining = None
pct = None
else:
remaining = ceiling - used
pct = used / ceiling * 100.0
if used > ceiling:
state = "exceeded"
elif used == ceiling:
state = "reached"
elif pct >= warn_at:
state = "warning"
else:
state = "ok"
return {
"project_id": project_id,
"project_name": project_name,
"baseline_minutes": baseline,
"logged_minutes": logged,
"used_minutes": used,
"bucket_ceiling_minutes": ceiling,
"remaining_minutes": remaining,
"percent_used": pct,
"warn_at_percent": warn_at,
"state": state,
}
def list_project_summaries(self):
"""Return one row per project with aggregate time/docs/invoices."""
rows = self.conn.execute(
"""
SELECT
p.id AS project_id,
p.name AS project_name,
COALESCE(pb.baseline_minutes, 0) AS baseline_minutes,
COALESCE(pb.bucket_ceiling_minutes, 0) AS bucket_ceiling_minutes,
COALESCE(pb.warn_at_percent, 80.0) AS warn_at_percent,
COALESCE((
SELECT SUM(t.minutes)
FROM time_log AS t
WHERE t.project_id = p.id
), 0) AS logged_minutes,
COALESCE((
SELECT COUNT(*)
FROM time_log AS t2
WHERE t2.project_id = p.id
), 0) AS time_log_count,
COALESCE((
SELECT COUNT(*)
FROM project_documents AS d
WHERE d.project_id = p.id
), 0) AS document_count,
COALESCE((
SELECT COUNT(*)
FROM invoices AS i
WHERE i.project_id = p.id
), 0) AS invoice_count
FROM projects AS p
LEFT JOIN project_buckets AS pb
ON pb.project_id = p.id
ORDER BY LOWER(p.name);
"""
).fetchall()
return rows
def time_logs_for_project(self, project_id: int):
"""Return all time-log entries for a project, newest first."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
return self.conn.execute(
"""
SELECT
t.id,
t.page_date,
t.project_id,
p.name AS project_name,
t.activity_id,
a.name AS activity_name,
t.minutes,
t.note,
t.created_at
FROM time_log AS t
JOIN projects AS p ON p.id = t.project_id
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
ORDER BY t.page_date DESC, t.created_at DESC, t.id DESC;
""",
(project_id,),
).fetchall()
def project_bucket_ledger_for_project(self, project_id: int):
"""Return bucket ledger rows, including time-log consumption entries."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
l.occurred_at AS occurred_at,
l.entry_type AS entry_type,
l.description AS description,
l.baseline_delta_minutes AS baseline_delta_minutes,
l.ceiling_delta_minutes AS ceiling_delta_minutes,
0 AS used_delta_minutes,
l.invoice_id AS invoice_id,
i.invoice_number AS invoice_number,
NULL AS time_log_id,
NULL AS page_date,
NULL AS activity_name,
l.id AS source_id
FROM project_bucket_ledger AS l
LEFT JOIN invoices AS i ON i.id = l.invoice_id
WHERE l.project_id = ?
UNION ALL
SELECT
t.created_at AS occurred_at,
'time_log' AS entry_type,
COALESCE(NULLIF(t.note, ''), a.name) AS description,
0 AS baseline_delta_minutes,
0 AS ceiling_delta_minutes,
t.minutes AS used_delta_minutes,
NULL AS invoice_id,
NULL AS invoice_number,
t.id AS time_log_id,
t.page_date AS page_date,
a.name AS activity_name,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id),
).fetchall()
return rows
def project_activity_log_for_project(self, project_id: int):
"""Return a generated project changelog from existing dated records."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
t.created_at AS occurred_at,
'time_log' AS event_type,
'Time logged' AS title,
printf('%.2f hours for %s%s',
t.minutes / 60.0,
a.name,
CASE
WHEN COALESCE(t.note, '') = '' THEN ''
ELSE ': ' || t.note
END) AS details,
t.id AS source_id
FROM time_log AS t
JOIN activities AS a ON a.id = t.activity_id
WHERE t.project_id = ?
UNION ALL
SELECT
d.uploaded_at AS occurred_at,
'document' AS event_type,
'Document added' AS title,
d.file_name || CASE
WHEN COALESCE(d.description, '') = '' THEN ''
ELSE ': ' || d.description
END AS details,
d.id AS source_id
FROM project_documents AS d
WHERE d.project_id = ?
UNION ALL
SELECT
COALESCE(i.created_at, i.issue_date) AS occurred_at,
'invoice' AS event_type,
'Invoice issued' AS title,
i.invoice_number || '' || printf('%.2f %s', i.total_cents / 100.0, i.currency) AS details,
i.id AS source_id
FROM invoices AS i
WHERE i.project_id = ?
UNION ALL
SELECT
l.occurred_at AS occurred_at,
'bucket' AS event_type,
'Bucket ledger updated' AS title,
COALESCE(l.description, l.entry_type) AS details,
l.id AS source_id
FROM project_bucket_ledger AS l
WHERE l.project_id = ?
ORDER BY occurred_at DESC, source_id DESC;
""",
(project_id, project_id, project_id, project_id),
).fetchall()
return rows
def list_activities(self) -> list[ActivityRow]:
cur = self.conn.cursor()
rows = cur.execute(
@ -2101,7 +2640,7 @@ class DBManager:
)
def list_client_companies(self) -> list[str]:
"""Return distinct client display names from project_billing."""
"""Return distinct client display names from project billing settings."""
cur = self.conn.cursor()
rows = cur.execute(
"""
@ -2203,6 +2742,42 @@ class DBManager:
# ------------------------- Invoices -------------------------------#
def invoices_for_project_with_documents(self, project_id: int):
"""Return all invoices for a project with linked invoice document metadata."""
try:
project_id = self._normalise_project_id(project_id)
except (TypeError, ValueError):
return []
rows = self.conn.execute(
"""
SELECT
i.id,
i.project_id,
p.name AS project_name,
i.invoice_number,
i.issue_date,
i.due_date,
i.currency,
i.tax_label,
i.tax_rate_percent,
i.subtotal_cents,
i.tax_cents,
i.total_cents,
i.paid_at,
i.payment_note,
i.document_id,
i.created_at,
d.file_name AS document_file_name
FROM invoices AS i
LEFT JOIN projects AS p ON p.id = i.project_id
LEFT JOIN project_documents AS d ON d.id = i.document_id
WHERE i.project_id = ?
ORDER BY i.issue_date DESC, i.invoice_number COLLATE NOCASE;
""",
(project_id,),
).fetchall()
return rows
def create_invoice(
self,
project_id: int,

View file

@ -91,6 +91,7 @@ class InvoiceDialog(QDialog):
super().__init__(parent)
self._db = db
self._project_id = project_id
self.last_invoice_id: int | None = None
self._start = start_date_iso
self._end = end_date_iso
@ -661,6 +662,7 @@ class InvoiceDialog(QDialog):
line_items=[(li.description, li.hours, li.rate_cents) for li in items],
time_log_ids=time_log_ids,
)
self.last_invoice_id = invoice_id
# Automatically create a reminder for the invoice due date
if self.cfg.reminders:

View file

@ -1,441 +1,490 @@
{
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
"db_issues_reported": "issue(s) reported",
"db_reopen_failed_after_rekey": "Re-open failed after rekey",
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
"database_compacted_successfully": "Database compacted successfully!",
"encryption": "Encryption",
"remember_key": "Remember key",
"change_encryption_key": "Change encryption key",
"enter_a_new_encryption_key": "Enter a new encryption key",
"reenter_the_new_key": "Re-enter the new key",
"key_mismatch": "Key mismatch",
"key_mismatch_explanation": "The two entries did not match.",
"empty_key": "Empty key",
"empty_key_explanation": "The key cannot be empty.",
"key_changed": "Key changed",
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error",
"success": "Success",
"close": "&Close",
"find": "Find",
"file": "File",
"locale": "Language",
"locale_restart": "Please restart the application to load the new language.",
"settings": "Settings",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
"never": "Never",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
"next": "Next",
"next_day": "Next day",
"today": "Today",
"show": "Show",
"edit": "Edit",
"delete": "Delete",
"history": "History",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
"export_failed": "Export failed",
"backup": "Backup",
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
"documentation": "Documentation",
"couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug",
"version": "Version",
"update": "Update",
"check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?",
"downloading": "Downloading",
"download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate",
"current": "current",
"selected": "selected",
"find_on_page": "Find on page",
"find_next": "Find next",
"find_previous": "Find previous",
"find_bar_type_to_search": "Type to search",
"find_bar_match_case": "Match case",
"history_dialog_preview": "Preview",
"history_dialog_diff": "Diff",
"history_dialog_revert_to_selected": "&Revert to selected",
"history_dialog_revert_failed": "Revert failed",
"history_dialog_delete": "&Delete revision",
"history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key",
"lock_overlay_locked": "Locked",
"lock_overlay_unlock": "Unlock",
"main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready",
"main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs",
"set_an_encryption_key": "Set an encryption key",
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
"unlock_encrypted_notebook": "Unlock encrypted notebook",
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
"open_in_new_tab": "Open in new tab",
"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",
"unlock_failed": "Unlock failed",
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
"unencrypted_export": "Unencrypted export",
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
"unrecognised_extension": "Unrecognised extension!",
"backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at",
"appearance": "Appearance",
"security": "Security",
"features": "Features",
"database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle",
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
"font_size": "Font size",
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
"search_for_notes_here": "Search for notes here",
"toolbar_format": "Format",
"toolbar_bold": "Bold",
"toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text",
"toolbar_font_smaller": "Smaller text",
"toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block",
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"color_hex": "Colour",
"date": "Date",
"page_or_document": "Page / Document",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages",
"stats_group_tags": "Tags",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions",
"stats_total_words": "Total words (current versions)",
"stats_unique_tags": "Unique tags",
"stats_page_most_tags": "Page with most tags",
"stats_activity_heatmap": "Activity heatmap",
"stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words",
"stats_metric_revisions": "Revisions",
"stats_metric_documents": "Documents",
"stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
"bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set reminder prompt",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm",
"activities": "Activities",
"activity": "Activity",
"note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required",
"add_activity": "Add activity",
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
"custom_range": "Custom",
"last_week": "Last week",
"last_month": "Last month",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
"all_projects": "All projects",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
"delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours": "Hours",
"created_at": "Created at",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log",
"project": "Project",
"project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required",
"project_required_title": "Project required",
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
"rename_activity_label": "Rename activity",
"add_project_title": "Add project",
"add_project_label": "Add a project",
"rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project",
"rename_project_title": "Rename this project",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"date_label": "Date: {date}",
"change_date": "Change date",
"select_date_title": "Select date",
"for": "For {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
"no_report_message": "Please run a report before exporting.",
"total": "Total",
"export_csv": "Export CSV",
"export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label":"Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"collapse": "Collapse",
"expand": "Expand",
"remove_collapse": "Remove collapse",
"collapse_selection": "Collapse selection",
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"stop_and_log": "Stop and log",
"manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders",
"once": "once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"add_reminder": "Add Reminder",
"set_reminder": "Set Reminder",
"edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminder": "Reminder",
"reminders": "Reminders",
"time": "Time",
"once": "Once",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"every_fortnight": "Every 2 weeks",
"every_month": "Every month (same date)",
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
"week_in_month": "Week in month",
"fortnightly": "Fortnightly",
"monthly_same_date": "Monthly (same date)",
"monthly_nth_weekday": "Monthly (nth weekday)",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"monday_short": "Mon",
"tuesday_short": "Tue",
"wednesday_short": "Wed",
"thursday_short": "Thu",
"friday_short": "Fri",
"saturday_short": "Sat",
"sunday_short": "Sun",
"day": "Day",
"text": "Text",
"type": "Type",
"active": "Active",
"actions": "Actions",
"edit_code_block": "Edit code block",
"delete_code_block": "Delete code block",
"search_result_heading_document": "Document",
"toolbar_documents": "Documents Manager",
"project_documents_title": "Project documents",
"documents_col_file": "File",
"documents_col_description": "Description",
"documents_col_added": "Added",
"documents_col_tags": "Tags",
"documents_col_size": "Size",
"documents_add": "&Add",
"documents_open": "&Open",
"documents_delete": "&Delete",
"documents_no_project_selected": "Please choose a project first.",
"documents_file_filter_all": "All files (*)",
"documents_add_failed": "Could not add document: {error}",
"documents_open_failed": "Could not open document: {error}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"documents_invalid_date_format": "Invalid date format",
"todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices",
"create_invoice": "Create Invoice",
"invoice_amount": "Amount",
"invoice_apply_tax": "Apply Tax",
"invoice_client_address": "Client Address",
"invoice_client_company": "Client Company",
"invoice_client_email": "Client E-mail",
"invoice_client_name": "Client Contact",
"invoice_currency": "Currency",
"invoice_dialog_title": "Create Invoice",
"invoice_due_date": "Due Date",
"invoice_hourly_rate": "Hourly Rate",
"invoice_hours": "Hours",
"invoice_issue_date": "Issue Date",
"invoice_mode_detailed": "Detailed mode",
"invoice_mode_summary": "Summary mode",
"invoice_number": "Invoice Number",
"invoice_save_and_export": "Save and export",
"invoice_save_pdf_title": "Save PDF",
"invoice_subtotal": "Subtotal",
"invoice_summary_default_desc": "Consultant services for the month of",
"invoice_summary_desc": "Summary description",
"invoice_summary_hours": "Summary hours",
"invoice_tax": "Tax details",
"invoice_tax_label": "Tax type",
"invoice_tax_rate": "Tax rate",
"invoice_tax_total": "Tax total",
"invoice_total": "Total",
"invoice_paid_at": "Paid on",
"invoice_payment_note": "Payment notes",
"invoice_project_required_title": "Project required",
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
"invoice_need_report_title": "Report required",
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
"invoice_company_profile": "Business Profile",
"invoice_company_name": "Business Name",
"invoice_company_address": "Address",
"invoice_company_phone": "Phone",
"invoice_company_email": "E-mail",
"invoice_company_tax_id": "Tax number",
"invoice_company_payment_details": "Payment details",
"invoice_company_logo": "Logo",
"invoice_company_logo_choose": "Choose logo",
"invoice_company_logo_set": "Logo has been set",
"invoice_company_logo_not_set": "Logo not set",
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
"invoice_invalid_amount": "The amount is invalid",
"invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice."
"db_sqlcipher_integrity_check_failed": "SQLCipher integrity check failed",
"db_issues_reported": "issue(s) reported",
"db_reopen_failed_after_rekey": "Re-open failed after rekey",
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
"database_compacted_successfully": "Database compacted successfully!",
"encryption": "Encryption",
"remember_key": "Remember key",
"change_encryption_key": "Change encryption key",
"enter_a_new_encryption_key": "Enter a new encryption key",
"reenter_the_new_key": "Re-enter the new key",
"key_mismatch": "Key mismatch",
"key_mismatch_explanation": "The two entries did not match.",
"empty_key": "Empty key",
"empty_key_explanation": "The key cannot be empty.",
"key_changed": "Key changed",
"key_changed_explanation": "The notebook was re-encrypted with the new key!",
"error": "Error",
"success": "Success",
"close": "&Close",
"find": "Find",
"file": "File",
"locale": "Language",
"locale_restart": "Please restart the application to load the new language.",
"settings": "Settings",
"theme": "Theme",
"system": "System",
"light": "Light",
"dark": "Dark",
"never": "Never",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
"next": "Next",
"next_day": "Next day",
"today": "Today",
"show": "Show",
"edit": "Edit",
"delete": "Delete",
"history": "History",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
"export_failed": "Export failed",
"backup": "Backup",
"backup_complete": "Backup complete",
"backup_failed": "Backup failed",
"quit": "Quit",
"cancel": "Cancel",
"save": "Save",
"help": "Help",
"saved": "Saved",
"saved_to": "Saved to",
"documentation": "Documentation",
"couldnt_open": "Couldn't open",
"report_a_bug": "Report a bug",
"version": "Version",
"update": "Update",
"check_for_updates": "Check for updates",
"could_not_check_for_updates": "Could not check for updates:\n",
"update_server_returned_an_empty_version_string": "Update server returned an empty version string",
"you_are_running_the_latest_version": "You are running the latest version:\n",
"there_is_a_new_version_available": "There is a new version available:\n",
"download_the_appimage": "Download the AppImage?",
"downloading": "Downloading",
"download_cancelled": "Download cancelled",
"failed_to_download_update": "Failed to download update:\n",
"could_not_read_bundled_gpg_public_key": "Could not read bundled GPG public key:\n",
"could_not_find_gpg_executable": "Could not find the 'gpg' executable to verify the download.",
"gpg_signature_verification_failed": "GPG signature verification failed. The downloaded files have been deleted.\n\n",
"downloaded_and_verified_new_appimage": "Downloaded and verified new AppImage:\n\n",
"navigate": "Navigate",
"current": "current",
"selected": "selected",
"find_on_page": "Find on page",
"find_next": "Find next",
"find_previous": "Find previous",
"find_bar_type_to_search": "Type to search",
"find_bar_match_case": "Match case",
"history_dialog_preview": "Preview",
"history_dialog_diff": "Diff",
"history_dialog_revert_to_selected": "&Revert to selected",
"history_dialog_revert_failed": "Revert failed",
"history_dialog_delete": "&Delete revision",
"history_dialog_delete_failed": "Could not delete revision",
"key_prompt_enter_key": "Enter key",
"lock_overlay_locked": "Locked",
"lock_overlay_unlock": "Unlock",
"main_window_lock_screen_accessibility": "&Lock screen",
"main_window_ready": "Ready",
"main_window_save_a_version": "Save a version",
"main_window_settings_accessible_flag": "Settin&gs",
"set_an_encryption_key": "Set an encryption key",
"set_an_encryption_key_explanation": "Bouquin encrypts your data.\n\nPlease create a strong passphrase to encrypt the notebook.\n\nYou can always change it later!",
"unlock_encrypted_notebook": "Unlock encrypted notebook",
"unlock_encrypted_notebook_explanation": "Enter your key to unlock the notebook",
"open_in_new_tab": "Open in new tab",
"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",
"unlock_failed": "Unlock failed",
"could_not_unlock_database_at_new_path": "Could not unlock database at new path.",
"unencrypted_export": "Unencrypted export",
"unencrypted_export_warning": "Exporting the database will be unencrypted!\nAre you sure you want to continue?\nIf you want an encrypted backup, choose Backup instead of Export.",
"unrecognised_extension": "Unrecognised extension!",
"backup_encrypted_notebook": "Backup encrypted notebook",
"enter_a_name_for_this_version": "Enter a name for this version",
"new_version_i_saved_at": "New version I saved at",
"appearance": "Appearance",
"security": "Security",
"features": "Features",
"database": "Database",
"save_key_warning": "If you don't want to be prompted for your encryption key, check this to remember it.\nWARNING: the key is saved to disk and could be recoverable if your disk is compromised.",
"lock_screen_when_idle": "Lock screen when idle",
"autolock_explanation": "Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it.\nSet to 0 (never) to never lock.",
"font_size": "Font size",
"font_size_explanation": "Changing this value will change the size of all paragraph text in all tabs. It does not affect heading or code block size",
"search_for_notes_here": "Search for notes here",
"toolbar_format": "Format",
"toolbar_bold": "Bold",
"toolbar_italic": "Italic",
"toolbar_strikethrough": "Strikethrough",
"toolbar_normal_paragraph_text": "Normal paragraph text",
"toolbar_font_smaller": "Smaller text",
"toolbar_font_larger": "Larger text",
"toolbar_bulleted_list": "Bulleted list",
"toolbar_numbered_list": "Numbered list",
"toolbar_code_block": "Code block",
"toolbar_heading": "Heading",
"toolbar_toggle_checkboxes": "Toggle checkboxes",
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"color_hex": "Colour",
"date": "Date",
"page_or_document": "Page / Document",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
"change_color": "Change colour",
"delete_tag": "Delete tag",
"delete_tag_confirm": "Are you sure you want to delete the tag '{name}'? This will remove it from all pages.",
"tag_already_exists_with_that_name": "A tag already exists with that name",
"statistics": "Statistics",
"main_window_statistics_accessible_flag": "Stat&istics",
"stats_group_pages": "Pages",
"stats_group_tags": "Tags",
"stats_group_documents": "Documents",
"stats_group_time_logging": "Time logging",
"stats_group_reminders": "Reminders",
"stats_pages_with_content": "Pages with content (current version)",
"stats_total_revisions": "Total revisions",
"stats_page_most_revisions": "Page with most revisions",
"stats_total_words": "Total words (current versions)",
"stats_unique_tags": "Unique tags",
"stats_page_most_tags": "Page with most tags",
"stats_activity_heatmap": "Activity heatmap",
"stats_heatmap_metric": "Colour by",
"stats_metric_words": "Words",
"stats_metric_revisions": "Revisions",
"stats_metric_documents": "Documents",
"stats_total_documents": "Total documents",
"stats_date_most_documents": "Date with most documents",
"stats_no_data": "No statistics available yet.",
"stats_time_total_hours": "Total hours logged",
"stats_time_day_most_hours": "Day with most hours logged",
"stats_time_project_most_hours": "Project with most hours logged",
"stats_time_activity_most_hours": "Activity with most hours logged",
"stats_total_reminders": "Total reminders",
"stats_date_most_reminders": "Day with most reminders",
"stats_metric_hours": "Hours",
"stats_metric_reminders": "Reminders",
"select_notebook": "Select notebook",
"bug_report_explanation": "Describe what went wrong, what you expected to happen, and any steps to reproduce.\n\nWe do not collect anything else except the Bouquin version number.\n\nIf you wish to be contacted, please leave contact information.\n\nYour request will be sent over HTTPS.",
"bug_report_placeholder": "Type your bug report here",
"bug_report_empty": "Please enter some details about the bug before sending.",
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set Reminder",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
"invalid_time_title": "Invalid time",
"invalid_time_message": "Please enter a time in the format HH:MM",
"dismiss": "Dismiss",
"toolbar_alarm": "Set reminder alarm",
"activities": "Activities",
"activity": "Activity",
"note": "Note",
"activity_delete_error_message": "A problem occurred deleting the activity",
"activity_delete_error_title": "Problem deleting activity",
"activity_rename_error_message": "A problem occurred renaming the activity",
"activity_rename_error_title": "Problem renaming activity",
"activity_required_message": "An activity name is required",
"activity_required_title": "Activity name required",
"add_activity": "Add activity",
"add_project": "Add project",
"add_time_entry": "Add time entry",
"time_period": "Time period",
"dont_group": "Don't group",
"by_activity": "by activity",
"by_day": "by day",
"by_month": "by month",
"by_week": "by week",
"date_range": "Date range",
"custom_range": "Custom",
"last_week": "Last week",
"last_month": "Last month",
"this_week": "This week",
"this_month": "This month",
"this_year": "This year",
"all_projects": "All projects",
"delete_activity": "Delete activity",
"delete_activity_confirm": "Are you sure you want to delete this activity?",
"delete_activity_title": "Delete activity - are you sure?",
"delete_project": "Delete project",
"delete_project_confirm": "Are you sure you want to delete this project?",
"delete_project_title": "Delete project - are you sure?",
"delete_time_entry": "Delete time entry",
"group_by": "Group by",
"hours": "Hours",
"created_at": "Created at",
"invalid_activity_message": "The activity is invalid",
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
"open_time_log": "Open time log",
"project": "Project",
"project_delete_error_message": "A problem occurred deleting the project",
"project_delete_error_title": "Problem deleting project",
"project_rename_error_message": "A problem occurred renaming the project",
"project_rename_error_title": "Problem renaming project",
"project_required_message": "A project is required",
"project_required_title": "Project required",
"projects": "Projects",
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"reporting": "Reporting",
"reporting_and_invoicing": "Reporting and Invoicing",
"run_report": "Run report",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
"rename_activity_label": "Rename activity",
"add_project_title": "Add project",
"add_project_label": "Add a project",
"rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project",
"rename_project_title": "Rename this project",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
"select_project_title": "Select project",
"time_log": "Time log",
"time_log_collapsed_hint": "Time log",
"date_label": "Date: {date}",
"change_date": "Change date",
"select_date_title": "Select date",
"for": "For {date}",
"time_log_no_date": "Time log",
"time_log_no_entries": "No time entries yet",
"time_log_report": "Time log report",
"time_log_report_title": "Time log for {project}",
"time_log_report_meta": "From {start} to {end}, grouped {granularity}",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"time_log_with_total": "Time log ({hours:.2f}h)",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",
"no_report_message": "Please run a report before exporting.",
"total": "Total",
"export_csv": "Export CSV",
"export_csv_error_title": "Export failed",
"export_csv_error_message": "Could not write CSV file:\n{error}",
"export_pdf": "Export PDF",
"export_pdf_error_title": "PDF export failed",
"export_pdf_error_message": "Could not write PDF file:\n{error}",
"enable_tags_feature": "Enable Tags",
"enable_time_log_feature": "Enable Time Logging",
"enable_reminders_feature": "Enable Reminders",
"reminders_webhook_section_title": "Send Reminders to a webhook",
"reminders_webhook_url_label": "Webhook URL",
"reminders_webhook_secret_label": "Webhook Secret (sent as\nX-Bouquin-Secret header)",
"enable_documents_feature": "Enable storing of documents",
"pomodoro_time_log_default_text": "Focus session",
"toolbar_pomodoro_timer": "Time-logging timer",
"set_code_language": "Set code language",
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"collapse": "Collapse",
"expand": "Expand",
"remove_collapse": "Remove collapse",
"collapse_selection": "Collapse selection",
"start": "Start",
"pause": "Pause",
"resume": "Resume",
"stop_and_log": "Stop and log",
"manage_reminders": "Manage Reminders",
"upcoming_reminders": "Upcoming Reminders",
"no_upcoming_reminders": "No upcoming reminders",
"once": "Once",
"daily": "daily",
"weekdays": "weekdays",
"weekly": "weekly",
"add_reminder": "Add Reminder",
"edit_reminder": "Edit Reminder",
"delete_reminder": "Delete Reminder",
"delete_reminders": "Delete Reminders",
"deleting_it_will_remove_all_future_occurrences": "Deleting it will remove all future occurrences.",
"this_is_a_reminder_of_type": "Note: This is a reminder of type",
"this_will_delete_the_actual_reminders": "Note: This will delete the actual reminders, not just individual occurrences.",
"reminders": "Reminders",
"time": "Time",
"every_day": "Every day",
"every_weekday": "Every weekday (Mon-Fri)",
"every_week": "Every week",
"every_fortnight": "Every 2 weeks",
"every_month": "Every month (same date)",
"every_month_nth_weekday": "Every month (e.g. 3rd Monday)",
"week_in_month": "Week in month",
"fortnightly": "Fortnightly",
"monthly_same_date": "Monthly (same date)",
"monthly_nth_weekday": "Monthly (nth weekday)",
"repeat": "Repeat",
"monday": "Monday",
"tuesday": "Tuesday",
"wednesday": "Wednesday",
"thursday": "Thursday",
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday",
"monday_short": "Mon",
"tuesday_short": "Tue",
"wednesday_short": "Wed",
"thursday_short": "Thu",
"friday_short": "Fri",
"saturday_short": "Sat",
"sunday_short": "Sun",
"day": "Day",
"text": "Text",
"type": "Type",
"active": "Active",
"actions": "Actions",
"edit_code_block": "Edit code block",
"delete_code_block": "Delete code block",
"search_result_heading_document": "Document",
"toolbar_documents": "Documents Manager",
"project_documents_title": "Project documents",
"documents_col_file": "File",
"documents_col_description": "Description",
"documents_col_added": "Added",
"documents_col_tags": "Tags",
"documents_col_size": "Size",
"documents_add": "&Add",
"documents_open": "&Open",
"documents_delete": "&Delete",
"documents_no_project_selected": "Please choose a project first.",
"documents_file_filter_all": "All files (*)",
"documents_add_failed": "Could not add document: {error}",
"documents_open_failed": "Could not open document: {error}",
"documents_confirm_delete": "Remove this document from the project?\n(The file on disk will not be deleted.)",
"documents_search_label": "Search",
"documents_search_placeholder": "Type to search documents (all projects)",
"documents_invalid_date_format": "Invalid date format",
"todays_documents": "Documents from this day",
"todays_documents_none": "No documents yet.",
"manage_invoices": "Manage Invoices",
"create_invoice": "Create Invoice",
"invoice_amount": "Amount",
"invoice_apply_tax": "Apply Tax",
"invoice_client_address": "Client Address",
"invoice_client_company": "Client Company",
"invoice_client_email": "Client E-mail",
"invoice_client_name": "Client Contact",
"invoice_currency": "Currency",
"invoice_dialog_title": "Create Invoice",
"invoice_due_date": "Due Date",
"invoice_hourly_rate": "Hourly Rate",
"invoice_hours": "Hours",
"invoice_issue_date": "Issue Date",
"invoice_mode_detailed": "Detailed mode",
"invoice_mode_summary": "Summary mode",
"invoice_number": "Invoice Number",
"invoice_save_and_export": "Save and export",
"invoice_save_pdf_title": "Save PDF",
"invoice_subtotal": "Subtotal",
"invoice_summary_default_desc": "Consultant services for the month of",
"invoice_summary_desc": "Summary description",
"invoice_summary_hours": "Summary hours",
"invoice_tax": "Tax details",
"invoice_tax_label": "Tax type",
"invoice_tax_rate": "Tax rate",
"invoice_tax_total": "Tax total",
"invoice_total": "Total",
"invoice_paid_at": "Paid on",
"invoice_payment_note": "Payment notes",
"invoice_project_required_title": "Project required",
"invoice_project_required_message": "Please select a specific project before trying to create an invoice.",
"invoice_need_report_title": "Report required",
"invoice_need_report_message": "Please run a time report before trying to create an invoice from it.",
"invoice_due_before_issue": "Due date cannot be earlier than the issue date.",
"invoice_paid_before_issue": "Paid date cannot be earlier than the issue date.",
"enable_invoicing_feature": "Enable Invoicing (requires Time Logging)",
"invoice_company_profile": "Business Profile",
"invoice_company_name": "Business Name",
"invoice_company_address": "Address",
"invoice_company_phone": "Phone",
"invoice_company_email": "E-mail",
"invoice_company_tax_id": "Tax number",
"invoice_company_payment_details": "Payment details",
"invoice_company_logo": "Logo",
"invoice_company_logo_choose": "Choose logo",
"invoice_company_logo_set": "Logo has been set",
"invoice_company_logo_not_set": "Logo not set",
"invoice_number_unique": "Invoice number must be unique. This invoice number already exists.",
"invoice_invalid_amount": "The amount is invalid",
"invoice_invalid_date_format": "Invalid date format",
"invoice_invalid_tax_rate": "The tax rate is invalid",
"invoice_no_items": "There are no items in the invoice",
"invoice_number_required": "An invoice number is required",
"invoice_required": "Please select a specific invoice before trying to delete an invoice.",
"refresh": "Refresh",
"status": "Status",
"client": "Client",
"documents": "Documents",
"invoices": "Invoices",
"documents_select_document": "Please select a document first.",
"toolbar_projects": "Projects",
"projects_title": "Projects",
"projects_none": "No projects have been configured yet. Add a project from the time logging dialog first.",
"projects_summary_tab": "Summary",
"project_bucket": "Project bucket",
"project_bucket_settings": "Bucket settings",
"project_bucket_replenish": "Replenish",
"project_bucket_baseline": "Baseline",
"project_bucket_ceiling": "Bucket ceiling",
"project_bucket_warn_at": "Warn at",
"project_hours_logged": "Logged",
"project_bucket_used": "Used",
"project_bucket_remaining": "Remaining",
"project_bucket_add_to_ceiling": "Add to ceiling",
"project_bucket_no_project": "Select a project to view its bucket.",
"project_bucket_unconfigured": "{project}: {used:.2f}h used so far ({baseline:.2f}h baseline + {logged:.2f}h logged). No bucket ceiling has been set.",
"project_bucket_status": "{project}: {used:.2f}h / {ceiling:.2f}h used{percent}{remaining}. Baseline {baseline:.2f}h, logged in Bouquin {logged:.2f}h. Status: {state}.",
"project_bucket_state_unconfigured": "No bucket",
"project_bucket_state_ok": "OK",
"project_bucket_state_warning": "Approaching bucket ceiling",
"project_bucket_state_reached": "Bucket ceiling reached",
"project_bucket_state_exceeded": "Bucket ceiling exceeded",
"project_bucket_alert_title": "Project bucket alert",
"project_bucket_alert_message": "{status}",
"project_open_invoice_document": "Open invoice document",
"project_invoice_no_document": "This invoice does not have a linked document.",
"project_bucket_invoice_prepaid": "Invoice prepaid hours",
"project_prepaid_invoice_default_desc": "Prepaid support bucket ({hours:.2f} hours)",
"project_prepaid_invoice_hours_required": "Enter a prepaid-hours amount greater than zero before creating an invoice.",
"time_logs": "Time logs",
"project_bucket_ledger_tab": "Bucket ledger",
"project_changelog_tab": "Project log",
"project_bucket_baseline_delta": "Baseline Δ",
"project_bucket_ceiling_delta": "Ceiling Δ",
"project_bucket_used_delta": "Used Δ",
"project_bucket_manual_topup_desc": "Manual bucket top-up ({hours:.2f} hours)",
"project_bucket_prepaid_invoice_desc": "Prepaid bucket invoice ({hours:.2f} hours)",
"project_bucket_ledger_type_settings_adjustment": "Bucket settings",
"project_bucket_ledger_type_manual_topup": "Manual top-up",
"project_bucket_ledger_type_prepaid_invoice": "Prepaid invoice",
"project_bucket_ledger_type_time_log": "Time logged",
"project_changelog_type_time_log": "Time log",
"project_changelog_type_document": "Document",
"project_changelog_type_invoice": "Invoice",
"project_changelog_type_bucket": "Bucket",
"summary": "Summary",
"details": "Details"
}

View file

@ -49,6 +49,7 @@ from PySide6.QtWidgets import (
from . import strings
from .bug_report_dialog import BugReportDialog
from .projects import ProjectsDialog
from .db import DBManager
from .documents import DocumentsDialog, TodaysDocumentsWidget
from .find_bar import FindBar
@ -239,6 +240,12 @@ class MainWindow(QMainWindow):
act_stats.setShortcut("Ctrl+Shift+S")
act_stats.triggered.connect(self._open_statistics)
file_menu.addAction(act_stats)
self.actProjects = QAction(strings._("projects"), self)
self.actProjects.setShortcut("Ctrl+Shift+C")
self.actProjects.setShortcutContext(Qt.ApplicationShortcut)
self.actProjects.triggered.connect(self._open_projects)
file_menu.addAction(self.actProjects)
self.addAction(self.actProjects)
act_lock = QAction(strings._("main_window_lock_screen_accessibility"), self)
act_lock.setShortcut("Ctrl+Shift+L")
act_lock.triggered.connect(self._enter_lock)
@ -338,6 +345,9 @@ class MainWindow(QMainWindow):
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
self.toolBar.actProjects.setVisible(False)
self.actProjects.setVisible(False)
self.actProjects.setEnabled(False)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)
@ -1461,6 +1471,7 @@ class MainWindow(QMainWindow):
self._tb_alarm = self._on_alarm_requested
self._tb_timer = self._on_timer_requested
self._tb_documents = self._on_documents_requested
self._tb_projects = self._open_projects
self._tb_font_larger = self._on_font_larger_requested
self._tb_font_smaller = self._on_font_smaller_requested
@ -1475,6 +1486,7 @@ class MainWindow(QMainWindow):
tb.alarmRequested.connect(self._tb_alarm)
tb.timerRequested.connect(self._tb_timer)
tb.documentsRequested.connect(self._tb_documents)
tb.projectsRequested.connect(self._tb_projects)
tb.insertImageRequested.connect(self._on_insert_image)
tb.historyRequested.connect(self._open_history)
tb.fontSizeLargerRequested.connect(self._tb_font_larger)
@ -1716,6 +1728,13 @@ class MainWindow(QMainWindow):
timer.start(msecs)
self._reminder_timers.append(timer)
# ----------- Projects handler ------------#
def _open_projects(self):
if not self.cfg.time_log:
return
dlg = ProjectsDialog(self.db, self)
dlg.exec()
# ----------- Documents handler ------------#
def _on_documents_requested(self):
documents_dlg = DocumentsDialog(self.db, self)
@ -1868,9 +1887,15 @@ class MainWindow(QMainWindow):
if not self.cfg.time_log:
self.time_log.hide()
self.toolBar.actTimer.setVisible(False)
self.toolBar.actProjects.setVisible(False)
self.actProjects.setVisible(False)
self.actProjects.setEnabled(False)
else:
self.time_log.show()
self.toolBar.actTimer.setVisible(True)
self.toolBar.actProjects.setVisible(True)
self.actProjects.setVisible(True)
self.actProjects.setEnabled(True)
if not self.cfg.reminders:
self.upcoming_reminders.hide()
self.toolBar.actAlarm.setVisible(False)

845
bouquin/projects.py Normal file
View file

@ -0,0 +1,845 @@
from __future__ import annotations
from PySide6.QtCore import QDate, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QComboBox,
QDialog,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
from . import strings
from .db import DBManager
from .document_utils import open_document_from_db
from .invoices import InvoiceDialog
_WARNING_STYLES = {
"unconfigured": "",
"ok": "QLabel { padding: 6px; border: 1px solid #5b8f5b; border-radius: 4px; }",
"warning": "QLabel { padding: 6px; border: 1px solid #b58900; border-radius: 4px; }",
"reached": (
"QLabel { padding: 6px; border: 1px solid #b85c00; "
"border-radius: 4px; font-weight: bold; }"
),
"exceeded": (
"QLabel { padding: 6px; border: 1px solid #b00020; "
"border-radius: 4px; font-weight: bold; }"
),
}
def hours_from_minutes(minutes: int | float | None) -> float:
return float(minutes or 0) / 60.0
def minutes_from_hours(hours: float | int | None) -> int:
return int(round(float(hours or 0) * 60))
def format_bucket_status(status: dict[str, object] | None) -> str:
"""Human-readable project bucket status used by project/time-log UIs."""
if not status:
return strings._("project_bucket_no_project")
project = str(status.get("project_name") or "")
state = str(status.get("state") or "unconfigured")
baseline = hours_from_minutes(status.get("baseline_minutes"))
logged = hours_from_minutes(status.get("logged_minutes"))
used = hours_from_minutes(status.get("used_minutes"))
ceiling = hours_from_minutes(status.get("bucket_ceiling_minutes"))
remaining_minutes = status.get("remaining_minutes")
remaining = (
None if remaining_minutes is None else hours_from_minutes(remaining_minutes)
)
pct = status.get("percent_used")
pct_text = "" if pct is None else f" ({float(pct):.1f}%)"
if state == "unconfigured":
return strings._("project_bucket_unconfigured").format(
project=project,
baseline=baseline,
logged=logged,
used=used,
)
remaining_text = "" if remaining is None else f", {remaining:.2f}h remaining"
return strings._("project_bucket_status").format(
project=project,
used=used,
ceiling=ceiling,
percent=pct_text,
remaining=remaining_text,
baseline=baseline,
logged=logged,
state=strings._(f"project_bucket_state_{state}"),
)
class ProjectsDialog(QDialog):
"""Project overview and prepaid-hours bucket manager."""
SUM_PROJECT = 0
SUM_LOGGED = 1
SUM_BASELINE = 2
SUM_USED = 3
SUM_CEILING = 4
SUM_REMAINING = 5
SUM_STATE = 6
SUM_TIME_LOGS = 7
SUM_DOCS = 8
SUM_INVOICES = 9
LOG_DATE = 0
LOG_ACTIVITY = 1
LOG_NOTE = 2
LOG_HOURS = 3
LOG_CREATED = 4
LEDGER_DATE = 0
LEDGER_TYPE = 1
LEDGER_BASELINE = 2
LEDGER_CEILING = 3
LEDGER_USED = 4
LEDGER_NOTE = 5
CHANGE_DATE = 0
CHANGE_TYPE = 1
CHANGE_TITLE = 2
CHANGE_DETAILS = 3
DOC_FILE = 0
DOC_ADDED = 1
DOC_DESCRIPTION = 2
DOC_SIZE = 3
INV_NUMBER = 0
INV_ISSUE = 1
INV_DUE = 2
INV_TOTAL = 3
INV_PAID = 4
INV_DOCUMENT = 5
def __init__(self, db: DBManager, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
self._reloading = False
self.setWindowTitle(strings._("projects_title"))
self.resize(1100, 700)
root = QVBoxLayout(self)
top_form = QFormLayout()
top_form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
root.addLayout(top_form)
project_row = QHBoxLayout()
self.project_combo = QComboBox()
self.project_combo.currentIndexChanged.connect(self._on_project_changed)
project_row.addWidget(self.project_combo, 1)
self.refresh_btn = QPushButton(strings._("refresh"))
self.refresh_btn.clicked.connect(self.reload)
project_row.addWidget(self.refresh_btn)
top_form.addRow(strings._("project"), project_row)
self.status_label = QLabel("")
self.status_label.setWordWrap(True)
self.status_label.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
)
self.status_label.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.MinimumExpanding
)
self.status_label.setMinimumHeight(
self.status_label.fontMetrics().lineSpacing() * 3 + 18
)
top_form.addRow(strings._("project_bucket"), self.status_label)
bucket_row = QHBoxLayout()
self.baseline_spin = QDoubleSpinBox()
self.baseline_spin.setRange(0.0, 1_000_000.0)
self.baseline_spin.setDecimals(2)
self.baseline_spin.setSingleStep(1.0)
self.baseline_spin.setSuffix(" h")
bucket_row.addWidget(QLabel(strings._("project_bucket_baseline")))
bucket_row.addWidget(self.baseline_spin)
self.ceiling_spin = QDoubleSpinBox()
self.ceiling_spin.setRange(0.0, 1_000_000.0)
self.ceiling_spin.setDecimals(2)
self.ceiling_spin.setSingleStep(1.0)
self.ceiling_spin.setSuffix(" h")
bucket_row.addWidget(QLabel(strings._("project_bucket_ceiling")))
bucket_row.addWidget(self.ceiling_spin)
self.warn_spin = QDoubleSpinBox()
self.warn_spin.setRange(0.0, 100.0)
self.warn_spin.setDecimals(0)
self.warn_spin.setSingleStep(5.0)
self.warn_spin.setSuffix(" %")
bucket_row.addWidget(QLabel(strings._("project_bucket_warn_at")))
bucket_row.addWidget(self.warn_spin)
self.save_bucket_btn = QPushButton(strings._("save"))
self.save_bucket_btn.clicked.connect(self._save_bucket)
bucket_row.addWidget(self.save_bucket_btn)
top_form.addRow(strings._("project_bucket_settings"), bucket_row)
topup_row = QHBoxLayout()
self.topup_spin = QDoubleSpinBox()
self.topup_spin.setRange(0.0, 1_000_000.0)
self.topup_spin.setDecimals(2)
self.topup_spin.setSingleStep(1.0)
self.topup_spin.setValue(40.0)
self.topup_spin.setSuffix(" h")
topup_row.addWidget(self.topup_spin)
self.topup_btn = QPushButton(strings._("project_bucket_add_to_ceiling"))
self.topup_btn.clicked.connect(self._add_to_ceiling)
topup_row.addWidget(self.topup_btn)
self.invoice_prepaid_btn = QPushButton(
strings._("project_bucket_invoice_prepaid")
)
self.invoice_prepaid_btn.clicked.connect(self._invoice_prepaid_hours)
topup_row.addWidget(self.invoice_prepaid_btn)
topup_row.addStretch(1)
top_form.addRow(strings._("project_bucket_replenish"), topup_row)
self.tabs = QTabWidget()
root.addWidget(self.tabs, 1)
self.summary_table = QTableWidget()
self.summary_table.setColumnCount(10)
self.summary_table.setHorizontalHeaderLabels(
[
strings._("project"),
strings._("project_hours_logged"),
strings._("project_bucket_baseline"),
strings._("project_bucket_used"),
strings._("project_bucket_ceiling"),
strings._("project_bucket_remaining"),
strings._("status"),
strings._("time_logs"),
strings._("documents"),
strings._("invoices"),
]
)
self.summary_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.summary_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.summary_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.summary_table.itemSelectionChanged.connect(self._on_summary_selected)
header = self.summary_table.horizontalHeader()
header.setSectionResizeMode(self.SUM_PROJECT, QHeaderView.Stretch)
for col in range(1, 10):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
self.tabs.addTab(self.summary_table, strings._("projects_summary_tab"))
logs_tab = QWidget()
logs_layout = QVBoxLayout(logs_tab)
self.time_logs_table = QTableWidget()
self.time_logs_table.setColumnCount(5)
self.time_logs_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("activity"),
strings._("note"),
strings._("hours"),
strings._("created_at"),
]
)
self.time_logs_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.time_logs_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.time_logs_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
log_header = self.time_logs_table.horizontalHeader()
log_header.setSectionResizeMode(self.LOG_DATE, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_ACTIVITY, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_NOTE, QHeaderView.Stretch)
log_header.setSectionResizeMode(self.LOG_HOURS, QHeaderView.ResizeToContents)
log_header.setSectionResizeMode(self.LOG_CREATED, QHeaderView.ResizeToContents)
logs_layout.addWidget(self.time_logs_table, 1)
self.tabs.addTab(logs_tab, strings._("time_logs"))
ledger_tab = QWidget()
ledger_layout = QVBoxLayout(ledger_tab)
self.bucket_ledger_table = QTableWidget()
self.bucket_ledger_table.setColumnCount(6)
self.bucket_ledger_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("project_bucket_baseline_delta"),
strings._("project_bucket_ceiling_delta"),
strings._("project_bucket_used_delta"),
strings._("note"),
]
)
self.bucket_ledger_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.bucket_ledger_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.bucket_ledger_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
ledger_header = self.bucket_ledger_table.horizontalHeader()
ledger_header.setSectionResizeMode(
self.LEDGER_DATE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_TYPE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_BASELINE, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_CEILING, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(
self.LEDGER_USED, QHeaderView.ResizeToContents
)
ledger_header.setSectionResizeMode(self.LEDGER_NOTE, QHeaderView.Stretch)
ledger_layout.addWidget(self.bucket_ledger_table, 1)
self.tabs.addTab(ledger_tab, strings._("project_bucket_ledger_tab"))
changelog_tab = QWidget()
changelog_layout = QVBoxLayout(changelog_tab)
self.changelog_table = QTableWidget()
self.changelog_table.setColumnCount(4)
self.changelog_table.setHorizontalHeaderLabels(
[
strings._("date"),
strings._("type"),
strings._("summary"),
strings._("details"),
]
)
self.changelog_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.changelog_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.changelog_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
changelog_header = self.changelog_table.horizontalHeader()
changelog_header.setSectionResizeMode(
self.CHANGE_DATE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TYPE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(
self.CHANGE_TITLE, QHeaderView.ResizeToContents
)
changelog_header.setSectionResizeMode(self.CHANGE_DETAILS, QHeaderView.Stretch)
changelog_layout.addWidget(self.changelog_table, 1)
self.tabs.addTab(changelog_tab, strings._("project_changelog_tab"))
docs_tab = QWidget()
docs_layout = QVBoxLayout(docs_tab)
self.documents_table = QTableWidget()
self.documents_table.setColumnCount(4)
self.documents_table.setHorizontalHeaderLabels(
[
strings._("documents_col_file"),
strings._("documents_col_added"),
strings._("documents_col_description"),
strings._("documents_col_size"),
]
)
self.documents_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.documents_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.documents_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.documents_table.itemDoubleClicked.connect(self._open_selected_document)
doc_header = self.documents_table.horizontalHeader()
doc_header.setSectionResizeMode(self.DOC_FILE, QHeaderView.Stretch)
doc_header.setSectionResizeMode(self.DOC_ADDED, QHeaderView.ResizeToContents)
doc_header.setSectionResizeMode(self.DOC_DESCRIPTION, QHeaderView.Stretch)
doc_header.setSectionResizeMode(self.DOC_SIZE, QHeaderView.ResizeToContents)
docs_layout.addWidget(self.documents_table, 1)
docs_buttons = QHBoxLayout()
docs_buttons.addStretch(1)
self.open_doc_btn = QPushButton(strings._("documents_open"))
self.open_doc_btn.clicked.connect(self._open_selected_document)
docs_buttons.addWidget(self.open_doc_btn)
docs_layout.addLayout(docs_buttons)
self.tabs.addTab(docs_tab, strings._("documents"))
invoices_tab = QWidget()
invoices_layout = QVBoxLayout(invoices_tab)
self.invoices_table = QTableWidget()
self.invoices_table.setColumnCount(6)
self.invoices_table.setHorizontalHeaderLabels(
[
strings._("invoice_number"),
strings._("invoice_issue_date"),
strings._("invoice_due_date"),
strings._("invoice_total"),
strings._("invoice_paid_at"),
strings._("documents_col_file"),
]
)
self.invoices_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.invoices_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.invoices_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.invoices_table.itemDoubleClicked.connect(self._open_invoice_document)
inv_header = self.invoices_table.horizontalHeader()
inv_header.setSectionResizeMode(self.INV_NUMBER, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_ISSUE, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_DUE, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_TOTAL, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_PAID, QHeaderView.ResizeToContents)
inv_header.setSectionResizeMode(self.INV_DOCUMENT, QHeaderView.Stretch)
invoices_layout.addWidget(self.invoices_table, 1)
invoice_buttons = QHBoxLayout()
invoice_buttons.addStretch(1)
self.open_invoice_doc_btn = QPushButton(
strings._("project_open_invoice_document")
)
self.open_invoice_doc_btn.clicked.connect(self._open_invoice_document)
invoice_buttons.addWidget(self.open_invoice_doc_btn)
invoices_layout.addLayout(invoice_buttons)
self.tabs.addTab(invoices_tab, strings._("invoices"))
bottom = QHBoxLayout()
bottom.addStretch(1)
close_btn = QPushButton(strings._("close"))
close_btn.clicked.connect(self.accept)
bottom.addWidget(close_btn)
root.addLayout(bottom)
self.reload()
def reload(self) -> None:
current = self._current_project_id()
self._load_projects(current)
self._reload_summary()
self._load_selected_project()
def _load_projects(self, preferred: int | None = None) -> None:
self._reloading = True
try:
self.project_combo.clear()
for r in self._db.list_project_summaries():
self.project_combo.addItem(str(r["project_name"]), int(r["project_id"]))
if preferred is not None:
idx = self.project_combo.findData(preferred)
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
elif self.project_combo.count() > 0:
self.project_combo.setCurrentIndex(0)
finally:
self._reloading = False
def _current_project_id(self) -> int | None:
data = self.project_combo.currentData()
return int(data) if data is not None else None
def _on_project_changed(self, _idx: int) -> None:
if self._reloading:
return
self._load_selected_project()
def _on_summary_selected(self) -> None:
if self._reloading:
return
row = self.summary_table.currentRow()
if row < 0:
return
item = self.summary_table.item(row, self.SUM_PROJECT)
if item is None:
return
project_id = item.data(Qt.ItemDataRole.UserRole)
if project_id is None:
return
idx = self.project_combo.findData(int(project_id))
if idx >= 0:
self.project_combo.setCurrentIndex(idx)
def _state_for_row(self, r) -> str:
baseline = int(r["baseline_minutes"] or 0)
logged = int(r["logged_minutes"] or 0)
used = baseline + logged
ceiling = int(r["bucket_ceiling_minutes"] or 0)
warn_at = float(r["warn_at_percent"] or 80.0)
if ceiling <= 0:
return "unconfigured"
pct = used / ceiling * 100.0
if used > ceiling:
return "exceeded"
if used == ceiling:
return "reached"
if pct >= warn_at:
return "warning"
return "ok"
def _reload_summary(self) -> None:
self._reloading = True
try:
rows = self._db.list_project_summaries()
self.summary_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
project_id = int(r["project_id"])
baseline = int(r["baseline_minutes"] or 0)
logged = int(r["logged_minutes"] or 0)
used = baseline + logged
ceiling = int(r["bucket_ceiling_minutes"] or 0)
remaining = ceiling - used if ceiling > 0 else None
state = self._state_for_row(r)
project_item = QTableWidgetItem(str(r["project_name"]))
project_item.setData(Qt.ItemDataRole.UserRole, project_id)
self.summary_table.setItem(row_idx, self.SUM_PROJECT, project_item)
self.summary_table.setItem(
row_idx,
self.SUM_LOGGED,
QTableWidgetItem(f"{hours_from_minutes(logged):.2f}"),
)
self.summary_table.setItem(
row_idx,
self.SUM_BASELINE,
QTableWidgetItem(f"{hours_from_minutes(baseline):.2f}"),
)
self.summary_table.setItem(
row_idx,
self.SUM_USED,
QTableWidgetItem(f"{hours_from_minutes(used):.2f}"),
)
ceiling_text = (
"" if ceiling <= 0 else f"{hours_from_minutes(ceiling):.2f}"
)
remaining_text = (
"" if remaining is None else f"{hours_from_minutes(remaining):.2f}"
)
state_text = strings._(f"project_bucket_state_{state}")
self.summary_table.setItem(
row_idx, self.SUM_CEILING, QTableWidgetItem(ceiling_text)
)
self.summary_table.setItem(
row_idx, self.SUM_REMAINING, QTableWidgetItem(remaining_text)
)
self.summary_table.setItem(
row_idx, self.SUM_STATE, QTableWidgetItem(state_text)
)
self.summary_table.setItem(
row_idx,
self.SUM_TIME_LOGS,
QTableWidgetItem(str(r["time_log_count"] or 0)),
)
self.summary_table.setItem(
row_idx,
self.SUM_DOCS,
QTableWidgetItem(str(r["document_count"] or 0)),
)
self.summary_table.setItem(
row_idx,
self.SUM_INVOICES,
QTableWidgetItem(str(r["invoice_count"] or 0)),
)
finally:
self._reloading = False
def _load_selected_project(self) -> None:
project_id = self._current_project_id()
if project_id is None:
self.status_label.setText(strings._("projects_none"))
self.status_label.setStyleSheet("")
self.baseline_spin.setValue(0.0)
self.ceiling_spin.setValue(0.0)
self.warn_spin.setValue(80.0)
self.time_logs_table.setRowCount(0)
self.bucket_ledger_table.setRowCount(0)
self.changelog_table.setRowCount(0)
self.documents_table.setRowCount(0)
self.invoices_table.setRowCount(0)
return
status = self._db.project_bucket_status(project_id)
self.status_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
self.status_label.setStyleSheet(_WARNING_STYLES.get(state, ""))
bucket = self._db.get_project_bucket(project_id)
self.baseline_spin.setValue(
hours_from_minutes(bucket["baseline_minutes"] if bucket else 0)
)
self.ceiling_spin.setValue(
hours_from_minutes(bucket["bucket_ceiling_minutes"] if bucket else 0)
)
self.warn_spin.setValue(float(bucket["warn_at_percent"] if bucket else 80.0))
self._reload_time_logs(project_id)
self._reload_bucket_ledger(project_id)
self._reload_changelog(project_id)
self._reload_documents(project_id)
self._reload_invoices(project_id)
def _reload_time_logs(self, project_id: int) -> None:
rows = self._db.time_logs_for_project(project_id)
self.time_logs_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
self.time_logs_table.setItem(
row_idx, self.LOG_DATE, QTableWidgetItem(r["page_date"] or "")
)
self.time_logs_table.setItem(
row_idx, self.LOG_ACTIVITY, QTableWidgetItem(r["activity_name"] or "")
)
self.time_logs_table.setItem(
row_idx, self.LOG_NOTE, QTableWidgetItem(r["note"] or "")
)
self.time_logs_table.setItem(
row_idx,
self.LOG_HOURS,
QTableWidgetItem(f"{hours_from_minutes(r['minutes']):.2f}"),
)
self.time_logs_table.setItem(
row_idx, self.LOG_CREATED, QTableWidgetItem(r["created_at"] or "")
)
def _format_delta_hours(self, minutes: int | None, invert: bool = False) -> str:
minutes = int(minutes or 0)
if minutes == 0:
return ""
if invert:
minutes = -minutes
sign = "+" if minutes > 0 else "-"
return f"{sign}{hours_from_minutes(abs(minutes)):.2f}"
def _reload_bucket_ledger(self, project_id: int) -> None:
rows = self._db.project_bucket_ledger_for_project(project_id)
self.bucket_ledger_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
entry_type = str(r["entry_type"] or "")
type_text = strings._(f"project_bucket_ledger_type_{entry_type}")
if type_text == f"project_bucket_ledger_type_{entry_type}":
type_text = entry_type.replace("_", " ").title()
note = r["description"] or ""
if r["invoice_number"]:
note = (
f"{note} ({r['invoice_number']})" if note else r["invoice_number"]
)
if entry_type == "time_log" and r["page_date"]:
activity = r["activity_name"] or ""
note = f"{r['page_date']}{activity}: {note}".strip()
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_TYPE, QTableWidgetItem(type_text)
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_BASELINE,
QTableWidgetItem(self._format_delta_hours(r["baseline_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_CEILING,
QTableWidgetItem(self._format_delta_hours(r["ceiling_delta_minutes"])),
)
self.bucket_ledger_table.setItem(
row_idx,
self.LEDGER_USED,
QTableWidgetItem(
self._format_delta_hours(r["used_delta_minutes"], invert=True)
),
)
self.bucket_ledger_table.setItem(
row_idx, self.LEDGER_NOTE, QTableWidgetItem(note)
)
def _reload_changelog(self, project_id: int) -> None:
rows = self._db.project_activity_log_for_project(project_id)
self.changelog_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
event_type = str(r["event_type"] or "")
type_text = strings._(f"project_changelog_type_{event_type}")
if type_text == f"project_changelog_type_{event_type}":
type_text = event_type.replace("_", " ").title()
self.changelog_table.setItem(
row_idx, self.CHANGE_DATE, QTableWidgetItem(r["occurred_at"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TYPE, QTableWidgetItem(type_text)
)
self.changelog_table.setItem(
row_idx, self.CHANGE_TITLE, QTableWidgetItem(r["title"] or "")
)
self.changelog_table.setItem(
row_idx, self.CHANGE_DETAILS, QTableWidgetItem(r["details"] or "")
)
def _reload_documents(self, project_id: int) -> None:
rows = self._db.documents_for_project(project_id)
self.documents_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
doc_id, _project_id, _project_name, file_name, description, size, added = r
file_item = QTableWidgetItem(file_name or "")
file_item.setData(Qt.ItemDataRole.UserRole, int(doc_id))
self.documents_table.setItem(row_idx, self.DOC_FILE, file_item)
self.documents_table.setItem(
row_idx, self.DOC_ADDED, QTableWidgetItem(added or "")
)
self.documents_table.setItem(
row_idx, self.DOC_DESCRIPTION, QTableWidgetItem(description or "")
)
self.documents_table.setItem(
row_idx, self.DOC_SIZE, QTableWidgetItem(str(size or 0))
)
def _reload_invoices(self, project_id: int) -> None:
rows = self._db.invoices_for_project_with_documents(project_id)
self.invoices_table.setRowCount(len(rows))
for row_idx, r in enumerate(rows):
document_id = r["document_id"]
file_name = r["document_file_name"] or ""
num_item = QTableWidgetItem(r["invoice_number"] or "")
num_item.setData(Qt.ItemDataRole.UserRole, int(r["id"]))
self.invoices_table.setItem(row_idx, self.INV_NUMBER, num_item)
self.invoices_table.setItem(
row_idx, self.INV_ISSUE, QTableWidgetItem(r["issue_date"] or "")
)
self.invoices_table.setItem(
row_idx, self.INV_DUE, QTableWidgetItem(r["due_date"] or "")
)
total = int(r["total_cents"] or 0) / 100.0
currency = r["currency"] or ""
self.invoices_table.setItem(
row_idx,
self.INV_TOTAL,
QTableWidgetItem(f"{total:.2f} {currency}".strip()),
)
self.invoices_table.setItem(
row_idx, self.INV_PAID, QTableWidgetItem(r["paid_at"] or "")
)
doc_item = QTableWidgetItem(file_name)
doc_item.setData(
Qt.ItemDataRole.UserRole,
int(document_id) if document_id else None,
)
self.invoices_table.setItem(row_idx, self.INV_DOCUMENT, doc_item)
def _save_bucket(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
self._db.upsert_project_bucket(
project_id,
minutes_from_hours(self.baseline_spin.value()),
minutes_from_hours(self.ceiling_spin.value()),
float(self.warn_spin.value()),
)
self.reload()
def _add_to_ceiling(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
add_minutes = minutes_from_hours(self.topup_spin.value())
if add_minutes <= 0:
return
self._db.add_to_project_bucket_ceiling(
project_id,
add_minutes,
description=strings._("project_bucket_manual_topup_desc").format(
hours=hours_from_minutes(add_minutes)
),
)
self.reload()
def _invoice_prepaid_hours(self) -> None:
project_id = self._current_project_id()
if project_id is None:
return
hours = float(self.topup_spin.value())
if hours <= 0.0:
QMessageBox.warning(
self,
strings._("project_bucket_invoice_prepaid"),
strings._("project_prepaid_invoice_hours_required"),
)
return
today = QDate.currentDate().toString("yyyy-MM-dd")
dialog = InvoiceDialog(
self._db,
project_id,
today,
today,
time_rows=[],
parent=self,
)
dialog.rb_summary.setChecked(True)
dialog.summary_desc_edit.setText(
strings._("project_prepaid_invoice_default_desc").format(hours=hours)
)
dialog.summary_hours_spin.setValue(hours)
dialog._recalc_totals()
if dialog.exec() == QDialog.Accepted:
invoice_id = getattr(dialog, "last_invoice_id", None)
if invoice_id is not None:
self._db.add_to_project_bucket_ceiling(
project_id,
minutes_from_hours(hours),
description=strings._("project_bucket_prepaid_invoice_desc").format(
hours=hours
),
invoice_id=int(invoice_id),
)
self.reload()
def _selected_doc_id(self) -> tuple[int, str] | None:
row = self.documents_table.currentRow()
if row < 0:
return None
item = self.documents_table.item(row, self.DOC_FILE)
if item is None:
return None
doc_id = item.data(Qt.ItemDataRole.UserRole)
file_name = item.text()
if doc_id is None or not file_name:
return None
return int(doc_id), file_name
def _open_selected_document(self, *_args) -> None:
selected = self._selected_doc_id()
if not selected:
QMessageBox.information(
self,
strings._("documents_open"),
strings._("documents_select_document"),
)
return
doc_id, file_name = selected
open_document_from_db(self._db, doc_id, file_name, parent_widget=self)
def _open_invoice_document(self, *_args) -> None:
row = self.invoices_table.currentRow()
if row < 0:
return
doc_item = self.invoices_table.item(row, self.INV_DOCUMENT)
if doc_item is None:
return
doc_id = doc_item.data(Qt.ItemDataRole.UserRole)
file_name = doc_item.text()
if doc_id is None or not file_name:
QMessageBox.information(
self,
strings._("project_open_invoice_document"),
strings._("project_invoice_no_document"),
)
return
open_document_from_db(self._db, int(doc_id), file_name, parent_widget=self)

View file

@ -42,6 +42,7 @@ from PySide6.QtWidgets import (
from sqlcipher4.dbapi2 import IntegrityError
from . import strings
from .projects import format_bucket_status
from .db import DBManager
from .settings import load_db_config
from .theme import ThemeManager
@ -302,6 +303,7 @@ class TimeLogDialog(QDialog):
# Project
proj_row = QHBoxLayout()
self.project_combo = QComboBox()
self.project_combo.currentIndexChanged.connect(self._refresh_bucket_indicator)
self.manage_projects_btn = QPushButton(strings._("manage_projects"))
self.manage_projects_btn.clicked.connect(self._manage_projects)
proj_row.addWidget(self.project_combo, 1)
@ -331,6 +333,10 @@ class TimeLogDialog(QDialog):
self.hours_spin.setValue(0.25)
form.addRow(strings._("hours"), self.hours_spin)
self.bucket_label = QLabel("")
self.bucket_label.setWordWrap(True)
form.addRow(strings._("project_bucket"), self.bucket_label)
root.addLayout(form)
# --- Buttons for entry
@ -409,6 +415,7 @@ class TimeLogDialog(QDialog):
self.project_combo.clear()
for proj_id, name in self._db.list_projects():
self.project_combo.addItem(name, proj_id)
self._refresh_bucket_indicator()
def _reload_activities(self) -> None:
activities = [name for _, name in self._db.list_activities()]
@ -461,11 +468,48 @@ class TimeLogDialog(QDialog):
self.total_label.setText(
strings._("time_log_total_hours").format(hours=self.total_hours)
)
self._refresh_bucket_indicator()
self._current_entry_id = None
self.delete_btn.setEnabled(False)
self.add_update_btn.setText("&" + strings._("add_time_entry"))
# ----- Project bucket indicator -----------------------------------
def _refresh_bucket_indicator(self, *_args) -> None:
if not hasattr(self, "bucket_label"):
return
proj_id = self._ensure_project_id()
status = self._db.project_bucket_status(proj_id) if proj_id else None
self.bucket_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
if state == "warning":
style = "QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
elif state in ("reached", "exceeded"):
style = "QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
elif state == "ok":
style = "QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
else:
style = ""
self.bucket_label.setStyleSheet(style)
def _maybe_show_bucket_alert(self, project_id: int | None) -> None:
if project_id is None:
return
status = self._db.project_bucket_status(project_id)
if not status:
return
state = str(status.get("state") or "")
if state not in {"reached", "exceeded"}:
return
QMessageBox.warning(
self,
strings._("project_bucket_alert_title"),
strings._("project_bucket_alert_message").format(
status=format_bucket_status(status)
),
)
# ----- Actions -----------------------------------------------------
def _on_change_date_clicked(self) -> None:
@ -561,6 +605,7 @@ class TimeLogDialog(QDialog):
)
self._reload_entries()
self._maybe_show_bucket_alert(proj_id)
if self.close_after_add:
self.close()
@ -1135,6 +1180,10 @@ class TimeReportDialog(QDialog):
self.total_label = QLabel("")
root.addWidget(self.total_label)
self.bucket_label = QLabel("")
self.bucket_label.setWordWrap(True)
root.addWidget(self.bucket_label)
# Close
close_row = QHBoxLayout()
close_row.addStretch(1)
@ -1328,6 +1377,28 @@ class TimeReportDialog(QDialog):
strings._("time_report_total").format(hours=total_hours)
)
if proj_data is None:
self.bucket_label.setText("")
self.bucket_label.setStyleSheet("")
else:
status = self._db.project_bucket_status(int(proj_data))
self.bucket_label.setText(format_bucket_status(status))
state = str(status.get("state") if status else "unconfigured")
if state == "warning":
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #b58900; border-radius: 4px; }"
)
elif state in ("reached", "exceeded"):
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #b00020; border-radius: 4px; font-weight: bold; }"
)
elif state == "ok":
self.bucket_label.setStyleSheet(
"QLabel { padding: 4px; border: 1px solid #5b8f5b; border-radius: 4px; }"
)
else:
self.bucket_label.setStyleSheet("")
def _export_csv(self):
if not self._last_rows:
QMessageBox.information(

View file

@ -21,6 +21,7 @@ class ToolBar(QToolBar):
alarmRequested = Signal()
timerRequested = Signal()
documentsRequested = Signal()
projectsRequested = Signal()
fontSizeLargerRequested = Signal()
fontSizeSmallerRequested = Signal()
@ -127,6 +128,11 @@ class ToolBar(QToolBar):
self.actDocuments = QAction("📁", self)
self.actDocuments.setToolTip(strings._("toolbar_documents"))
self.actDocuments.triggered.connect(self.documentsRequested)
# Projects
self.actProjects = QAction("📌", self)
self.actProjects.setToolTip(strings._("toolbar_projects"))
self.actProjects.triggered.connect(self.projectsRequested)
# Headings are mutually exclusive (like radio buttons)
self.grpHeadings = QActionGroup(self)
self.grpHeadings.setExclusive(True)
@ -159,6 +165,7 @@ class ToolBar(QToolBar):
self.actInsertImg,
self.actAlarm,
self.actTimer,
self.actProjects,
self.actDocuments,
self.actHistory,
]
@ -186,6 +193,7 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actCheckboxes, "")
self._style_letter_button(self.actAlarm, "")
self._style_letter_button(self.actTimer, "")
self._style_letter_button(self.actProjects, "📌")
self._style_letter_button(self.actDocuments, "📁")
# History

8
debian/changelog vendored
View file

@ -1,3 +1,11 @@
bouquin (0.9.0) unstable; urgency=medium
* Add 'Projects' interface for unified time/invoice/docs view.
* Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
* Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
-- Miguel Jacq <mig@mig5.net> Sun, 07 Jun 2026 17:18:00 +1000
bouquin (0.8.4) unstable; urgency=medium
* Dependency updates

295
poetry.lock generated
View file

@ -13,13 +13,13 @@ files = [
[[package]]
name = "certifi"
version = "2026.4.22"
version = "2026.5.20"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
{file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
{file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"},
{file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"},
]
[[package]]
@ -173,117 +173,117 @@ files = [
[[package]]
name = "coverage"
version = "7.14.0"
version = "7.14.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
{file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
{file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
{file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
{file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
{file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
{file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
{file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
{file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
{file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
{file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
{file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
{file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
{file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
{file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
{file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
{file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
{file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
{file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
{file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
{file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
{file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
{file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
{file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
{file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
{file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
{file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
{file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
{file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
{file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
{file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
{file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
{file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
{file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
{file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
{file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
{file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
{file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
{file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
{file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
{file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
{file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
{file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
{file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
{file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
{file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
{file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
{file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
{file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
{file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
{file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
{file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
{file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
{file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
{file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
{file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
{file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
{file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
{file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"},
{file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"},
{file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"},
{file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"},
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"},
{file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"},
{file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"},
{file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"},
{file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"},
{file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"},
{file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"},
{file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"},
{file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"},
{file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"},
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"},
{file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"},
{file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"},
{file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"},
{file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"},
{file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"},
{file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"},
{file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"},
{file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"},
{file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"},
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"},
{file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"},
{file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"},
{file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"},
{file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"},
{file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"},
{file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"},
{file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"},
{file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"},
{file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"},
{file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"},
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"},
{file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"},
{file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"},
{file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"},
{file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"},
{file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"},
{file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"},
{file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"},
{file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"},
{file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"},
{file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"},
{file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"},
{file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"},
{file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"},
{file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"},
{file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"},
{file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"},
{file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"},
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"},
{file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"},
{file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"},
{file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"},
{file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"},
{file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"},
{file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"},
{file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"},
{file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"},
{file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"},
{file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"},
{file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"},
{file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"},
{file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"},
{file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"},
{file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"},
]
[package.dependencies]
@ -325,13 +325,13 @@ test = ["pytest (>=6)"]
[[package]]
name = "idna"
version = "3.15"
version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
files = [
{file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
{file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
]
[package.extras]
@ -421,57 +421,58 @@ tomli = {version = "*", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1f2735dc4f2bd4ec452ae50502c8a22128bba0aced35358a2bbc58384b820c6f"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c642e2d25704ca746fd37f56feacf25c5aecc4cd40bef23d18eec81f87d9dc00"},
{file = "pyside6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:267b344c73580ac938ca63c611881fb42a3922ebfe043e271005f4f06c372c4e"},
{file = "pyside6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:9092cb002ca43c64006afb2e0d0f6f51aef17aa737c33a45e502326a081ddcbc"},
{file = "pyside6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:b15f39acc2b8f46251a630acad0d97f9a0a0461f2baffcd66d7adfada8eb641e"},
{file = "pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2"},
{file = "pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84"},
{file = "pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6"},
{file = "pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805"},
]
[package.dependencies]
PySide6_Addons = "6.11.0"
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
PySide6_Addons = "6.11.1"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""}
[[package]]
name = "pyside6-addons"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Addons)"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6_addons-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d5eaa4643302e3a0fa94c5766234bee4073d7d5ab9c2b7fd222692a176faf182"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ac6fe3d4ef4497dde3efc5e896b0acd53ff6c93be4bf485f045690f919419f35"},
{file = "pyside6_addons-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:8ffb40222456078930816ebcac2f2511716d2acbc11716dd5acc5c365179a753"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:413e6121c24f5ffdce376298059eddecff74aa6d638e94e0f6015b33d29b889e"},
{file = "pyside6_addons-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:aaaee83385977a0fe134b2f4fbfb92b45a880d5b656e4d90a708eef10b1b6de8"},
{file = "pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f"},
{file = "pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923"},
{file = "pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37"},
]
[package.dependencies]
PySide6_Essentials = "6.11.0"
shiboken6 = "6.11.0"
PySide6_Essentials = "6.11.1"
shiboken6 = "6.11.1"
[[package]]
name = "pyside6-essentials"
version = "6.11.0"
version = "6.11.1"
description = "Python bindings for the Qt cross-platform application and UI framework (Essentials)"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "pyside6_essentials-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:85d6ca87ef35fa6565d385ede72ae48420dd3f63113929d10fc800f6b0360e01"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc20e7afd5fc6fe51297db91cef997ce60844be578f7a49fc61b7ab9657a8849"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4854cb0a1b061e7a576d8fb7bb7cf9f49540d558b1acb7df0742a7afefe61e4e"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:3b3362882ad9389357a80504e600180006a957731fec05786fced7b038461fdf"},
{file = "pyside6_essentials-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:81ca603dbf21bc39f89bb42db215c25ebe0c879a1a4c387625c321d2730ec187"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40"},
{file = "pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f"},
]
[package.dependencies]
shiboken6 = "6.11.0"
shiboken6 = "6.11.1"
[[package]]
name = "pytest"
@ -554,13 +555,13 @@ doc = ["sphinx", "sphinx_rtd_theme"]
[[package]]
name = "requests"
version = "2.34.0"
version = "2.34.2"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.10"
files = [
{file = "requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60"},
{file = "requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a"},
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
]
[package.dependencies]
@ -575,16 +576,16 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "shiboken6"
version = "6.11.0"
version = "6.11.1"
description = "Python/C++ bindings helper module"
optional = false
python-versions = "<3.15,>=3.10"
files = [
{file = "shiboken6-6.11.0-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:d88e8a1eb705f2b9ad21db08a61ae1dc0c773e5cd86a069de0754c4cf1f9b43b"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad54e64f8192ddbdff0c54ac82b89edcd62ed623f502ea21c960541d19514053"},
{file = "shiboken6-6.11.0-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a10dc7718104ea2dc15d5b0b96909b77162ce1c76fcc6968e6df692b947a00e9"},
{file = "shiboken6-6.11.0-cp310-abi3-win_amd64.whl", hash = "sha256:483ff78a73c7b3189ca924abc694318084f078bcfeaffa68e32024ff2d025ee1"},
{file = "shiboken6-6.11.0-cp310-abi3-win_arm64.whl", hash = "sha256:3bd76cf56105ab2d62ecaff630366f11264f69b88d488f10f048da9a065781f4"},
{file = "shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe"},
{file = "shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f"},
{file = "shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7"},
{file = "shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d"},
]
[[package]]

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "bouquin"
version = "0.8.4"
version = "0.9.0"
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

@ -4,7 +4,7 @@
# provides the Python distribution/module as "sqlcipher4". To keep Fedora's
# auto-generated python3dist() Requires correct, we rewrite the dependency key in
# pyproject.toml at build time.
%global upstream_version 0.8.4
%global upstream_version 0.9.0
Name: bouquin
Version: %{upstream_version}
@ -82,6 +82,10 @@ install -Dpm 0644 bouquin/icons/bouquin.svg %{buildroot}%{_datadir}/icons/hicolo
%{_datadir}/icons/hicolor/scalable/apps/bouquin.svg
%changelog
* Sun Jun 07 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Add 'Projects' interface for unified time/invoice/docs view.
- Add ability to set a 'bucket' of (prepaid) hours for a project and warn when time logged approaches it.
- Add ability to invoice for the increase in prepaid project bucket hours (without having had to 'log' them).
* Wed May 13 2026 Miguel Jacq <mig@mig5.net> - %{version}-%{release}
- Dependency updates
- SQLCipher 4.16.0

View file

@ -1900,9 +1900,60 @@ def test_main_window_without_time_log(qtbot, app, tmp_db_cfg):
qtbot.addWidget(window)
window.show()
# Verify time_log widget is hidden
# Verify time_log widget is hidden, including dependent Projects entry points.
assert window.time_log.isHidden()
assert not window.toolBar.actTimer.isVisible()
assert not window.toolBar.actProjects.isVisible()
assert not window.actProjects.isVisible()
assert not window.actProjects.isEnabled()
def test_main_window_projects_action_visible_with_time_log(qtbot, app, tmp_db_cfg):
"""Projects is available from the menu/shortcut only when time logging is enabled."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", True)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
assert window.toolBar.actProjects.isVisible()
assert window.actProjects.isVisible()
assert window.actProjects.isEnabled()
def test_main_window_open_projects_noops_when_time_log_disabled(qtbot, app, tmp_db_cfg):
"""The handler is also guarded, so a stale shortcut cannot open Projects."""
s = get_settings()
s.setValue("db/default_db", str(tmp_db_cfg.path))
s.setValue("db/key", tmp_db_cfg.key)
s.setValue("ui/idle_minutes", 0)
s.setValue("ui/theme", "light")
s.setValue("ui/move_todos", True)
s.setValue("ui/tags", True)
s.setValue("ui/time_log", False)
s.setValue("ui/reminders", True)
s.setValue("ui/locale", "en")
s.setValue("ui/font_size", 11)
themes = ThemeManager(app, ThemeConfig(theme=Theme.LIGHT))
window = MainWindow(themes=themes)
qtbot.addWidget(window)
window.show()
with patch("bouquin.main_window.ProjectsDialog") as projects_dialog:
window._open_projects()
projects_dialog.assert_not_called()
def test_main_window_without_documents(qtbot, app, tmp_db_cfg):

768
tests/test_projects.py Normal file
View file

@ -0,0 +1,768 @@
from unittest.mock import patch
from PySide6.QtCore import QDate
from PySide6.QtWidgets import QDialog, QMessageBox
from bouquin.projects import (
ProjectsDialog,
format_bucket_status,
hours_from_minutes,
minutes_from_hours,
)
from bouquin.time_log import TimeLogDialog, TimeReportDialog
def _add_project(fresh_db, project_name: str) -> int:
project_id = fresh_db.add_project(project_name)
fresh_db.upsert_project_billing(
project_id,
hourly_rate_cents=15000,
currency="AUD",
tax_label="GST",
tax_rate_percent=10.0,
client_name=f"{project_name} Contact",
client_company=project_name,
client_address="1 Example Street",
client_email="client@example.test",
)
return project_id
def _add_minutes(fresh_db, project_id: int, minutes: int, note: str = "Work") -> int:
activity_id = fresh_db.add_activity("Support")
return fresh_db.add_time_log("2026-01-01", project_id, activity_id, minutes, note)
# ============================================================================
# Unit helpers
# ============================================================================
def test_project_hour_minute_helpers_round_trip():
assert hours_from_minutes(None) == 0.0
assert hours_from_minutes(90) == 1.5
assert minutes_from_hours(None) == 0
assert minutes_from_hours(1.25) == 75
assert minutes_from_hours(1.333) == 80
def test_format_bucket_status_handles_none_unconfigured_and_reached(fresh_db):
assert "Select a project" in format_bucket_status(None)
project_id = _add_project(fresh_db, "No Bucket Project")
_add_minutes(fresh_db, project_id, 30)
unconfigured = fresh_db.project_bucket_status(project_id)
text = format_bucket_status(unconfigured)
assert "No Bucket Project" in text
assert "No bucket ceiling" in text
assert "0.50h used" in text
fresh_db.upsert_project_bucket(project_id, 30, 60, 50.0)
reached = fresh_db.project_bucket_status(project_id)
text = format_bucket_status(reached)
assert "1.00h / 1.00h" in text
assert "Bucket ceiling reached" in text
# ============================================================================
# DB behaviour
# ============================================================================
def test_project_bucket_status_includes_baseline_and_logged_time(fresh_db):
project_id = _add_project(fresh_db, "Support Retainer")
_add_minutes(fresh_db, project_id, 90)
fresh_db.upsert_project_bucket(
project_id,
baseline_minutes=60,
bucket_ceiling_minutes=180,
warn_at_percent=80.0,
)
status = fresh_db.project_bucket_status(project_id)
assert status["project_id"] == project_id
assert status["project_name"] == "Support Retainer"
assert status["baseline_minutes"] == 60
assert status["logged_minutes"] == 90
assert status["used_minutes"] == 150
assert status["remaining_minutes"] == 30
assert status["percent_used"] == 150 / 180 * 100.0
assert status["state"] == "warning"
fresh_db.add_to_project_bucket_ceiling(project_id, 60)
status = fresh_db.project_bucket_status(project_id)
assert status["bucket_ceiling_minutes"] == 240
assert status["state"] == "ok"
def test_project_bucket_status_state_boundaries(fresh_db):
project_id = _add_project(fresh_db, "Boundary Project")
_add_minutes(fresh_db, project_id, 30)
status = fresh_db.project_bucket_status(project_id)
assert status["state"] == "unconfigured"
assert status["bucket_ceiling_minutes"] == 0
assert status["remaining_minutes"] is None
assert status["percent_used"] is None
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
fresh_db.upsert_project_bucket(project_id, 66, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 96
assert status["percent_used"] == 80.0
assert status["state"] == "warning"
fresh_db.upsert_project_bucket(project_id, 90, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 120
assert status["remaining_minutes"] == 0
assert status["state"] == "reached"
fresh_db.upsert_project_bucket(project_id, 91, 120, 80.0)
status = fresh_db.project_bucket_status(project_id)
assert status["used_minutes"] == 121
assert status["remaining_minutes"] == -1
assert status["state"] == "exceeded"
def test_project_bucket_input_sanitisation_and_invalid_projects(fresh_db):
project_id = _add_project(fresh_db, "Sanitised Project")
fresh_db.upsert_project_bucket(
project_id,
baseline_minutes=-60,
bucket_ceiling_minutes=-120,
warn_at_percent=150.0,
)
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["project_id"] == project_id
assert bucket["baseline_minutes"] == 0
assert bucket["bucket_ceiling_minutes"] == 0
assert bucket["warn_at_percent"] == 100.0
fresh_db.upsert_project_bucket(project_id, 10, 60, -5.0)
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["warn_at_percent"] == 0.0
fresh_db.add_to_project_bucket_ceiling(project_id, -60)
assert fresh_db.get_project_bucket(project_id)["bucket_ceiling_minutes"] == 60
for bad_project_id in (0, -1):
try:
fresh_db.upsert_project_bucket(bad_project_id, 0, 60, 80.0)
except ValueError as exc:
assert "invalid project id" in str(exc)
else:
raise AssertionError("invalid project id should raise")
assert fresh_db.get_project_bucket(0) is None
assert fresh_db.logged_minutes_for_project(0) == 0
assert fresh_db.project_bucket_status(0) is None
assert fresh_db.time_logs_for_project(0) == []
assert fresh_db.invoices_for_project_with_documents(0) == []
def test_project_summaries_include_all_projects_and_are_sorted(fresh_db):
alpha = _add_project(fresh_db, "alpha project")
zulu = _add_project(fresh_db, "Zulu Project")
fresh_db.upsert_project_bucket(zulu, 15, 120, 80.0)
rows = fresh_db.list_project_summaries()
names = [r["project_name"] for r in rows]
assert names == ["alpha project", "Zulu Project"]
alpha_row = next(r for r in rows if r["project_id"] == alpha)
assert alpha_row["document_count"] == 0
assert alpha_row["invoice_count"] == 0
assert alpha_row["time_log_count"] == 0
assert alpha_row["logged_minutes"] == 0
assert alpha_row["baseline_minutes"] == 0
zulu_row = next(r for r in rows if r["project_id"] == zulu)
assert zulu_row["baseline_minutes"] == 15
assert zulu_row["bucket_ceiling_minutes"] == 120
def test_project_documents_time_logs_and_invoices_are_isolated(fresh_db, tmp_path):
project_id = _add_project(fresh_db, "Project With Docs")
other_project = _add_project(fresh_db, "Other Project")
_add_minutes(fresh_db, project_id, 120, "Build work")
_add_minutes(fresh_db, other_project, 300, "Other work")
fresh_db.upsert_project_bucket(project_id, 0, 240, 80.0)
doc_path = tmp_path / "invoice.pdf"
doc_path.write_bytes(b"not really a pdf")
doc_id = fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="Invoice document",
uploaded_at="2026-02-02",
)
other_doc = tmp_path / "other.pdf"
other_doc.write_bytes(b"other")
fresh_db.add_document_from_path(other_project, str(other_doc))
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="INV-1",
issue_date="2026-02-03",
due_date="2026-02-17",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid bucket", 2.0, 10000)],
time_log_ids=[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
fresh_db.create_invoice(
project_id=other_project,
invoice_number="INV-OTHER",
issue_date="2026-02-03",
due_date="2026-02-17",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Other", 1.0, 10000)],
time_log_ids=[],
)
row = next(
r for r in fresh_db.list_project_summaries() if r["project_id"] == project_id
)
assert row["logged_minutes"] == 120
assert row["time_log_count"] == 1
assert row["document_count"] == 1
assert row["invoice_count"] == 1
logs = fresh_db.time_logs_for_project(project_id)
assert len(logs) == 1
assert logs[0]["note"] == "Build work"
docs = fresh_db.documents_for_project(project_id)
assert len(docs) == 1
assert docs[0][3] == "invoice.pdf"
invoices = fresh_db.invoices_for_project_with_documents(project_id)
assert len(invoices) == 1
assert invoices[0]["invoice_number"] == "INV-1"
assert invoices[0]["document_id"] == doc_id
assert invoices[0]["document_file_name"] == "invoice.pdf"
def test_prepaid_invoice_can_exist_without_logged_time_links(fresh_db):
project_id = _add_project(fresh_db, "Prepaid Project")
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="PREPAID-1",
issue_date="2026-02-10",
due_date="2026-02-24",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid support bucket", 40.0, 15000)],
time_log_ids=[],
)
invoice = fresh_db.invoices_for_project_with_documents(project_id)[0]
assert invoice["id"] == invoice_id
assert invoice["total_cents"] == 600000
linked = fresh_db.conn.execute(
"SELECT COUNT(*) AS c FROM invoice_time_log WHERE invoice_id = ?",
(invoice_id,),
).fetchone()
assert linked["c"] == 0
# ============================================================================
# UI behaviour
# ============================================================================
def test_projects_dialog_loads_summary_time_logs_documents_and_invoices(
qtbot, fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "UI Project")
_add_minutes(fresh_db, project_id, 60, "Initial support")
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
doc_path = tmp_path / "ui-invoice.pdf"
doc_path.write_bytes(b"ui")
doc_id = fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="UI invoice",
uploaded_at="2026-03-01",
)
invoice_id = fresh_db.create_invoice(
project_id,
"UI-1",
"2026-03-02",
"2026-03-16",
"AUD",
None,
None,
"summary",
[("UI work", 1.0, 10000)],
[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.currentData() == project_id
assert dialog.summary_table.rowCount() == 1
assert dialog.time_logs_table.rowCount() == 1
assert dialog.documents_table.rowCount() == 1
assert dialog.invoices_table.rowCount() == 1
assert dialog.summary_table.item(0, dialog.SUM_USED).text() == "1.50"
assert (
dialog.summary_table.item(0, dialog.SUM_STATE).text()
== "Approaching bucket ceiling"
)
assert dialog.time_logs_table.item(0, dialog.LOG_NOTE).text() == "Initial support"
assert dialog.documents_table.item(0, dialog.DOC_FILE).text() == "ui-invoice.pdf"
assert dialog.invoices_table.item(0, dialog.INV_NUMBER).text() == "UI-1"
assert "UI Project" in dialog.status_label.text()
assert "1.50h / 2.00h" in dialog.status_label.text()
assert (
dialog.status_label.minimumHeight()
>= dialog.status_label.fontMetrics().lineSpacing() * 3 + 18
)
def test_projects_dialog_saves_and_replenishes_bucket(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Editable Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.project_combo.currentData() == project_id
dialog.baseline_spin.setValue(1.5)
dialog.ceiling_spin.setValue(10.0)
dialog.warn_spin.setValue(75.0)
dialog._save_bucket()
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["baseline_minutes"] == 90
assert bucket["bucket_ceiling_minutes"] == 600
assert bucket["warn_at_percent"] == 75.0
dialog.topup_spin.setValue(40.0)
dialog._add_to_ceiling()
bucket = fresh_db.get_project_bucket(project_id)
assert bucket["bucket_ceiling_minutes"] == 3000
assert "48.50h remaining" in dialog.status_label.text()
class _FakeRadioButton:
def __init__(self):
self.checked = False
def setChecked(self, checked):
self.checked = checked
class _FakeLineEdit:
def __init__(self):
self.value = ""
def setText(self, text):
self.value = text
class _FakeSpinBox:
def __init__(self):
self.value_set = None
def setValue(self, value):
self.value_set = value
class _FakeInvoiceDialog:
instances = []
def __init__(
self, db, project_id, start_date_iso, end_date_iso, time_rows=None, parent=None
):
self.db = db
self.project_id = project_id
self.start_date_iso = start_date_iso
self.end_date_iso = end_date_iso
self.time_rows = time_rows
self.parent = parent
self.rb_summary = _FakeRadioButton()
self.summary_desc_edit = _FakeLineEdit()
self.summary_hours_spin = _FakeSpinBox()
self.recalculated = False
self.executed = False
_FakeInvoiceDialog.instances.append(self)
def _recalc_totals(self):
self.recalculated = True
def exec(self):
self.executed = True
return QDialog.Accepted
def test_projects_dialog_can_open_prepaid_invoice_for_unspent_hours(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Prepaid UI Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(40.0)
_FakeInvoiceDialog.instances.clear()
with patch("bouquin.projects.InvoiceDialog", _FakeInvoiceDialog):
dialog._invoice_prepaid_hours()
assert len(_FakeInvoiceDialog.instances) == 1
invoice_dialog = _FakeInvoiceDialog.instances[0]
assert invoice_dialog.db is fresh_db
assert invoice_dialog.project_id == project_id
assert invoice_dialog.time_rows == []
assert invoice_dialog.parent is dialog
assert invoice_dialog.rb_summary.checked is True
assert invoice_dialog.summary_hours_spin.value_set == 40.0
assert invoice_dialog.summary_desc_edit.value == (
"Prepaid support bucket (40.00 hours)"
)
assert invoice_dialog.recalculated is True
assert invoice_dialog.executed is True
def test_projects_dialog_warns_when_prepaid_invoice_hours_are_zero(qtbot, fresh_db):
_add_project(fresh_db, "Zero Project")
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(0.0)
with patch.object(QMessageBox, "warning") as warning:
dialog._invoice_prepaid_hours()
assert warning.called
assert "greater than zero" in warning.call_args.args[2]
def test_projects_dialog_opens_selected_document_and_invoice_document(
qtbot, fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "Open Project")
doc_path = tmp_path / "open.pdf"
doc_path.write_bytes(b"open")
doc_id = fresh_db.add_document_from_path(project_id, str(doc_path))
invoice_id = fresh_db.create_invoice(
project_id,
"OPEN-1",
"2026-05-01",
None,
"AUD",
None,
None,
"summary",
[("Open work", 1.0, 10000)],
[],
)
fresh_db.set_invoice_document(invoice_id, doc_id)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.documents_table.selectRow(0)
with patch("bouquin.projects.open_document_from_db") as open_doc:
dialog._open_selected_document()
open_doc.assert_called_once_with(
fresh_db, doc_id, "open.pdf", parent_widget=dialog
)
dialog.invoices_table.selectRow(0)
with patch("bouquin.projects.open_document_from_db") as open_doc:
dialog._open_invoice_document()
open_doc.assert_called_once_with(
fresh_db, doc_id, "open.pdf", parent_widget=dialog
)
def test_projects_dialog_reports_missing_invoice_document(qtbot, fresh_db):
project_id = _add_project(fresh_db, "No Doc Project")
fresh_db.create_invoice(
project_id,
"NO-DOC-1",
"2026-05-01",
None,
"AUD",
None,
None,
"summary",
[("Work", 1.0, 10000)],
[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.invoices_table.selectRow(0)
with patch.object(QMessageBox, "information") as info:
dialog._open_invoice_document()
assert info.called
def test_time_log_dialog_updates_bucket_indicator_and_alerts_on_reach(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Logging Project")
fresh_db.upsert_project_bucket(project_id, 0, 60, 80.0)
dialog = TimeLogDialog(fresh_db, "2026-06-01")
qtbot.addWidget(dialog)
idx = dialog.project_combo.findData(project_id)
dialog.project_combo.setCurrentIndex(idx)
assert "0.00h / 1.00h" in dialog.bucket_label.text()
assert "Status: OK" in dialog.bucket_label.text()
dialog.activity_edit.setText("Support")
dialog.hours_spin.setValue(1.0)
with patch.object(QMessageBox, "warning") as warning:
dialog._on_add_or_update()
assert warning.called
assert "Bucket ceiling reached" in warning.call_args.args[2]
status = fresh_db.project_bucket_status(project_id)
assert status["state"] == "reached"
assert "Bucket ceiling reached" in dialog.bucket_label.text()
def test_time_log_dialog_does_not_alert_before_reaching_bucket(qtbot, fresh_db):
project_id = _add_project(fresh_db, "Safe Logging")
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
dialog = TimeLogDialog(fresh_db, "2026-06-02")
qtbot.addWidget(dialog)
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
dialog.activity_edit.setText("Support")
dialog.hours_spin.setValue(0.5)
with patch.object(QMessageBox, "warning") as warning:
dialog._on_add_or_update()
warning.assert_not_called()
assert fresh_db.project_bucket_status(project_id)["state"] == "ok"
def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all(
qtbot, fresh_db
):
project_id = _add_project(fresh_db, "Report Project")
_add_minutes(fresh_db, project_id, 90)
fresh_db.upsert_project_bucket(project_id, 0, 120, 75.0)
dialog = TimeReportDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.from_date.setDate(QDate.fromString("2026-01-01", "yyyy-MM-dd"))
dialog.to_date.setDate(QDate.fromString("2026-01-31", "yyyy-MM-dd"))
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(project_id))
dialog._run_report()
assert "Report Project" in dialog.bucket_label.text()
assert "Approaching bucket ceiling" in dialog.bucket_label.text()
dialog.project_combo.setCurrentIndex(dialog.project_combo.findData(None))
dialog._run_report()
assert dialog.bucket_label.text() == ""
def test_project_bucket_ledger_records_settings_topups_and_time_use(fresh_db):
project_id = _add_project(fresh_db, "Ledger Project")
fresh_db.upsert_project_bucket(project_id, 30, 120, 80.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="Extra prepaid hours",
)
_add_minutes(fresh_db, project_id, 45, "Ledger work")
rows = fresh_db.project_bucket_ledger_for_project(project_id)
types = [r["entry_type"] for r in rows]
assert "settings_adjustment" in types
assert "manual_topup" in types
assert "time_log" in types
settings = next(r for r in rows if r["entry_type"] == "settings_adjustment")
assert settings["baseline_delta_minutes"] == 30
assert settings["ceiling_delta_minutes"] == 120
topup = next(r for r in rows if r["entry_type"] == "manual_topup")
assert topup["ceiling_delta_minutes"] == 60
assert topup["description"] == "Extra prepaid hours"
time_log = next(r for r in rows if r["entry_type"] == "time_log")
assert time_log["used_delta_minutes"] == 45
assert time_log["description"] == "Ledger work"
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 30
assert status["logged_minutes"] == 45
assert status["bucket_ceiling_minutes"] == 180
assert status["remaining_minutes"] == 105
def test_project_bucket_ledger_records_negative_corrections(fresh_db):
project_id = _add_project(fresh_db, "Correction Project")
fresh_db.upsert_project_bucket(project_id, 120, 240, 80.0)
fresh_db.upsert_project_bucket(project_id, 60, 180, 80.0)
rows = [
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "settings_adjustment"
]
assert len(rows) == 2
assert any(r["baseline_delta_minutes"] == -60 for r in rows)
assert any(r["ceiling_delta_minutes"] == -60 for r in rows)
status = fresh_db.project_bucket_status(project_id)
assert status["baseline_minutes"] == 60
assert status["bucket_ceiling_minutes"] == 180
def test_project_activity_log_includes_time_documents_invoices_and_bucket_events(
fresh_db, tmp_path
):
project_id = _add_project(fresh_db, "Activity Log Project")
_add_minutes(fresh_db, project_id, 30, "Log work")
fresh_db.upsert_project_bucket(project_id, 0, 120, 80.0)
doc_path = tmp_path / "activity-doc.pdf"
doc_path.write_bytes(b"activity")
fresh_db.add_document_from_path(
project_id,
str(doc_path),
description="Activity document",
uploaded_at="2026-04-01",
)
fresh_db.create_invoice(
project_id=project_id,
invoice_number="ACT-001",
issue_date="2026-04-02",
due_date="2026-04-16",
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Activity prepaid", 2.0, 10000)],
time_log_ids=[],
)
rows = fresh_db.project_activity_log_for_project(project_id)
event_types = [r["event_type"] for r in rows]
assert "time_log" in event_types
assert "document" in event_types
assert "invoice" in event_types
assert "bucket" in event_types
assert any("Log work" in r["details"] for r in rows)
assert any("activity-doc.pdf" in r["details"] for r in rows)
assert any("ACT-001" in r["details"] for r in rows)
assert any("Bucket settings updated" in r["details"] for r in rows)
def test_projects_dialog_shows_bucket_ledger_and_project_log(qtbot, fresh_db, tmp_path):
project_id = _add_project(fresh_db, "Ledger UI Project")
_add_minutes(fresh_db, project_id, 60, "UI ledger work")
fresh_db.upsert_project_bucket(project_id, 30, 120, 75.0)
fresh_db.add_to_project_bucket_ceiling(
project_id,
60,
description="UI top-up",
)
doc_path = tmp_path / "ledger-ui.pdf"
doc_path.write_bytes(b"ledger")
fresh_db.add_document_from_path(project_id, str(doc_path), uploaded_at="2026-05-01")
fresh_db.create_invoice(
project_id=project_id,
invoice_number="LEDGER-UI-001",
issue_date="2026-05-02",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Ledger UI prepaid", 1.0, 10000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
assert dialog.bucket_ledger_table.rowCount() == 3
ledger_types = {
dialog.bucket_ledger_table.item(row, dialog.LEDGER_TYPE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
}
assert "Bucket settings" in ledger_types
assert "Manual top-up" in ledger_types
assert "Time logged" in ledger_types
assert any(
dialog.bucket_ledger_table.item(row, dialog.LEDGER_NOTE).text()
for row in range(dialog.bucket_ledger_table.rowCount())
)
changelog_types = {
dialog.changelog_table.item(row, dialog.CHANGE_TYPE).text()
for row in range(dialog.changelog_table.rowCount())
}
assert {"Time log", "Document", "Invoice", "Bucket"}.issubset(changelog_types)
assert any(
"LEDGER-UI-001"
in dialog.changelog_table.item(row, dialog.CHANGE_DETAILS).text()
for row in range(dialog.changelog_table.rowCount())
)
def test_prepaid_invoice_dialog_adds_bucket_ledger_entry_when_invoice_is_created(
qtbot, fresh_db
):
project_id = _add_project(fresh_db, "Prepaid Ledger Project")
invoice_id = fresh_db.create_invoice(
project_id=project_id,
invoice_number="PREPAID-LEDGER-001",
issue_date="2026-06-01",
due_date=None,
currency="AUD",
tax_label=None,
tax_rate_percent=None,
detail_mode="summary",
line_items=[("Prepaid", 40.0, 15000)],
time_log_ids=[],
)
dialog = ProjectsDialog(fresh_db)
qtbot.addWidget(dialog)
dialog.topup_spin.setValue(40.0)
class _InvoiceDialogWithId(_FakeInvoiceDialog):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.last_invoice_id = invoice_id
with patch("bouquin.projects.InvoiceDialog", _InvoiceDialogWithId):
dialog._invoice_prepaid_hours()
row = next(
r
for r in fresh_db.project_bucket_ledger_for_project(project_id)
if r["entry_type"] == "prepaid_invoice"
)
assert row["ceiling_delta_minutes"] == 2400
assert row["invoice_id"] == invoice_id
assert "40.00 hours" in row["description"]