Architecture review & code critique

The z-planner Dossier

Every line of a hand-built Next.js Kanban planner, read and judged against 2026 standards by a fleet of 93 AI review agents. The good, the bad, and the Dispatch<any>.

8,239
lines of TS/TSX
117
commits
19
API routes
32
reducer actions
138
issues found
64
strengths found

01What it is

z-planner is a Kanban-style task planner — boards, columns, drag-and-drop cards, subtasks, color-coded categories, an archive, search and category filters — built entirely by hand by one developer between September 2023 and July 2024.

You sign in with Clerk, land on a board, and drag cards between columns like Trello. Cards open into a dialog with notes and a Notion-style subtask editor (Enter inserts, Backspace-on-empty deletes, arrow keys move focus). Completing a card moves it to the bottom of its column; the next visit archives it. A settings page manages boards, columns, and categories, each guarded by confirmation dialogs. Under the hood it is a single-page app: one initial fetch pulls the user's entire planner into a normalized client-side store, and every interaction updates the UI optimistically before persisting through a REST API to a single MongoDB document per user.

Next.js 14 (App Router) TypeScript strict React 18 Clerk MongoDB + Mongoose @hello-pangea/dnd shadcn/ui + Radix Tailwind CSS immer zod + react-hook-form axios sonner

The honest banner

The codebase contains a (currently disabled) news banner that reads: “Do not expect anything to work or your data to be saved.” As you are about to see, this is the most accurate line of documentation in the repository — and also far too modest, because most things do work, smoothly, in the happy path. This dossier is largely the story of the unhappy paths.

02How it's built

Architecturally, z-planner is a classic 2023-era client-side SPA wearing an App Router folder structure. The root layout is the only Server Component in the entire route tree — everything else, both layouts and all five pages, is 'use client'.

The lifecycle of every interaction

