diff --git a/apps/admin-dashboard/src/components/reminder-crud-panel.tsx b/apps/admin-dashboard/src/components/reminder-crud-panel.tsx new file mode 100644 index 0000000..0a04f4f --- /dev/null +++ b/apps/admin-dashboard/src/components/reminder-crud-panel.tsx @@ -0,0 +1,639 @@ +import type { Dispatch, FormEvent, SetStateAction } from "react" + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { Check, Loader2, Pencil, Plus, RefreshCw, RotateCcw, Save, Trash2, X } from "lucide-react" +import { useMemo, useState } from "react" +import { toast } from "sonner" + +import type { FeedItem } from "@/lib/api" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" +import { executeSourceAction, fetchFeed } from "@/lib/api" + +const REMINDER_SOURCE_ID = "freya.reminders" + +type ReminderPriority = "low" | "normal" | "high" +type ReminderFrequency = "daily" | "weekly" | "monthly" | "yearly" +type ReminderEditScope = "this-occurrence" | "this-and-future" | "entire-series" + +interface ReminderRecurrence { + frequency: ReminderFrequency + interval: number + count?: number + until?: string +} + +interface ReminderFeedData extends Record { + reminderId: string + occurrenceId: string + title: string + notes: string | null + originalDueAt: string + dueAt: string + timeZone: string + recurrence: ReminderRecurrence | null + priority: ReminderPriority + completedAt: string | null +} + +interface ReminderFormState { + title: string + notes: string + dueAt: string + priority: ReminderPriority + scope: ReminderEditScope + recurs: boolean + frequency: ReminderFrequency + interval: string + count: string + until: string +} + +const emptyForm: ReminderFormState = { + title: "", + notes: "", + dueAt: toLocalInput(new Date()), + priority: "normal", + scope: "entire-series", + recurs: false, + frequency: "daily", + interval: "1", + count: "", + until: "", +} + +export function ReminderCrudPanel() { + const queryClient = useQueryClient() + const [form, setForm] = useState(emptyForm) + const [editing, setEditing] = useState(null) + const [deleteScopes, setDeleteScopes] = useState>({}) + + const { + data: feed, + isFetching, + refetch, + } = useQuery({ + queryKey: ["feed"], + queryFn: fetchFeed, + }) + + const reminders = useMemo( + () => (feed?.items ?? []).filter(isReminderItem).map((item) => item.data), + [feed], + ) + + const actionMutation = useMutation({ + mutationFn: (input: { actionId: string; params: unknown }) => + executeSourceAction(REMINDER_SOURCE_ID, input.actionId, input.params), + }) + + const busy = actionMutation.isPending + const canConfigureRecurrence = !editing || form.scope !== "this-occurrence" + + async function runAction(actionId: string, params: unknown, success: string): Promise { + try { + await actionMutation.mutateAsync({ actionId, params }) + await queryClient.invalidateQueries({ queryKey: ["feed"] }) + toast.success(success) + return true + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)) + return false + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault() + + if (editing) { + const patch = formToPatch(formFromReminder(editing), form) + if (Object.keys(patch).length === 0) { + toast.info("No changes to save") + return + } + + const saved = await runAction( + "update-reminder", + { + reminderId: editing.reminderId, + scope: form.scope, + occurrenceDueAt: editing.originalDueAt, + patch, + }, + "Reminder updated", + ) + if (saved) resetForm() + return + } else { + const created = await runAction( + "create-reminder", + formToCreatePayload(form), + "Reminder created", + ) + if (created) resetForm() + } + } + + function startEdit(reminder: ReminderFeedData) { + setEditing(reminder) + setForm(formFromReminder(reminder)) + } + + function resetForm() { + setEditing(null) + setForm({ ...emptyForm, dueAt: toLocalInput(new Date()) }) + } + + function getDeleteScope(reminder: ReminderFeedData): ReminderEditScope { + return ( + deleteScopes[reminderKey(reminder)] ?? + (reminder.recurrence ? "this-occurrence" : "entire-series") + ) + } + + function setDeleteScope(reminder: ReminderFeedData, scope: ReminderEditScope) { + setDeleteScopes((prev) => ({ ...prev, [reminderKey(reminder)]: scope })) + } + + return ( + + +
+ Reminders + +
+
+ +
+
+
+ + setFormField(setForm, "title", event.target.value)} + disabled={busy} + required + /> +
+ +
+ + setFormField(setForm, "notes", event.target.value)} + disabled={busy} + /> +
+ +
+ + setFormField(setForm, "dueAt", event.target.value)} + disabled={busy} + required + /> +
+ +
+ + +
+ + {editing?.recurrence && ( +
+ + +
+ )} +
+ + {canConfigureRecurrence && ( +
+
+ + setFormField(setForm, "recurs", checked)} + disabled={busy} + /> +
+ + {form.recurs && ( + <> +
+ + +
+ +
+ + setFormField(setForm, "interval", event.target.value)} + disabled={busy} + /> +
+ +
+ + setFormField(setForm, "count", event.target.value)} + disabled={busy} + /> +
+ +
+ + setFormField(setForm, "until", event.target.value)} + disabled={busy} + /> +
+ + )} +
+ )} + +
+ {editing && ( + + )} + +
+
+ +
+
+ + {reminders.length} {reminders.length === 1 ? "occurrence" : "occurrences"} + + {!editing && ( + + )} +
+ + {reminders.length === 0 && ( +
+ No reminders in the current feed. +
+ )} + + {reminders.map((reminder) => { + const deleteScope = getDeleteScope(reminder) + return ( + setDeleteScope(reminder, scope)} + onEdit={() => startEdit(reminder)} + onComplete={() => + runAction( + reminder.completedAt ? "uncomplete-reminder" : "complete-reminder", + { + reminderId: reminder.reminderId, + occurrenceDueAt: reminder.originalDueAt, + }, + reminder.completedAt ? "Reminder reopened" : "Reminder completed", + ) + } + onDelete={() => { + if ( + !confirm( + `Delete ${formatScope(deleteScope).toLowerCase()} for "${reminder.title}"?`, + ) + ) { + return + } + void runAction( + "delete-reminder", + { + reminderId: reminder.reminderId, + scope: deleteScope, + occurrenceDueAt: reminder.originalDueAt, + }, + "Reminder deleted", + ).then((deleted) => { + if (deleted && editing?.reminderId === reminder.reminderId) resetForm() + }) + }} + /> + ) + })} +
+
+
+ ) +} + +function ReminderRow({ + reminder, + busy, + deleteScope, + onDeleteScopeChange, + onEdit, + onComplete, + onDelete, +}: { + reminder: ReminderFeedData + busy: boolean + deleteScope: ReminderEditScope + onDeleteScopeChange: (scope: ReminderEditScope) => void + onEdit: () => void + onComplete: () => void + onDelete: () => void +}) { + return ( +
+
+
+ {reminder.title} + + {reminder.completedAt ? "Done" : reminder.priority} + + {reminder.recurrence && ( + + {formatRecurrence(reminder.recurrence)} + + )} +
+
{formatDate(reminder.dueAt)}
+ {reminder.notes &&
{reminder.notes}
} +
+
+ {reminder.recurrence && ( + + )} + + + +
+
+ ) +} + +function formToCreatePayload(form: ReminderFormState): Record { + return { + title: form.title.trim(), + notes: form.notes.trim() || null, + dueAt: toIsoString(form.dueAt), + timeZone: localTimeZone(), + priority: form.priority, + recurrence: recurrenceValueFromForm(form), + } +} + +function formToPatch(initial: ReminderFormState, form: ReminderFormState): Record { + const patch: Record = {} + const title = form.title.trim() + const notes = form.notes.trim() || null + const initialNotes = initial.notes.trim() || null + + if (title !== initial.title.trim()) patch.title = title + if (notes !== initialNotes) patch.notes = notes + if (form.dueAt !== initial.dueAt) { + patch.dueAt = toIsoString(form.dueAt) + patch.timeZone = localTimeZone() + } + if (form.priority !== initial.priority) patch.priority = form.priority + if (form.scope !== "this-occurrence" && recurrenceChanged(initial, form)) { + patch.recurrence = recurrenceValueFromForm(form) + } + + return patch +} + +function recurrenceValueFromForm(form: ReminderFormState): ReminderRecurrence | null { + return form.recurs ? recurrenceFromForm(form) : null +} + +function recurrenceFromForm(form: ReminderFormState): ReminderRecurrence { + const recurrence: ReminderRecurrence = { + frequency: form.frequency, + interval: Math.max(1, Number(form.interval) || 1), + } + + const count = Number(form.count) + if (Number.isInteger(count) && count > 0) recurrence.count = count + if (form.until) recurrence.until = toIsoString(form.until) + + return recurrence +} + +function formFromReminder(reminder: ReminderFeedData): ReminderFormState { + return { + title: reminder.title, + notes: reminder.notes ?? "", + dueAt: toLocalInput(new Date(reminder.dueAt)), + priority: reminder.priority, + scope: reminder.recurrence ? "this-occurrence" : "entire-series", + recurs: reminder.recurrence !== null, + frequency: reminder.recurrence?.frequency ?? "daily", + interval: String(reminder.recurrence?.interval ?? 1), + count: reminder.recurrence?.count ? String(reminder.recurrence.count) : "", + until: reminder.recurrence?.until ? toLocalInput(new Date(reminder.recurrence.until)) : "", + } +} + +function setFormField( + setForm: Dispatch>, + key: TKey, + value: ReminderFormState[TKey], +) { + setForm((prev) => ({ ...prev, [key]: value })) +} + +function recurrenceChanged(initial: ReminderFormState, form: ReminderFormState): boolean { + return ( + JSON.stringify(recurrenceValueFromForm(initial)) !== + JSON.stringify(recurrenceValueFromForm(form)) + ) +} + +function reminderKey(reminder: ReminderFeedData): string { + return `${reminder.reminderId}:${reminder.occurrenceId}` +} + +function isReminderItem(item: FeedItem): item is FeedItem & { data: ReminderFeedData } { + return ( + item.sourceId === REMINDER_SOURCE_ID && + typeof item.data.reminderId === "string" && + typeof item.data.occurrenceId === "string" && + typeof item.data.title === "string" && + typeof item.data.originalDueAt === "string" && + typeof item.data.dueAt === "string" + ) +} + +function toLocalInput(date: Date): string { + const offsetMs = date.getTimezoneOffset() * 60 * 1000 + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16) +} + +function toIsoString(value: string): string { + return new Date(value).toISOString() +} + +function localTimeZone(): string { + return Intl.DateTimeFormat().resolvedOptions().timeZone +} + +function formatDate(value: string): string { + return new Date(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + +function formatRecurrence(recurrence: ReminderRecurrence): string { + return recurrence.interval === 1 + ? recurrence.frequency + : `${recurrence.frequency} / ${recurrence.interval}` +} + +function formatScope(scope: ReminderEditScope): string { + switch (scope) { + case "this-occurrence": + return "this occurrence" + case "this-and-future": + return "this and future" + case "entire-series": + return "entire series" + } +} diff --git a/apps/admin-dashboard/src/components/source-config-panel.tsx b/apps/admin-dashboard/src/components/source-config-panel.tsx index ba7ab3f..3c6f160 100644 --- a/apps/admin-dashboard/src/components/source-config-panel.tsx +++ b/apps/admin-dashboard/src/components/source-config-panel.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner" import type { ConfigFieldDef, SourceDefinition } from "@/lib/api" +import { ReminderCrudPanel } from "@/components/reminder-crud-panel" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -267,6 +268,8 @@ export function SourceConfigPanel({ source, onUpdate }: SourceConfigPanelProps) )} {source.id === "freya.location" && } + + {source.id === "freya.reminders" && enabled && } ) } @@ -477,6 +480,17 @@ function FieldInput({ ) } + if (field.type === "boolean") { + return ( +
+ + +
+ ) + } + return (