Workflow-App/Workflow/docs/superpowers/plans/2026-09-16-notes-tab.md
sjcoetzeesj-coder 11d0178ab1 feat: commit Workflow app source; move CIPC credentials to env
Adds the full Next.js app (app/, components/, lib/, scripts/, assets/,
docs/, Dockerfile, compose, configs) to version control.

Security:
- CIPC BizPortal customer-code logins (Login 1 + Login 3) now read
  CIPC_LOGIN1_CODE / CIPC_LOGIN1_PASSWORD / CIPC_LOGIN3_CODE /
  CIPC_LOGIN3_PASSWORD from .env.local instead of source.
- BizPortal personal login in apply-bizportal route reads
  BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD.
- Added .env.example as the committed template.

.gitignore excludes .env*, node_modules, .next, logs, sqlite, Playwright
storage_state.json (live CIPC cookies), scratch dirs, client PDFs and
debug screenshots.
2026-10-18 15:21:45 +02:00

986 lines
No EOL
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Notes Tab Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a PB-backed `Notes` tab (second in nav, after Overview) to the Workflow app — create / list / toggle-done / edit / delete with All / Open / Done filter chips.
**Architecture:** Server Component page (`app/notes/page.tsx`) loads notes via `fetchCollection`, passes them to a Client Component (`app/notes/NotesList.tsx`) that owns UI state and calls dedicated `/api/notes/*` JSON routes that wrap `lib/pb.ts` helpers. NavBar reads a single `LINKS` array — one new entry covers both desktop top nav and mobile bottom tab bar.
**Tech Stack:** Next.js 14 (App Router), TypeScript, Tailwind, PocketBase (existing server-only `lib/pb.ts`).
## Global Constraints
- **No new dependencies** — use only what's already in `package.json`.
- **Server-only PB access** — never import `lib/pb.ts` from a Client Component (it's marked `import "server-only"`).
- **Field allowlist** — every write route must sanitize against an explicit field allowlist (matches existing `/api/pb/records/*` convention). Hardcoded `collection = "ysterbaard_notes"`.
- **NavBar icon rule** — inline SVG, `stroke-currentColor`, `strokeWidth={1.6}`. No emoji, no gradients (anti-slop rule per `NavBar.tsx` header comment).
- **Existing patterns** — match `app/tasks/page.tsx` / `app/companies/page.tsx` for the page shape; match `app/api/pb/records/[id]/route.ts` for the PATCH/DELETE route shape.
- **No test infrastructure in this repo** — verification is `npx tsc --noEmit` + `npm run build` + manual dev-server check.
- **PB collection prerequisite** — `ysterbaard_notes` must already exist on PB (user creates it manually in admin UI per the design spec).
---
## File Structure
### New files
- `app/notes/page.tsx` — Server Component, loads notes, renders `<NotesList>`.
- `app/notes/NotesList.tsx` — Client Component, owns CRUD UI state, optimistic updates, filter chips.
- `app/api/notes/route.ts` — `POST` create handler.
- `app/api/notes/[id]/route.ts` — `PATCH` update + `DELETE` delete handler.
### Modified files
- `lib/types.ts` — append `YsterbaardNote` interface.
- `components/NavBar.tsx` — insert `{ href: "/notes", label: "Notes", icon: IconNotepad }` at index 1 of `LINKS`; add `IconNotepad` SVG component.
No files deleted. No env changes.
---
## Task 1: Add `YsterbaardNote` type
**Files:**
- Modify: `lib/types.ts` (append new interface at end)
**Interfaces:**
- Consumes: nothing.
- Produces: `YsterbaardNote` interface (imported by page.tsx and NotesList.tsx).
- [ ] **Step 1: Read end of `lib/types.ts`**
```bash
tail -n 30 "C:\Users\SJ\.minimax-agent\projects\Workflow\lib\types.ts"
```
Expected: see the closing braces of the last interface, with a trailing newline.
- [ ] **Step 2: Append the `YsterbaardNote` interface**
Append at the end of `lib/types.ts`:
```ts
/**
* PocketBase collection: ysterbaard_notes
* Plain-text personal checklist items. No relations to companies / directors
* in v1. `done_at` is set server-side when `is_done` flips to true, and
* cleared when it flips back to false.
*/
export interface YsterbaardNote {
[key: string]: unknown;
id: string;
collectionId: string;
collectionName: string;
title: string;
content: string | null;
is_done: boolean;
done_at: string | null;
created: string;
updated: string;
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: exit 0, no output. (We're only adding an unused interface, so there should be no type errors.)
- [ ] **Step 4: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add lib/types.ts
git commit -m "feat(notes): add YsterbaardNote type"
```
---
## Task 2: Add Notes tab to NavBar
**Files:**
- Modify: `components/NavBar.tsx` (one new entry in `LINKS`, one new `IconNotepad` component)
**Interfaces:**
- Consumes: existing `LINKS` shape (already defined).
- Produces: `Notes` tab visible at index 1 in both desktop top nav and mobile bottom tab bar.
- [ ] **Step 1: Insert the Notes entry at index 1 of `LINKS`**
In `components/NavBar.tsx`, replace:
```ts
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
```
with:
```ts
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/notes", label: "Notes", icon: IconNotepad },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
```
- [ ] **Step 2: Append `IconNotepad` to the icon set**
After the existing `IconReceipt` function (the last icon defined before the `ThemeToggle` comment block), insert:
```tsx
/**
* Notepad glyph for the Notes tab. Rounded rectangle with a horizontal
* ruled line and a folded top-right corner — distinguishes it from
* IconClipboard (task list with a clip) and IconDocument (sealed page).
*/
function IconNotepad({ className }: IconProps): ReactNode {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8z" />
<polyline points="14,2 14,8 19,8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="14" y2="17" />
</svg>
);
}
```
- [ ] **Step 3: Verify TypeScript compiles**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: exit 0, no output.
- [ ] **Step 4: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add components/NavBar.tsx
git commit -m "feat(notes): add Notes tab to NavBar at index 1"
```
---
## Task 3: Create `app/notes/page.tsx` (Server Component)
**Files:**
- Create: `app/notes/page.tsx`
**Interfaces:**
- Consumes: `YsterbaardNote` from `@/lib/types`, `fetchCollection` from `@/lib/pb`, `PageHeader` from `@/components/PageHeader`.
- Produces: a default-exported Server Component that loads notes and renders `<NotesList initialNotes={notes} />`.
- [ ] **Step 1: Create the file**
Create `app/notes/page.tsx` with:
```tsx
// app/notes/page.tsx
// Server Component. Loads all notes from PocketBase and hands them to
// the Client Component <NotesList> which owns the CRUD UI state.
//
// Notes live in the ysterbaard_notes collection. If the collection is
// missing (PB returns 404) the page renders an actionable error so the
// user knows to create it in the admin UI.
import { fetchCollection } from "@/lib/pb";
import type { YsterbaardNote } from "@/lib/types";
import PageHeader from "@/components/PageHeader";
import NotesList from "./NotesList";
export const dynamic = "force-dynamic";
async function loadNotes(): Promise<YsterbaardNote[]> {
try {
const list = await fetchCollection<YsterbaardNote>("ysterbaard_notes", {
page: 1,
perPage: 500,
sort: "-created",
});
return list.items ?? [];
} catch (err) {
// Surface the failure to the client so the page can show guidance.
const msg = err instanceof Error ? err.message : "Unknown PB error";
if (msg.includes("404") || msg.toLowerCase().includes("not found")) {
throw new Error(
"The ysterbaard_notes collection is missing on PocketBase. " +
"Open https://pb.agent.apphub.co.za/_/ and create it before " +
"using this tab. See docs/superpowers/specs/2026-09-16-notes-tab-design.md for the field spec.",
);
}
throw err;
}
}
export default async function NotesPage() {
const notes = await loadNotes();
return (
<>
<PageHeader
eyebrow="Quick notes"
title="Notes"
subtitle="Things to do. Tick when done, edit, or delete."
/>
<NotesList initialNotes={notes} />
</>
);
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: error TS2307 "Cannot find module './NotesList'" — expected, because NotesList doesn't exist yet. Don't fix here; it gets resolved in Task 4.
- [ ] **Step 3: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add app/notes/page.tsx
git commit -m "feat(notes): add /notes server page"
```
---
## Task 4: Create `app/notes/NotesList.tsx` (Client Component)
**Files:**
- Create: `app/notes/NotesList.tsx`
**Interfaces:**
- Consumes: `YsterbaardNote` type from `@/lib/types`. Expects prop `{ initialNotes: YsterbaardNote[] }`. Calls:
- `fetch('/api/notes', { method: 'POST', body: JSON.stringify({ title, content }) })` → returns `YsterbaardNote`.
- `fetch('/api/notes/' + id, { method: 'PATCH', body: JSON.stringify({ title?, content?, is_done? }) })` → returns `YsterbaardNote`.
- `fetch('/api/notes/' + id, { method: 'DELETE' })` → returns `{ ok: true }`.
- Produces: full CRUD UI with filter chips.
- [ ] **Step 1: Create the file**
Create `app/notes/NotesList.tsx` with:
```tsx
// app/notes/NotesList.tsx
// Client Component. Owns the notes UI state (filter, add-form, edit,
// optimistic CRUD). All server calls go through /api/notes/* JSON routes.
"use client";
import { useMemo, useState } from "react";
import type { YsterbaardNote } from "@/lib/types";
type Filter = "all" | "open" | "done";
interface Props {
initialNotes: YsterbaardNote[];
}
export default function NotesList({ initialNotes }: Props) {
const [notes, setNotes] = useState<YsterbaardNote[]>(initialNotes);
const [filter, setFilter] = useState<Filter>("all");
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Sort: open first (created desc), then done (done_at desc).
const sorted = useMemo(() => {
const open = notes
.filter((n) => !n.is_done)
.slice()
.sort((a, b) => (a.created < b.created ? 1 : -1));
const done = notes
.filter((n) => n.is_done)
.slice()
.sort((a, b) => {
const ad = a.done_at ?? a.updated ?? a.created;
const bd = b.done_at ?? b.updated ?? b.created;
return ad < bd ? 1 : -1;
});
return [...open, ...done];
}, [notes]);
const filtered = useMemo(() => {
if (filter === "open") return sorted.filter((n) => !n.is_done);
if (filter === "done") return sorted.filter((n) => n.is_done);
return sorted;
}, [sorted, filter]);
function refresh(updated: YsterbaardNote) {
setNotes((prev) => prev.map((n) => (n.id === updated.id ? updated : n)));
}
async function toggleDone(n: YsterbaardNote) {
const nextDone = !n.is_done;
// Optimistic
setNotes((prev) =>
prev.map((x) =>
x.id === n.id
? { ...x, is_done: nextDone, done_at: nextDone ? new Date().toISOString() : null }
: x,
),
);
try {
const res = await fetch(`/api/notes/${encodeURIComponent(n.id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_done: nextDone }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = (await res.json()) as YsterbaardNote;
refresh(updated);
} catch (e) {
setError(`Could not update note: ${e instanceof Error ? e.message : "unknown"}`);
// Rollback
setNotes((prev) =>
prev.map((x) =>
x.id === n.id
? { ...x, is_done: !nextDone, done_at: !nextDone ? new Date().toISOString() : null }
: x,
),
);
}
}
async function deleteNote(n: YsterbaardNote) {
if (!confirm(`Delete "${n.title}"?`)) return;
const snapshot = notes;
setNotes((prev) => prev.filter((x) => x.id !== n.id));
try {
const res = await fetch(`/api/notes/${encodeURIComponent(n.id)}`, { method: "DELETE" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
} catch (e) {
setError(`Could not delete note: ${e instanceof Error ? e.message : "unknown"}`);
setNotes(snapshot);
}
}
async function createNote(payload: { title: string; content: string }) {
const res = await fetch("/api/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = (await res.json()) as YsterbaardNote;
setNotes((prev) => [created, ...prev]);
}
async function updateNote(id: string, payload: { title?: string; content?: string }) {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated = (await res.json()) as YsterbaardNote;
refresh(updated);
}
return (
<div className="container-x py-6 space-y-4">
{error && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
{error}
<button className="ml-2 underline" onClick={() => setError(null)}>
dismiss
</button>
</div>
)}
<div className="flex items-center justify-between gap-3">
<div className="inline-flex rounded-md border border-border bg-surface-warm p-0.5">
{(["all", "open", "done"] as Filter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
data-od-id={`notes-filter-${f}`}
className={
"px-3 py-1 text-xs font-semibold rounded " +
(filter === f
? "bg-surface text-fg"
: "text-muted hover:text-fg")
}
>
{f === "all" ? "All" : f === "open" ? "Open" : "Done"}
</button>
))}
</div>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
data-od-id="notes-add"
className="inline-flex items-center gap-1.5 rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold"
>
+ Add note
</button>
)}
</div>
{adding && (
<AddForm
onCancel={() => setAdding(false)}
onSubmit={async (payload) => {
try {
await createNote(payload);
setAdding(false);
} catch (e) {
setError(`Could not create note: ${e instanceof Error ? e.message : "unknown"}`);
}
}}
/>
)}
{filtered.length === 0 ? (
<EmptyState filter={filter} />
) : (
<ul className="space-y-2">
{filtered.map((n) =>
editingId === n.id ? (
<EditRow
key={n.id}
note={n}
onCancel={() => setEditingId(null)}
onSave={async (payload) => {
try {
await updateNote(n.id, payload);
setEditingId(null);
} catch (e) {
setError(`Could not save note: ${e instanceof Error ? e.message : "unknown"}`);
}
}}
/>
) : (
<Row
key={n.id}
note={n}
onToggle={() => toggleDone(n)}
onEdit={() => setEditingId(n.id)}
onDelete={() => deleteNote(n)}
/>
),
)}
</ul>
)}
</div>
);
}
function AddForm({
onCancel,
onSubmit,
}: {
onCancel: () => void;
onSubmit: (payload: { title: string; content: string }) => Promise<void>;
}) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
return (
<div className="rounded-md border border-border bg-surface p-4 space-y-2">
<input
autoFocus
type="text"
placeholder="Note title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm font-semibold text-fg placeholder:text-muted"
data-od-id="notes-add-title"
/>
<textarea
placeholder="Details (optional)"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm text-fg placeholder:text-muted"
data-od-id="notes-add-content"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="px-3 py-1.5 text-sm font-semibold text-muted hover:text-fg"
>
Cancel
</button>
<button
type="button"
disabled={busy || !title.trim()}
onClick={async () => {
setBusy(true);
try {
await onSubmit({ title: title.trim(), content: content.trim() });
} finally {
setBusy(false);
}
}}
className="rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold disabled:opacity-50"
data-od-id="notes-add-save"
>
Save
</button>
</div>
</div>
);
}
function Row({
note,
onToggle,
onEdit,
onDelete,
}: {
note: YsterbaardNote;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<li
className={
"rounded-md border border-border bg-surface px-3 py-2.5 flex items-start gap-3 " +
(note.is_done ? "opacity-70" : "")
}
data-od-id={`notes-row-${note.id}`}
>
<input
type="checkbox"
checked={note.is_done}
onChange={onToggle}
aria-label={note.is_done ? "Mark as not done" : "Mark as done"}
className="mt-1 h-4 w-4 shrink-0 cursor-pointer"
data-od-id={`notes-toggle-${note.id}`}
/>
<div className="flex-1 min-w-0">
<div
className={
"font-semibold text-fg break-words " +
(note.is_done ? "line-through text-muted" : "")
}
>
{note.title}
</div>
{note.content ? (
<div
className={
"text-sm mt-0.5 break-words whitespace-pre-wrap " +
(note.is_done ? "text-muted line-through" : "text-muted")
}
>
{note.content}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onEdit}
className="px-2 py-1 text-xs font-semibold text-muted hover:text-fg"
data-od-id={`notes-edit-${note.id}`}
>
Edit
</button>
<button
type="button"
onClick={onDelete}
className="px-2 py-1 text-xs font-semibold text-muted hover:text-red-700"
data-od-id={`notes-delete-${note.id}`}
>
Delete
</button>
</div>
</li>
);
}
function EditRow({
note,
onCancel,
onSave,
}: {
note: YsterbaardNote;
onCancel: () => void;
onSave: (payload: { title: string; content: string }) => Promise<void>;
}) {
const [title, setTitle] = useState(note.title);
const [content, setContent] = useState(note.content ?? "");
const [busy, setBusy] = useState(false);
return (
<li className="rounded-md border border-accent bg-surface p-4 space-y-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm font-semibold text-fg"
data-od-id={`notes-edit-title-${note.id}`}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm text-fg"
data-od-id={`notes-edit-content-${note.id}`}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="px-3 py-1.5 text-sm font-semibold text-muted hover:text-fg"
>
Cancel
</button>
<button
type="button"
disabled={busy || !title.trim()}
onClick={async () => {
setBusy(true);
try {
await onSave({ title: title.trim(), content: content.trim() });
} finally {
setBusy(false);
}
}}
className="rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold disabled:opacity-50"
data-od-id={`notes-edit-save-${note.id}`}
>
Save
</button>
</div>
</li>
);
}
function EmptyState({ filter }: { filter: Filter }) {
const msg =
filter === "open"
? "No open notes. Great work."
: filter === "done"
? "No done notes yet."
: "No notes yet. Click + Add note to get started.";
return (
<div className="rounded-md border border-dashed border-border bg-surface-warm px-4 py-8 text-center text-sm text-muted">
{msg}
</div>
);
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: error TS2307 "Cannot find module '@/app/api/notes/route'" — expected, the API routes don't exist yet. Don't fix here; resolved in Tasks 5–6.
- [ ] **Step 3: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add app/notes/NotesList.tsx
git commit -m "feat(notes): add NotesList client component with CRUD + filters"
```
---
## Task 5: Create POST handler `app/api/notes/route.ts`
**Files:**
- Create: `app/api/notes/route.ts`
**Interfaces:**
- Consumes: JSON `{ title: string, content?: string }`. Hardcoded `collection = "ysterbaard_notes"`. Allowlist: `title`, `content`, `is_done`, `done_at`.
- Produces: created `YsterbaardNote` JSON, or `{ error: string }` with 400/502.
- [ ] **Step 1: Create the file**
Create `app/api/notes/route.ts` with:
```ts
// app/api/notes/route.ts
// POST /api/notes — create a new note.
// Hardcoded collection = "ysterbaard_notes". Field-allowlisted.
import { NextRequest, NextResponse } from "next/server";
import { createRecord } from "@/lib/pb";
import type { YsterbaardNote } from "@/lib/types";
export const dynamic = "force-dynamic";
const COLLECTION = "ysterbaard_notes";
const FIELD_ALLOWLIST = ["title", "content", "is_done", "done_at"] as const;
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const title = typeof body.title === "string" ? body.title.trim() : "";
const content = typeof body.content === "string" ? body.content.trim() : "";
if (!title) {
return NextResponse.json({ error: "title is required" }, { status: 400 });
}
const sanitised: Record<string, unknown> = { title };
if (content) sanitised.content = content;
try {
const record = await createRecord<YsterbaardNote>(COLLECTION, sanitised);
return NextResponse.json(record, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown PB error";
console.error("[notes create]", message);
return NextResponse.json({ error: message }, { status: 502 });
}
}
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: exit 0. (The other errors from Tasks 3–4 only resolve once both API route files exist.)
- [ ] **Step 3: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add app/api/notes/route.ts
git commit -m "feat(notes): add POST /api/notes handler"
```
---
## Task 6: Create PATCH + DELETE handlers `app/api/notes/[id]/route.ts`
**Files:**
- Create: `app/api/notes/[id]/route.ts`
**Interfaces:**
- Consumes:
- `PATCH`: JSON `{ title?: string, content?: string, is_done?: boolean }`. Hardcoded `collection = "ysterbaard_notes"`. Allowlist: `title`, `content`, `is_done`, `done_at`.
- `DELETE`: path `[id]`.
- Produces:
- `PATCH`: updated `YsterbaardNote` JSON.
- `DELETE`: `{ ok: true }`.
- [ ] **Step 1: Create the file**
Create `app/api/notes/[id]/route.ts` with:
```ts
// app/api/notes/[id]/route.ts
// PATCH /api/notes/[id] — update an existing note (edit text, toggle done).
// DELETE /api/notes/[id] — delete a note.
// Hardcoded collection = "ysterbaard_notes". Field-allowlisted.
import { NextRequest, NextResponse } from "next/server";
import { updateRecord, deleteRecord } from "@/lib/pb";
import type { YsterbaardNote } from "@/lib/types";
export const dynamic = "force-dynamic";
const COLLECTION = "ysterbaard_notes";
const FIELD_ALLOWLIST = ["title", "content", "is_done", "done_at"] as const;
export async function PATCH(
req: NextRequest,
{ params }: { params: { id: string } },
) {
const id = params.id;
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const sanitised: Record<string, unknown> = {};
if (typeof body.title === "string") {
const t = body.title.trim();
if (!t) return NextResponse.json({ error: "title cannot be empty" }, { status: 400 });
sanitised.title = t;
}
if (typeof body.content === "string") {
sanitised.content = body.content.trim();
}
if (typeof body.is_done === "boolean") {
sanitised.is_done = body.is_done;
sanitised.done_at = body.is_done ? new Date().toISOString() : null;
}
// Refuse empty patches so we don't issue pointless PATCH calls.
if (Object.keys(sanitised).length === 0) {
return NextResponse.json({ error: "no editable fields supplied" }, { status: 400 });
}
try {
const updated = await updateRecord<YsterbaardNote>(COLLECTION, id, sanitised);
return NextResponse.json(updated, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown PB error";
console.error("[notes patch]", id, message);
return NextResponse.json({ error: message }, { status: 502 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: { id: string } },
) {
const id = params.id;
try {
await deleteRecord(COLLECTION, id);
return NextResponse.json({ ok: true }, { status: 200 });
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown PB error";
console.error("[notes delete]", id, message);
return NextResponse.json({ error: message }, { status: 502 });
}
}
```
- [ ] **Step 2: Verify TypeScript compiles across the whole project**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx tsc --noEmit`
Expected: exit 0, no output.
- [ ] **Step 3: Commit**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add app/api/notes/[id]/route.ts
git commit -m "feat(notes): add PATCH + DELETE /api/notes/[id] handlers"
```
---
## Task 7: Lint + build + smoke-test in browser
**Files:**
- Touch: nothing.
- [ ] **Step 1: Run linter**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npx next lint --dir app/notes --dir app/api/notes --dir components --dir lib`
Expected: no errors. Warnings are OK if they exist in existing files.
- [ ] **Step 2: Run production build**
Run: `cd "C:\Users\SJ\.minimax-agent\projects\Workflow" ; npm run build`
Expected: build succeeds. The `/notes` route appears in the build output table.
- [ ] **Step 3: Smoke-test the dev server**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
npm run dev
```
Then in another shell:
```bash
curl -i http://localhost:3000/notes
```
Expected: HTTP 200 with the Notes page HTML. (The dev server default port per `package.json` — check `next.config.mjs` / `package.json` scripts if it's not 3000.)
If the PB collection is missing, the page will render the error message telling you to create it. That's the expected failure mode at this stage — Task 8 below is the user-facing fix.
- [ ] **Step 4: Verify nav placement**
Visit `http://localhost:3000` in a browser and confirm:
- `Notes` is the 2nd item in the top nav (after `Overview`).
- `Notes` is the 2nd item in the bottom mobile tab bar (after `Overview`).
- The icon is a notepad (folded corner + ruled line), distinct from the clipboard.
- [ ] **Step 5: Commit build artifacts only if changed**
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git status --short .next
```
Expected: `.next/` is gitignored. No commit needed unless `package-lock.json` or other tracked files moved.
---
## Task 8: User creates PB collection (manual, outside this plan)
This task is **not for the agent**. The user must:
1. Open `https://pb.agent.apphub.co.za/_/`.
2. New collection → name `ysterbaard_notes`.
3. Add fields per the spec: `title` (text, required, max 200), `content` (text, plain), `is_done` (bool, default false), `done_at` (date).
4. Open the API rules: `listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule` — set each to **empty string**.
5. If after saving the rules the in-app server gets 403 on `/notes`, PATCH the collection once via the admin UI to "wake up" the rules (same fix as `ysterbaard_annual_returns` on 2026-09-16).
Once the user confirms the collection exists, the agent does the smoke test in Task 9.
- [ ] **(user action)** Create `ysterbaard_notes` in PB admin UI.
---
## Task 9: End-to-end CRUD smoke test
- [ ] **Step 1: Confirm the dev server still runs and the page loads**
Visit `http://localhost:3000/notes`. Expected: the page renders the empty state ("No notes yet. Click + Add note to get started.").
- [ ] **Step 2: Create a note**
Click `+ Add note`, type a title (e.g. "Renew BizPortal subscription"), optionally a content body, click Save. Expected: new row appears at top.
- [ ] **Step 3: Toggle done**
Click the checkbox on a note. Expected: row moves to bottom, title strikes through, becomes dim.
- [ ] **Step 4: Edit**
Click `Edit` on a row. Modify the title. Click Save. Expected: title updated in place, edit panel collapses.
- [ ] **Step 5: Filter**
Click `Open` chip. Expected: done rows are hidden. Click `Done` chip. Expected: only done rows show. Click `All` chip. Expected: everything shows.
- [ ] **Step 6: Delete**
Click `Delete` on a row, confirm the dialog. Expected: row disappears.
- [ ] **Step 7: Commit any docs updates**
If during smoke test you noticed copy / wording that should be tweaked in the design spec, update both the spec and this plan in one commit:
```bash
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
git add docs/superpowers/specs/2026-09-16-notes-tab-design.md
git add docs/superpowers/plans/2026-09-16-notes-tab.md
git commit -m "docs(notes): spec/plan tweaks after smoke test"
```
---
## Self-Review Notes
- **Spec coverage:**
- Server Component page → Task 3.
- Client Component list with filter chips → Task 4.
- POST handler → Task 5.
- PATCH + DELETE handlers → Task 6.
- NavBar entry + icon → Task 2.
- `YsterbaardNote` type → Task 1.
- Verification → Tasks 7 + 9.
- **Placeholder scan:** no TBD / TODO / vague steps found.
- **Type consistency:** `YsterbaardNote` (Task 1) is consumed by page.tsx (Task 3), NotesList.tsx (Task 4), and `[id]/route.ts` (Task 6). Same field names used everywhere (`title`, `content`, `is_done`, `done_at`).
- **Commit granularity:** each task is its own commit, scoped to one concern. Easy to revert if a single task fails.