fix(web): otte steder hvor grænsefladen blev ved med at være dansk

Skærmbillederne skulle tages på engelsk, og det var dét, der fandt dem:
en konto sat til engelsk fik stadig "Forsinket" over de forsinkede opgaver,
"Uden sektion" over den første kolonne på tavlen, "Aldrig" i gentagelses-
menuen, "hver dag" på rækken, "til instansen · inviteret af" på invitationen,
"3 projekter" på kontoen, og hele historikken — nitten hændelser, alle på
dansk. En funktion, der kun virker på det ene sprog, er en funktion, der er i
stykker.

Gentagelsen er den eneste, der ikke bare manglede en nøgle. Serveren skriver
`recurrence_text` på dansk og skal blive ved med det: én besked går over
WebSocket til alle i projektet på én gang, og de læser ikke det samme sprog.
Så de regler, der har et navn, får det i when.js — på den side, der ved, hvis
skærm det er — og alt mere specifikt falder tilbage til serverens ord.

Mellemrummet i historikken er skrevet ud. Svelte fjerner det, der åbner en
blok, så "inviterede" og adressen løb sammen til ét ord.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kristian Wind
2026-08-20 07:23:42 +02:00
co-authored by Claude Opus 5
parent 4b5f3f0864
commit dce8800f93
12 changed files with 158 additions and 55 deletions
+1 -1
View File
@@ -64,7 +64,7 @@
// The unsectioned column comes first, as it does in the list view: it is where // The unsectioned column comes first, as it does in the list view: it is where
// quick add puts things, so it is where new work appears. // quick add puts things, so it is where new work appears.
let columns = $derived([ let columns = $derived([
{ id: '', name: 'Uden sektion' }, { id: '', name: t('project.noSection') },
...sections.map((s) => ({ id: s.id, name: s.name })) ...sections.map((s) => ({ id: s.id, name: s.name }))
]); ]);
+3 -13
View File
@@ -14,6 +14,7 @@
import { app, projectName } from '$lib/stores.svelte.js'; import { app, projectName } from '$lib/stores.svelte.js';
import { focusOnMount } from '$lib/focus.js'; import { focusOnMount } from '$lib/focus.js';
import { t } from '$lib/i18n.svelte.js'; import { t } from '$lib/i18n.svelte.js';
import { REPEATS } from '$lib/when.js';
let { task, onclose } = $props(); let { task, onclose } = $props();
@@ -29,17 +30,6 @@
/** Who the task can be given to. Only ever more than one on a shared project. */ /** Who the task can be given to. Only ever more than one on a shared project. */
let members = $state([]); let members = $state([]);
// The rules worth a menu entry. Anything more specific is typed into the
// title, where the parser understands far more than a list ever could.
const REPEATS = [
{ rule: '', label: 'Aldrig' },
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
{ rule: 'FREQ=MONTHLY', label: t('detail.monthly') },
{ rule: 'FREQ=YEARLY', label: t('detail.yearly') }
];
let subtasks = $state([]); let subtasks = $state([]);
let comments = $state([]); let comments = $state([]);
let attachments = $state([]); let attachments = $state([]);
@@ -452,13 +442,13 @@
<label for="repeat">{t('detail.repeats')}</label> <label for="repeat">{t('detail.repeats')}</label>
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}> <select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
{#each REPEATS as option (option.rule)} {#each REPEATS as option (option.rule)}
<option value={option.rule}>{option.label}</option> <option value={option.rule}>{t(option.label)}</option>
{/each} {/each}
<!-- A rule typed as "hver anden tirsdag" has no entry in the list; <!-- A rule typed as "hver anden tirsdag" has no entry in the list;
showing it rather than snapping to the nearest preset is the showing it rather than snapping to the nearest preset is the
difference between a menu and a menu that quietly edits. --> difference between a menu and a menu that quietly edits. -->
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)} {#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option> <option value={recurrence}>{task.recurrence_text ?? t('detail.asWritten')}</option>
{/if} {/if}
</select> </select>
</div> </div>
+7 -4
View File
@@ -10,9 +10,12 @@
*/ */
import { app } from '$lib/stores.svelte.js'; import { app } from '$lib/stores.svelte.js';
import { t, tag } from '$lib/i18n.svelte.js'; import { t, tag } from '$lib/i18n.svelte.js';
import { repeatLabel } from '$lib/when.js';
let { task, onedit } = $props(); let { task, onedit } = $props();
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
let leaving = $state(false); let leaving = $state(false);
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p })); const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
@@ -84,7 +87,7 @@
<span class="description">{task.description}</span> <span class="description">{task.description}</span>
{/if} {/if}
{#if assignee || due || task.labels?.length || task.recurrence_text || task.subtask_count || task.attachment_count} {#if assignee || due || task.labels?.length || repeats || task.subtask_count || task.attachment_count}
<span class="meta"> <span class="meta">
{#if assignee} {#if assignee}
<!-- First in the row, because "who" changes what the rest of the line <!-- First in the row, because "who" changes what the rest of the line
@@ -102,15 +105,15 @@
{due.text}{#if time}&nbsp;{time}{/if} {due.text}{#if time}&nbsp;{time}{/if}
</span> </span>
{/if} {/if}
{#if task.recurrence_text} {#if repeats}
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}> <span class="repeat" title={t('task.repeats', { rule: repeats })}>
<svg viewBox="0 0 24 24" aria-hidden="true"> <svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M17 2l4 4-4 4" /> <path d="M17 2l4 4-4 4" />
<path d="M3 11V9a4 4 0 014-4h14" /> <path d="M3 11V9a4 4 0 014-4h14" />
<path d="M7 22l-4-4 4-4" /> <path d="M7 22l-4-4 4-4" />
<path d="M21 13v2a4 4 0 01-4 4H3" /> <path d="M21 13v2a4 4 0 01-4 4H3" />
</svg> </svg>
{task.recurrence_text} {repeats}
</span> </span>
{/if} {/if}
<!-- What is underneath, said as a fraction rather than a total: "3" tells <!-- What is underneath, said as a fraction rather than a total: "3" tells
+32 -24
View File
@@ -1,40 +1,48 @@
/** /**
* What each recorded activity event is called. * What each recorded activity event is called.
* *
* The keys are the server's and they are stable; this is the only place they * The keys are the server's and they are stable; this is the only place they get a
* become Danish. It lives in its own module because two surfaces read the same log * name. It lives in its own module because two surfaces read the same log now — a
* now — a project's own history panel, and the administrator's instance-wide audit * project's own history panel, and the administrator's instance-wide audit page —
* page — and two copies of nineteen strings is two places for them to drift apart. * and two copies of twenty strings is two places for them to drift apart.
*
* The names themselves are in the dictionaries rather than here. They were written
* out in Danish, which made the whole of both history pages Danish for somebody
* reading the rest of the app in English.
* *
* An unknown key falls through to the key itself rather than being hidden: a build * An unknown key falls through to the key itself rather than being hidden: a build
* that records something this table has not learned yet should still show that it * that records something this table has not learned yet should still show that it
* happened. * happened.
*/ */
import { t } from './i18n.svelte.js';
export const EVENTS = { export const EVENTS = {
'project.created': 'oprettede projektet', 'project.created': 'event.projectCreated',
'project.updated': 'ændrede projektet', 'project.updated': 'event.projectUpdated',
'project.deleted': 'slettede projektet', 'project.deleted': 'event.projectDeleted',
'project.imported': 'importerede projektet', 'project.imported': 'event.projectImported',
'section.created': 'oprettede en sektion', 'section.created': 'event.sectionCreated',
'section.updated': 'ændrede en sektion', 'section.updated': 'event.sectionUpdated',
'section.deleted': 'slettede en sektion', 'section.deleted': 'event.sectionDeleted',
'member.invited': 'inviterede', 'member.invited': 'event.memberInvited',
'member.added': 'tilføjede', 'member.added': 'event.memberAdded',
'member.removed': 'fjernede', 'member.removed': 'event.memberRemoved',
'member.role_changed': 'ændrede rollen for', 'member.role_changed': 'event.memberRoleChanged',
'task.created': 'oprettede en opgave', 'task.created': 'event.taskCreated',
'task.updated': 'ændrede en opgave', 'task.updated': 'event.taskUpdated',
'task.completed': 'lukkede en opgave', 'task.completed': 'event.taskCompleted',
'task.reopened': 'genåbnede en opgave', 'task.reopened': 'event.taskReopened',
'task.moved': 'flyttede en opgave', 'task.moved': 'event.taskMoved',
'task.deleted': 'slettede en opgave', 'task.deleted': 'event.taskDeleted',
'task.split': 'delte en opgave op', 'task.split': 'event.taskSplit',
'comment.created': 'skrev en kommentar' 'comment.created': 'event.commentCreated',
'attachment.added': 'event.attachmentAdded'
}; };
/** The name of an event, or the raw key when it is one this build does not know. */ /** The name of an event, or the raw key when it is one this build does not know. */
export function eventName(event) { export function eventName(event) {
return EVENTS[event] ?? event; const key = EVENTS[event];
return key ? t(key) : event;
} }
/** The bit of context an entry carries, when it has one worth reading. */ /** The bit of context an entry carries, when it has one worth reading. */
+33
View File
@@ -303,6 +303,9 @@ export const da = {
'users.taskOne': 'opgave', 'users.taskOne': 'opgave',
'users.taskMany': 'opgaver', 'users.taskMany': 'opgaver',
'users.never': 'aldrig set', 'users.never': 'aldrig set',
'users.toProject': 'til {name}',
'users.toInstance': 'til instansen',
'users.invitedBy': 'inviteret af {name}',
// --- settings: history and errors ----------------------------------------------- // --- settings: history and errors -----------------------------------------------
'history.title': 'Historik', 'history.title': 'Historik',
@@ -318,6 +321,30 @@ export const da = {
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.', 'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
'history.nothing': 'Der er ikke registreret noget endnu.', 'history.nothing': 'Der er ikke registreret noget endnu.',
'history.deletedAccount': 'En slettet konto', 'history.deletedAccount': 'En slettet konto',
// Hvad hver hændelse i loggen hedder. Nøglerne er serverens og ligger i
// events.js; her står de ord, de bliver til.
'event.projectCreated': 'oprettede projektet',
'event.projectUpdated': 'ændrede projektet',
'event.projectDeleted': 'slettede projektet',
'event.projectImported': 'importerede projektet',
'event.sectionCreated': 'oprettede en sektion',
'event.sectionUpdated': 'ændrede en sektion',
'event.sectionDeleted': 'slettede en sektion',
'event.memberInvited': 'inviterede',
'event.memberAdded': 'tilføjede',
'event.memberRemoved': 'fjernede',
'event.memberRoleChanged': 'ændrede rollen for',
'event.taskCreated': 'oprettede en opgave',
'event.taskUpdated': 'ændrede en opgave',
'event.taskCompleted': 'lukkede en opgave',
'event.taskReopened': 'genåbnede en opgave',
'event.taskMoved': 'flyttede en opgave',
'event.taskDeleted': 'slettede en opgave',
'event.taskSplit': 'delte en opgave op',
'event.commentCreated': 'skrev en kommentar',
'event.attachmentAdded': 'vedhæftede en fil',
'errors.title': 'Serverfejl', 'errors.title': 'Serverfejl',
'errors.hint': 'errors.hint':
'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.', 'Hver 500, API’et har svaret, med hvad handleren var i gang med og fejlen den ramte. Gemt i databasen, ikke kun i loggen — containerens log starter forfra ved hver genstart, så forklaringen er som regel væk, når nogen kigger. Ryddes efter 30 dage.',
@@ -497,8 +524,13 @@ export const da = {
'detail.labelsPlaceholder': 'adskilt af komma', 'detail.labelsPlaceholder': 'adskilt af komma',
'detail.project': 'Projekt', 'detail.project': 'Projekt',
'detail.repeats': 'Gentages', 'detail.repeats': 'Gentages',
'detail.never': 'Aldrig',
'detail.daily': 'Hver dag',
'detail.weekly': 'Hver uge',
'detail.fortnightly': 'Hver anden uge',
'detail.monthly': 'Hver måned', 'detail.monthly': 'Hver måned',
'detail.yearly': 'Hvert år', 'detail.yearly': 'Hvert år',
'detail.asWritten': 'Som skrevet',
'detail.assignee': 'Ansvarlig', 'detail.assignee': 'Ansvarlig',
'detail.subtasks': 'Undertasks', 'detail.subtasks': 'Undertasks',
'detail.addSubtask': 'Tilføj en undertask', 'detail.addSubtask': 'Tilføj en undertask',
@@ -688,6 +720,7 @@ export const da = {
'push.upToDate': 'Ingen nyere version fundet.', 'push.upToDate': 'Ingen nyere version fundet.',
'view.nothingMoreToday': 'Ikke mere i dag.', 'view.nothingMoreToday': 'Ikke mere i dag.',
'view.overdue': 'Forsinket',
'project.roleFor': 'Rolle for {name}', 'project.roleFor': 'Rolle for {name}',
'project.more': 'Flere handlinger', 'project.more': 'Flere handlinger',
+33
View File
@@ -298,6 +298,9 @@ export const en = {
'users.taskOne': 'task', 'users.taskOne': 'task',
'users.taskMany': 'tasks', 'users.taskMany': 'tasks',
'users.never': 'never seen', 'users.never': 'never seen',
'users.toProject': 'to {name}',
'users.toInstance': 'to the instance',
'users.invitedBy': 'invited by {name}',
// --- settings: history and errors ----------------------------------------------- // --- settings: history and errors -----------------------------------------------
'history.title': 'History', 'history.title': 'History',
@@ -313,6 +316,30 @@ export const en = {
'history.noMatch': 'Nothing matches the filters.', 'history.noMatch': 'Nothing matches the filters.',
'history.nothing': 'Nothing has been recorded yet.', 'history.nothing': 'Nothing has been recorded yet.',
'history.deletedAccount': 'A deleted account', 'history.deletedAccount': 'A deleted account',
// What each recorded event is called. The keys are the server's and live in
// events.js; these are the words they become.
'event.projectCreated': 'created the project',
'event.projectUpdated': 'changed the project',
'event.projectDeleted': 'deleted the project',
'event.projectImported': 'imported the project',
'event.sectionCreated': 'created a section',
'event.sectionUpdated': 'changed a section',
'event.sectionDeleted': 'deleted a section',
'event.memberInvited': 'invited',
'event.memberAdded': 'added',
'event.memberRemoved': 'removed',
'event.memberRoleChanged': 'changed the role of',
'event.taskCreated': 'created a task',
'event.taskUpdated': 'changed a task',
'event.taskCompleted': 'closed a task',
'event.taskReopened': 'reopened a task',
'event.taskMoved': 'moved a task',
'event.taskDeleted': 'deleted a task',
'event.taskSplit': 'split a task up',
'event.commentCreated': 'wrote a comment',
'event.attachmentAdded': 'attached a file',
'errors.title': 'Server errors', 'errors.title': 'Server errors',
'errors.hint': 'errors.hint':
'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.', 'Every 500 the API has answered, with what the handler was attempting and the error it hit. Written to the database rather than only to the log — a container’s log starts over on every restart, so the explanation is usually gone by the time anybody looks. Kept for 30 days.',
@@ -493,8 +520,13 @@ export const en = {
'detail.labelsPlaceholder': 'separated by commas', 'detail.labelsPlaceholder': 'separated by commas',
'detail.project': 'Project', 'detail.project': 'Project',
'detail.repeats': 'Repeats', 'detail.repeats': 'Repeats',
'detail.never': 'Never',
'detail.daily': 'Every day',
'detail.weekly': 'Every week',
'detail.fortnightly': 'Every other week',
'detail.monthly': 'Every month', 'detail.monthly': 'Every month',
'detail.yearly': 'Every year', 'detail.yearly': 'Every year',
'detail.asWritten': 'As written',
'detail.assignee': 'Assigned to', 'detail.assignee': 'Assigned to',
'detail.subtasks': 'Sub-tasks', 'detail.subtasks': 'Sub-tasks',
'detail.addSubtask': 'Add a sub-task', 'detail.addSubtask': 'Add a sub-task',
@@ -683,6 +715,7 @@ export const en = {
'push.upToDate': 'No newer version found.', 'push.upToDate': 'No newer version found.',
'view.nothingMoreToday': 'Nothing more today.', 'view.nothingMoreToday': 'Nothing more today.',
'view.overdue': 'Overdue',
'project.roleFor': 'Role for {name}', 'project.roleFor': 'Role for {name}',
'project.more': 'More actions', 'project.more': 'More actions',
+29
View File
@@ -49,3 +49,32 @@ export function fullDate(iso) {
year: 'numeric' year: 'numeric'
}); });
} }
/**
* The repeat rules worth a menu entry, and what to call them.
*
* Here rather than in the drawer that draws the menu, because the row draws the
* same rule as a badge and the two had already disagreed: the menu said "Hver
* dag" in both languages, and the badge said whatever the server said.
*
* The server sends `recurrence_text` with every task and sends it in Danish. It
* has to: one payload goes over the WebSocket to everybody in the project at
* once, and they do not all read the same language. So the rules that have a name
* are named here, on the side that knows whose screen this is, and anything more
* specific — "hver anden tirsdag", typed into the title — falls back to what the
* server wrote.
*/
export const REPEATS = [
{ rule: '', label: 'detail.never' },
{ rule: 'FREQ=DAILY', label: 'detail.daily' },
{ rule: 'FREQ=WEEKLY', label: 'detail.weekly' },
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'detail.fortnightly' },
{ rule: 'FREQ=MONTHLY', label: 'detail.monthly' },
{ rule: 'FREQ=YEARLY', label: 'detail.yearly' }
];
/** What a repeating task says on its row. */
export function repeatLabel(rule, fallback = '') {
const known = REPEATS.find((option) => option.rule && option.rule === rule);
return known ? t(known.label) : fallback;
}
+2 -2
View File
@@ -56,7 +56,7 @@
{#if liveOverdue.length} {#if liveOverdue.length}
<section> <section>
<h2 class="overdue">Forsinket</h2> <h2 class="overdue">{t('view.overdue')}</h2>
{#each liveOverdue as task (task.id)} {#each liveOverdue as task (task.id)}
<TaskRow {task} /> <TaskRow {task} />
{/each} {/each}
@@ -65,7 +65,7 @@
<section> <section>
{#if liveOverdue.length} {#if liveOverdue.length}
<h2>I dag</h2> <h2>{t('nav.today')}</h2>
{/if} {/if}
{#each liveToday as task (task.id)} {#each liveToday as task (task.id)}
<TaskRow {task} /> <TaskRow {task} />
@@ -177,9 +177,11 @@
<div class="what"> <div class="what">
<span class="primary-line">{pending.email}</span> <span class="primary-line">{pending.email}</span>
<span class="secondary"> <span class="secondary">
{pending.project_name ? `til ${pending.project_name}` : 'til instansen'} {pending.project_name
? t('users.toProject', { name: pending.project_name })
: t('users.toInstance')}
· {t('tokens.expiresOn', { when: expires(pending.expires_at) })} · {t('tokens.expiresOn', { when: expires(pending.expires_at) })}
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if} {#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
</span> </span>
</div> </div>
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button> <button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
@@ -209,7 +211,8 @@
</span> </span>
<span class="secondary"> <span class="secondary">
{user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })} {user.email} · {ago(user.last_seen_at, { never: 'when.neverSignedIn' })}
{#if user.project_count}· {count(user.project_count, 'projekt', 'projekter')}{/if} {#if user.project_count}·
{count(user.project_count, 'users.projectOne', 'users.projectMany')}{/if}
</span> </span>
</div> </div>
@@ -159,9 +159,12 @@
<span class="who" class:gone={!entry.user_name} <span class="who" class:gone={!entry.user_name}
>{entry.user_name || t('history.deletedAccount')}</span >{entry.user_name || t('history.deletedAccount')}</span
> >
<!-- The space is written out. Svelte drops the whitespace that
opens a block, so "invited" and the address ran together. -->
<span class="event" <span class="event"
>{eventName(entry.event)}{#if eventDetail(entry)} >{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
<span class="detail">{eventDetail(entry)}</span>{/if}</span >{eventDetail(entry)}</span
>{/if}</span
> >
</span> </span>
<span class="secondary">{entry.project_name}</span> <span class="secondary">{entry.project_name}</span>
+3 -2
View File
@@ -581,8 +581,9 @@
>{entry.user_name || t('history.deletedAccount')}</span >{entry.user_name || t('history.deletedAccount')}</span
> >
<span class="what"> <span class="what">
{eventName(entry.event)}{#if eventDetail(entry)} {eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
<span class="detail">{eventDetail(entry)}</span>{/if} >{eventDetail(entry)}</span
>{/if}
</span> </span>
<span class="when">{when(entry.created_at)}</span> <span class="when">{when(entry.created_at)}</span>
</li> </li>
+4 -4
View File
@@ -20,7 +20,7 @@
import TaskRow from '$lib/components/TaskRow.svelte'; import TaskRow from '$lib/components/TaskRow.svelte';
import QuickAdd from '$lib/components/QuickAdd.svelte'; import QuickAdd from '$lib/components/QuickAdd.svelte';
import CalendarView from '$lib/components/CalendarView.svelte'; import CalendarView from '$lib/components/CalendarView.svelte';
import { t } from '$lib/i18n.svelte.js'; import { t, tag } from '$lib/i18n.svelte.js';
let days = $state([]); let days = $state([]);
@@ -48,9 +48,9 @@
const today = new Date(new Date().toDateString()); const today = new Date(new Date().toDateString());
const diff = Math.round((day - today) / 86400000); const diff = Math.round((day - today) / 86400000);
if (diff === 0) return 'I dag'; if (diff === 0) return t('task.today');
if (diff === 1) return 'I morgen'; if (diff === 1) return t('task.tomorrow');
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' }); return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
} }
// Reading tasks back out of the store keeps a completed row disappearing // Reading tasks back out of the store keeps a completed row disappearing