feat(i18n): the machinery, and the shell translated

The account has had a `locale` since the beginning, and it only chose which
grammar quick add parsed a line with — so setting it to English changed nothing
anybody could see. This is the other half of that field, started.

`t()` reads `i18n.locale`, which is `$state`, so every component that calls it
redraws when the language changes; that is what makes the setting take effect
without a reload, and why the module is `.svelte.js`. Danish is the source and the
fallback: 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, so dates follow the language instead of being `da-DK` in a dozen files.

`internal/i18n` is a Go package that exists only to fail the build when the two
dictionaries drift — the same trick `colors_test.go` plays on the palette. It
checks the placeholders as well as the keys: a translation that spells `{n}` as
`{antal}` leaves `{antal}` on the screen, which is worse than an untranslated
sentence because it looks like a bug in the app rather than a gap in a dictionary.

Converted so far: the shell. Sidebar, layout, quick add, task row, command
palette — fifty-two keys, and the surfaces people look at constantly. Twenty
files remain, the settings pages being the heavy ones.

The language picker is deliberately not wired up yet. It still says "Sprog i
hurtig tilføjelse" and still only changes the parser. Wiring it before the sweep
is done would hand anybody who picks English a bilingual screen — the
half-finished-theme failure `app.css` already taught us. As it stands Danish is
unchanged and English is not offered, which is the honest state for something
half done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-18 09:25:58 +02:00
co-authored by Claude Opus 5
parent 804963b222
commit e3c7fc792a
9 changed files with 403 additions and 55 deletions
+116
View File
@@ -0,0 +1,116 @@
// Package i18n exists only to hold this test.
//
// The interface's strings live in the frontend, where they are read; nothing in Go
// needs them. What Go can do is fail the build when the two dictionaries drift,
// which is the failure mode a translation always has and which nothing else here
// would notice: a key present in Danish and missing from English renders as Danish
// inside an English screen, and a key present only in English is a string nobody
// will ever see.
//
// A scanner rather than a parser, the same approach `colors_test.go` and
// `openapi_test.go` take: it needs the keys out of two lists this repository owns,
// and that is worth more than a dependency in a project whose whole shape is "one
// static binary".
package i18n
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
)
func TestTheTwoDictionariesHaveTheSameKeys(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readKeys(t, filepath.Join(dir, "da.js"))
en := readKeys(t, filepath.Join(dir, "en.js"))
if len(da) == 0 {
t.Fatal("no keys found in da.js — the scanner is looking at the wrong shape")
}
var missing, extra []string
for key := range da {
if !en[key] {
missing = append(missing, key)
}
}
for key := range en {
if !da[key] {
extra = append(extra, key)
}
}
sort.Strings(missing)
sort.Strings(extra)
if len(missing) > 0 {
t.Errorf("%d keys are in da.js and not in en.js — they render as Danish inside an "+
"English screen:\n %s", len(missing), strings.Join(missing, "\n "))
}
if len(extra) > 0 {
t.Errorf("%d keys are in en.js and not in da.js — nothing reads them, because Danish "+
"is the source:\n %s", len(extra), strings.Join(extra, "\n "))
}
}
// The placeholders have to match too. `t()` fills `{name}` from what the caller
// passes, so a translation that spells one differently leaves `{navn}` on the
// screen — which is worse than an untranslated sentence, because it looks like a
// bug in the app rather than a gap in the dictionary.
func TestTheTwoDictionariesUseTheSamePlaceholders(t *testing.T) {
dir := filepath.Join("..", "..", "web", "src", "lib", "locales")
da := readEntries(t, filepath.Join(dir, "da.js"))
en := readEntries(t, filepath.Join(dir, "en.js"))
for key, danish := range da {
english, ok := en[key]
if !ok {
continue // reported by the test above
}
want := placeholders(danish)
got := placeholders(english)
if strings.Join(want, ",") != strings.Join(got, ",") {
t.Errorf("%s: Danish fills %v, English fills %v", key, want, got)
}
}
}
var (
// `'some.key': 'the string',` — single-quoted keys and values, which is what
// prettier writes for these files.
reEntry = regexp.MustCompile(`(?m)^\s*'([a-zA-Z0-9._]+)':\s*(.*?),?\s*$`)
rePlaceholder = regexp.MustCompile(`\{(\w+)\}`)
)
func readKeys(t *testing.T, path string) map[string]bool {
t.Helper()
keys := map[string]bool{}
for key := range readEntries(t, path) {
keys[key] = true
}
return keys
}
func readEntries(t *testing.T, path string) map[string]string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", filepath.Base(path), err)
}
out := map[string]string{}
for _, m := range reEntry.FindAllStringSubmatch(string(body), -1) {
out[m[1]] = m[2]
}
return out
}
func placeholders(s string) []string {
var found []string
for _, m := range rePlaceholder.FindAllStringSubmatch(s, -1) {
found = append(found, m[1])
}
sort.Strings(found)
return found
}
+5 -4
View File
@@ -1,6 +1,7 @@
<script>
/** Cmd+K: search across everything the user can see. */
import { api } from '$lib/api.js';
import { t } from '$lib/i18n.svelte.js';
import { goto } from '$app/navigation';
let { open = $bindable(false) } = $props();
@@ -87,13 +88,13 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="scrim" onclick={() => (open = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label="Søg">
<div class="palette" onclick={(e) => e.stopPropagation()} role="dialog" aria-label={t('nav.search')}>
<input
bind:this={input}
bind:value={query}
{onkeydown}
placeholder="Søg i opgaver og projekter"
aria-label="Søg"
placeholder={t('nav.searchLong')}
aria-label={t('nav.search')}
autocomplete="off"
spellcheck="false"
/>
@@ -115,7 +116,7 @@
{/each}
</ul>
{:else if query.trim()}
<p class="empty">Ingen resultater.</p>
<p class="empty">{t('nav.noResults')}</p>
{/if}
</div>
</div>
+4 -3
View File
@@ -14,6 +14,7 @@
*/
import { api } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
let { projectId = undefined, onadded = undefined, autofocus = false } = $props();
let text = $state('');
@@ -143,15 +144,15 @@
bind:value={text}
{onkeydown}
type="text"
placeholder="Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”"
aria-label="Ny opgave"
placeholder={t('task.placeholder')}
aria-label={t('task.new')}
autocomplete="off"
spellcheck="false"
/>
</div>
{#if text.trim()}
<button type="submit" class="submit" disabled={submitting}>Tilføj</button>
<button type="submit" class="submit" disabled={submitting}>{t('task.add')}</button>
{/if}
</form>
+31 -30
View File
@@ -4,6 +4,7 @@
import { TASK, PROJECT, GROUP, startDrag, carries, dragged, accept } from '$lib/dnd.js';
import { COLORS, colorVar } from '$lib/colors.js';
import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
@@ -109,11 +110,9 @@
// Says what happens to the projects, because "slet gruppen" reads like it
// might take them with it. It does not: only the heading goes.
const kept =
count === 1
? 'Projektet i den bliver, men ligger bagefter uden gruppe.'
: `De ${count} projekter bliver, men ligger bagefter uden gruppe.`;
if (!confirm(count ? `Slet gruppen "${group.name}"? ${kept}` : `Slet gruppen "${group.name}"?`))
return;
count === 1 ? t('sidebar.deleteGroupOne') : t('sidebar.deleteGroupMany', { n: count });
const question = t('sidebar.deleteGroup', { name: group.name });
if (!confirm(count ? `${question} ${kept}` : question)) return;
await app.deleteGroup(group.id);
}
@@ -354,11 +353,11 @@
ondrop={onDropOnToday}
>
<span class="dot today" aria-hidden="true"></span>
I dag
{t('nav.today')}
</a>
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
Kommende
{t('nav.upcoming')}
</a>
{#if app.projects.some((p) => p.shared)}
<!-- Only where delegating is possible at all. On an instance with one
@@ -366,7 +365,7 @@
entry is a question the sidebar keeps asking and answering "no". -->
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
<span class="dot" aria-hidden="true"></span>
Venter på andre
{t('nav.delegated')}
</a>
{/if}
{#if app.inbox}
@@ -457,14 +456,14 @@
ondragleave={() => (overGroup = null)}
ondrop={(e) => onDropInGroup(e, '')}
>
{@render foldHeading('projects', 'Projekter')}
{@render foldHeading('projects', t('sidebar.projects'))}
<button
class="icon"
onclick={() => {
adding = !adding;
addingGroup = false;
}}
aria-label="Nyt projekt">+</button
aria-label={t('sidebar.newProject')}>+</button
>
<button
class="icon new-group"
@@ -473,8 +472,8 @@
adding = false;
newGroupName = '';
}}
aria-label="Ny gruppe"
title="Ny gruppe"
aria-label={t('sidebar.newGroup')}
title={t('sidebar.newGroup')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
@@ -487,8 +486,8 @@
<input
bind:value={newName}
use:focusOnMount
placeholder="Projektnavn"
aria-label="Projektnavn"
placeholder={t('sidebar.projectName')}
aria-label={t('sidebar.projectName')}
onblur={() => !newName.trim() && (adding = false)}
onkeydown={(e) => e.key === 'Escape' && (adding = false)}
/>
@@ -500,8 +499,8 @@
<input
bind:value={newGroupName}
use:focusOnMount
placeholder="Gruppens navn"
aria-label="Gruppens navn"
placeholder={t('sidebar.groupName')}
aria-label={t('sidebar.groupName')}
onblur={() => !newGroupName.trim() && (addingGroup = false)}
onkeydown={(e) => e.key === 'Escape' && (addingGroup = false)}
/>
@@ -514,7 +513,7 @@
{/each}
{#if own.length === 0 && !adding}
<p class="empty">Ingen projekter endnu.</p>
<p class="empty">{t('sidebar.noProjects')}</p>
{/if}
{/if}
</div>
@@ -564,10 +563,10 @@
<input
bind:value={groupName}
use:focusOnMount
aria-label="Gruppens navn"
aria-label={t('sidebar.groupName')}
onkeydown={(e) => e.key === 'Escape' && (renamingGroup = null)}
/>
<div class="swatches" role="group" aria-label="Farve på {group.name}">
<div class="swatches" role="group" aria-label={t('group.colorOf', { name: group.name })}>
{#each COLORS as color (color.id)}
<button
type="button"
@@ -597,7 +596,9 @@
class="chevron-button"
onclick={() => app.toggleGroup(group.id)}
aria-expanded={!group.collapsed}
aria-label={group.collapsed ? `Fold ${group.name} ud` : `Fold ${group.name} sammen`}
aria-label={group.collapsed
? t('sidebar.unfold', { name: group.name })
: t('sidebar.fold', { name: group.name })}
>
<svg
class="chevron"
@@ -624,9 +625,9 @@
onclick={() => {
renamingGroup = group.id;
groupName = group.name;
}}>Omdøb</button
}}>{t('sidebar.rename')}</button
>
<button class="group-action remove" onclick={() => removeGroup(group)}>Slet</button>
<button class="group-action remove" onclick={() => removeGroup(group)}>{t('sidebar.delete')}</button>
{/if}
</div>
@@ -635,7 +636,7 @@
{@render projectRow(project, true)}
{/each}
{#if !inside.length}
<p class="empty">Tom — træk et projekt herop.</p>
<p class="empty">{t('sidebar.emptyGroup')}</p>
{/if}
{/if}
</div>
@@ -643,7 +644,7 @@
{#if shared.length}
<div class="group">
<div class="group-head">{@render foldHeading('shared', 'Delt med mig')}</div>
<div class="group-head">{@render foldHeading('shared', t('sidebar.shared'))}</div>
{#if !app.sectionCollapsed('shared')}
{#each shared as project (project.id)}
{@render projectRow(project, false)}
@@ -654,7 +655,7 @@
{#if filters.length}
<div class="group">
<div class="group-head">{@render foldHeading('filters', 'Filtre')}</div>
<div class="group-head">{@render foldHeading('filters', t('sidebar.filters'))}</div>
{#if !app.sectionCollapsed('filters')}
{#each filters as filter (filter.id)}
<a
@@ -672,7 +673,7 @@
{#if labels.length}
<div class="group">
<div class="group-head">{@render foldHeading('labels', 'Etiketter')}</div>
<div class="group-head">{@render foldHeading('labels', t('sidebar.labels'))}</div>
{#if !app.sectionCollapsed('labels')}
{#each labels.filter((l) => l.task_count > 0) as label (label.id)}
<a
@@ -702,20 +703,20 @@
d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 008 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06A1.65 1.65 0 004.6 15a1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06A1.65 1.65 0 009 4.6a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06A1.65 1.65 0 0019.4 9v0a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z"
/>
</svg>
Indstillinger
{t('nav.settings')}
</a>
<!-- A quiet indicator, not an alarm: losing the socket for a moment is
normal, and the app keeps working without it. -->
{#if !app.connected}
<span class="offline" title="Ændringer fra andre vises ikke lige nu">Offline</span>
<span class="offline" title={t('nav.offlineHint')}>{t('nav.offline')}</span>
{/if}
<button class="user" onclick={signOut}>
<span class="avatar" style="background: {app.user?.avatar_color}">
{app.user?.name?.[0]?.toUpperCase() ?? '?'}
</span>
<span class="user-name">{app.user?.name}</span>
<span class="signout">Log ud</span>
<span class="signout">{t('nav.signOut')}</span>
</button>
</div>
<!-- Also a slider for the keyboard: arrow keys move it, and Home puts it back.
@@ -726,7 +727,7 @@
class:resizing
role="separator"
aria-orientation="vertical"
aria-label="Bredde på sidebjælken"
aria-label={t('nav.sidebarWidth')}
tabindex="0"
onpointerdown={startResize}
onpointermove={onResize}
+15 -14
View File
@@ -9,12 +9,13 @@
* feels like a mis-click rather than an accomplishment.
*/
import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js';
let { task, onedit } = $props();
let leaving = $state(false);
const PRIORITY_LABEL = { 1: 'Prioritet 1', 2: 'Prioritet 2', 3: 'Prioritet 3', 4: 'Ingen prioritet' };
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
async function toggle() {
if (task.completed) {
@@ -33,17 +34,17 @@
const due = new Date(date + 'T00:00:00');
const days = Math.round((due - new Date(today.toDateString())) / 86400000);
if (days === 0) return { text: 'I dag', tone: 'today' };
if (days === 1) return { text: 'I morgen', tone: 'soon' };
if (days === -1) return { text: 'I går', tone: 'overdue' };
if (days < 0) return { text: `${Math.abs(days)} dage forsinket`, tone: 'overdue' };
if (days === 0) return { text: t('task.today'), tone: 'today' };
if (days === 1) return { text: t('task.tomorrow'), tone: 'soon' };
if (days === -1) return { text: t('task.yesterday'), tone: 'overdue' };
if (days < 0) return { text: t('task.overdue', { n: Math.abs(days) }), tone: 'overdue' };
if (days < 7)
return {
text: due.toLocaleDateString('da-DK', { weekday: 'long' }),
text: due.toLocaleDateString(tag(), { weekday: 'long' }),
tone: 'soon'
};
return {
text: due.toLocaleDateString('da-DK', { day: 'numeric', month: 'short' }),
text: due.toLocaleDateString(tag(), { day: 'numeric', month: 'short' }),
tone: 'later'
};
}
@@ -53,7 +54,7 @@
let due = $derived(dueLabel(task.due_date));
let time = $derived(
task.due_datetime
? new Date(task.due_datetime).toLocaleTimeString('da-DK', {
? new Date(task.due_datetime).toLocaleTimeString(tag(), {
hour: '2-digit',
minute: '2-digit'
})
@@ -66,8 +67,8 @@
class="check"
class:checked={task.completed || leaving}
onclick={toggle}
aria-label={task.completed ? 'Genåbn opgave' : 'Markér som færdig'}
title={PRIORITY_LABEL[task.priority]}
aria-label={task.completed ? t('task.reopen') : t('task.complete')}
title={priorityLabel(task.priority)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path class="tick" d="M6 12.5l4 4 8-8.5" />
@@ -89,7 +90,7 @@
<!-- First in the row, because "who" changes what the rest of the line
means: a date on somebody else's task is their deadline, not
yours. -->
<span class="assignee" title="Ansvarlig: {assignee.name}">
<span class="assignee" title={t('task.assignee', { name: assignee.name })}>
<span class="face" style="background: {assignee.avatar_color}">
{assignee.name[0]?.toUpperCase() ?? '?'}
</span>
@@ -102,7 +103,7 @@
</span>
{/if}
{#if task.recurrence_text}
<span class="repeat" title="Gentages {task.recurrence_text}">
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" />
@@ -119,7 +120,7 @@
<span
class="badge"
class:all-done={task.subtask_done === task.subtask_count}
title="{task.subtask_done} af {task.subtask_count} undertasks er lukket"
title={t('task.subtasks', { done: task.subtask_done, total: task.subtask_count })}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 6h11" />
@@ -133,7 +134,7 @@
</span>
{/if}
{#if task.attachment_count}
<span class="badge" title="{task.attachment_count} vedhæftet">
<span class="badge" title={t('task.attachments', { n: task.attachment_count })}>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
+79
View File
@@ -0,0 +1,79 @@
/**
* The interface's language.
*
* Every string a person reads used to be written into the component that drew it,
* in Danish. The account already had a `locale` — but it only chose which grammar
* quick add parsed a line with, so setting it to English changed nothing anybody
* could see. This is the other half of that field.
*
* Danish is the fallback and the source of truth. It is the language the app was
* written in, and it is the one whose phrasing was argued over; English is a
* translation of it. A key missing from English therefore falls through to Danish
* rather than to the key, because a Danish sentence in an English screen is
* legible and `settings.notifications.title` is not.
*
* `t()` reads `i18n.locale`, which is `$state`, so every component that calls it
* re-renders when the account's language changes. That is what makes the setting
* take effect without a reload — and it is why this file is `.svelte.js`.
*/
import { da } from './locales/da.js';
import { en } from './locales/en.js';
const DICTIONARIES = { da, en };
class I18n {
/**
* Starts Danish and is set from the account as soon as the session loads.
*
* Not read from localStorage on the way in. The language belongs to the
* account — the same person reading in Danish on their laptop wants Danish on
* their phone — unlike the sidebar's width, which belongs to the screen.
*/
locale = $state('da');
set(locale) {
this.locale = locale in DICTIONARIES ? locale : 'da';
}
}
export const i18n = new I18n();
/**
* The string for `key`, with `{name}` placeholders filled from `params`.
*
* Placeholders rather than string concatenation, because word order is not a
* constant across languages: "3 tasks left" and "3 opgaver tilbage" happen to
* agree, and "shared with me" and "delt med mig" do not.
*/
export function t(key, params) {
const dict = DICTIONARIES[i18n.locale] ?? da;
const raw = dict[key] ?? da[key] ?? key;
if (!params) return raw;
return raw.replace(/\{(\w+)\}/g, (whole, name) => (name in params ? String(params[name]) : whole));
}
/**
* The plural form for `n`.
*
* Both languages here have exactly two forms, so this takes the two rather than
* pulling in a plural-rules engine for a case neither language has. The count is
* substituted as `{n}`, so "1 opgave" and "3 opgaver" are both one lookup.
*/
export function plural(n, oneKey, manyKey) {
return t(n === 1 ? oneKey : manyKey, { n });
}
/**
* The BCP 47 tag for `Intl` — dates, times and numbers.
*
* The whole reason this exists: `toLocaleDateString('da-DK', …)` was written into
* a dozen components, so an English interface would still have said "24. aug." and
* started its weeks wherever da-DK does. One place to ask, so there is one place
* to be wrong.
*
* `en-GB` rather than `en-US`: the app is day-month everywhere else, weeks start
* on Monday, and an American date format inside a European layout reads as a bug.
*/
export function tag() {
return i18n.locale === 'en' ? 'en-GB' : 'da-DK';
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Danish, and the source of truth.
*
* This is the language the app was written in and the one whose phrasing was
* argued over; `en.js` is a translation of it. A key missing from English falls
* through to here rather than to the key itself, because a Danish sentence in an
* English screen can be read and `settings.notifications.title` cannot.
*
* Keys are grouped by where they are read, not by what they say. Two screens that
* happen to use the word "Slet" get a key each: they are two decisions, and the
* day one of them needs to say "Fjern" instead, a shared key is a rewrite of both.
*
* `{name}` placeholders are filled by `t()`. They exist because word order is not
* a constant across languages — "3 tasks left" and "3 opgaver tilbage" happen to
* agree, "shared with me" and "delt med mig" do not.
*/
export const da = {
// --- the shell ---------------------------------------------------------------
'nav.today': 'I dag',
'nav.upcoming': 'Kommende',
'nav.delegated': 'Venter på andre',
'nav.inbox': 'Indbakke',
'nav.settings': 'Indstillinger',
'nav.signOut': 'Log ud',
'nav.main': 'Hovedmenu',
'nav.search': 'Søg',
'nav.searchLong': 'Søg i opgaver og projekter',
'nav.noResults': 'Ingen resultater.',
'nav.showSidebar': 'Vis sidebjælken',
'nav.hideSidebar': 'Skjul sidebjælken',
'nav.showMenu': 'Vis menu',
'nav.sidebarWidth': 'Bredde på sidebjælken',
'nav.toggleTheme': 'Skift mellem lyst og mørkt tema',
'nav.offline': 'Offline',
'nav.offlineHint': 'Ændringer fra andre vises ikke lige nu',
'nav.doItToday': 'Gør det i dag',
// --- the sidebar's own sections ------------------------------------------------
'sidebar.projects': 'Projekter',
'sidebar.shared': 'Delt med mig',
'sidebar.filters': 'Filtre',
'sidebar.labels': 'Etiketter',
'sidebar.newProject': 'Nyt projekt',
'sidebar.newGroup': 'Ny gruppe',
'sidebar.projectName': 'Projektnavn',
'sidebar.groupName': 'Gruppens navn',
'sidebar.noProjects': 'Ingen projekter endnu.',
'sidebar.emptyGroup': 'Tom — træk et projekt herop.',
'sidebar.rename': 'Omdøb',
'sidebar.delete': 'Slet',
'sidebar.fold': 'Fold {name} sammen',
'sidebar.unfold': 'Fold {name} ud',
'sidebar.deleteGroup': 'Slet gruppen "{name}"?',
'sidebar.deleteGroupOne': 'Projektet i den bliver, men ligger bagefter uden gruppe.',
'sidebar.deleteGroupMany': 'De {n} projekter bliver, men ligger bagefter uden gruppe.',
// --- a task in a list ----------------------------------------------------------
'task.add': 'Tilføj',
'task.new': 'Ny opgave',
'task.placeholder': 'Tilføj en opgave — prøv “betal moms i morgen kl 10 p1 #Firma”',
'task.complete': 'Markér som færdig',
'task.reopen': 'Genåbn opgave',
'task.today': 'I dag',
'task.tomorrow': 'I morgen',
'task.yesterday': 'I går',
'task.overdue': '{n} dage forsinket',
'task.repeats': 'Gentages {rule}',
'task.assignee': 'Ansvarlig: {name}',
'task.subtasks': '{done} af {total} undertasks er lukket',
'task.attachments': '{n} vedhæftet',
'task.priority': 'Prioritet {n}',
'task.noPriority': 'Ingen prioritet',
// --- a project group -----------------------------------------------------------
'group.colorOf': 'Farve på {name}'
};
+72
View File
@@ -0,0 +1,72 @@
/**
* English, translated from `da.js`.
*
* Danish is the source: it is the language the app was written in, and the one
* whose phrasing was argued over. A key missing here falls through to Danish
* rather than to the key itself — a Danish sentence in an English screen can be
* read, and `settings.notifications.title` cannot.
*
* Dates are not here. They go through `tag()`, which returns `en-GB` for this
* locale: the app is day-month everywhere, weeks start on Monday, and an American
* date format inside a European layout reads as a bug.
*/
export const en = {
// --- the shell ---------------------------------------------------------------
'nav.today': 'Today',
'nav.upcoming': 'Upcoming',
'nav.delegated': 'Waiting on others',
'nav.inbox': 'Inbox',
'nav.settings': 'Settings',
'nav.signOut': 'Sign out',
'nav.main': 'Main menu',
'nav.search': 'Search',
'nav.searchLong': 'Search tasks and projects',
'nav.noResults': 'Nothing found.',
'nav.showSidebar': 'Show the sidebar',
'nav.hideSidebar': 'Hide the sidebar',
'nav.showMenu': 'Show menu',
'nav.sidebarWidth': 'Sidebar width',
'nav.toggleTheme': 'Switch between light and dark',
'nav.offline': 'Offline',
'nav.offlineHint': 'Changes from other people are not showing right now',
'nav.doItToday': 'Do it today',
// --- the sidebar's own sections ------------------------------------------------
'sidebar.projects': 'Projects',
'sidebar.shared': 'Shared with me',
'sidebar.filters': 'Filters',
'sidebar.labels': 'Labels',
'sidebar.newProject': 'New project',
'sidebar.newGroup': 'New group',
'sidebar.projectName': 'Project name',
'sidebar.groupName': "The group's name",
'sidebar.noProjects': 'No projects yet.',
'sidebar.emptyGroup': 'Empty — drag a project up here.',
'sidebar.rename': 'Rename',
'sidebar.delete': 'Delete',
'sidebar.fold': 'Collapse {name}',
'sidebar.unfold': 'Expand {name}',
'sidebar.deleteGroup': 'Delete the group "{name}"?',
'sidebar.deleteGroupOne': 'The project in it stays, and ends up without a group.',
'sidebar.deleteGroupMany': 'The {n} projects stay, and end up without a group.',
// --- a task in a list ----------------------------------------------------------
'task.add': 'Add',
'task.new': 'New task',
'task.placeholder': 'Add a task — try “pay VAT tomorrow at 10 p1 #Company”',
'task.complete': 'Mark as done',
'task.reopen': 'Reopen task',
'task.today': 'Today',
'task.tomorrow': 'Tomorrow',
'task.yesterday': 'Yesterday',
'task.overdue': '{n} days overdue',
'task.repeats': 'Repeats {rule}',
'task.assignee': 'Assigned to {name}',
'task.subtasks': '{done} of {total} sub-tasks are done',
'task.attachments': '{n} attached',
'task.priority': 'Priority {n}',
'task.noPriority': 'No priority',
// --- a project group -----------------------------------------------------------
'group.colorOf': 'Colour of {name}'
};
+5 -4
View File
@@ -7,6 +7,7 @@
import CommandPalette from '$lib/components/CommandPalette.svelte';
import SignIn from '$lib/components/SignIn.svelte';
import TaskDetail from '$lib/components/TaskDetail.svelte';
import { t } from '$lib/i18n.svelte.js';
let { children } = $props();
@@ -106,9 +107,9 @@
<button
class="fold"
onclick={() => sidebar.toggle()}
aria-label={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
aria-label={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')}
aria-pressed={sidebar.collapsed}
title={sidebar.collapsed ? 'Vis sidebjælken' : 'Skjul sidebjælken'}
title={sidebar.collapsed ? t('nav.showSidebar') : t('nav.hideSidebar')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="3" y="4" width="18" height="16" rx="2" />
@@ -117,14 +118,14 @@
</button>
<button class="search" onclick={() => (paletteOpen = true)}>
<span>Søg</span>
<span>{t('nav.search')}</span>
<kbd>⌘K</kbd>
</button>
<button
class="theme"
onclick={() => theme.toggle()}
aria-label="Skift mellem lyst og mørkt tema"
aria-label={t('nav.toggleTheme')}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z" />