BROWSER (CLIENT) NEXT.JS SERVER MONGODB UI event: drag card / type title / check subtask components/planner/** (all 'use client') mutation util (1 of 23 near-identical files) utils/plannerUtils/** — computes new order arrays optimistic dispatch UI updates instantly axios + Bearer token fire-and-forget, no await PlannerContext — one giant normalized store useReducer + immer · 32 actions · 5 entity maps ~37 consumers · zero memoization anywhere .catch → dispatch('backendErrorOccurred') a flag that NO component ever reads — dead end clerkMiddleware protects /boards(.*) at the edge withMiddleware(handler) withAuth ∘ withDbConnect ⚠ errors return HTTP 200 route handler (1 of 19) no body validation, no zod dot-path $set on user's document GET /api/planner ⚠ writes on every read (archiving) returns the user's entire world 1 document per user boards columns taskCards subTasks categories boardOrder ⚠ no index on clerkUserId atomic per-user writes for free
write path (optimistic, fire-and-forget) read path (one fetch, whole account) error path (a bridge to nowhere)

The single-document data model — the smartest decision in the codebase

The entire planner lives in one MongoDB document per user: five Map fields of flat entities, plus ID arrays for ordering. It's a Redux normalized store, persisted. No joins, no populate(), no cross-collection transactions — and dragging a card across two columns is one atomic $set. The client store and the database have the same shape, so the GET endpoint is effectively a store-hydration call.

{
  clerkUserId: "user_2f...",  // tenancy = this one filter, on every query
  boardOrder: ["b1", "b2"],
  boards:    { b1: { name, columns: ["c1","c2"], categories: [...] } },
  columns:   { c1: { name, taskCards: ["t1","t2"] } },
  taskCards: { t1: { title, content, category, status, subTasks: ["s1"] } },
  subTasks:  { s1: { title, checked } },
  categories: { ... }  // ids are 17-char client-minted nanoids, _id: false everywhere
}

Eleven components deep

The render path from the page to a single subtask input crosses eleven components. Component decomposition is genuinely disciplined — each file does one thing — but every level subscribes to the same global context, so they all re-render together.

└─ BoardPage app/boards/[boardId]/page.tsx
  └─ Board + PlannerFiltersProvider
    └─ TaskColumns DragDropContext lives here
      └─ TaskColumn Draggable (columns reorder horizontally)
        └─ ColumnTasks Droppable + inline filtering
          └─ TaskCard Dialog + ContextMenu + DropdownMenu, every card
            └─ TaskCardWrapper drag handle = the whole card
              └─ TaskCardDialog
                └─ EditableSubTasks createPortal hack for dnd-in-dialog
                  └─ EditableSubTask
                    └─ Input focus ring removed, naturally

By the numbers

LayerShape
Pages5 pages, 2 layouts, 0 loading.tsx / error.tsx / not-found.tsx. 6 of 7 app-route files are 'use client'.
API19 route files, 20 handlers (1 GET, 5 POST, 9 PATCH, 5 DELETE), 530 lines, all wrapped in the same withMiddleware.
State2 reducer stores (planner + filters), 32 + 3 actions, 5 normalized entity maps, ~37 consuming components.
Mutations23 near-identical util files (940 lines): optimistic dispatch → axios → identical 4-line catch. 3 are debounced at 500ms.
Components98 files: 55 planner, 8 global, 34 shadcn/Aceternity primitives (12 of which have zero importers), 1 orphaned theme provider.
Code split8,239 lines total: 2,889 vendored UI boilerplate, 5,350 hand-written.

03Scorecard

Nine review dimensions, scored against modern (2026) Next.js standards — React Server Components, Server Actions, typed boundaries, accessible-by-default UI. Built in 2023–24, judged by 2026 rules: the scores are harsh by construction.

Code Quality & DXsymmetric structure, copy-paste abstraction
4.5
TypeScriptstrict mode without strict thinking
4.5
Next.js StandardsApp Router shell, SPA soul
4.0
React & Statesolid reducer core, zero memoization
4.0
Securitywatertight tenancy, leaked credentials
4.0
Performancegreat network discipline, render storms
4.0
API & Backendsmart data model, broken plumbing
3.5
UX & Accessibilitycaring UX, mouse-only in the core flows
3.5
Correctnesshappy path only; failure is invisible
3.0

Overall: 3.9 / 10 against 2026 standards — with the repeated caveat from nearly every reviewer: “for a solo 2023–24 build, the instincts are better than average.”

04The Good

The reviewers logged 64 genuine strengths. These are the ones that show real engineering judgment — decisions many production codebases get wrong.

design

The single-document model

One MongoDB document per user makes the hottest multi-entity write — dragging a card across columns — atomic without transactions, and makes cross-tenant IDOR structurally impossible: every query is scoped to { clerkUserId }, so possessing someone else's card ID gets you nothing.

design

Properly normalized state

Five ID-keyed entity maps plus order arrays — textbook Redux normalization, hand-rolled with useReducer + immer. No entity duplication, O(1) lookups, reordering is an array swap. The server returns data already in store shape.

design

Idempotent reorder writes

Drag mutations send the complete final ID array, not from/to deltas. The server $sets it wholesale — replaying a retried request converges to the same state. This is the right consistency model, chosen apparently by instinct.

react

State/dispatch context split

Separate PlannerContext and PlannerDispatchContext — the canonical optimization that lets dispatch-only consumers skip re-renders. Done in both stores. (Undone by the lack of memoization elsewhere, but the idiom is correct.)

react

Derived state stays derived

Progress bars, filtered lists, and category counts are computed in render from canonical state, never duplicated into useState. Stable entity IDs are used as keys in every drag-critical list. Error boundary actually wired, with imperative showBoundary on fetch failure.

backend

Canonical serverless Mongoose caching

dbConnect caches the connection promise on global with bufferCommands: false and promise-reset on failure — the officially recommended pattern, implemented correctly.

backend

Composable API middleware

withMiddleware = withAuth(withDbConnect(handler)) applied to all 19 routes with total consistency. Identity comes exclusively from server-side Clerk auth() — never from the request body. No route can forget to authenticate.

perf

Network discipline

Exactly one PATCH per drag. Text edits debounced at 500ms. Lodash imported modularly. framer-motion confined to the landing page — the board itself runs zero animation libraries. Fonts self-hosted via next/font.

ux

Confirmation culture

Every destructive action gates behind an AlertDialog with explanatory copy. Disabled delete buttons get tooltips explaining why ("A board with columns cannot be deleted."). New users get a real welcome flow, not an empty screen.

ux

The subtask editor

Enter inserts a subtask below, Backspace-on-empty deletes, ArrowUp/Down move focus — a Notion-style keyboard editing flow most hobby Kanban apps never attempt.

ts

Strict mode, honestly kept

"strict": true with almost no @ts-ignore, ~5 narrow casts in the whole app, string-literal unions instead of enums, and textbook zod + react-hook-form schema-first typing in all 8 forms.

dx

Symmetric, predictable structure

All five domains (board/card/category/column/subTask) follow identical addNew*/change*/delete* naming. Path aliases everywhere, zero TODO/FIXME debt, and comments that record reasoning — why drag state lives where it does — not restatements of code.

