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

32 KiB
Raw Blame History

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

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:

/**
 * 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 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:

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 IconNotepad to 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: 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:

// 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: 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:

// 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 }. 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:

// 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 }. 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:

// 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:

  • 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

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:

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.