diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index c99bfb1..ade06bd 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1081,6 +1081,70 @@ test('en gruppe er en side med sine projekter, en beskrivelse og filer', async ( expect(trouble).toEqual([]); }); +/** + * The account's language changes the interface, not only the parser. + * + * `locale` has been on the account since the beginning and only chose which + * grammar quick add read a line with — so picking English changed nothing anybody + * could see, and the field said so honestly: "Sprog i hurtig tilføjelse". + * + * Asserted on both directions and on a date, because the date formatter is the + * half that is easy to leave behind: `toLocaleDateString('da-DK', …)` was written + * into a dozen components, and an English interface still saying "24. aug." is the + * shape of a half-finished translation. + * + * It puts Danish back at the end. Everything after this reads Danish labels, and a + * test that leaves the account in English fails the ones that follow it rather + * than itself — which is the worst way for a suite to report anything. + */ +test('kontoens sprog skifter hele fladen, ikke kun parseren', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/indstillinger'); + + const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' }); + await expect(sidebar.getByRole('link', { name: 'I dag' })).toBeVisible(); + + await page.getByLabel('Sprog').selectOption('en'); + await page.getByRole('button', { name: 'Gem' }).first().click(); + + // The sidebar redraws without a reload: i18n.locale is $state, which is the + // whole reason that module is .svelte.js. + const english = page.getByRole('navigation', { name: 'Main menu' }); + await expect(english.getByRole('link', { name: 'Today' })).toBeVisible(); + await expect(english.getByRole('link', { name: 'Upcoming' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + + // And it survives a reload, which is the difference between the page keeping up + // and the account actually holding it. + await page.reload(); + await expect( + page.getByRole('navigation', { name: 'Main menu' }).getByRole('link', { name: 'Today' }) + ).toBeVisible(); + + // A date, in the half that is easy to leave in Danish. The month grid's heading + // is `toLocaleDateString`, which was `'da-DK'` in a dozen components before + // `tag()` existed — an English interface still saying "december" is exactly the + // shape of a half-finished translation. + await page.goto('/upcoming'); + await page.getByRole('button', { name: 'Month', exact: true }).click(); + await expect(page.locator('.calendar h2')).not.toContainText( + /januar|februar|marts|maj|juni|juli|oktober|december/i + ); + + // The Inbox keeps its name: it is a project row created at signup, not a + // string in the interface, and the person can rename it like any other. + + // Danish again, for everything that runs after this. + await page.goto('/indstillinger'); + await page.getByLabel('Language').selectOption('da'); + await page.getByRole('button', { name: 'Save' }).first().click(); + await expect( + page.getByRole('navigation', { name: 'Hovedmenu' }).getByRole('link', { name: 'I dag' }) + ).toBeVisible(); + + expect(trouble).toEqual([]); +}); + /** * The sidebar never scrolls sideways. * diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 882976c..52f9b07 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -22,46 +22,54 @@ export class ApiError extends Error { } } -/** Danish messages for the codes the API can return. The server sends English - * prose for logs; what a person reads is decided here, where the locale is known. */ +import { t } from './i18n.svelte.js'; + +/** + * The key for each code the API can return. + * + * Keys rather than sentences, because the server's own prose is English and + * written for a log — what a person reads is decided here, where the locale is + * known. `errorcodes_test.go` walks this table against the codes Go can emit, so + * a code the server gained and this did not fails the build rather than falling + * through to that log prose. + */ const MESSAGES = { - unauthorized: 'Forkert e-mail eller adgangskode.', - totp_required: 'Indtast koden fra din authenticator.', - forbidden: 'Det har du ikke adgang til.', - not_found: 'Findes ikke.', - conflict: 'Det er der allerede.', - rate_limited: 'For mange forsøg. Vent et øjeblik.', - validation_failed: 'Tjek felterne herunder.', - payload_too_large: 'Det er for stort.', - internal_error: 'Noget gik galt. Prøv igen.', - bad_request: 'Anmodningen kunne ikke læses.', + unauthorized: 'error.unauthorized', + totp_required: 'error.totpRequired', + forbidden: 'error.forbidden', + not_found: 'error.notFound', + conflict: 'error.conflict', + rate_limited: 'error.rateLimited', + validation_failed: 'error.validation', + payload_too_large: 'error.tooLarge', + internal_error: 'error.internal', + bad_request: 'error.badRequest', // Client-only: a network failure is not an HTTP status, so the server has no // code for it. - offline: 'Ingen forbindelse til serveren.', + offline: 'error.offline', // Situations that used to arrive as a plain `conflict` and were therefore - // shown as "Det er der allerede." — which was not merely vague but wrong. An + // shown as "that already exists" — which was not merely vague but wrong. An // unconfigured Gmail is not a Gmail that is already connected. - gmail_not_configured: - 'Gmail er ikke sat op på denne server. Administratoren skal registrere en OAuth-klient hos Google og sætte VERDANDE_GMAIL_CLIENT_ID og _SECRET.', - ai_not_configured: 'Der er ikke valgt en AI-udbyder. Sæt den op under Indstillinger → AI.', - totp_not_enabled: 'To-faktor er ikke slået til.', - inbox_protected: 'Indbakken kan ikke slettes.', - last_admin: - 'Det er den sidste administrator. Gør en anden til administrator først — ellers er der ingen, der kan komme ind på denne side igen.' + gmail_not_configured: 'error.gmailNotConfigured', + ai_not_configured: 'error.aiNotConfigured', + totp_not_enabled: 'error.totpNotEnabled', + inbox_protected: 'error.inboxProtected', + last_admin: 'error.lastAdmin' }; /** * What to show a person for a failed request. * - * A code with no message here falls back to the server's prose, which is English - * and written for a log — not good, but better than swallowing the only - * explanation there is. A code that turns up in that fallback is a code this - * table is missing. + * A code with no key here falls back to the server's prose, which is English and + * written for a log — not good, but better than swallowing the only explanation + * there is. A code that turns up in that fallback is a code this table is missing. */ export function humanMessage(err) { - if (!(err instanceof ApiError)) return MESSAGES.offline; - return MESSAGES[err.code] ?? err.message ?? MESSAGES.internal_error; + if (!(err instanceof ApiError)) return t(MESSAGES.offline); + const key = MESSAGES[err.code]; + if (key) return t(key); + return err.message ?? t(MESSAGES.internal_error); } async function request(method, path, body, options = {}) { diff --git a/web/src/lib/colors.js b/web/src/lib/colors.js index 4bb8285..bd5e99e 100644 --- a/web/src/lib/colors.js +++ b/web/src/lib/colors.js @@ -21,17 +21,20 @@ * carries a `--color-` token for each, and a Go test reads this file to check * the server's list of accepted names has not drifted from it. */ +// `name` is a key rather than a word: the picker calls `t()` on it. The ids stay +// exactly as they are — they are what the database stores, and `internal/store` +// has a test that reads them out of this file. export const COLORS = [ - { id: 'graphite', name: 'Grafit' }, - { id: 'tomato', name: 'Tomat' }, - { id: 'amber', name: 'Rav' }, - { id: 'lime', name: 'Lime' }, - { id: 'green', name: 'Grøn' }, - { id: 'teal', name: 'Petrol' }, - { id: 'blue', name: 'Blå' }, - { id: 'indigo', name: 'Indigo' }, - { id: 'violet', name: 'Violet' }, - { id: 'magenta', name: 'Magenta' } + { id: 'graphite', name: 'color.graphite' }, + { id: 'tomato', name: 'color.tomato' }, + { id: 'amber', name: 'color.amber' }, + { id: 'lime', name: 'color.lime' }, + { id: 'green', name: 'color.green' }, + { id: 'teal', name: 'color.teal' }, + { id: 'blue', name: 'color.blue' }, + { id: 'indigo', name: 'color.indigo' }, + { id: 'violet', name: 'color.violet' }, + { id: 'magenta', name: 'color.magenta' } ]; /** The CSS variable a colour id paints with, falling back to the default. */ diff --git a/web/src/lib/components/BoardView.svelte b/web/src/lib/components/BoardView.svelte index 58925b3..6c62a41 100644 --- a/web/src/lib/components/BoardView.svelte +++ b/web/src/lib/components/BoardView.svelte @@ -13,6 +13,7 @@ import { TASK, startDrag, carries, accept } from '$lib/dnd.js'; import { focusOnMount } from '$lib/focus.js'; import TaskRow from './TaskRow.svelte'; + import { t } from '$lib/i18n.svelte.js'; let { project, sections, canEdit, onsectionadded } = $props(); @@ -143,7 +144,7 @@ {/each} {#if !tasks.length} -

