Add notes module
This commit is contained in:
@@ -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.
|
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
|
## Goals
|
||||||
@@ -67,7 +69,7 @@ src/
|
|||||||
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
|
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
|
||||||
- **Feature flags.** Env/db-driven for staging experiments.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Goals
|
||||||
@@ -67,7 +69,7 @@ src/
|
|||||||
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
|
- **Permissions.** Household-scoped by default. Share-tokens grant scoped read/write per entity.
|
||||||
- **Feature flags.** Env/db-driven for staging experiments.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Status
|
# 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
|
## 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.
|
- **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.
|
- **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
|
## Next up
|
||||||
|
|
||||||
- **12 — Notes module** (or next task in `docs/tasks/`).
|
- Next task in `docs/tasks/`.
|
||||||
|
|
||||||
## Development login/testing notes
|
## 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
|
## How to resume in a fresh session
|
||||||
|
|
||||||
1. Open the repo root in VS Code.
|
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.
|
3. After it lands, append the result to the **Done** section here, bump **Next up**, and commit.
|
||||||
|
|
||||||
## Environment notes
|
## Environment notes
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ $env:PLAYWRIGHT_STORAGE_STATE='tests/.auth/dev-user.json'
|
|||||||
pnpm test:e2e
|
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
|
## Production Removal Plan
|
||||||
|
|
||||||
Before production deployment, complete the checklist below.
|
Before production deployment, complete the checklist below.
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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 <NoteEditor note={note} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NoteEditor } from "@/modules/notes/components/note-editor";
|
||||||
|
|
||||||
|
export default function NewNotePage() {
|
||||||
|
return <NoteEditor />;
|
||||||
|
}
|
||||||
@@ -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 <NotesIndex notes={notes} />;
|
||||||
|
}
|
||||||
+2
-1
@@ -3,9 +3,10 @@ import postgres from "postgres";
|
|||||||
import * as coreSchema from "@/modules/_core/schema";
|
import * as coreSchema from "@/modules/_core/schema";
|
||||||
import * as calendarSchema from "@/modules/calendar/schema";
|
import * as calendarSchema from "@/modules/calendar/schema";
|
||||||
import * as listsSchema from "@/modules/lists/schema";
|
import * as listsSchema from "@/modules/lists/schema";
|
||||||
|
import * as notesSchema from "@/modules/notes/schema";
|
||||||
|
|
||||||
const client = postgres(process.env["DATABASE_URL"]!);
|
const client = postgres(process.env["DATABASE_URL"]!);
|
||||||
|
|
||||||
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema };
|
const schema = { ...coreSchema, ...calendarSchema, ...listsSchema, ...notesSchema };
|
||||||
|
|
||||||
export const db = drizzle(client, { schema });
|
export const db = drizzle(client, { schema });
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { AdapterAccountType } from "@auth/core/adapters";
|
import type { AdapterAccountType } from "@auth/core/adapters";
|
||||||
import {
|
import {
|
||||||
|
index,
|
||||||
integer,
|
integer,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
pgTable,
|
pgTable,
|
||||||
primaryKey,
|
primaryKey,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
uniqueIndex,
|
||||||
uuid,
|
uuid,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
@@ -82,3 +84,22 @@ export const householdMembers = pgTable(
|
|||||||
},
|
},
|
||||||
(t) => [primaryKey({ columns: [t.householdId, t.userId] })],
|
(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),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function MarkdownPreview({ markdown }: { markdown: string }) {
|
||||||
|
const blocks = parseBlocks(markdown);
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No preview yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 text-sm leading-6">
|
||||||
|
{blocks.map((block, index) => {
|
||||||
|
if (block.type === "heading") {
|
||||||
|
return (
|
||||||
|
<h2 key={index} className="text-lg font-semibold">
|
||||||
|
{block.text}
|
||||||
|
</h2>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (block.type === "list") {
|
||||||
|
return (
|
||||||
|
<ul key={index} className="list-disc space-y-1 pl-5">
|
||||||
|
{block.items.map((item, itemIndex) => (
|
||||||
|
<li key={itemIndex}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <p key={index}>{block.text}</p>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="mx-auto grid w-full max-w-6xl gap-5 p-4">
|
||||||
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">{currentNote ? currentNote.title : "New note"}</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Markdown notes shared with the household.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{currentNote ? (
|
||||||
|
<Button variant="outline" onClick={togglePinned} disabled={isPending}>
|
||||||
|
{pinned ? <PinOff /> : <Pin />}
|
||||||
|
{pinned ? "Unpin note" : "Pin note"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{currentNote ? (
|
||||||
|
<Button variant="destructive" onClick={removeNote} disabled={isPending}>
|
||||||
|
<Trash2 />
|
||||||
|
Delete note
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button onClick={saveNote} disabled={!title.trim() || isPending}>
|
||||||
|
<Save />
|
||||||
|
Save note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(280px,420px)]">
|
||||||
|
<section className="grid gap-4 rounded-lg border bg-background p-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="note-title">Title</Label>
|
||||||
|
<Input id="note-title" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="note-body">Body</Label>
|
||||||
|
<textarea
|
||||||
|
id="note-body"
|
||||||
|
aria-label="Body"
|
||||||
|
className="min-h-80 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm leading-6 outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
value={body}
|
||||||
|
onChange={(event) => setBody(event.target.value)}
|
||||||
|
placeholder="# Dinner ideas - Tacos - Soup"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="note-reminder">Reminder</Label>
|
||||||
|
<Input
|
||||||
|
id="note-reminder"
|
||||||
|
type="datetime-local"
|
||||||
|
value={remindAt}
|
||||||
|
onChange={(event) => setRemindAt(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="rounded-lg border bg-card p-4 text-card-foreground">
|
||||||
|
<h2 className="mb-3 text-sm font-medium text-muted-foreground">Preview</h2>
|
||||||
|
<MarkdownPreview markdown={body} />
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalDateTimeValue(value: string | null) {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000);
|
||||||
|
return local.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Plus, Pin } from "lucide-react";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import type { NoteDto } from "../server/queries";
|
||||||
|
|
||||||
|
export function NotesIndex({ notes }: { notes: NoteDto[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-5xl gap-6 p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">Notes</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Shared reminders, reference notes, and loose household details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/notes/new" className={buttonVariants()}>
|
||||||
|
<Plus />
|
||||||
|
New note
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<div className="rounded-lg border bg-background p-8 text-center text-sm text-muted-foreground">
|
||||||
|
No notes yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<Link
|
||||||
|
key={note.id}
|
||||||
|
href={`/notes/${note.id}`}
|
||||||
|
className="grid min-h-32 gap-3 rounded-lg border bg-card p-4 text-card-foreground transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<h2 className="font-medium">{note.title}</h2>
|
||||||
|
{note.pinned ? (
|
||||||
|
<Pin aria-label="Pinned" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-3 text-sm text-muted-foreground">
|
||||||
|
{note.body || "No body text."}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Updated {new Date(note.updatedAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import type { ModuleManifest } from "../_core/module";
|
|
||||||
|
|
||||||
const manifest: ModuleManifest = {
|
|
||||||
id: "notes",
|
|
||||||
name: "Notes",
|
|
||||||
nav: { href: "/notes", label: "Notes", icon: "file-text" },
|
|
||||||
entities: [
|
|
||||||
{
|
|
||||||
type: "notes.note",
|
|
||||||
label: { singular: "Note", plural: "Notes" },
|
|
||||||
resolveUrl: (id) => `/notes/${id}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
dashboardWidgets: [],
|
|
||||||
quickAdds: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default manifest;
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ModuleManifest } from "../_core/module";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { searchNotes } from "./server/queries";
|
||||||
|
|
||||||
|
const notesWidgetConfigSchema = z.object({
|
||||||
|
filter: z.enum(["pinned", "all"]),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const manifest: ModuleManifest = {
|
||||||
|
id: "notes",
|
||||||
|
name: "Notes",
|
||||||
|
nav: { href: "/notes", label: "Notes", icon: "file-text" },
|
||||||
|
entities: [
|
||||||
|
{
|
||||||
|
type: "notes.note",
|
||||||
|
label: { singular: "Note", plural: "Notes" },
|
||||||
|
share: { canShare: true, defaultCapabilities: ["read"] },
|
||||||
|
reminder: { canRemind: true },
|
||||||
|
search: { search: searchNotes },
|
||||||
|
resolveUrl: (id) => `/notes/${id}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dashboardWidgets: [
|
||||||
|
{
|
||||||
|
id: "notes.filtered",
|
||||||
|
title: "Notes",
|
||||||
|
description: "Pinned notes or recent notes.",
|
||||||
|
category: "Notes",
|
||||||
|
defaultSize: { w: 4, h: 3 },
|
||||||
|
minSize: { w: 3, h: 2 },
|
||||||
|
defaultPriority: 40,
|
||||||
|
configSchema: notesWidgetConfigSchema,
|
||||||
|
defaultConfig: { filter: "pinned", limit: 10 },
|
||||||
|
resolveConfigOptions: async () => undefined,
|
||||||
|
render: ({ config }) => {
|
||||||
|
const parsed = notesWidgetConfigSchema.parse(config);
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{parsed.filter === "pinned" ? "Pinned notes" : "Notes"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quickAdds: [
|
||||||
|
{
|
||||||
|
id: "notes.new-note",
|
||||||
|
label: "New note",
|
||||||
|
icon: "file-plus",
|
||||||
|
action: () => undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { boolean, index, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||||
|
import { households, users } from "../_core/schema";
|
||||||
|
|
||||||
|
export const notes = pgTable(
|
||||||
|
"notes",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
householdId: uuid("household_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => households.id, { onDelete: "cascade" }),
|
||||||
|
authorId: uuid("author_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
body: text("body").notNull().default(""),
|
||||||
|
pinned: boolean("pinned").notNull().default(false),
|
||||||
|
remindAt: timestamp("remind_at", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index("notes_household_pinned_updated_idx").on(t.householdId, t.pinned, t.updatedAt),
|
||||||
|
index("notes_author_idx").on(t.authorId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type Note = typeof notes.$inferSelect;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { reminders } from "@/modules/_core/schema";
|
||||||
|
import { notes } from "../schema";
|
||||||
|
import { canAccessNote, getNote } from "./queries";
|
||||||
|
|
||||||
|
const noteInput = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
body: z.string().max(20000).default(""),
|
||||||
|
pinned: z.boolean().default(false),
|
||||||
|
remindAt: z.coerce.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNoteInput = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
title: noteInput.shape.title.optional(),
|
||||||
|
body: z.string().max(20000).optional(),
|
||||||
|
pinned: z.boolean().optional(),
|
||||||
|
remindAt: z.coerce.date().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function createNote(input: z.input<typeof noteInput>) {
|
||||||
|
const parsed = noteInput.parse(input);
|
||||||
|
const { household, user } = await getCurrentSession();
|
||||||
|
|
||||||
|
const [note] = await db.transaction(async (tx) => {
|
||||||
|
const [created] = await tx
|
||||||
|
.insert(notes)
|
||||||
|
.values({
|
||||||
|
householdId: household.id,
|
||||||
|
authorId: user.id,
|
||||||
|
title: parsed.title,
|
||||||
|
body: parsed.body,
|
||||||
|
pinned: parsed.pinned,
|
||||||
|
remindAt: parsed.remindAt ?? null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!created) throw new Error("Note was not created");
|
||||||
|
await syncNoteReminder(tx, {
|
||||||
|
householdId: household.id,
|
||||||
|
noteId: created.id,
|
||||||
|
remindAt: created.remindAt,
|
||||||
|
});
|
||||||
|
return [created];
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/notes");
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNote(input: z.input<typeof updateNoteInput>) {
|
||||||
|
const parsed = updateNoteInput.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessNote(parsed.id, household.id);
|
||||||
|
|
||||||
|
const [note] = await db.transaction(async (tx) => {
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(notes)
|
||||||
|
.set({
|
||||||
|
title: parsed.title,
|
||||||
|
body: parsed.body,
|
||||||
|
pinned: parsed.pinned,
|
||||||
|
remindAt: parsed.remindAt === undefined ? undefined : parsed.remindAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(notes.id, parsed.id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) throw new Error("Note was not updated");
|
||||||
|
if (parsed.remindAt !== undefined) {
|
||||||
|
await syncNoteReminder(tx, {
|
||||||
|
householdId: household.id,
|
||||||
|
noteId: updated.id,
|
||||||
|
remindAt: updated.remindAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [updated];
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/notes");
|
||||||
|
revalidatePath(`/notes/${parsed.id}`);
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNotePinned(input: { id: string; pinned: boolean }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid(), pinned: z.boolean() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessNote(parsed.id, household.id);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(notes)
|
||||||
|
.set({ pinned: parsed.pinned, updatedAt: new Date() })
|
||||||
|
.where(eq(notes.id, parsed.id));
|
||||||
|
|
||||||
|
revalidatePath("/notes");
|
||||||
|
revalidatePath(`/notes/${parsed.id}`);
|
||||||
|
return getNote(parsed.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNote(input: { id: string }) {
|
||||||
|
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
await assertCanAccessNote(parsed.id, household.id);
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.delete(reminders)
|
||||||
|
.where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, parsed.id)));
|
||||||
|
await tx.delete(notes).where(eq(notes.id, parsed.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertCanAccessNote(noteId: string, householdId: string) {
|
||||||
|
if (!(await canAccessNote(noteId, householdId))) throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncNoteReminder(
|
||||||
|
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||||
|
input: { householdId: string; noteId: string; remindAt: Date | null },
|
||||||
|
) {
|
||||||
|
await tx
|
||||||
|
.delete(reminders)
|
||||||
|
.where(and(eq(reminders.entityType, "notes.note"), eq(reminders.entityId, input.noteId)));
|
||||||
|
|
||||||
|
if (!input.remindAt) return;
|
||||||
|
|
||||||
|
await tx.insert(reminders).values({
|
||||||
|
householdId: input.householdId,
|
||||||
|
entityType: "notes.note",
|
||||||
|
entityId: input.noteId,
|
||||||
|
fireAt: input.remindAt,
|
||||||
|
channel: "in_app",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@/lib/db";
|
||||||
|
import { getCurrentSession } from "@/lib/session";
|
||||||
|
import { notes } from "../schema";
|
||||||
|
|
||||||
|
export type NoteDto = {
|
||||||
|
id: string;
|
||||||
|
householdId: string;
|
||||||
|
authorId: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
pinned: boolean;
|
||||||
|
remindAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listNotes(): Promise<NoteDto[]> {
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(eq(notes.householdId, household.id))
|
||||||
|
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
|
||||||
|
|
||||||
|
return rows.map(toNoteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNote(id: string): Promise<NoteDto> {
|
||||||
|
const parsed = z.string().uuid().parse(id);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const [note] = await db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(and(eq(notes.id, parsed), eq(notes.householdId, household.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!note) throw new Error("Note not found");
|
||||||
|
return toNoteDto(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function canAccessNote(noteId: string, householdId: string) {
|
||||||
|
const [note] = await db
|
||||||
|
.select({ id: notes.id })
|
||||||
|
.from(notes)
|
||||||
|
.where(and(eq(notes.id, noteId), eq(notes.householdId, householdId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return !!note;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchNotes(query: string, householdId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: notes.id,
|
||||||
|
title: notes.title,
|
||||||
|
body: notes.body,
|
||||||
|
})
|
||||||
|
.from(notes)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(notes.householdId, householdId),
|
||||||
|
or(sql`${notes.title} ilike ${`%${query}%`}`, sql`${notes.body} ilike ${`%${query}%`}`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
url: `/notes/${row.id}`,
|
||||||
|
excerpt: row.body.slice(0, 160),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWidgetNotes(input: { filter: "pinned" | "all"; limit?: number }) {
|
||||||
|
const parsed = z
|
||||||
|
.object({
|
||||||
|
filter: z.enum(["pinned", "all"]),
|
||||||
|
limit: z.number().int().min(1).max(50).optional(),
|
||||||
|
})
|
||||||
|
.parse(input);
|
||||||
|
const { household } = await getCurrentSession();
|
||||||
|
const conditions = [eq(notes.householdId, household.id)];
|
||||||
|
if (parsed.filter === "pinned") conditions.push(eq(notes.pinned, true));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(notes)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
|
||||||
|
.limit(parsed.limit ?? 10);
|
||||||
|
|
||||||
|
return rows.map(toNoteDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNoteDto(note: typeof notes.$inferSelect): NoteDto {
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
householdId: note.householdId,
|
||||||
|
authorId: note.authorId,
|
||||||
|
title: note.title,
|
||||||
|
body: note.body,
|
||||||
|
pinned: note.pinned,
|
||||||
|
remindAt: note.remindAt?.toISOString() ?? null,
|
||||||
|
createdAt: note.createdAt.toISOString(),
|
||||||
|
updatedAt: note.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
const databaseUrl = process.env["DATABASE_URL"] ?? "postgres://famapp:famapp@localhost:5432/famapp";
|
||||||
|
|
||||||
|
test("notes happy path", async ({ page }) => {
|
||||||
|
const suffix = Date.now().toString();
|
||||||
|
const title = `E2E Note ${suffix}`;
|
||||||
|
const editedTitle = `E2E Updated ${suffix}`;
|
||||||
|
const body = `# Heading ${suffix}
|
||||||
|
|
||||||
|
- Milk
|
||||||
|
- Bread
|
||||||
|
|
||||||
|
<script>window.__famappInjected = true</script>`;
|
||||||
|
const reminder = "2026-05-20T09:30";
|
||||||
|
|
||||||
|
await page.goto("/notes");
|
||||||
|
await expect(page.getByRole("heading", { name: "Notes" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("link", { name: "New note" }).click();
|
||||||
|
await page.getByLabel("Title").fill(title);
|
||||||
|
await page.getByLabel("Body").fill(body);
|
||||||
|
await page.getByLabel("Reminder").fill(reminder);
|
||||||
|
await page.getByRole("button", { name: "Save note" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/notes\/[0-9a-f-]+$/);
|
||||||
|
await expect(page.getByRole("heading", { name: new RegExp(title) })).toBeVisible();
|
||||||
|
await expect(page.getByRole("heading", { name: `Heading ${suffix}` })).toBeVisible();
|
||||||
|
await expect(page.getByRole("complementary").getByText("Milk")).toBeVisible();
|
||||||
|
await expect(page.locator("script", { hasText: "window.__famappInjected" })).toHaveCount(0);
|
||||||
|
await expect
|
||||||
|
.poll(() => page.evaluate(() => Reflect.get(window, "__famappInjected")))
|
||||||
|
.toBeUndefined();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Pin note" }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Unpin note" })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByLabel("Title").fill(editedTitle);
|
||||||
|
await page.getByRole("button", { name: "Save note" }).click();
|
||||||
|
await expect(page.getByRole("heading", { name: new RegExp(editedTitle) })).toBeVisible();
|
||||||
|
|
||||||
|
const noteId = new URL(page.url()).pathname.split("/").pop();
|
||||||
|
expect(noteId).toBeTruthy();
|
||||||
|
|
||||||
|
const sql = postgres(databaseUrl);
|
||||||
|
try {
|
||||||
|
const reminders = await sql`
|
||||||
|
select entity_type, entity_id, fire_at
|
||||||
|
from reminders
|
||||||
|
where entity_type = 'notes.note' and entity_id = ${noteId}
|
||||||
|
`;
|
||||||
|
expect(reminders).toHaveLength(1);
|
||||||
|
expect(reminders[0]?.entity_type).toBe("notes.note");
|
||||||
|
} finally {
|
||||||
|
await sql.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto("/notes");
|
||||||
|
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeVisible();
|
||||||
|
await page.getByRole("link", { name: new RegExp(editedTitle) }).click();
|
||||||
|
await page.getByRole("button", { name: "Delete note" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/notes$/);
|
||||||
|
await expect(page.getByRole("link", { name: new RegExp(editedTitle) })).toBeHidden();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user