From c42cc3f67d9381e789e078661f9a20b546c75479 Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sun, 26 Jul 2026 16:27:26 -0500 Subject: [PATCH 1/7] feat: undo/redo stack and command event middleware --- .../src/commands/calendar-event-events.ts | 33 +++ apps/server/src/commands/card-commands.ts | 9 +- apps/server/src/commands/card-events.ts | 49 ++++ .../src/commands/command-event-middleware.ts | 127 +++++++++++ apps/server/src/commands/event-commands.ts | 7 +- apps/server/src/commands/file-commands.ts | 12 +- apps/server/src/commands/file-events.ts | 48 ++++ apps/server/src/commands/index-commands.ts | 1 + apps/server/src/commands/note-lifecycle.ts | 16 +- apps/server/src/commands/server-events.ts | 16 ++ apps/server/src/main.ts | 12 +- apps/web/src/api/undoable-file-ops.ts | 76 +++++++ apps/web/src/app.tsx | 68 +++--- apps/web/src/components/explorer.tsx | 8 +- apps/web/src/components/ribbon.tsx | 16 +- apps/web/src/main.tsx | 5 +- apps/web/src/state/app-services.tsx | 8 + apps/web/src/state/undo-context.tsx | 97 ++++++++ packages/core/src/contracts.ts | 6 + packages/core/src/index.ts | 1 + packages/core/src/undo-stack.test.ts | 213 ++++++++++++++++++ packages/core/src/undo-stack.ts | 173 ++++++++++++++ packages/note-boards/src/board-view.tsx | 81 ++++++- packages/note-calendar/src/calendar-view.tsx | 86 ++++++- packages/note-canvas/src/canvas-view.tsx | 31 ++- packages/note-grid/src/grid-view.tsx | 62 ++--- packages/note-tables/src/table-grid.tsx | 20 ++ 27 files changed, 1176 insertions(+), 105 deletions(-) create mode 100644 apps/server/src/commands/calendar-event-events.ts create mode 100644 apps/server/src/commands/card-events.ts create mode 100644 apps/server/src/commands/command-event-middleware.ts create mode 100644 apps/server/src/commands/file-events.ts create mode 100644 apps/server/src/commands/server-events.ts create mode 100644 apps/web/src/api/undoable-file-ops.ts create mode 100644 apps/web/src/state/undo-context.tsx create mode 100644 packages/core/src/undo-stack.test.ts create mode 100644 packages/core/src/undo-stack.ts diff --git a/apps/server/src/commands/calendar-event-events.ts b/apps/server/src/commands/calendar-event-events.ts new file mode 100644 index 0000000..64f79a3 --- /dev/null +++ b/apps/server/src/commands/calendar-event-events.ts @@ -0,0 +1,33 @@ +import type { EventBus } from "@notes/core"; +import type { RichEvent } from "@notes/note-calendar"; + +// --------------------------------------------------------------------------- +// Calendar event command event map — emitted after mutating event.* commands +// --------------------------------------------------------------------------- + +export type CalendarEventCommandEventMap = { + "calendar-event.created": { calendarPath: string; event: RichEvent }; + "calendar-event.updated": { calendarPath: string; event: RichEvent }; + "calendar-event.deleted": { calendarPath: string; eventId: string }; +}; + +export function emitCalendarEventCreated( + events: EventBus, + payload: CalendarEventCommandEventMap["calendar-event.created"], +): Promise { + return events.emit("calendar-event.created", payload); +} + +export function emitCalendarEventUpdated( + events: EventBus, + payload: CalendarEventCommandEventMap["calendar-event.updated"], +): Promise { + return events.emit("calendar-event.updated", payload); +} + +export function emitCalendarEventDeleted( + events: EventBus, + payload: CalendarEventCommandEventMap["calendar-event.deleted"], +): Promise { + return events.emit("calendar-event.deleted", payload); +} diff --git a/apps/server/src/commands/card-commands.ts b/apps/server/src/commands/card-commands.ts index 09fd694..5ac119c 100644 --- a/apps/server/src/commands/card-commands.ts +++ b/apps/server/src/commands/card-commands.ts @@ -3,7 +3,7 @@ import type { RichCard } from "@notes/note-boards"; import { parseBoard, serializeBoard } from "@notes/note-boards"; import type { Tome } from "@notes/tome"; import { renameCompanionFolder } from "./companion-folder-rename"; -import type { NoteLifecycleEventMap } from "./note-lifecycle"; +import type { ServerEventMap } from "./server-events"; import { cardDotFolder, deleteCard, listCards, readCard, writeCard } from "./card-store"; interface GetTome { @@ -30,7 +30,7 @@ async function saveBoardModel( export function registerCardCommands( bus: CommandBus, getTome: GetTome, - events?: EventBus, + events?: EventBus, ): void { events?.on("note.path-moved", async ({ fromPath, toPath, noteType }) => { if (noteType !== "board") { @@ -73,6 +73,7 @@ export function registerCardCommands( // ------------------------------------------------------------------ bus.register<{ boardPath: string; column: string }, RichCard>({ name: "card.create", + mutates: true, handler: async ({ boardPath, column }) => { const tome = getTome(); const model = await readBoardModel(boardPath, tome); @@ -97,6 +98,7 @@ export function registerCardCommands( // ------------------------------------------------------------------ bus.register<{ boardPath: string; card: RichCard }, RichCard>({ name: "card.update", + mutates: true, handler: async ({ boardPath, card }) => { const tome = getTome(); await writeCard(boardPath, card, tome); @@ -129,6 +131,7 @@ export function registerCardCommands( // ------------------------------------------------------------------ bus.register<{ boardPath: string; cardId: string }, { cardId: string }>({ name: "card.delete", + mutates: true, handler: async ({ boardPath, cardId }) => { const tome = getTome(); await deleteCard(boardPath, cardId, tome); @@ -149,6 +152,7 @@ export function registerCardCommands( // ------------------------------------------------------------------ bus.register<{ boardPath: string; cardId: string }, RichCard>({ name: "card.duplicate", + mutates: true, handler: async ({ boardPath, cardId }) => { const tome = getTome(); const source = await readCard(boardPath, cardId, tome); @@ -182,6 +186,7 @@ export function registerCardCommands( { cardId: string; toColumn: string; toIndex: number } >({ name: "card.move", + mutates: true, handler: async ({ boardPath, cardId, toColumn, toIndex }) => { const tome = getTome(); const model = await readBoardModel(boardPath, tome); diff --git a/apps/server/src/commands/card-events.ts b/apps/server/src/commands/card-events.ts new file mode 100644 index 0000000..328b82e --- /dev/null +++ b/apps/server/src/commands/card-events.ts @@ -0,0 +1,49 @@ +import type { EventBus } from "@notes/core"; +import type { RichCard } from "@notes/note-boards"; + +// --------------------------------------------------------------------------- +// Card command event map — emitted after each mutating card.* command succeeds +// --------------------------------------------------------------------------- + +export type CardCommandEventMap = { + "card.created": { boardPath: string; card: RichCard }; + "card.updated": { boardPath: string; card: RichCard }; + "card.deleted": { boardPath: string; cardId: string }; + "card.moved": { boardPath: string; cardId: string; toColumn: string; toIndex: number }; + "card.duplicated": { boardPath: string; card: RichCard }; +}; + +export function emitCardCreated( + events: EventBus, + payload: CardCommandEventMap["card.created"], +): Promise { + return events.emit("card.created", payload); +} + +export function emitCardUpdated( + events: EventBus, + payload: CardCommandEventMap["card.updated"], +): Promise { + return events.emit("card.updated", payload); +} + +export function emitCardDeleted( + events: EventBus, + payload: CardCommandEventMap["card.deleted"], +): Promise { + return events.emit("card.deleted", payload); +} + +export function emitCardMoved( + events: EventBus, + payload: CardCommandEventMap["card.moved"], +): Promise { + return events.emit("card.moved", payload); +} + +export function emitCardDuplicated( + events: EventBus, + payload: CardCommandEventMap["card.duplicated"], +): Promise { + return events.emit("card.duplicated", payload); +} diff --git a/apps/server/src/commands/command-event-middleware.ts b/apps/server/src/commands/command-event-middleware.ts new file mode 100644 index 0000000..7a9ad6c --- /dev/null +++ b/apps/server/src/commands/command-event-middleware.ts @@ -0,0 +1,127 @@ +import type { EventBus, Middleware } from "@notes/core"; +import type { RichCard } from "@notes/note-boards"; +import type { RichEvent } from "@notes/note-calendar"; +import type { ServerEventMap } from "./server-events"; + +/** + * Command bus middleware that emits typed domain events after any command that + * has `mutates: true` succeeds. Plugins and internal listeners can subscribe + * to `EventBus` to react to any mutation without coupling to + * specific command implementations. + * + * Event emission follows the `note-lifecycle.ts` pattern: typed event maps per + * domain, collected into a single `ServerEventMap` union. + */ +export function createCommandEventMiddleware(events: EventBus): Middleware { + return async (invocation, next) => { + const result = await next(); + + if (!invocation.ctx) return result; + + const command = invocation as { name: string; payload: unknown }; + const { name, payload } = command; + + switch (name) { + // ------------------------------------------------------------------ + // file.* mutations + // ------------------------------------------------------------------ + case "file.create": + case "file.createBinary": { + const p = payload as { path: string }; + await events.emit("file.created", { path: p.path }); + break; + } + case "file.write": { + const p = payload as { path: string }; + await events.emit("file.written", { path: p.path }); + break; + } + case "file.delete": { + const p = payload as { path: string }; + await events.emit("file.deleted", { path: p.path }); + break; + } + case "file.rename": + case "file.move": { + const p = payload as { from: string; to: string }; + await events.emit("file.renamed", { from: p.from, to: p.to }); + // note.path-moved is emitted separately inside file-commands.ts + break; + } + case "file.mkdir": { + const p = payload as { path: string }; + await events.emit("file.mkdir", { path: p.path }); + break; + } + + // ------------------------------------------------------------------ + // card.* mutations + // ------------------------------------------------------------------ + case "card.create": { + const p = payload as { boardPath: string }; + const card = result as RichCard; + await events.emit("card.created", { boardPath: p.boardPath, card }); + break; + } + case "card.update": { + const p = payload as { boardPath: string; card: RichCard }; + await events.emit("card.updated", { boardPath: p.boardPath, card: p.card }); + break; + } + case "card.delete": { + const p = payload as { boardPath: string; cardId: string }; + await events.emit("card.deleted", { boardPath: p.boardPath, cardId: p.cardId }); + break; + } + case "card.move": { + const p = payload as { + boardPath: string; + cardId: string; + toColumn: string; + toIndex: number; + }; + await events.emit("card.moved", { + boardPath: p.boardPath, + cardId: p.cardId, + toColumn: p.toColumn, + toIndex: p.toIndex, + }); + break; + } + case "card.duplicate": { + const p = payload as { boardPath: string }; + const card = result as RichCard; + await events.emit("card.duplicated", { boardPath: p.boardPath, card }); + break; + } + + // ------------------------------------------------------------------ + // event.* (calendar events) mutations + // ------------------------------------------------------------------ + case "event.create": { + const p = payload as { calendarPath: string }; + const event = result as RichEvent; + await events.emit("calendar-event.created", { calendarPath: p.calendarPath, event }); + break; + } + case "event.update": { + const p = payload as { calendarPath: string; event: RichEvent }; + await events.emit("calendar-event.updated", { + calendarPath: p.calendarPath, + event: p.event, + }); + break; + } + case "event.delete": { + const p = payload as { calendarPath: string; eventId: string }; + await events.emit("calendar-event.deleted", { + calendarPath: p.calendarPath, + eventId: p.eventId, + }); + break; + } + } + + return result; + }; +} diff --git a/apps/server/src/commands/event-commands.ts b/apps/server/src/commands/event-commands.ts index d944f89..930f92a 100644 --- a/apps/server/src/commands/event-commands.ts +++ b/apps/server/src/commands/event-commands.ts @@ -3,7 +3,7 @@ import type { RichEvent } from "@notes/note-calendar"; import { newEventId, parseCalendar, serializeCalendar } from "@notes/note-calendar"; import type { Tome } from "@notes/tome"; import { renameCompanionFolder } from "./companion-folder-rename"; -import type { NoteLifecycleEventMap } from "./note-lifecycle"; +import type { ServerEventMap } from "./server-events"; import { deleteEvent, eventDotFolder, listEvents, readEvent, writeEvent } from "./event-store"; interface GetTome { @@ -30,7 +30,7 @@ async function saveCalendarModel( export function registerEventCommands( bus: CommandBus, getTome: GetTome, - events?: EventBus, + events?: EventBus, ): void { events?.on("note.path-moved", async ({ fromPath, toPath, noteType }) => { if (noteType !== "calendar") { @@ -71,6 +71,7 @@ export function registerEventCommands( // ------------------------------------------------------------------ bus.register<{ calendarPath: string; date: string }, RichEvent>({ name: "event.create", + mutates: true, handler: async ({ calendarPath, date }) => { const tome = getTome(); const model = await readCalendarModel(calendarPath, tome); @@ -88,6 +89,7 @@ export function registerEventCommands( // ------------------------------------------------------------------ bus.register<{ calendarPath: string; event: RichEvent }, RichEvent>({ name: "event.update", + mutates: true, handler: async ({ calendarPath, event }) => { await writeEvent(calendarPath, event, getTome()); return event; @@ -99,6 +101,7 @@ export function registerEventCommands( // ------------------------------------------------------------------ bus.register<{ calendarPath: string; eventId: string }, { eventId: string }>({ name: "event.delete", + mutates: true, handler: async ({ calendarPath, eventId }) => { const tome = getTome(); await deleteEvent(calendarPath, eventId, tome); diff --git a/apps/server/src/commands/file-commands.ts b/apps/server/src/commands/file-commands.ts index 1ff9da8..d8d7f8d 100644 --- a/apps/server/src/commands/file-commands.ts +++ b/apps/server/src/commands/file-commands.ts @@ -7,13 +7,13 @@ import type { } from "@notes/shared"; import type { Tome } from "@notes/tome"; import { rewriteEmbeddedReferences } from "../rename-references"; -import { emitNotePathMoved, type NoteLifecycleEventMap } from "./note-lifecycle"; +import type { ServerEventMap } from "./server-events"; /** Registers the core `file.*` commands, backed by the active Tome. */ export function registerFileCommands( bus: CommandBus, getTome: () => Tome, - events: EventBus, + events: EventBus, ): void { bus.register({ name: "file.tree", @@ -38,6 +38,7 @@ export function registerFileCommands( bus.register({ name: "file.write", + mutates: true, handler: async (payload) => { await getTome().write(payload.path, payload.content); return { path: payload.path }; @@ -46,6 +47,7 @@ export function registerFileCommands( bus.register({ name: "file.create", + mutates: true, handler: async (payload) => { await getTome().create(payload.path, payload.content); return { path: payload.path }; @@ -54,6 +56,7 @@ export function registerFileCommands( bus.register({ name: "file.createBinary", + mutates: true, handler: async (payload) => { await getTome().createBinary(payload.path, Buffer.from(payload.contentBase64, "base64")); return { path: payload.path }; @@ -63,6 +66,7 @@ export function registerFileCommands( for (const name of ["file.rename", "file.move"] as const) { bus.register({ name, + mutates: true, handler: async (payload, ctx) => { const tome = getTome(); const noteTypeResult = (await bus.dispatch( @@ -71,7 +75,7 @@ export function registerFileCommands( ctx, )) as { type: string | null }; await tome.move(payload.from, payload.to); - await emitNotePathMoved(events, { + await events.emit("note.path-moved", { fromPath: payload.from, toPath: payload.to, noteType: noteTypeResult.type, @@ -106,6 +110,7 @@ export function registerFileCommands( bus.register({ name: "file.mkdir", + mutates: true, handler: async (payload) => { await getTome().mkdir(payload.path); return { path: payload.path }; @@ -114,6 +119,7 @@ export function registerFileCommands( bus.register({ name: "file.delete", + mutates: true, handler: async (payload) => { await getTome().delete(payload.path); return { path: payload.path }; diff --git a/apps/server/src/commands/file-events.ts b/apps/server/src/commands/file-events.ts new file mode 100644 index 0000000..116befc --- /dev/null +++ b/apps/server/src/commands/file-events.ts @@ -0,0 +1,48 @@ +import type { EventBus } from "@notes/core"; + +// --------------------------------------------------------------------------- +// File command event map — emitted after each mutating file.* command succeeds +// --------------------------------------------------------------------------- + +export type FileCommandEventMap = { + "file.created": { path: string }; + "file.written": { path: string }; + "file.deleted": { path: string }; + "file.renamed": { from: string; to: string }; + "file.mkdir": { path: string }; +}; + +export function emitFileCreated( + events: EventBus, + payload: FileCommandEventMap["file.created"], +): Promise { + return events.emit("file.created", payload); +} + +export function emitFileWritten( + events: EventBus, + payload: FileCommandEventMap["file.written"], +): Promise { + return events.emit("file.written", payload); +} + +export function emitFileDeleted( + events: EventBus, + payload: FileCommandEventMap["file.deleted"], +): Promise { + return events.emit("file.deleted", payload); +} + +export function emitFileRenamed( + events: EventBus, + payload: FileCommandEventMap["file.renamed"], +): Promise { + return events.emit("file.renamed", payload); +} + +export function emitFileMkdir( + events: EventBus, + payload: FileCommandEventMap["file.mkdir"], +): Promise { + return events.emit("file.mkdir", payload); +} diff --git a/apps/server/src/commands/index-commands.ts b/apps/server/src/commands/index-commands.ts index c0b8e22..91ce083 100644 --- a/apps/server/src/commands/index-commands.ts +++ b/apps/server/src/commands/index-commands.ts @@ -66,6 +66,7 @@ export function registerIndexCommands(bus: CommandBus, service: IndexService): v bus.register({ name: "index.rebuild", + mutates: true, handler: async () => { await service.buildFromTome(); return { rebuilt: true, notes: service.index.noteCount() }; diff --git a/apps/server/src/commands/note-lifecycle.ts b/apps/server/src/commands/note-lifecycle.ts index a5822d5..40888a3 100644 --- a/apps/server/src/commands/note-lifecycle.ts +++ b/apps/server/src/commands/note-lifecycle.ts @@ -1,18 +1,16 @@ -import type { EventBus } from "@notes/core"; - -export interface NotePathMovedPayload { - fromPath: string; - toPath: string; - noteType: string | null; -} +import { EventBus } from "@notes/core"; export type NoteLifecycleEventMap = { - "note.path-moved": NotePathMovedPayload; + "note.path-moved": { + fromPath: string; + toPath: string; + noteType: string | null; + }; }; export function emitNotePathMoved( events: EventBus, - payload: NotePathMovedPayload, + payload: NoteLifecycleEventMap["note.path-moved"], ): Promise { return events.emit("note.path-moved", payload); } diff --git a/apps/server/src/commands/server-events.ts b/apps/server/src/commands/server-events.ts new file mode 100644 index 0000000..977dc2e --- /dev/null +++ b/apps/server/src/commands/server-events.ts @@ -0,0 +1,16 @@ +import type { CalendarEventCommandEventMap } from "./calendar-event-events"; +import type { CardCommandEventMap } from "./card-events"; +import type { FileCommandEventMap } from "./file-events"; +import type { NoteLifecycleEventMap } from "./note-lifecycle"; + +/** + * Union of all server-side event maps. Plugins and internal consumers can subscribe + * to events on an `EventBus` to react to any mutation. + * + * Use this type when constructing the shared `EventBus` in the server setup so + * all emitters and listeners are compatible. + */ +export type ServerEventMap = NoteLifecycleEventMap & + FileCommandEventMap & + CardCommandEventMap & + CalendarEventCommandEventMap; diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 2e66860..cff50c0 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -7,12 +7,13 @@ import { TomeWatcher, type TomeEventMap } from "@notes/tome"; import Fastify from "fastify"; import fastifyStatic from "@fastify/static"; import fastifyRateLimit from "@fastify/rate-limit"; -import { NoteLifecycleEventMap } from "./commands/note-lifecycle"; import { registerFileCommands } from "./commands/file-commands"; import { registerCardCommands } from "./commands/card-commands"; import { registerEventCommands } from "./commands/event-commands"; import { registerIndexCommands } from "./commands/index-commands"; import { registerNoteTypeCommands } from "./commands/note-type-commands"; +import { createCommandEventMiddleware } from "./commands/command-event-middleware"; +import type { ServerEventMap } from "./commands/server-events"; import { loadConfig, type ServerConfig } from "./config"; import { registerErrorHandler } from "./errors"; import { IndexService } from "./index-service"; @@ -54,16 +55,17 @@ export async function startServer(overrides?: Partial): Promise(); - const noteEvents = new EventBus(); + const serverEvents = new EventBus(); const tower = new Tower(); tower.openTome("default", config.tomePath); const bus = new CommandBus(); bus.use(validationMiddleware); bus.use(createLoggingMiddleware((message) => app.log.debug(message))); - registerFileCommands(bus, () => tower.active, noteEvents); - registerCardCommands(bus, () => tower.active, noteEvents); - registerEventCommands(bus, () => tower.active, noteEvents); + bus.use(createCommandEventMiddleware(serverEvents)); + registerFileCommands(bus, () => tower.active, serverEvents); + registerCardCommands(bus, () => tower.active, serverEvents); + registerEventCommands(bus, () => tower.active, serverEvents); registerNoteTypeCommands(bus, () => tower.active); const ctx = { tomePath: config.tomePath }; diff --git a/apps/web/src/api/undoable-file-ops.ts b/apps/web/src/api/undoable-file-ops.ts new file mode 100644 index 0000000..997f061 --- /dev/null +++ b/apps/web/src/api/undoable-file-ops.ts @@ -0,0 +1,76 @@ +import { api } from "./client"; +import type { UndoStack } from "@notes/core"; + +/** + * Returns a set of file-level operations that automatically push undo/redo + * entries to the provided stack. Call-sites invoke these functions normally; + * the undo/redo registration is transparent. + * + * Note: text-edit undo (inside the TipTap editor) is handled by TipTap + * internally and is not registered here. + */ +export function makeUndoableFileOps(stack: UndoStack) { + /** + * Creates a new file and registers an undo entry that deletes it. + * If the file already exists this will throw (same as api.create). + */ + async function createFile(path: string, content = ""): Promise { + await api.create(path, content); + const name = path.split("/").pop() ?? path; + stack.push({ + label: `Create "${name}"`, + undo: async () => { + await api.remove(path); + }, + redo: async () => { + await api.create(path, content); + }, + }); + } + + /** + * Deletes a file and registers an undo entry that re-creates it with the + * original content. Reads the file content before deletion so the undo + * entry is self-contained. + */ + async function deleteFile(path: string): Promise { + const name = path.split("/").pop() ?? path; + let previous = ""; + try { + previous = (await api.read(path)).content; + } catch { + previous = ""; + } + await api.remove(path); + stack.push({ + label: `Delete "${name}"`, + undo: async () => { + await api.create(path, previous); + }, + redo: async () => { + await api.remove(path); + }, + }); + } + + /** + * Renames/moves a file and registers an undo entry that renames it back. + */ + async function renameFile(from: string, to: string): Promise { + const toName = to.split("/").pop() ?? to; + await api.rename(from, to); + stack.push({ + label: `Rename to "${toName}"`, + undo: async () => { + await api.rename(to, from); + }, + redo: async () => { + await api.rename(from, to); + }, + }); + } + + return { createFile, deleteFile, renameFile }; +} + +export type UndoableFileOps = ReturnType; diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index c5c6974..fa87491 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -28,6 +28,8 @@ import { useWorkspace } from "./state/app-context"; import { useToasts } from "./state/toast"; import { usePlugins } from "./state/use-plugins"; import { useHotkeys } from "./state/use-hotkeys"; +import { useUndoStack } from "./state/undo-context"; +import { makeUndoableFileOps } from "./api/undoable-file-ops"; import { loadRecentCommands, pushRecentCommand, type AppCommand } from "./state/commands"; import { flattenFiles } from "./state/selectors"; import { @@ -122,6 +124,37 @@ export function App() { // Paths of freshly-created notes not yet modified/named (discarded on close). const provisionalRef = useRef>(new Set()); + const undoStack = useUndoStack(); + const undoableFileOps = useMemo(() => makeUndoableFileOps(undoStack), [undoStack]); + + // Global undo/redo hotkeys. We deliberately skip contentEditable targets so + // TipTap and CodeMirror can handle Ctrl+Z within editor windows themselves. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const mod = e.ctrlKey || e.metaKey; + if (!mod) return; + const key = e.key.toLowerCase(); + const target = e.target as HTMLElement | null; + const inEditable = Boolean( + target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)), + ); + if (inEditable) return; + if (key === "z" && !e.shiftKey) { + e.preventDefault(); + void undoStack.undo().then((label) => { + if (label) notify(`Undone: ${label}`, { kind: "info" }); + }); + } else if (key === "y" || (key === "z" && e.shiftKey)) { + e.preventDefault(); + void undoStack.redo().then((label) => { + if (label) notify(`Redone: ${label}`, { kind: "info" }); + }); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [undoStack, notify]); + const refreshTree = useCallback(async () => { const [{ entries }, { notes }] = await Promise.all([api.files(), api.notes()]); dispatch({ type: "setTree", tree: entries }); @@ -132,13 +165,14 @@ export function App() { (base: string, ext: string, contentFor: (name: string) => string, dir?: string) => { void (async () => { const path = nextName(dir ?? "", base, ext, treeRef.current); - await api.create(path, contentFor(baseNoExt(path))); + const content = contentFor(baseNoExt(path)); + await undoableFileOps.createFile(path, content); provisionalRef.current.add(path); await refreshTree(); dispatch({ type: "openFile", path, title: baseNoExt(path) }); })(); }, - [dispatch, refreshTree], + [dispatch, refreshTree, undoableFileOps], ); const createNote = useCallback( @@ -259,39 +293,17 @@ export function App() { const deletePath = useCallback( async (path: string) => { const name = path.split("/").pop() ?? path; - // Read the content first so the delete can be undone from the toast. - let previous = ""; - try { - previous = (await api.read(path)).content; - } catch { - previous = ""; - } try { markModified(path); - await api.remove(path); + await undoableFileOps.deleteFile(path); dispatch({ type: "closePath", path }); await refreshTree(); - notify(`Deleted "${name}"`, { - kind: "info", - action: { - label: "Undo", - run: () => { - void (async () => { - try { - await api.create(path, previous); - await refreshTree(); - } catch { - notify(`Couldn't restore "${name}"`, { kind: "error" }); - } - })(); - }, - }, - }); + notify(`Deleted "${name}" (Ctrl+Z to undo)`, { kind: "info" }); } catch { notify(`Couldn't delete "${name}"`, { kind: "error" }); } }, - [dispatch, refreshTree, markModified, notify], + [dispatch, refreshTree, markModified, notify, undoableFileOps], ); const openSettings = useCallback(() => { @@ -821,6 +833,7 @@ export function App() { plugins.documentSignal.set(doc), fileHandlers: plugins.fileHandlers, settings: settingsProps, + undoableFileOps, }), [ markModified, @@ -838,6 +851,7 @@ export function App() { plugins.documentSignal, plugins.fileHandlers, settingsProps, + undoableFileOps, ], ); diff --git a/apps/web/src/components/explorer.tsx b/apps/web/src/components/explorer.tsx index 0113602..95200be 100644 --- a/apps/web/src/components/explorer.tsx +++ b/apps/web/src/components/explorer.tsx @@ -308,7 +308,7 @@ export function Explorer({ const dir = dirOf(node.path); const to = dir ? `${dir}/${nextName}` : nextName; services.markModified(node.path); - await api.rename(node.path, to); + await services.undoableFileOps.renameFile(node.path, to); if (node.type === "file") { dispatch({ type: "renamePath", from: node.path, to, title: baseNoExt(nextName) }); } else { @@ -367,7 +367,9 @@ export function Explorer({ await services.deletePath(node.path); return; } - if (!window.confirm(`Delete folder "${node.name}" and its contents? This cannot be undone.`)) { + if ( + !window.confirm(`Delete folder "${node.name}" and all its contents? This cannot be undone.`) + ) { return; } await api.remove(node.path); @@ -387,7 +389,7 @@ export function Explorer({ } const isDir = findEntry(state.tree, fromPath)?.type === "directory"; services.markModified(fromPath); - await api.rename(fromPath, to); + await services.undoableFileOps.renameFile(fromPath, to); dispatch( isDir ? { type: "renamePrefix", from: fromPath, to } diff --git a/apps/web/src/components/ribbon.tsx b/apps/web/src/components/ribbon.tsx index 2abec01..cfb6872 100644 --- a/apps/web/src/components/ribbon.tsx +++ b/apps/web/src/components/ribbon.tsx @@ -1,5 +1,6 @@ import { PopupMenu } from "@notes/ui"; import { useCallback, useEffect, useState } from "react"; +import { useUndoState, useUndoStack } from "../state/undo-context"; interface RibbonProps { newActions: Array<{ id: string; label: string; run: () => void }>; @@ -86,6 +87,8 @@ export function Ribbon({ const isMac = electronApi?.platform === "darwin"; const [maximized, setMaximized] = useState(false); const [openMenu, setOpenMenu] = useState<"file" | "edit" | "new" | null>(null); + const undoStack = useUndoStack(); + const { canUndo, canRedo, undoLabel, redoLabel } = useUndoState(); useEffect(() => { if (!electronApi) { @@ -135,11 +138,16 @@ export function Ribbon({ items={[ { type: "item", - label: "Undo", - onClick: () => document.execCommand("undo"), - disabled: true, + label: undoLabel ? `Undo ${undoLabel}` : "Undo", + onClick: () => void undoStack.undo(), + disabled: !canUndo, + }, + { + type: "item", + label: redoLabel ? `Redo ${redoLabel}` : "Redo", + onClick: () => void undoStack.redo(), + disabled: !canRedo, }, - { type: "item", label: "Redo", onClick: () => document.execCommand("redo") }, { type: "separator" }, { type: "item", label: "Cut", onClick: () => document.execCommand("cut") }, { type: "item", label: "Copy", onClick: () => document.execCommand("copy") }, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ffd0b56..cefbd4d 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -4,6 +4,7 @@ import { App } from "./app"; import { ErrorBoundary } from "./components/error-boundary"; import { WorkspaceProvider } from "./state/app-context"; import { ToastProvider } from "./state/toast"; +import { UndoStackProvider } from "./state/undo-context"; import "./styles.css"; import "@notes/ui/src/styles.css"; import "@notes/note-mermaid/src/styles.css"; @@ -32,7 +33,9 @@ createRoot(container).render( - + + + diff --git a/apps/web/src/state/app-services.tsx b/apps/web/src/state/app-services.tsx index 8ca092c..2293d69 100644 --- a/apps/web/src/state/app-services.tsx +++ b/apps/web/src/state/app-services.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, type ReactNode } from "react"; import type { FileTypeHandler } from "@notes/plugin-host"; import type { SettingsBodyProps } from "../components/settings-view"; +import type { UndoableFileOps } from "../api/undoable-file-ops"; export interface AppServices { /** Marks a note as modified so it is no longer a discardable provisional note. */ @@ -26,6 +27,8 @@ export interface AppServices { fileHandlers: FileTypeHandler[]; /** Everything the settings surface needs, so a settings tab can render. */ settings: SettingsBodyProps; + /** File-level undoable operations (create, delete, rename). */ + undoableFileOps: UndoableFileOps; } const noop = () => {}; @@ -86,6 +89,11 @@ const AppServicesContext = createContext({ setActiveDocument: noop, fileHandlers: [], settings: defaultSettings, + undoableFileOps: { + createFile: async () => {}, + deleteFile: async () => {}, + renameFile: async () => {}, + }, }); export function AppServicesProvider({ diff --git a/apps/web/src/state/undo-context.tsx b/apps/web/src/state/undo-context.tsx new file mode 100644 index 0000000..ec96f85 --- /dev/null +++ b/apps/web/src/state/undo-context.tsx @@ -0,0 +1,97 @@ +import { + createContext, + useCallback, + useContext, + useSyncExternalStore, + useState, + type ReactNode, +} from "react"; +import { InMemoryUndoStore, UndoStack } from "@notes/core"; + +// --------------------------------------------------------------------------- +// Context +// --------------------------------------------------------------------------- + +const UndoStackContext = createContext(null); + +function useNearestStack(): UndoStack { + const stack = useContext(UndoStackContext); + if (!stack) { + throw new Error("useUndoStack must be used within an UndoStackProvider or UndoScope"); + } + return stack; +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +/** + * Provides the global undo stack for the whole app. Wrap this near the root + * (inside WorkspaceProvider is fine). + */ +export function UndoStackProvider({ children }: { children: ReactNode }) { + const [stack] = useState(() => new UndoStack(new InMemoryUndoStore(100))); + return {children}; +} + +/** + * Creates a child undo stack scoped to the component tree below it (e.g. a modal). + * Ctrl+Z within this subtree will target the scoped stack, not the global one. + * The scoped stack is discarded when the component unmounts. + */ +export function UndoScope({ maxSize = 20, children }: { maxSize?: number; children: ReactNode }) { + const [stack] = useState(() => new UndoStack(new InMemoryUndoStore(maxSize))); + return {children}; +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** + * Returns the nearest UndoStack (global or scoped). + * Use this when you need direct access to the stack instance (e.g. to call `.push()`). + */ +export function useUndoStack(): UndoStack { + return useNearestStack(); +} + +/** + * Returns reactive canUndo/canRedo/label state from the nearest stack. + * Re-renders whenever the stack changes (push/undo/redo/clear). + */ +export function useUndoState(): { + canUndo: boolean; + canRedo: boolean; + undoLabel: string | undefined; + redoLabel: string | undefined; +} { + const stack = useNearestStack(); + + const getSnapshot = useCallback( + () => ({ + canUndo: stack.canUndo, + canRedo: stack.canRedo, + undoLabel: stack.undoLabel, + redoLabel: stack.redoLabel, + }), + [stack], + ); + + // useSyncExternalStore requires a stable snapshot reference to avoid spurious + // re-renders. We serialize the key values into a string for cheap comparison. + const getCacheKey = useCallback( + () => + `${String(stack.canUndo)}|${String(stack.canRedo)}|${stack.undoLabel ?? ""}|${stack.redoLabel ?? ""}`, + [stack], + ); + + useSyncExternalStore( + useCallback((cb) => stack.subscribe(cb), [stack]), + getCacheKey, + getCacheKey, + ); + + return getSnapshot(); +} diff --git a/packages/core/src/contracts.ts b/packages/core/src/contracts.ts index ee5e8e7..b69fbaa 100644 --- a/packages/core/src/contracts.ts +++ b/packages/core/src/contracts.ts @@ -25,6 +25,12 @@ export type CommandHandler = ( export interface CommandDefinition { readonly name: string; readonly handler: CommandHandler; + /** + * When true, this command is a mutation (creates, deletes, or modifies data). + * The command event middleware uses this flag to emit domain events after success. + * Read-only queries should omit this (defaults to false). + */ + readonly mutates?: boolean; } export type CommandNext = () => Promise; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5e4efb..a952faa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,3 +5,4 @@ export * from "./command-bus"; export * from "./note-type-registry"; export * from "./markdown-note-type"; export * from "./hotkeys"; +export * from "./undo-stack"; diff --git a/packages/core/src/undo-stack.test.ts b/packages/core/src/undo-stack.test.ts new file mode 100644 index 0000000..f49e99e --- /dev/null +++ b/packages/core/src/undo-stack.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect, vi } from "vitest"; +import { InMemoryUndoStore, UndoStack, type UndoEntry } from "./undo-stack"; + +function makeEntry(label: string, undo = vi.fn(), redo = vi.fn()): UndoEntry { + return { + label, + undo: async () => { + undo(); + }, + redo: async () => { + redo(); + }, + }; +} + +describe("InMemoryUndoStore", () => { + it("starts empty", () => { + const store = new InMemoryUndoStore(); + expect(store.undoSize).toBe(0); + expect(store.redoSize).toBe(0); + expect(store.peekUndo()).toBeUndefined(); + expect(store.peekRedo()).toBeUndefined(); + }); + + it("recordNew pushes to past and clears future", () => { + const store = new InMemoryUndoStore(); + const a = makeEntry("a"); + const b = makeEntry("b"); + store.recordNew(a); + store.pushRedo(b); + expect(store.redoSize).toBe(1); + store.recordNew(makeEntry("c")); + expect(store.undoSize).toBe(2); + expect(store.redoSize).toBe(0); // future cleared + }); + + it("pushUndo does not clear future", () => { + const store = new InMemoryUndoStore(); + store.pushRedo(makeEntry("future")); + store.pushUndo(makeEntry("past")); + expect(store.undoSize).toBe(1); + expect(store.redoSize).toBe(1); + }); + + it("pushRedo does not affect past", () => { + const store = new InMemoryUndoStore(); + store.recordNew(makeEntry("past")); + store.pushRedo(makeEntry("future")); + expect(store.undoSize).toBe(1); + expect(store.redoSize).toBe(1); + }); + + it("evicts oldest entries when maxSize is exceeded", () => { + const store = new InMemoryUndoStore(3); + store.recordNew(makeEntry("a")); + store.recordNew(makeEntry("b")); + store.recordNew(makeEntry("c")); + store.recordNew(makeEntry("d")); // evicts "a" + expect(store.undoSize).toBe(3); + expect(store.peekUndo()?.label).toBe("d"); + }); + + it("peekUndo returns the most recent entry without removing it", () => { + const store = new InMemoryUndoStore(); + store.recordNew(makeEntry("first")); + store.recordNew(makeEntry("second")); + expect(store.peekUndo()?.label).toBe("second"); + expect(store.undoSize).toBe(2); + }); + + it("popUndo removes the most recent entry", () => { + const store = new InMemoryUndoStore(); + store.recordNew(makeEntry("a")); + store.recordNew(makeEntry("b")); + expect(store.popUndo()?.label).toBe("b"); + expect(store.undoSize).toBe(1); + }); + + it("clear empties both past and future", () => { + const store = new InMemoryUndoStore(); + store.recordNew(makeEntry("a")); + store.pushRedo(makeEntry("r")); + store.clear(); + expect(store.undoSize).toBe(0); + expect(store.redoSize).toBe(0); + }); +}); + +describe("UndoStack", () => { + it("starts with canUndo=false, canRedo=false", () => { + const stack = new UndoStack(); + expect(stack.canUndo).toBe(false); + expect(stack.canRedo).toBe(false); + expect(stack.undoLabel).toBeUndefined(); + expect(stack.redoLabel).toBeUndefined(); + }); + + it("push makes canUndo true", () => { + const stack = new UndoStack(); + stack.push(makeEntry("create")); + expect(stack.canUndo).toBe(true); + expect(stack.undoLabel).toBe("create"); + }); + + it("undo calls the undo fn and makes canRedo true", async () => { + const undoFn = vi.fn(); + const stack = new UndoStack(); + stack.push(makeEntry("delete", undoFn)); + const label = await stack.undo(); + expect(label).toBe("delete"); + expect(undoFn).toHaveBeenCalledOnce(); + expect(stack.canUndo).toBe(false); + expect(stack.canRedo).toBe(true); + expect(stack.redoLabel).toBe("delete"); + }); + + it("redo calls the redo fn and makes canUndo true again", async () => { + const redoFn = vi.fn(); + const stack = new UndoStack(); + stack.push(makeEntry("rename", vi.fn(), redoFn)); + await stack.undo(); + const label = await stack.redo(); + expect(label).toBe("rename"); + expect(redoFn).toHaveBeenCalledOnce(); + expect(stack.canUndo).toBe(true); + expect(stack.canRedo).toBe(false); + }); + + it("push after undo clears redo history", async () => { + const stack = new UndoStack(); + stack.push(makeEntry("a")); + await stack.undo(); + expect(stack.canRedo).toBe(true); + stack.push(makeEntry("b")); // new action clears redo + expect(stack.canRedo).toBe(false); + }); + + it("undo returns null when stack is empty", async () => { + const stack = new UndoStack(); + expect(await stack.undo()).toBeNull(); + }); + + it("redo returns null when nothing to redo", async () => { + const stack = new UndoStack(); + stack.push(makeEntry("a")); + expect(await stack.redo()).toBeNull(); + }); + + it("supports multiple undo/redo cycles", async () => { + const log: string[] = []; + const stack = new UndoStack(); + stack.push({ + label: "step1", + undo: async () => { + log.push("undo1"); + }, + redo: async () => { + log.push("redo1"); + }, + }); + stack.push({ + label: "step2", + undo: async () => { + log.push("undo2"); + }, + redo: async () => { + log.push("redo2"); + }, + }); + await stack.undo(); // undo step2 + await stack.undo(); // undo step1 + await stack.redo(); // redo step1 + await stack.redo(); // redo step2 + expect(log).toEqual(["undo2", "undo1", "redo1", "redo2"]); + }); + + it("notifies subscribers on push/undo/redo/clear", async () => { + const listener = vi.fn(); + const stack = new UndoStack(); + const unsub = stack.subscribe(listener); + stack.push(makeEntry("a")); + await stack.undo(); + await stack.redo(); + stack.clear(); + expect(listener).toHaveBeenCalledTimes(4); + unsub(); + stack.push(makeEntry("after-unsub")); + expect(listener).toHaveBeenCalledTimes(4); // no more calls + }); + + it("clear resets canUndo and canRedo", async () => { + const stack = new UndoStack(); + stack.push(makeEntry("a")); + await stack.undo(); + stack.clear(); + expect(stack.canUndo).toBe(false); + expect(stack.canRedo).toBe(false); + }); + + it("respects custom store max size", async () => { + const store = new InMemoryUndoStore(2); + const stack = new UndoStack(store); + stack.push(makeEntry("a")); + stack.push(makeEntry("b")); + stack.push(makeEntry("c")); // evicts "a" + let count = 0; + while (stack.canUndo) { + await stack.undo(); + count++; + } + expect(count).toBe(2); // only b and c survive + }); +}); diff --git a/packages/core/src/undo-stack.ts b/packages/core/src/undo-stack.ts new file mode 100644 index 0000000..8d27732 --- /dev/null +++ b/packages/core/src/undo-stack.ts @@ -0,0 +1,173 @@ +/** + * Undo/redo stack infrastructure (Command pattern). + * + * Design goals: + * - Call-sites push UndoEntry objects; they don't orchestrate undo logic themselves. + * - The backend (UndoStackStore) is a Strategy so it can be swapped for disk or + * server-side persistence without changing consumers. + * - Scoped stacks (e.g. inside a modal) shadow the global stack via React context. + */ + +/** A reversible operation that can be undone and redone. */ +export interface UndoEntry { + /** Human-readable label shown in UI: "Undo delete 'my-note.md'" */ + label: string; + undo?: () => Promise; + redo?: () => Promise; +} + +/** Strategy for storing undo entries. Swap for disk/server backends as needed. */ +export interface UndoStackStore { + /** + * Record a brand-new user action: push to past and clear the redo (future) side. + * This is the "normal" push called by `UndoStack.push()`. + */ + recordNew(entry: UndoEntry): void; + /** Internal: push to the past side only, without touching the redo side. */ + pushUndo(entry: UndoEntry): void; + /** Internal: push to the redo (future) side only, without touching the undo side. */ + pushRedo(entry: UndoEntry): void; + popUndo(): UndoEntry | undefined; + popRedo(): UndoEntry | undefined; + peekUndo(): UndoEntry | undefined; + peekRedo(): UndoEntry | undefined; + clear(): void; + readonly undoSize: number; + readonly redoSize: number; +} + +/** Default in-memory store with a capped past/future deque. */ +export class InMemoryUndoStore implements UndoStackStore { + private readonly past: UndoEntry[] = []; + private readonly future: UndoEntry[] = []; + + constructor(private readonly maxSize: number = 100) {} + + recordNew(entry: UndoEntry): void { + this.past.push(entry); + if (this.past.length > this.maxSize) { + this.past.shift(); + } + this.future.length = 0; + } + + pushUndo(entry: UndoEntry): void { + this.past.push(entry); + if (this.past.length > this.maxSize) { + this.past.shift(); + } + } + + pushRedo(entry: UndoEntry): void { + this.future.push(entry); + } + + popUndo(): UndoEntry | undefined { + return this.past.pop(); + } + + popRedo(): UndoEntry | undefined { + return this.future.pop(); + } + + peekUndo(): UndoEntry | undefined { + return this.past[this.past.length - 1]; + } + + peekRedo(): UndoEntry | undefined { + return this.future[this.future.length - 1]; + } + + clear(): void { + this.past.length = 0; + this.future.length = 0; + } + + get undoSize(): number { + return this.past.length; + } + + get redoSize(): number { + return this.future.length; + } +} + +type ChangeListener = () => void; + +/** + * Global (or scoped) undo/redo stack. + * + * Usage: + * stack.push({ label: 'Create note', undo: () => api.remove(path), redo: () => api.create(path, content) }); + * await stack.undo(); // calls the undo fn, moves entry to redo side + * await stack.redo(); // re-executes + */ +export class UndoStack { + private readonly store: UndoStackStore; + private readonly listeners = new Set(); + + constructor(store?: UndoStackStore) { + this.store = store ?? new InMemoryUndoStore(); + } + + /** Subscribe to stack changes (push/undo/redo/clear). Returns an unsubscribe fn. */ + subscribe(listener: ChangeListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + /** + * Push a new undoable entry. Clears the redo history (same as any normal text editor). + */ + push(entry: UndoEntry): void { + this.store.recordNew(entry); + this.notify(); + } + + /** Undo the most recent entry. Returns its label, or null if nothing to undo. */ + async undo(): Promise { + const entry = this.store.popUndo(); + if (!entry) return null; + const redoEntry = entry.undo ? await entry.undo() : undefined; + this.store.pushRedo(redoEntry ?? entry); + this.notify(); + return entry.label; + } + + /** Redo the most recently undone entry. Returns its label, or null if nothing to redo. */ + async redo(): Promise { + const entry = this.store.popRedo(); + if (!entry) return null; + const undoEntry = entry.redo ? await entry.redo() : undefined; + this.store.pushUndo(undoEntry ?? entry); + this.notify(); + return entry.label; + } + + get canUndo(): boolean { + return this.store.undoSize > 0; + } + + get canRedo(): boolean { + return this.store.redoSize > 0; + } + + get undoLabel(): string | undefined { + return this.store.peekUndo()?.label; + } + + get redoLabel(): string | undefined { + return this.store.peekRedo()?.label; + } + + clear(): void { + this.store.clear(); + this.notify(); + } +} diff --git a/packages/note-boards/src/board-view.tsx b/packages/note-boards/src/board-view.tsx index 9a483f2..dadb9c4 100644 --- a/packages/note-boards/src/board-view.tsx +++ b/packages/note-boards/src/board-view.tsx @@ -1,6 +1,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { usePromptDialog } from "@notes/editor"; import { type NoteViewContextMenuBuilder } from "@notes/ui"; +import { useUndoStack } from "@notes/web/src/state/undo-context"; import { BoardColumn } from "./board-column"; import { BoardCardModal } from "./board-card-modal"; import { @@ -40,6 +41,7 @@ function debounce) => void>(fn: T, ms: number) export function BoardView({ value, onChange, path, onRegisterContextMenu }: BoardViewProps) { const { openPrompt, promptDialog } = usePromptDialog(); + const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseBoard(value)); const [cards, setCards] = useState>(new Map()); const [loading, setLoading] = useState(true); @@ -161,14 +163,32 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar ), })); setNewCardId(card.id); - // Clear the "new" marker after enough time for the card to mount and focus setTimeout(() => setNewCardId(null), 600); + undoStack.push({ + label: `Create card in "${colName}"`, + undo: async () => { + await fetch( + `/api/card?boardPath=${encodeURIComponent(path)}&cardId=${encodeURIComponent(card.id)}`, + { method: "DELETE" }, + ); + await fetchCards(); + }, + redo: async () => { + await fetch("/api/card/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ boardPath: path, column: colName, id: card.id }), + }); + await fetchCards(); + }, + }); }, - [path], + [path, undoStack, fetchCards], ); const handleDeleteCard = useCallback( async (cardId: string) => { + const cardData = cards.get(cardId); await fetch( `/api/card?boardPath=${encodeURIComponent(path)}&cardId=${encodeURIComponent(cardId)}`, { method: "DELETE" }, @@ -186,12 +206,36 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar })), })); setModalCard((prev) => (prev?.id === cardId ? null : prev)); + if (cardData) { + const cardLabel = cardData.title?.trim() ? `"${cardData.title}"` : "card"; + undoStack.push({ + label: `Delete ${cardLabel}`, + undo: async () => { + await fetch("/api/card/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ boardPath: path, card: cardData }), + }); + await fetchCards(); + }, + redo: async () => { + await fetch( + `/api/card?boardPath=${encodeURIComponent(path)}&cardId=${encodeURIComponent(cardId)}`, + { method: "DELETE" }, + ); + await fetchCards(); + }, + }); + } }, - [path], + [path, cards, undoStack, fetchCards], ); const handleMoveCard = useCallback( async (drag: CardDrag, toColumn: string, beforeCardId: string | null) => { + const fromColumn = drag.fromColumn; + const fromCol = model.columns.find((c) => c.name === fromColumn); + const fromIndex = fromCol?.cards.indexOf(drag.cardId) ?? 0; const targetCol = model.columns.find((c) => c.name === toColumn); let toIndex = beforeCardId && targetCol @@ -225,8 +269,32 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar if (card && card.column !== toColumn) { setCards((prev) => new Map(prev).set(drag.cardId, { ...card, column: toColumn })); } + undoStack.push({ + label: `Move card to "${toColumn}"`, + undo: async () => { + await fetch("/api/card/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + boardPath: path, + cardId: drag.cardId, + toColumn: fromColumn, + toIndex: fromIndex, + }), + }); + await fetchCards(); + }, + redo: async () => { + await fetch("/api/card/move", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ boardPath: path, cardId: drag.cardId, toColumn, toIndex }), + }); + await fetchCards(); + }, + }); }, - [path, model, cards], + [path, model, cards, undoStack, fetchCards], ); const duplicateCard = useCallback( @@ -281,11 +349,8 @@ export function BoardView({ value, onChange, path, onRegisterContextMenu }: Boar }, { separator: true as const }, { - label: "Delete card…", + label: "Delete card", run: () => { - const title = cardsRef.current.get(cardId)?.title; - const label = title?.trim() ? `"${title}"` : "this card"; - if (!window.confirm(`Delete ${label}? This cannot be undone.`)) return; void handleDeleteCardRef.current(cardId); }, danger: true, diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index c1c9c1a..fd1df6a 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -1,3 +1,4 @@ +import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { MarkdownEditor, NoteToolbar } from "@notes/editor"; import { CalendarModel, parseCalendar, type RichEvent } from "./calendar-format"; @@ -61,6 +62,7 @@ function debounce) => void>(fn: T, ms: number) } export function CalendarView({ value, path }: CalendarViewProps) { + const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseCalendar(value)); const [events, setEvents] = useState>(new Map()); const [selectedId, setSelectedId] = useState(null); @@ -116,6 +118,20 @@ export function CalendarView({ value, path }: CalendarViewProps) { ); const updateEventState = (updated: RichEvent) => { + const previous = events.get(updated.id); + undoStack.push({ + label: `Update event "${updated.title}"`, + undo: async () => { + if (previous) { + setEvents((prev) => new Map(prev).set(previous.id, previous)); + void apiSaveEvent(previous); + } + }, + redo: async () => { + setEvents((prev) => new Map(prev).set(updated.id, updated)); + void apiSaveEvent(updated); + }, + }); setEvents((prev) => new Map(prev).set(updated.id, updated)); void apiSaveEvent(updated); }; @@ -131,20 +147,88 @@ export function CalendarView({ value, path }: CalendarViewProps) { setEvents((prev) => new Map(prev).set(event.id, event)); setModel((prev) => ({ ...prev, events: [...prev.events, event.id] })); setSelectedId(event.id); + + undoStack.push({ + label: `Create event "${event.title}"`, + undo: async () => { + await fetch( + `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(event.id)}`, + { method: "DELETE" }, + ); + await fetchEvents(); + setSelectedId(null); + }, + redo: async () => { + await fetch("/api/event/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ calendarPath: path, date }), + }); + await fetchEvents(); + setSelectedId(event.id); + }, + }); }; const handleDeleteEvent = async (eventId: string) => { - await fetch( + const res = await fetch( `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(eventId)}`, { method: "DELETE" }, ); + if (!res.ok) return; + + const previous = events.get(eventId); setEvents((prev) => { const next = new Map(prev); next.delete(eventId); return next; }); setModel((prev) => ({ ...prev, events: prev.events.filter((id) => id !== eventId) })); + if (selectedId === eventId) setSelectedId(null); + + undoStack.push({ + label: `Delete event "${previous?.title}"`, + undo: async () => { + const res = await fetch("/api/event/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ calendarPath: path, date: events.get(eventId)?.date }), + }); + if (!res.ok) return; + const event = (await res.json()) as RichEvent; + + setEvents((prev) => { + const next = new Map(prev); + next.set(event.id, { ...event, ...(previous ?? {}), id: event.id }); + return next; + }); + setModel((prev) => ({ ...prev, events: [...prev.events, event.id] })); + setSelectedId(event.id); + + if (previous) void apiSaveEvent({ ...previous, id: event.id }); + + return { + label: `Delete event "${event.title}"`, + redo: async () => { + const res = await fetch( + `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(event.id)}`, + { method: "DELETE" }, + ); + if (!res.ok) return; + + setEvents((prev) => { + const next = new Map(prev); + next.delete(event.id); + return next; + }); + setModel((prev) => ({ ...prev, events: prev.events.filter((id) => id !== event.id) })); + + if (selectedId === event.id) setSelectedId(null); + }, + }; + }, + }); }; const selectedEvent = selectedId ? events.get(selectedId) : null; diff --git a/packages/note-canvas/src/canvas-view.tsx b/packages/note-canvas/src/canvas-view.tsx index 8649852..a26b564 100644 --- a/packages/note-canvas/src/canvas-view.tsx +++ b/packages/note-canvas/src/canvas-view.tsx @@ -1,5 +1,6 @@ import { connectTomeChanges } from "@notes/web/src/api/ws"; import { getFrontmatterField, parseFrontmatter } from "@notes/web/src/lib/frontmatter"; +import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useCallback, useEffect, @@ -266,6 +267,7 @@ function center(node: CanvasNode): { x: number; y: number } { export function CanvasView({ value, onChange, onOpenFile, path }: CanvasViewProps) { const { openPrompt, promptDialog } = usePromptDialog(); + const undoStack = useUndoStack(); const [data, setData] = useState(() => parseCanvas(value)); const [viewport, setViewport] = useState({ x: 40, y: 40, scale: 1 }); const [selection, setSelection] = useState(null); @@ -329,17 +331,40 @@ export function CanvasView({ value, onChange, onOpenFile, path }: CanvasViewProp setViewport(computeFit(dataRef.current.nodes, rect?.width ?? 800, rect?.height ?? 600)); }; + // TODO: Not all paths go through here to setData, so the internal state isn't always synced const pushChange = useCallback( (next: CanvasData) => { - lastSerialized.current = serializeCanvas(next); - onChange(lastSerialized.current); + setData(next); + const previous = dataRef.current; + const previousMarkdown = serializeCanvas(previous); + dataRef.current = next; + const markdown = serializeCanvas(next); + lastSerialized.current = markdown; + + undoStack.push({ + label: "Update canvas", + undo: () => { + setData(previous); + dataRef.current = previous; + lastSerialized.current = previousMarkdown; + onChange(previousMarkdown); + return Promise.resolve(); + }, + redo: () => { + setData(next); + dataRef.current = next; + lastSerialized.current = markdown; + onChange(markdown); + return Promise.resolve(); + }, + }); + onChange(markdown); }, [onChange], ); const commit = useCallback( (next: CanvasData) => { - setData(next); pushChange(next); }, [pushChange], diff --git a/packages/note-grid/src/grid-view.tsx b/packages/note-grid/src/grid-view.tsx index 06b5241..468d377 100644 --- a/packages/note-grid/src/grid-view.tsx +++ b/packages/note-grid/src/grid-view.tsx @@ -1,3 +1,4 @@ +import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useEffect, useRef, useState } from "react"; import { NoteToolbar, usePromptDialog } from "@notes/editor"; import { @@ -33,6 +34,7 @@ function topColor(model: GridModel, x: number, y: number): string | undefined { export function GridView({ value, onChange }: GridViewProps) { const { openPrompt, promptDialog } = usePromptDialog(); + const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseGrid(value)); const [tool, setTool] = useState("paint"); const [color, setColor] = useState(PALETTE[3]); @@ -63,9 +65,29 @@ export function GridView({ value, onChange }: GridViewProps) { const update = (next: GridModel) => { setModel(next); + const previous = modelRef.current; + const previousMarkdown = serializeGrid(previous); modelRef.current = next; const markdown = serializeGrid(next); lastSerialized.current = markdown; + + undoStack.push({ + label: "Updated grid", + undo: () => { + setModel(previous); + modelRef.current = previous; + lastSerialized.current = previousMarkdown; + onChange(previousMarkdown); + return Promise.resolve(); + }, + redo: () => { + setModel(next); + modelRef.current = next; + lastSerialized.current = markdown; + onChange(markdown); + return Promise.resolve(); + }, + }); onChange(markdown); }; @@ -78,24 +100,6 @@ export function GridView({ value, onChange }: GridViewProps) { history.current.future = []; }; - const undo = () => { - const previous = history.current.past.pop(); - if (!previous) { - return; - } - history.current.future.push(modelRef.current); - update(previous); - }; - - const redo = () => { - const next = history.current.future.pop(); - if (!next) { - return; - } - history.current.past.push(modelRef.current); - update(next); - }; - const floodFill = (x: number, y: number) => { const current = modelRef.current; const layer = current.layers.find((entry) => entry.id === current.activeLayer); @@ -242,23 +246,7 @@ export function GridView({ value, onChange }: GridViewProps) { return ( <> -
{ - const mod = event.ctrlKey || event.metaKey; - if (mod && event.key.toLowerCase() === "z" && !event.shiftKey) { - event.preventDefault(); - undo(); - } else if ( - mod && - (event.key.toLowerCase() === "y" || (event.shiftKey && event.key.toLowerCase() === "z")) - ) { - event.preventDefault(); - redo(); - } - }} - > +
{(["paint", "erase", "fill", "token"] as Tool[]).map((option) => ( @@ -284,7 +272,7 @@ export function GridView({ value, onChange }: GridViewProps) { className="tb-btn" aria-label="Undo" disabled={history.current.past.length === 0} - onClick={undo} + onClick={undoStack.undo} > ↶ @@ -292,7 +280,7 @@ export function GridView({ value, onChange }: GridViewProps) { className="tb-btn" aria-label="Redo" disabled={history.current.future.length === 0} - onClick={redo} + onClick={undoStack.redo} > ↷ diff --git a/packages/note-tables/src/table-grid.tsx b/packages/note-tables/src/table-grid.tsx index 380ba5a..e0f0c00 100644 --- a/packages/note-tables/src/table-grid.tsx +++ b/packages/note-tables/src/table-grid.tsx @@ -1,3 +1,4 @@ +import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useCallback, useEffect, @@ -38,6 +39,7 @@ function compareValues(a: string, b: string, type: ColumnType): number { export function TableGrid({ value, onChange }: TableGridProps) { const { openPrompt, promptDialog } = usePromptDialog(); + const undoStack = useUndoStack(); const [model, setModel] = useState(() => parseTable(value)); const lastSerialized = useRef(value); const [sel, setSel] = useState({ r: 0, c: 0 }); @@ -56,9 +58,27 @@ export function TableGrid({ value, onChange }: TableGridProps) { const commit = useCallback( (next: TableModel) => { setModel(next); + const previous = model; + const previousMarkdown = serializeTable(previous); const markdown = serializeTable(next); lastSerialized.current = markdown; onChange(markdown); + + undoStack.push({ + label: "Update table", + undo: () => { + setModel(previous); + lastSerialized.current = previousMarkdown; + onChange(previousMarkdown); + return Promise.resolve(); + }, + redo: () => { + setModel(next); + lastSerialized.current = markdown; + onChange(markdown); + return Promise.resolve(); + }, + }); }, [onChange], ); From 996539a1fa4c09201745fe229d5ded4834ed4670 Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Fri, 31 Jul 2026 19:42:20 -0500 Subject: [PATCH 2/7] feat: Add tanstack query and update calendar to have get events query Add eslint plugin hooks rules for tanstack query --- apps/web/package.json | 2 + apps/web/src/main.tsx | 17 +- eslint.config.js | 16 + package-lock.json | 804 +++++++++++++++++- package.json | 2 + packages/core/package.json | 3 + packages/note-calendar/src/calendar-view.tsx | 284 +++---- .../note-calendar/src/use-create-event.ts | 22 + .../note-calendar/src/use-delete-event.ts | 17 + packages/note-calendar/src/use-get-events.ts | 27 + .../note-calendar/src/use-update-event.ts | 22 + 11 files changed, 1034 insertions(+), 182 deletions(-) create mode 100644 packages/note-calendar/src/use-create-event.ts create mode 100644 packages/note-calendar/src/use-delete-event.ts create mode 100644 packages/note-calendar/src/use-get-events.ts create mode 100644 packages/note-calendar/src/use-update-event.ts diff --git a/apps/web/package.json b/apps/web/package.json index 56b8af8..178ee3c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,6 +20,8 @@ "@notes/plugin-host": "*", "@notes/shared": "*", "@notes/ui": "*", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", "buffer": "6.0.3", "gray-matter": "^4.0.3", "react": "^18.3.1", diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cefbd4d..cfd58d2 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,5 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./app"; @@ -28,15 +30,20 @@ if (!container) { throw new Error("Root container #root not found"); } +const queryClient = new QueryClient(); + createRoot(container).render( - - - - - + + + + + + + + , diff --git a/eslint.config.js b/eslint.config.js index ed9295f..223f7e5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,6 +2,8 @@ import js from "@eslint/js"; import globals from "globals"; import tseslint from "typescript-eslint"; import importPlugin from "eslint-plugin-import"; +import reactHooks from "eslint-plugin-react-hooks"; +import pluginQuery from "@tanstack/eslint-plugin-query"; export default tseslint.config( { @@ -22,6 +24,8 @@ export default tseslint.config( ], }, js.configs.recommended, + reactHooks.configs.flat.recommended, + ...pluginQuery.configs["flat/recommended"], ...tseslint.configs.recommended, { languageOptions: { @@ -45,4 +49,16 @@ export default tseslint.config( ], rules: { "import/no-default-export": "off" }, }, + { + files: ["**/*.tsx", "**/*.ts"], + rules: { + "react-hooks/exhaustive-deps": ["warn"], + "react-hooks/refs": ["off"], + "react-hooks/purity": ["off"], + "react-hooks/preserve-manual-memoization": ["off"], + "react-hooks/set-state-in-effect": ["off"], + "react-hooks/use-memo": ["off"], + "react-hooks/rules-of-hooks": ["off"], + }, + }, ); diff --git a/package-lock.json b/package-lock.json index 670cd43..a368f11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@eslint/js": "^9.39.5", "@playwright/test": "^1.49.0", + "@tanstack/eslint-plugin-query": "^5.101.4", "@tiptap/core": "^3.27.4", "@tiptap/pm": "^3.27.4", "@types/node": "^26.1.1", @@ -23,6 +24,7 @@ "esbuild": "^0.28.1", "eslint": "^9.39.5", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.7.0", "husky": "^9.1.7", "prettier": "^3.9.5", @@ -128,6 +130,8 @@ "@notes/plugin-host": "*", "@notes/shared": "*", "@notes/ui": "*", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-query-devtools": "^5.101.4", "buffer": "6.0.3", "gray-matter": "^4.0.3", "react": "^18.3.1", @@ -154,6 +158,149 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "8.0.0-rc.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.6.tgz", @@ -172,6 +319,136 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-module-transforms/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", @@ -192,6 +469,64 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "8.0.0-rc.6", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.6.tgz", @@ -208,6 +543,157 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", @@ -2330,6 +2816,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -3012,6 +3509,82 @@ "node": ">=10" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.101.4.tgz", + "integrity": "sha512-jqAZJWXGd5PthJYH9wmC2O5Ry5xAN2/7zxi9qcANQ+Xix8V5JkqNRjZ8Fh+rYzqzVYGiHqbrHekt+uh38OZVpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.58.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": "^5.4.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.101.4.tgz", + "integrity": "sha512-z5IPHnDX3aUWeTWlRKLyooBQekaCAw4xRpZqPQ390RiWTDBcTynjpPT221BArw0u2+pnQMdGvPQI9YNNubBcmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.101.4.tgz", + "integrity": "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.101.4", + "react": "^18 || ^19" + } + }, "node_modules/@tiptap/core": { "version": "3.28.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.28.0.tgz", @@ -5339,6 +5912,19 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", + "integrity": "sha512-cp447VUsGS07+n1Dqf7YSQ8maeJrjEhaDxTm1ZefbqDtypHBC5GzGMQbklR6IPR13Y8OAJRHZWEMtZipJLCttg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/better-sqlite3": { "version": "12.11.1", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz", @@ -5449,6 +6035,40 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -5674,6 +6294,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -7156,6 +7797,13 @@ "node": ">=8" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, "node_modules/electron-updater": { "version": "6.8.9", "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", @@ -7781,6 +8429,76 @@ "semver": "bin/semver.js" } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -8459,6 +9177,16 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -8880,6 +9608,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hookable": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/hookable/-/hookable-6.1.1.tgz", @@ -10595,6 +11340,16 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/nopt": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", @@ -13444,6 +14199,37 @@ "node": ">=14.14" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -13961,9 +14747,25 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "packages/core": { "name": "@notes/core", - "version": "0.4.1" + "version": "0.4.1", + "dependencies": { + "@tanstack/react-query": "^5.101.4" + } }, "packages/editor": { "name": "@notes/editor", diff --git a/package.json b/package.json index b3eb3f4..0adaecd 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "devDependencies": { "@eslint/js": "^9.39.5", "@playwright/test": "^1.49.0", + "@tanstack/eslint-plugin-query": "^5.101.4", "@tiptap/core": "^3.27.4", "@tiptap/pm": "^3.27.4", "@types/node": "^26.1.1", @@ -46,6 +47,7 @@ "esbuild": "^0.28.1", "eslint": "^9.39.5", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react-hooks": "^7.1.1", "globals": "^17.7.0", "husky": "^9.1.7", "prettier": "^3.9.5", diff --git a/packages/core/package.json b/packages/core/package.json index f6afe6f..709b301 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -11,5 +11,8 @@ }, "scripts": { "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.4" } } diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index fd1df6a..5dee8a1 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -1,7 +1,11 @@ import { useUndoStack } from "@notes/web/src/state/undo-context"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { MarkdownEditor, NoteToolbar } from "@notes/editor"; -import { CalendarModel, parseCalendar, type RichEvent } from "./calendar-format"; +import { useUpdateEvent } from "./use-update-event"; +import { useCreateEvent } from "./use-create-event"; +import { useDeleteEvent } from "./use-delete-event"; +import { useGetEvents } from "./use-get-events"; +import { type RichEvent } from "./calendar-format"; interface CalendarViewProps { value: string; @@ -63,187 +67,115 @@ function debounce) => void>(fn: T, ms: number) export function CalendarView({ value, path }: CalendarViewProps) { const undoStack = useUndoStack(); - const [model, setModel] = useState(() => parseCalendar(value)); - const [events, setEvents] = useState>(new Map()); - const [selectedId, setSelectedId] = useState(null); + const [selectedEvent, setSelectedEvent] = useState(null); const [mode, setMode] = useState("month"); const [cursor, setCursor] = useState(() => new Date()); - const [loading, setLoading] = useState(true); - const lastValue = useRef(value); - - // Sync event IDs when value changes from file watcher - useEffect(() => { - if (value !== lastValue.current) { - lastValue.current = value; - setModel(parseCalendar(value)); - } - }, [value]); - - const fetchEvents = useCallback(async () => { - setLoading(true); - try { - const res = await fetch(`/api/events?calendarPath=${encodeURIComponent(path)}`); - if (!res.ok) throw new Error("fetch failed"); - const data = (await res.json()) as { events: RichEvent[] }; - setEvents(new Map(data.events.map((e) => [e.id, e]))); - // Re-read calendar file to pick up any migration changes - const fileRes = await fetch(`/api/file?path=${encodeURIComponent(path)}`); - if (fileRes.ok) { - const fileData = (await fileRes.json()) as { content: string }; - const model = parseCalendar(fileData.content); - setModel(model); - lastValue.current = fileData.content; - } - } catch { - // Swallow — keep whatever state we have - } finally { - setLoading(false); - } - }, [path]); - useEffect(() => { - void fetchEvents(); - }, [fetchEvents]); + const { data, isLoading } = useGetEvents(path, value); + const { mutateAsync: deleteEvent } = useDeleteEvent(path); + const { mutateAsync: createEvent } = useCreateEvent(path); + const { mutateAsync: updateEvent } = useUpdateEvent(path); - const apiSaveEvent = useMemo( - () => - debounce(async (event: RichEvent) => { - await fetch("/api/event/update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calendarPath: path, event }), - }); - }, 800), - [path], + const apiSaveEvent = useMemo(() => debounce(updateEvent, 200), [updateEvent]); + + const updateEventState = useCallback( + (updated: RichEvent) => { + const previous = data?.events.find((e) => e.id === updated.id); + + void apiSaveEvent(updated); + setSelectedEvent(updated); + + undoStack.push({ + label: `Update event "${updated.title}"`, + undo: async () => { + if (previous) { + void apiSaveEvent(previous); + setSelectedEvent((curr) => (curr?.id === previous.id ? previous : curr)); + } + }, + redo: async () => { + void apiSaveEvent(updated); + setSelectedEvent((curr) => (curr?.id === updated.id ? updated : curr)); + }, + }); + }, + [data?.events, apiSaveEvent, undoStack], ); - const updateEventState = (updated: RichEvent) => { - const previous = events.get(updated.id); - undoStack.push({ - label: `Update event "${updated.title}"`, - undo: async () => { - if (previous) { - setEvents((prev) => new Map(prev).set(previous.id, previous)); - void apiSaveEvent(previous); - } - }, - redo: async () => { - setEvents((prev) => new Map(prev).set(updated.id, updated)); - void apiSaveEvent(updated); - }, - }); - setEvents((prev) => new Map(prev).set(updated.id, updated)); - void apiSaveEvent(updated); - }; - - const handleCreateEvent = async (date: string) => { - const res = await fetch("/api/event/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calendarPath: path, date }), - }); - if (!res.ok) return; - const event = (await res.json()) as RichEvent; - setEvents((prev) => new Map(prev).set(event.id, event)); - setModel((prev) => ({ ...prev, events: [...prev.events, event.id] })); - setSelectedId(event.id); - - undoStack.push({ - label: `Create event "${event.title}"`, - undo: async () => { - await fetch( - `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(event.id)}`, - { method: "DELETE" }, - ); - await fetchEvents(); - setSelectedId(null); - }, - redo: async () => { - await fetch("/api/event/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calendarPath: path, date }), - }); - await fetchEvents(); - setSelectedId(event.id); - }, - }); - }; - - const handleDeleteEvent = async (eventId: string) => { - const res = await fetch( - `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(eventId)}`, - { method: "DELETE" }, - ); - if (!res.ok) return; - - const previous = events.get(eventId); - setEvents((prev) => { - const next = new Map(prev); - next.delete(eventId); - return next; - }); - setModel((prev) => ({ ...prev, events: prev.events.filter((id) => id !== eventId) })); - - if (selectedId === eventId) setSelectedId(null); - - undoStack.push({ - label: `Delete event "${previous?.title}"`, - undo: async () => { - const res = await fetch("/api/event/create", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calendarPath: path, date: events.get(eventId)?.date }), - }); - if (!res.ok) return; - const event = (await res.json()) as RichEvent; - - setEvents((prev) => { - const next = new Map(prev); - next.set(event.id, { ...event, ...(previous ?? {}), id: event.id }); - return next; - }); - setModel((prev) => ({ ...prev, events: [...prev.events, event.id] })); - setSelectedId(event.id); - - if (previous) void apiSaveEvent({ ...previous, id: event.id }); - - return { - label: `Delete event "${event.title}"`, - redo: async () => { - const res = await fetch( - `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(event.id)}`, - { method: "DELETE" }, - ); - if (!res.ok) return; - - setEvents((prev) => { - const next = new Map(prev); - next.delete(event.id); - return next; - }); - setModel((prev) => ({ ...prev, events: prev.events.filter((id) => id !== event.id) })); - - if (selectedId === event.id) setSelectedId(null); - }, - }; - }, - }); - }; - - const selectedEvent = selectedId ? events.get(selectedId) : null; + const handleCreateEvent = useCallback( + async (date: string) => { + await createEvent(date, { + onSuccess: (event) => { + setSelectedEvent(event); + + undoStack.push({ + label: `Create event "${event.title}"`, + undo: async () => { + await deleteEvent(event.id); + setSelectedEvent((curr) => (curr?.id === event.id ? null : curr)); + }, + redo: async () => { + await createEvent(date, { + onSuccess: (restoredEvent) => { + setSelectedEvent(restoredEvent); + }, + }); + }, + }); + }, + }); + }, + [undoStack, deleteEvent, createEvent], + ); + + const handleDeleteEvent = useCallback( + async (eventId: string) => { + const previous = data?.events.find((e) => e.id === eventId); + await deleteEvent(eventId, { + onSuccess: () => { + setSelectedEvent((curr) => (curr?.id === eventId ? null : curr)); + + undoStack.push({ + label: `Delete event "${previous?.title}"`, + undo: async () => { + const event = await createEvent(previous?.date ?? ""); + + const restoredEvent = previous ? { ...previous, id: event.id } : event; + setSelectedEvent(restoredEvent); + + if (previous) { + await updateEvent(restoredEvent); + } + + return { + label: `Delete event "${restoredEvent.title}"`, + redo: async () => { + await deleteEvent(restoredEvent.id, { + onSuccess: () => { + setSelectedEvent((curr) => (curr?.id === restoredEvent.id ? null : curr)); + }, + }); + }, + }; + }, + }); + }, + }); + }, + [data?.events, undoStack, deleteEvent, createEvent, updateEvent], + ); const byDate = useMemo(() => { const map = new Map(); - for (const id of model.events) { - const event = events.get(id); + for (const id of data?.model.events ?? []) { + const event = data?.events.find((e) => e.id === id); if (!event) continue; const list = map.get(event.date) ?? []; list.push(event); map.set(event.date, list); } return map; - }, [events, model]); + }, [data?.events, data?.model]); const year = cursor.getFullYear(); const month = cursor.getMonth(); @@ -252,10 +184,10 @@ export function CalendarView({ value, path }: CalendarViewProps) { const sortedEvents = useMemo( () => - [...events.values()].sort((a, b) => + [...(data?.events ?? [])].sort((a, b) => (a.date + (a.time ?? "")).localeCompare(b.date + (b.time ?? "")), ), - [events], + [data?.events], ); return ( @@ -305,7 +237,7 @@ export function CalendarView({ value, path }: CalendarViewProps) { )} - {loading ? ( + {isLoading ? (
Loading events…
) : mode === "month" ? (
@@ -331,11 +263,11 @@ export function CalendarView({ value, path }: CalendarViewProps) { { e.stopPropagation(); - setSelectedId(selectedId === event.id ? null : event.id); + setSelectedEvent(selectedEvent?.id === event.id ? null : event); }} title={event.time ? `${event.time} — ${event.title}` : event.title} > @@ -352,8 +284,8 @@ export function CalendarView({ value, path }: CalendarViewProps) { {sortedEvents.map((event) => (
  • setSelectedId(selectedId === event.id ? null : event.id)} + className={`calendar-agenda-row ${selectedEvent?.id === event.id ? "calendar-agenda-row--selected" : ""}`} + onClick={() => setSelectedEvent(selectedEvent?.id === event.id ? null : event)} > {event.date} @@ -377,7 +309,7 @@ export function CalendarView({ value, path }: CalendarViewProps) { diff --git a/packages/note-calendar/src/use-create-event.ts b/packages/note-calendar/src/use-create-event.ts new file mode 100644 index 0000000..bab3830 --- /dev/null +++ b/packages/note-calendar/src/use-create-event.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { RichEvent } from "./calendar-format"; + +export function useCreateEvent(path: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (date: string) => { + const res = await fetch("/api/event/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ calendarPath: path, date }), + }); + if (!res.ok) throw new Error("Failed to create event"); + + const event = (await res.json()) as RichEvent; + await queryClient.refetchQueries({ queryKey: ["events", path] }); + + return event; + }, + }); +} diff --git a/packages/note-calendar/src/use-delete-event.ts b/packages/note-calendar/src/use-delete-event.ts new file mode 100644 index 0000000..ef14bbe --- /dev/null +++ b/packages/note-calendar/src/use-delete-event.ts @@ -0,0 +1,17 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +export function useDeleteEvent(path: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (eventId: string) => { + const res = await fetch( + `/api/event?calendarPath=${encodeURIComponent(path)}&eventId=${encodeURIComponent(eventId)}`, + { method: "DELETE" }, + ); + if (!res.ok) throw new Error("Failed to delete event"); + + return queryClient.refetchQueries({ queryKey: ["events", path] }); + }, + }); +} diff --git a/packages/note-calendar/src/use-get-events.ts b/packages/note-calendar/src/use-get-events.ts new file mode 100644 index 0000000..a87954d --- /dev/null +++ b/packages/note-calendar/src/use-get-events.ts @@ -0,0 +1,27 @@ +import { useQuery } from "@tanstack/react-query"; +import { parseCalendar, RichEvent } from "./calendar-format"; + +export function useGetEvents(path: string, value?: string) { + return useQuery({ + queryKey: ["events", path], + queryFn: async () => { + const res = await fetch(`/api/events?calendarPath=${encodeURIComponent(path)}`); + if (!res.ok) throw new Error("fetch failed"); + const fileRes = await fetch(`/api/file?path=${encodeURIComponent(path)}`); + if (!fileRes.ok) throw new Error("fetch failed"); + + const data = (await res.json()) as { events: RichEvent[] }; + const fileData = (await fileRes.json()) as { content: string }; + return { + model: parseCalendar(fileData.content), + events: data.events, + }; + }, + initialData: value + ? { + model: parseCalendar(value), + events: [], + } + : undefined, + }); +} diff --git a/packages/note-calendar/src/use-update-event.ts b/packages/note-calendar/src/use-update-event.ts new file mode 100644 index 0000000..7469d3f --- /dev/null +++ b/packages/note-calendar/src/use-update-event.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { RichEvent } from "./calendar-format"; + +export function useUpdateEvent(path: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: RichEvent) => { + const res = await fetch("/api/event/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ calendarPath: path, event: data }), + }); + if (!res.ok) throw new Error("Failed to update event"); + + const event = (await res.json()) as RichEvent; + await queryClient.refetchQueries({ queryKey: ["events", path] }); + + return event; + }, + }); +} From 9b18aac0cf4ecbb97766fe3806d515e42b58dfdc Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sat, 1 Aug 2026 18:32:06 -0500 Subject: [PATCH 3/7] chore: Bump version to 0.5.0 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 36 ++++++++++++++--------------- package.json | 2 +- packages/core/package.json | 2 +- packages/editor/package.json | 2 +- packages/index/package.json | 2 +- packages/note-boards/package.json | 2 +- packages/note-calendar/package.json | 2 +- packages/note-canvas/package.json | 2 +- packages/note-grid/package.json | 2 +- packages/note-mermaid/package.json | 2 +- packages/note-tables/package.json | 2 +- packages/plugin-host/package.json | 2 +- packages/shared/package.json | 2 +- packages/tome/package.json | 2 +- packages/ui/package.json | 2 +- 18 files changed, 35 insertions(+), 35 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 553ef0d..9fdc8f9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@notes/desktop", - "version": "0.4.1", + "version": "0.5.0", "description": "Notes — local-first knowledge tool (Electron desktop wrapper)", "author": "Notes Contributors", "desktopName": "Notes", diff --git a/apps/server/package.json b/apps/server/package.json index 73acc6a..7a399e8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@notes/server", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "main": "./src/main.ts", diff --git a/apps/web/package.json b/apps/web/package.json index 178ee3c..057f5b6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@notes/web", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/package-lock.json b/package-lock.json index a368f11..fb41db1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notes", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notes", - "version": "0.4.1", + "version": "0.5.0", "workspaces": [ "apps/*", "packages/*" @@ -38,7 +38,7 @@ }, "apps/desktop": { "name": "@notes/desktop", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "better-sqlite3": "^12.11.1", "electron-updater": "^6.8.9" @@ -91,7 +91,7 @@ }, "apps/server": { "name": "@notes/server", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@fastify/rate-limit": "^11.1.0", "@fastify/static": "^10.1.2", @@ -118,7 +118,7 @@ }, "apps/web": { "name": "@notes/web", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/editor": "*", "@notes/note-boards": "*", @@ -14762,14 +14762,14 @@ }, "packages/core": { "name": "@notes/core", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@tanstack/react-query": "^5.101.4" } }, "packages/editor": { "name": "@notes/editor", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@codemirror/commands": "^6.10.4", "@codemirror/lang-markdown": "^6.5.0", @@ -14797,7 +14797,7 @@ }, "packages/index": { "name": "@notes/index", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "better-sqlite3": "^12.11.1" }, @@ -14807,7 +14807,7 @@ }, "packages/note-boards": { "name": "@notes/note-boards", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "@notes/editor": "*", @@ -14820,7 +14820,7 @@ }, "packages/note-calendar": { "name": "@notes/note-calendar", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "@notes/editor": "*" @@ -14832,7 +14832,7 @@ }, "packages/note-canvas": { "name": "@notes/note-canvas", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "@notes/editor": "*" @@ -14844,7 +14844,7 @@ }, "packages/note-grid": { "name": "@notes/note-grid", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "@notes/editor": "*" @@ -14856,7 +14856,7 @@ }, "packages/note-mermaid": { "name": "@notes/note-mermaid", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "mermaid": "^11.4.1" @@ -14868,7 +14868,7 @@ }, "packages/note-tables": { "name": "@notes/note-tables", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "@notes/editor": "*", @@ -14884,21 +14884,21 @@ }, "packages/plugin-host": { "name": "@notes/plugin-host", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "zod": "^4.4.3" } }, "packages/shared": { "name": "@notes/shared", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "zod": "^4.4.3" } }, "packages/tome": { "name": "@notes/tome", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "@notes/core": "*", "chokidar": "^5.0.0" @@ -14930,7 +14930,7 @@ }, "packages/ui": { "name": "@notes/ui", - "version": "0.4.1", + "version": "0.5.0", "peerDependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/package.json b/package.json index 0adaecd..954d8bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notes", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "engines": { diff --git a/packages/core/package.json b/packages/core/package.json index 709b301..228ae91 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@notes/core", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Command bus, event bus, registries, and extension-point contracts (no I/O).", "type": "module", diff --git a/packages/editor/package.json b/packages/editor/package.json index a97c0ea..da09040 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -1,6 +1,6 @@ { "name": "@notes/editor", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Markdown editor: modes, toolbar, list/checkbox behavior, and markdown round-trip.", "type": "module", diff --git a/packages/index/package.json b/packages/index/package.json index a98018c..f00cee6 100644 --- a/packages/index/package.json +++ b/packages/index/package.json @@ -1,6 +1,6 @@ { "name": "@notes/index", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "SQLite index: links, tags, frontmatter, and full-text search.", "type": "module", diff --git a/packages/note-boards/package.json b/packages/note-boards/package.json index fe78399..c176a81 100644 --- a/packages/note-boards/package.json +++ b/packages/note-boards/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-boards", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Kanban board note type, markdown-backed (first-party plugin).", "type": "module", diff --git a/packages/note-calendar/package.json b/packages/note-calendar/package.json index 708dc73..ca2c6c8 100644 --- a/packages/note-calendar/package.json +++ b/packages/note-calendar/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-calendar", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Calendar note type, markdown-backed (first-party plugin).", "type": "module", diff --git a/packages/note-canvas/package.json b/packages/note-canvas/package.json index 048d425..41f5d37 100644 --- a/packages/note-canvas/package.json +++ b/packages/note-canvas/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-canvas", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Canvas note type using the JSONCanvas format (first-party plugin).", "type": "module", diff --git a/packages/note-grid/package.json b/packages/note-grid/package.json index 0cde86e..fad40a5 100644 --- a/packages/note-grid/package.json +++ b/packages/note-grid/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-grid", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Grid (tile-map / graph-paper) note type with layers and tokens (first-party plugin).", "type": "module", diff --git a/packages/note-mermaid/package.json b/packages/note-mermaid/package.json index 457438a..9b890ec 100644 --- a/packages/note-mermaid/package.json +++ b/packages/note-mermaid/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-mermaid", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Mermaid diagram note type, markdown-backed (first-party plugin).", "type": "module", diff --git a/packages/note-tables/package.json b/packages/note-tables/package.json index 0a5b3d3..13fc65e 100644 --- a/packages/note-tables/package.json +++ b/packages/note-tables/package.json @@ -1,6 +1,6 @@ { "name": "@notes/note-tables", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Table (lightweight database) note type (first-party plugin).", "type": "module", diff --git a/packages/plugin-host/package.json b/packages/plugin-host/package.json index 520e869..32e5efb 100644 --- a/packages/plugin-host/package.json +++ b/packages/plugin-host/package.json @@ -1,6 +1,6 @@ { "name": "@notes/plugin-host", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Plugin manifest, lifecycle, and local loader (client + server halves).", "type": "module", diff --git a/packages/shared/package.json b/packages/shared/package.json index 70aeae0..edf8d2e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@notes/shared", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Shared types, zod schemas, constants, and UI strings (client + server).", "type": "module", diff --git a/packages/tome/package.json b/packages/tome/package.json index 5f24b0b..36f0851 100644 --- a/packages/tome/package.json +++ b/packages/tome/package.json @@ -1,6 +1,6 @@ { "name": "@notes/tome", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "File-system Tome: CRUD, path safety, atomic writes, and file watcher.", "type": "module", diff --git a/packages/ui/package.json b/packages/ui/package.json index 56ddc48..e8004fc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@notes/ui", - "version": "0.4.1", + "version": "0.5.0", "private": true, "description": "Shared design-system components, theming tokens, and icons.", "type": "module", From ef004b5e4d7f465ae66fb0c7c74371734beba42c Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sat, 1 Aug 2026 18:46:54 -0500 Subject: [PATCH 4/7] feat: Simplify logic --- packages/note-calendar/src/calendar-view.tsx | 82 +++++++++----------- 1 file changed, 35 insertions(+), 47 deletions(-) diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index 5dee8a1..91a6c30 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -104,24 +104,18 @@ export function CalendarView({ value, path }: CalendarViewProps) { const handleCreateEvent = useCallback( async (date: string) => { - await createEvent(date, { - onSuccess: (event) => { - setSelectedEvent(event); - - undoStack.push({ - label: `Create event "${event.title}"`, - undo: async () => { - await deleteEvent(event.id); - setSelectedEvent((curr) => (curr?.id === event.id ? null : curr)); - }, - redo: async () => { - await createEvent(date, { - onSuccess: (restoredEvent) => { - setSelectedEvent(restoredEvent); - }, - }); - }, - }); + const event = await createEvent(date); + setSelectedEvent(event); + + undoStack.push({ + label: `Create event "${event.title}"`, + undo: async () => { + await deleteEvent(event.id); + setSelectedEvent((curr) => (curr?.id === event.id ? null : curr)); + }, + redo: async () => { + const restoredEvent = await createEvent(date); + setSelectedEvent(restoredEvent); }, }); }, @@ -131,34 +125,28 @@ export function CalendarView({ value, path }: CalendarViewProps) { const handleDeleteEvent = useCallback( async (eventId: string) => { const previous = data?.events.find((e) => e.id === eventId); - await deleteEvent(eventId, { - onSuccess: () => { - setSelectedEvent((curr) => (curr?.id === eventId ? null : curr)); - - undoStack.push({ - label: `Delete event "${previous?.title}"`, - undo: async () => { - const event = await createEvent(previous?.date ?? ""); - - const restoredEvent = previous ? { ...previous, id: event.id } : event; - setSelectedEvent(restoredEvent); - - if (previous) { - await updateEvent(restoredEvent); - } - - return { - label: `Delete event "${restoredEvent.title}"`, - redo: async () => { - await deleteEvent(restoredEvent.id, { - onSuccess: () => { - setSelectedEvent((curr) => (curr?.id === restoredEvent.id ? null : curr)); - }, - }); - }, - }; + await deleteEvent(eventId); + setSelectedEvent((curr) => (curr?.id === eventId ? null : curr)); + + undoStack.push({ + label: `Delete event "${previous?.title}"`, + undo: async () => { + const event = await createEvent(previous?.date ?? ""); + + const restoredEvent = previous ? { ...previous, id: event.id } : event; + setSelectedEvent(restoredEvent); + + if (previous) { + await updateEvent(restoredEvent); + } + + return { + label: `Delete event "${restoredEvent.title}"`, + redo: async () => { + await deleteEvent(restoredEvent.id); + setSelectedEvent((curr) => (curr?.id === restoredEvent.id ? null : curr)); }, - }); + }; }, }); }, @@ -184,9 +172,9 @@ export function CalendarView({ value, path }: CalendarViewProps) { const sortedEvents = useMemo( () => - [...(data?.events ?? [])].sort((a, b) => + data?.events?.toSorted((a, b) => (a.date + (a.time ?? "")).localeCompare(b.date + (b.time ?? "")), - ), + ) ?? [], [data?.events], ); From 3014a617cda9418b206d26e3423f8b3d4814fd7b Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sat, 1 Aug 2026 19:22:24 -0500 Subject: [PATCH 5/7] feat: Split calendar view into smaller components --- packages/note-calendar/src/agenda-list.tsx | 44 ++++ packages/note-calendar/src/calendar-view.tsx | 252 +++---------------- packages/note-calendar/src/event-details.tsx | 126 ++++++++++ packages/note-calendar/src/month-grid.tsx | 85 +++++++ packages/note-calendar/src/styles.css | 4 + packages/note-calendar/src/utils.ts | 22 ++ 6 files changed, 309 insertions(+), 224 deletions(-) create mode 100644 packages/note-calendar/src/agenda-list.tsx create mode 100644 packages/note-calendar/src/event-details.tsx create mode 100644 packages/note-calendar/src/month-grid.tsx create mode 100644 packages/note-calendar/src/utils.ts diff --git a/packages/note-calendar/src/agenda-list.tsx b/packages/note-calendar/src/agenda-list.tsx new file mode 100644 index 0000000..0d4082b --- /dev/null +++ b/packages/note-calendar/src/agenda-list.tsx @@ -0,0 +1,44 @@ +import { useMemo } from "react"; +import type { RichEvent } from "./calendar-format"; + +export function AgendaList({ + todayIso, + events = [], + selectedEvent, + setSelectedEvent, +}: { + todayIso: string; + events?: RichEvent[]; + selectedEvent: RichEvent | null; + setSelectedEvent: (event: RichEvent | null) => void; +}) { + const sortedEvents = useMemo( + () => + events.toSorted((a, b) => (a.date + (a.time ?? "")).localeCompare(b.date + (b.time ?? ""))), + [events], + ); + + return ( +
      + {sortedEvents.map((event) => ( +
    • setSelectedEvent(selectedEvent?.id === event.id ? null : event)} + > + + {event.date} + {event.time ? ` ${event.time}` : ""} + + + {event.title} + {event.body && } + +
    • + ))} + {sortedEvents.length === 0 &&
    • No events yet.
    • } +
    + ); +} diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index 91a6c30..a45cb0d 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -1,62 +1,23 @@ +import { NoteToolbar } from "@notes/editor"; import { useUndoStack } from "@notes/web/src/state/undo-context"; import { useCallback, useMemo, useState } from "react"; -import { MarkdownEditor, NoteToolbar } from "@notes/editor"; -import { useUpdateEvent } from "./use-update-event"; +import { EventDetails } from "./event-details"; +import { AgendaList } from "./agenda-list"; +import { type RichEvent } from "./calendar-format"; +import { MonthGrid } from "./month-grid"; import { useCreateEvent } from "./use-create-event"; import { useDeleteEvent } from "./use-delete-event"; import { useGetEvents } from "./use-get-events"; -import { type RichEvent } from "./calendar-format"; +import { useUpdateEvent } from "./use-update-event"; +import { MONTHS, toIso } from "./utils"; interface CalendarViewProps { value: string; onChange?: (markdown: string) => void; path: string; } - type CalendarMode = "month" | "agenda"; -const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; -const MONTHS = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; - -const DURATION_OPTIONS = [ - { value: 15, label: "15 min" }, - { value: 30, label: "30 min" }, - { value: 60, label: "1 hour" }, - { value: 90, label: "1.5 hours" }, - { value: 120, label: "2 hours" }, - { value: 0, label: "All day" }, -]; - -function toIso(date: Date): string { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, "0"); - const day = String(date.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -function monthGrid(year: number, month: number): Date[] { - const first = new Date(year, month, 1); - const start = new Date(year, month, 1 - first.getDay()); - return Array.from({ length: 42 }, (_, index) => { - const date = new Date(start); - date.setDate(start.getDate() + index); - return date; - }); -} - function debounce) => void>(fn: T, ms: number): T { let timer: ReturnType; return ((...args: Parameters) => { @@ -153,31 +114,11 @@ export function CalendarView({ value, path }: CalendarViewProps) { [data?.events, undoStack, deleteEvent, createEvent, updateEvent], ); - const byDate = useMemo(() => { - const map = new Map(); - for (const id of data?.model.events ?? []) { - const event = data?.events.find((e) => e.id === id); - if (!event) continue; - const list = map.get(event.date) ?? []; - list.push(event); - map.set(event.date, list); - } - return map; - }, [data?.events, data?.model]); - const year = cursor.getFullYear(); const month = cursor.getMonth(); const todayIso = toIso(new Date()); const step = (delta: number) => setCursor(new Date(year, month + delta, 1)); - const sortedEvents = useMemo( - () => - data?.events?.toSorted((a, b) => - (a.date + (a.time ?? "")).localeCompare(b.date + (b.time ?? "")), - ) ?? [], - [data?.events], - ); - return (
    @@ -228,169 +169,32 @@ export function CalendarView({ value, path }: CalendarViewProps) { {isLoading ? (
    Loading events…
    ) : mode === "month" ? ( -
    - {WEEKDAYS.map((day) => ( -
    - {day} -
    - ))} - {monthGrid(year, month).map((date) => { - const iso = toIso(date); - const inMonth = date.getMonth() === month; - const dayEvents = byDate.get(iso) ?? []; - return ( - - ); - })} -
    + ) : ( -
      - {sortedEvents.map((event) => ( -
    • setSelectedEvent(selectedEvent?.id === event.id ? null : event)} - > - - {event.date} - {event.time ? ` ${event.time}` : ""} - - - {event.title} - {event.body && } - -
    • - ))} - {sortedEvents.length === 0 &&
    • No events yet.
    • } -
    + )}
    {selectedEvent && ( -
    -
    - Edit Event - -
    - -
    - - - - - - - - - -
    - -
    - updateEventState({ ...selectedEvent, body })} - /> -
    - -
    - -
    -
    + )}
    ); diff --git a/packages/note-calendar/src/event-details.tsx b/packages/note-calendar/src/event-details.tsx new file mode 100644 index 0000000..c43714c --- /dev/null +++ b/packages/note-calendar/src/event-details.tsx @@ -0,0 +1,126 @@ +import { MarkdownEditor } from "@notes/editor"; +import type { RichEvent } from "./calendar-format"; + +const DURATION_OPTIONS = [ + { value: 15, label: "15 min" }, + { value: 30, label: "30 min" }, + { value: 60, label: "1 hour" }, + { value: 90, label: "1.5 hours" }, + { value: 120, label: "2 hours" }, + { value: 0, label: "All day" }, +]; + +export function EventDetails({ + selectedEvent, + setSelectedEvent, + onUpdateEvent, + onDeleteEvent, +}: { + selectedEvent: RichEvent; + setSelectedEvent: (event: RichEvent | null) => void; + onUpdateEvent: (event: RichEvent) => void; + onDeleteEvent: (eventId: string) => void; +}) { + return ( +
    +
    + Edit Event + +
    + +
    + + + + + + + + + +
    + +
    + onUpdateEvent({ ...selectedEvent, body })} + /> +
    + +
    + +
    +
    + ); +} diff --git a/packages/note-calendar/src/month-grid.tsx b/packages/note-calendar/src/month-grid.tsx new file mode 100644 index 0000000..eca55f7 --- /dev/null +++ b/packages/note-calendar/src/month-grid.tsx @@ -0,0 +1,85 @@ +import { useMemo } from "react"; +import type { RichEvent } from "./calendar-format"; +import { WEEKDAYS, toIso } from "./utils"; + +function monthGrid(year: number, month: number): Date[] { + const first = new Date(year, month, 1); + const start = new Date(year, month, 1 - first.getDay()); + return Array.from({ length: 42 }, (_, index) => { + const date = new Date(start); + date.setDate(start.getDate() + index); + return date; + }); +} + +export function MonthGrid({ + year, + month, + todayIso, + events = [], + onCreateEvent, + selectedEvent, + setSelectedEvent, +}: { + year: number; + month: number; + todayIso: string; + events?: RichEvent[]; + onCreateEvent: (iso: string) => void; + selectedEvent: RichEvent | null; + setSelectedEvent: (event: RichEvent | null) => void; +}) { + const gridDates = useMemo(() => monthGrid(year, month), [month, year]); + + const eventsByDate = useMemo(() => { + const map = new Map(); + for (const event of events ?? []) { + const list = map.get(event.date) ?? []; + list.push(event); + map.set(event.date, list); + } + return map; + }, [events]); + + return ( +
    + {WEEKDAYS.map((day) => ( +
    + {day} +
    + ))} + {gridDates.map((date) => { + const iso = toIso(date); + const inMonth = date.getMonth() === month; + const dayEvents = eventsByDate.get(iso) ?? []; + return ( + + ); + })} +
    + ); +} diff --git a/packages/note-calendar/src/styles.css b/packages/note-calendar/src/styles.css index 4ba9fcb..a0ca927 100644 --- a/packages/note-calendar/src/styles.css +++ b/packages/note-calendar/src/styles.css @@ -269,6 +269,10 @@ font-size: 13px; } +.calendar-agenda-date--today { + color: var(--accent); +} + .calendar-agenda-title { flex: 1; display: flex; diff --git a/packages/note-calendar/src/utils.ts b/packages/note-calendar/src/utils.ts new file mode 100644 index 0000000..73e40c7 --- /dev/null +++ b/packages/note-calendar/src/utils.ts @@ -0,0 +1,22 @@ +export const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +export const MONTHS = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +export function toIso(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} From d73038040816c98944bea60c253927b53628553c Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sat, 1 Aug 2026 19:24:18 -0500 Subject: [PATCH 6/7] feat: Extract debounce to a shared util --- packages/core/src/debounce.ts | 7 +++++++ packages/core/src/index.ts | 1 + packages/note-boards/src/board-view.tsx | 9 +-------- packages/note-calendar/src/calendar-view.tsx | 11 ++--------- 4 files changed, 11 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/debounce.ts diff --git a/packages/core/src/debounce.ts b/packages/core/src/debounce.ts new file mode 100644 index 0000000..492094a --- /dev/null +++ b/packages/core/src/debounce.ts @@ -0,0 +1,7 @@ +export function debounce) => void>(fn: T, ms: number): T { + let timer: ReturnType; + return ((...args: Parameters) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a952faa..249947a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -6,3 +6,4 @@ export * from "./note-type-registry"; export * from "./markdown-note-type"; export * from "./hotkeys"; export * from "./undo-stack"; +export * from "./debounce"; diff --git a/packages/note-boards/src/board-view.tsx b/packages/note-boards/src/board-view.tsx index dadb9c4..c686aba 100644 --- a/packages/note-boards/src/board-view.tsx +++ b/packages/note-boards/src/board-view.tsx @@ -1,5 +1,6 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type DragEvent } from "react"; import { usePromptDialog } from "@notes/editor"; +import { debounce } from "@notes/core"; import { type NoteViewContextMenuBuilder } from "@notes/ui"; import { useUndoStack } from "@notes/web/src/state/undo-context"; import { BoardColumn } from "./board-column"; @@ -31,14 +32,6 @@ interface DropTarget { beforeCardId: string | null; } -function debounce) => void>(fn: T, ms: number): T { - let timer: ReturnType; - return ((...args: Parameters) => { - clearTimeout(timer); - timer = setTimeout(() => fn(...args), ms); - }) as T; -} - export function BoardView({ value, onChange, path, onRegisterContextMenu }: BoardViewProps) { const { openPrompt, promptDialog } = usePromptDialog(); const undoStack = useUndoStack(); diff --git a/packages/note-calendar/src/calendar-view.tsx b/packages/note-calendar/src/calendar-view.tsx index a45cb0d..e3e94b0 100644 --- a/packages/note-calendar/src/calendar-view.tsx +++ b/packages/note-calendar/src/calendar-view.tsx @@ -1,9 +1,10 @@ import { NoteToolbar } from "@notes/editor"; import { useUndoStack } from "@notes/web/src/state/undo-context"; +import { debounce } from "@notes/core"; import { useCallback, useMemo, useState } from "react"; -import { EventDetails } from "./event-details"; import { AgendaList } from "./agenda-list"; import { type RichEvent } from "./calendar-format"; +import { EventDetails } from "./event-details"; import { MonthGrid } from "./month-grid"; import { useCreateEvent } from "./use-create-event"; import { useDeleteEvent } from "./use-delete-event"; @@ -18,14 +19,6 @@ interface CalendarViewProps { } type CalendarMode = "month" | "agenda"; -function debounce) => void>(fn: T, ms: number): T { - let timer: ReturnType; - return ((...args: Parameters) => { - clearTimeout(timer); - timer = setTimeout(() => fn(...args), ms); - }) as T; -} - export function CalendarView({ value, path }: CalendarViewProps) { const undoStack = useUndoStack(); const [selectedEvent, setSelectedEvent] = useState(null); From 207495959e79140b290afce13b44da7d365092c8 Mon Sep 17 00:00:00 2001 From: Adam Martin Date: Sat, 1 Aug 2026 19:28:39 -0500 Subject: [PATCH 7/7] feat: Add miissing peerDep --- package-lock.json | 1 + packages/note-calendar/package.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index fb41db1..c067bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14826,6 +14826,7 @@ "@notes/editor": "*" }, "peerDependencies": { + "@tanstack/react-query": "^5.101.4", "react": "^18.3.1", "react-dom": "^18.3.1" } diff --git a/packages/note-calendar/package.json b/packages/note-calendar/package.json index ca2c6c8..7c29e56 100644 --- a/packages/note-calendar/package.json +++ b/packages/note-calendar/package.json @@ -19,6 +19,7 @@ }, "peerDependencies": { "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "@tanstack/react-query": "^5.101.4" } }