Tom

+

{t('view.emptySection')}

{/if} @@ -156,8 +157,8 @@ !name.trim() && (adding = false)} onkeydown={(e) => e.key === 'Escape' && (adding = false)} /> @@ -168,7 +169,7 @@ onclick={() => { adding = true; name = ''; - }}>+ Tilføj sektion{t('view.addSection')} {/if} diff --git a/web/src/lib/components/CalendarView.svelte b/web/src/lib/components/CalendarView.svelte index a377b6a..c880643 100644 --- a/web/src/lib/components/CalendarView.svelte +++ b/web/src/lib/components/CalendarView.svelte @@ -13,6 +13,7 @@ */ import { app } from '$lib/stores.svelte.js'; import { TASK, startDrag, carries, dragged, accept } from '$lib/dnd.js'; + import { t, tag } from '$lib/i18n.svelte.js'; let { /** @@ -53,7 +54,21 @@ let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date())); - const WEEKDAYS = ['man', 'tir', 'ons', 'tor', 'fre', 'lør', 'søn']; + /** + * The weekday names, from Intl rather than written out. + * + * Not to save seven strings — so the abbreviations are the ones the language + * actually uses. Every language shortens its weekdays differently, and guessing + * at that from a translation table is how a calendar ends up saying something + * no reader of that language would write. + * + * Monday first, because the grid is. The 1st of January 2024 was a Monday; any + * Monday would do, and a constant one keeps this out of the render path. + */ + const WEEKDAYS = $derived.by(() => { + const format = new Intl.DateTimeFormat(tag(), { weekday: 'short' }); + return Array.from({ length: 7 }, (_, i) => format.format(new Date(Date.UTC(2024, 0, 1 + i)))); + }); function startOfMonth(d) { return new Date(d.getFullYear(), d.getMonth(), 1); @@ -123,7 +138,7 @@ ); const monthName = $derived( - cursor.toLocaleDateString('da-DK', { month: 'long', year: 'numeric' }) + cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' }) ); // "24. aug. – 30. aug. 2026", collapsed to one month name when the week does not @@ -134,9 +149,9 @@ const day = { day: 'numeric', month: 'short' }; const left = from.getMonth() === to.getMonth() - ? from.toLocaleDateString('da-DK', { day: 'numeric' }) - : from.toLocaleDateString('da-DK', day); - return `${left}–${to.toLocaleDateString('da-DK', day)} ${to.getFullYear()}`; + ? from.toLocaleDateString(tag(), { day: 'numeric' }) + : from.toLocaleDateString(tag(), day); + return `${left}–${to.toLocaleDateString(tag(), day)} ${to.getFullYear()}`; }); /** @@ -196,17 +211,17 @@
-

{heading} - {#if weekNumber}uge {weekNumber}{/if} + {#if weekNumber}{t('view.weekNumber', { n: weekNumber })}{/if}

- - +
{/each} diff --git a/web/src/lib/components/Sidebar.svelte b/web/src/lib/components/Sidebar.svelte index 0a1788e..6bdcd0d 100644 --- a/web/src/lib/components/Sidebar.svelte +++ b/web/src/lib/components/Sidebar.svelte @@ -328,7 +328,7 @@ } -
- {/each} diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index 1578554..94812e1 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -12,6 +12,7 @@ import BoardView from '$lib/components/BoardView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte'; import { focusOnMount } from '$lib/focus.js'; + import { t } from '$lib/i18n.svelte.js'; let project = $state(null); /** @@ -326,7 +327,7 @@ class="title" use:focusOnMount value={project.name} - aria-label="Projektets navn" + aria-label={t('project.name')} onblur={(e) => rename(e.currentTarget)} onkeydown={(e) => { if (e.key === 'Enter') e.currentTarget.blur(); @@ -339,7 +340,7 @@ {:else}

{#if isOwner} - {:else} @@ -353,13 +354,13 @@ style="background: {colorVar(project.color)}" onclick={() => (choosingColor = !choosingColor)} aria-expanded={choosingColor} - aria-label="Farve på projektet" - title="Farve på projektet" + aria-label={t('project.color')} + title={t('project.color')} > {/if} -
- {#each [['list', 'Liste'], ['board', 'Board'], ['calendar', 'Kalender']] as [value, label]} +
+ {#each [['list', t('view.list')], ['board', t('view.board')], ['calendar', t('view.calendar')]] as [value, label]} {/if} {#if !project.is_inbox} {/if} {#if isOwner && !project.is_inbox} -
{/if} {#if choosingColor} -
+
{#each COLORS as color (color.id)} @@ -473,20 +474,20 @@ - + + - + {#if inviteLink} {/if} @@ -523,7 +524,7 @@ the label is a heading for the only list there is, and the empty state below already says the project is empty. --> {#if sections.length && !unsectioned.length} -

Uden sektion

+

{t('project.noSection')}

{/if} @@ -542,7 +543,7 @@ (renamingSection = null)} onkeydown={(e) => e.key === 'Escape' && (renamingSection = null)} /> @@ -555,17 +556,17 @@ onclick={() => { renamingSection = section.id; sectionName = section.name; - }}>Omdøb{t('project.rename')} {/if} {/if}
{#if !tasks.length} -

Tom

+

{t('view.emptySection')}

{/if} {/each} @@ -577,8 +578,8 @@ !sectionName.trim() && (addingSection = false)} onkeydown={(e) => e.key === 'Escape' && (addingSection = false)} /> @@ -591,7 +592,7 @@ sectionName = ''; }} > - + Tilføj sektion + {t('view.addSection')} {/if} @@ -600,25 +601,25 @@ {#if !open.length && !sections.length}

- Ingenting her endnu. + {t('project.nothingHere')}

{/if} {/if} {#if project.role === 'viewer'} -

Du kan se dette projekt, men ikke ændre det.

+

{t('project.readOnly')}

{/if} {:else if status === 'loading'}
{:else if status === 'denied'} -

Projektet findes ikke, eller du har ikke adgang til det.

+

{t('project.notFound')}

{:else}

- Kunne ikke hente projektet. Serveren svarede ikke. - + {t('project.loadFailed')} +

{/if}
diff --git a/web/src/routes/uddelegeret/+page.svelte b/web/src/routes/uddelegeret/+page.svelte index 76a311f..e62f237 100644 --- a/web/src/routes/uddelegeret/+page.svelte +++ b/web/src/routes/uddelegeret/+page.svelte @@ -10,6 +10,7 @@ import { api } from '$lib/api.js'; import { app } from '$lib/stores.svelte.js'; import TaskRow from '$lib/components/TaskRow.svelte'; + import { t } from '$lib/i18n.svelte.js'; let people = $state([]); let loaded = $state(false); @@ -31,7 +32,7 @@
-

Venter på andre

+

{t('view.delegated')}

{#each people as person (person.user_id)} {@const tasks = live(person)} @@ -54,7 +55,7 @@ {#if loaded && !people.some((p) => live(p).length)}

- Du venter ikke på nogen. + {t('view.delegatedEmpty')}

{/if}
diff --git a/web/src/routes/upcoming/+page.svelte b/web/src/routes/upcoming/+page.svelte index 6c71bdf..9caf3a1 100644 --- a/web/src/routes/upcoming/+page.svelte +++ b/web/src/routes/upcoming/+page.svelte @@ -20,6 +20,7 @@ import TaskRow from '$lib/components/TaskRow.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte'; import CalendarView from '$lib/components/CalendarView.svelte'; + import { t } from '$lib/i18n.svelte.js'; let days = $state([]); @@ -80,9 +81,9 @@
-

Kommende

-
- {#each [['list', 'Liste'], ['week', 'Uge'], ['calendar', 'Måned']] as [value, label]} +

{t('view.upcoming')}

+
+ {#each [['list', t('view.list')], ['week', t('view.week')], ['calendar', t('view.month')]] as [value, label]}