diff --git a/STATUS.md b/STATUS.md index 306a246..7ba4555 100644 --- a/STATUS.md +++ b/STATUS.md @@ -19,7 +19,9 @@ Living progress tracker. Update at the end of each task. Codex and Claude Code b - **86 — Journal module** (ADR 0005 accepted `8e2ddd6`). Per-user `journal_entries` schema + migration `0020_journal_entries.sql`; mood catalog (multi-select), optional stress/pills, rich-text body; index with calendar dots, entry editor, Recharts mood tracker, insights (streak/trends/correlations); `/api/v1/journal/entries` CRUD with bearer token auth; OpenAPI updated. Unit tests: `journal-analytics.test.ts`. E2E: `tests/e2e/journal.spec.ts`. Run `pnpm db:migrate` for migration `0020`. -- **88 — LLM agent chat** (ADR 0006). `/assistant` chat UI; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls (lists, calendar, notes, journal); `POST /api/agent/chat`. Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. +- **88 — LLM agent chat** (ADR 0006). Opt-in floating chat bubble; OpenAI-compatible LLM client (`LLM_BASE_URL` / `LLM_MODEL`); mock provider when unset; direct tool schemas → `/api/v1/` HTTP calls; `POST /api/agent/chat`. Per-user `assistant_enabled` (default off). Unit tests: `agent-chat.test.ts`. E2E: `tests/e2e/assistant.spec.ts`. Migration `0021`. + +- **Journal + dashboard polish** (follow-on to 86/81/80/85/88). Journal dashboard widgets (`journal.recent`, `journal.mood-tracker` with year/month config); journal quick-add dialog (moods, stress/pills on by default, `StressSlider`, rich-text reflection); mood tracker UI redesign + `?day=` navigation; `listMoodTrackerEntries` (500 cap) fixes widget Zod error. Dashboard edit mode uses cookie draft (`dashboard-editor-draft.ts`, `syncEditorDraftLayout`) so add/remove/config refreshes live widget previews. Rich-text in quick-add (notes, calendar, journal) and calendar event notes. Agent bubble UI + expanded API tools (garden care, share links, OpenAPI docs). Widget picker hover contrast fix. Gitea #38 (animation polish) remains open. - **01 — Repo init & tooling** (commit `b89690a`). pnpm 10 + TS strict + ESLint flat + Prettier. All acceptance criteria green. - **02 — Next.js app skeleton**. Next.js 15 + React 19 + Tailwind v4 + shadcn/ui (button, card, input, dialog). `pnpm dev` serves placeholder, `pnpm build` produces `.next/standalone/`, `pnpm lint` clean. Added `.npmrc` with `node-linker=hoisted` for Windows symlink compatibility. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 60e2548..db407c2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -314,6 +314,103 @@ components: properties: occurredOn: { type: string, format: date } + CareLog: + type: object + properties: + id: { type: string, format: uuid } + careType: { type: string } + notes: { type: string, nullable: true } + performedAt: { type: string, format: date-time } + performedBy: { type: string, format: uuid, nullable: true } + + CareLogInput: + type: object + required: [careType] + properties: + careType: { type: string } + notes: { type: string, nullable: true } + performedAt: { type: string, format: date-time } + + CareSchedule: + type: object + properties: + id: { type: string, format: uuid } + careType: { type: string } + intervalDays: { type: integer } + lastPerformedAt: { type: string, format: date-time, nullable: true } + nextDueAt: { type: string, format: date-time, nullable: true } + enabled: { type: boolean } + daysUntilDue: { type: integer, nullable: true } + isOverdue: { type: boolean } + + CareScheduleInput: + type: object + required: [careType, intervalDays] + properties: + careType: { type: string } + intervalDays: { type: integer, minimum: 1, maximum: 365 } + enabled: { type: boolean } + + CareScheduleToggleInput: + type: object + required: [enabled] + properties: + enabled: { type: boolean } + + ScheduleOnCalendarInput: + type: object + required: [calendarId] + properties: + calendarId: { type: string, format: uuid } + reminderMinutesBefore: { type: integer, nullable: true } + + ShareableEntityType: + type: object + properties: + type: { type: string } + label: { type: string } + defaultCapabilities: + type: array + items: { type: string } + + ShareLink: + type: object + properties: + id: { type: string, format: uuid } + entityType: { type: string } + entityId: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + expiresAt: { type: string, format: date-time, nullable: true } + capabilities: + type: object + properties: + read: { type: boolean } + write: { type: boolean } + + ShareLinkCreateInput: + type: object + required: [entityType, entityId] + properties: + entityType: { type: string } + entityId: { type: string, format: uuid } + expiresAt: { type: string, format: date-time, nullable: true } + capabilities: + type: object + properties: + read: { type: boolean } + write: { type: boolean } + + ShareLinkCreated: + type: object + properties: + url: { type: string, format: uri } + expiresAt: { type: string, format: date-time, nullable: true } + + PushOverdueResult: + type: object + properties: + added: { type: integer } + paths: /api/v1/calendars: get: @@ -791,6 +888,193 @@ paths: application/json: schema: { $ref: "#/components/schemas/OkResponse" } + /api/v1/garden/plants/{id}/care-logs: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: List care logs for a plant + tags: [Garden] + parameters: + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/CareLog" } + post: + summary: Log care performed on a plant + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CareLogInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/CareLog" } + + /api/v1/garden/plants/{id}/care-schedules: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + get: + summary: List care schedules for a plant + tags: [Garden] + responses: + "200": + content: + application/json: + schema: + type: array + items: { $ref: "#/components/schemas/CareSchedule" } + post: + summary: Create or update a care schedule + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CareScheduleInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/CareSchedule" } + + /api/v1/garden/care-schedules/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + patch: + summary: Enable or disable a care schedule + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CareScheduleToggleInput" } + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + delete: + summary: Delete a care schedule + tags: [Garden] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/garden/care-schedules/{id}/calendar: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + post: + summary: Add next due care to calendar + tags: [Garden] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ScheduleOnCalendarInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/garden/overdue-care/push: + post: + summary: Push overdue care items to task list + tags: [Garden] + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/PushOverdueResult" } + + /api/v1/share-links: + get: + summary: List share links or shareable entity types + tags: [Sharing] + parameters: + - name: entityTypes + in: query + schema: { type: string, enum: ["true"] } + description: When true, returns shareable entity types instead of links + - name: entityType + in: query + schema: { type: string } + - name: entityId + in: query + schema: { type: string, format: uuid } + responses: + "200": + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: "#/components/schemas/ShareLink" } + - type: array + items: { $ref: "#/components/schemas/ShareableEntityType" } + post: + summary: Create a share link + tags: [Sharing] + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ShareLinkCreateInput" } + responses: + "201": + content: + application/json: + schema: { $ref: "#/components/schemas/ShareLinkCreated" } + + /api/v1/share-links/{id}: + parameters: + - name: id + in: path + required: true + schema: { type: string, format: uuid } + delete: + summary: Revoke a share link + tags: [Sharing] + responses: + "200": + content: + application/json: + schema: { $ref: "#/components/schemas/OkResponse" } + + /api/v1/openapi: + get: + summary: OpenAPI specification (YAML) + tags: [Meta] + responses: + "200": + description: OpenAPI 3.1 YAML document + content: + application/yaml: + schema: { type: string } + /api/v1/bangs: get: summary: Bang stats (total + recent) @@ -916,4 +1200,6 @@ tags: - name: Notes - name: Journal - name: Garden + - name: Sharing - name: Bangs + - name: Meta diff --git a/docs/issues-map.md b/docs/issues-map.md index 12db726..2057cc1 100644 --- a/docs/issues-map.md +++ b/docs/issues-map.md @@ -2,55 +2,56 @@ Design IDs from `docs/superpowers/specs/2026-07-03-backlog-triage-design.md` → Gitea (`ginnoir/famapp`). -| Design | Gitea | Title | URL | -| --- | --- | --- | --- | -| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 | -| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 | -| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 | -| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 | -| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 | -| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 | -| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 | -| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 | -| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 | -| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 | -| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 | -| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 | -| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 | -| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 | -| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 | -| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 | -| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 | -| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 | -| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 | -| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 | -| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 | -| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 | -| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 | -| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 | -| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 | -| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 | -| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 | -| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 | -| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 | -| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 | -| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 | -| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 | -| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 | -| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 | -| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 | -| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 | -| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 | +| Design | Gitea | Title | URL | +| ------ | ----- | ------------------------------------------------------------------- | -------------------------------------------------- | +| 1 | 1 | Quick-add opens create UI, not module page | https://gitea.ginnoir.com/ginnoir/famapp/issues/1 | +| 2 | 2 | Dashboard edit mode renders live widgets at true size | https://gitea.ginnoir.com/ginnoir/famapp/issues/2 | +| 3 | 3 | Garden container plant count is wrong | https://gitea.ginnoir.com/ginnoir/famapp/issues/3 | +| 4 | 4 | Bangs are not editable (add edit and delete) | https://gitea.ginnoir.com/ginnoir/famapp/issues/4 | +| 5 | 5 | Notes overflow horizontally on mobile | https://gitea.ginnoir.com/ginnoir/famapp/issues/5 | +| 6 | 6 | Missing back navigation on detail pages (audit + shared affordance) | https://gitea.ginnoir.com/ginnoir/famapp/issues/6 | +| 7 | 28 | Calendar reminders overhaul | https://gitea.ginnoir.com/ginnoir/famapp/issues/28 | +| 8 | 29 | Lists index: inline task add + list property edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/29 | +| 9 | 30 | Comments on lists and tasks | https://gitea.ginnoir.com/ginnoir/famapp/issues/30 | +| 10 | 7 | Notes editor overhaul (shared rich-text component) | https://gitea.ginnoir.com/ginnoir/famapp/issues/7 | +| 10.1 | 8 | Research ADR: rich-text editor library and storage format | https://gitea.ginnoir.com/ginnoir/famapp/issues/8 | +| 10.2 | 9 | Shared rich-text editor component | https://gitea.ginnoir.com/ginnoir/famapp/issues/9 | +| 10.3 | 10 | Wire shared editor into notes create/edit | https://gitea.ginnoir.com/ginnoir/famapp/issues/10 | +| 10.4 | 11 | Render note formatting on all notes surfaces | https://gitea.ginnoir.com/ginnoir/famapp/issues/11 | +| 10.5 | 12 | Fix notes mobile horizontal overflow | https://gitea.ginnoir.com/ginnoir/famapp/issues/12 | +| 11 | 31 | Bang stats dashboard widget | https://gitea.ginnoir.com/ginnoir/famapp/issues/31 | +| 12 | 35 | Appearance / theming options | https://gitea.ginnoir.com/ginnoir/famapp/issues/35 | +| 13 | 19 | Journal module | https://gitea.ginnoir.com/ginnoir/famapp/issues/19 | +| 13.1 | 20 | Research ADR: journal/mood tracking build-vs-adopt | https://gitea.ginnoir.com/ginnoir/famapp/issues/20 | +| 13.2 | 21 | Journal schema + CRUD | https://gitea.ginnoir.com/ginnoir/famapp/issues/21 | +| 13.3 | 22 | Journal index (recent, browse, entry calendar) | https://gitea.ginnoir.com/ginnoir/famapp/issues/22 | +| 13.4 | 23 | Journal entry detail and create | https://gitea.ginnoir.com/ginnoir/famapp/issues/23 | +| 13.5 | 24 | Journal mood tracker view | https://gitea.ginnoir.com/ginnoir/famapp/issues/24 | +| 13.6 | 25 | Journal insights/stats views | https://gitea.ginnoir.com/ginnoir/famapp/issues/25 | +| 13.7 | 26 | Journal E2E happy path | https://gitea.ginnoir.com/ginnoir/famapp/issues/26 | +| 13.8 | 27 | Journal API endpoints | https://gitea.ginnoir.com/ginnoir/famapp/issues/27 | +| 14 | 13 | API + LLM agent chat | https://gitea.ginnoir.com/ginnoir/famapp/issues/13 | +| 14.1 | 14 | Research ADR: API auth, surface shape, and LLM agent architecture | https://gitea.ginnoir.com/ginnoir/famapp/issues/14 | +| 14.2 | 15 | API surface + token auth for existing modules | https://gitea.ginnoir.com/ginnoir/famapp/issues/15 | +| 14.3 | 16 | LLM agent chat UI | https://gitea.ginnoir.com/ginnoir/famapp/issues/16 | +| 14.4 | 17 | Map agent tool-calling to API | https://gitea.ginnoir.com/ginnoir/famapp/issues/17 | +| 14.5 | 18 | Agent smoke tests with mock provider | https://gitea.ginnoir.com/ginnoir/famapp/issues/18 | +| 15 | 32 | Pets module | https://gitea.ginnoir.com/ginnoir/famapp/issues/32 | +| 16 | 33 | Shopping/pantry module | https://gitea.ginnoir.com/ginnoir/famapp/issues/33 | +| 17 | 34 | Backups and exports | https://gitea.ginnoir.com/ginnoir/famapp/issues/34 | +| 18 | 36 | GPS locations for calendar events | https://gitea.ginnoir.com/ginnoir/famapp/issues/36 | +| 19 | 37 | Lists and notes cohesion (research proposal) | https://gitea.ginnoir.com/ginnoir/famapp/issues/37 | +| — | 38 | Revisit journal day-switch animations (smoother paper transition) | https://gitea.ginnoir.com/ginnoir/famapp/issues/38 | ## Dependencies (P1) -| Issue | Depends on | -| --- | --- | -| #5 (notes overflow) | #7 (rich-text epic) | -| #12 (mobile overflow child) | #8 (rich-text research ADR) | -| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) | -| #16 (agent UI) | #15 (API surface) | -| #17 (tool-calling) | #15 (API surface) | -| #27 (journal API) | #15 (API surface) | +| Issue | Depends on | +| --------------------------- | -------------------------------------- | +| #5 (notes overflow) | #7 (rich-text epic) | +| #12 (mobile overflow child) | #8 (rich-text research ADR) | +| #19 (journal epic) | #7 (rich-text epic), #15 (API surface) | +| #16 (agent UI) | #15 (API surface) | +| #17 (tool-calling) | #15 (API surface) | +| #27 (journal API) | #15 (API surface) | Native Gitea issue dependencies API 404s on this instance; dependencies are also noted as comments on the issues above. diff --git a/docs/tasks/81-dashboard-edit-live-widgets.md b/docs/tasks/81-dashboard-edit-live-widgets.md index 6aca567..7559a7d 100644 --- a/docs/tasks/81-dashboard-edit-live-widgets.md +++ b/docs/tasks/81-dashboard-edit-live-widgets.md @@ -25,9 +25,9 @@ Edit mode currently shows empty placeholders; on save, content-heavy widgets (e. ## Acceptance criteria -- [ ] Edit mode shows real widget content for registered widgets. -- [ ] Saving layout does not cause a large content-driven reflow for content-heavy widgets. -- [ ] E2E covers real content visible in edit mode. +- [x] Edit mode shows real widget content for registered widgets. +- [x] Saving layout does not cause a large content-driven reflow for content-heavy widgets. +- [x] E2E covers real content visible in edit mode. ## Notes diff --git a/drizzle/0021_user_assistant_enabled.sql b/drizzle/0021_user_assistant_enabled.sql new file mode 100644 index 0000000..a7fe2b1 --- /dev/null +++ b/drizzle/0021_user_assistant_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "assistant_enabled" boolean DEFAULT false NOT NULL; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5b9ff23..e2553a9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1751750400000, "tag": "0020_journal_entries", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1751754000000, + "tag": "0021_user_assistant_enabled", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/agent/chat/route.ts b/src/app/api/agent/chat/route.ts index 6bd41c2..9a904f1 100644 --- a/src/app/api/agent/chat/route.ts +++ b/src/app/api/agent/chat/route.ts @@ -1,10 +1,13 @@ import { z } from "zod"; import { apiError, apiJson } from "@/lib/api-handler"; import { resolveApiAuth } from "@/lib/api-auth"; +import { getAssistantEnabled } from "@/lib/assistant-preference"; import { isLlmConfigured } from "@/lib/llm"; +import { encodeSseEvent } from "@/modules/agent/server/progress"; import { runAgentChat } from "@/modules/agent/server/run"; const chatInput = z.object({ + stream: z.boolean().optional(), messages: z .array( z.object({ @@ -18,10 +21,15 @@ const chatInput = z.object({ export async function POST(request: Request) { const auth = await resolveApiAuth(request); - if (!auth) { + if (!auth?.userId) { return apiError("Unauthorized", 401); } + const assistantEnabled = await getAssistantEnabled(auth.userId); + if (!assistantEnabled) { + return apiError("Assistant not enabled", 403); + } + let body: unknown; try { body = await request.json(); @@ -34,6 +42,47 @@ export async function POST(request: Request) { return apiError(parsed.error.issues[0]?.message ?? "Validation error", 400); } + if (parsed.data.stream) { + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + const send = (event: Parameters[0]) => { + controller.enqueue(encoder.encode(encodeSseEvent(event))); + }; + + try { + const result = await runAgentChat({ + messages: parsed.data.messages, + request, + onProgress: send, + }); + + send({ + type: "done", + message: { + role: "assistant", + content: result.message.content, + }, + toolCalls: result.toolCalls, + }); + } catch (err) { + const message = err instanceof Error ? err.message : "Agent request failed"; + send({ type: "error", message }); + } finally { + controller.close(); + } + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } + try { const result = await runAgentChat({ messages: parsed.data.messages, diff --git a/src/app/api/v1/garden/care-schedules/[id]/calendar/route.ts b/src/app/api/v1/garden/care-schedules/[id]/calendar/route.ts new file mode 100644 index 0000000..023321a --- /dev/null +++ b/src/app/api/v1/garden/care-schedules/[id]/calendar/route.ts @@ -0,0 +1,13 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { scheduleOnCalendarForScope } from "@/modules/garden/server/actions"; +import { scheduleOnCalendarInput } from "@/modules/garden/server/schemas"; + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = scheduleOnCalendarInput.parse({ ...(body as object), scheduleId: id }); + await scheduleOnCalendarForScope(scope, parsed); + return apiJson({ ok: true }, 201); + }); +} diff --git a/src/app/api/v1/garden/care-schedules/[id]/route.ts b/src/app/api/v1/garden/care-schedules/[id]/route.ts new file mode 100644 index 0000000..6f68971 --- /dev/null +++ b/src/app/api/v1/garden/care-schedules/[id]/route.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { + deleteCareScheduleForScope, + toggleCareScheduleForScope, +} from "@/modules/garden/server/actions"; +import { careScheduleToggleInput } from "@/modules/garden/server/schemas"; + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = careScheduleToggleInput.parse({ ...(body as object), id }); + await toggleCareScheduleForScope(scope, parsed); + return apiJson({ ok: true }); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await deleteCareScheduleForScope(scope, { id }); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/garden/overdue-care/push/route.ts b/src/app/api/v1/garden/overdue-care/push/route.ts new file mode 100644 index 0000000..4cee85a --- /dev/null +++ b/src/app/api/v1/garden/overdue-care/push/route.ts @@ -0,0 +1,9 @@ +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { pushOverdueToTaskListForScope } from "@/modules/garden/server/actions"; + +export async function POST(request: Request) { + return withApiHandler(request, async (scope) => { + const result = await pushOverdueToTaskListForScope(scope); + return apiJson(result, 201); + }); +} diff --git a/src/app/api/v1/garden/plants/[id]/care-logs/route.ts b/src/app/api/v1/garden/plants/[id]/care-logs/route.ts new file mode 100644 index 0000000..e10f003 --- /dev/null +++ b/src/app/api/v1/garden/plants/[id]/care-logs/route.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { logCareForScope } from "@/modules/garden/server/actions"; +import { careLogInput } from "@/modules/garden/server/schemas"; +import { getCareLogsForScope } from "@/modules/garden/server/queries"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + z.string().uuid().parse(id); + const url = new URL(req.url); + const limitRaw = url.searchParams.get("limit"); + const limit = limitRaw ? z.coerce.number().int().min(1).max(100).parse(limitRaw) : 20; + const logs = await getCareLogsForScope(scope.householdId, id, limit); + return apiJson(logs); + }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + z.string().uuid().parse(id); + const body: unknown = await req.json(); + const parsed = careLogInput.parse({ ...(body as object), plantId: id }); + const log = await logCareForScope(scope, parsed); + return apiJson( + { + id: log.id, + careType: log.careType, + notes: log.notes, + performedAt: log.performedAt.toISOString(), + performedBy: log.performedBy, + }, + 201, + ); + }); +} diff --git a/src/app/api/v1/garden/plants/[id]/care-schedules/route.ts b/src/app/api/v1/garden/plants/[id]/care-schedules/route.ts new file mode 100644 index 0000000..d7a927c --- /dev/null +++ b/src/app/api/v1/garden/plants/[id]/care-schedules/route.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { upsertCareScheduleForScope } from "@/modules/garden/server/actions"; +import { careScheduleInput } from "@/modules/garden/server/schemas"; +import { getCareSchedulesForScope } from "@/modules/garden/server/queries"; + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + const schedules = await getCareSchedulesForScope(scope.householdId, id); + return apiJson(schedules); + }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope, req) => { + z.string().uuid().parse(id); + const body: unknown = await req.json(); + const parsed = careScheduleInput.parse({ ...(body as object), plantId: id }); + const schedule = await upsertCareScheduleForScope(scope, parsed); + return apiJson( + { + id: schedule.id, + careType: schedule.careType, + intervalDays: schedule.intervalDays, + lastPerformedAt: schedule.lastPerformedAt?.toISOString() ?? null, + nextDueAt: schedule.nextDueAt?.toISOString() ?? null, + enabled: schedule.enabled, + }, + 201, + ); + }); +} diff --git a/src/app/api/v1/journal/entries/route.ts b/src/app/api/v1/journal/entries/route.ts index ab5ff82..8866f17 100644 --- a/src/app/api/v1/journal/entries/route.ts +++ b/src/app/api/v1/journal/entries/route.ts @@ -4,8 +4,11 @@ import { journalEntryInput } from "@/modules/journal/server/schemas"; import { listJournalEntriesForScope } from "@/modules/journal/server/queries"; export async function GET(request: Request) { - return withApiHandler(request, async (scope) => { - const entries = await listJournalEntriesForScope(scope); + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const limitParam = url.searchParams.get("limit"); + const limit = limitParam ? Math.min(Math.max(Number(limitParam) || 20, 1), 100) : undefined; + const entries = await listJournalEntriesForScope(scope, limit ? { limit } : undefined); return apiJson(entries); }); } diff --git a/src/app/api/v1/openapi/route.ts b/src/app/api/v1/openapi/route.ts new file mode 100644 index 0000000..64341bc --- /dev/null +++ b/src/app/api/v1/openapi/route.ts @@ -0,0 +1,14 @@ +import { readFile } from "fs/promises"; +import path from "path"; +import { withApiHandler } from "@/lib/api-handler"; + +export async function GET(request: Request) { + return withApiHandler(request, async () => { + const specPath = path.join(process.cwd(), "docs", "api", "openapi.yaml"); + const spec = await readFile(specPath, "utf8"); + return new Response(spec, { + status: 200, + headers: { "Content-Type": "application/yaml; charset=utf-8" }, + }); + }); +} diff --git a/src/app/api/v1/share-links/[id]/route.ts b/src/app/api/v1/share-links/[id]/route.ts new file mode 100644 index 0000000..2ba94bd --- /dev/null +++ b/src/app/api/v1/share-links/[id]/route.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { revokeShareLinkForScope } from "@/modules/_core/share-api"; + +export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + return withApiHandler(request, async (scope) => { + z.string().uuid().parse(id); + await revokeShareLinkForScope(scope, id); + return apiJson({ ok: true }); + }); +} diff --git a/src/app/api/v1/share-links/route.ts b/src/app/api/v1/share-links/route.ts new file mode 100644 index 0000000..7ced0ed --- /dev/null +++ b/src/app/api/v1/share-links/route.ts @@ -0,0 +1,55 @@ +import { z } from "zod"; +import { apiJson, withApiHandler } from "@/lib/api-handler"; +import { + createShareLinkForScope, + listShareLinksForScope, + listShareableEntityTypes, +} from "@/modules/_core/share-api"; + +const createShareLinkInput = z.object({ + entityType: z.string().trim().min(1), + entityId: z.string().uuid(), + expiresAt: z.string().datetime().nullable().optional(), + capabilities: z + .object({ + read: z.boolean().optional(), + write: z.boolean().optional(), + }) + .optional(), +}); + +export async function GET(request: Request) { + return withApiHandler(request, async (scope, req) => { + const url = new URL(req.url); + const entityType = url.searchParams.get("entityType") ?? undefined; + const entityId = url.searchParams.get("entityId") ?? undefined; + + if (url.searchParams.get("entityTypes") === "true") { + return apiJson(listShareableEntityTypes()); + } + + const links = await listShareLinksForScope(scope, { entityType, entityId }); + return apiJson(links); + }); +} + +export async function POST(request: Request) { + return withApiHandler(request, async (scope, req) => { + const body: unknown = await req.json(); + const parsed = createShareLinkInput.parse(body); + const expiresAt = parsed.expiresAt ? new Date(parsed.expiresAt) : null; + + const result = await createShareLinkForScope(scope, parsed.entityType, parsed.entityId, { + expiresAt, + capabilities: parsed.capabilities, + }); + + return apiJson( + { + url: result.url, + expiresAt: result.expiresAt?.toISOString() ?? null, + }, + 201, + ); + }); +} diff --git a/src/app/assistant/page.tsx b/src/app/assistant/page.tsx deleted file mode 100644 index 4a569a5..0000000 --- a/src/app/assistant/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { AssistantChat } from "@/modules/agent/components/assistant-chat"; -import { isLlmConfigured } from "@/lib/llm"; - -export default function AssistantPage() { - return ; -} diff --git a/src/app/d/[slug]/page.tsx b/src/app/d/[slug]/page.tsx index c9e3739..b518042 100644 --- a/src/app/d/[slug]/page.tsx +++ b/src/app/d/[slug]/page.tsx @@ -2,7 +2,8 @@ import { notFound } from "next/navigation"; import { Suspense } from "react"; import { getCurrentSession } from "@/lib/session"; import { parseDashboardLayout, widgetContentKey } from "@/lib/dashboard"; -import { computeDefaultLayout } from "@/lib/dashboard.server"; +import { readEditorDraftLayout } from "@/lib/dashboard-editor-draft"; +import { computeDefaultLayout, normalizeDashboardLayout } from "@/lib/dashboard.server"; import { getWidget, getWidgetMetas } from "@/modules/_core"; import { getDashboardBySlug } from "@/app/d/actions"; import { DashboardEditor } from "@/components/dashboard-editor"; @@ -49,7 +50,9 @@ export default async function DashboardPage({ const dashboard = await getDashboardBySlug(slug); if (!dashboard) notFound(); - const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout(); + const storedLayout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout(); + const draftLayout = isEditing ? await readEditorDraftLayout(dashboard.id) : null; + const layout = normalizeDashboardLayout(draftLayout ?? storedLayout); const widgetMetas = getWidgetMetas(); if (isEditing) { @@ -67,6 +70,7 @@ export default async function DashboardPage({ return ( , updatedAt: new Date() }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + await clearEditorDraftLayout(id); revalidatePath("/"); } +export async function syncEditorDraftLayout(id: string, layout: DashboardLayout): Promise { + const { user } = await getCurrentSession(); + const [row] = await db + .select({ id: dashboards.id }) + .from(dashboards) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))) + .limit(1); + if (!row) throw new Error("Dashboard not found"); + + for (const placement of layout.widgets) { + const widget = getWidget(placement.widgetId); + if (!widget) continue; + widget.configSchema.parse(placement.config); + } + + await writeEditorDraftLayout(id, layout); +} + +export async function discardEditorDraftLayout(id: string): Promise { + const { user } = await getCurrentSession(); + const [row] = await db + .select({ id: dashboards.id }) + .from(dashboards) + .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))) + .limit(1); + if (!row) throw new Error("Dashboard not found"); + await clearEditorDraftLayout(id); +} + export async function resetDashboardLayout(id: string): Promise { const { user } = await getCurrentSession(); const layout = computeDefaultLayout(); @@ -166,6 +197,7 @@ export async function resetDashboardLayout(id: string): Promise { .update(dashboards) .set({ layout: layout as unknown as Record, updatedAt: new Date() }) .where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))); + await clearEditorDraftLayout(id); revalidatePath("/"); } diff --git a/src/app/globals.css b/src/app/globals.css index bd2e953..6265a4d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -818,6 +818,86 @@ bottom: 18px; } +/* ── Assistant chat bubble (opt-in per user) ─────────────────────── */ +.assistant-bubble { + position: fixed; + right: 18px; + bottom: 24px; + z-index: 70; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 12px; + pointer-events: none; +} +:where(html[data-nav="bottom"]) .assistant-bubble { + bottom: calc(62px + max(env(safe-area-inset-bottom, 0px), 8px) + 12px); +} +:where(html[data-nav="fab"]) .assistant-bubble { + bottom: calc(18px + 52px + 12px); +} +.assistant-bubble > * { + pointer-events: auto; +} +.assistant-bubble-panel { + display: flex; + flex-direction: column; + width: min(360px, calc(100vw - 24px)); + height: min(480px, calc(100vh - 140px)); + border-radius: var(--r-md); + border: 0.5px solid var(--hair); + background: color-mix(in oklab, var(--card) 94%, transparent); + backdrop-filter: blur(16px) saturate(160%); + box-shadow: + 0 12px 40px rgba(31, 27, 22, 0.16), + 0 2px 8px rgba(31, 27, 22, 0.08); + padding: 12px; + overflow: hidden; +} +.assistant-bubble-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + flex-shrink: 0; +} +.assistant-bubble-close { + appearance: none; + border: 0; + background: transparent; + color: var(--ink-mute); + border-radius: 8px; + width: 32px; + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.assistant-bubble-close:hover { + background: var(--shade); + color: var(--ink); +} +.assistant-bubble-trigger { + width: 52px; + height: 52px; + border-radius: 999px; + border: 0; + background: var(--ink); + color: var(--paper); + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: + 0 4px 14px rgba(31, 27, 22, 0.22), + 0 1px 2px rgba(31, 27, 22, 0.18); +} +.assistant-bubble-trigger:hover { + background: var(--ink-2); +} + /* ── Buttons (used inside .topbar etc; - {devLoginEnabled && ( -
{ - "use server"; - // Auth.js may resolve either "authjs.session-token" (HTTP/dev) or - // "__Secure-authjs.session-token" (HTTPS) depending on AUTH_URL, - // trustHost, and proxy headers. Set both so the session is found - // regardless — this is dev-only code, correctness > elegance. - const { sessionToken, expires } = await createDevSession(); - const cookieStore = await cookies(); - const base = { httpOnly: true, sameSite: "lax" as const, path: "/", expires }; - cookieStore.set("authjs.session-token", sessionToken, base); - cookieStore.set("__Secure-authjs.session-token", sessionToken, { - ...base, - secure: true, - }); - redirect("/"); - }} - className="mt-3" - > + {devLoginEnabled ? ( +
- )} + ) : null} ); diff --git a/src/app/settings/assistant-actions.ts b/src/app/settings/assistant-actions.ts new file mode 100644 index 0000000..ea923ef --- /dev/null +++ b/src/app/settings/assistant-actions.ts @@ -0,0 +1,14 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; +import { db } from "@/lib/db"; +import { users } from "@/modules/_core/schema"; +import { getCurrentSession } from "@/lib/session"; + +export async function setAssistantEnabled(enabled: boolean): Promise { + const { user } = await getCurrentSession(); + await db.update(users).set({ assistantEnabled: enabled }).where(eq(users.id, user.id)); + revalidatePath("/settings"); + revalidatePath("/", "layout"); +} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index cbf9da2..b2dfe02 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -13,11 +13,23 @@ import { AvatarFallbackWithName } from "@/components/avatar-fallback"; import { revokeShareLinkAction } from "./actions"; import { getHouseholdApiTokenStatus } from "@/modules/_core/api-token"; import { ApiTokenSettings } from "@/components/api-token-settings"; +import { AssistantOptIn } from "@/components/assistant-opt-in"; import { listCalendars } from "@/modules/calendar/server/queries"; import { listLists } from "@/modules/lists/server/queries"; import Link from "next/link"; import { NavIcon } from "@/components/nav-icon"; -import { Mail, Globe, History, Sun, Bell, Pencil, Lock, Plus, KeyRound } from "lucide-react"; +import { + Mail, + Globe, + History, + Sun, + Bell, + Pencil, + Lock, + Plus, + KeyRound, + MessageCircle, +} from "lucide-react"; const VALID_SECTIONS = new Set([ "household", @@ -289,31 +301,44 @@ function AppearanceSection({ themeDashLayout: string; themeCalView: string; themeNavStyle: string; + assistantEnabled: boolean; }; }) { return ( - - - Appearance - - - - - - + <> + + + Appearance + + + + + + + + + + Assistant + + + + + + + ); } diff --git a/src/components/assistant-opt-in.tsx b/src/components/assistant-opt-in.tsx new file mode 100644 index 0000000..50a4621 --- /dev/null +++ b/src/components/assistant-opt-in.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { setAssistantEnabled } from "@/app/settings/assistant-actions"; +import { Switch } from "@/components/ui/switch"; + +export function AssistantOptIn({ enabled }: { enabled: boolean }) { + const [isPending, startTransition] = useTransition(); + const router = useRouter(); + + function toggle(next: boolean) { + startTransition(async () => { + await setAssistantEnabled(next); + router.refresh(); + }); + } + + return ( + + ); +} diff --git a/src/components/dashboard-editor.tsx b/src/components/dashboard-editor.tsx index 9f5151b..dd5b64c 100644 --- a/src/components/dashboard-editor.tsx +++ b/src/components/dashboard-editor.tsx @@ -11,7 +11,12 @@ import { GripVertical, Settings2, Trash2, RotateCcw, Plus, LayoutGrid } from "lu import type { DashboardLayout, WidgetPlacement, PresetId } from "@/lib/dashboard"; import { computePresetLayoutFromMetas, widgetContentKey } from "@/lib/dashboard"; import type { SerializedWidgetMeta } from "@/modules/_core/registry"; -import { saveDashboardLayout, resetDashboardLayout } from "@/app/d/actions"; +import { + saveDashboardLayout, + resetDashboardLayout, + syncEditorDraftLayout, + discardEditorDraftLayout, +} from "@/app/d/actions"; import { Button } from "@/components/ui/button"; import { WidgetPicker } from "./widget-picker"; @@ -58,8 +63,18 @@ export function DashboardEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dashboard.slug]); + function publishDraft(next: WidgetPlacement[]) { + startTransition(async () => { + await syncEditorDraftLayout(dashboard.id, { version: 1, widgets: next }); + router.refresh(); + }); + } + function handleCancel() { - router.push(`/d/${dashboard.slug}`); + startTransition(async () => { + await discardEditorDraftLayout(dashboard.id); + router.push(`/d/${dashboard.slug}`); + }); } function handleSave() { @@ -89,26 +104,32 @@ export function DashboardEditor({ } function removeWidget(index: number) { - setPlacements((current) => current.filter((_, i) => i !== index)); + const next = placements.filter((_, i) => i !== index); + setPlacements(next); setIsDirty(true); + publishDraft(next); } function addWidget(widgetId: string, config: unknown) { const meta = widgetMetas.find((m) => m.id === widgetId); if (!meta) return; const maxY = placements.reduce((m, p) => Math.max(m, p.y + p.h), 0); - setPlacements((current) => [ - ...current, + const next = [ + ...placements, { widgetId, config, x: 0, y: maxY, w: meta.defaultSize.w, h: meta.defaultSize.h }, - ]); + ]; + setPlacements(next); setIsDirty(true); setPickerOpen(false); + publishDraft(next); } function updateConfig(index: number, config: unknown) { - setPlacements((current) => current.map((p, i) => (i === index ? { ...p, config } : p))); + const next = placements.map((p, i) => (i === index ? { ...p, config } : p)); + setPlacements(next); setIsDirty(true); setConfiguringIndex(null); + publishDraft(next); } function applyPreset(preset: PresetId) { @@ -116,6 +137,7 @@ export function DashboardEditor({ setPlacements(next.widgets); setIsDirty(true); setPresetMenuOpen(false); + publishDraft(next.widgets); } const gridItems: Layout = placements.map((p, i) => ({ diff --git a/src/components/dashboard-greeting.tsx b/src/components/dashboard-greeting.tsx index 8d2ca76..6a21709 100644 --- a/src/components/dashboard-greeting.tsx +++ b/src/components/dashboard-greeting.tsx @@ -1,5 +1,3 @@ -"use client"; - const greetingForHour = (h: number) => h < 5 ? "Up early" : h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; @@ -18,7 +16,9 @@ export function DashboardGreeting({ firstName }: { firstName: string }) { {greeting} {firstName && `, ${firstName}`}. -

{today}

+

+ {today} +

); } diff --git a/src/components/quick-add/create-host.tsx b/src/components/quick-add/create-host.tsx index 88f9878..dd38a77 100644 --- a/src/components/quick-add/create-host.tsx +++ b/src/components/quick-add/create-host.tsx @@ -6,6 +6,7 @@ import { CalendarCreateDialog } from "@/components/quick-add/dialogs/calendar-cr import { CalendarEventCreateDialog } from "@/components/quick-add/dialogs/calendar-event-create-dialog"; import { ContainerCreateDialog } from "@/components/quick-add/dialogs/container-create-dialog"; import { GardenCareLogDialog } from "@/components/quick-add/dialogs/garden-care-log-dialog"; +import { JournalEntryCreateDialog } from "@/components/quick-add/dialogs/journal-entry-create-dialog"; import { ListCreateDialog } from "@/components/quick-add/dialogs/list-create-dialog"; import { ListItemCreateDialog } from "@/components/quick-add/dialogs/list-item-create-dialog"; import { NoteCreateDialog } from "@/components/quick-add/dialogs/note-create-dialog"; @@ -29,6 +30,10 @@ export function QuickAddCreateHost() { onOpenChange={onOpenChange} /> + import("@/components/rich-text/rich-text-editor").then((mod) => mod.RichTextEditor), + { + ssr: false, + loading: () => ( +
+ Loading editor… +
+ ), + }, +); + type Props = { open: boolean; onOpenChange: (open: boolean) => void; @@ -30,7 +44,7 @@ type Props = { export function CalendarEventCreateDialog({ open, onOpenChange }: Props) { return ( - + {open ? onOpenChange(false)} /> : null} @@ -71,7 +85,7 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { endAt: new Date(endAt), allDay: false, location: location || null, - notes: notes || null, + notes: richTextToPlainText(notes) ? notes : null, remindMinutesBefore: remind ? 30 : null, }); onDone(); @@ -146,13 +160,15 @@ function CalendarEventCreateForm({ onDone }: { onDone: () => void }) { onChange={(e) => setLocation(e.target.value)} /> -
+
-