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" } }