Kanban module
@monark/kanban is an extended module that adds a Kanban board view (a
sibling to the calendar). This is the internals reference ; the package
README is the API doc.
Shape
An org owns many boards ; a board owns ordered columns ; a column owns
ordered cards. Cards drag between columns. Like the calendar, the module
owns its Prisma models directly in the shared schema.prisma under the
// ── MODULE: kanban ── banner (KanbanBoard, KanbanBoardRoleAccess,
KanbanColumn, KanbanCard ; migrations 20260725200201_add_kanban,
20260726023411_kanban_card_fields, 20260726040000_kanban_card_multi_assignee,
20260727150000_kanban_card_multi_reviewer, 20260727170000_kanban_card_subtasks,
20260808020000_kanban_card_blocks, 20260808030000_kanban_drop_subtasks).
Boards and cards soft-delete (deletedAt) ;
columns hard-delete and cascade their cards.
A card carries title, description (a BlockNote block array, Json) +
descriptionText (its plain-text projection via blocksToText, kept in sync on
write so the query language filters on it), assigneeIds / reviewerIds (text
arrays : a card can have many assignees and many reviewers ; org-member
userIds, not FKs), dueAt?,
priority? (KanbanCardPriority : LOW / MEDIUM /
HIGH / CRITICAL : a severity-graded colour, KANBAN_PRIORITY_COLOR in
contracts/types.ts driving a faded-tint badge on the card + in the editor
picker, matching the Data-Models select badges), and estimate? (Int). A card's
checklist lives in the block body now (BlockNote checkListItem blocks) :
the old dedicated subtasks column was dropped. Estimate + priority render as
chips on the card, and checklist progress renders as a thin progress bar
flush with the card's bottom edge (blue while in progress, green when complete ;
the card is overflow-hidden so the bar clips to the rounded corners, exact
done/total on its title/aria), derived from the description's checklist blocks
via checklistProgress (@monark/common/blocks). The whole card opens the
editor on click.
descriptionJSON gotcha + drag activation. TheJsoncolumn surfaces as the recursivePrisma.JsonValue; on the already-wideKanbanCardrow that pushed tRPC's output type inference past tsc's instantiation-depth limit (TS2589). Fix:KanbanCardRowoverridesdescriptiontounknown(data.ts), and every reader coerces it (the block editor casts toBlock[], the card face reads it throughchecklistProgress). Drag activation is input-aware : aMouseSensorstarts on a 6px move (so a click stays a click), while aTouchSensorneeds a ~250ms long-press before dragging : a quick touch swipe pans/scrolls the board instead (the card sets notouch-action: none, so the browser owns the gesture until the press delay elapses). This is the fix for "I tried to scroll and moved a card" on mobile, where horizontal space is tight.
The card editor also carries a Status select (the board's columns). Picking a
different status moves the card to the end of that column : cards.update takes
an optional columnId, updateCard repositions it, and the handler emits the
same kanban.card-moved event a drag would (so automation / webhooks see one
move signal regardless of how the move was triggered).
Boundary note. The written contract says extended modules must not edit
schema.prisma. The calendar already owns models there, andcheck:tiersonly inspects package deps, so we follow that precedent. The rule-pure alternatives (metadata sidecar, or making kanban core) were rejected for a feature that needs indexed, ordered, FK-linked rows.
Ordering
Columns and cards each carry an integer position in gaps of 10 (the house
pattern from data-models' reorderDataFields). A reorder sends the full
ordered id list and rewrites positions in a transaction. moveCard(boardId, toColumnId, orderedIdsInTarget) pins each target card's columnId and rewrites
its position in one transaction ; the source column keeps its (harmless) gaps.
Cross-cutting wiring (the "Big 4/5")
Registered at api boot in services/api/src/server.ts:
- RBAC :
registerKanbanPermissions()→kanban.{view,create,edit,delete,manage}. Every mutation guards withrequirePermission(ctx, "kanban.<key>", org.id). Per-board access viaKanbanBoardRoleAccess(empty = everyone ;kanban.managebypasses), mirroringCalendarRoleAccess. - Event bus : every mutation emits a
KanbanEventsmember (board-created/column-created/card-created/card-updated/card-moved/card-deleted/card-assigned). - Webhooks : free :
registerKanbanEventTypes()registers the operator descriptions, making every event subscribable. - Notifications :
registerKanbanNotificationKinds()+registerKanbanNotificationSubscriber(). The subscriber reacts tokanban.card-assignedandnotify()s the assignee (kanban.card.assignedkind, in-app default-on + email opt-in, en + fr). Self-assignment is skipped. - Feature flags :
registerKanbanFeatureFlags()→kanban.board(default-on). The route 404s and the primary-nav entry hides when it resolvesfalse(the nav reads it viafeatureFlags.getAllForSession).
Web view
services/web/src/app/(authed)/kanban/:
page.tsx (server: auth + flag gate + board/permission load), kanban-shell.tsx
(URL ?board=<id> state, dialogs, and the compact board combo ; a single
control whose name + chevron open the board switcher while an inline pen opens
the board settings ; there is no separate columns / edit-board toolbar button),
kanban-board-view.tsx (the @dnd-kit board: one DndContext, a vertical
SortableContext per column, highlight-only onDragOver, resolve + persist on
onDragEnd), the board / column dialogs, and the card editor Sheet.
Clicking a card opens card-editor.tsx as a right-side panel on desktop
(non-modal + no overlay, so the board stays visible/interactive behind it ; a
PanelHeader supplies the title + close X, and outside clicks are ignored so a
stray click can't drop the form) and a full-screen sheet on mobile (the
transform-free full variant + the shared usePanelIsMobile visual-viewport
check, so it can't blow out the iOS layout viewport). CardEditor keeps the
Sheet mounted and remounts only its inner CardForm (keyed by card id) when
you pick a different card, so switching cards re-seeds the fields without
replaying the panel's open animation / layout shift. Column management lives on
the board, not a dialog: each column header carries the drag grip + a … menu
(add card / edit / delete), and a trailing AddColumn slot creates new columns
inline (the old ColumnManager dialog was removed). The board primitives
(BoardArea, BoardColumn, KanbanCard) live in the package as framework-pure
React ; dnd-kit and shadcn stay in the web layer.
Filtering (MonarkQL)
Behind the kanban.query flag, the board toolbar shows the shared QueryBar
(services/web/src/components/query/query-bar.tsx, over the generic
@monark/query language). The tree compiles to a typed
Prisma.KanbanCardWhereInput in server/query-compiler.ts
(compileKanbanFilter, via walkFilter) and loads through the cards.list
procedure ; @variables (@me, @today) resolve server-side. Queryable fields
- their kinds live in
contracts/query-fields.ts(priorityis an orderedSelect :priority:>=HIGHexpands to a value set). The web builds the field list (labels + per-board options) client-side from the board's columns + org members. Saved views + a list view are the planned follow-ups.
Extending
- A new card field: add it to
KanbanCard(schema + migration), thecardsinput/patch inserver/index.ts, and the card editor. - Data-Model integration (materialize records into cards): follow the calendar's
registerModelIntegration+ subscriber pattern (deferred).
Testing
Two suites :
- Unit (
pnpm --filter @monark/kanban test, no Docker) : the MonarkQL filter compiler (tests/query-compiler.test.ts, incl. thedescriptionfilter now targeting thedescriptionTextprojection) and the query-fields kind map. (The card's checklist derivation is unit-tested at its source,checklistProgressin@monark/common.) - Integration (
pnpm --filter @monark/kanban test:integration, needs Docker) ; two files against a Postgres testcontainer :kanban-data.test.ts(data layer) : default-column seeding (names, gaps-of-10 positions, colours), column reorder, cross-columnmoveCardreindex,searchCardsscoping/soft-delete, multi-assignee + multi-reviewer round-trips (replace / untouched-on-undefined/ clear), the block-arraydescription+ itsdescriptionTextprojection round-trip, and the status-change move (updateCard({ columnId })appends to the target column).kanban-router.test.ts(tRPC procedures, viat.createCallerFactory) : RBAC guards (kanban.view/editdeny/allow), per-board role access (requireAccessibleBoard; restricted board hidden from a viewer, visible to the gated role, bypassed bykanban.manage), and event emission + change-detection captured off the bus (on(WILDCARD_EVENT_TYPE)) :card-created+ onecard-assignedper assignee, an update'schangedarray +card-assignedonly for newly-added assignees, a status change emittingcard-moved, and a single-field change emitting no false assignee/move events. This mirrors data-models'authorization.test.tsand stays self-contained (publiccreateRole/assignRole+ the caller factory : no shared harness, no core-module change).