Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5228aed8 | ||
|
|
9155e799b1 | ||
|
|
5764271579 | ||
|
|
8a6329bfc9 | ||
|
|
fb8abcaa74 | ||
|
|
956e57f234 | ||
|
|
661e47ebdf |
@@ -273,6 +273,49 @@ 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, "null"]
|
||||
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]
|
||||
@@ -4801,6 +4844,17 @@ components:
|
||||
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.
|
||||
example: ["labels"]
|
||||
default_reminder_min:
|
||||
type: [integer, "null"]
|
||||
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.
|
||||
|
||||
A type union 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: -10
|
||||
created_at: { type: string, format: date-time }
|
||||
|
||||
RecoveryCodes:
|
||||
@@ -4991,6 +5045,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 +5071,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 }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+44
-3
@@ -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
|
||||
}
|
||||
|
||||
+133
-8
@@ -86,6 +86,31 @@ async function nyNote(side) {
|
||||
return felt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Som `nyNote`, men UDEN klikket i feltet.
|
||||
*
|
||||
* Klikket er netop det, markør-prøverne ikke må gøre: de måler, at markøren
|
||||
* står i overskriften, uden at nogen har sat den. Ventetiden skal de til
|
||||
* gengæld have, og manglen på den kostede to røde CI-kørsler.
|
||||
*
|
||||
* `click()` på "Ny note" vender tilbage, så snart klikket er sendt, mens noten
|
||||
* først skal oprettes på serveren og tegnes bagefter. Uden ventetiden går det,
|
||||
* der tastes, ind i den FORRIGE note eller ingen steder — og så er der heller
|
||||
* ingen gemning at vente på, så en `waitForResponse` løber tørt i stedet. Det
|
||||
* er to forskellige påstande, der fejler, med én årsag. Se kommentaren over
|
||||
* `nyNote`: faren var skrevet ned, med sin løsning, før prøverne blev skrevet.
|
||||
*
|
||||
* Overskriften er værnet frem for bare det tomme felt: <h1> skrives af samme
|
||||
* effekt, der sætter markøren, så når den er der, er markøren sat.
|
||||
*/
|
||||
async function nyTomNote(side) {
|
||||
await side.getByRole('button', { name: 'Ny note' }).click();
|
||||
const felt = side.getByRole('textbox', { name: 'Notens tekst' });
|
||||
await expect(felt).toHaveText('');
|
||||
await expect(side.locator('.editor h1')).toHaveCount(1);
|
||||
return felt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kommendes liste kan se længere frem end en uge.
|
||||
*
|
||||
@@ -5721,9 +5746,19 @@ async function caretBlock(page) {
|
||||
test('en ny note åbner med markøren i overskriften', async ({ page }) => {
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await nyTomNote(page);
|
||||
|
||||
expect(await caretBlock(page), 'markøren skal stå i h1').toBe('h1');
|
||||
// `expect.poll` og ikke én læsning: `click()` vender tilbage, så snart klikket er
|
||||
// sendt, mens markøren sættes efter en netværksrundtur OG en gentegning. Lokalt
|
||||
// er rundturen under et millisekund, så ét snapshot ramte altid — på en runner
|
||||
// ramte det `null`, og prøven bestod på main ved held frem for ved regel.
|
||||
//
|
||||
// Kontrollen nedenfor (`stjæler ikke markøren`) må IKKE laves om til en poll:
|
||||
// den forventer null, så en poll ville bestå på første læsning, uanset hvad
|
||||
// koden gjorde. Den ankrer sig i stedet på overskriftens tekst først.
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1' })
|
||||
.toBe('h1');
|
||||
|
||||
// Og den står et sted, man kan skrive: det, der tastes, bliver titlen, og
|
||||
// titlen er det, listen kalder noten.
|
||||
@@ -5742,10 +5777,18 @@ test('en note med indhold stjæler ikke markøren', async ({ page }) => {
|
||||
// overskriften og skubbe det, man var i gang med.
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/noter');
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await nyTomNote(page);
|
||||
|
||||
// Lyt FØR der skrives. Her stod `waitForTimeout(1200)`, og kommentaren over
|
||||
// `gemt` i toppen af filen nævner præcis det tal: et fast ophold er et gæt om,
|
||||
// hvor hurtig maskinen er, og gættet holder indtil CI er travl. Det holdt indtil
|
||||
// CI var travl — gemningen var ikke nået, genindlæsningen smed det skrevne væk,
|
||||
// og prøven faldt på at noten ikke fandtes frem for på markøren, som er det den
|
||||
// måler. Svaret på PATCH'en er det eneste, der beviser, at serveren har hørt om
|
||||
// det.
|
||||
const gemt = noteGemt(page);
|
||||
await page.keyboard.type('Allerede skrevet');
|
||||
// Vent til den er gemt, så den er en note med indhold næste gang den åbnes.
|
||||
await page.waitForTimeout(1200);
|
||||
await gemt;
|
||||
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: /Allerede skrevet/ }).first().click();
|
||||
@@ -5762,9 +5805,11 @@ test('urd-døren opfører sig ens: ny note, markør i overskriften', async ({ pa
|
||||
const trouble = watchForTrouble(page);
|
||||
await page.goto('/urd');
|
||||
await expect(page).toHaveURL(/\/noter$/);
|
||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||
await nyTomNote(page);
|
||||
|
||||
expect(await caretBlock(page), 'markøren skal stå i h1 også i urd').toBe('h1');
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 også i urd' })
|
||||
.toBe('h1');
|
||||
expect(trouble).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -5787,7 +5832,9 @@ test('en note oprettet fra et projekt åbner også med markøren i overskriften'
|
||||
await page.getByRole('button', { name: 'Ny note', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/noter\?note=/);
|
||||
|
||||
expect(await caretBlock(page), 'markøren skal stå i h1 efter navigationen').toBe('h1');
|
||||
await expect
|
||||
.poll(() => caretBlock(page), { message: 'markøren skal stå i h1 efter navigationen' })
|
||||
.toBe('h1');
|
||||
await page.keyboard.type('Dæk og kort');
|
||||
await expect(page.locator('.editor h1').first()).toHaveText('Dæk og kort');
|
||||
|
||||
@@ -5848,6 +5895,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
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)}
|
||||
<div class="reminder">
|
||||
<span>{reminder.remind_at ? stamp(reminder.remind_at) : `${reminder.offset_min} min.`}</span>
|
||||
<span>{reminder.remind_at
|
||||
? stamp(reminder.remind_at)
|
||||
: reminderOffsetLabel(reminder.offset_min)}</span>
|
||||
{#if reminder.sent}<span class="sent">{t('detail.sent')}</span>{/if}
|
||||
<button class="remove" onclick={() => removeReminder(reminder)} aria-label={t('detail.delete')}>
|
||||
×
|
||||
|
||||
@@ -270,6 +270,8 @@ export const da = {
|
||||
// Diktat, ikke tale: programmet laver ingen talegenkendelse, det stiller
|
||||
// feltet frem, så tastaturets egen mikrofon kan bruges. Hjælpelinjen siger
|
||||
// det, fordi en knap med en mikrofon på lover noget andet.
|
||||
'task.alarmNeedsATime':
|
||||
'Opgaven er oprettet. Alarmen er ikke — "en time før" skal have et klokkeslæt at regne tilbage fra.',
|
||||
'task.speak': 'Tal en opgave ind',
|
||||
'task.speakHint': 'Tryk mikrofonen på tastaturet, og sig hvad der skal gøres.',
|
||||
'task.syntaxProject': 'projekt',
|
||||
@@ -403,6 +405,18 @@ export const da = {
|
||||
'account.recoveryLeftOne': '{n} gendannelseskode tilbage.',
|
||||
'account.recoveryLeftMany': '{n} gendannelseskoder tilbage.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarm som standard',
|
||||
'alarm.hint':
|
||||
'Opgaver med bu00e5de en dato og et klokkeslu00e6t fu00e5r en alarm, uden at du beder om den. En opgave uden klokkeslu00e6t fu00e5r ingen — der er intet tidspunkt at ringe pu00e5. Skriv "ingen alarm" i linjen for at springe en enkelt over, eller "mind mig en time fu00f8r" for at give den sin egen.',
|
||||
'alarm.on': 'Giv opgaver med et klokkeslu00e6t en alarm',
|
||||
'alarm.when': 'Hvornu00e5r',
|
||||
'alarm.atDue': 'Pu00e5 tidspunktet',
|
||||
'alarm.minBefore': '{n} min. fu00f8r',
|
||||
'alarm.hourBefore': '1 time fu00f8r',
|
||||
'alarm.hoursBefore': '{n} timer fu00f8r',
|
||||
'alarm.dayBefore': 'Dagen fu00f8r',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifikationer på enheden',
|
||||
'push.hint':
|
||||
@@ -762,6 +776,14 @@ export const da = {
|
||||
'detail.newSubtask': 'Ny undertask',
|
||||
'detail.files': 'Filer',
|
||||
'detail.delete': 'Slet',
|
||||
'detail.remindAtDue': 'På tidspunktet',
|
||||
'detail.remindBefore': '{when} før',
|
||||
'detail.remindAfter': '{when} efter',
|
||||
'when.minuteMany': '{n} min.',
|
||||
'when.hourOne': '1 time',
|
||||
'when.hourMany': '{n} timer',
|
||||
'when.dayOne': '1 dag',
|
||||
'when.dayMany': '{n} dage',
|
||||
'detail.reminders': 'Påmindelser',
|
||||
'detail.newReminder': 'Ny påmindelse',
|
||||
'detail.sent': 'sendt',
|
||||
|
||||
@@ -264,6 +264,8 @@ export const en = {
|
||||
'task.pastedAdd': 'Add {n} tasks',
|
||||
'task.pastedCancel': 'Cancel',
|
||||
'task.new': 'New task',
|
||||
'task.alarmNeedsATime':
|
||||
'The task is created. The alarm is not — "an hour before" needs a time to count back from.',
|
||||
'task.speak': 'Speak a task',
|
||||
'task.speakHint': 'Tap the microphone on your keyboard and say what needs doing.',
|
||||
'task.syntaxProject': 'project',
|
||||
@@ -397,6 +399,18 @@ export const en = {
|
||||
'account.recoveryLeftOne': '{n} recovery code left.',
|
||||
'account.recoveryLeftMany': '{n} recovery codes left.',
|
||||
|
||||
// --- settings: alarm as standard -----------------------------------------------
|
||||
'alarm.title': 'Alarms by default',
|
||||
'alarm.hint':
|
||||
'Tasks with both a date and a time get an alarm without being asked. A task with no time gets none — there is no moment to ring at. Write "no alarm" in the line to skip a single one, or "remind me an hour before" to give it its own.',
|
||||
'alarm.on': 'Give tasks with a time an alarm',
|
||||
'alarm.when': 'When',
|
||||
'alarm.atDue': 'At the time',
|
||||
'alarm.minBefore': '{n} min before',
|
||||
'alarm.hourBefore': '1 hour before',
|
||||
'alarm.hoursBefore': '{n} hours before',
|
||||
'alarm.dayBefore': 'The day before',
|
||||
|
||||
// --- settings: notifications ---------------------------------------------------
|
||||
'push.title': 'Notifications on this device',
|
||||
'push.hint':
|
||||
@@ -756,6 +770,14 @@ export const en = {
|
||||
'detail.newSubtask': 'New sub-task',
|
||||
'detail.files': 'Files',
|
||||
'detail.delete': 'Delete',
|
||||
'detail.remindAtDue': 'At the time',
|
||||
'detail.remindBefore': '{when} before',
|
||||
'detail.remindAfter': '{when} after',
|
||||
'when.minuteMany': '{n} min',
|
||||
'when.hourOne': '1 hour',
|
||||
'when.hourMany': '{n} hours',
|
||||
'when.dayOne': '1 day',
|
||||
'when.dayMany': '{n} days',
|
||||
'detail.reminders': 'Reminders',
|
||||
'detail.newReminder': 'New reminder',
|
||||
'detail.sent': 'sent',
|
||||
|
||||
@@ -38,6 +38,7 @@ export const SETTINGS = [
|
||||
// Notifikationer
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'dagens-plan', key: 'ai.plan', tab: 'settings.tab.notifications', words: 'plan morgen daily' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'push', key: 'push.title', tab: 'settings.tab.notifications', words: 'push besked notification badge' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'standard-alarm', key: 'alarm.title', tab: 'settings.tab.notifications', words: 'alarm pu00e5mindelse standard reminder default klokkeslu00e6t' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'seneste', key: 'push.recent', tab: 'settings.tab.notifications', words: 'log historik' },
|
||||
{ path: '/indstillinger/notifikationer', anchor: 'version', key: 'push.version', tab: 'settings.tab.notifications', words: 'opdatering update version', admin: true },
|
||||
|
||||
|
||||
@@ -687,6 +687,10 @@ class AppState {
|
||||
try {
|
||||
const task = await api.quickAdd(text, projectId, sectionId, parentId);
|
||||
this.upsert(task);
|
||||
// Sagt højt frem for tabt. En alarm, der ikke kunne sættes, er den eneste
|
||||
// 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.
|
||||
if (task?.reminder_not_set) this.toast(t('task.alarmNeedsATime'));
|
||||
return task;
|
||||
} catch (e) {
|
||||
this.toast(humanMessage(e));
|
||||
|
||||
@@ -184,3 +184,37 @@ function every(interval, unit) {
|
||||
if (interval === 2) return t(`repeat.everyOther.${unit}`);
|
||||
return t(`repeat.everyN.${unit}`, { n: interval });
|
||||
}
|
||||
|
||||
/**
|
||||
* En påmindelses forskydning, skrevet som ord.
|
||||
*
|
||||
* Lageret LÆGGER tallet til forfaldstiden (`due_datetime + offset_min * 60`), så
|
||||
* en alarm før er negativ. Rå blev det vist som "-60 min.": et fortegn, læseren
|
||||
* selv skal oversætte, på netop den oplysning man skal kunne stole på uden at
|
||||
* regne — og en alarm, man har misforstået, opdager man først, da den ringede på
|
||||
* det forkerte tidspunkt.
|
||||
*
|
||||
* Retningen er skilt fra varigheden, så der kun er ét sæt enheder at vedligeholde.
|
||||
* En positiv forskydning kan ikke laves hverken i hurtig tilføjelse eller i
|
||||
* skærmbilledet — kun gennem API'et — men den har et ord her frem for at falde
|
||||
* tilbage på et tal, for det er den slags gren, der står urørt indtil den en dag
|
||||
* ikke gør.
|
||||
*/
|
||||
export function reminderOffsetLabel(minutes) {
|
||||
if (minutes === 0) return t('detail.remindAtDue');
|
||||
const span = durationLabel(Math.abs(minutes));
|
||||
return minutes < 0 ? t('detail.remindBefore', { when: span }) : t('detail.remindAfter', { when: span });
|
||||
}
|
||||
|
||||
/** Minutter som hele dage, hele timer eller minutter — den største, der går op. */
|
||||
function durationLabel(minutes) {
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return days === 1 ? t('when.dayOne') : t('when.dayMany', { n: days });
|
||||
}
|
||||
if (minutes % 60 === 0) {
|
||||
const hours = minutes / 60;
|
||||
return hours === 1 ? t('when.hourOne') : t('when.hourMany', { n: hours });
|
||||
}
|
||||
return t('when.minuteMany', { n: minutes });
|
||||
}
|
||||
|
||||
@@ -5,6 +5,39 @@
|
||||
import * as push from '$lib/push.js';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- alarm som standard -------------------------------------------------------
|
||||
|
||||
// Minutter i forhold til forfaldstiden, negativt for fu00f8r. Samme fortegn som
|
||||
// reminders.offset_min, fordi det er den kolonne, vu00e6rdien ender i — to
|
||||
// fortegnsregler for det samme begreb viser sig kun som en alarm pu00e5 et forkert
|
||||
// tidspunkt.
|
||||
const ALARM_CHOICES = [0, -5, -10, -15, -30, -60, -120, -1440];
|
||||
|
||||
// Valgt, nu00e5r indstillingen slu00e5s til igen. Pu00e5 tidspunktet er sju00e6ldent det, man
|
||||
// mener med en alarm: der er ingen varsel i at blive mindet om noget, mens det
|
||||
// sker.
|
||||
const ALARM_DEFAULT = -10;
|
||||
|
||||
function alarmLabel(minutes) {
|
||||
if (minutes === 0) return t('alarm.atDue');
|
||||
const n = Math.abs(minutes);
|
||||
if (n === 1440) return t('alarm.dayBefore');
|
||||
if (n === 60) return t('alarm.hourBefore');
|
||||
if (n % 60 === 0) return t('alarm.hoursBefore', { n: n / 60 });
|
||||
return t('alarm.minBefore', { n });
|
||||
}
|
||||
|
||||
async function saveAlarm(minutes) {
|
||||
try {
|
||||
await api.setDefaultReminder(minutes);
|
||||
// Kontoen genlu00e6ses frem for at blive gu00e6ttet lokalt: serveren er den, der
|
||||
// afviser et positivt tal, su00e5 det, der vises, skal vu00e6re det, der blev gemt.
|
||||
app.user = await api.me();
|
||||
} catch (e) {
|
||||
app.toast(humanMessage(e));
|
||||
}
|
||||
}
|
||||
|
||||
// --- web push ----------------------------------------------------------------
|
||||
|
||||
let pushState = $state('loading');
|
||||
@@ -254,6 +287,37 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="standard-alarm">
|
||||
<header>
|
||||
<h2>{t('alarm.title')}</h2>
|
||||
<p class="hint">{t('alarm.hint')}</p>
|
||||
</header>
|
||||
|
||||
<label class="check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={app.user?.default_reminder_min != null}
|
||||
onchange={(e) => saveAlarm(e.currentTarget.checked ? ALARM_DEFAULT : null)}
|
||||
/>
|
||||
<span>{t('alarm.on')}</span>
|
||||
</label>
|
||||
|
||||
{#if app.user?.default_reminder_min != null}
|
||||
<div class="field">
|
||||
<label for="alarmwhen">{t('alarm.when')}</label>
|
||||
<select
|
||||
id="alarmwhen"
|
||||
value={app.user.default_reminder_min}
|
||||
onchange={(e) => saveAlarm(Number(e.currentTarget.value))}
|
||||
>
|
||||
{#each ALARM_CHOICES as minutes (minutes)}
|
||||
<option value={minutes}>{alarmLabel(minutes)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel" id="push">
|
||||
<header>
|
||||
<h2>{t('push.title')}</h2>
|
||||
|
||||
Reference in New Issue
Block a user