Merge pull request #588 from DRYTRIX/rc/v5.3.1

Rc/v5.3.1
This commit is contained in:
Dries Peeters
2026-04-15 08:20:53 +02:00
committed by GitHub
20 changed files with 480 additions and 36 deletions
@@ -0,0 +1,63 @@
name: Translation improvement
description: Suggest a correction or better wording for interface text in a specific language (no Git required).
title: "[i18n] "
labels: []
body:
- type: markdown
attributes:
value: |
Thank you for helping improve translations. Before submitting, skim **Rules for translators** in `docs/CONTRIBUTING_TRANSLATIONS.md` in this repository (placeholders, context, what not to change).
- type: dropdown
id: language
attributes:
label: Language
description: Locale code for the translation you are improving.
options:
- nl (Nederlands)
- de (Deutsch)
- fr (Français)
- it (Italiano)
- fi (Suomi)
- es (Español)
- no (Norsk)
- ar (العربية)
- he (עברית)
- en (English)
validations:
required: true
- type: input
id: location
attributes:
label: Where did you see this?
description: Page name, menu path, or URL path (e.g. Dashboard, Settings, /login).
validations:
required: true
- type: textarea
id: current_text
attributes:
label: Current text (as shown in the app)
description: Copy the exact wording from the UI in the language you selected.
validations:
required: true
- type: textarea
id: suggested_text
attributes:
label: Suggested text
description: Your improved translation. Keep any %(name)s-style placeholders identical to the current text if present.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Notes (optional)
description: Why this reads better, grammar fix, formal vs informal tone, screenshot description, etc.
- type: markdown
attributes:
value: |
**Optional:** Attach a screenshot in a follow-up comment if the form does not allow images.
+34
View File
@@ -0,0 +1,34 @@
# Manual Crowdin sync: uploads English source .po, downloads translations, opens a PR.
# Prerequisites: repo secrets CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN (see docs/CONTRIBUTING_TRANSLATIONS.md).
name: Crowdin sync
on:
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Crowdin push and pull
uses: crowdin/github-action@v2
with:
upload_sources: true
# Set to true once to seed Crowdin with existing translations/…/messages.po, then set back to false.
upload_translations: false
download_translations: true
localization_branch_name: i18n/crowdin
create_pull_request: true
pull_request_title: "chore(i18n): Crowdin translations"
pull_request_body: "Automated sync from Crowdin. Review placeholders and `no` vs `nb` paths before merge."
commit_message: "chore(i18n): sync Crowdin translations"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+6 -2
View File
@@ -8,16 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Quote create returned HTTP 500 after save (#583)** — The quote was persisted, but the redirect to the quote detail page crashed because the view expected `requires_approval` and `can_be_sent`, and templates could set `approval_level`, while the `Quote` model had no matching fields. Added `requires_approval` and `approval_level` columns, a `can_be_sent` property aligned with send rules, wired the create-form checkbox, fixed the approval banner branch to use `approval_status == 'not_required'`, migration `145_add_quotes_requires_approval`, and regression coverage in `tests/test_routes/test_quotes_web.py`.
- **Quote create returned HTTP 500 after save (#583)** — The quote was saved, but the redirect to the quote detail page crashed when **Valid until** was set: the template compared `valid_until` to `now()`, and `now` was never defined in the Jinja context. The expired badge now uses `Quote.is_expired` (same rule, app timezone). Regression coverage in `tests/test_routes/test_quotes_web.py` posts `valid_until` so the view path is exercised.
- **Desktop app navigation guard** — `will-navigate` no longer mis-classifies `file:` loads (opaque `"null"` origin) as external navigation. Allowed in-app protocols include `file:`, `about:`, and `devtools:`; `http:` / `https:` are still blocked from the embedded window.
- **Desktop offline UI (bundle)** — Shared helpers load before dependent modules; timesheet period and time-off request lists expose **Delete** where allowed (with `currentUserProfile.id` for ownership); approve/reject controls read approval state from `state.currentUserProfile`; API client includes `deleteTimesheetPeriod` and `deleteTimeOffRequest`.
### Added
- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** line tables include **Move up** / **Move down** per row so items can be reordered without deleting and re-entering lines; PDFs and detail views follow the saved order.
- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** forms include per-row **Move up** / **Move down** controls on **Quote line items**, **Costs**, and **Extra goods** so rows can be reordered without deleting and re-entering data; PDFs and detail views follow the saved order. New translatable UI strings: **Order**, **Move up**, **Move down** (run `pybabel extract` / `update` per [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md)).
- **Offline queue replay** — Queued requests now store method, headers, and body in a replay-safe form (serializable for localStorage). POST/PUT requests replayed when back online send the same body and method. Legacy queue items (with `options` only) are still replayed via fallback.
- **Inventory API scopes** — New scopes `read:inventory` and `write:inventory` for inventory-only API access. Existing `read:projects` and `write:projects` still grant the same inventory access for backward compatibility.
- **Client portal reports: date range and CSV export** — Reports support optional `days` query param (1365, default 30). Add `?format=csv` to download a CSV of the same report (summary, hours by project, time by date). Export uses the same access control as the reports page.
- **Jira webhook verification** — When a webhook secret is configured in the Jira integration (Connection Settings → Webhook Secret), incoming webhooks are verified using HMAC-SHA256 of the request body. Supported headers: `X-Hub-Signature-256`, `X-Atlassian-Webhook-Signature`, `X-Hub-Signature`. Requests with missing or invalid signature are rejected. If no secret is set, behavior is unchanged (all webhooks accepted).
- **Crowdin integration (maintainers)** — Root [`crowdin.yml`](crowdin.yml) maps `translations/en/LC_MESSAGES/messages.po` to per-locale `messages.po` paths (with `nb``no` for Norwegian). Manual [`.github/workflows/crowdin-sync.yml`](.github/workflows/crowdin-sync.yml) uploads sources and downloads translations when `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` are set. [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) includes a Crowdin setup section; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) and contributor docs cross-link it.
### Changed
- **Documentation (translations)** — Added [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for contributors without Git (issue template, optional spreadsheet or hosted platform, maintainer workflow). Root [CONTRIBUTING.md](CONTRIBUTING.md) links to it; [docs/TRANSLATION_SYSTEM.md](docs/TRANSLATION_SYSTEM.md) defers the enabled locale list to `app/config.py` (`LANGUAGES`) and points translators at the new guide.
- **Factur-X / PDF/A-3 invoice PDFs (export and email)** — Download and email attachments use the same embed-and-normalize path. Embedded CII uses Associated File relationship **Data** and MIME **text/xml**. PDF/A-3 normalization embeds sRGB via `app/resources/icc/` (override with `INVOICE_SRGB_ICC_PATH`). Added `app/utils/invoice_pdf_postprocess.py` and tests; [PEPPOL e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md) updated (veraPDF note, pytest command).
- **Documentation sync** — CODEBASE_AUDIT.md: marked gaps 2.32.7 and 2.9 as fixed; added “Implemented 2026-03-16” summary. CLIENT_FEATURES_IMPLEMENTATION_STATUS: report date range and CSV export noted as implemented. INCOMPLETE_IMPLEMENTATIONS_ANALYSIS: added “Verified 2026-03-16” for webhook verification, issues permissions, search API, offline queue.
- **Activity feed API date params** — `/api/activity` now returns 400 with a clear message when `start_date` or `end_date` are invalid (e.g. not ISO 8601). Invalid dates on the web route `/activity` are logged and the filter is skipped (no 500).
+1
View File
@@ -5,6 +5,7 @@ Thank you for your interest in contributing to TimeTracker. This page gives you
## How to Contribute
- **Report bugs** — Use the [GitHub issue tracker](https://github.com/drytrix/TimeTracker/issues). Include steps to reproduce, expected vs actual behavior, and your environment (OS, deployment method, version).
- **Improve translations (no Git)** — Use the **Translation improvement** issue template, or read [docs/CONTRIBUTING_TRANSLATIONS.md](docs/CONTRIBUTING_TRANSLATIONS.md) for spreadsheet, maintainer workflow, and optional Crowdin setup ([`crowdin.yml`](crowdin.yml), **Actions → Crowdin sync**).
- **Suggest features** — Open a [feature request](https://github.com/drytrix/TimeTracker/issues/new?template=feature_request.md) with a clear description and use case.
- **Submit code** — Fork the repo, create a branch, make your changes, add tests, and open a pull request. Follow the [full contributing guidelines](docs/development/CONTRIBUTING.md) for setup, coding standards, and PR process.
@@ -10,6 +10,54 @@ document.addEventListener('DOMContentLoaded', function() {
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
function reorderButtonsHtml() {
return (
'<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">' +
'<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _("Move up") }}" aria-label="{{ _("Move up") }}"><i class="fas fa-chevron-up text-xs"></i></button>' +
'<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _("Move down") }}" aria-label="{{ _("Move down") }}"><i class="fas fa-chevron-down text-xs"></i></button></div>'
);
}
function refreshReorderStates(parent, rowSelector) {
if (!parent) return;
const rows = parent.querySelectorAll(rowSelector);
rows.forEach(function(r, i) {
const up = r.querySelector('.quote-line-move-up');
const down = r.querySelector('.quote-line-move-down');
if (up) up.disabled = i === 0;
if (down) down.disabled = i === rows.length - 1;
});
}
function moveRowInSection(row, delta) {
const parent = row.parentElement;
if (!parent) return;
var sel = null;
if (row.classList.contains('quote-item-row')) sel = '.quote-item-row';
else if (row.classList.contains('quote-expense-row')) sel = '.quote-expense-row';
else if (row.classList.contains('quote-good-row')) sel = '.quote-good-row';
else return;
const rows = Array.from(parent.querySelectorAll(sel));
const idx = rows.indexOf(row);
if (idx < 0) return;
const newIdx = idx + delta;
if (newIdx < 0 || newIdx >= rows.length) return;
const ref = rows[newIdx];
if (delta < 0) parent.insertBefore(row, ref);
else parent.insertBefore(row, ref.nextSibling);
refreshReorderStates(parent, sel);
calculateTotals();
}
function wireReorderButtons(row) {
row.querySelector('.quote-line-move-up')?.addEventListener('click', function() {
moveRowInSection(row, -1);
});
row.querySelector('.quote-line-move-down')?.addEventListener('click', function() {
moveRowInSection(row, 1);
});
}
function stockOptionsHtml(selectedId) {
let h = '<option value="">{{ _("None") }}</option>';
if (stockItems && Array.isArray(stockItems)) {
@@ -40,7 +88,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!stockCols || !descWrap) return;
if (stockMode) {
stockCols.classList.remove('hidden');
descWrap.classList.remove('md:col-span-6');
descWrap.classList.remove('md:col-span-5');
descWrap.classList.add('md:col-span-2');
} else {
stockCols.classList.add('hidden');
@@ -53,7 +101,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (ss) ss.value = '';
if (ws) ws.value = '';
descWrap.classList.remove('md:col-span-2');
descWrap.classList.add('md:col-span-6');
descWrap.classList.add('md:col-span-5');
}
}
@@ -94,12 +142,15 @@ document.addEventListener('DOMContentLoaded', function() {
}
row.querySelector('.remove-item')?.addEventListener('click', function() {
row.remove();
if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row');
calculateTotals();
});
wireReorderButtons(row);
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
applyItemRowLayout(row);
if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row');
}
function addItemRow() {
@@ -109,14 +160,15 @@ document.addEventListener('DOMContentLoaded', function() {
'<input type="hidden" name="item_id[]" value="">' +
'<input type="hidden" name="item_stock_item_id[]" value="">' +
'<input type="hidden" name="item_warehouse_id[]" value="">' +
reorderButtonsHtml() +
'<div class="md:col-span-2 min-w-0">' +
'<select name="item_line_source[]" class="form-input item-line-source text-sm">' +
'<option value="manual" selected>{{ _("Manual entry") }}</option>' +
'<option value="stock">{{ _("From stock") }}</option></select></div>' +
'<div class="item-stock-cols md:col-span-4 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
'<div class="item-stock-cols md:col-span-3 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
'<select class="form-input item-stock-select text-sm">' + stockOptionsHtml() + '</select>' +
'<select class="form-input item-warehouse-select text-sm">' + warehouseOptionsHtml() + '</select></div>' +
'<div class="item-desc-wrap md:col-span-6 min-w-0">' +
'<div class="item-desc-wrap md:col-span-5 min-w-0">' +
'<input type="text" name="item_description[]" class="w-full form-input item-description" placeholder="{{ _("Item description") }}" data-calc-trigger></div>' +
'<input type="number" name="item_quantity[]" value="1" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
'<input type="text" name="item_unit[]" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="{{ _("Unit") }}">' +
@@ -142,8 +194,9 @@ document.addEventListener('DOMContentLoaded', function() {
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition';
row.innerHTML =
'<input type="hidden" name="qe_id[]" value="">' +
reorderButtonsHtml() +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _("Title") }}" data-calc-trigger></div>' +
'<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><select name="qe_category[]" class="form-input">' + qeCategoryOptions + '</select></div>' +
'<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" placeholder="0" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm"></div>' +
@@ -152,11 +205,14 @@ document.addEventListener('DOMContentLoaded', function() {
expensesContainer.appendChild(row);
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
row.remove();
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
calculateTotals();
});
wireReorderButtons(row);
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
calculateTotals();
}
@@ -172,7 +228,8 @@ document.addEventListener('DOMContentLoaded', function() {
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition';
row.innerHTML =
'<input type="hidden" name="qg_id[]" value="">' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
reorderButtonsHtml() +
'<div class="md:col-span-1 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
'<div class="md:col-span-2 min-w-0"><select name="qg_category[]" class="form-input">' + qgCategoryOptions + '</select></div>' +
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" value="1" step="0.01" min="0" data-calc-trigger></div>' +
@@ -183,11 +240,14 @@ document.addEventListener('DOMContentLoaded', function() {
goodsContainer.appendChild(row);
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
row.remove();
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
calculateTotals();
});
wireReorderButtons(row);
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
calculateTotals();
}
@@ -248,8 +308,10 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
row.remove();
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
calculateTotals();
});
wireReorderButtons(row);
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
@@ -257,13 +319,19 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.quote-good-row').forEach(function(row) {
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
row.remove();
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
calculateTotals();
});
wireReorderButtons(row);
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
el.addEventListener('input', calculateTotals);
});
});
if (itemsContainer) refreshReorderStates(itemsContainer, '.quote-item-row');
if (expensesContainer) refreshReorderStates(expensesContainer, '.quote-expense-row');
if (goodsContainer) refreshReorderStates(goodsContainer, '.quote-good-row');
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
calculateTotals();
});
+6 -3
View File
@@ -58,8 +58,9 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-2">{{ _('Line type') }}</div>
<div class="md:col-span-4">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-3">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Qty') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
@@ -89,8 +90,9 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-2">{{ _('Title') }}</div>
<div class="md:col-span-3">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Amount') }}</div>
<div class="md:col-span-2">{{ _('Date') }}</div>
@@ -119,7 +121,8 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Name') }}</div>
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-1">{{ _('Name') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Qty') }}</div>
+22 -7
View File
@@ -63,8 +63,9 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-2">{{ _('Line type') }}</div>
<div class="md:col-span-4 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-3 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-1">{{ _('Qty') }}</div>
<div class="md:col-span-1">{{ _('Unit') }}</div>
@@ -77,13 +78,17 @@
<input type="hidden" name="item_id[]" value="{{ item.id }}">
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
</div>
<div class="md:col-span-2 min-w-0">
<select name="item_line_source[]" class="form-input item-line-source text-sm" title="{{ _('Line type') }}">
<option value="manual" {% if not item.stock_item_id %}selected{% endif %}>{{ _('Manual entry') }}</option>
<option value="stock" {% if item.stock_item_id %}selected{% endif %}>{{ _('From stock') }}</option>
</select>
</div>
<div class="item-stock-cols md:col-span-4 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
<div class="item-stock-cols md:col-span-3 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
<select class="form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
<option value="">{{ _('None') }}</option>
{% for stock_item in stock_items %}
@@ -97,7 +102,7 @@
{% endfor %}
</select>
</div>
<div class="item-desc-wrap md:col-span-2 min-w-0 {% if not item.stock_item_id %}md:col-span-6{% endif %}">
<div class="item-desc-wrap min-w-0 {% if item.stock_item_id %}md:col-span-2{% else %}md:col-span-5{% endif %}">
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="w-full form-input item-description" data-calc-trigger>
</div>
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>
@@ -133,8 +138,9 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-2">{{ _('Title') }}</div>
<div class="md:col-span-3">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Amount') }}</div>
<div class="md:col-span-2">{{ _('Date') }}</div>
@@ -144,8 +150,12 @@
{% for item in quote.items if (item.line_kind or 'item') == 'expense' %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="qe_id[]" value="{{ item.id }}">
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
</div>
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _('Title') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0">
<select name="qe_category[]" class="form-input">
<option value="travel" {% if item.category == 'travel' %}selected{% endif %}>{{ _('Travel') }}</option>
@@ -185,7 +195,8 @@
</button>
</div>
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
<div class="md:col-span-2">{{ _('Name') }}</div>
<div class="md:col-span-1 text-center">{{ _('Order') }}</div>
<div class="md:col-span-1">{{ _('Name') }}</div>
<div class="md:col-span-2">{{ _('Description') }}</div>
<div class="md:col-span-2">{{ _('Category') }}</div>
<div class="md:col-span-2">{{ _('Qty') }}</div>
@@ -197,7 +208,11 @@
{% for item in quote.items if (item.line_kind or 'item') == 'good' %}
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition">
<input type="hidden" name="qg_id[]" value="{{ item.id }}">
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
<div class="quote-line-reorder md:col-span-1 flex flex-row md:flex-col gap-1 items-center justify-center min-w-0 self-center">
<button type="button" class="quote-line-move-up p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move up') }}" aria-label="{{ _('Move up') }}"><i class="fas fa-chevron-up text-xs"></i></button>
<button type="button" class="quote-line-move-down p-1.5 rounded border border-border-light dark:border-border-dark bg-background-light dark:bg-background-dark hover:bg-gray-100 dark:hover:bg-gray-700 text-text-muted-light dark:text-text-muted-dark disabled:opacity-40 disabled:pointer-events-none" title="{{ _('Move down') }}" aria-label="{{ _('Move down') }}"><i class="fas fa-chevron-down text-xs"></i></button>
</div>
<div class="md:col-span-1 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
<div class="md:col-span-2 min-w-0">
<select name="qg_category[]" class="form-input">
+1 -1
View File
@@ -413,7 +413,7 @@
<dt class="font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Valid Until') }}</dt>
<dd class="text-gray-600 dark:text-gray-400">
{{ quote.valid_until|format_date if quote.valid_until else '' }}
{% if quote.valid_until and quote.valid_until < now() %}
{% if quote.is_expired %}
<span class="ml-2 px-2 py-1 text-xs rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">{{ _('Expired') }}</span>
{% endif %}
</dd>
+23
View File
@@ -0,0 +1,23 @@
# Crowdin CLI / GitHub Action configuration.
# See docs/CONTRIBUTING_TRANSLATIONS.md → Crowdin setup.
#
# Secrets (never commit real values):
# CROWDIN_PROJECT_ID numeric project ID from Crowdin → Project Settings → Details
# CROWDIN_PERSONAL_TOKEN Account Settings → API → New token (scope: Project; Manager on this project)
project_id_env: CROWDIN_PROJECT_ID
api_token_env: CROWDIN_PERSONAL_TOKEN
base_path: .
base_url: https://api.crowdin.com
preserve_hierarchy: true
files:
- source: /translations/en/LC_MESSAGES/messages.po
translation: /translations/%two_letters_code%/LC_MESSAGES/%original_file_name%
# Minor edits to English source strings keep existing translations as unapproved instead of wiping them
update_option: update_as_unapproved
# Crowdin often uses "nb" for Norwegian Bokmål; this app uses locale folder "no" (see app/config.py).
languages_mapping:
two_letters_code:
"nb": "no"
+11
View File
@@ -67,12 +67,18 @@ For detailed instructions, see [Windows Code Signing Guide](../../docs/WINDOWS_C
## Development
### Renderer bundle
The UI is bundled from [`src/renderer/js/app.js`](src/renderer/js/app.js) into [`src/renderer/js/bundle.js`](src/renderer/js/bundle.js) with esbuild (`npm run build:renderer`). Anything the app needs at runtime (including [`src/renderer/js/utils/helpers.js`](src/renderer/js/utils/helpers.js), which sets `window.Helpers`) must be imported from `app.js` so it is included in the bundle. After changing renderer source files, run `npm run build:renderer` before packaging or committing an updated `bundle.js`.
### Run in Development Mode
```bash
npm start
```
(`npm start` runs `build:renderer` first, then launches Electron.)
### Run with DevTools
```bash
@@ -173,6 +179,11 @@ The connection is automatically checked every 30 seconds.
- Check that electron-store is working properly
- Try clearing settings and re-entering them
**Window stuck on loading, blank content, or unstable navigation (especially Windows):**
- Use the latest release or rebuild from source; older builds could mis-handle `file:` navigation in the main process or ship a renderer bundle without helpers loaded.
- From source, run `npm install` and `npm run build:renderer`, then `npm start` or rebuild the installer.
- See [Desktop build Windows troubleshooting](../../docs/admin/configuration/DESKTOP_BUILD_WINDOWS_TROUBLESHOOTING.md) for environment-specific build issues.
For more details, see [Desktop Settings Guide](../../docs/DESKTOP_SETTINGS.md).
## Project Structure
+19 -3
View File
@@ -211,12 +211,28 @@ ipcMain.on('splash:ready', () => {
}
});
// Prevent navigation to external URLs
// Prevent navigation to external URLs (file: uses opaque origin "null", not "file://")
app.on('web-contents-created', (event, contents) => {
contents.on('will-navigate', (event, navigationUrl) => {
const parsedUrl = new URL(navigationUrl);
if (parsedUrl.origin !== 'file://') {
let parsedUrl;
try {
parsedUrl = new URL(navigationUrl);
} catch {
event.preventDefault();
return;
}
const protocol = parsedUrl.protocol;
if (
protocol === 'file:' ||
protocol === 'about:' ||
protocol === 'devtools:'
) {
return;
}
if (protocol === 'http:' || protocol === 'https:') {
event.preventDefault();
return;
}
event.preventDefault();
});
});
+1
View File
@@ -1,4 +1,5 @@
// Main application logic
require('./utils/helpers');
const { storeGet, storeSet, storeDelete, storeClear } = window.config || {};
const ApiClient = require('./api/client');
const StorageService = require('./storage/storage');
+91 -4
View File
@@ -23,6 +23,83 @@
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/renderer/js/utils/helpers.js
var require_helpers = __commonJS({
"src/renderer/js/utils/helpers.js"(exports, module) {
function formatDuration2(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor(seconds % 3600 / 60);
const secs = seconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m ${secs}s`;
}
function formatDurationLong2(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor(seconds % 3600 / 60);
const secs = seconds % 60;
return `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
function formatDate(date) {
if (typeof date === "string") {
date = new Date(date);
}
return date.toLocaleDateString();
}
function formatDateTime2(date) {
if (typeof date === "string") {
date = new Date(date);
}
return date.toLocaleString();
}
function parseISODate(dateString) {
return new Date(dateString);
}
function isValidUrl2(string) {
try {
const url = new URL(string);
return url.protocol === "http:" || url.protocol === "https:";
} catch (_) {
return false;
}
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
if (typeof module !== "undefined" && module.exports) {
module.exports = {
formatDuration: formatDuration2,
formatDurationLong: formatDurationLong2,
formatDate,
formatDateTime: formatDateTime2,
parseISODate,
isValidUrl: isValidUrl2,
debounce
};
}
if (typeof window !== "undefined") {
window.Helpers = {
formatDuration: formatDuration2,
formatDurationLong: formatDurationLong2,
formatDate,
formatDateTime: formatDateTime2,
parseISODate,
isValidUrl: isValidUrl2,
debounce
};
}
}
});
// node_modules/axios/dist/browser/axios.cjs
var require_axios = __commonJS({
"node_modules/axios/dist/browser/axios.cjs"(exports, module) {
@@ -2751,6 +2828,9 @@
if (reason) data.reason = reason;
return await this.client.post(`/api/v1/timesheet-periods/${periodId}/reject`, data);
}
async deleteTimesheetPeriod(periodId) {
return await this.client.delete(`/api/v1/timesheet-periods/${periodId}`);
}
async getLeaveTypes() {
return await this.client.get("/api/v1/time-off/leave-types");
}
@@ -2787,6 +2867,9 @@
if (comment) data.comment = comment;
return await this.client.post(`/api/v1/time-off/requests/${requestId}/reject`, data);
}
async deleteTimeOffRequest(requestId) {
return await this.client.delete(`/api/v1/time-off/requests/${requestId}`);
}
};
if (typeof module !== "undefined" && module.exports) {
module.exports = ApiClient2;
@@ -7730,6 +7813,7 @@
});
// src/renderer/js/app.js
require_helpers();
var { storeGet, storeSet, storeDelete, storeClear } = window.config || {};
var ApiClient = require_client();
var StorageService = require_storage();
@@ -7793,11 +7877,12 @@
const role = String(user.role || "").toLowerCase();
const roleCanApprove = ["admin", "owner", "manager", "approver"].includes(role);
state.currentUserProfile = {
id: user.id,
is_admin: Boolean(user.is_admin),
can_approve: Boolean(user.is_admin) || roleCanApprove
};
} catch (_) {
state.currentUserProfile = { is_admin: false, can_approve: false };
state.currentUserProfile = { id: null, is_admin: false, can_approve: false };
}
}
function updateConnectionStatus(status) {
@@ -8485,8 +8570,9 @@
</div>
<div class="entry-actions">
${String(period.status || "").toLowerCase() === "draft" ? `<button class="btn btn-sm btn-primary" onclick="submitTimesheetPeriodAction(${period.id})">Submit</button>` : ""}
${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `<button class="btn btn-sm btn-primary" onclick="reviewTimesheetPeriodAction(${period.id}, true)">Approve</button>` : ""}
${String(period.status || "").toLowerCase() === "submitted" && currentUserProfile.can_approve ? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>` : ""}
${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `<button class="btn btn-sm btn-primary" onclick="reviewTimesheetPeriodAction(${period.id}, true)">Approve</button>` : ""}
${String(period.status || "").toLowerCase() === "submitted" && state.currentUserProfile.can_approve ? `<button class="btn btn-sm btn-danger" onclick="reviewTimesheetPeriodAction(${period.id}, false)">Reject</button>` : ""}
${["draft", "rejected"].includes(String(period.status || "").toLowerCase()) ? `<button class="btn btn-sm btn-danger" onclick="deleteTimesheetPeriodAction(${period.id})">Delete</button>` : ""}
</div>
</div>
`).join("");
@@ -8534,7 +8620,7 @@
const leaveType = req.leave_type_name || "Leave";
const status = req.status || "";
const pending = String(status).toLowerCase() === "submitted";
const canReview = pending && currentUserProfile.can_approve;
const canReview = pending && state.currentUserProfile.can_approve;
return `
<div class="entry-item">
<div class="entry-info">
@@ -8545,6 +8631,7 @@
<div class="entry-time">${status}</div>
${canReview ? `<button class="btn btn-sm btn-primary" onclick="reviewTimeOffRequestAction(${req.id}, true)">Approve</button>` : ""}
${canReview ? `<button class="btn btn-sm btn-danger" onclick="reviewTimeOffRequestAction(${req.id}, false)">Reject</button>` : ""}
${["draft", "submitted", "cancelled"].includes(String(status).toLowerCase()) && (req.user_id === state.currentUserProfile.id || state.currentUserProfile.can_approve) ? `<button class="btn btn-sm btn-danger" onclick="deleteTimeOffRequestAction(${req.id})">Delete</button>` : ""}
</div>
</div>
`;
+98
View File
@@ -0,0 +1,98 @@
# Contributing translations (no Git required)
This project uses **GNU gettext** `.po` files under `translations/<locale>/LC_MESSAGES/messages.po`, compiled at app startup (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)). You do **not** need Git or developer tools to suggest fixes.
## How we accept help (channels)
Maintainers should pick what fits workload and community size. The **default** for this repository is **A**; scale up to **B** or **C** when needed.
| Channel | Best for | Git? |
|--------|-----------|------|
| **A. GitHub issue (recommended default)** | Wrong/missing wording, one string or a small batch | No — use the “Translation improvement” template when creating an issue |
| **B. Spreadsheet or form** | Many rows at once, non-GitHub users | No — maintainer copies suggestions into `.po` |
| **C. Hosted translation platform** | Ongoing community, many languages, history and glossaries | No for translators; maintainer connects repo or uploads `.po` |
### A. GitHub issue (primary)
1. Open a new issue and choose **Translation improvement**.
2. Fill in language, where you saw the text, current UI text, and your suggested wording.
3. A maintainer updates the correct `messages.po` and merges the change.
No repository access is required.
### B. Spreadsheet or form (optional)
Use when contributors cannot or will not use GitHub:
1. Maintainer shares a table with columns such as: **Language code**, **Screen or page**, **Text as shown now**, **Should be (your suggestion)**, **Notes**.
2. Contributors only edit the suggestion column.
3. Maintainer applies changes to the `.po` files and validates placeholders (see below).
### C. Hosted platform (optional, higher volume)
Examples: [Weblate](https://weblate.org/) (open source, can be self-hosted), [Crowdin](https://crowdin.com/), [POEditor](https://poeditor.com/), [Transifex](https://www.transifex.com/). Translators work in the browser; integration or export/import keeps `.po` in sync with the codebase. Setup is maintainer-owned.
#### Crowdin setup (maintainers)
This repo includes a root [`crowdin.yml`](../crowdin.yml) that maps **source** `translations/en/LC_MESSAGES/messages.po` to **translations** under `translations/<locale>/LC_MESSAGES/messages.po`, with **`nb``no`** so Norwegian matches `app/config.py` (`no`, not `nb`). You may still have a legacy `translations/nb/` tree locally; prefer **`no`** in Crowdin and in config so you do not maintain two Norwegian copies.
1. **Create a Crowdin account and project** at [crowdin.com](https://crowdin.com/) → **Create project**.
2. **Source language:** English. Treat the resource as **Gettext PO** (`.po`).
3. **Target languages:** Add every locale you ship: `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he` (match `LANGUAGES` in `app/config.py`). For Norwegian, add Norwegian (Bokmål) in Crowdin; the `crowdin.yml` mapping writes files into `translations/no/`.
4. **Sync with this repository (pick one):**
- **GitHub Action:** In the GitHub repo, add Actions secrets `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` (Crowdin project **Details** shows the numeric project ID; **Account Settings → API** creates the token with project access, typically Manager). Run **Crowdin sync** from the **Actions** tab → **Run workflow**. For a **one-time** import of existing `.po` files into Crowdins translation memory, temporarily set `upload_translations: true` in [.github/workflows/crowdin-sync.yml](../.github/workflows/crowdin-sync.yml), run it once, then set it back to `false`.
- **Crowdins GitHub integration:** Crowdin → **Integrations → GitHub** → connect the repo and branch; point it at the same `crowdin.yml` so Crowdin can open PRs when translations are updated.
- **Crowdin CLI:** Install the [Crowdin CLI](https://crowdin.github.io/crowdin-cli/), export the same env vars, run `crowdin upload sources` (and optionally `crowdin upload translations` once) from the repository root.
5. **When developers add or change `_()` strings:** Run `pybabel extract` / `pybabel update` locally (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)), commit if you version those files, then upload sources to Crowdin again.
6. **Landing translations:** Approve in Crowdin if you use review, then download (workflow or integration PR), merge, and run the app so `.mo` files rebuild.
Translators only need a Crowdin account; they do not use git.
### Other options (reference)
- **Poedit:** Maintainer can zip `translations/<lang>/LC_MESSAGES/messages.po` for a trusted translator; they edit in [Poedit](https://poedit.net/) and send the file back. Avoid two people editing the same locale in parallel without coordination.
- **GitHub web editor on `.po` files:** Possible for experts only; easy to break quoting or plural blocks.
## Rules for translators
Follow these so your suggestion can be applied without breaking the app:
1. **Do not change English source keys.** In `.po` files those are `msgid` lines. In an issue or spreadsheet you describe what you see; maintainers map it to the file. Never invent a new English “key” string.
2. **Preserve placeholders exactly.** If the UI shows `Hello, %(username)s` or similar, your translation must include the same placeholders (same names, same `%(name)s`-style segments). Same for `%s`, `%d`, or other format tokens.
3. **Plurals:** Some strings have one vs many forms. If you are unsure, describe the case in **Notes** and a maintainer will set `msgstr[0]` / `msgstr[1]` correctly in the `.po` file.
4. **Context matters.** Say which **page**, **button**, or **dialog** the text appears on, and attach a **screenshot** if possible. One English phrase can appear in multiple places with different meanings.
5. **Length and tone:** Short labels (buttons, nav) should stay compact. Full sentences can be more natural in your language than literal word-for-word English.
**Supported locale codes** (see `app/config.py` `LANGUAGES`): `en`, `nl`, `de`, `fr`, `it`, `fi`, `es`, `no`, `ar`, `he`.
## Maintainer workflow
Designate at least one person responsible for translation intake (issues, spreadsheet, or platform export).
### Applying contributor suggestions
1. Identify the locale file: `translations/<locale>/LC_MESSAGES/messages.po`.
2. Find the entry (by `msgid` / English source or grep for the current `msgstr`).
3. Update `msgstr` (and plural `msgstr[n]` if needed). Remove `#, fuzzy` if you are sure the translation is correct (fuzzy entries may be ignored at compile time depending on setup).
4. Restart the app or trigger your usual deploy so `.mo` is regenerated (see [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md) — compilation runs on startup via `app/utils/i18n.py`).
### After new UI strings ship in code
When developers add or change translatable strings:
```bash
pybabel extract -F babel.cfg -o messages.pot .
pybabel update -i messages.pot -d translations
```
Then fill new empty entries in each `messages.po`, run the app, and smoke-test critical screens in a few locales.
### Verification checklist
- [ ] Placeholders in `msgstr` match the `msgid` / source string.
- [ ] `.po` file is valid UTF-8 and parses (Poedit or `msgfmt --check`).
- [ ] UI checked in the target language for overflow or clipping on small screens (especially for short buttons).
## See also
- Technical overview: [TRANSLATION_SYSTEM.md](TRANSLATION_SYSTEM.md)
+12 -8
View File
@@ -2,7 +2,7 @@
## Overview
TimeTracker includes a comprehensive internationalization (i18n) system powered by Flask-Babel. The application supports 6 languages out of the box:
TimeTracker includes a comprehensive internationalization (i18n) system powered by Flask-Babel. Enabled locales are defined in `app/config.py` (`LANGUAGES`), for example:
- **English** (en) - Default
- **Dutch** (nl - Nederlands)
@@ -10,6 +10,7 @@ TimeTracker includes a comprehensive internationalization (i18n) system powered
- **French** (fr - Français)
- **Italian** (it - Italiano)
- **Finnish** (fi - Suomi)
- **Spanish** (es), **Norwegian** (no), **Arabic** (ar), **Hebrew** (he), and others as configured
## User Experience
@@ -38,7 +39,7 @@ Language preference is persisted:
### Translation Files
Translation files are located in `translations/` directory:
Translation files are located in `translations/` directory (see `app/config.py` for the full list of enabled locales; additional `.po` files may exist for locales not yet wired in config).
```
translations/
@@ -47,7 +48,9 @@ translations/
├── de/LC_MESSAGES/messages.po # German
├── fr/LC_MESSAGES/messages.po # French
├── it/LC_MESSAGES/messages.po # Italian
── fi/LC_MESSAGES/messages.po # Finnish
── fi/LC_MESSAGES/messages.po # Finnish
├── es/LC_MESSAGES/messages.po # Spanish
└── ... # Other locales as configured
```
### Configuration
@@ -252,7 +255,7 @@ Potential improvements:
1. Add more languages (Spanish, Portuguese, Japanese, etc.)
2. Right-to-left (RTL) language support (Arabic, Hebrew)
3. User-contributed translations via Crowdin or similar
3. User-contributed translations via [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issues, spreadsheet, or a hosted platform such as Weblate/Crowdin)
4. Automatic language detection improvement
5. Translation coverage reporting
@@ -261,13 +264,14 @@ Potential improvements:
For questions or issues with translations:
1. Check this documentation
2. Review `app/__init__.py` locale selector
3. Inspect browser network requests to `/i18n/set-language`
4. Check application logs for translation compilation errors
2. **Contributors without Git:** see [CONTRIBUTING_TRANSLATIONS.md](CONTRIBUTING_TRANSLATIONS.md) (issue template, spreadsheet option, maintainer workflow, and optional [Crowdin](https://crowdin.com/) using root [`crowdin.yml`](../crowdin.yml) and the **Crowdin sync** GitHub Action)
3. Review `app/__init__.py` locale selector
4. Inspect browser network requests to `/i18n/set-language`
5. Check application logs for translation compilation errors
---
**Last Updated**: 2025-10-07
**Last Updated**: 2026-04-15
**Flask-Babel Version**: 4.0.0
**Babel Version**: 2.14.0
@@ -143,6 +143,16 @@ If you're hitting path length limits:
- Cleaner, more reliable than npm install
- Build script already uses this
## App window stuck on loading or shows blank content
If the installer or executable starts but the main window never leaves the loading state, shows a blank page, or behaves as if navigation is stuck (often reported on Windows 11):
1. **Update to the latest build** from the project releases or rebuild from the current `develop` branch. Older builds could mishandle `file:` URL navigation in Electron or ship an incomplete renderer bundle.
2. **Rebuild the renderer** when building from source: from the `desktop` folder run `npm install` then `npm run build:renderer`, then `npm run build:win` (or your usual build command). The packaged app expects an up-to-date `src/renderer/js/bundle.js`.
3. **Confirm the server URL** on the login screen and try again after a full quit and restart.
If the problem persists after a clean rebuild, open an issue with your app version, Windows build, and any DevTools console output (run `npm run dev` for a local build with DevTools).
## Additional Resources
- [npm Troubleshooting Guide](https://docs.npmjs.com/common-errors)
+4
View File
@@ -50,6 +50,10 @@ This project and everyone participating in it is governed by our [Code of Conduc
- Push to the branch (`git push origin feature/amazing-feature`)
- Open a Pull Request
### Translations (no Git required)
Contributors who only want to fix wording can use the **Translation improvement** GitHub issue template or follow [CONTRIBUTING_TRANSLATIONS.md](../CONTRIBUTING_TRANSLATIONS.md) (spreadsheet option, maintainer workflow, optional Crowdin using [`crowdin.yml`](../../crowdin.yml) and the **Crowdin sync** workflow). Developers adding new `_('...')` strings should run `pybabel extract` / `update` as described there.
## Development Setup
### Prerequisites
+1 -1
View File
@@ -58,6 +58,6 @@ This document tracks frontend modernization phases and how to run quality checks
|------|-----------|
| Web base layout | `app/templates/base.html`, `app/static/base-init.js`, `app/static/pwa-enhancements.js` |
| Web search/IDs | `app/templates/*/list.html` (unique filter search IDs), `app/static/enhanced-search.js` |
| Desktop renderer | `desktop/src/renderer/js/app.js`, `desktop/src/renderer/js/state.js`, `desktop/src/renderer/js/ui/notifications.js`, `desktop/src/renderer/js/bundle.js` |
| Desktop renderer | `desktop/src/renderer/js/app.js` (esbuild entry; import shared modules such as `utils/helpers.js` from here), `desktop/src/renderer/js/state.js`, `desktop/src/renderer/js/ui/notifications.js`, `desktop/src/renderer/js/bundle.js` (run `npm run build:renderer` after renderer changes) |
| Mobile finance | `mobile/lib/presentation/screens/finance_workforce_screen.dart`, `mobile/lib/presentation/providers/finance_workforce_providers.dart` |
| Mobile home | `mobile/lib/presentation/screens/home_screen.dart` (IndexedStack for tabs) |
+1 -1
View File
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='5.3.0',
version='5.3.1',
packages=find_packages(),
include_package_data=True,
package_data={
+2
View File
@@ -14,6 +14,8 @@ def test_create_quote_redirect_then_view_returns_200(admin_authenticated_client,
"title": "Regression Quote 583",
"tax_rate": "0",
"currency_code": "EUR",
# Triggers Valid until block + expired badge on view (issue #583 used undefined now()).
"valid_until": "2020-01-01",
},
follow_redirects=False,
)