From 2219f6af13423091ae7435b11f2bb03dc7e83030 Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Thu, 20 Aug 2026 22:00:05 +0200 Subject: [PATCH] =?UTF-8?q?fix(i18n):=20en=20gentagelse=20siges=20nu=20p?= =?UTF-8?q?=C3=A5=20l=C3=A6serens=20sprog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recurrence.Describe` skriver dansk. Fladen kunne navngive de fem almindelige regler selv, og alt andet — "hver mandag", "hverdage", "hver anden uge tirsdag" — faldt tilbage til serverens tekst, som er dansk uanset hvad kontoen er sat til. Serveren kan ikke bare tage en locale med, og det er ikke dovenskab: `toTaskJSON` har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser, hvor én nyttelast når alle i et projekt på én gang. Der findes ikke ét sprog at skrive den i. Men reglen er den samme, uanset hvem der læser den. Den ligger allerede i nyttelasten, så beskrivelsen hører hjemme dér, hvor læseren er — i fladen. Serverens tekst bliver stående som reserve. Den er stadig den, et API-kald og en MCP-forbindelse får, og de har ingen flade at oversætte i. Ugedagen kommer fra `Intl` og ikke fra en tabel — ikke for at spare syv strenge pr. sprog, men for at få de navne, sproget faktisk bruger. Dagene sorteres, som ugen går, frem for som reglen tilfældigvis skrev dem: "mandag og torsdag" og "torsdag og mandag" er den samme regel, og den ene af dem er den, folk siger. Og to har sit eget ord på begge sprog — "hver anden", "every other". Det er ikke tallet to sat ind i den samme sætning, så det er sin egen nøgle. Et sprog, der bøjer enheden efter tallet, kan ikke sættes sammen af stumper; derfor står tallet inde i teksten frem for at blive limet på. Prøven bruger `hver mandag` og `hverdage`, fordi de netop *ikke* står på listen over de fem, fladen altid har kunnet. En tredje vending, jeg først skrev prøven med — "hver anden tirsdag" — viste sig ikke at være en, hurtig tilføjelse overhovedet læser: den lagde "hver anden" i titlen og gjorde "tirsdag" til en dato. Det er en anden sag, og den står nu skrevet ét sted, hvor den kan tages op. Co-Authored-By: Claude Opus 5 --- web/e2e/smoke.spec.js | 47 ++++++++++++++++ web/src/lib/locales/da.js | 19 +++++++ web/src/lib/locales/en.js | 19 +++++++ web/src/lib/when.js | 110 +++++++++++++++++++++++++++++++++++++- 4 files changed, 193 insertions(+), 2 deletions(-) diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 4e0c325..793a5cd 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -3423,3 +3423,50 @@ test('et projekts kalender kan vises som en uge', async ({ page }) => { expect(trouble).toEqual([]); }); + +/** + * En gentagelse siges på læserens sprog, også dem serveren kun kan på dansk. + * + * `recurrence.Describe` skriver dansk og kan ikke bare tage en locale med: + * `toTaskJSON` har niogtyve kaldesteder, og mange er WebSocket-udsendelser, hvor én + * nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at skrive + * den i. Reglen er den samme uanset hvem der læser den, så beskrivelsen hører + * hjemme dér, hvor læseren er. + * + * De to her er valgt, fordi de *ikke* står på listen over de fem almindelige, som + * fladen altid har kunnet navngive. Før det her faldt de tilbage til serverens + * tekst — og den er dansk, hvad end kontoen er sat til. + */ +test('en gentagelse, serveren kun kan på dansk, siges alligevel i fladen', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.goto('/'); + + for (const [text, says] of [ + ['vande planterne hver mandag', 'hver mandag'], + ['tømme opvasker hverdage', 'hverdage'] + ]) { + const box = page.getByLabel('Ny opgave'); + await box.fill(text); + await box.press('Enter'); + await page.waitForTimeout(400); + + // I indbakken og ikke på I dag: en ugentlig regel sætter den første + // forfaldsdag til den næste sådan dag, som sjældent er i dag. + await page + .getByRole('navigation', { name: 'Hovedmenu' }) + .getByRole('link', { name: /^Indbakke/ }) + .click(); + + const name = text.split(' ').slice(0, 2).join(' '); + const row = page.locator('.row', { hasText: name }).first(); + await expect(row).toBeVisible(); + await expect( + row.locator('.repeat'), + `"${text}" blev ikke læst som en gentagelse` + ).toContainText(says); + + await page.goto('/'); + } + + expect(trouble).toEqual([]); +}); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 79788c1..4930f1b 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -152,6 +152,25 @@ export const da = { 'task.yesterday': 'I går', 'task.overdue': '{n} dage forsinket', 'task.repeats': 'Gentages {rule}', + 'repeat.weekdays': 'hverdage', + 'repeat.weekends': 'hver weekend', + 'repeat.and': 'og', + 'repeat.everyDay': 'hver {day}', + 'repeat.everyNthWeekOn': 'hver {n}. uge {day}', + 'repeat.everyOtherWeekOn': 'hver anden uge {day}', + 'repeat.monthlyOn': 'den {day}. hver måned', + 'repeat.every.day': 'hver dag', + 'repeat.every.week': 'hver uge', + 'repeat.every.month': 'hver måned', + 'repeat.every.year': 'hvert år', + 'repeat.everyOther.day': 'hver anden dag', + 'repeat.everyOther.week': 'hver anden uge', + 'repeat.everyOther.month': 'hver anden måned', + 'repeat.everyOther.year': 'hvert andet år', + 'repeat.everyN.day': 'hver {n}. dag', + 'repeat.everyN.week': 'hver {n}. uge', + 'repeat.everyN.month': 'hver {n}. måned', + 'repeat.everyN.year': 'hvert {n}. år', 'task.assignee': 'Ansvarlig: {name}', 'task.subtasks': '{done} af {total} undertasks er lukket', 'task.attachments': '{n} vedhæftet', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 35bb2ac..ce13913 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -148,6 +148,25 @@ export const en = { 'task.yesterday': 'Yesterday', 'task.overdue': '{n} days overdue', 'task.repeats': 'Repeats {rule}', + 'repeat.weekdays': 'weekdays', + 'repeat.weekends': 'every weekend', + 'repeat.and': 'and', + 'repeat.everyDay': 'every {day}', + 'repeat.everyNthWeekOn': 'every {n} weeks on {day}', + 'repeat.everyOtherWeekOn': 'every other week on {day}', + 'repeat.monthlyOn': 'the {day}th of every month', + 'repeat.every.day': 'every day', + 'repeat.every.week': 'every week', + 'repeat.every.month': 'every month', + 'repeat.every.year': 'every year', + 'repeat.everyOther.day': 'every other day', + 'repeat.everyOther.week': 'every other week', + 'repeat.everyOther.month': 'every other month', + 'repeat.everyOther.year': 'every other year', + 'repeat.everyN.day': 'every {n} days', + 'repeat.everyN.week': 'every {n} weeks', + 'repeat.everyN.month': 'every {n} months', + 'repeat.everyN.year': 'every {n} years', 'task.assignee': 'Assigned to {name}', 'task.subtasks': '{done} of {total} sub-tasks are done', 'task.attachments': '{n} attached', diff --git a/web/src/lib/when.js b/web/src/lib/when.js index 4d944af..1313335 100644 --- a/web/src/lib/when.js +++ b/web/src/lib/when.js @@ -73,8 +73,114 @@ export const REPEATS = [ { rule: 'FREQ=YEARLY', label: 'detail.yearly' } ]; -/** What a repeating task says on its row. */ +/** + * What a repeating task says on its row. + * + * Beskrevet her og ikke på serveren, og det er ikke en smagssag. + * + * `recurrence.Describe` skriver dansk. Den kan ikke bare tage en locale med: + * `toTaskJSON` har niogtyve kaldesteder, og mange af dem er WebSocket-udsendelser, + * hvor én nyttelast når alle i et projekt på én gang — der findes ikke ét sprog at + * skrive den i. Reglen ligger allerede i nyttelasten, og den er den samme uanset + * hvem der læser den. Så beskrivelsen hører hjemme dér, hvor læseren er. + * + * Serverens tekst bliver som reserve. Den er stadig den, et API-kald og en + * MCP-forbindelse får, og de har ingen flade at oversætte i. + */ export function repeatLabel(rule, fallback = '') { + if (!rule) return ''; const known = REPEATS.find((option) => option.rule && option.rule === rule); - return known ? t(known.label) : fallback; + if (known) return t(known.label); + return describeRule(rule) || fallback; +} + +/** Ugedagene i den rækkefølge RRULE navngiver dem. */ +const RRULE_DAYS = { MO: 0, TU: 1, WE: 2, TH: 3, FR: 4, SA: 5, SU: 6 }; + +/** + * Ugedagens navn på læserens sprog, fra Intl frem for fra en tabel. + * + * Ikke for at spare syv strenge pr. sprog — for at få de navne, sproget faktisk + * bruger. Den 1. januar 2024 var en mandag; enhver mandag ville gøre det, og en + * fast en holder det ude af tegningen. + */ +function dayName(code) { + const index = RRULE_DAYS[code]; + if (index === undefined) return code; + return new Intl.DateTimeFormat(tag(), { weekday: 'long' }).format( + new Date(Date.UTC(2024, 0, 1 + index)) + ); +} + +/** + * En RRULE sagt med ord. + * + * Dækker det, verdandes egen hurtig tilføjelse kan lave, og falder tilbage til + * reglen selv for alt andet — en kalenderklient kan sende ting, ingen her har + * skrevet, og en halv oversættelse af dem ville være værre end den rå regel. + */ +export function describeRule(rule) { + if (!rule) return ''; + const parts = {}; + for (const pair of rule.replace(/^RRULE:/i, '').split(';')) { + const [key, value] = pair.split('='); + if (key && value !== undefined) parts[key.toUpperCase()] = value; + } + + const interval = Number(parts.INTERVAL) > 0 ? Number(parts.INTERVAL) : 1; + + if (parts.BYDAY) { + if (parts.BYDAY === 'MO,TU,WE,TH,FR') return t('repeat.weekdays'); + if (parts.BYDAY === 'SA,SU') return t('repeat.weekends'); + + // Sorteret som ugen går, ikke som reglen tilfældigvis skrev dem: "mandag og + // torsdag" og "torsdag og mandag" er den samme regel, og den ene af dem er + // den, folk siger. + const names = parts.BYDAY.split(',') + .filter((code) => code in RRULE_DAYS) + .sort((a, b) => RRULE_DAYS[a] - RRULE_DAYS[b]) + .map(dayName); + if (!names.length) return ''; + + const joined = + names.length === 1 + ? names[0] + : names.slice(0, -1).join(', ') + ' ' + t('repeat.and') + ' ' + names.at(-1); + + // "hver mandag", ikke "hver uge mandag". En ugentlig regel, der navngiver + // sine dage, har ikke også brug for ordet for intervallet. + if (interval <= 1) return t('repeat.everyDay', { day: joined }); + // To har sit eget ord på begge sprog — "hver anden", "every other" — og det + // er ikke tallet to sat ind i den samme sætning. Et sprog, der bøjer, kan + // ikke sættes sammen af stumper. + if (interval === 2) return t('repeat.everyOtherWeekOn', { day: joined }); + return t('repeat.everyNthWeekOn', { n: interval, day: joined }); + } + + switch (parts.FREQ?.toUpperCase()) { + case 'DAILY': + return every(interval, 'day'); + case 'WEEKLY': + return every(interval, 'week'); + case 'MONTHLY': + if (parts.BYMONTHDAY) return t('repeat.monthlyOn', { day: parts.BYMONTHDAY }); + return every(interval, 'month'); + case 'YEARLY': + return every(interval, 'year'); + default: + return ''; + } +} + +/** + * "hver dag", "hver anden uge", "hver tredje måned". + * + * Tallet står i teksten frem for at blive sat sammen af stumper: "every other + * week" og "hver anden uge" er ikke to ord byttet om, og et sprog, der bøjer + * enheden efter tallet, kan ikke sættes sammen af to nøgler. + */ +function every(interval, unit) { + if (interval <= 1) return t(`repeat.every.${unit}`); + if (interval === 2) return t(`repeat.everyOther.${unit}`); + return t(`repeat.everyN.${unit}`, { n: interval }); }