1b5d35ad37
GET merges every pending reminder, ordered by next fire, with the latest 50 rows and no duplicates, so old pending work cannot fall off a history window. Recurring rows show their next fire and cron expression. A pending row carries an inline cancel POST. Success answers 303 so a refresh cannot repeat the mutation. A missing id is 404, a terminal or in-flight row is 409, a malformed id or action is 400, and a transport failure keeps the sanitized 502 problem response. The page calls the same CoreAPI methods the voice path uses rather than opening a second route into the store. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
181 lines
5.4 KiB
Go
181 lines
5.4 KiB
Go
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)})
|
|
}
|