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.
32 KiB
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.tsfrom a Client Component (it's markedimport "server-only"). - Field allowlist — every write route must sanitize against an explicit field allowlist (matches existing
/api/pb/records/*convention). Hardcodedcollection = "ysterbaard_notes". - NavBar icon rule — inline SVG,
stroke-currentColor,strokeWidth={1.6}. No emoji, no gradients (anti-slop rule perNavBar.tsxheader comment). - Existing patterns — match
app/tasks/page.tsx/app/companies/page.tsxfor the page shape; matchapp/api/pb/records/[id]/route.tsfor 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_notesmust 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—POSTcreate handler.app/api/notes/[id]/route.ts—PATCHupdate +DELETEdelete handler.
Modified files
lib/types.ts— appendYsterbaardNoteinterface.components/NavBar.tsx— insert{ href: "/notes", label: "Notes", icon: IconNotepad }at index 1 ofLINKS; addIconNotepadSVG 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:
YsterbaardNoteinterface (imported by page.tsx and NotesList.tsx). -
Step 1: Read end of
lib/types.ts
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
YsterbaardNoteinterface
Append at the end of lib/types.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
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 inLINKS, one newIconNotepadcomponent)
Interfaces:
-
Consumes: existing
LINKSshape (already defined). -
Produces:
Notestab 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:
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
with:
const LINKS = [
{ href: "/", label: "Overview", icon: IconHome },
{ href: "/notes", label: "Notes", icon: IconNotepad },
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
- Step 2: Append
IconNotepadto the icon set
After the existing IconReceipt function (the last icon defined before the ThemeToggle comment block), insert:
/**
* 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
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:
YsterbaardNotefrom@/lib/types,fetchCollectionfrom@/lib/pb,PageHeaderfrom@/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:
// 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
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:
YsterbaardNotetype from@/lib/types. Expects prop{ initialNotes: YsterbaardNote[] }. Calls:fetch('/api/notes', { method: 'POST', body: JSON.stringify({ title, content }) })→ returnsYsterbaardNote.fetch('/api/notes/' + id, { method: 'PATCH', body: JSON.stringify({ title?, content?, is_done? }) })→ returnsYsterbaardNote.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:
// 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
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 }. Hardcodedcollection = "ysterbaard_notes". Allowlist:title,content,is_done,done_at. -
Produces: created
YsterbaardNoteJSON, or{ error: string }with 400/502. -
Step 1: Create the file
Create app/api/notes/route.ts with:
// 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
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 }. Hardcodedcollection = "ysterbaard_notes". Allowlist:title,content,is_done,done_at.DELETE: path[id].
-
Produces:
PATCH: updatedYsterbaardNoteJSON.DELETE:{ ok: true }.
-
Step 1: Create the file
Create app/api/notes/[id]/route.ts with:
// 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
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
cd "C:\Users\SJ\.minimax-agent\projects\Workflow"
npm run dev
Then in another shell:
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:
-
Notesis the 2nd item in the top nav (afterOverview). -
Notesis the 2nd item in the bottom mobile tab bar (afterOverview). -
The icon is a notepad (folded corner + ruled line), distinct from the clipboard.
-
Step 5: Commit build artifacts only if changed
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:
- Open
https://pb.agent.apphub.co.za/_/. - New collection → name
ysterbaard_notes. - Add fields per the spec:
title(text, required, max 200),content(text, plain),is_done(bool, default false),done_at(date). - Open the API rules:
listRule,viewRule,createRule,updateRule,deleteRule— set each to empty string. - 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 asysterbaard_annual_returnson 2026-09-16).
Once the user confirms the collection exists, the agent does the smoke test in Task 9.
- (user action) Create
ysterbaard_notesin 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:
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.
YsterbaardNotetype → 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.