Skip to content

Kanban / To-Do List

  • Build a project board with boards, columns, cards, drag-and-drop, and checklists

Prerequisite

Complete the Get Started guide first.

A kanban board is how teams organize work visually — moving cards through stages from "to do" to "done". Think project management, sprint planning, content pipelines, personal productivity, or any workflow where tasks move through distinct phases.

By the end of this example you will have:

  • Boards — multiple boards for different projects or workflows
  • Columns — customizable stages with drag-and-drop reordering
  • Cards — tasks with descriptions, assignees, labels, due dates, and checklists
  • Board dashboard — stats showing card counts, overdue items, and team workload
  • Realtime — all changes sync instantly across connected browsers

Database Schema and Permissions

Prompt

txt
We are building a kanban board app on top of this template. Create the
database schema and add the permissions we need.

Database (via Supabase MCP):

Create five tables:

1. `boards` table — id (uuid, default gen_random_uuid(), primary key),
   team_id (uuid, references teams(id) on delete cascade, not null),
   name (text, not null),
   description (text, nullable),
   color (text, default '#3b82f6', not null),
   is_archived (boolean, default false, not null),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

2. `columns` table — id (uuid, default gen_random_uuid(), primary key),
   board_id (uuid, references boards(id) on delete cascade, not null),
   name (text, not null),
   sort_order (integer, default 0, not null),
   card_limit (integer, nullable),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

