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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user