Files
verdande/web/src/lib/when.js
T
kristianwindandClaude Opus 5 956e57f234 feat(opgaver): alarmen kan tales ind, og kan være standard
To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.

Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.

SÆTNINGEN

Hurtig tilføjelse forstår nu alarmen:

    ring til tandlægen i morgen kl 9 #hjem mind mig en time før

Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.

Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.

Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.

STANDARDEN

En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.

Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.

Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.

Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.

TRE TING LÆSNINGEN FANGEDE

* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
  (`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
  havde givet en alarm en time for sent, og det er den ene retning en
  påmindelse ikke må tage fejl i. Samme konvention hele vejen —
  indstillingen, parseren og kolonnen — for to fortegnsregler for det
  samme begreb viser sig kun som en alarm på et forkert tidspunkt.

* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
  `AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
  med en dag men ingen klokketid ville have lagt en række, der aldrig
  fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
  Det er den ene slags fejl en påmindelse ikke må have, fordi intet
  bagefter afslører den: der står ingen række at savne.

* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
  de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
  første, der bruger den i praksis. Den skriver nu "1 time før".

PRØVET

Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.

Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.

Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.

Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 19:16:14 +02:00

221 lines
8.4 KiB
JavaScript

/**
* How long ago something was, in the account's language.
*
* Four pages had a copy of this — the user list, the error log, the history page
* and the backup panel — and they had already drifted: one said "aldrig logget
* ind", another "aldrig", and two of them stopped at days while the others carried
* on to a date. Four copies is three chances for the fifth one to be different
* again.
*
* Written by hand rather than with `Intl.RelativeTimeFormat`, which would say
* "3 hours ago" and "for 3 timer siden" correctly and cannot say "lige nu" — and
* "just now" is the answer for most of what these lists show.
*/
import { t, tag } from './i18n.svelte.js';
export function ago(iso, { never = 'when.never' } = {}) {
if (!iso) return t(never);
const then = new Date(iso);
const seconds = Math.round((Date.now() - then) / 1000);
if (seconds < 60) return t('when.justNow');
if (seconds < 3600) return t('when.minutes', { n: Math.floor(seconds / 60) });
if (seconds < 86400) return t('when.hours', { n: Math.floor(seconds / 3600) });
if (seconds < 604800) return t('when.days', { n: Math.floor(seconds / 86400) });
return then.toLocaleDateString(tag(), { day: 'numeric', month: 'short', year: 'numeric' });
}
/** A date and a time, for a list where the time of day matters. */
export function stamp(iso) {
return new Date(iso).toLocaleString(tag(), {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
/** A day, without the year — for something inside the next few months. */
export function shortDate(iso) {
return new Date(iso).toLocaleDateString(tag(), { day: 'numeric', month: 'short' });
}
/** A day with the year, for something that could be any distance away. */
export function fullDate(iso) {
return new Date(iso).toLocaleDateString(tag(), {
day: 'numeric',
month: 'short',
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.
*
* 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);
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 });
}
/**
* En påmindelses forskydning, skrevet som ord.
*
* Lageret LÆGGER tallet til forfaldstiden (`due_datetime + offset_min * 60`), så
* en alarm før er negativ. Rå blev det vist som "-60 min.": et fortegn, læseren
* selv skal oversætte, på netop den oplysning man skal kunne stole på uden at
* regne — og en alarm, man har misforstået, opdager man først, da den ringede på
* det forkerte tidspunkt.
*
* Retningen er skilt fra varigheden, så der kun er ét sæt enheder at vedligeholde.
* En positiv forskydning kan ikke laves hverken i hurtig tilføjelse eller i
* skærmbilledet — kun gennem API'et — men den har et ord her frem for at falde
* tilbage på et tal, for det er den slags gren, der står urørt indtil den en dag
* ikke gør.
*/
export function reminderOffsetLabel(minutes) {
if (minutes === 0) return t('detail.remindAtDue');
const span = durationLabel(Math.abs(minutes));
return minutes < 0 ? t('detail.remindBefore', { when: span }) : t('detail.remindAfter', { when: span });
}
/** Minutter som hele dage, hele timer eller minutter — den største, der går op. */
function durationLabel(minutes) {
if (minutes % 1440 === 0) {
const days = minutes / 1440;
return days === 1 ? t('when.dayOne') : t('when.dayMany', { n: days });
}
if (minutes % 60 === 0) {
const hours = minutes / 60;
return hours === 1 ? t('when.hourOne') : t('when.hourMany', { n: hours });
}
return t('when.minuteMany', { n: minutes });
}