# 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 ``. - `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 ( ); } ``` - [ ] **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 ``. - [ ] **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 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 { try { const list = await fetchCollection("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 ( <> ); } ``` - [ ] **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(initialNotes); const [filter, setFilter] = useState("all"); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); const [error, setError] = useState(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 (
{error && (
{error}
)}
{(["all", "open", "done"] as Filter[]).map((f) => ( ))}
{!adding && ( )}
{adding && ( 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 ? ( ) : (
    {filtered.map((n) => editingId === n.id ? ( 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"}`); } }} /> ) : ( toggleDone(n)} onEdit={() => setEditingId(n.id)} onDelete={() => deleteNote(n)} /> ), )}
)}
); } function AddForm({ onCancel, onSubmit, }: { onCancel: () => void; onSubmit: (payload: { title: string; content: string }) => Promise; }) { const [title, setTitle] = useState(""); const [content, setContent] = useState(""); const [busy, setBusy] = useState(false); return (
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" />