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 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4b5f3f0864
commit
dce8800f93
@@ -64,7 +64,7 @@
|
||||
// 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.
|
||||
let columns = $derived([
|
||||
{ id: '', name: 'Uden sektion' },
|
||||
{ id: '', name: t('project.noSection') },
|
||||
...sections.map((s) => ({ id: s.id, name: s.name }))
|
||||
]);
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { app, projectName } from '$lib/stores.svelte.js';
|
||||
import { focusOnMount } from '$lib/focus.js';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
import { REPEATS } from '$lib/when.js';
|
||||
|
||||
let { task, onclose } = $props();
|
||||
|
||||
@@ -29,17 +30,6 @@
|
||||
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||
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 comments = $state([]);
|
||||
let attachments = $state([]);
|
||||
@@ -452,13 +442,13 @@
|
||||
<label for="repeat">{t('detail.repeats')}</label>
|
||||
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||
{#each REPEATS as option (option.rule)}
|
||||
<option value={option.rule}>{option.label}</option>
|
||||
<option value={option.rule}>{t(option.label)}</option>
|
||||
{/each}
|
||||
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||
showing it rather than snapping to the nearest preset is the
|
||||
difference between a menu and a menu that quietly edits. -->
|
||||
{#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}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -10,9 +10,12 @@
|
||||
*/
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
import { repeatLabel } from '$lib/when.js';
|
||||
|
||||
let { task, onedit } = $props();
|
||||
|
||||
let repeats = $derived(repeatLabel(task.recurrence_rule, task.recurrence_text));
|
||||
|
||||
let leaving = $state(false);
|
||||
|
||||
const priorityLabel = (p) => (p === 4 ? t('task.noPriority') : t('task.priority', { n: p }));
|
||||
@@ -84,7 +87,7 @@
|
||||
<span class="description">{task.description}</span>
|
||||
{/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">
|
||||
{#if assignee}
|
||||
<!-- First in the row, because "who" changes what the rest of the line
|
||||
@@ -102,15 +105,15 @@
|
||||
{due.text}{#if time} {time}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if task.recurrence_text}
|
||||
<span class="repeat" title={t('task.repeats', { rule: task.recurrence_text })}>
|
||||
{#if repeats}
|
||||
<span class="repeat" title={t('task.repeats', { rule: repeats })}>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M17 2l4 4-4 4" />
|
||||
<path d="M3 11V9a4 4 0 014-4h14" />
|
||||
<path d="M7 22l-4-4 4-4" />
|
||||
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
||||
</svg>
|
||||
{task.recurrence_text}
|
||||
{repeats}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- What is underneath, said as a fraction rather than a total: "3" tells
|
||||
|
||||
+32
-24
@@ -1,40 +1,48 @@
|
||||
/**
|
||||
* What each recorded activity event is called.
|
||||
*
|
||||
* The keys are the server's and they are stable; this is the only place they
|
||||
* become Danish. It lives in its own module because two surfaces read the same log
|
||||
* now — a project's own history panel, and the administrator's instance-wide audit
|
||||
* page — and two copies of nineteen strings is two places for them to drift apart.
|
||||
* The keys are the server's and they are stable; this is the only place they get a
|
||||
* name. It lives in its own module because two surfaces read the same log now — a
|
||||
* project's own history panel, and the administrator's instance-wide audit page —
|
||||
* 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
|
||||
* that records something this table has not learned yet should still show that it
|
||||
* happened.
|
||||
*/
|
||||
import { t } from './i18n.svelte.js';
|
||||
|
||||
export const EVENTS = {
|
||||
'project.created': 'oprettede projektet',
|
||||
'project.updated': 'ændrede projektet',
|
||||
'project.deleted': 'slettede projektet',
|
||||
'project.imported': 'importerede projektet',
|
||||
'section.created': 'oprettede en sektion',
|
||||
'section.updated': 'ændrede en sektion',
|
||||
'section.deleted': 'slettede en sektion',
|
||||
'member.invited': 'inviterede',
|
||||
'member.added': 'tilføjede',
|
||||
'member.removed': 'fjernede',
|
||||
'member.role_changed': 'ændrede rollen for',
|
||||
'task.created': 'oprettede en opgave',
|
||||
'task.updated': 'ændrede en opgave',
|
||||
'task.completed': 'lukkede en opgave',
|
||||
'task.reopened': 'genåbnede en opgave',
|
||||
'task.moved': 'flyttede en opgave',
|
||||
'task.deleted': 'slettede en opgave',
|
||||
'task.split': 'delte en opgave op',
|
||||
'comment.created': 'skrev en kommentar'
|
||||
'project.created': 'event.projectCreated',
|
||||
'project.updated': 'event.projectUpdated',
|
||||
'project.deleted': 'event.projectDeleted',
|
||||
'project.imported': 'event.projectImported',
|
||||
'section.created': 'event.sectionCreated',
|
||||
'section.updated': 'event.sectionUpdated',
|
||||
'section.deleted': 'event.sectionDeleted',
|
||||
'member.invited': 'event.memberInvited',
|
||||
'member.added': 'event.memberAdded',
|
||||
'member.removed': 'event.memberRemoved',
|
||||
'member.role_changed': 'event.memberRoleChanged',
|
||||
'task.created': 'event.taskCreated',
|
||||
'task.updated': 'event.taskUpdated',
|
||||
'task.completed': 'event.taskCompleted',
|
||||
'task.reopened': 'event.taskReopened',
|
||||
'task.moved': 'event.taskMoved',
|
||||
'task.deleted': 'event.taskDeleted',
|
||||
'task.split': 'event.taskSplit',
|
||||
'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. */
|
||||
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. */
|
||||
|
||||
@@ -303,6 +303,9 @@ export const da = {
|
||||
'users.taskOne': 'opgave',
|
||||
'users.taskMany': 'opgaver',
|
||||
'users.never': 'aldrig set',
|
||||
'users.toProject': 'til {name}',
|
||||
'users.toInstance': 'til instansen',
|
||||
'users.invitedBy': 'inviteret af {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'Historik',
|
||||
@@ -318,6 +321,30 @@ export const da = {
|
||||
'history.noMatch': 'Ingen hændelser matcher de valgte filtre.',
|
||||
'history.nothing': 'Der er ikke registreret noget endnu.',
|
||||
'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.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.',
|
||||
@@ -497,8 +524,13 @@ export const da = {
|
||||
'detail.labelsPlaceholder': 'adskilt af komma',
|
||||
'detail.project': 'Projekt',
|
||||
'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.yearly': 'Hvert år',
|
||||
'detail.asWritten': 'Som skrevet',
|
||||
'detail.assignee': 'Ansvarlig',
|
||||
'detail.subtasks': 'Undertasks',
|
||||
'detail.addSubtask': 'Tilføj en undertask',
|
||||
@@ -688,6 +720,7 @@ export const da = {
|
||||
'push.upToDate': 'Ingen nyere version fundet.',
|
||||
|
||||
'view.nothingMoreToday': 'Ikke mere i dag.',
|
||||
'view.overdue': 'Forsinket',
|
||||
'project.roleFor': 'Rolle for {name}',
|
||||
|
||||
'project.more': 'Flere handlinger',
|
||||
|
||||
@@ -298,6 +298,9 @@ export const en = {
|
||||
'users.taskOne': 'task',
|
||||
'users.taskMany': 'tasks',
|
||||
'users.never': 'never seen',
|
||||
'users.toProject': 'to {name}',
|
||||
'users.toInstance': 'to the instance',
|
||||
'users.invitedBy': 'invited by {name}',
|
||||
|
||||
// --- settings: history and errors -----------------------------------------------
|
||||
'history.title': 'History',
|
||||
@@ -313,6 +316,30 @@ export const en = {
|
||||
'history.noMatch': 'Nothing matches the filters.',
|
||||
'history.nothing': 'Nothing has been recorded yet.',
|
||||
'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.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.',
|
||||
@@ -493,8 +520,13 @@ export const en = {
|
||||
'detail.labelsPlaceholder': 'separated by commas',
|
||||
'detail.project': 'Project',
|
||||
'detail.repeats': 'Repeats',
|
||||
'detail.never': 'Never',
|
||||
'detail.daily': 'Every day',
|
||||
'detail.weekly': 'Every week',
|
||||
'detail.fortnightly': 'Every other week',
|
||||
'detail.monthly': 'Every month',
|
||||
'detail.yearly': 'Every year',
|
||||
'detail.asWritten': 'As written',
|
||||
'detail.assignee': 'Assigned to',
|
||||
'detail.subtasks': 'Sub-tasks',
|
||||
'detail.addSubtask': 'Add a sub-task',
|
||||
@@ -683,6 +715,7 @@ export const en = {
|
||||
'push.upToDate': 'No newer version found.',
|
||||
|
||||
'view.nothingMoreToday': 'Nothing more today.',
|
||||
'view.overdue': 'Overdue',
|
||||
'project.roleFor': 'Role for {name}',
|
||||
|
||||
'project.more': 'More actions',
|
||||
|
||||
@@ -49,3 +49,32 @@ export function fullDate(iso) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
{#if liveOverdue.length}
|
||||
<section>
|
||||
<h2 class="overdue">Forsinket</h2>
|
||||
<h2 class="overdue">{t('view.overdue')}</h2>
|
||||
{#each liveOverdue as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
{/each}
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
<section>
|
||||
{#if liveOverdue.length}
|
||||
<h2>I dag</h2>
|
||||
<h2>{t('nav.today')}</h2>
|
||||
{/if}
|
||||
{#each liveToday as task (task.id)}
|
||||
<TaskRow {task} />
|
||||
|
||||
@@ -177,9 +177,11 @@
|
||||
<div class="what">
|
||||
<span class="primary-line">{pending.email}</span>
|
||||
<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) })}
|
||||
{#if pending.invited_by}· inviteret af {pending.invited_by}{/if}
|
||||
{#if pending.invited_by}· {t('users.invitedBy', { name: pending.invited_by })}{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button class="secondary" onclick={() => revoke(pending)}>{t('users.revoke')}</button>
|
||||
@@ -209,7 +211,8 @@
|
||||
</span>
|
||||
<span class="secondary">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -159,9 +159,12 @@
|
||||
<span class="who" class:gone={!entry.user_name}
|
||||
>{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"
|
||||
>{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
<span class="detail">{eventDetail(entry)}</span>{/if}</span
|
||||
>{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||
>{eventDetail(entry)}</span
|
||||
>{/if}</span
|
||||
>
|
||||
</span>
|
||||
<span class="secondary">{entry.project_name}</span>
|
||||
|
||||
@@ -581,8 +581,9 @@
|
||||
>{entry.user_name || t('history.deletedAccount')}</span
|
||||
>
|
||||
<span class="what">
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}
|
||||
<span class="detail">{eventDetail(entry)}</span>{/if}
|
||||
{eventName(entry.event)}{#if eventDetail(entry)}{' '}<span class="detail"
|
||||
>{eventDetail(entry)}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<span class="when">{when(entry.created_at)}</span>
|
||||
</li>
|
||||
|
||||
@@ -20,7 +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';
|
||||
import { t, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
let days = $state([]);
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
const today = new Date(new Date().toDateString());
|
||||
const diff = Math.round((day - today) / 86400000);
|
||||
|
||||
if (diff === 0) return 'I dag';
|
||||
if (diff === 1) return 'I morgen';
|
||||
return day.toLocaleDateString('da-DK', { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
if (diff === 0) return t('task.today');
|
||||
if (diff === 1) return t('task.tomorrow');
|
||||
return day.toLocaleDateString(tag(), { weekday: 'long', day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// Reading tasks back out of the store keeps a completed row disappearing
|
||||
|
||||
Reference in New Issue
Block a user