package main import ( _ "embed" "encoding/json" "errors" "fmt" "net/http" "strconv" "strings" "github.com/kami/maven/internal/ipc" ) //go:embed reminders.html var remindersHTML string var remindersTmpl = parsePage("reminders", remindersHTML, nil) // reminderRow is one line on /reminders, with the payload unwrapped and both // timestamps already in his clock. // // The page rendered `{{.Payload}}` and the UTC instant, so a reminder read // `{"text":"выпить таблетки"}` and fired an hour off what he was told // (Vikunja #469). Neither is a formatting nicety: the envelope is an internal // shape he never chose, and a time on a page he reads is the time on his wall. type reminderRow struct { ID int64 Created string Fires string Schedule string Status string Detail string Text string CanCancel bool } // reminderText unwraps the {"text":...} payload the router writes. // // A copy of store.ReminderText rather than a call to it, because mavweb is one // of the pure-Go daemons and internal/store carries the CGO sqlite driver. The // ipc DTO is decoupled from the store on purpose, so the unwrap belongs to // whoever renders it. Payload that is not that shape is shown as he said it. func reminderText(payload string) string { var m map[string]any if err := json.Unmarshal([]byte(payload), &m); err == nil { if t, ok := m["text"]; ok { if s, isStr := t.(string); isStr && s != "" { return s } } } return strings.TrimSpace(payload) } func reminderRows(rs []ipc.Reminder) []reminderRow { out := make([]reminderRow, 0, len(rs)) for _, r := range rs { fire := r.NextFireTs if fire.IsZero() { fire = r.FireTs } status := r.Status detail := "" schedule := "" if r.Cron != "" { schedule = "recurring · " + r.Cron } if !r.DeliveryBlockedTs.IsZero() { status = "blocked" detail = r.DeliveryBlockedError } else if r.DeliveryAttempts > 0 && !r.NextAttemptTs.IsZero() { detail = "retry " + r.NextAttemptTs.Local().Format("02 Jan 15:04") } out = append(out, reminderRow{ ID: r.ID, Created: r.CreatedTs.Local().Format("02 Jan 15:04"), Fires: fire.Local().Format("02 Jan 15:04"), Schedule: schedule, Status: status, Detail: detail, Text: reminderText(r.Payload), CanCancel: r.Status == "pending", }) } return out } // remindersForPage keeps every pending row reachable while retaining the // recent terminal history the page already showed. Pending rows come first in // firing order (the CoreAPI contract); IDs present in the recent window are not // duplicated below them. func remindersForPage(pending, recent []ipc.Reminder) []ipc.Reminder { out := make([]ipc.Reminder, 0, len(pending)+len(recent)) seen := make(map[int64]bool, len(pending)) for _, reminder := range pending { out = append(out, reminder) seen[reminder.ID] = true } for _, reminder := range recent { if seen[reminder.ID] { continue } out = append(out, reminder) } return out } func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if !requireCore(w, r, core, "reminders") { return } msg := "" if r.Method == http.MethodGet && r.URL.Query().Get("cancelled") == "1" { msg = "reminder cancelled" } switch r.Method { case http.MethodGet: case http.MethodPost: if strings.TrimSpace(r.FormValue("action")) != "cancel" { writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "unknown reminder action", nil) return } id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("id")), 10, 64) if err != nil || id <= 0 { writeProblem(w, r, http.StatusBadRequest, problemInvalidRequest, "invalid reminder id", err) return } if err := core.CancelReminder(r.Context(), id); err != nil { switch { case errors.Is(err, ipc.ErrReminderNotFound): writeProblem(w, r, http.StatusNotFound, problemResourceNotFound, "reminder not found", err) case errors.Is(err, ipc.ErrReminderInFlight): writeProblem(w, r, http.StatusConflict, problemCoreChangeFailed, "reminder delivery has already started", err) case errors.Is(err, ipc.ErrReminderState): writeProblem(w, r, http.StatusConflict, problemCoreChangeFailed, "reminder is no longer pending", err) default: writeProblem(w, r, http.StatusBadGateway, problemCoreChangeFailed, "reminder cancellation failed", fmt.Errorf("cancel reminder %d: %w", id, err)) } return } http.Redirect(w, r, "/reminders?cancelled=1", http.StatusSeeOther) return default: writeProblem(w, r, http.StatusMethodNotAllowed, problemMethodNotAllowed, "method not allowed", nil) return } pending, err := core.ListPendingReminders(r.Context(), 0) if err != nil { public := "reminders unavailable" if msg != "" { public = "reminder cancelled; refreshed list unavailable" } writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed, public, fmt.Errorf("list pending reminders: %w", err)) return } recent, err := core.ListReminders(r.Context(), 50) if err != nil { public := "reminders unavailable" if msg != "" { public = "reminder cancelled; refreshed list unavailable" } writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed, public, fmt.Errorf("list reminders: %w", err)) return } reminders := remindersForPage(pending, recent) renderPage(w, remindersTmpl, struct { Msg string Reminders []reminderRow }{msg, reminderRows(reminders)}) }