diff --git a/bouquin/db.py b/bouquin/db.py index c6b08c2..4a4e451 100644 --- a/bouquin/db.py +++ b/bouquin/db.py @@ -323,6 +323,24 @@ class DBManager: ) ); + 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), @@ -352,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) @@ -382,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 @@ -1243,6 +1276,104 @@ class DBManager: 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, @@ -1250,16 +1381,23 @@ class DBManager: bucket_ceiling_minutes: int, warn_at_percent: float = 80.0, ) -> None: - """Save cumulative prepaid-hour bucket settings for a project. + """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. + 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 = min(100.0, max(0.0, float(warn_at_percent or 0.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( """ @@ -1271,41 +1409,67 @@ class DBManager: ) VALUES (?, ?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET - baseline_minutes = excluded.baseline_minutes, - bucket_ceiling_minutes = excluded.bucket_ceiling_minutes, warn_at_percent = excluded.warn_at_percent, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'); """, ( project_id, - baseline_minutes, - bucket_ceiling_minutes, + 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) -> None: + 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 - with self.conn: - self.conn.execute( - """ - INSERT INTO project_buckets ( - project_id, - baseline_minutes, - bucket_ceiling_minutes, - warn_at_percent - ) - VALUES (?, 0, ?, 80.0) - ON CONFLICT(project_id) DO UPDATE SET - bucket_ceiling_minutes = bucket_ceiling_minutes + excluded.bucket_ceiling_minutes, - updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'); - """, - (project_id, add_minutes), - ) + 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.""" @@ -1326,6 +1490,24 @@ class DBManager: """, (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: @@ -1471,6 +1653,122 @@ class DBManager: (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( @@ -2479,6 +2777,7 @@ class DBManager: 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 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 8788fd0..b3cd3f9 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -1,473 +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", - "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" + "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/projects.py b/bouquin/projects.py index 9a78bf4..efde645 100644 --- a/bouquin/projects.py +++ b/bouquin/projects.py @@ -108,6 +108,18 @@ class ProjectsDialog(QDialog): 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 @@ -264,6 +276,72 @@ class ProjectsDialog(QDialog): 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() @@ -479,6 +557,8 @@ class ProjectsDialog(QDialog): 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 @@ -498,6 +578,8 @@ class ProjectsDialog(QDialog): 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) @@ -523,6 +605,81 @@ class ProjectsDialog(QDialog): 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)) @@ -592,7 +749,13 @@ class ProjectsDialog(QDialog): 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) + 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: @@ -626,6 +789,16 @@ class ProjectsDialog(QDialog): 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: diff --git a/tests/test_projects.py b/tests/test_projects.py index bf75165..9c04dcc 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -578,3 +578,191 @@ def test_time_report_dialog_shows_bucket_for_selected_project_and_clears_for_all 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"]