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}