Make /reminders the complete cancellation surface (V-719)

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>
This commit is contained in:
2026-08-15 17:19:38 +04:00
parent 85a3397bf4
commit 1b5d35ad37
4 changed files with 323 additions and 18 deletions
+111 -15
View File
@@ -3,8 +3,10 @@ package main
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/kami/maven/internal/ipc"
@@ -23,11 +25,14 @@ var remindersTmpl = parsePage("reminders", remindersHTML, nil)
// (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 {
Created string
Fires string
Status string
Detail string
Text string
ID int64
Created string
Fires string
Schedule string
Status string
Detail string
Text string
CanCancel bool
}
// reminderText unwraps the {"text":...} payload the router writes.
@@ -51,8 +56,16 @@ func reminderText(payload string) string {
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
@@ -60,25 +73,108 @@ func reminderRows(rs []ipc.Reminder) []reminderRow {
detail = "retry " + r.NextAttemptTs.Local().Format("02 Jan 15:04")
}
out = append(out, reminderRow{
Created: r.CreatedTs.Local().Format("02 Jan 15:04"),
Fires: r.FireTs.Local().Format("02 Jan 15:04"),
Status: status,
Detail: detail,
Text: reminderText(r.Payload),
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
}
reminders, err := core.ListReminders(r.Context(), 50)
if err != nil {
writeProblem(w, r, http.StatusBadGateway, problemCoreReadFailed,
"reminders unavailable", fmt.Errorf("list reminders: %w", err))
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
}
renderPage(w, remindersTmpl, map[string]any{"Reminders": reminderRows(reminders)})
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)})
}