From 956e57f234586a731080bfb09fb8343b4ae97e5a Mon Sep 17 00:00:00 2001 From: kristianwind Date: Thu, 8 Oct 2026 19:16:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(opgaver):=20alarmen=20kan=20tales=20ind,?= =?UTF-8?q?=20og=20kan=20v=C3=A6re=20standard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi.yaml | 92 +++++ internal/httpapi/auth_handlers.go | 46 ++- internal/httpapi/server.go | 1 + internal/httpapi/tasks_handlers.go | 90 +++- internal/httpapi/tasks_test.go | 387 ++++++++++++++++++ internal/quickadd/quickadd.go | 181 +++++++- internal/quickadd/quickadd_test.go | 148 +++++++ .../migrations/0036_alarm_som_standard.sql | 23 ++ internal/store/users.go | 47 ++- web/e2e/smoke.spec.js | 78 ++++ web/src/lib/api.js | 3 + web/src/lib/components/TaskDetail.svelte | 6 +- web/src/lib/locales/da.js | 22 + web/src/lib/locales/en.js | 22 + web/src/lib/settingsindex.js | 1 + web/src/lib/stores.svelte.js | 4 + web/src/lib/when.js | 34 ++ .../indstillinger/notifikationer/+page.svelte | 64 +++ 18 files changed, 1239 insertions(+), 10 deletions(-) create mode 100644 internal/store/migrations/0036_alarm_som_standard.sql diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 0e24d10..ddc40c9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 } diff --git a/internal/httpapi/auth_handlers.go b/internal/httpapi/auth_handlers.go index 94e6f9c..158b3a1 100644 --- a/internal/httpapi/auth_handlers.go +++ b/internal/httpapi/auth_handlers.go @@ -33,7 +33,14 @@ type userJSON struct { // opposite case and stays in localStorage. SidebarCollapsed []string `json:"sidebar_collapsed"` NavOrder []string `json:"nav_order"` - CreatedAt string `json:"created_at"` + // DefaultReminderMin is 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. + // + // A pointer rather than a number with a sentinel, so that "off" and "at the + // due moment" stay different answers: zero is a real setting here. + DefaultReminderMin *int `json:"default_reminder_min"` + CreatedAt string `json:"created_at"` } func toUserJSON(u *store.User) userJSON { @@ -51,7 +58,8 @@ func toUserJSON(u *store.User) userJSON { ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor, Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin, TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order, - CreatedAt: u.CreatedAt.Format(time.RFC3339), + DefaultReminderMin: u.DefaultReminderMin, + CreatedAt: u.CreatedAt.Format(time.RFC3339), } } @@ -112,6 +120,40 @@ func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// handleSetDefaultReminder turns the account's standing alarm on or off. +// +// `minutes` is relative to the due moment and negative for before it, which is the +// convention reminders.offset_min uses because that is the column it lands in. Null +// is off. A positive number is refused: an alarm AFTER the thing it is warning +// about is not a reminder, and allowing it here would make the one mistake this +// value can make — a sign the wrong way round — look like a choice. +func (s *Server) handleSetDefaultReminder(w http.ResponseWriter, r *http.Request) { + var req struct { + Minutes *int `json:"minutes"` + } + if err := decodeJSON(w, r, &req); err != nil { + return + } + if req.Minutes != nil { + switch { + case *req.Minutes > 0: + writeFieldErrors(w, map[string]string{"minutes": "must be zero or negative — an alarm comes before"}) + return + // A week is the outer edge of "remind me ahead of this"; beyond it the + // number is far more likely to be a unit mistake than an intention. + case *req.Minutes < -7*24*60: + writeFieldErrors(w, map[string]string{"minutes": "must be within a week of the due time"}) + return + } + } + user := userFrom(r.Context()) + if err := s.db.SetDefaultReminder(r.Context(), user.ID, req.Minutes); err != nil { + s.storeError(w, r, "set default reminder", err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // --- sign in ----------------------------------------------------------------- type loginRequest struct { diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 506be82..aa500da 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -328,6 +328,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Patch("/me", s.handleUpdateProfile) r.Put("/sidebar-sections", s.handleSetSidebarSections) r.Put("/nav-order", s.handleSetNavOrder) + r.Put("/default-reminder", s.handleSetDefaultReminder) r.Post("/logout", s.handleLogout) r.Post("/password/change", s.handleChangePassword) r.Get("/recovery-codes", s.handleRecoveryCodesCount) diff --git a/internal/httpapi/tasks_handlers.go b/internal/httpapi/tasks_handlers.go index abe55e6..4391995 100644 --- a/internal/httpapi/tasks_handlers.go +++ b/internal/httpapi/tasks_handlers.go @@ -243,6 +243,10 @@ func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) { // The store wrote the labels; the struct it was handed does not know that, and // the response has to describe the task that now exists. t.Labels = req.Labels + if err := s.applyDefaultReminder(r.Context(), t, user); err != nil { + s.internal(w, r, "default reminder", err) + return + } s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content}) s.publish(t.ProjectID, "task.created", toTaskJSON(*t)) // Lavet til en anden med det samme. Det er den samme besked som at blive @@ -337,6 +341,12 @@ func (s *Server) handleUpdateTask(w http.ResponseWriter, r *http.Request) { s.storeError(w, r, "get task", err) return } + // Et klokkeslu00e6t kan ogsu00e5 komme til EFTER at opgaven er lavet, og det er den + // halvdel, der ellers mangler i et lu00f8fte om "alle opgaver med en dato og tid". + if err := s.applyDefaultReminder(r.Context(), t, user); err != nil { + s.internal(w, r, "default reminder", err) + return + } s.activity(r, t.ProjectID, t.ID, "task.updated", nil) s.publish(t.ProjectID, "task.updated", toTaskJSON(*t)) if req.AssigneeID != nil && t.AssigneeID != wasAssigned { @@ -620,14 +630,55 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) { s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content}) s.publish(t.ProjectID, "task.created", toTaskJSON(*t)) - if unknownProject != "" || unknownSection != "" { + // The alarm the line asked for, after the task because a reminder needs its id. + // + // Set when the line asked to be reminded and the task has nothing to count back + // from. An offset is relative to the due moment, and DueReminders selects the + // offset form only `AND t.due_datetime IS NOT NULL` — so on a task with a day + // but no clock there is nothing to be an hour before, and the row would sit in + // the table forever without firing. Reported the same way an unknown project + // is: the task is created either way, and the caller is told what could not be + // honoured. An alarm that silently never rings is the one way a reminder must + // not fail, because nothing afterwards reveals it. + reminderNotSet := "" + switch { + case len(parsed.ReminderAt) == len("2006-01-02T15:04"): + day, clock := parsed.ReminderAt[:10], parsed.ReminderAt[11:] + if _, when, err := resolveDue(day, clock, user.Timezone); err == nil && when != nil { + if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, when, nil); err != nil { + s.internal(w, r, "create reminder", err) + return + } + } + case parsed.ReminderOffsetMin != nil: + if t.DueDatetime == nil { + reminderNotSet = "no due time to count back from" + } else if _, err := s.db.CreateReminder(r.Context(), t.ID, user.ID, nil, parsed.ReminderOffsetMin); err != nil { + s.internal(w, r, "create reminder", err) + return + } + } + + // Standarden til sidst, og efter den udtrykkelige: "mind mig en time fu00f8r" har + // allerede lagt sin, og applyDefaultReminder lu00e6gger ikke en oven pu00e5. "ingen + // alarm" er den anden vej ud u2014 den eneste mu00e5de at sige nej til standarden, + // mens man skriver opgaven, frem for at u00e5bne den bagefter og slette alarmen. + if !parsed.NoReminder { + if err := s.applyDefaultReminder(r.Context(), t, user); err != nil { + s.internal(w, r, "default reminder", err) + return + } + } + + if unknownProject != "" || unknownSection != "" || reminderNotSet != "" { // Alongside the task rather than instead of it: the task was created, and // this says what could not be honoured while creating it. writeJSON(w, http.StatusCreated, struct { taskJSON UnknownProject string `json:"unknown_project,omitempty"` UnknownSection string `json:"unknown_section,omitempty"` - }{toTaskJSON(*t), unknownProject, unknownSection}) + ReminderNotSet string `json:"reminder_not_set,omitempty"` + }{toTaskJSON(*t), unknownProject, unknownSection, reminderNotSet}) return } writeJSON(w, http.StatusCreated, toTaskJSON(*t)) @@ -804,3 +855,38 @@ func (s *Server) handleSnoozeTask(w http.ResponseWriter, r *http.Request) { s.publish(t.ProjectID, "task.updated", toTaskJSON(*t)) writeJSON(w, http.StatusOK, toTaskJSON(*t)) } + +// applyDefaultReminder gives a task the alarm the account asks for without being +// asked each time. +// +// Three things make it a no-op, and each of them is the point rather than a guard: +// +// - The account has the default off. That is where every account starts, and it +// stays there until somebody turns it on: switching it on in a migration would +// have given every existing user alarms they never asked for, on tasks already +// lying there, in software other people run themselves. +// - The task has no clock. A task due "tomorrow" has no moment to ring at, and +// DueReminders selects the offset form only `AND t.due_datetime IS NOT NULL` — +// so the row would sit there and never fire. +// - The task already has a reminder. The line may have asked for one itself, or +// the task may be being edited for the second time; either way the default is +// a default and must not stack a second alarm on top of a stated one. +// +// Called on every path a task can gain a clock — created, edited, or quick-added — +// because a default that only applies to one of them is a setting that is true on +// Tuesdays. CalDAV is deliberately not one of them: a task arriving from Apple +// Reminders brings its own alarm, and two alarms for one task is worse than none. +func (s *Server) applyDefaultReminder(ctx context.Context, t *store.Task, user *store.User) error { + if user.DefaultReminderMin == nil || t == nil || t.DueDatetime == nil { + return nil + } + existing, err := s.db.ListReminders(ctx, t.ID) + if err != nil { + return err + } + if len(existing) > 0 { + return nil + } + _, err = s.db.CreateReminder(ctx, t.ID, user.ID, nil, user.DefaultReminderMin) + return err +} diff --git a/internal/httpapi/tasks_test.go b/internal/httpapi/tasks_test.go index 8ceba18..f98cf5d 100644 --- a/internal/httpapi/tasks_test.go +++ b/internal/httpapi/tasks_test.go @@ -2080,3 +2080,390 @@ func TestSnoozeSinksAndWakes(t *testing.T) { t.Errorf("woken task did not return: order = %v", got) } } + +// --- alarms written into the line --------------------------------------------- + +// A dictated sentence sets its own alarm, and the alarm actually goes off. +// +// The assertion is on DueReminders rather than on a row existing, and the +// difference is the whole test: a reminder row with the offset the wrong way round +// exists just as convincingly and rings an hour LATE. So the question asked here is +// "would this fire, and when" — one minute before it is due it must not be in the +// list, one minute after it must. +func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "ring til tandlægen i morgen kl 9 mind mig en time før", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + if body["content"] != "ring til tandlægen" { + t.Errorf("content = %v, want %q", body["content"], "ring til tandlægen") + } + if body["reminder_not_set"] != nil { + t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"]) + } + + taskID, _ := body["id"].(string) + rems, err := ts.db.ListReminders(ctx, taskID) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Fatalf("got %d reminders, want 1", len(rems)) + } + if rems[0].OffsetMin == nil { + t.Fatalf("reminder has no offset; it should not be absolute") + } + if *rems[0].OffsetMin != -60 { + t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+ + "positive number is an alarm an hour late", *rems[0].OffsetMin) + } + + due, err := time.Parse(time.RFC3339, body["due_datetime"].(string)) + if err != nil { + t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err) + } + + early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute)) + if err != nil { + t.Fatalf("due reminders (early): %v", err) + } + if len(early) != 0 { + t.Errorf("61 minutes before it is due, %d reminders are already due", len(early)) + } + + ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute)) + if err != nil { + t.Fatalf("due reminders (ripe): %v", err) + } + if len(ripe) != 1 { + t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe)) + } + if ripe[0].TaskContent != "ring til tandlægen" { + t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent) + } +} + +// An alarm with nothing to count back from is refused out loud. +// +// "mind mig en time før" on a task with a day but no clock has no moment to be an +// hour before, and DueReminders selects the offset form only when the task has a +// due_datetime — so the row would sit there and never fire. The task is created and +// the caller is told, the same way an unknown #project is reported. An alarm that +// silently never rings is the one way a reminder must not fail. +func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "køb mælk i morgen mind mig en time før", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + if body["content"] != "køb mælk" { + t.Errorf("content = %v, want %q", body["content"], "køb mælk") + } + if body["reminder_not_set"] == nil { + t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so") + } + + taskID, _ := body["id"].(string) + rems, err := ts.db.ListReminders(ctx, taskID) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+ + "than none, because nothing afterwards reveals it", len(rems)) + } +} + +// A reminder given a clock of its own is kept as an absolute moment. +func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "ring til tandlægen i morgen kl 9 mind mig kl 8", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + + taskID, _ := body["id"].(string) + rems, err := ts.db.ListReminders(ctx, taskID) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Fatalf("got %d reminders, want 1", len(rems)) + } + if rems[0].OffsetMin != nil { + t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin) + } + + due, err := time.Parse(time.RFC3339, body["due_datetime"].(string)) + if err != nil { + t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err) + } + // Due at nine, reminded at eight: one hour earlier, on the task's own day. + if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) { + t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want) + } +} + +// --- the account's standing alarm -------------------------------------------- + +// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes +// before the due moment. +func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) { + t.Helper() + resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("set default reminder: %d %v", resp.StatusCode, body) + } +} + +// With the setting on, a task that has a clock gets its alarm without being asked. +func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen kl 16", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Fatalf("got %d reminders, want 1", len(rems)) + } + if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 { + t.Errorf("offset = %v, want -10", rems[0].OffsetMin) + } +} + +// And off is where every account starts. This is the control for the test above: +// without it, an alarm appearing for any reason at all reads as the setting working. +func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen kl 16", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+ + "on for everybody is not a default anybody chose", len(rems)) + } +} + +// A task with a day but no clock gets nothing, because there is no moment to ring +// at: DueReminders selects the offset form only when due_datetime is set, so the +// row would be one that never fires. +func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems)) + } +} + +// The default does not stack on top of an alarm the line asked for. +func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen kl 16 mind mig en time før", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems)) + } + if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 { + t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin) + } +} + +// "ingen alarm" is the way out of the default while writing the task. +// +// Without it the only way to have a timed task that does not ring is to create it, +// open it, and delete the alarm that was just made — which is the omission a +// default quietly creates. +func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen kl 16 ingen alarm", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + if body["content"] != "hente cyklen" { + t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen") + } + + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems)) + } +} + +// A clock added later gets the alarm too. +// +// This is the half a promise about "every task with a date and time" loses if the +// default is only applied where tasks are created: a task written now and given a +// time this afternoon is the same task. +func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + taskID := body["id"].(string) + + rems, err := ts.db.ListReminders(ctx, taskID) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Fatalf("got %d reminders before there was a clock, want 0", len(rems)) + } + + resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{ + "due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"), + "due_time": "16:00", + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("patch task: %d %v", resp.StatusCode, body) + } + + rems, err = ts.db.ListReminders(ctx, taskID) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Fatalf("got %d reminders after the clock was added, want 1", len(rems)) + } + if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 { + t.Errorf("offset = %v, want -10", rems[0].OffsetMin) + } +} + +// A positive offset is refused. An alarm after the thing it warns 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 somebody made. +func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10}) + if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest { + t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body) + } +} + +// Null turns it off again, and the alarms already made are left alone: a default is +// what happens to the NEXT task, not a statement about the ones already written. +func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + ctx := t.Context() + turnOnDefaultAlarm(t, ts, -10) + + resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente cyklen i morgen kl 16", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + kept := body["id"].(string) + + resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil}) + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("turn off: %d %v", resp.StatusCode, body) + } + + resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ + "text": "hente bilen i morgen kl 17", + }) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("quick add: %d %v", resp.StatusCode, body) + } + rems, err := ts.db.ListReminders(ctx, body["id"].(string)) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 0 { + t.Errorf("got %d reminders after turning the default off, want 0", len(rems)) + } + + rems, err = ts.db.ListReminders(ctx, kept) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + if len(rems) != 1 { + t.Errorf("the alarm made while it was on is gone; turning a default off is not " + + "a statement about the tasks already written") + } +} diff --git a/internal/quickadd/quickadd.go b/internal/quickadd/quickadd.go index 3d8a3eb..b1ec03c 100644 --- a/internal/quickadd/quickadd.go +++ b/internal/quickadd/quickadd.go @@ -37,6 +37,7 @@ const ( KindSection Kind = "section" KindLabel Kind = "label" KindRepeat Kind = "repeat" + KindReminder Kind = "reminder" ) // Span is a byte range of the input that was consumed, and what it was read as. @@ -70,7 +71,28 @@ type Result struct { Recurrence string `json:"recurrence_rule,omitempty"` // RecurrenceText is that rule written back out in Danish, for the preview. RecurrenceText string `json:"recurrence_text,omitempty"` - Spans []Span `json:"spans,omitempty"` + // ReminderOffsetMin is when to be told, as minutes relative to the task's due + // moment: negative for before it, zero for at it. Nil when the line asked for + // no reminder. + // + // Relative rather than absolute, so that moving the task moves its alarm with + // it. Mind the sign: the store ADDS this to the due time + // (`due_datetime + offset_min * 60`), so "an hour before" is -60. Getting that + // backwards gives an alarm an hour late, which is the one direction a reminder + // must never be wrong in. + ReminderOffsetMin *int `json:"reminder_offset_min,omitempty"` + // ReminderAt is an absolute reminder, "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, not today. + ReminderAt string `json:"reminder_at,omitempty"` + // NoReminder is the line saying 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. Without that the only way out is to create the task, + // open it, and delete the alarm that was just made. + NoReminder bool `json:"no_reminder,omitempty"` + Spans []Span `json:"spans,omitempty"` } // Parse reads input as of the moment `now`, which is also the timezone dates are @@ -96,6 +118,20 @@ func Parse(input string, now time.Time, locale string) Result { // mask keeps byte offsets intact, so spans still point into the original input. masked := mask(input, spans) + // Reminders before the clock and the calendar, and that order is the whole + // trick: "mind mig en time før" has a duration in it and "mind mig kl 8" has a + // clock, and either one read by the time parser first becomes the task's own + // due time instead of its alarm. The phrase is anchored on a keyword the way a + // project is anchored on '#', so consuming it this early cannot take anything + // that was not offered to it. + // "No alarm" before "alarm", or the refusal leaves the very word the stage + // below is looking for standing in the line. + res.NoReminder, spans = extractNoReminder(masked, spans) + masked = mask(masked, spans) + + rem, spans := extractReminder(masked, spans) + masked = mask(masked, spans) + // Recurrence before the date parser, and this order is load-bearing: "hver // mandag" contains a weekday, and a date parser reading it first would claim // Monday as a one-off due date and leave "hver" stranded in the title. @@ -137,6 +173,21 @@ func Parse(input string, now time.Time, locale string) Result { res.DueTime = pad2(hour) + ":" + pad2(minute) } + // The reminder is resolved last, because an absolute one needs the due date the + // stages above just worked out. + if rem.found { + if rem.hasClock { + day := res.DueDate + if day == "" { + day = now.Format("2006-01-02") + } + res.ReminderAt = day + "T" + pad2(rem.hour) + ":" + pad2(rem.min) + } else { + off := rem.offsetMin + res.ReminderOffsetMin = &off + } + } + sort.Slice(spans, func(i, j int) bool { return spans[i].Start < spans[j].Start }) res.Spans = spans res.Content = remove(input, spans) @@ -687,3 +738,131 @@ func extractRecurrence(input string, spans []Span) (string, []Span) { } return "", spans } + +// --- reminders ---------------------------------------------------------------- + +// reReminderLead finds the words that introduce an alarm. A reminder is anchored +// on a keyword the way a project is anchored on '#': nothing becomes an alarm by +// standing near a clock, because "ring til tandlægen kl 9" asks for a task at nine +// and not to be woken before it. +// +// The trailing \b is load-bearing. Without it the pattern matches the first six +// letters of "alarmen" and of "mind migrationen", takes them out of the title, and +// sets an alarm nobody asked for. +var reReminderLead = regexp.MustCompile( + `(?i)(?:^|\s)(mind\s+mig(?:\s+om\s+det)?|påmind(?:\s+mig)?|paamind(?:\s+mig)?` + + `|remind\s+me(?:\s+about\s+it)?|med\s+alarm)\b`) + +// reReminderOffset reads the tail as "this long before": "en time før", "10 min +// før", "et kvarter før", "dagen før", "two days before". +var reReminderOffset = regexp.MustCompile( + `(?i)^[\s,]*(?:om\s+)?(\d+|en|et|a|an|to|two|tre|three)?\s*` + + `(halv\s+time|halvtime|kvarter|minutter|minut|minutes|minute|min\.?` + + `|timer|time|hours|hour|dage|dagen|dag|days|day)\s*` + + `(?:før|foer|inden|before)`) + +// reReminderClock reads the tail as a clock of its own: "kl 8", "kl. 7.30", "at 7:30". +var reReminderClock = regexp.MustCompile(`(?i)^[\s,]*(?:kl\.?|klokken|at)\s*(\d{1,2})(?:[.:](\d{2}))?`) + +// reminder is what the phrase said. OffsetMin is minutes relative to the due +// moment and negative for "before"; zero means at the due moment, which is what a +// bare "mind mig" asks for. +type reminder struct { + found bool + offsetMin int + hour, min int + hasClock bool +} + +// extractReminder reads the alarm out of the line. +// +// The lead-in is matched first and the tail interpreted afterwards, rather than in +// one pattern, for the same reason the recurrence stage does it: a single +// expression able to express all three endings — a duration, a clock, or nothing +// at all — matches far more than the three things it is for. +func extractReminder(input string, spans []Span) (reminder, []Span) { + m := reReminderLead.FindStringSubmatchIndex(input) + if m == nil { + return reminder{}, spans + } + + rem := reminder{found: true} + start, end := m[2], m[3] + tail := input[end:] + + if t := reReminderOffset.FindStringSubmatchIndex(tail); t != nil { + n := 1 + if t[2] >= 0 { + n = spokenNumber(tail[t[2]:t[3]]) + } + rem.offsetMin = -n * unitMinutes(tail[t[4]:t[5]]) + end += t[1] + } else if t := reReminderClock.FindStringSubmatchIndex(tail); t != nil { + hour := atoi(tail, t[2], t[3]) + minute := 0 + if t[4] >= 0 { + minute = atoi(tail, t[4], t[5]) + } + // An impossible clock is not a reminder. Saying nothing is better than + // setting one for 25:00, which the store would happily keep and never fire. + if !valid(hour, minute) { + return reminder{}, spans + } + rem.hour, rem.min, rem.hasClock = hour, minute, true + end += t[1] + } + + return rem, append(spans, Span{Start: start, End: end, Kind: KindReminder}) +} + +// spokenNumber reads the count in front of a unit. People dictating say the word +// rather than the digit, and "mind mig en time før" is the commonest form there is. +func spokenNumber(w string) int { + switch strings.ToLower(w) { + case "en", "et", "a", "an": + return 1 + case "to", "two": + return 2 + case "tre", "three": + return 3 + } + if n, err := strconv.Atoi(w); err == nil { + return n + } + return 1 +} + +// unitMinutes turns the unit into minutes. "halv time" and "kvarter" carry their +// own count, so they come first: with a leading "en" they multiply by one and come +// out as themselves. +func unitMinutes(unit string) int { + u := strings.ToLower(strings.Join(strings.Fields(unit), " ")) + switch { + case strings.HasPrefix(u, "halv"): + return 30 + case strings.HasPrefix(u, "kvarter"): + return 15 + case strings.HasPrefix(u, "min"): + return 1 + case strings.HasPrefix(u, "time"), strings.HasPrefix(u, "hour"): + return 60 + case strings.HasPrefix(u, "dag"), strings.HasPrefix(u, "day"): + return 1440 + } + return 0 +} + +// reNoReminder finds the line asking not to be reminded. The account-wide default +// gives every task with a clock an alarm, and this is how one task opts out while +// it is being written rather than afterwards. +var reNoReminder = regexp.MustCompile( + `(?i)(?:^|\s)(ingen\s+(?:alarm|påmindelse|paamindelse)|uden\s+alarm` + + `|no\s+alarm|no\s+reminder|without\s+an?\s+alarm)\b`) + +func extractNoReminder(input string, spans []Span) (bool, []Span) { + m := reNoReminder.FindStringSubmatchIndex(input) + if m == nil { + return false, spans + } + return true, append(spans, Span{Start: m[2], End: m[3], Kind: KindReminder}) +} diff --git a/internal/quickadd/quickadd_test.go b/internal/quickadd/quickadd_test.go index aacc761..3fe7c7d 100644 --- a/internal/quickadd/quickadd_test.go +++ b/internal/quickadd/quickadd_test.go @@ -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) + } +} diff --git a/internal/store/migrations/0036_alarm_som_standard.sql b/internal/store/migrations/0036_alarm_som_standard.sql new file mode 100644 index 0000000..8b3b2e8 --- /dev/null +++ b/internal/store/migrations/0036_alarm_som_standard.sql @@ -0,0 +1,23 @@ +-- Alarm som standard på opgaver med et klokkeslæt. +-- +-- Påmindelser har virket længe og skulle sættes én ad gangen: en opgave med +-- "i morgen kl 9" fik en tid og ingen alarm, for intet skaber en påmindelse af sig +-- selv. Det er den rigtige standard for en opgave UDEN klokkeslæt — der er intet +-- tidspunkt at ringe på — men for en med et er det en omvej, man går hver gang. +-- +-- Minutter i forhold til forfaldstiden, og NULL betyder slået fra. Én nullable +-- kolonne frem for et flueben plus et tal, så tilstanden "slået til uden +-- forskydning" ikke findes og derfor ikke kan blive uenig med sig selv. +-- +-- Fortegnet er det samme som i reminders.offset_min, fordi det er den kolonne, +-- værdien ender i: lageret LÆGGER tallet til forfaldstiden, så en alarm FØR er +-- negativ og 0 er præcis på tidspunktet. To forskellige fortegnsregler for det +-- samme begreb er den slags forskel, der kun viser sig som en alarm, der ringer +-- på det forkerte tidspunkt. +-- +-- NULL for alle, der findes i dag, og det er med vilje. At slå den til i en +-- migrering ville give hver eksisterende bruger alarmer, de ikke har bedt om, på +-- opgaver de har liggende — og det her er software, andre selv driver. En +-- indstilling, man selv slår til, er en anden sag end en notifikation, der +-- pludselig kommer. +ALTER TABLE users ADD COLUMN default_reminder_min INTEGER; diff --git a/internal/store/users.go b/internal/store/users.go index 2d757fa..97a43d3 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -46,7 +46,22 @@ type User struct { // NavOrder is the order this person wants the fixed views in, by key. Empty // means the order the program ships with. Unknown keys are ignored and missing // ones are appended, so adding a view later is not a migration. - NavOrder []string + NavOrder []string + + // DefaultReminderMin gives every task that has a clock an alarm without being + // asked, as minutes relative to the due moment: negative for before it, zero + // for at it. Nil is off, which is what every account starts as. + // + // The same sign convention as reminders.offset_min, because this is the column + // the value ends up in — the store ADDS it to the due time. Two sign rules for + // one idea is the kind of difference that only shows up as an alarm going off + // at the wrong moment. + // + // Only tasks with a clock. A task due "tomorrow" with no time has no moment to + // ring at, and DueReminders selects the offset form only when due_datetime is + // set, so an alarm there would be a row that never fires. + DefaultReminderMin *int + CreatedAt time.Time UpdatedAt time.Time } @@ -136,7 +151,8 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) { } const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled, - avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at` + avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, + default_reminder_min, created_at, updated_at` func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) { var u User @@ -145,10 +161,12 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e var created, updated int64 var collapsed, navOrder string + var defaultReminder sql.NullInt64 err := db.QueryRowContext(ctx, query, args...).Scan( &u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled, - &u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated) + &u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, + &defaultReminder, &created, &updated) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -172,6 +190,10 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e _ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed) u.NavOrder = []string{} _ = json.Unmarshal([]byte(navOrder), &u.NavOrder) + if defaultReminder.Valid { + v := int(defaultReminder.Int64) + u.DefaultReminderMin = &v + } u.CreatedAt = time.Unix(created, 0).UTC() u.UpdatedAt = time.Unix(updated, 0).UTC() return &u, nil @@ -691,3 +713,22 @@ func (db *DB) UserByICSToken(ctx context.Context, token string) (*User, error) { } return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE ics_token = ?`, token) } + +// SetDefaultReminder records whether tasks with a clock get an alarm unasked, and +// how far ahead of the due moment it rings. Nil turns it off. +func (db *DB) SetDefaultReminder(ctx context.Context, userID string, minutes *int) error { + var value any + if minutes != nil { + value = *minutes + } + res, err := db.ExecContext(ctx, + `UPDATE users SET default_reminder_min = ?, updated_at = ? WHERE id = ?`, + value, time.Now().Unix(), userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 5715c2d..3fa115e 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -5848,6 +5848,84 @@ test('den runde knap sætter markøren i feltet, så tastaturet kan diktere', as expect(trouble).toEqual([]); }); +/** + * En talt opgave kan sætte sin egen alarm. + * + * Det var omvejen: en dikteret "i morgen kl 9" fik en tid og INGEN alarm, for + * ingenting skaber en påmindelse af sig selv — `CreateReminder` blev kun kaldt fra + * sit eget endepunkt. Så skulle man bagefter åbne opgaven, finde Påmindelser og + * vælge et klokkeslæt, og den omvej er længere end at skrive opgaven. + * + * Nu siger sætningen det selv, og det gælder uanset hvor stemmen kommer ind: + * tastaturets diktering, en genvej eller noget tredje ender som tekst gennem den + * samme parser. + * + * Prøven går hele vejen til skærmbilledet frem for at stoppe ved svaret, fordi + * forskydningen er negativ i lageret — det lægger den til forfaldstiden — og + * "-60 min." på skærmen er et fortegn, læseren selv skal oversætte. + */ +test('en talt opgave kan sætte sin egen alarm', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.setViewportSize(PHONE); + await page.goto('/'); + + await page.getByRole('button', { name: 'Tal en opgave ind' }).click(); + const felt = page.locator('.sheet input[type="text"]'); + + // Set, før den sendes: alarmen lyser op som et forstået felt, ved siden af + // datoen. Det er hele pointen med fremvisningen — en parser, man ikke kan se + // arbejde, holder man op med at stole på, første gang den tager fejl. + await felt.fill('booke tandrens hos Lisa i morgen kl 9 mind mig en time før'); + await expect(page.locator(".sheet mark[data-kind='reminder']")).toHaveText('mind mig en time før'); + + await felt.press('Enter'); + await expect(page.locator('.sheet.open')).toHaveCount(0); + + // Opgaven er i morgen, så den står ikke på I dag — se de andre prøver her. + // Et navn ingen anden prøve bruger, og det er ikke pynt: suiten kører med én + // arbejder mod ÉN database, så "ring til tandlægen" findes fire steder i denne + // fil, når denne prøve kommer til. `.first()` ramte en af de andres opgave, som + // ikke har nogen alarm — og kørt alene bestod prøven, fordi der kun var én. + await page.goto('/upcoming'); + await page.getByText('booke tandrens hos Lisa').click(); + + // Og alarmen står der, skrevet som ord og ikke som et fortegn. + await expect(page.getByText('1 time før')).toBeVisible(); + + expect(trouble).toEqual([]); +}); + +/** + * En alarm, der ikke kan ringe, bliver sagt højt. + * + * "mind mig en time før" på en opgave med en dag men ingen klokketid har intet at + * være en time før: `DueReminders` vælger forskydningsformen kun + * `AND t.due_datetime IS NOT NULL`, så rækken ville stå i tabellen og aldrig fyre. + * + * Det er den ene slags påmindelse, hvis fejl intet bagefter afslører — der står + * ingen række at savne, og man opdager den først, da den ikke ringede. Derfor + * bliver opgaven oprettet og beskeden sagt, i stedet for at alarmen tabes i + * stilhed. + */ +test('en alarm uden klokkeslæt at regne tilbage fra bliver sagt højt', async ({ page }) => { + const trouble = watchForTrouble(page); + await page.setViewportSize(PHONE); + await page.goto('/'); + + await page.getByRole('button', { name: 'Tal en opgave ind' }).click(); + const felt = page.locator('.sheet input[type="text"]'); + await felt.fill('hente pakken på posthuset i morgen mind mig en time før'); + await felt.press('Enter'); + + await expect(page.getByText(/Alarmen er ikke/)).toBeVisible(); + + // Opgaven er lavet alligevel. Et forkert ord må ikke koste tanken. + await page.goto('/upcoming'); + await expect(page.getByText('hente pakken på posthuset')).toBeVisible(); + + expect(trouble).toEqual([]); +}); + test('skuffens felt stjæler ikke skriv-hvor-som-helst-genvejen', async ({ page }) => { // Genvejen finder feltet med querySelector('[data-quickadd]'), som returnerer // FØRSTE match i dokumentet. Skuffens felt må derfor ikke bære mærket — ellers diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 924f380..7e79115 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -241,6 +241,9 @@ export const api = { // a save button, and this writes on every click of a chevron. setNavOrder: (order) => put('/auth/nav-order', { order }), setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }), + // null slu00e5r den fra. Minutter i forhold til forfaldstiden, negativt for fu00f8r — + // samme fortegn som reminders.offset_min, fordi det er den kolonne, den ender i. + setDefaultReminder: (minutes) => put('/auth/default-reminder', { minutes }), signup: (data) => post('/auth/signup', data), forgotPassword: (email) => post('/auth/password/forgot', { email }), resetPassword: (token, password) => post('/auth/password/reset', { token, password }), diff --git a/web/src/lib/components/TaskDetail.svelte b/web/src/lib/components/TaskDetail.svelte index 140f690..94f3375 100644 --- a/web/src/lib/components/TaskDetail.svelte +++ b/web/src/lib/components/TaskDetail.svelte @@ -14,7 +14,7 @@ import { app, projectName, clockOf } from '$lib/stores.svelte.js'; import { focusOnMount } from '$lib/focus.js'; import { t, tag } from '$lib/i18n.svelte.js'; - import { REPEATS } from '$lib/when.js'; + import { REPEATS, reminderOffsetLabel } from '$lib/when.js'; import { readList } from '$lib/pastelist.js'; let { task, onclose } = $props(); @@ -757,7 +757,9 @@ {#each reminders as reminder (reminder.id)}
- {reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`} + {reminder.remind_at + ? stamp(reminder.remind_at) + : reminderOffsetLabel(reminder.offset_min)} {#if reminder.sent}{t('detail.sent')}{/if}