diff --git a/AGENTS.md b/AGENTS.md index bc35a0a..4d38b5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,8 @@ Self-hosted family coordination web app for Matt and his wife. Replaces a commer This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in [`docs/tasks/`](docs/tasks/) reference this file; do not duplicate its contents there. +Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory. + --- ## Goals @@ -67,7 +69,7 @@ src/ - **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity. - **Feature flags.** Env/db-driven for staging experiments. -**Rule for contributors (including future Sonnet sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. +**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. --- @@ -142,6 +144,7 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p - **One module = one PR/commit boundary** when possible. - **Tests:** Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD. - **Secrets** via `.env` (gitignored) and `.env.example` (committed, no values). +- **Teardown after validation.** After build/test/E2E validation, stop any dev servers or compose services started for the task unless the user explicitly asks to keep them running. This avoids stale instances and port conflicts in later sessions. --- diff --git a/CLAUDE.md b/CLAUDE.md index fa607fa..5d61e30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,8 @@ Self-hosted family coordination web app for Matt and his wife. Replaces a commer This file is the canonical brief. Read it at the start of every session before making changes. Sub-task briefs in [`docs/tasks/`](docs/tasks/) reference this file; do not duplicate its contents there. +Codex and Claude Code both work on this project. Keep `AGENTS.md`, `CLAUDE.md`, `STATUS.md`, task briefs, and dev notes synchronized so either agent can pick up the next task without relying on agent-specific memory. + --- ## Goals @@ -67,7 +69,7 @@ src/ - **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity. - **Feature flags.** Env/db-driven for staging experiments. -**Rule for contributors (including future Sonnet sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. +**Rule for contributors (including future Codex and Claude Code sessions):** if a feature requires a change to `_core` to support a new entity type, that's a smell — extend the registry instead. --- @@ -142,6 +144,7 @@ Tasks for each phase live in [`docs/tasks/`](docs/tasks/). Sub-sessions should p - **One module = one PR/commit boundary** when possible. - **Tests:** Vitest for units (where it pays off), Playwright for one happy-path E2E per module. Don't write tests for trivial CRUD. - **Secrets** via `.env` (gitignored) and `.env.example` (committed, no values). +- **Teardown after validation.** After build/test/E2E validation, stop any dev servers or compose services started for the task unless the user explicitly asks to keep them running. This avoids stale instances and port conflicts in later sessions. --- diff --git a/STATUS.md b/STATUS.md index b693912..d04cd87 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,6 +1,6 @@ # Status -Living progress tracker. Update at the end of each task. The canonical brief is [`CLAUDE.md`](CLAUDE.md); task briefs live in [`docs/tasks/`](docs/tasks/). +Living progress tracker. Update at the end of each task. Codex and Claude Code both work on this project, so write status notes and next-step instructions for either agent to resume. Canonical briefs are [`AGENTS.md`](AGENTS.md) and [`CLAUDE.md`](CLAUDE.md); keep them synchronized. Task briefs live in [`docs/tasks/`](docs/tasks/). ## Done @@ -18,10 +18,11 @@ Living progress tracker. Update at the end of each task. The canonical brief is - **10 — Calendar module**. Added `calendars` and `calendar_events` schema + migration `0003_rainy_ravenous.sql`, default Home/Personal calendar seeding, first-login default calendar creation, visibility-safe calendar/event queries, CRUD server actions, FullCalendar-backed `/calendar` UI with sidebar calendar management and event create/edit/delete/drag updates. Calendar manifest now registers share/reminder/search capabilities, two configurable widgets, and quick-add entries. Added Playwright happy-path spec in `tests/e2e/calendar.spec.ts`. `pnpm db:generate`, `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass. - **11 — Lists module**. Added `lists` and `list_items` schema + migration `0004_opposite_wraith.sql`, default Shopping/Tasks seeding on first access/sign-in/seed, household-gated list and item CRUD server actions, reorder support, and Postgres `LISTEN/NOTIFY` to SSE bridge documented in ADR `0002`. Added `/lists` grouped index, `/lists/[id]` keyboard-first item entry with checkbox toggles and swipe/delete, manifest entity/search/widget/quick-add registrations, and Playwright happy-path spec in `tests/e2e/lists.spec.ts`. `pnpm typecheck`, `pnpm lint`, and `pnpm build` pass. +- **12 — Notes module**. Added generic core `reminders` table plus household-scoped `notes` schema in migration `0006_new_hannibal_king.sql`, notes CRUD server actions, reminder synchronization for `notes.note`, `/notes` index, `/notes/new`, `/notes/[id]` editor with safe markdown preview, manifest entity/search/reminder/share registration, `notes.filtered` widget registration, quick-add placeholder, and Playwright happy-path spec in `tests/e2e/notes.spec.ts`. `pnpm typecheck`, `pnpm lint`, `pnpm build`, and notes E2E pass. ## Next up -- **12 — Notes module** (or next task in `docs/tasks/`). +- Next task in `docs/tasks/`. ## Development login/testing notes @@ -41,7 +42,7 @@ Living progress tracker. Update at the end of each task. The canonical brief is ## How to resume in a fresh session 1. Open the repo root in VS Code. -2. Tell Claude: _"Read [CLAUDE.md](CLAUDE.md) and [STATUS.md](STATUS.md), then complete [docs/tasks/03-drizzle-postgres.md](docs/tasks/03-drizzle-postgres.md). Stop at the acceptance criteria."_ +2. Tell Codex or Claude Code: _"Read [AGENTS.md](AGENTS.md), [CLAUDE.md](CLAUDE.md), and [STATUS.md](STATUS.md), then complete the next task in [docs/tasks/](docs/tasks/). Stop at the acceptance criteria."_ 3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit. ## Environment notes diff --git a/docs/dev-login.md b/docs/dev-login.md index b32666b..f6e15ee 100644 --- a/docs/dev-login.md +++ b/docs/dev-login.md @@ -76,6 +76,18 @@ $env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json' pnpm test:e2e ``` +After validation, tear down services started for the test run unless you are intentionally keeping the app open: + +```powershell +# Stop a manually started Next dev server if one is still running on port 3000. +Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | + ForEach-Object { Stop-Process -Id $_ } + +# Stop the local database container when the session is finished. +docker compose -f docker-compose.dev.yaml down +``` + ## Production Removal Plan Before production deployment, complete the checklist below. diff --git a/drizzle/0006_new_hannibal_king.sql b/drizzle/0006_new_hannibal_king.sql new file mode 100644 index 0000000..323bd85 --- /dev/null +++ b/drizzle/0006_new_hannibal_king.sql @@ -0,0 +1,29 @@ +CREATE TABLE "reminders" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "entity_type" text NOT NULL, + "entity_id" uuid NOT NULL, + "fire_at" timestamp with time zone NOT NULL, + "channel" text DEFAULT 'in_app' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "notes" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "household_id" uuid NOT NULL, + "author_id" uuid NOT NULL, + "title" text NOT NULL, + "body" text DEFAULT '' NOT NULL, + "pinned" boolean DEFAULT false NOT NULL, + "remind_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "reminders" ADD CONSTRAINT "reminders_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_household_id_households_id_fk" FOREIGN KEY ("household_id") REFERENCES "public"."households"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "notes" ADD CONSTRAINT "notes_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "reminders_entity_unique" ON "reminders" USING btree ("entity_type","entity_id");--> statement-breakpoint +CREATE INDEX "reminders_household_fire_at_idx" ON "reminders" USING btree ("household_id","fire_at");--> statement-breakpoint +CREATE INDEX "notes_household_pinned_updated_idx" ON "notes" USING btree ("household_id","pinned","updated_at");--> statement-breakpoint +CREATE INDEX "notes_author_idx" ON "notes" USING btree ("author_id"); \ No newline at end of file diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx new file mode 100644 index 0000000..bd7c1c2 --- /dev/null +++ b/src/app/notes/[id]/page.tsx @@ -0,0 +1,11 @@ +import { notFound } from "next/navigation"; +import { NoteEditor } from "@/modules/notes/components/note-editor"; +import { getNote } from "@/modules/notes/server/queries"; + +export default async function NotePage({ params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const note = await getNote(id).catch(() => null); + if (!note) notFound(); + + return ; +} diff --git a/src/app/notes/new/page.tsx b/src/app/notes/new/page.tsx new file mode 100644 index 0000000..62d75e9 --- /dev/null +++ b/src/app/notes/new/page.tsx @@ -0,0 +1,5 @@ +import { NoteEditor } from "@/modules/notes/components/note-editor"; + +export default function NewNotePage() { + return ; +} diff --git a/src/app/notes/page.tsx b/src/app/notes/page.tsx new file mode 100644 index 0000000..3f36e7d --- /dev/null +++ b/src/app/notes/page.tsx @@ -0,0 +1,7 @@ +import { NotesIndex } from "@/modules/notes/components/notes-index"; +import { listNotes } from "@/modules/notes/server/queries"; + +export default async function NotesPage() { + const notes = await listNotes(); + return ; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index cc7f7e7..6b1f335 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -3,9 +3,10 @@ import postgres from "postgres"; import * as coreSchema from "@/modules/_core/schema"; import * as calendarSchema from "@/modules/calendar/schema"; import * as listsSchema from "@/modules/lists/schema"; +import * as notesSchema from "@/modules/notes/schema"; const client = postgres(process.env["DATABASE_URL"]!); -const schema = { ...coreSchema, ...calendarSchema, ...listsSchema }; +const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema }; export const db = drizzle(client, { schema }); diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index d76d363..6c9f477 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -1,11 +1,13 @@ import type { AdapterAccountType } from "@auth/core/adapters"; import { + index, integer, pgEnum, pgTable, primaryKey, text, timestamp, + uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; @@ -82,3 +84,22 @@ export const householdMembers = pgTable( }, (t) => [primaryKey({ columns: [t.householdId, t.userId] })], ); + +export const reminders = pgTable( + "reminders", + { + id: uuid("id").primaryKey().defaultRandom(), + householdId: uuid("household_id") + .notNull() + .references(() => households.id, { onDelete: "cascade" }), + entityType: text("entity_type").notNull(), + entityId: uuid("entity_id").notNull(), + fireAt: timestamp("fire_at", { withTimezone: true }).notNull(), + channel: text("channel").notNull().default("in_app"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex("reminders_entity_unique").on(t.entityType, t.entityId), + index("reminders_household_fire_at_idx").on(t.householdId, t.fireAt), + ], +); diff --git a/src/modules/notes/components/markdown-preview.tsx b/src/modules/notes/components/markdown-preview.tsx new file mode 100644 index 0000000..8b8d0f6 --- /dev/null +++ b/src/modules/notes/components/markdown-preview.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; + +export function MarkdownPreview({ markdown }: { markdown: string }) { + const blocks = parseBlocks(markdown); + + if (blocks.length === 0) { + return

