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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
661e47ebdf
commit
956e57f234
@@ -636,3 +636,151 @@ func TestEveryOtherWeekdayIsARepetition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- reminders ----------------------------------------------------------------
|
||||
|
||||
// A dictated sentence can set its own alarm.
|
||||
//
|
||||
// The offset is negative because the store ADDS it to the due moment
|
||||
// (`due_datetime + offset_min * 60`). A positive number there is an alarm after
|
||||
// the thing it was meant to warn about, which is the one direction a reminder must
|
||||
// never be wrong in — so the sign is asserted here and not assumed.
|
||||
func TestParseReminderOffset(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want int
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 min før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig 10 minutter før", -10},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig et kvarter før", -15},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig en halv time før", -30},
|
||||
{"møde i morgen kl 9 mind mig dagen før", -1440},
|
||||
{"møde i morgen kl 9 mind mig to dage før", -2880},
|
||||
{"møde i morgen kl 9 mind mig 2 timer før", -120},
|
||||
// Det er talt ind, så kommaet hører til sætningen og ikke til syntaksen.
|
||||
{"ring til tandlægen i morgen kl 9, mind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, påmind mig en time før", -60},
|
||||
{"ring til tandlægen i morgen kl 9, mind mig om det en time før", -60},
|
||||
// Engelsk i samme linje, som resten af parseren gør det.
|
||||
{"call the dentist tomorrow at 9 remind me an hour before", -60},
|
||||
{"call the dentist tomorrow at 9 remind me 15 minutes before", -15},
|
||||
{"call the dentist tomorrow at 9 remind me two days before", -2880},
|
||||
// En bar påmindelse er på selve forfaldstidspunktet.
|
||||
{"ring til tandlægen i morgen kl 9 mind mig", 0},
|
||||
{"ring til tandlægen i morgen kl 9 med alarm", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderOffsetMin == nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = nil, want %d", c.in, c.want)
|
||||
continue
|
||||
}
|
||||
if *got.ReminderOffsetMin != c.want {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want %d", c.in, *got.ReminderOffsetMin, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder with a clock of its own takes the day the task is due.
|
||||
//
|
||||
// "mind mig kl 8" about something due Friday means Friday at eight. Resolving it
|
||||
// against today instead would fire the alarm days early, silently.
|
||||
func TestParseReminderClockTakesTheDueDay(t *testing.T) {
|
||||
now := ref(t)
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl 8", "2026-03-11T08:00"},
|
||||
{"ring til tandlægen i morgen kl 9 mind mig kl. 7.30", "2026-03-11T07:30"},
|
||||
{"ring til tandlægen i morgen kl 9, påmind kl 7:15", "2026-03-11T07:15"},
|
||||
{"call the dentist tomorrow at 9 remind me at 8", "2026-03-11T08:00"},
|
||||
// Ingen dato i linjen: så er det i dag, som en bar klokketid også er.
|
||||
{"ring til tandlægen mind mig kl 23", "2026-03-10T23:00"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := Parse(c.in, now, "da")
|
||||
if got.ReminderAt != c.want {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want %q", c.in, got.ReminderAt, c.want)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): also set an offset (%d); a reminder is one or the other",
|
||||
c.in, *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The alarm does not eat the task's own time, and the task's time does not eat the
|
||||
// alarm. This is the ordering the stage exists for.
|
||||
func TestReminderDoesNotTakeTheTasksOwnTime(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 8", ref(t), "da")
|
||||
|
||||
if got.DueTime != "09:00" {
|
||||
t.Errorf("DueTime = %q, want 09:00 — the reminder took the task's clock", got.DueTime)
|
||||
}
|
||||
if got.DueDate != "2026-03-11" {
|
||||
t.Errorf("DueDate = %q, want 2026-03-11", got.DueDate)
|
||||
}
|
||||
if got.ReminderAt != "2026-03-11T08:00" {
|
||||
t.Errorf("ReminderAt = %q, want 2026-03-11T08:00", got.ReminderAt)
|
||||
}
|
||||
if got.Content != "ring til tandlægen" {
|
||||
t.Errorf("Content = %q, want %q", got.Content, "ring til tandlægen")
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing becomes an alarm by standing near a clock.
|
||||
func TestNoReminderWhereNoneWasAskedFor(t *testing.T) {
|
||||
now := ref(t)
|
||||
for _, in := range []string{
|
||||
"ring til tandlægen i morgen kl 9",
|
||||
"betal moms i morgen kl 10 p1 #Firma @regnskab",
|
||||
// The \b cases: these words merely begin with the keywords.
|
||||
"skift batteri i alarmen",
|
||||
"mind migrationen om at køre",
|
||||
"vand planterne hver mandag kl 9",
|
||||
} {
|
||||
got := Parse(in, now, "da")
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("Parse(%q): ReminderOffsetMin = %d, want none", in, *got.ReminderOffsetMin)
|
||||
}
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("Parse(%q): ReminderAt = %q, want none", in, got.ReminderAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The phrase is highlighted, like every other thing the parser claims — see the
|
||||
// package comment: a parser you cannot see working is one you stop trusting.
|
||||
func TestReminderIsHighlighted(t *testing.T) {
|
||||
in := "ring til tandlægen i morgen kl 9 mind mig en time før"
|
||||
got := Parse(in, ref(t), "da")
|
||||
|
||||
var span *Span
|
||||
for i := range got.Spans {
|
||||
if got.Spans[i].Kind == KindReminder {
|
||||
span = &got.Spans[i]
|
||||
}
|
||||
}
|
||||
if span == nil {
|
||||
t.Fatalf("no %q span in %v", KindReminder, got.Spans)
|
||||
}
|
||||
if marked := in[span.Start:span.End]; marked != "mind mig en time før" {
|
||||
t.Errorf("highlighted %q, want %q", marked, "mind mig en time før")
|
||||
}
|
||||
}
|
||||
|
||||
// An impossible clock is not a reminder: the store would keep 25:00 and never fire
|
||||
// it, so saying nothing is the honest answer.
|
||||
func TestImpossibleReminderClockIsNotKept(t *testing.T) {
|
||||
got := Parse("ring til tandlægen i morgen kl 9 mind mig kl 25", ref(t), "da")
|
||||
|
||||
if got.ReminderAt != "" {
|
||||
t.Errorf("ReminderAt = %q, want none", got.ReminderAt)
|
||||
}
|
||||
if got.ReminderOffsetMin != nil {
|
||||
t.Errorf("ReminderOffsetMin = %d, want none", *got.ReminderOffsetMin)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user