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
@@ -10,6 +10,8 @@
*/
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
import { ago, shortDate } from '$lib/when.js';
let users = $state([]);
let invites = $state([]);
@@ -76,22 +78,25 @@
* warning.
*/
async function remove(user) {
const parts = [`Slet ${user.name} <${user.email}> for altid?`, ''];
const parts = [t('users.deleteQuestion', { name: user.name, email: user.email }), ''];
if (user.project_count || user.task_count) {
parts.push(
`Det sletter ${count(user.project_count, 'projekt', 'projekter')} og ` +
`${count(user.task_count, 'opgave', 'opgaver')} i dem.`,
t('users.deleteTakes', {
projects: count(user.project_count, 'users.projectOne', 'users.projectMany'),
tasks: count(user.task_count, 'users.taskOne', 'users.taskMany')
}),
''
);
}
if (user.authored_elsewhere) {
parts.push(
`${count(user.authored_elsewhere, 'opgave', 'opgaver')} i andres projekter ` +
`bliver stående uden forfatter.`,
t('users.deleteLeaves', {
tasks: count(user.authored_elsewhere, 'users.taskOne', 'users.taskMany')
}),
''
);
}
parts.push('Det kan ikke fortrydes. Der er ingen papirkurv for konti.');
parts.push(t('users.deleteFinal'));
if (!confirm(parts.join('\n'))) return;
const previous = users;
@@ -105,8 +110,7 @@
}
async function revoke(pending) {
if (!confirm(`Træk invitationen til ${pending.email} tilbage? Linket holder op med at virke.`))
return;
if (!confirm(t('users.revokeQuestion', { email: pending.email }))) return;
const previous = invites;
invites = invites.filter((i) => i.id !== pending.id);
@@ -118,53 +122,41 @@
}
}
const count = (n, one, many) => `${n} ${n === 1 ? one : many}`;
const count = (n, one, many) => `${n} ${t(n === 1 ? one : many)}`;
function ago(iso) {
if (!iso) return 'aldrig logget ind';
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return 'lige nu';
if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`;
if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`;
if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`;
return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' });
}
const expires = (iso) =>
new Date(iso).toLocaleDateString('da-DK', { day: 'numeric', month: 'short' });
const expires = shortDate;
</script>
<section class="panel">
<header>
<h2>Inviter en bruger</h2>
<h2>{t('users.invite')}</h2>
<p class="hint">
Der er ingen åben registrering. Du sender et link, og personen vælger selv navn
og adgangskode — så er der ikke en adgangskode, to mennesker kender.
{t('users.inviteHint')}
</p>
</header>
<form onsubmit={invite}>
<div class="field">
<label for="ny-email">E-mailadresse</label>
<label for="ny-email">{t('users.emailAddress')}</label>
<input
id="ny-email"
type="email"
bind:value={email}
placeholder="navn@example.dk"
placeholder={t('users.emailPlaceholder')}
required
aria-invalid={errors.email ? 'true' : undefined}
/>
{#if errors.email}<p class="error">Skal være en e-mailadresse.</p>{/if}
{#if errors.email}<p class="error">{t('users.mustBeEmail')}</p>{/if}
</div>
<div class="row">
<button class="primary" type="submit" disabled={busy}>Send invitation</button>
<button class="primary" type="submit" disabled={busy}>{t('users.sendInvite')}</button>
</div>
</form>
{#if link}
<div class="field">
<p class="hint">Invitationen er oprettet. Send linket til personen:</p>
<p class="hint">{t('users.inviteMade')}</p>
<code class="link-out">{link}</code>
</div>
{/if}
@@ -173,10 +165,9 @@
{#if invites.length}
<section class="panel">
<header>
<h2>Afventer svar</h2>
<h2>{t('users.pending')}</h2>
<p class="hint">
Invitationer, der er sendt og ikke brugt endnu. Kun linkets hash er gemt, så
det kan ikke vises igen — er det gået til den forkerte, så træk det tilbage.
{t('users.pendingHint')}
</p>
</header>
@@ -187,11 +178,11 @@
<span class="primary-line">{pending.email}</span>
<span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'}
· udløber {expires(pending.expires_at)}
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
</span>
</div>
<button class="secondary" onclick={() => revoke(pending)}>Træk tilbage</button>
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
</li>
{/each}
</ul>
@@ -200,8 +191,8 @@
<section class="panel">
<header>
<h2>Konti</h2>
<p class="hint">Alle på denne instans.</p>
<h2>{t('users.accounts')}</h2>
<p class="hint">{t('users.accountsHint')}</p>
</header>
<ul class="rows">
@@ -213,11 +204,11 @@
<div class="what">
<span class="primary-line">
{user.name}
{#if user.is_admin}<span class="badge">administrator</span>{/if}
{#if user.self}<span class="badge quiet">dig</span>{/if}
{#if user.is_admin}<span class="badge">{t('users.admin')}</span>{/if}
{#if user.self}<span class="badge quiet">{t('users.you')}</span>{/if}
</span>
<span class="secondary">
{user.email} · {ago(user.last_seen_at)}
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if}
</span>
</div>
@@ -227,9 +218,9 @@
wastes a click to teach you a rule. -->
{#if !user.self}
<button class="secondary" onclick={() => setAdmin(user, !user.is_admin)}>
{user.is_admin ? 'Fjern administrator' : 'Gør til administrator'}
{user.is_admin ? t('users.removeAdmin') : t('users.makeAdmin')}
</button>
<button class="danger" onclick={() => remove(user)}>Slet</button>
<button class="danger" onclick={() => remove(user)}>{t('users.delete')}</button>
{/if}
</li>
{/each}