diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index f19e84d..e4d0168 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -5,6 +5,7 @@ import { useSessionStore } from "../stores/session"; import { useConfigStore } from "../stores/config"; import { useLabelsStore } from "../stores/labels"; import { useSavedFiltersStore, type SavedFilter } from "../stores/savedFilters"; +import { useReminderStore } from "../stores/reminders"; import { useUiStore } from "../stores/ui"; import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; @@ -19,6 +20,7 @@ const session = useSessionStore(); const config = useConfigStore(); const labels = useLabelsStore(); const savedFilters = useSavedFiltersStore(); +const reminders = useReminderStore(); const ui = useUiStore(); async function removeView(f: SavedFilter) { @@ -153,9 +155,13 @@ function onKeydown(e: KeyboardEvent) { onMounted(() => { if (!labels.loaded) void labels.load(); if (!savedFilters.loaded) void savedFilters.load(); + reminders.start(); // foreground reminder delivery while the app is open window.addEventListener("keydown", onKeydown); }); -onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown)); +onBeforeUnmount(() => { + reminders.stop(); + window.removeEventListener("keydown", onKeydown); +}); const currentLabelId = computed(() => (route.name === "label" ? String(route.params.id) : null)); diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index a530a9d..53689fb 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -68,6 +68,7 @@ const draftNote = computed(() => ({ archived: false, trashed: false, remind_at: null, + recurrence: null, labels: labelList.value, items: [], attachments: [], @@ -377,6 +378,17 @@ async function onReminderChange(e: Event) { if (!id) return; await notes.setReminder(id, fromLocalInput((e.target as HTMLInputElement).value)); } +async function onRecurrenceChange(e: Event) { + const id = await ensureDraft(); + if (!id) return; + await notes.setRecurrence(id, (e.target as HTMLSelectElement).value || null); +} +async function completeReminder() { + if (noteId.value) await notes.completeReminder(noteId.value); +} +async function snoozeReminder(minutes: number) { + if (noteId.value) await notes.snoozeReminder(noteId.value, minutes); +} // ---- labels ---- async function onLabelsChange(next: NoteLabel[]) { @@ -755,6 +767,34 @@ defineExpose({ open }); +
+ + + + + +
+
{ async function mutate( id: string, - changes: Partial>, + changes: Partial< + Pick + >, ): Promise { reconcile(await api.patch(`/api/notes/${id}`, changes)); } @@ -173,8 +176,16 @@ export const useNotesStore = defineStore("notes", () => { const setColor = (id: string, color: NoteColor) => mutate(id, { color }); const setKind = (id: string, kind: NoteKind) => mutate(id, { kind }); const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); + const setRecurrence = (id: string, recurrence: string | null) => mutate(id, { recurrence }); const saveEdit = (id: string, changes: { title: string; body: string; color: NoteColor }) => mutate(id, changes); + async function completeReminder(id: string): Promise { + reconcile(await api.post(`/api/notes/${id}/reminder/complete`)); + } + async function snoozeReminder(id: string, minutes: number): Promise { + reconcile(await api.post(`/api/notes/${id}/reminder/snooze`, { minutes })); + } + async function setLabels(id: string, labelIds: string[]): Promise { reconcile(await api.put(`/api/notes/${id}/labels`, { label_ids: labelIds })); } @@ -300,6 +311,9 @@ export const useNotesStore = defineStore("notes", () => { setColor, setKind, setReminder, + setRecurrence, + completeReminder, + snoozeReminder, saveEdit, setLabels, addItem, diff --git a/frontend/src/stores/reminders.ts b/frontend/src/stores/reminders.ts new file mode 100644 index 0000000..95f3e5b --- /dev/null +++ b/frontend/src/stores/reminders.ts @@ -0,0 +1,70 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { api } from "../api/client"; +import router from "../router"; +import { useUiStore } from "./ui"; +import type { Note } from "./notes"; + +// Foreground reminder delivery — NO Web Push / service worker (per operator). While +// the app is open, poll for due reminders and surface each once as a toast (and, if +// the user opted in, a page-context OS notification). Real background delivery is a +// native-client concern. +const POLL_MS = 45_000; +const RECENT_MS = 15 * 60 * 1000; // on first load, only announce reminders due this recently + +export const useReminderStore = defineStore("reminders", () => { + const osSupported = typeof Notification !== "undefined"; + const osEnabled = ref(osSupported && Notification.permission === "granted"); + // note id → the remind_at we already fired, so each occurrence notifies at most once. + const notified = new Map(); + let timer: ReturnType | undefined; + let primed = false; + + async function check(): Promise { + const ui = useUiStore(); + let notes: Note[]; + try { + notes = (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes; + } catch { + return; + } + const now = Date.now(); + for (const n of notes) { + if (!n.remind_at) continue; + const due = Date.parse(n.remind_at); + if (due > now || notified.get(n.id) === n.remind_at) continue; + notified.set(n.id, n.remind_at); + // Silently prime a stale backlog on first load; only announce recently-due ones. + if (!primed && now - due > RECENT_MS) continue; + const title = n.display_title || "Reminder"; + ui.showToast(`⏰ ${title}`, { + label: "Open", + run: () => void router.push({ path: "/", query: { open: n.id } }), + }); + if (osEnabled.value) { + try { + new Notification("ThoughtSync reminder", { body: title }); + } catch { + /* notifications unavailable — the toast still fired */ + } + } + } + primed = true; + } + + function start(): void { + if (timer) return; + void check(); + timer = setInterval(() => void check(), POLL_MS); + } + function stop(): void { + if (timer) clearInterval(timer); + timer = undefined; + } + async function enableOs(): Promise { + if (!osSupported) return; + osEnabled.value = (await Notification.requestPermission()) === "granted"; + } + + return { osSupported, osEnabled, start, stop, enableOs }; +}); diff --git a/frontend/src/views/RemindersView.vue b/frontend/src/views/RemindersView.vue index 40b2b6e..04e847b 100644 --- a/frontend/src/views/RemindersView.vue +++ b/frontend/src/views/RemindersView.vue @@ -2,10 +2,12 @@ import { onMounted, ref } from "vue"; import { api } from "../api/client"; import { useNotesStore, type Note } from "../stores/notes"; -import NoteCard from "../components/NoteCard.vue"; +import { useReminderStore } from "../stores/reminders"; +import { formatReminder, isOverdue } from "../notes/datetime"; import NoteEditor from "../components/NoteEditor.vue"; const notes = useNotesStore(); +const reminders = useReminderStore(); const items = ref([]); const loading = ref(true); const error = ref(""); @@ -15,8 +17,7 @@ async function load() { loading.value = true; error.value = ""; try { - const res = await api.get<{ notes: Note[] }>("/api/notes/reminders"); - items.value = res.notes; + items.value = (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes; } catch (e) { error.value = (e as { error?: string }).error ?? "Couldn't load reminders."; items.value = []; @@ -37,12 +38,35 @@ async function onNavigate(id: string) { editing.value = found ?? (await notes.fetchOne(id)); } +async function done(n: Note) { + await notes.completeReminder(n.id); + await load(); +} +async function snooze(n: Note, minutes: number) { + await notes.snoozeReminder(n.id, minutes); + await load(); +} + onMounted(load);