From ec542d864171145b84cb558a03c3d453ba9bcc48 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Wed, 1 Apr 2026 13:44:51 -0300 Subject: [PATCH 1/2] Add reminders #20 --- packages/backend/src/api/index.ts | 10 + packages/backend/src/api/reminder.ts | 133 +++++++++++ packages/backend/src/index.ts | 15 ++ packages/backend/src/schemas/reminder.ts | 15 ++ packages/backend/src/types/events.ts | 2 + packages/backend/src/utils/reminderFile.ts | 48 ++++ packages/backend/src/utils/reminderTimer.ts | 46 ++++ .../components/content/editor/NoteEditor.vue | 82 ++++++- .../editor/extensions/reminder-node.ts | 218 ++++++++++++++++++ .../editor/reminders/ReminderPicker.vue | 178 ++++++++++++++ .../editor/reminders/showReminderPicker.ts | 33 +++ .../reminders/ReminderNotificationManager.vue | 36 +++ .../shared/reminders/ReminderToast.vue | 153 ++++++++++++ .../reminders/mountReminderNotifications.ts | 25 ++ packages/frontend/src/index.ts | 3 + .../frontend/src/repositories/reminders.ts | 56 +++++ packages/frontend/src/stores/reminders.ts | 97 ++++++++ packages/frontend/src/utils/eventBus.ts | 12 + .../frontend/src/utils/notificationSound.ts | 36 +++ packages/frontend/src/utils/reminderStates.ts | 40 ++++ packages/shared/src/index.ts | 10 + 21 files changed, 1244 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/api/reminder.ts create mode 100644 packages/backend/src/schemas/reminder.ts create mode 100644 packages/backend/src/utils/reminderFile.ts create mode 100644 packages/backend/src/utils/reminderTimer.ts create mode 100644 packages/frontend/src/components/content/editor/extensions/reminder-node.ts create mode 100644 packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue create mode 100644 packages/frontend/src/components/content/editor/reminders/showReminderPicker.ts create mode 100644 packages/frontend/src/components/shared/reminders/ReminderNotificationManager.vue create mode 100644 packages/frontend/src/components/shared/reminders/ReminderToast.vue create mode 100644 packages/frontend/src/components/shared/reminders/mountReminderNotifications.ts create mode 100644 packages/frontend/src/repositories/reminders.ts create mode 100644 packages/frontend/src/stores/reminders.ts create mode 100644 packages/frontend/src/utils/notificationSound.ts create mode 100644 packages/frontend/src/utils/reminderStates.ts diff --git a/packages/backend/src/api/index.ts b/packages/backend/src/api/index.ts index 788c4c4..8b8ed9a 100644 --- a/packages/backend/src/api/index.ts +++ b/packages/backend/src/api/index.ts @@ -11,6 +11,12 @@ import { updateNote, } from "./note"; import { getCurrentProjectId } from "./project"; +import { + createReminder, + deleteReminder, + dismissReminder, + getReminders, +} from "./reminder"; export { getTree, @@ -26,4 +32,8 @@ export { getLegacyNotes, migrateNote, getFileContent, + getReminders, + createReminder, + deleteReminder, + dismissReminder, }; diff --git a/packages/backend/src/api/reminder.ts b/packages/backend/src/api/reminder.ts new file mode 100644 index 0000000..0375066 --- /dev/null +++ b/packages/backend/src/api/reminder.ts @@ -0,0 +1,133 @@ +import { randomUUID } from "crypto"; + +import type { SDK } from "caido:plugin"; +import type { Reminder, Result } from "shared"; +import { error, ok } from "shared"; + +import { + createReminderSchema, + deleteReminderSchema, + dismissReminderSchema, +} from "../schemas/reminder"; +import { ensureProjectDirectory } from "../utils/fileSystem"; +import { readRemindersFile, writeRemindersFile } from "../utils/reminderFile"; + +/** + * Get all reminders for the current project + */ +export async function getReminders(sdk: SDK): Promise> { + try { + const projectIDResult = await ensureProjectDirectory(sdk); + if (projectIDResult.kind === "Error") { + return error(projectIDResult.error); + } + + const reminders = readRemindersFile(projectIDResult.value); + return ok(reminders); + } catch (err) { + sdk.console.error(`Error getting reminders: ${err}`); + return error(err instanceof Error ? err.message : String(err)); + } +} + +/** + * Create a new reminder + */ +export async function createReminder( + sdk: SDK, + notePath: string, + context: string, + reminderAt: string, +): Promise> { + try { + createReminderSchema.parse({ notePath, context, reminderAt }); + + const projectIDResult = await ensureProjectDirectory(sdk); + if (projectIDResult.kind === "Error") { + return error(projectIDResult.error); + } + + const projectID = projectIDResult.value; + const reminder: Reminder = { + id: randomUUID(), + notePath, + context, + reminderAt, + createdAt: new Date().toISOString(), + triggered: false, + dismissed: false, + }; + + const reminders = readRemindersFile(projectID); + reminders.push(reminder); + writeRemindersFile(projectID, reminders); + + return ok(reminder); + } catch (err) { + sdk.console.error(`Error creating reminder: ${err}`); + return error(err instanceof Error ? err.message : String(err)); + } +} + +/** + * Delete a reminder by ID + */ +export async function deleteReminder( + sdk: SDK, + reminderId: string, +): Promise> { + try { + deleteReminderSchema.parse({ reminderId }); + + const projectIDResult = await ensureProjectDirectory(sdk); + if (projectIDResult.kind === "Error") { + return error(projectIDResult.error); + } + + const projectID = projectIDResult.value; + const reminders = readRemindersFile(projectID); + const filtered = reminders.filter((r) => r.id !== reminderId); + + if (filtered.length === reminders.length) { + return error(`Reminder not found: ${reminderId}`); + } + + writeRemindersFile(projectID, filtered); + return ok(true); + } catch (err) { + sdk.console.error(`Error deleting reminder: ${err}`); + return error(err instanceof Error ? err.message : String(err)); + } +} + +/** + * Dismiss a reminder (mark as acknowledged by user) + */ +export async function dismissReminder( + sdk: SDK, + reminderId: string, +): Promise> { + try { + dismissReminderSchema.parse({ reminderId }); + + const projectIDResult = await ensureProjectDirectory(sdk); + if (projectIDResult.kind === "Error") { + return error(projectIDResult.error); + } + + const projectID = projectIDResult.value; + const reminders = readRemindersFile(projectID); + const target = reminders.find((r) => r.id === reminderId); + + if (!target) { + return error(`Reminder not found: ${reminderId}`); + } + + target.dismissed = true; + writeRemindersFile(projectID, reminders); + return ok(true); + } catch (err) { + sdk.console.error(`Error dismissing reminder: ${err}`); + return error(err instanceof Error ? err.message : String(err)); + } +} diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index dfa2745..653f85e 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -3,12 +3,16 @@ import type { DefineAPI, SDK } from "caido:plugin"; import { createFolder, createNote, + createReminder, deleteFolder, deleteNote, + deleteReminder, + dismissReminder, getCurrentProjectId, getFileContent, getLegacyNotes, getNote, + getReminders, getTree, migrateNote, moveItem, @@ -16,6 +20,7 @@ import { updateNote, } from "./api"; import { type BackendEvents } from "./types/events"; +import { startReminderTimer } from "./utils/reminderTimer"; export type { BackendEvents } from "./types/events"; @@ -33,6 +38,10 @@ export type API = DefineAPI<{ getLegacyNotes: typeof getLegacyNotes; migrateNote: typeof migrateNote; getFileContent: typeof getFileContent; + getReminders: typeof getReminders; + createReminder: typeof createReminder; + deleteReminder: typeof deleteReminder; + dismissReminder: typeof dismissReminder; }>; export function init(sdk: SDK) { @@ -49,10 +58,16 @@ export function init(sdk: SDK) { sdk.api.register("getLegacyNotes", getLegacyNotes); sdk.api.register("migrateNote", migrateNote); sdk.api.register("getFileContent", getFileContent); + sdk.api.register("getReminders", getReminders); + sdk.api.register("createReminder", createReminder); + sdk.api.register("deleteReminder", deleteReminder); + sdk.api.register("dismissReminder", dismissReminder); sdk.events.onProjectChange((sdk, project) => { sdk.api.send("notes++:projectChange", project?.getId()); }); + startReminderTimer(sdk); + sdk.console.log("Notes++ backend initialized successfully"); } diff --git a/packages/backend/src/schemas/reminder.ts b/packages/backend/src/schemas/reminder.ts new file mode 100644 index 0000000..05c4d7d --- /dev/null +++ b/packages/backend/src/schemas/reminder.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; + +export const createReminderSchema = z.object({ + notePath: z.string().min(1), + context: z.string(), + reminderAt: z.string().min(1), +}); + +export const deleteReminderSchema = z.object({ + reminderId: z.string().min(1), +}); + +export const dismissReminderSchema = z.object({ + reminderId: z.string().min(1), +}); diff --git a/packages/backend/src/types/events.ts b/packages/backend/src/types/events.ts index bbe95c7..d4ebc9f 100644 --- a/packages/backend/src/types/events.ts +++ b/packages/backend/src/types/events.ts @@ -1,5 +1,7 @@ import type { DefineEvents } from "caido:plugin"; +import type { Reminder } from "shared"; export type BackendEvents = DefineEvents<{ "notes++:projectChange": (projectId: string) => void; + "notes++:reminderDue": (reminder: Reminder) => void; }>; diff --git a/packages/backend/src/utils/reminderFile.ts b/packages/backend/src/utils/reminderFile.ts new file mode 100644 index 0000000..2dd6298 --- /dev/null +++ b/packages/backend/src/utils/reminderFile.ts @@ -0,0 +1,48 @@ +import * as fs from "fs"; +import path from "path"; + +import type { Reminder } from "shared"; + +import { + createDirectory, + directoryExists, + fileExists, + toSystemPath, +} from "./fileSystem"; +import { getNoteRootPath } from "./paths"; + +const REMINDERS_FILENAME = "reminders.json"; + +export function getRemindersFilePath(projectID: string): string { + return path.join(getNoteRootPath(projectID), REMINDERS_FILENAME); +} + +export function readRemindersFile(projectID: string): Reminder[] { + const filePath = getRemindersFilePath(projectID); + + if (!fileExists(filePath)) { + return []; + } + + try { + const raw = fs.readFileSync(toSystemPath(filePath), "utf8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as Reminder[]) : []; + } catch { + return []; + } +} + +export function writeRemindersFile( + projectID: string, + reminders: Reminder[], +): void { + const filePath = getRemindersFilePath(projectID); + const dirPath = path.dirname(filePath); + + if (!directoryExists(dirPath)) { + createDirectory(dirPath); + } + + fs.writeFileSync(toSystemPath(filePath), JSON.stringify(reminders, null, 2)); +} diff --git a/packages/backend/src/utils/reminderTimer.ts b/packages/backend/src/utils/reminderTimer.ts new file mode 100644 index 0000000..77e94d1 --- /dev/null +++ b/packages/backend/src/utils/reminderTimer.ts @@ -0,0 +1,46 @@ +import type { SDK } from "caido:plugin"; + +import type { API } from "../index"; +import type { BackendEvents } from "../types/events"; + +import { readRemindersFile, writeRemindersFile } from "./reminderFile"; + +const CHECK_INTERVAL_MS = 30_000; + +/** Periodically checks for due reminders and dispatches notification events. */ +export function startReminderTimer(sdk: SDK): () => void { + async function checkReminders() { + try { + const project = await sdk.projects.getCurrent(); + const projectID = project?.getId(); + + if (!projectID) return; + + const reminders = readRemindersFile(projectID); + const now = new Date(); + let changed = false; + + for (const reminder of reminders) { + if (reminder.triggered || reminder.dismissed) continue; + + const dueDate = new Date(reminder.reminderAt); + if (dueDate <= now) { + reminder.triggered = true; + changed = true; + sdk.api.send("notes++:reminderDue", reminder); + } + } + + if (changed) { + writeRemindersFile(projectID, reminders); + } + } catch (err) { + sdk.console.error(`Error checking reminders: ${err}`); + } + } + + checkReminders(); + const intervalId = setInterval(checkReminders, CHECK_INTERVAL_MS); + + return () => clearInterval(intervalId); +} diff --git a/packages/frontend/src/components/content/editor/NoteEditor.vue b/packages/frontend/src/components/content/editor/NoteEditor.vue index 005b741..f897c91 100644 --- a/packages/frontend/src/components/content/editor/NoteEditor.vue +++ b/packages/frontend/src/components/content/editor/NoteEditor.vue @@ -33,18 +33,24 @@ import MarkdownStyling from "./extensions/markdown-styling"; import { createFileMention } from "./extensions/mentions/mention-file"; import { createSessionMention } from "./extensions/mentions/mention-request"; import createSuggestion from "./extensions/mentions/suggestion"; +import { ReminderNode } from "./extensions/reminder-node"; import { Search } from "./extensions/search"; import SearchUI from "./extensions/search/SearchUI.vue"; import { SlashCommands } from "./extensions/slash-commands"; +import { showReminderPicker } from "./reminders/showReminderPicker"; import TableMenu from "./TableMenu.vue"; import { useSDK } from "@/plugins/sdk"; +import { useContextMenuStore } from "@/stores/contextMenu"; import { useNotesStore } from "@/stores/notes"; +import { useRemindersStore } from "@/stores/reminders"; import { emitter } from "@/utils/eventBus"; import { compressImage } from "@/utils/images"; const sdk = useSDK(); const notesStore = useNotesStore(); +const contextMenuStore = useContextMenuStore(); +const remindersStore = useRemindersStore(); const suggestion = createSuggestion(sdk); const SessionMention = createSessionMention(sdk); const FileMention = createFileMention(sdk); @@ -188,12 +194,38 @@ const editor = useEditor({ TableHeader, TableCell, FileMention, + ReminderNode, SlashCommands.configure({ sdk }), ], editorProps: { attributes: { class: "mx-auto focus:outline-none font-mono dark:text-surface-100", }, + handleDOMEvents: { + contextmenu: (view: EditorView, event: MouseEvent) => { + const { from, to } = view.state.selection; + if (from === to) return false; + + event.preventDefault(); + const selectedText = view.state.doc.textBetween(from, to, " "); + + contextMenuStore.showContextMenu(event, [ + { + label: "Set Reminder", + icon: "fas fa-bell", + command: () => { + emitter.emit("openReminderPicker", { + selectedText, + position: { x: event.clientX, y: event.clientY }, + selectionRange: { from, to }, + }); + }, + }, + ]); + + return true; + }, + }, handleDrop: ( view: EditorView, event: DragEvent, @@ -264,8 +296,9 @@ watch( } if (editor.value && newNote) { - const content = newNote.content; - editor.value.commands.setContent(content); + editor.value.storage.reminderNode.isContentReplacement = true; + editor.value.commands.setContent(newNote.content); + editor.value.storage.reminderNode.isContentReplacement = false; restoreCursorPosition(newNote.path); } @@ -274,19 +307,60 @@ watch( { immediate: true }, ); +const handleCancelReminder = (data: { id: string }) => { + remindersStore.deleteReminder(data.id); +}; + +const handleOpenReminderPicker = (data: { + selectedText: string; + position: { x: number; y: number }; + selectionRange: { from: number; to: number }; +}) => { + showReminderPicker(data.position, async (reminderAt: Date) => { + if (!notesStore.currentNotePath || !editor.value) return; + + const reminder = await remindersStore.createReminder( + notesStore.currentNotePath, + data.selectedText, + reminderAt.toISOString(), + ); + + if (reminder) { + editor.value + .chain() + .focus() + .setTextSelection(data.selectionRange.to) + .insertContent({ + type: "reminderNode", + attrs: { + id: reminder.id, + reminderAt: reminder.reminderAt, + context: data.selectedText, + }, + }) + .run(); + } + }); +}; + onMounted(() => { if (editor.value && notesStore.currentNote) { - const content = notesStore.currentNote.content; - editor.value.commands.setContent(content); + editor.value.storage.reminderNode.isContentReplacement = true; + editor.value.commands.setContent(notesStore.currentNote.content); + editor.value.storage.reminderNode.isContentReplacement = false; restoreCursorPosition(notesStore.currentNote.path); } emitter.on("restoreFocus", restoreFocus); + emitter.on("openReminderPicker", handleOpenReminderPicker); + emitter.on("cancelReminder", handleCancelReminder); }); onUnmounted(() => { saveCursorPosition(); emitter.off("restoreFocus", restoreFocus); + emitter.off("openReminderPicker", handleOpenReminderPicker); + emitter.off("cancelReminder", handleCancelReminder); }); diff --git a/packages/frontend/src/components/content/editor/extensions/reminder-node.ts b/packages/frontend/src/components/content/editor/extensions/reminder-node.ts new file mode 100644 index 0000000..521f048 --- /dev/null +++ b/packages/frontend/src/components/content/editor/extensions/reminder-node.ts @@ -0,0 +1,218 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +import { emitter } from "@/utils/eventBus"; +import { + getReminderDisplayState, + type ReminderDisplayState, +} from "@/utils/reminderStates"; + +const STATE_CHECK_INTERVAL_MS = 15_000; + +const styleId = "reminder-node-style"; +if (!document.getElementById(styleId)) { + const style = document.createElement("style"); + style.id = styleId; + style.textContent = ` + .reminder-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + line-height: 1.6; + vertical-align: baseline; + cursor: default; + user-select: none; + white-space: nowrap; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + } + .reminder-chip:hover { + filter: brightness(1.15); + } + .reminder-chip__icon { + font-size: 10px; + flex-shrink: 0; + } + .reminder-chip__close { + display: none; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + margin-left: 2px; + font-size: 9px; + opacity: 0.6; + color: inherit; + transition: opacity 0.15s ease; + } + .reminder-chip__close:hover { + opacity: 1; + } + .reminder-chip--cancellable:hover .reminder-chip__close { + display: inline-flex; + } + `; + document.head.appendChild(style); +} + +/** + * Tailwind classes per state. These elements live inside the editor + * (descendant of #plugin--notesplusplus) so prefix-wrapped utilities apply. + */ +const stateStyles: Record = { + upcoming: "bg-primary-500/15 text-primary-400 border border-primary-500/30", + due: "bg-orange-500/15 text-orange-400 border border-orange-500/30", + missed: "bg-orange-500/15 text-orange-400 border border-orange-500/30", + dismissed: + "bg-green-500/10 text-green-500 border border-green-500/20 line-through", +}; + +const stateIcons: Record = { + upcoming: "fas fa-clock", + due: "fas fa-bell", + missed: "fas fa-exclamation-circle", + dismissed: "fas fa-check-circle", +}; + +function formatReminderDate(isoString: string): string { + const date = new Date(isoString); + const now = new Date(); + const isToday = date.toDateString() === now.toDateString(); + + const tomorrow = new Date(now); + tomorrow.setDate(tomorrow.getDate() + 1); + const isTomorrow = date.toDateString() === tomorrow.toDateString(); + + const timeStr = date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }); + + if (isToday) return `Today ${timeStr}`; + if (isTomorrow) return `Tomorrow ${timeStr}`; + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export const ReminderNode = Node.create({ + name: "reminderNode", + group: "inline", + inline: true, + atom: true, + + addStorage() { + return { + isContentReplacement: false, + }; + }, + + addAttributes() { + return { + id: { default: "" }, + reminderAt: { default: "" }, + context: { default: "" }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-reminder-node]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + "span", + mergeAttributes({ "data-reminder-node": "" }, HTMLAttributes), + ]; + }, + + addNodeView() { + return (nodeViewProps) => { + const { id: reminderId, reminderAt, context } = nodeViewProps.node.attrs; + + const container = document.createElement("span"); + container.contentEditable = "false"; + container.title = context ? `Reminder: ${context as string}` : "Reminder"; + + const icon = document.createElement("i"); + container.appendChild(icon); + + const label = document.createElement("span"); + label.textContent = formatReminderDate(reminderAt as string); + container.appendChild(label); + + let cancelledViaButton = false; + + const closeBtn = document.createElement("button"); + closeBtn.className = "reminder-chip__close"; + closeBtn.title = "Cancel reminder"; + closeBtn.innerHTML = ''; + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + cancelledViaButton = true; + emitter.emit("cancelReminder", { id: reminderId as string }); + const pos = nodeViewProps.getPos(); + if (typeof pos === "number") { + nodeViewProps.editor.commands.deleteRange({ + from: pos, + to: pos + nodeViewProps.node.nodeSize, + }); + } + }); + container.appendChild(closeBtn); + + let currentState: ReminderDisplayState | "" = ""; + + function applyState() { + const state = getReminderDisplayState( + reminderId as string, + reminderAt as string, + ); + if (state === currentState) return; + currentState = state; + const cancellable = + state === "upcoming" ? " reminder-chip--cancellable" : ""; + container.className = `reminder-chip${cancellable} ${stateStyles[state]}`; + icon.className = `reminder-chip__icon ${stateIcons[state]}`; + } + + applyState(); + const intervalId = setInterval(applyState, STATE_CHECK_INTERVAL_MS); + + const handleStateChange = (data: { + id: string; + state: "dismissed" | "missed"; + }) => { + if (data.id === reminderId) applyState(); + }; + emitter.on("reminderStateChanged", handleStateChange); + + return { + dom: container, + destroy: () => { + clearInterval(intervalId); + emitter.off("reminderStateChanged", handleStateChange); + + const { editor } = nodeViewProps; + const storage = editor.storage.reminderNode; + const isUserDeletion = + !cancelledViaButton && + !editor.isDestroyed && + !storage?.isContentReplacement; + + if (isUserDeletion) { + emitter.emit("cancelReminder", { id: reminderId as string }); + } + }, + }; + }; + }, +}); diff --git a/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue b/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue new file mode 100644 index 0000000..596272f --- /dev/null +++ b/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue @@ -0,0 +1,178 @@ + + + diff --git a/packages/frontend/src/components/content/editor/reminders/showReminderPicker.ts b/packages/frontend/src/components/content/editor/reminders/showReminderPicker.ts new file mode 100644 index 0000000..aa93d92 --- /dev/null +++ b/packages/frontend/src/components/content/editor/reminders/showReminderPicker.ts @@ -0,0 +1,33 @@ +import { createApp, h } from "vue"; + +import ReminderPicker from "./ReminderPicker.vue"; + +export function showReminderPicker( + position: { x: number; y: number }, + onConfirm: (date: Date) => void, +): void { + const container = document.createElement("div"); + container.id = "reminder-picker-container"; + document.body.appendChild(container); + + const cleanup = () => { + app.unmount(); + container.remove(); + }; + + const app = createApp({ + render: () => + h(ReminderPicker, { + position, + onConfirm: (date: Date) => { + onConfirm(date); + cleanup(); + }, + onCancel: () => { + cleanup(); + }, + }), + }); + + app.mount(container); +} diff --git a/packages/frontend/src/components/shared/reminders/ReminderNotificationManager.vue b/packages/frontend/src/components/shared/reminders/ReminderNotificationManager.vue new file mode 100644 index 0000000..967477f --- /dev/null +++ b/packages/frontend/src/components/shared/reminders/ReminderNotificationManager.vue @@ -0,0 +1,36 @@ + + + diff --git a/packages/frontend/src/components/shared/reminders/ReminderToast.vue b/packages/frontend/src/components/shared/reminders/ReminderToast.vue new file mode 100644 index 0000000..b56ff75 --- /dev/null +++ b/packages/frontend/src/components/shared/reminders/ReminderToast.vue @@ -0,0 +1,153 @@ + + + diff --git a/packages/frontend/src/components/shared/reminders/mountReminderNotifications.ts b/packages/frontend/src/components/shared/reminders/mountReminderNotifications.ts new file mode 100644 index 0000000..1013204 --- /dev/null +++ b/packages/frontend/src/components/shared/reminders/mountReminderNotifications.ts @@ -0,0 +1,25 @@ +import type { Pinia } from "pinia"; +import { createApp } from "vue"; + +import ReminderNotificationManager from "./ReminderNotificationManager.vue"; + +import { SDKPlugin } from "@/plugins/sdk"; +import type { FrontendSDK } from "@/types"; + +/** + * Mount the global reminder notification manager at document.body. + * Runs independently of the Notes++ page so toasts appear on any Caido page. + */ +export function mountReminderNotifications( + sdk: FrontendSDK, + pinia: Pinia, +): void { + const container = document.createElement("div"); + container.id = "notesplusplus-reminder-notifications"; + document.body.appendChild(container); + + const app = createApp(ReminderNotificationManager); + app.use(SDKPlugin, sdk); + app.use(pinia); + app.mount(container); +} diff --git a/packages/frontend/src/index.ts b/packages/frontend/src/index.ts index 27a09fd..1ac0f9c 100644 --- a/packages/frontend/src/index.ts +++ b/packages/frontend/src/index.ts @@ -15,6 +15,7 @@ import { showNoteModal, showSearchModal, } from "@/actions/actions"; +import { mountReminderNotifications } from "@/components/shared/reminders/mountReminderNotifications"; import { emitter } from "@/utils/eventBus"; import { convertMarkdownToTipTap } from "@/utils/markdownToJSON"; @@ -40,6 +41,8 @@ export const init = (sdk: FrontendSDK) => { app.mount(root); + mountReminderNotifications(sdk, pinia); + sdk.commands.register("notesplusplus:floating-modal", { name: "Write Note", group: "Notes++", diff --git a/packages/frontend/src/repositories/reminders.ts b/packages/frontend/src/repositories/reminders.ts new file mode 100644 index 0000000..8fe3a66 --- /dev/null +++ b/packages/frontend/src/repositories/reminders.ts @@ -0,0 +1,56 @@ +import { useSDK } from "@/plugins/sdk"; + +export const useRemindersRepository = () => { + const sdk = useSDK(); + + async function getReminders() { + const result = await sdk.backend.getReminders(); + if (result.kind === "Error") { + throw new Error(`Error loading reminders: ${result.error}`); + } + + return result.value; + } + + async function createReminder( + notePath: string, + context: string, + reminderAt: string, + ) { + const result = await sdk.backend.createReminder( + notePath, + context, + reminderAt, + ); + if (result.kind === "Error") { + throw new Error(`Error creating reminder: ${result.error}`); + } + + return result.value; + } + + async function deleteReminder(reminderId: string) { + const result = await sdk.backend.deleteReminder(reminderId); + if (result.kind === "Error") { + throw new Error(`Error deleting reminder: ${result.error}`); + } + + return result.value; + } + + async function dismissReminder(reminderId: string) { + const result = await sdk.backend.dismissReminder(reminderId); + if (result.kind === "Error") { + throw new Error(`Error dismissing reminder: ${result.error}`); + } + + return result.value; + } + + return { + getReminders, + createReminder, + deleteReminder, + dismissReminder, + }; +}; diff --git a/packages/frontend/src/stores/reminders.ts b/packages/frontend/src/stores/reminders.ts new file mode 100644 index 0000000..47dde5c --- /dev/null +++ b/packages/frontend/src/stores/reminders.ts @@ -0,0 +1,97 @@ +import { defineStore } from "pinia"; +import { type Reminder } from "shared"; +import { ref } from "vue"; + +import { useSDK } from "@/plugins/sdk"; +import { useRemindersRepository } from "@/repositories/reminders"; +import { emitter } from "@/utils/eventBus"; +import { playNotificationSound } from "@/utils/notificationSound"; +import { loadReminderStates, setReminderState } from "@/utils/reminderStates"; + +export const useRemindersStore = defineStore("reminders", () => { + const sdk = useSDK(); + const repository = useRemindersRepository(); + + const activeToasts = ref([]); + + async function createReminder( + notePath: string, + context: string, + reminderAt: string, + ) { + try { + const reminder = await repository.createReminder( + notePath, + context, + reminderAt, + ); + sdk.window.showToast("Reminder set", { variant: "success" }); + return reminder; + } catch (error) { + sdk.window.showToast(`Error creating reminder: ${error}`, { + variant: "error", + }); + return undefined; + } + } + + async function deleteReminder(reminderId: string) { + try { + await repository.deleteReminder(reminderId); + return true; + } catch (error) { + sdk.window.showToast(`Error deleting reminder: ${error}`, { + variant: "error", + }); + return false; + } + } + + /** User explicitly dismissed — persists to backend and marks chip as dismissed. */ + async function dismissReminder(reminderId: string) { + activeToasts.value = activeToasts.value.filter((r) => r.id !== reminderId); + + setReminderState(reminderId, "dismissed"); + emitter.emit("reminderStateChanged", { + id: reminderId, + state: "dismissed", + }); + + try { + await repository.dismissReminder(reminderId); + } catch (error) { + sdk.window.showToast(`Error dismissing reminder: ${error}`, { + variant: "error", + }); + } + } + + /** Toast auto-expired without user action. */ + function markMissed(reminderId: string) { + activeToasts.value = activeToasts.value.filter((r) => r.id !== reminderId); + + setReminderState(reminderId, "missed"); + emitter.emit("reminderStateChanged", { + id: reminderId, + state: "missed", + }); + } + + sdk.backend.onEvent("notes++:reminderDue", (reminder: Reminder) => { + activeToasts.value.push(reminder); + playNotificationSound(); + }); + + repository + .getReminders() + .then(loadReminderStates) + .catch(() => {}); + + return { + activeToasts, + createReminder, + deleteReminder, + dismissReminder, + markMissed, + }; +}); diff --git a/packages/frontend/src/utils/eventBus.ts b/packages/frontend/src/utils/eventBus.ts index 74d3dcd..358345f 100644 --- a/packages/frontend/src/utils/eventBus.ts +++ b/packages/frontend/src/utils/eventBus.ts @@ -6,6 +6,18 @@ type Events = { restoreFocus: void; showMigrationDialog: { path: string; content: string }[]; confirmMigration: { path: string; content: string }[]; + openReminderPicker: { + selectedText: string; + position: { x: number; y: number }; + selectionRange: { from: number; to: number }; + }; + reminderStateChanged: { + id: string; + state: "dismissed" | "missed"; + }; + cancelReminder: { + id: string; + }; }; export const emitter = mitt(); diff --git a/packages/frontend/src/utils/notificationSound.ts b/packages/frontend/src/utils/notificationSound.ts new file mode 100644 index 0000000..f04b208 --- /dev/null +++ b/packages/frontend/src/utils/notificationSound.ts @@ -0,0 +1,36 @@ +/** + * Two-tone ascending chime via Web Audio API. + */ +export function playNotificationSound(): void { + try { + const ctx = new AudioContext(); + const play = () => { + const frequencies = [587.33, 880]; + + frequencies.forEach((freq, i) => { + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.connect(gain); + gain.connect(ctx.destination); + + osc.frequency.value = freq; + osc.type = "sine"; + + const start = ctx.currentTime + i * 0.15; + gain.gain.setValueAtTime(0.3, start); + gain.gain.exponentialRampToValueAtTime(0.001, start + 0.5); + + osc.start(start); + osc.stop(start + 0.5); + }); + }; + + if (ctx.state === "suspended") { + ctx.resume().then(play); + } else { + play(); + } + } catch { + // Audio not available in this environment + } +} diff --git a/packages/frontend/src/utils/reminderStates.ts b/packages/frontend/src/utils/reminderStates.ts new file mode 100644 index 0000000..2947f1d --- /dev/null +++ b/packages/frontend/src/utils/reminderStates.ts @@ -0,0 +1,40 @@ +import type { Reminder } from "shared"; + +export type ReminderDisplayState = "upcoming" | "due" | "dismissed" | "missed"; + +const registry = new Map(); + +export function setReminderState( + id: string, + state: "dismissed" | "missed", +): void { + registry.set(id, state); +} + +/** + * Resolve the display state for a reminder chip. + * Priority: explicit state from registry > time-based calculation. + */ +export function getReminderDisplayState( + id: string, + reminderAt: string, +): ReminderDisplayState { + const override = registry.get(id); + if (override) return override; + + return new Date(reminderAt) <= new Date() ? "due" : "upcoming"; +} + +/** + * Populate the registry from backend reminder data. + * Call on store init to restore states after page refresh. + */ +export function loadReminderStates(reminders: Reminder[]): void { + for (const r of reminders) { + if (r.dismissed) { + registry.set(r.id, "dismissed"); + } else if (r.triggered) { + registry.set(r.id, "missed"); + } + } +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e286dd7..318a0d1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -37,6 +37,16 @@ export interface NoteModalSaveData { notePath: string; } +export interface Reminder { + id: string; + notePath: string; + context: string; + reminderAt: string; + createdAt: string; + triggered: boolean; + dismissed: boolean; +} + export type Result = | { kind: "Error"; error: string } | { kind: "Success"; value: T }; From c5150b91735a96dabbc9011eebf429195ff851e3 Mon Sep 17 00:00:00 2001 From: Amr Elsagaei Date: Mon, 13 Apr 2026 11:16:56 -0300 Subject: [PATCH 2/2] Polish and fix bugs --- packages/backend/src/api/reminder.ts | 8 ++++- packages/backend/src/index.ts | 5 ++- packages/backend/src/schemas/reminder.ts | 2 +- packages/backend/src/utils/reminderFile.ts | 16 ++++++++- .../editor/extensions/reminder-node.ts | 13 ++++--- .../editor/reminders/ReminderPicker.vue | 1 + .../shared/reminders/ReminderToast.vue | 5 ++- .../frontend/src/repositories/reminders.ts | 34 ++++--------------- packages/frontend/src/stores/reminders.ts | 30 ++++++++++++---- .../frontend/src/utils/notificationSound.ts | 18 ++++++++-- 10 files changed, 86 insertions(+), 46 deletions(-) diff --git a/packages/backend/src/api/reminder.ts b/packages/backend/src/api/reminder.ts index 0375066..3715ee7 100644 --- a/packages/backend/src/api/reminder.ts +++ b/packages/backend/src/api/reminder.ts @@ -42,6 +42,12 @@ export async function createReminder( try { createReminderSchema.parse({ notePath, context, reminderAt }); + const parsedDate = new Date(reminderAt); + if (isNaN(parsedDate.getTime())) { + return error("Invalid reminder date"); + } + const normalizedReminderAt = parsedDate.toISOString(); + const projectIDResult = await ensureProjectDirectory(sdk); if (projectIDResult.kind === "Error") { return error(projectIDResult.error); @@ -52,7 +58,7 @@ export async function createReminder( id: randomUUID(), notePath, context, - reminderAt, + reminderAt: normalizedReminderAt, createdAt: new Date().toISOString(), triggered: false, dismissed: false, diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 653f85e..ffa4ce2 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -24,6 +24,8 @@ import { startReminderTimer } from "./utils/reminderTimer"; export type { BackendEvents } from "./types/events"; +let stopReminderTimer: (() => void) | undefined; + export type API = DefineAPI<{ getTree: typeof getTree; getNote: typeof getNote; @@ -67,7 +69,8 @@ export function init(sdk: SDK) { sdk.api.send("notes++:projectChange", project?.getId()); }); - startReminderTimer(sdk); + stopReminderTimer?.(); + stopReminderTimer = startReminderTimer(sdk); sdk.console.log("Notes++ backend initialized successfully"); } diff --git a/packages/backend/src/schemas/reminder.ts b/packages/backend/src/schemas/reminder.ts index 05c4d7d..8254802 100644 --- a/packages/backend/src/schemas/reminder.ts +++ b/packages/backend/src/schemas/reminder.ts @@ -3,7 +3,7 @@ import { z } from "zod"; export const createReminderSchema = z.object({ notePath: z.string().min(1), context: z.string(), - reminderAt: z.string().min(1), + reminderAt: z.string().datetime({ offset: true }), }); export const deleteReminderSchema = z.object({ diff --git a/packages/backend/src/utils/reminderFile.ts b/packages/backend/src/utils/reminderFile.ts index 2dd6298..062225a 100644 --- a/packages/backend/src/utils/reminderFile.ts +++ b/packages/backend/src/utils/reminderFile.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import path from "path"; import type { Reminder } from "shared"; +import { z } from "zod"; import { createDirectory, @@ -13,6 +14,18 @@ import { getNoteRootPath } from "./paths"; const REMINDERS_FILENAME = "reminders.json"; +const ReminderSchema = z.object({ + id: z.string(), + notePath: z.string(), + context: z.string(), + reminderAt: z.string(), + createdAt: z.string(), + triggered: z.boolean(), + dismissed: z.boolean(), +}); + +const RemindersArraySchema = z.array(ReminderSchema); + export function getRemindersFilePath(projectID: string): string { return path.join(getNoteRootPath(projectID), REMINDERS_FILENAME); } @@ -27,7 +40,8 @@ export function readRemindersFile(projectID: string): Reminder[] { try { const raw = fs.readFileSync(toSystemPath(filePath), "utf8"); const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as Reminder[]) : []; + const result = RemindersArraySchema.safeParse(parsed); + return result.success ? (result.data as Reminder[]) : []; } catch { return []; } diff --git a/packages/frontend/src/components/content/editor/extensions/reminder-node.ts b/packages/frontend/src/components/content/editor/extensions/reminder-node.ts index 521f048..68e198d 100644 --- a/packages/frontend/src/components/content/editor/extensions/reminder-node.ts +++ b/packages/frontend/src/components/content/editor/extensions/reminder-node.ts @@ -36,7 +36,7 @@ if (!document.getElementById(styleId)) { flex-shrink: 0; } .reminder-chip__close { - display: none; + display: inline-flex; align-items: center; justify-content: center; background: none; @@ -45,15 +45,17 @@ if (!document.getElementById(styleId)) { padding: 0 2px; margin-left: 2px; font-size: 9px; - opacity: 0.6; + opacity: 0; color: inherit; transition: opacity 0.15s ease; } - .reminder-chip__close:hover { + .reminder-chip__close:hover, + .reminder-chip__close:focus-visible { opacity: 1; } - .reminder-chip--cancellable:hover .reminder-chip__close { - display: inline-flex; + .reminder-chip--cancellable:hover .reminder-chip__close, + .reminder-chip--cancellable:focus-within .reminder-chip__close { + opacity: 0.6; } `; document.head.appendChild(style); @@ -154,6 +156,7 @@ export const ReminderNode = Node.create({ const closeBtn = document.createElement("button"); closeBtn.className = "reminder-chip__close"; closeBtn.title = "Cancel reminder"; + closeBtn.setAttribute("aria-label", "Cancel reminder"); closeBtn.innerHTML = ''; closeBtn.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue b/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue index 596272f..42d0fb0 100644 --- a/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue +++ b/packages/frontend/src/components/content/editor/reminders/ReminderPicker.vue @@ -61,6 +61,7 @@ function handleDateInput(event: Event) { function handleConfirm() { const date = new Date(dateValue.value); if (isNaN(date.getTime())) return; + if (date <= new Date()) return; emit("confirm", date); } diff --git a/packages/frontend/src/components/shared/reminders/ReminderToast.vue b/packages/frontend/src/components/shared/reminders/ReminderToast.vue index b56ff75..9766c83 100644 --- a/packages/frontend/src/components/shared/reminders/ReminderToast.vue +++ b/packages/frontend/src/components/shared/reminders/ReminderToast.vue @@ -17,6 +17,7 @@ const TICK_MS = 50; const MAX_CONTEXT_LENGTH = 60; const isHovered = ref(false); +const isFocused = ref(false); const progress = ref(100); const visible = ref(false); @@ -25,7 +26,7 @@ let elapsed = 0; function startTimer() { timerId = setInterval(() => { - if (isHovered.value) return; + if (isHovered.value || isFocused.value) return; elapsed += TICK_MS; progress.value = Math.max(0, 100 - (elapsed / AUTO_DISMISS_MS) * 100); @@ -99,6 +100,8 @@ onUnmounted(() => { }" @mouseenter="isHovered = true" @mouseleave="isHovered = false" + @focusin="isFocused = true" + @focusout="isFocused = false" >
{ const sdk = useSDK(); async function getReminders() { - const result = await sdk.backend.getReminders(); - if (result.kind === "Error") { - throw new Error(`Error loading reminders: ${result.error}`); - } - - return result.value; + return handleBackendCall(sdk.backend.getReminders(), sdk); } async function createReminder( @@ -17,34 +13,18 @@ export const useRemindersRepository = () => { context: string, reminderAt: string, ) { - const result = await sdk.backend.createReminder( - notePath, - context, - reminderAt, + return handleBackendCall( + sdk.backend.createReminder(notePath, context, reminderAt), + sdk, ); - if (result.kind === "Error") { - throw new Error(`Error creating reminder: ${result.error}`); - } - - return result.value; } async function deleteReminder(reminderId: string) { - const result = await sdk.backend.deleteReminder(reminderId); - if (result.kind === "Error") { - throw new Error(`Error deleting reminder: ${result.error}`); - } - - return result.value; + return handleBackendCall(sdk.backend.deleteReminder(reminderId), sdk); } async function dismissReminder(reminderId: string) { - const result = await sdk.backend.dismissReminder(reminderId); - if (result.kind === "Error") { - throw new Error(`Error dismissing reminder: ${result.error}`); - } - - return result.value; + return handleBackendCall(sdk.backend.dismissReminder(reminderId), sdk); } return { diff --git a/packages/frontend/src/stores/reminders.ts b/packages/frontend/src/stores/reminders.ts index 47dde5c..3475b87 100644 --- a/packages/frontend/src/stores/reminders.ts +++ b/packages/frontend/src/stores/reminders.ts @@ -49,17 +49,21 @@ export const useRemindersStore = defineStore("reminders", () => { /** User explicitly dismissed — persists to backend and marks chip as dismissed. */ async function dismissReminder(reminderId: string) { + const removedToast = activeToasts.value.find((r) => r.id === reminderId); activeToasts.value = activeToasts.value.filter((r) => r.id !== reminderId); - setReminderState(reminderId, "dismissed"); - emitter.emit("reminderStateChanged", { - id: reminderId, - state: "dismissed", - }); - try { await repository.dismissReminder(reminderId); + + setReminderState(reminderId, "dismissed"); + emitter.emit("reminderStateChanged", { + id: reminderId, + state: "dismissed", + }); } catch (error) { + if (removedToast) { + activeToasts.value.push(removedToast); + } sdk.window.showToast(`Error dismissing reminder: ${error}`, { variant: "error", }); @@ -84,7 +88,19 @@ export const useRemindersStore = defineStore("reminders", () => { repository .getReminders() - .then(loadReminderStates) + .then((reminders) => { + loadReminderStates(reminders); + for (const r of reminders) { + if (r.dismissed) { + emitter.emit("reminderStateChanged", { + id: r.id, + state: "dismissed", + }); + } else if (r.triggered) { + emitter.emit("reminderStateChanged", { id: r.id, state: "missed" }); + } + } + }) .catch(() => {}); return { diff --git a/packages/frontend/src/utils/notificationSound.ts b/packages/frontend/src/utils/notificationSound.ts index f04b208..4e4aaf3 100644 --- a/packages/frontend/src/utils/notificationSound.ts +++ b/packages/frontend/src/utils/notificationSound.ts @@ -1,9 +1,20 @@ /** * Two-tone ascending chime via Web Audio API. + * Uses a cached singleton AudioContext to avoid hitting browser limits. */ + +let cachedCtx: AudioContext | undefined; + +function getAudioContext(): AudioContext { + if (!cachedCtx || cachedCtx.state === "closed") { + cachedCtx = new AudioContext(); + } + return cachedCtx; +} + export function playNotificationSound(): void { try { - const ctx = new AudioContext(); + const ctx = getAudioContext(); const play = () => { const frequencies = [587.33, 880]; @@ -26,7 +37,10 @@ export function playNotificationSound(): void { }; if (ctx.state === "suspended") { - ctx.resume().then(play); + ctx + .resume() + .then(play) + .catch(() => {}); } else { play(); }