diff --git a/CHANGELOG.md b/CHANGELOG.md index 67d0b04..19290cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bouquin/db.py b/bouquin/db.py index 157aae8..35888a9 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -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, diff --git a/bouquin/invoices.py b/bouquin/invoices.py index fde6a92..45cfe59 100644 --- a/bouquin/invoices.py +++ b/bouquin/invoices.py @@ -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: diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index f1f86dd..b3cd3f9 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -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" } diff --git a/bouquin/main_window.py b/bouquin/main_window.py index 2759272..7328276 100644 --- a/bouquin/main_window.py +++ b/bouquin/main_window.py @@ -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) diff --git a/bouquin/projects.py b/bouquin/projects.py new file mode 100644 index 0000000..efde645 --- /dev/null +++ b/bouquin/projects.py @@ -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) diff --git a/bouquin/time_log.py b/bouquin/time_log.py index 1e4b303..eaee522 100644 --- a/bouquin/time_log.py +++ b/bouquin/time_log.py @@ -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( diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index 8e8c4bf..2004f43 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -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 diff --git a/debian/changelog b/debian/changelog index 8280172..66c85ee 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 Sun, 07 Jun 2026 17:18:00 +1000 + bouquin (0.8.4) unstable; urgency=medium * Dependency updates diff --git a/poetry.lock b/poetry.lock index 6470743..f3bb0e2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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]] diff --git a/pyproject.toml b/pyproject.toml index 90cbf03..e07d5f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] readme = "README.md" diff --git a/rpm/bouquin.spec b/rpm/bouquin.spec index 11598f4..46c8bc6 100644 --- a/rpm/bouquin.spec +++ b/rpm/bouquin.spec @@ -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 - %{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 - %{version}-%{release} - Dependency updates - SQLCipher 4.16.0 diff --git a/tests/test_main_window.py b/tests/test_main_window.py index 6c09e71..94bd91b 100644 --- a/tests/test_main_window.py +++ b/tests/test_main_window.py @@ -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): diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..9c04dcc --- /dev/null +++ b/tests/test_projects.py @@ -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"]