FS Garage Kanban (FE) Architecture
System Overview
The board groups a flat list of tasks into six fixed columns by status, renders them with drag-and-drop, and on every drop recomputes just two fields on the moved task - status and a fractional order_index - rather than renumbering an entire column.
Architecture Diagram
graph TB
subgraph FE["Frontend: base-fs-garage-fe"]
StatusConfig["configs/tasks/general/status.js<br/>STATUS_CONFIG (6 columns)"]
KanbanBoard["KanbanBoard.jsx<br/>groupTasksByColumn + drag state"]
KanbanColumn["KanbanColumn.jsx"]
KanbanTaskCard["KanbanTaskCard.jsx"]
Store["store/index.js<br/>updateTask, updateTaskInStore"]
end
PocketBase["PocketBase: tasks collection"]
StatusConfig -->|"defines columns"| KanbanBoard
KanbanBoard --> KanbanColumn
KanbanColumn --> KanbanTaskCard
KanbanBoard -->|"on drop: status + order_index"| Store
Store -->|"PATCH"| PocketBase
How to read this diagram
Arrows mean flow / dependency / "talks to", not button clicks.
Core Components
Column configuration (configs/tasks/general/status.js)
STATUS_CONFIG is the single source of truth for the board's six columns (To Do, In Progress, In Review, In Testing, In Acceptance, Done), each listing the raw task statuses it groups (e.g. "In Progress" covers In Progress, In Dev Testing, Needs Rework) plus a display color, icon, and statusOrder.
KanbanBoard.jsx
Owns local optimistic task state, builds columns via groupTasksByColumn, and handles onDragEnd/onDragUpdate from @hello-pangea/dnd's DragDropContext.
KanbanColumn.jsx / KanbanTaskCard.jsx
Render one column's task list and one task card respectively; presentation only, no calculation logic of their own.
Ordering & Column Calculations
Grouping tasks into columns
groupTasksByColumn(tasks) =
for each column in STATUS_CONFIG:
column.tasks = tasks.filter(task => column.statuses.includes(task.status))
.sort((a, b) => a.order_index - b.order_index)
A task's column is determined entirely by which STATUS_CONFIG group its raw status value belongs to - there's no separate "column" field on the task itself.
Sub-order within a shared column
Several raw statuses can share one column (e.g. "In Review" = Ready for Review, In Review, Ready for Staging, In Staging). getStatusSubOrder derives a finer-grained order value so that detail isn't lost when they visually collapse into one column:
subOrder = column.statusOrder + (indexOfStatusWithinColumn + 1) / 10
Example: "In Review" has statusOrder = 3; its four statuses get 3.1, 3.2, 3.3, 3.4 in listed order.
Fractional order-index on drag-and-drop
Dropping a card never renumbers the whole column - it computes one new value that sits between its new neighbors:
calculateOrderIndex(columnTasks, destinationIndex):
if destinationIndex == 0: neighbor.order_index - 0.5 (dropped at top)
if destinationIndex >= length: lastTask.order_index + 1 (dropped at bottom)
otherwise: (prevTask.order_index + nextTask.order_index) / 2 (dropped between two cards)
Dropping within the same column, the dragged task is excluded from columnTasks before this runs, to avoid an off-by-one against its own old position.
Column-to-status mapping on cross-column drop
Moving a card to a different column rewrites its status to a fixed representative value for that column (not just "whatever status the column happens to display"):
| Column | Status written on drop |
|---|---|
| Todo | To Do |
| In Progress | In Progress |
| In Review | Ready for Review |
| In Testing | Ready for Testing |
| In Acceptance | Ready for Acceptance |
| Done | Ready for Production |
Dropping within the same column never changes status - only order_index moves.
Optimistic update with rollback
1. Compute {status, order_index} for the dropped task
2. Update local board state immediately (optimistic)
3. Update the in-memory store silently (no extra rerender)
4. PATCH the task via store/index.js's updateTask
5. If the PATCH fails or throws: revert local state AND the store to the
pre-drag {status, order_index} - the board never shows a position the
server didn't actually accept
Data Flow
sequenceDiagram
participant User
participant Board as KanbanBoard.jsx
participant Store as store/index.js
participant PocketBase
User->>Board: Drag task card to a new column/position
Board->>Board: calculateOrderIndex(destinationTasks, destinationIndex)
Board->>Board: resolve status via COLUMN_STATUS_MAP (if cross-column)
Board-->>User: Optimistic re-render (immediate)
Board->>Store: updateTask(taskId, {status, order_index})
Store->>PocketBase: PATCH tasks/:id
alt save fails
PocketBase-->>Store: error
Store-->>Board: failure
Board->>Board: revert to pre-drag state
else save succeeds
PocketBase-->>Store: updated task
end
Design Decisions
Fractional indexing instead of renumbering
Context: Reordering within a column by renumbering every task's order_index would mean writing every affected row on each drag, not just the one that moved.
Decision: Compute a single midpoint value between the dropped task's new neighbors ((prev + next) / 2) - only the dragged task is written. This is the same fractional-indexing approach used for ordering in many drag-and-drop lists, trading unbounded float precision for far fewer writes.
A fixed column-to-status map, not "whatever the column shows"
Context: A column groups several raw statuses (e.g. In Review covers four); dropping a card into that column needs to pick one concrete status to write.
Decision: COLUMN_STATUS_MAP pins one representative status per column, so cross-column drops are deterministic instead of guessing from whichever status happened to sort first.
Optimistic UI with an explicit rollback path
Context: Waiting for the PocketBase round-trip before showing a moved card would make drag-and-drop feel laggy.
Decision: Update local state immediately, then reconcile: on failure, restore the exact pre-drag task list and store values rather than leaving an inconsistent, unsaved-looking board.
Technology Stack
| Layer | Technology |
|---|---|
| Framework | Next.js App Router |
| Language | JavaScript |
| Drag-and-drop | @hello-pangea/dnd |
| Backend | PocketBase tasks collection (shared, no Kanban-specific schema) |
| Default branch | dev |