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 <[email protected]>
This commit is contained in:
kristianwind
2026-10-08 19:16:14 +02:00
co-authored by Claude Opus 5
parent 661e47ebdf
commit 956e57f234
18 changed files with 1239 additions and 10 deletions
+92
View File
@@ -273,6 +273,50 @@ paths:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
/api/v1/auth/default-reminder:
put:
tags: [auth]
summary: The account's standing alarm on tasks that have a clock.
description: |
Reminders have always had to be set one at a time: a task written as
"i morgen kl 9" got a time and no alarm, because nothing creates a reminder
by itself. That is the right default for a task with no clock — there is no
moment to ring at — and an errand you walk every time for a task with one.
`minutes` is relative to the due moment and **negative for before it**, the
same convention `reminders.offset_min` uses because that is the column the
value ends up in: the store adds it to the due time. `0` is exactly at the
due moment. `null` turns the default off, which is where every account
starts — switching it on for everybody in a migration would have meant
alarms nobody asked for, on tasks already lying there.
A positive number is refused. An alarm after the thing it is warning about
is not a reminder, and accepting one here would make the single mistake this
value can make — a sign the wrong way round — look like a choice.
The default never stacks on an alarm the task already has, and a task can
opt out as it is written with "ingen alarm" in the quick-add line.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
minutes:
type: integer
nullable: true
maximum: 0
minimum: -10080
description: |
Minutes relative to the due moment, negative for before it.
Null is off.
example: -10
responses:
"204": { description: Saved. }
"400": { $ref: "#/components/responses/BadRequest" }
"422": { $ref: "#/components/responses/Validation" }
/api/v1/auth/nav-order:
put:
tags: [auth]
@@ -4800,6 +4844,17 @@ components:
The sidebar headings this person has folded away. Always an array,
never null: a client that has to check for both an absent array and an
empty one will one day check for only one of them.
default_reminder_min:
type: integer
nullable: true
description: |
The account's standing alarm on tasks that have a clock, as minutes
relative to the due moment and negative for before it. Null is off,
which is where every account starts.
Nullable rather than a number with a sentinel, because zero is a real
setting here — an alarm exactly at the due moment — so "off" and "at
the time" have to stay different answers.
example: ["labels"]
created_at: { type: string, format: date-time }
@@ -4991,6 +5046,21 @@ components:
task is still created, in the Inbox — a thought is not thrown away
over a typo — but the caller is told, because the alternative is
that the name vanishes from the title and nothing says why.
unknown_section:
type: string
description: |
The same, for a `/section` that no section of the project the task
landed in answers to. The task is created without a section.
reminder_not_set:
type: string
description: |
Present when the line asked to be reminded and the task has
nothing to count back from — "mind mig en time før" on a task with
a day but no clock. The offset form of a reminder fires relative to
the due moment, so without one the row would sit in the table and
never go off. The task is created; the alarm is not, and this says
so. An alarm that silently never rings is the one way a reminder
must not fail, because nothing afterwards reveals it.
ParsedTask:
type: object
@@ -5002,6 +5072,28 @@ components:
due_date: { type: string, format: date }
due_datetime: { type: string, format: date-time }
recurrence: { type: string }
reminder_offset_min:
type: integer
description: |
When to be told, as minutes relative to the due moment: negative for
before it, zero for at it. Absent when the line asked for no alarm.
Relative rather than absolute, so moving the task moves its alarm with
it. Mind the sign — the store adds this to the due time, so "an hour
before" is -60.
no_reminder:
type: boolean
description: |
The line said it wants no alarm on this one — "ingen alarm". It exists
because of the account-wide default: with alarms on for everything that
has a clock, there has to be a way to say "not this one" in the same
breath as writing it, rather than creating the task, opening it and
deleting the alarm just made.
reminder_at:
type: string
description: |
An absolute alarm, `YYYY-MM-DDTHH:MM`, set when the line gave the
reminder a clock of its own. It takes the day the task is due: "mind
mig kl 8" about something due Friday means Friday at eight.
labels:
type: array
items: { type: string }