reminders: add ListReminders IPC + /reminders web page

Store layer: ListReminders returns the n most recent reminders
(newest first). IPC: new MethodListReminders wired through server,
client, and lockedAPI. Web: /reminders page with table of created
time, fire time, status badge, and payload text; empty state with
prompt to ask maven for a reminder. Sidebar entry under Automation.
This commit is contained in:
kami
2026-07-06 22:12:10 +04:00
parent d493be34b2
commit 6bab68e96d
8 changed files with 338 additions and 52 deletions
+1
View File
@@ -109,6 +109,7 @@ func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked } func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) { return ipc.Presence{}, errLocked }
func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { return 0, errLocked } func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) { return 0, errLocked }
func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { return errLocked } func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error { return errLocked }
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) { return nil, errLocked }
func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { return 0, errLocked } func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { return 0, errLocked }
func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return errLocked } func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error { return errLocked }
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked } func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) { return nil, errLocked }
+252 -52
View File
@@ -54,33 +54,210 @@ var traceHTML string
//go:embed notifications.html //go:embed notifications.html
var notificationsHTML string var notificationsHTML string
// navHTML — the shared site nav, parsed into every server-rendered template //go:embed reminders.html
// alongside ui.css so the pages read as one app. Invoke as var remindersHTML string
// {{template "nav" "<active-page>"}}; the argument highlights the current link.
const navHTML = `{{define "nav"}}<nav class=site> // ── Ethos Workstation Shell ──
<a href=/ class="{{if eq . "voice"}}active{{end}}">voice</a> //
<a href=/dash class="{{if eq . "dash"}}active{{end}}">dash</a> // Two template pieces that wrap every page:
<a href=/history class="{{if eq . "history"}}active{{end}}">history</a> // {{template "shellTop" "<page-key>"}} ← opens <html>, topbar, sidebar, content
<a href=/trace class="{{if eq . "trace"}}active{{end}}">trace</a> // {{template "shellBottom"}} ← closes content, inspector, </html>
<a href=/notifications class="{{if eq . "notifications"}}active{{end}}">notifications</a> //
<a href=/tools class="{{if eq . "tools"}}active{{end}}">tools</a> // The page-key argument highlights the active sidebar link and sets breadcrumbs.
<a href=/auth/passkey class="{{if eq . "passkey"}}active{{end}}">passkey</a>
</nav>{{end}}` // sidebarSections maps sidebar section → page entries {label, url, icon}
var sidebarSections = []struct {
Label string
Pages []struct{ Label, URL, Key string }
}{
{
Label: "Workspace",
Pages: []struct{ Label, URL, Key string }{
{Label: "Dashboard", URL: "/dash", Key: "dash"},
},
},
{
Label: "Infrastructure",
Pages: []struct{ Label, URL, Key string }{
{Label: "History", URL: "/history", Key: "history"},
},
},
{
Label: "Automation",
Pages: []struct{ Label, URL, Key string }{
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
},
},
{
Label: "AI",
Pages: []struct{ Label, URL, Key string }{
{Label: "Voice", URL: "/", Key: "voice"},
},
},
{
Label: "Settings",
Pages: []struct{ Label, URL, Key string }{
{Label: "Tools", URL: "/tools", Key: "tools"},
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
},
},
}
func sidebarActive(url, key string, activeKey string) string {
if key == activeKey {
return `class="active"`
}
return ""
}
// sidebarHTML renders the sidebar navigation given the active page key.
func sidebarHTML(active string) template.HTML {
var b strings.Builder
for _, sec := range sidebarSections {
b.WriteString(`<div class=sidebar-section>`)
b.WriteString(`<div class=sidebar-label>`)
b.WriteString(sec.Label)
b.WriteString(`</div>`)
for _, p := range sec.Pages {
cls := ""
if p.Key == active {
cls = ` class="active"`
}
b.WriteString(`<a href="`)
b.WriteString(p.URL)
b.WriteString(`"`)
b.WriteString(cls)
b.WriteString(`><span class=icon>`)
b.WriteString(pageIcon(p.Key))
b.WriteString(`</span><span>`)
b.WriteString(p.Label)
b.WriteString(`</span></a>`)
}
b.WriteString(`</div>`)
}
return template.HTML(b.String())
}
// pageIcon returns a lucide-style inline SVG icon path for the given page.
func pageIcon(key string) string {
switch key {
case "dash":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>`
case "history":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`
case "trace":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>`
case "notifications":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>`
case "reminders":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>`
case "voice":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>`
case "tools":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`
case "passkey":
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`
default:
return `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/></svg>`
}
}
// pageTitle returns the human-readable page title for the given key.
func pageTitle(key string) string {
switch key {
case "dash":
return "Dashboard"
case "history":
return "History"
case "trace":
return "Rule Trace"
case "notifications":
return "Notifications"
case "reminders":
return "Reminders"
case "voice":
return "Voice"
case "tools":
return "Tools"
case "passkey":
return "Passkey"
default:
return key
}
}
// shellTopHTML opens the shell and renders the top bar + sidebar.
// Usage: {{template "shellTop" "<page-key>"}}
const shellTopHTML = `{{define "shellTop"}}<!doctype html><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1">
<title>maven · {{pageTitle .}}</title>
<link rel=stylesheet href=/ui.css>
<div class=shell>
<header class=topbar>
<div class=breadcrumbs>
<span class=current>{{pageTitle .}}</span>
</div>
<div class=topbar-actions>
<div class=search-trigger onclick="window.__openSearch()" role=button tabindex=0>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Search
<span class=kbd-hint>Ctrl+/</span>
</div>
<button class=icon-btn onclick="window.__openPalette()" title="Command Palette (Ctrl+K)" aria-label="Command Palette">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 7 4 4 7 4"/><polyline points="17 4 20 4 20 7"/><polyline points="20 17 20 20 17 20"/><polyline points="7 20 4 20 4 17"/></svg>
</button>
<span class=conn-status>
<span class="dot online" id=connDot></span>
</span>
</div>
</header>
<div class=shell-body>
<aside class=sidebar>
{{sidebarHTML .}}
</aside>
<main class=content>
{{end}}`
// shellBottomHTML closes the content area, inspector, and shell.
// Usage: {{template "shellBottom"}}
const shellBottomHTML = `{{define "shellBottom"}}
</main>
<aside class=inspector id=inspector>
<div class=inspector-inner>
<div class=inspector-header>
<span id=inspectorTitle>Details</span>
<button class=inspector-close onclick="closeInspector()" aria-label="Close inspector">&times;</button>
</div>
<div class=inspector-body id=inspectorBody></div>
</div>
</aside>
</div>
</div>
<script src=/mavweb.js></script>
{{end}}`
// shellFuncs returns the FuncMap shared by every server-rendered page template.
func shellFuncs() template.FuncMap {
return template.FuncMap{
"pageTitle": pageTitle,
"sidebarHTML": sidebarHTML,
"ago": func(t time.Time) string {
if t.IsZero() {
return "never"
}
return time.Since(t).Round(time.Second).String() + " ago"
},
"connected": func() bool { return true }, // if page renders, core was available
}
}
// dashTmpl — the monitoring read surface, server-rendered from dash.html; // dashTmpl — the monitoring read surface, server-rendered from dash.html;
// a small fetch loop refreshes the tables in place. html/template escapes the // a small fetch loop refreshes the tables in place. html/template escapes the
// user text in facts/nudges. Read-only: browses the append-only store via // user text in facts/nudges. Read-only: browses the append-only store via
// CoreAPI, never writes — the store IS the audit trail, this just shows it. // CoreAPI, never writes — the store IS the audit trail, this just shows it.
var dashTmpl = template.Must(template.New("dash").Funcs(template.FuncMap{ var dashTmpl = template.Must(template.New("dash").Funcs(shellFuncs()).Parse(shellTopHTML + dashHTML + shellBottomHTML))
"ago": func(t time.Time) string {
// A zero timestamp (no presence signal yet, fresh DB) would make
// time.Since saturate to ~292y (MaxInt64) and render as garbage.
if t.IsZero() {
return "never"
}
return time.Since(t).Round(time.Second).String() + " ago"
},
}).Parse(navHTML + dashHTML))
func noCache(h http.Handler) http.Handler { func noCache(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -158,6 +335,9 @@ func main() {
mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/notifications", func(w http.ResponseWriter, r *http.Request) {
handleNotifications(w, r, core) handleNotifications(w, r, core)
}) })
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
handleReminders(w, r, core)
})
// ----- passkey (WebAuthn) endpoints ----- // ----- passkey (WebAuthn) endpoints -----
// Wired when both -core and a configured origin are present. The origin // Wired when both -core and a configured origin are present. The origin
// must match the browser's view of mavweb (e.g. https://maven.kvmx.ru). // must match the browser's view of mavweb (e.g. https://maven.kvmx.ru).
@@ -367,22 +547,19 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form // toolsTmpl — the enable surface. Server-rendered, no JS: a plain HTML form
// POSTs back to /tools to enable a proposal. html/template escapes tool names + // POSTs back to /tools to enable a proposal. html/template escapes tool names +
// utterances (they came from voice STT — untrusted text). // utterances (they came from voice STT — untrusted text).
var toolsTmpl = template.Must(template.New("tools").Funcs(template.FuncMap{ var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
"join": strings.Join, m := shellFuncs()
}).Parse(navHTML + toolsHTML)) m["join"] = strings.Join
return m
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
const toolsHTML = `<!doctype html><meta charset=utf-8> const toolsHTML = `{{template "shellTop" "tools"}}
<meta name=viewport content="width=device-width,initial-scale=1"> <h1>Tools</h1>
<title>maven · tools</title> <p class=hint>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
<link rel=stylesheet href=/ui.css>
{{template "nav" "tools"}}
<main class=page>
<h1>tools</h1>
<p class=muted>enabling requires step-up — <a href=/auth/passkey>assert a passkey</a> first.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}} {{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card> <section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2> <h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=muted>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p> {{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr> <div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr> {{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td> <td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
@@ -394,7 +571,11 @@ const toolsHTML = `<!doctype html><meta charset=utf-8>
<label><input type=checkbox name=destructive> destructive</label> <label><input type=checkbox name=destructive> destructive</label>
<button class=btn>enable</button></form></td> <button class=btn>enable</button></form></td>
</tr>{{end}}</table></div> </tr>{{end}}</table></div>
{{else}}<p class=muted>none pending.</p>{{end}} {{else}}<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<div>no proposed tools</div>
<div class=hint>maven will propose tools here when she needs help running an action</div>
</div>{{end}}
</section> </section>
<section class=card> <section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2> <h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
@@ -406,32 +587,33 @@ const toolsHTML = `<!doctype html><meta charset=utf-8>
<input type=hidden name=scope value="{{.Scope}}"> <input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable> <input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div> <button class=btn>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<p class=muted>none enabled.</p>{{end}} {{else}}<div class=empty style=padding:var(--ethos-space-5)>
<svg class=icon width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<div>no tools enabled</div>
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section> </section>
</main> {{template "shellBottom"}}`
`
var historyTmpl = template.Must(template.New("history").Parse(navHTML + historyHTML)) var historyTmpl = template.Must(template.New("history").Funcs(shellFuncs()).Parse(shellTopHTML + historyHTML + shellBottomHTML))
var notificationsTmpl = template.Must(template.New("notifications").Parse(navHTML + notificationsHTML)) var notificationsTmpl = template.Must(template.New("notifications").Funcs(shellFuncs()).Parse(shellTopHTML + notificationsHTML + shellBottomHTML))
var passkeyTmpl = template.Must(template.New("passkey").Parse(navHTML + passkeyPageHTML)) var remindersTmpl = template.Must(template.New("reminders").Funcs(shellFuncs()).Parse(shellTopHTML + remindersHTML + shellBottomHTML))
var traceTmpl = template.Must(template.New("trace").Funcs(template.FuncMap{ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Parse(shellTopHTML + passkeyPageHTML + shellBottomHTML))
"ago": func(t time.Time) string {
if t.IsZero() { var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
return "never" m := shellFuncs()
} m["fmtTime"] = func(t *time.Time) string {
return time.Since(t).Round(time.Second).String() + " ago"
},
"fmtTime": func(t *time.Time) string {
if t == nil || t.IsZero() { if t == nil || t.IsZero() {
return "—" return "—"
} }
return t.Format("15:04:05") return t.Format("15:04:05")
}, }
"join": strings.Join, m["join"] = strings.Join
}).Parse(navHTML + traceHTML)) return m
}()).Parse(shellTopHTML + traceHTML + shellBottomHTML))
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil { if core == nil {
@@ -471,6 +653,24 @@ func handleNotifications(w http.ResponseWriter, r *http.Request, core ipc.CoreAP
} }
} }
func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable)
return
}
ctx := r.Context()
reminders, err := core.ListReminders(ctx, 50)
if err != nil {
log.Printf("reminders: %v", err)
http.Error(w, "reminders error: "+err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil {
log.Printf("reminders template: %v", err)
}
}
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil { if core == nil {
http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable)
+17
View File
@@ -0,0 +1,17 @@
{{template "shellTop" "reminders"}}
<h1>Reminders</h1>
{{if .Reminders}}<div class=scroll><table>
<tr><th>created</th><th>fires</th><th>status</th><th>what</th></tr>
{{range .Reminders}}<tr>
<td class=hint>{{.CreatedTs.Format "02 Jan 15:04"}}</td>
<td>{{.FireTs.Format "02 Jan 15:04"}}</td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td class=text-max>{{.Payload}}</td>
</tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<div>no reminders yet</div>
<div class=hint>ask maven to remind you of something</div>
</div>{{end}}
{{template "shellBottom"}}
</html>
+1
View File
@@ -221,6 +221,7 @@ type CoreAPI interface {
Presence(ctx context.Context) (Presence, error) Presence(ctx context.Context) (Presence, error)
CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error)
MarkReminder(ctx context.Context, id int64, status string) error MarkReminder(ctx context.Context, id int64, status string) error
ListReminders(ctx context.Context, n int) ([]Reminder, error)
RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error)
ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error
RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error)
+8
View File
@@ -249,6 +249,14 @@ func (c *Client) MarkReminder(ctx context.Context, id int64, status string) erro
return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil) return c.call(ctx, MethodMarkReminder, markReminderReq{ID: id, Status: status}, nil)
} }
func (c *Client) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
var out []Reminder
if err := c.call(ctx, MethodListReminders, nReq{N: n}, &out); err != nil {
return nil, err
}
return out, nil
}
func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) { func (c *Client) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
var r idResp var r idResp
if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil { if err := c.call(ctx, MethodRecordNudge, recordNudgeReq{Rule: rule, Channel: channel, Message: message, Ts: ts}, &r); err != nil {
+38
View File
@@ -78,6 +78,18 @@ func (a *storeAPI) MarkReminder(ctx context.Context, id int64, status string) er
return mapErr(a.s.MarkReminder(ctx, id, status)) return mapErr(a.s.MarkReminder(ctx, id, status))
} }
func (a *storeAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rs, err := a.s.ListReminders(ctx, n)
if err != nil {
return nil, mapErr(err)
}
out := make([]Reminder, len(rs))
for i, r := range rs {
out[i] = toReminder(r)
}
return out, nil
}
func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error { func (a *storeAPI) RescheduleReminder(ctx context.Context, id int64, now time.Time) error {
return mapErr(a.s.RescheduleReminder(ctx, id, now)) return mapErr(a.s.RescheduleReminder(ctx, id, now))
} }
@@ -210,6 +222,18 @@ func toTool(t store.Tool) Tool {
} }
} }
func toReminder(r store.Reminder) Reminder {
return Reminder{
ID: r.ID,
CreatedTs: r.CreatedTs,
FireTs: r.FireTs,
NextFireTs: r.NextFireTs,
Payload: r.Payload,
Status: r.Status,
Cron: r.Cron,
}
}
func toNote(n store.Note) Note { func toNote(n store.Note) Note {
return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score} return Note{ID: n.ID, Ts: n.Ts, Text: n.Text, Source: n.Source, Score: n.Score}
} }
@@ -516,6 +540,20 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
err := api.MarkReminder(ctx, p.ID, p.Status) err := api.MarkReminder(ctx, p.ID, p.Status)
return marshalResult(nil), err return marshalResult(nil), err
case MethodListReminders:
var p nReq
if err := unmarshalParams(req.Params, &p); err != nil {
return nil, err
}
out, err := api.ListReminders(ctx, p.N)
if err != nil {
return nil, err
}
if out == nil {
out = []Reminder{}
}
return marshalResult(out), nil
case MethodRecordNudge: case MethodRecordNudge:
var p recordNudgeReq var p recordNudgeReq
if err := unmarshalParams(req.Params, &p); err != nil { if err := unmarshalParams(req.Params, &p); err != nil {
+1
View File
@@ -20,6 +20,7 @@ const (
MethodPresence Method = "presence" MethodPresence Method = "presence"
MethodCreateReminder Method = "create_reminder" MethodCreateReminder Method = "create_reminder"
MethodMarkReminder Method = "mark_reminder" MethodMarkReminder Method = "mark_reminder"
MethodListReminders Method = "list_reminders"
MethodRecordNudge Method = "record_nudge" MethodRecordNudge Method = "record_nudge"
MethodResolveNudge Method = "resolve_nudge" MethodResolveNudge Method = "resolve_nudge"
MethodRecentOutcomes Method = "recent_outcomes" MethodRecentOutcomes Method = "recent_outcomes"
+20
View File
@@ -115,6 +115,26 @@ func (s *Store) MarkReminder(ctx context.Context, id int64, status string) error
return err return err
} }
// ListReminders returns the n most recent reminders, newest first.
func (s *Store) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, created_ts, fire_ts, next_fire_ts, payload, status, cron
FROM reminders ORDER BY created_ts DESC, id DESC LIMIT ?`, n)
if err != nil {
return nil, fmt.Errorf("list reminders: %w", err)
}
defer rows.Close()
var out []Reminder
for rows.Next() {
r, err := scanReminder(rows)
if err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// RescheduleReminder computes the next fire time for a recurring reminder and // RescheduleReminder computes the next fire time for a recurring reminder and
// updates next_fire_ts. Returns ErrReminderState if the reminder is not // updates next_fire_ts. Returns ErrReminderState if the reminder is not
// recurring or not pending. If no more valid fire times exist, marks it fired. // recurring or not pending. If no more valid fire times exist, marks it fired.