3. `cards` table — id (uuid, default gen_random_uuid(), primary key),
   column_id (uuid, references columns(id) on delete cascade, not null),
   board_id (uuid, references boards(id) on delete cascade, not null),
   title (text, not null),
   description (text, nullable),
   sort_order (integer, default 0, not null),
   assigned_to (uuid, references profiles(id) on delete set null, nullable),
   priority (text, check priority in ('low', 'normal', 'high', 'urgent'),
   default 'normal', not null),
   due_date (date, nullable),
   labels (text[], default '{}', not null),
   is_archived (boolean, default false, not null),
   created_by (uuid, references profiles(id), not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

4. `card_comments` table — id (uuid, default gen_random_uuid(), primary key),
   card_id (uuid, references cards(id) on delete cascade, not null),
   user_id (uuid, references profiles(id), not null),
   content (text, not null),
   created_at (timestamptz, default now()).

5. `card_checklist_items` table — id (uuid, default gen_random_uuid(),
   primary key),
   card_id (uuid, references cards(id) on delete cascade, not null),
   title (text, not null),
   is_completed (boolean, default false, not null),
   sort_order (integer, default 0, not null),
   created_at (timestamptz, default now()),
   updated_at (timestamptz, default now()).

Enable RLS on all five tables. Create policies that use the existing
`is_team_member()` function to scope access. For `boards`, check team_id.
For `columns` and `cards`, join through `boards` to check team membership.
For `card_comments` and `card_checklist_items`, join through `cards` and
`boards`.

All policies should allow select, insert, update, and delete for team
members. We will handle finer-grained permission checks in the server
routes, not in RLS.

After creating the tables, add these permissions to `shared/permissions.ts`:

```
"boards.view": ["owner", "admin", "member"],
"boards.create": ["owner", "admin", "member"],
"boards.update": ["owner", "admin"],
"boards.delete": ["owner", "admin"],
"cards.view": ["owner", "admin", "member"],
"cards.create": ["owner", "admin", "member"],
"cards.update": ["owner", "admin", "member"],
"cards.delete": ["owner", "admin"],
```

Regenerate the TypeScript types via Supabase MCP and save them to
`shared/types/database.types.ts`.

Adding plugins later?

If you plan to add API Keys & External API or AI Chat later, their pages will guide you through adding the required permissions. You don't need to add them now.

Boards & Columns

Prompt

txt
Build the board and column management — the structural layer of the kanban.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/boards` — uses `authUser(event, "boards.view")`.
  Returns all non-archived boards for the team, ordered by name asc. Include
  a count of columns and total card count per board by joining. Support
  optional query param `?include_archived=true` to also return archived boards.

- `POST /api/teams/[teamId]/boards` — uses
  `authUser(event, "boards.create")`. Reads { name, description, color }
  from the body. Validates that name is required. Sets team_id from auth
  context and created_by from the authenticated user (user.sub). After
  creating the board, create three default columns: "To Do" (sort_order 0),
  "In Progress" (sort_order 1), "Done" (sort_order 2).

- `PATCH /api/teams/[teamId]/boards/[boardId]` — uses
  `authUser(event, "boards.update")`. Updates whichever fields are provided.
  Validates that the board belongs to the team.

- `DELETE /api/teams/[teamId]/boards/[boardId]` — uses
  `authUser(event, "boards.delete")`. Validates that the board belongs to
  the team before deleting.

- `GET /api/teams/[teamId]/boards/[boardId]/columns` — uses
  `authUser(event, "boards.view")`. Returns all columns for the board,
  ordered by sort_order asc. Include a count of non-archived cards per column.

- `POST /api/teams/[teamId]/boards/[boardId]/columns` — uses
  `authUser(event, "boards.update")`. Reads { name, card_limit } from the
  body. Validates that name is required. Sets sort_order to max existing
  sort_order + 1 for this board.

- `PATCH /api/teams/[teamId]/boards/[boardId]/columns/[columnId]` — uses
  `authUser(event, "boards.update")`. Updates name, card_limit, or
  sort_order. Validates that the column belongs to the board.

- `DELETE /api/teams/[teamId]/boards/[boardId]/columns/[columnId]` — uses
  `authUser(event, "boards.update")`. Return 400 if the column still has
  cards. Validates board membership.

- `POST /api/teams/[teamId]/boards/[boardId]/columns/reorder` — uses
  `authUser(event, "boards.update")`. Reads { column_ids } from the body
  (an ordered array of column UUIDs). Updates the sort_order of each column
  to match its array index. Validates that all columns belong to the board.

UI:

Create `app/pages/boards/index.vue` — a board list page as a top-level
route, wrapped in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: title "Boards",
  `UDashboardSidebarCollapse` on the left. On the right, a "New Board"
  button wrapped in `CanAccess permission="boards.create"`.

- The body shows a grid of board cards (not a table). Each card shows the
  board name, description (truncated), color accent bar on the left or top,
  column count, and card count. Use `USkeleton` for loading. Show empty state.

- "New Board" button opens a `UModal` with a form: name (required),
  description, color (preset color picker with 6-8 color options). Use Zod
  for validation. Show loading on submit.

- Clicking a board card navigates to `app/pages/boards/[boardId].vue` —
  the kanban board view (built in the next prompt).

- Board settings: a gear icon on each board card (wrapped in
  `CanAccess permission="boards.update"`) that opens a `UModal` for
  editing name, description, color, and archiving. Include delete wrapped
  in `CanAccess permission="boards.delete"` with confirmation.

Sidebar navigation:

Add a "Boards" link to the top navigation group in
`app/components/layout/sidebar/Links.vue`, between "Dashboard" and
"Settings". Use the icon `i-solar-widget-2-bold-duotone`.

Cards (Kanban Board View)

Prompt

txt
Build the kanban board view and card management — the main interactive feature.

Server routes (all use `authUser` with the appropriate permission):

- `GET /api/teams/[teamId]/boards/[boardId]/cards` — uses
  `authUser(event, "cards.view")`. Returns all non-archived cards for the
  board with assignee name joined in, grouped by column. Order by sort_order
  asc within each column. Support optional query params: `?assigned_to=`
  to filter by assignee, `?priority=` to filter by priority,
  `?include_archived=true`.

- `POST /api/teams/[teamId]/boards/[boardId]/cards` — uses
  `authUser(event, "cards.create")`. Reads { column_id, title, description,
  assigned_to, priority, due_date, labels } from the body. Validates that
  column_id and title are required. Sets board_id from the route, sort_order
  to max existing sort_order + 1 for that column, and created_by from the
  authenticated user.

- `PATCH /api/teams/[teamId]/boards/[boardId]/cards/[cardId]` — uses
  `authUser(event, "cards.update")`. Updates whichever fields are provided.
  Validates that the card belongs to the board.

- `DELETE /api/teams/[teamId]/boards/[boardId]/cards/[cardId]` — uses
  `authUser(event, "cards.delete")`. Validates board membership.

- `POST /api/teams/[teamId]/boards/[boardId]/cards/move` — uses
  `authUser(event, "cards.update")`. Reads { card_id, target_column_id,
  target_sort_order } from the body. Moves the card to the target column
  at the specified sort position. Reorders other cards in both the source
  and target columns to fill gaps and make room.

- `GET /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/comments` — uses
  `authUser(event, "cards.view")`. Returns all comments with author name,
  ordered by created_at asc.

- `POST /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/comments` — uses
  `authUser(event, "cards.view")`. Any team member can comment. Reads
  { content } from body. Sets user_id from authenticated user.

- `GET /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist` — uses
  `authUser(event, "cards.view")`. Returns all checklist items ordered by
  sort_order asc.

- `POST /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist` — uses
  `authUser(event, "cards.update")`. Reads { title } from body.

- `PATCH /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist/[itemId]`
  — uses `authUser(event, "cards.update")`. Updates title or is_completed.

- `DELETE /api/teams/[teamId]/boards/[boardId]/cards/[cardId]/checklist/[itemId]`
  — uses `authUser(event, "cards.update")`.

UI:

Create `app/pages/boards/[boardId].vue` — the kanban board view, wrapped
in a `UDashboardPanel`:

- `UDashboardNavbar` in the header: board name as title (fetched from the
  board endpoint), `UDashboardSidebarCollapse` on the left. On the right,
  a "New Card" button wrapped in `CanAccess permission="cards.create"`,
  and a board settings gear icon.

- The board body is a horizontal scrollable container with columns
  side-by-side. Each column is a vertical container with:
  - A header showing the column name, card count (and card limit if set,
    e.g. "3 / 5"), and an "Add Card" button.
  - A scrollable list of card components below the header.
  - If the column is at its card_limit, the header shows a warning color.

- Each card component shows: title, priority badge (small colored dot),
  assignee avatar or initials, due date (with red text if overdue), label
  badges, and a checklist progress indicator (e.g. "2/5") if the card has
  checklist items.

- **Drag and drop**: cards can be dragged between columns and reordered
  within a column. Install and use `vuedraggable` (or `@vueuse/integrations`
  with sortable). On drop, call the `/cards/move` endpoint with the new
  column and position. Update the UI optimistically — show the card in its
  new position immediately, then roll back if the API call fails.

- Clicking a card opens a `USlideover` for the card detail. Show all fields
  in an editable form at the top: title, description (textarea), column
  (select to move between columns), assignee (select from team members,
  use placeholder "Unassigned"), priority (select), due date (`UPopover`
  with `UCalendar`), labels (comma-separated input).

  Below the form, show three sections:
  1. "Checklist" — a list of checklist items with checkboxes. Completed
     items are struck through. An "Add Item" input at the bottom. Delete
     button (x) on each item.
  2. "Comments" — a scrollable list of comments (author, content, timestamp)
     with a text input at the bottom to add a new comment.
  3. Actions — archive card toggle, delete button wrapped in
     `CanAccess permission="cards.delete"` with confirmation.

- "New Card" (either the top button or column-level button) opens a `UModal`
  with a quick-create form: title (required), column (pre-selected if added
  from a column button), assignee, priority, due date. Use Zod for
  validation.

- After any card change, refresh the board.

Dashboard and Realtime

Prompt 1 — Dashboard

txt
Replace the placeholder dashboard with real board and task stats.

Server route:

Create `GET /api/teams/[teamId]/stats` — uses `authUser(event, "team.view")`.
Returns a JSON object with:

- `board_count` — total number of non-archived boards for the team
- `total_cards` — total number of non-archived cards across all boards
- `overdue_cards` — count of non-archived cards with due_date < today
- `completed_this_week` — count of cards that were moved to a column named
  "Done" (case-insensitive) in the current calendar week, based on
  updated_at
- `cards_by_priority` — array of { priority, count } for non-archived cards
- `cards_by_board` — array of { board_name, board_color, card_count } for
  each non-archived board, ordered by card_count desc
- `my_cards` — up to 10 non-archived cards assigned to the requesting user,
  ordered by due_date asc (nulls last), with board name and column name
- `overdue_list` — up to 10 overdue non-archived cards with board name,
  column name, assignee name, title, and due_date

All queries are scoped to the team's team_id.

UI:

Update `app/pages/dashboard/index.vue` to show:

- A row of four stat cards at the top using a grid layout. Each card shows
  an icon, a label, and the value. Cards: "Active Boards" (board_count),
  "Total Cards" (total_cards), "Overdue" (overdue_cards, red text if > 0),
  "Completed This Week" (completed_this_week).
  Use `USkeleton` placeholders while loading.

- Below the stats, a two-column layout:
  - Left column: "My Cards" — a list of cards assigned to the current user.
    Each item shows card title, board name (with color dot), column name,
    priority badge, and due date (red if overdue). Clicking a card navigates
    to the board. Show an empty state if no assigned cards.
  - Right column: "Overdue" — a list of overdue cards. Each item shows
    title, board name, assignee name, and how many days overdue. Show an
    empty state if nothing is overdue.

Remove any placeholder/scaffolding content that was in the dashboard before.
Keep the `UDashboardPanel` wrapper with navbar and sidebar collapse.

Prompt 2 — Realtime

txt
Add Supabase Realtime sync for the new tables so changes appear instantly
across browser sessions.

Database migration (via Supabase MCP):

Enable realtime publication and full replica identity for the new tables:

```sql
ALTER PUBLICATION supabase_realtime ADD TABLE boards, columns, cards, card_comments, card_checklist_items;
ALTER TABLE boards REPLICA IDENTITY FULL;
ALTER TABLE columns REPLICA IDENTITY FULL;
ALTER TABLE cards REPLICA IDENTITY FULL;
ALTER TABLE card_comments REPLICA IDENTITY FULL;
ALTER TABLE card_checklist_items REPLICA IDENTITY FULL;
```

Composables:

1. Create `app/composables/useRealtimeCards.ts` — takes a
   `Ref<Card[]>` (or whatever the card array type is). Registers on
   the `cards` table via the existing `useRealtime` composable's
   `onTable` function. Handles INSERT (dedup against existing items by id),
   UPDATE (replace the matching item), and DELETE (remove by id). Always
   assign a new array to `.value` — do not mutate in place, or Vue will
   not re-render.

2. Create `app/composables/useRealtimeColumns.ts` — same pattern but for
   the `columns` table. Used in the board view to detect new columns or
   reorder changes.

Update `app/composables/useRealtime.ts` to subscribe to `boards`, `columns`,
`cards`, `card_comments`, and `card_checklist_items` tables in addition to
the existing subscriptions.

Integration:

- In `app/pages/boards/[boardId].vue`, call `useRealtimeCards(cards)` and
  `useRealtimeColumns(columns)` to keep the board view in sync when
  another user moves or creates cards.
- In `app/pages/boards/index.vue`, refetch the board list when any realtime
  event fires on `boards`. Use `onTable("boards", refetch)` directly.
- The dashboard stats page should refetch stats when any realtime event
  fires on cards or boards. Use `onTable("cards", refetchStats)` and
  `onTable("boards", refetchStats)` directly in the dashboard page.

What You Built

Starting from a template that handled auth, teams, roles, and permissions, you added:

  1. Boards — multiple project boards with color-coding and archiving
  2. Columns — customizable pipeline stages with card limits and drag-and-drop reordering
  3. Cards — full task cards with assignees, priorities, due dates, labels, checklists, and comments
  4. Drag and drop — cards move between columns with optimistic updates and API persistence
  5. Dashboard — personal task list, overdue tracking, and team workload
  6. Realtime — all changes sync instantly across connected browsers

Every feature follows the same patterns: permission-gated server routes, team-scoped data, Nuxt UI components, and the conventions defined in CLAUDE.md.

What's Next

  • API Keys & External API — let external tools create cards or update statuses programmatically
  • AI Chat — a chatbot that answers questions like "What cards are overdue?" or "How many tasks did we complete this week?"