05The Bad

138 issues survived adversarial verification (every major finding was independently re-checked against the code by a second agent — none were refuted). Six are critical. The pattern across all of them: the happy path was built with care; the failure path was never built at all.

critical resolved

Live database credentials in git history

A real MongoDB Atlas username + password was committed in backend/.env and later "deleted" — but deleting a file does not remove it from history, and the repo is public. Resolved same-day: the credential was tested and found dead (the Atlas cluster it pointed at no longer exists), and the history has since been scrubbed with git filter-repo and force-pushed.

git history · scrubbed June 10, 2026
critical

Every API response is HTTP 200 — including errors

NextResponse.json({ status: 401 }) puts the status code in the response body; the real HTTP status stays 200. Universal across all 19 routes. Axios only rejects on non-2xx, so the client's error handling is unreachable for auth failures and server errors.

lib/middleware.ts:41 · all 19 routes
critical

Failed saves are invisible: no rollback, write-only error flag

All 23 mutation utils catch errors by setting backendErrorOccurred — a flag no component ever reads. No rollback, no retry, no error toast; meanwhile success toasts fire before the request. A failed write shows a green checkmark and loses your data on next reload.

plannerReducer.tsx:10 · all of utils/plannerUtils/**
critical

The shared debounce that eats your edits

Title/content edits share a single module-level lodash debounce. Edit card A, then click into card B within 500ms: A's pending PATCH is cancelled — replaced by B's. A's title silently never persists. Three files carry the same copy-pasted bug.

changeCardTitle.ts:6 · changeCardContent.ts · changeSubTaskTitle.ts
critical

The core interaction is mouse-only

Opening a task card cannot be done with a keyboard: the DialogTrigger sits on a plain div, and the only focusable element lifts the card for dragging instead. Adding a card is an onClick on an SVG. There is not a single aria-label in the hand-written codebase.

TaskCard.tsx:36 · AddNewCardButton.tsx:20
critical

The error-handling layer is dead code

withDbConnect does return handler(...) inside a try — without await, the promise's rejection escapes the catch. Malformed JSON, Mongoose errors, and the empty-body TypeError in PATCH all bypass the only error handler the API has.

lib/middleware.ts:24,44
Correctness & race conditions 368
major
Rapid drags race with no orderingEach drag fires an independent fire-and-forget PATCH carrying a full-array snapshot — no queue, no version field, and a variable-latency getToken() before each send. Drag 1's request can arrive after drag 2's and resurrect stale order.moveCardAcrossColumns.ts:32
major
Checking a card reorders locally but never persists the orderThe reducer moves a completed card to the bottom of its column; the PATCH sends only { status }. The order exists exclusively in client memory — guaranteed divergence on reload.plannerReducer.tsx:108 · changeCardCheckedStatus.ts
major
GET /api/planner is a destructive readEvery page load: read the document, archive completed cards in JS, then blind-overwrite the entire taskCards and columns maps — even when nothing changed. Any write landing in that window (second tab, in-flight debounced PATCH) is clobbered.app/api/planner/route.ts:26
major
Dragging under an active filter corrupts card orderFiltered-out cards keep their pre-filter indices, so Draggables have gapped indices (0, 2, 5) — which @hello-pangea/dnd explicitly requires to be consecutive. The reported drop index then splices the wrong position in the canonical array.ColumnTasks.tsx:30
major
PATCH applies only the first body key — and throws on an empty bodyObject.entries(body)[0] silently drops every field after the first, throws a TypeError on {} (unhandled, see the missing await), and accepts arbitrary field names into the update path.cards/[cardId]/route.ts:18
major
Deletes never cascadeDeleting a column orphans its cards' map entries forever; deleting a board orphans every column, card, subtask, and category it owned — on both server and client. The document grows monotonically, and orphans ship to the client on every load.boards/[boardId]/route.ts:20 · columns/[columnId]/route.ts:11
minor
Also notedSuccess toasts fire before the network result · no AbortController on the initial fetch (StrictMode double-fetch can reset state over user mutations) · form bypasses handleSubmit with no double-submit guard · client-minted IDs never validated server-side (a forged ID silently overwrites data) · dead scheduledTaskCards state · no undo for permanent destructive actions.
API & backend 285
major
No index, no unique constraint on clerkUserIdThe lookup key for every one of 19 routes triggers a collection scan per request, and the create-on-first-GET path (findOne, then save) is racy enough to mint duplicate planner documents for one user — two tabs on first load can do it.models/Planner.ts:55 · app/api/planner/route.ts:8
major
Zero server-side validation — zod never crosses the wirezod is used in exactly 8 files, all client forms. Every route destructures await req.json() raw; whole client objects are persisted verbatim; TypeScript request interfaces are compile-time fiction with [key: string]: any escape hatches.all 19 routes
major
Update paths built from attacker-controlled keysThe PATCH handler splices the first body key directly into a dotted $set path with no allowlist. Scoped to the user's own document (so not cross-tenant), but a dotted key like "a.b" writes unmodeled nested state the app never expects.cards/[cardId]/route.ts:18 · subtasks/[subTaskId]/route.ts
major
Multi-step mutations are non-atomic — needlesslyCategory delete runs three sequential updateOnes, card delete two, subtask delete two — all against the same single document, where one combined $set/$unset/$pull update would be atomic for free. A failure between steps leaves dangling references.boards/[boardId]/categories/[categoryId]/route.ts:21
major
Dead second Mongo client wired to a browser-exposed env varconfig/mongo-client.ts is imported nowhere, duplicates dbConnect via the raw driver, and reads NEXT_PUBLIC_MONGO_URI — a prefix that tells Next.js to inline the value into the client bundle. One import away from publishing the database credential.config/mongo-client.ts:3
minor
Also notedArchived cards are write-only (no endpoint can read them — the archive UI reads cards the API filtered out) · writes never check matchedCount, so operations on nonexistent resources return success · updateMany used on per-user singletons · schema lacks timestamps, enums, and any optimistic-concurrency story · response bodies carry no server echo to reconcile against.
Next.js standards 511
major
"next": "latest" is a build time bombSeven dependencies float on "latest" while the lockfile freezes mid-2024 (Next 14.2.4, React 18.2, Tailwind 3.3). Any lockless install in 2026 jumps multiple majors simultaneously: async params, React 18/19 peer conflicts, and the Tailwind v4 PostCSS break — all at once.package.json:47
major
The App Router is used as a static shellEvery page and layout is 'use client'; the 13-line root layout is the only Server Component. No RSC data fetching, no Server Actions, no streaming — the entire dataset arrives via client-side axios after hydration. This is the exact pattern RSC was designed to replace.app/** · hooks/Planner/Planner.tsx:40
major
Hand-plumbed Bearer tokens that solve a non-problemAll ~20 mutation utils fetch a Clerk token and attach an Authorization header — but Clerk's session cookie already authenticates same-origin requests to auth(). Boilerplate ceremony on every call site for zero benefit.utils/plannerUtils/**
major
No Metadata API — the deployed app has no <title>Zero export const metadata anywhere. Browser tabs show the bare URL; link previews are empty. This has been the App Router standard since Next 13.2.app/layout.tsx:6
major
No loading.tsx / error.tsx / not-found.tsx anywhereLoading is hand-rolled spinners behind hasLoaded flags; errors use a third-party boundary inside a client layout; unknown boards router.push('/not-found') — during render, to a route that doesn't exist.app/boards/[boardId]/page.tsx:16
minor
Also notedSWR declared, never imported once · two Tailwind configs (the .ts one is dead and internally broken, with pasted placeholder comments) · reactStrictMode: false · CalSans font instantiated twice in different modules · client-side useEffect redirects where server redirect() belongs · dead next-themes setup with suppressHydrationWarning stranded on body · JSX.Element typings that break under React 19 · middleware matcher excludes any route containing a dot · tsconfig targets ES5.
React & state management 185
major
One monolithic context, zero memoizationThe entire app state is one context value consumed by ~37 components, and grep finds zero React.memo/useMemo/useCallback in app code. Every keystroke in a card title re-renders every card, column, and settings panel on the board.hooks/Planner/Planner.tsx:73
major
Drag state routed through the global storePicking up or dropping a card dispatches idOfCardBeingDragged globally — re-rendering the entire board at the two most latency-sensitive moments of a drag, the exact anti-pattern @hello-pangea/dnd's performance docs warn about. The library's own snapshot.isDragging was available the whole time.components/planner/utils.ts:74 · TaskCard.tsx:60
major
form.watch() subscribed in the render bodyThe callback form of watch() registers a subscription requiring useEffect cleanup; here it runs in render, leaking one subscription per keystroke.InitializingTaskCard.tsx:47
major
DOM node leak: a portal div appended to body on every renderThe dnd-inside-Radix-Dialog workaround creates and appends a fresh div per render with no cleanup. Typing a 20-character subtask title leaks 40+ orphaned DOM nodes.EditableSubTasks.tsx:22
major
Components defined inside components in all three Settings cardsEach parent render creates new component types, unmounting and remounting the subtree — discarding Radix Select state along the way. (Severity adjusted to minor by the verifier in one case, but the pattern repeats across three files.)ManageColumnsCard.tsx:22 · ManageBoardsCard.tsx:21 · ManageCategoriesCard.tsx:32
major
Settings page can crash after deleting a boardselectedBoard is seeded once from context and never reconciled; delete that board in the card above and boards[selectedBoard].columns dereferences undefined.ManageColumnsCard.tsx:13
minor
Also notedFilter reset dispatches into a context with no provider above it (silently no-ops) · index- and name-based list keys in the sidebar · dead state and dead actions in the store · prop-to-state syncing via useEffect plus direct DOM mutation · six ephemeral drag/init UI fields living in the persistent global store.
Security 252
critical
Leaked Atlas credentials (see critical card above) — since resolvedTested post-review: the target cluster was already deleted, so the credential was dead. History scrubbed with git filter-repo the same day. The lesson stands: rotate first, audit later — the git history of a public repo is forever.was backend/.env · scrubbed
major
No server-side input validation on any route bodyCombined with mass-assignment via dynamic update keys and unvalidated client-minted IDs interpolated into Mongo paths. None of it crosses tenants — the single-document model contains the blast radius — but a user can corrupt their own document in ways the app can't repair.columns/[columnId]/cards/route.ts:9
major
No rate limiting, no security headers, statuses hidden in bodiesAn authenticated client can hammer unbounded writes toward MongoDB's 16MB document limit. No CSP/HSTS/X-Frame-Options. Monitoring tools see HTTP 200 for every failure.middleware.ts · next.config.js
credit
What's genuinely solidIdentity always from server-side auth(), never the request. Uniform tenant scoping makes classic IDOR structurally impossible — a traced attack with another user's cardId modifies nothing. Zero XSS sinks: no dangerouslySetInnerHTML, no innerHTML, no eval.
TypeScript 57
major
The heart of the app is Dispatch<any> — 31 times32 reducer action types, zero compile-time payload checking, no exhaustiveness. The compiler verifies nothing about the app's central data flow, and at least one missing-payload bug already slipped through.types.tsx:96 · every mutation util
major
The Mongoose model is imported via require()So every database read/write in all routes is any. A real null-dereference in the category-delete route hides behind it.models/Planner.ts:1
major
No shared client/server contractRequest bodies are trusted casts; response.data hydrates the entire store untyped and unvalidated; axios.get<T> appears zero times. The three layers where TypeScript would earn its keep — dispatch, database, wire — are all any.hooks/Planner/Planner.tsx:50
major
Gratuitous any where the real type is one import awaysource: any, destination: any for dnd's DraggableLocation — the same package whose types are already imported one file over.moveCardAcrossColumns.ts:8
minor
Also notedIdentical zod schemas copy-pasted across 4+ components (some recreated inside the component body) · pure-type files with .tsx extensions · untouched 2023 scaffold tsconfig (target es5, no noUncheckedIndexedAccess) · one shared all-optional params grab-bag interface for every route.
Performance 167
major
A 4-hop waterfall before the first card rendersHTML shell → JS hydration → Clerk client init → getToken() in useEffect → GET /api/planner. Nothing renders until all of it lands — and the GET performs a full-map database write on every read.hooks/Planner/Planner.tsx:40
major
Opening one board downloads the entire accountEvery board, every card body, every subtask, across all boards — payload and time-to-interactive grow with total account history, not the board being viewed. Per-board routes already exist and go unused for this.app/api/planner/route.ts:8
major
Every keystroke re-renders every cardGiant context + zero memoization + every card mounting its own Dialog, ContextMenu, and DropdownMenu trees. On a 100-card board this is the difference between smooth and janky — and it directly fights dnd's documented performance guidance.Planner.tsx:73 · TaskCard.tsx
major
cypress ships in production dependenciesHundreds of MB of post-install binary in every prod install — for a test framework with zero tests in the repo.package.json:38
minor
Also notedSix more dead dependencies including a third icon library · getToken() awaited per keystroke outside the debounce · search dispatches per keystroke into unmemoized per-column filtering · permanent full-viewport blurred-gradient animation on the landing page · stack frozen in mid-2024, forfeiting two years of framework performance work.
Code quality & DX 167
major
23 near-identical mutation files — consistency by copy-paste940 lines all repeating the same dispatch/axios/Bearer/catch skeleton that one ~30-line generic helper would replace (an ~80% reduction). The duplication didn't just cost lines: it propagated the shared-debounce data-loss bug into three files instead of containing it in one.utils/plannerUtils/**
major
Zero tests, no CI, minimal lintingNo test files of any kind, no .github/, a one-line .eslintrc — while the two most testable pieces of the app (the pure 204-line reducer and the drag-end handler) sit there begging for unit tests.repo root
major
Hygiene rot: dead files, phantom deps, stale nameAn empty 0-byte plannerUtils.ts · a dead-and-broken tailwind.config.ts beside the real .js · nanoid imported but not declared (breaks under pnpm) · ~7 confirmed-unused packages · package.json still named "dominion" — the previous project's name · a one-line README with zero setup docs.package.json · tailwind.config.ts
minor
Also notedFive dead components kept alive by commented-out call sites · the global error boundary logs 'error occured lol' · commented-out code across nine files · CategoryBadge duplicated then diverged · mixed default/named export conventions · stale pages/api comments in 7 of 19 App Router route files.
UX & accessibility 2149
major
Keyboard reordering defeated by hover-only drag handles@hello-pangea/dnd ships Space-to-lift keyboard support for free; the subtask handle is visibility: hidden except on mouse hover, which removes it from the tab order entirely.EditableSubTask.tsx:40
major
Dark mode is fully wired and fully unreachableTokens defined, next-themes installed, ModeToggle built (ironically the best-labeled button in the codebase) — but the ThemeProvider is never mounted and the toggle is commented out. Light colors are also hardcoded throughout, so wiring it up would break anyway.app/layout.tsx:8 · NavLinks.tsx:28
major
Zero responsive design in the plannergrep for responsive breakpoints across components/planner/** returns nothing. Fixed 384px columns (wider than most phones), w-5/6 board, inline 87vh heights. Desktop-only by construction.TaskColumn.tsx:21
major
Invalid HTML in dialogs: forms nested inside <p>Entire edit forms (with buttons and a nested AlertDialog) live inside DialogDescription — a paragraph element. Browsers re-parent it; React hydration mismatches follow. Plus buttons-inside-buttons in five places, mismatched label htmlFor associations, and duplicate id='terms' checkboxes from pasted shadcn docs.ModifyBoardDialogContent.tsx:49 · CategoryColorPicker.tsx:18
major
White text on yellow-500: category colors fail WCAG badlyAll 18 category colors hardcode white text on 500-series hues — white on yellow is ~1.6:1 against a 4.5:1 requirement, at text-xs where it hurts most. The color swatches themselves are mouse-only divs with no name and no selected state.TaskCard/utils.ts:10 · CategoryColorPicker.tsx:31
major
Fighting Radix instead of fixing the structureBoth settings dialog wrappers manually clear document.body.style.pointerEvents on a 100ms timer after close — with comments admitting the page would otherwise become permanently unclickable after the nested Dialog → AlertDialog flow.ManageItemCardDialogWrapper.tsx:30
major
Sidebar navigation is buttons, not links<button onClick={router.push}> — no middle-click, no link semantics, no prefetch, no aria-current. Focus rings are also deliberately stripped from every editing input, so keyboard users navigate blind.SidebarButton.tsx:19 · TaskFilterSearchBar.tsx:9
minor
Also notedCategory dropdown checkmark never shows (compares ID to display name) · 'Add subtask' is a non-focusable div · blank screen while planner loads on /boards · column headers are full-width drag handles disguised as static text · the main edit dialog has no DialogTitle and unlabeled fields · dead second toast system in the bundle.

06The Verdict

z-planner is a well-organized 2023-era SPA wearing an App Router costume, built by someone whose instincts are consistently better than their follow-through. The high-level decisions are genuinely good — the single-document data model that makes tenancy watertight and the hot-path write atomic, the normalized Redux-style store, idempotent full-array reorders, optimistic UI on all 23 mutations, a uniformly applied auth wrapper. These are choices many professional teams get wrong.

But the codebase optimizes relentlessly for the happy path. Every error path either doesn't exist (backendErrorOccurred: written 23 times, read zero times), can't fire (HTTP 200 for every failure; an un-awaited handler escaping its try/catch), or actively destroys data (the shared debounce; the destructive GET). The consistency that makes the code pleasant to read was achieved by copy-paste, so its one data-loss bug exists in three files. And the gap between installed and used — SWR never imported, zod never on the server, cypress with zero tests, dark mode built but never mounted — tells the story of a project that kept buying tools for the person it wanted to become.

Scored 3.9/10 against 2026 standards; closer to a 6.5 against the standards of its own era. The architecture underneath is worth keeping. The plumbing needs a deliberate week of repair.

07Modernization Roadmap

In strict priority order. Items 1–3 are a single afternoon and remove the live hazards; items 6–8 are the actual migration to modern Next.js.

✓ done · june 10

Rotate the leaked MongoDB Atlas credential

Resolved: the leaked credential was tested and found dead (its Atlas cluster no longer exists), and backend/.env was purged from all 117 commits with git filter-repo + force-push the same day as the review. Still to do as part of this item: delete config/mongo-client.ts and the NEXT_PUBLIC_MONGO_URI variable.

today · 1 hour

Fix the response plumbing

Move status codes into ResponseInit (NextResponse.json(body, { status })) and add the missing await in withDbConnect/withAuth. Two tiny diffs that make every error in the system visible for the first time.

today · 30 min

Pin every dependency & index clerkUserId

Kill all seven "latest" specifiers, move cypress out (or just delete it), declare nanoid, drop the ~7 unused packages. Add unique: true, index: true to clerkUserId and switch create-on-GET to an upsert.

1 day

Make failure visible: rollback + per-entity debounce

Capture a pre-mutation snapshot and restore it in catch (or adopt SWR/TanStack mutate with rollbackOnError — SWR is already installed). Key the debounce per entity ID. Move success toasts after the response. This closes every silent-data-loss path.

1 day

Type the three blank roads

One PlannerAction discriminated union (kills all 31 Dispatch<any>), ESM-import the Mongoose model with InferSchemaType, and one shared zod schema module parsed on both client and server. Roughly doubles real type safety without touching architecture.

2–3 days

Migrate to Next 15/16: RSC + Server Actions

Fetch the planner in a Server Component with await auth() + a direct Mongo query (no HTTP hop, no Bearer ceremony), pass it to a client Board island. Replace the 23 mutation files + 19 routes with Server Actions using useOptimistic — which gives rollback for free. Add loading.tsx / error.tsx / not-found.tsx and a metadata export.

1–2 days

The accessibility pass

Real buttons for card-open and card-add, focus-visible drag handles, restore focus rings, dark-text badges on light hues, links instead of router.push buttons, labels that match their inputs, and mount the dark mode that's been sitting there finished since 2024.

ongoing

Tests + CI for the pure core

The 204-line reducer and the drag-end handler are pure functions — Vitest tests need no mocks. A GitHub Action running lint + typecheck + test would have caught a third of this dossier automatically.

08Methodology

This dossier was produced by Claude (Fable 5) running a structured multi-agent review on June 10, 2026.

PhaseWhat happened
Map6 agents charted one subsystem each — routing, API surface, state management, client mutations, component tree, data model — producing file-level traces of every read and write path.
Review9 agents each critiqued one dimension (Next.js standards, React & state, API/backend, security, TypeScript, performance, correctness, code quality, UX/a11y), instructed to find genuine strengths as well as flaws, citing file and line for every claim.
VerifyEvery major or critical finding went to an adversarial verification agent instructed to refute it by re-reading the code. 77 findings were confirmed, 0 refuted; a handful had severity adjusted.
CriticA completeness agent walked the repo tree hunting for anything the dimensional reviews missed — which is how the phantom nanoid dependency, the ES5 target, and the .gitignore archaeology made it in.

93 agents · 1,421 tool calls · ~3.2M tokens · 27 minutes wall-clock · repo at commit f07d5e0 (July 14, 2024)