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.
986 lines
No EOL
32 KiB
Markdown
986 lines
No EOL
32 KiB
Markdown
# 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. |