Make the bucket a ledger event - add project log view for such events and others (time log, invoicing etc)

This commit is contained in:
Miguel Jacq 2026-06-07 17:37:52 +10:00
parent 58333bf93c
commit 54af723b53
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
5 changed files with 1175 additions and 496 deletions

View file

@ -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

View file

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

View file

@ -1,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"
}

View file

@ -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:

View file

@ -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"]