feat(i18n): the interface speaks both languages

The account has had a `locale` since the beginning and it 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". It says "Sprog" now,
because it means it.

Every component goes through `t()`. Danish is the source: a key missing from
English falls through to Danish rather than to the key, because a Danish sentence
in an English screen can be read and `settings.notifications.title` cannot.

`tag()` is the single place `Intl` is asked. Before, `'da-DK'` was written into a
dozen components, and that is the half of a translation that gets left behind — an
English interface still saying "24. aug." is exactly the shape of a job half done.
There is a smoke test that switches to English and refuses to see a Danish month
name in the calendar's heading.

Colour and theme names are keys now. `colors.js` keeps its ids untouched — they
are what the database stores, and `internal/store` reads them out of that file —
but `name` is `'color.tomato'`, resolved where it is drawn.

The Inbox keeps its name. It is a project row the server creates at signup, not a
string in the interface, and it can be renamed like any other project.

Four copies of "3 hours ago" became one, in `web/src/lib/when.js`. They had
already drifted: one said "aldrig logget ind", another "aldrig", and two stopped
at days where the others carried on to a date.

Routes stay Danish. `/indstillinger`, `/projekt`, `/uddelegeret` are bookmarks and
they are in every smoke test; translating them buys nothing and costs both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:46:08 +02:00
co-authored by Claude Opus 5
parent e3c7fc792a
commit 4d8b70918b
30 changed files with 1657 additions and 566 deletions
+64
View File
@@ -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.
*