No preview yet.

; + } + + return ( +
+ {blocks.map((block, index) => { + if (block.type === "heading") { + return ( +

+ {block.text} +

+ ); + } + + if (block.type === "list") { + return ( +
    + {block.items.map((item, itemIndex) => ( +
  • {item}
  • + ))} +
+ ); + } + + return

{block.text}

; + })} +
+ ); +} + +type MarkdownBlock = + | { type: "heading"; text: ReactNode } + | { type: "list"; items: ReactNode[] } + | { type: "paragraph"; text: ReactNode }; + +function parseBlocks(markdown: string): MarkdownBlock[] { + const blocks: MarkdownBlock[] = []; + const lines = markdown.replace(/\r\n/g, "\n").split("\n"); + let paragraph: string[] = []; + let listItems: string[] = []; + + function flushParagraph() { + if (paragraph.length === 0) return; + blocks.push({ type: "paragraph", text: paragraph.join(" ") }); + paragraph = []; + } + + function flushList() { + if (listItems.length === 0) return; + blocks.push({ type: "list", items: listItems.map((item) => item) }); + listItems = []; + } + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + flushParagraph(); + flushList(); + continue; + } + + if (line.startsWith("# ")) { + flushParagraph(); + flushList(); + blocks.push({ type: "heading", text: line.slice(2).trim() }); + continue; + } + + if (line.startsWith("- ")) { + flushParagraph(); + listItems.push(line.slice(2).trim()); + continue; + } + + flushList(); + paragraph.push(line); + } + + flushParagraph(); + flushList(); + return blocks; +} diff --git a/src/modules/notes/components/note-editor.tsx b/src/modules/notes/components/note-editor.tsx new file mode 100644 index 0000000..4f99c1e --- /dev/null +++ b/src/modules/notes/components/note-editor.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Pin, PinOff, Save, Trash2 } from "lucide-react"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import type { NoteDto } from "../server/queries"; +import { createNote, deleteNote, setNotePinned, updateNote } from "../server/actions"; +import { MarkdownPreview } from "./markdown-preview"; + +export function NoteEditor({ note }: { note?: NoteDto }) { + const router = useRouter(); + const [currentNote, setCurrentNote] = useState(note); + const [title, setTitle] = useState(note?.title ?? ""); + const [body, setBody] = useState(note?.body ?? ""); + const [remindAt, setRemindAt] = useState(toLocalDateTimeValue(note?.remindAt ?? null)); + const [isPending, startTransition] = useTransition(); + + const pinned = currentNote?.pinned ?? false; + + function saveNote() { + startTransition(async () => { + if (currentNote) { + const updated = await updateNote({ + id: currentNote.id, + title, + body, + remindAt: remindAt ? new Date(remindAt) : null, + }); + setCurrentNote({ + ...updated, + createdAt: updated.createdAt.toISOString(), + updatedAt: updated.updatedAt.toISOString(), + remindAt: updated.remindAt?.toISOString() ?? null, + }); + return; + } + + const created = await createNote({ + title, + body, + remindAt: remindAt ? new Date(remindAt) : null, + }); + router.push(`/notes/${created.id}`); + }); + } + + function togglePinned() { + if (!currentNote) return; + startTransition(async () => { + setCurrentNote(await setNotePinned({ id: currentNote.id, pinned: !pinned })); + }); + } + + function removeNote() { + if (!currentNote) return; + startTransition(async () => { + await deleteNote({ id: currentNote.id }); + router.push("/notes"); + }); + } + + return ( +
+
+
+

{currentNote ? currentNote.title : "New note"}

+

Markdown notes shared with the household.

+
+
+ {currentNote ? ( + + ) : null} + {currentNote ? ( + + ) : null} + +
+
+ +
+
+
+ + setTitle(event.target.value)} /> +
+
+ +