/projects/tasksTask board
A team's work as a kanban board with WIP limits, filters by owner and priority, a list and a data table of the same rows, and an inline add.
Installs at /projects/tasks. The page is a server component: it reads every task through db.tasks, works out who and which project each belongs to, and hands plain rows to one client island that owns the three views, the filters, the inline add and the move. The board, the list and the data table are the same rows read three ways — the table takes the rows the board's filters left and adds its own sorting and paging — so nothing is duplicated between them. A move is optimistic — the card lands where it was dropped and the server action writes behind it — and a refusal puts the card back with the reason in a callout, because a card that sprang back silently would read as a bug. The one rule the board enforces is that a finished task is not reopened by dragging: closing something is a decision, and it is undone from the task rather than from the board. The columns and their WIP limits are vocabulary and live beside the island, not in data.ts, which imports db and must never reach the browser.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { TasksView } from "./components/tasks-view"
import {
assigneeOptions,
currentUser,
projectOptions,
shellNotifications,
taskRows,
taskSummary,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The team's board. The page is a server component: it reads every task
* through `db` and hands them to one client island, which owns the two views,
* the filters, the inline add and the move that writes.
*/
export default function TasksPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.tasks}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="projects-tasks-title"
title="Tasks"
description="Everything the team has open, by the column it is in."
meta={taskSummary()}
/>
<TasksView titleId="projects-tasks-title" rows={taskRows()} assignees={assigneeOptions()} projects={projectOptions()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/projects-tasksNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { TasksView } from "./components/tasks-view"
import {
assigneeOptions,
currentUser,
projectOptions,
shellNotifications,
taskRows,
taskSummary,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
/**
* The team's board. The page is a server component: it reads every task
* through `db` and hands them to one client island, which owns the two views,
* the filters, the inline add and the move that writes.
*/
export default function TasksPage() {
return (
<AppShell
nav={NAV}
activeHref={ROUTES.tasks}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
titleId="projects-tasks-title"
title="Tasks"
description="Everything the team has open, by the column it is in."
meta={taskSummary()}
/>
<TasksView titleId="projects-tasks-title" rows={taskRows()} assignees={assigneeOptions()} projects={projectOptions()} />
</AppShell>
)
}/**
* What this page reads. Every task, who has it and which project it belongs to
* comes from `db.tasks`, `db.members` and `db.projects`; nothing here is
* written out, and "now" is `REFERENCE_DATE`, which is what a due date is late
* against.
*
* The client island imports only the types below. The vocabulary a board needs
* — its columns, its filter options — lives beside the island in
* `components/board-vocabulary.ts`, so nothing that reaches `db` is ever
* pulled into the browser.
*/
import { getInitials } from "@/lib/format"
import { REFERENCE_DATE, db, type Member, type Project, type Task } from "@/lib/sample-data"
export type TaskStatus = Task["status"]
export type TaskPriority = Task["priority"]
export type TaskRow = {
id: string
title: string
status: TaskStatus
priority: TaskPriority
points: number
dueAt: Date
/** True when the due date is behind us and the task is not finished. */
overdue: boolean
assigneeId: string
assigneeName: string
assigneeInitials: string
assigneeAvatar?: string
projectId: string
projectCode: string
}
type Lookups = { members: Map<string, Member>; projects: Map<string, Project> }
// Read per call, never held at module scope: an assignee renamed or a project
// written since the server started is what the next render shows.
const lookups = (): Lookups => ({
members: new Map(db.members.all().map((member) => [member.id, member])),
projects: new Map(db.projects.all().map((project) => [project.id, project])),
})
/** One task as this page reads it. Exported because the actions return one. */
export function toRow(task: Task, { members, projects } = lookups()): TaskRow {
const member = members.get(task.assignee)
const project = projects.get(task.projectId)
return {
id: task.id,
title: task.title,
status: task.status,
priority: task.priority,
points: task.points,
dueAt: task.dueAt,
overdue: task.status !== "done" && task.dueAt.getTime() < REFERENCE_DATE.getTime(),
assigneeId: task.assignee,
assigneeName: member?.name ?? "Unassigned",
assigneeInitials: getInitials(member?.name ?? "Unassigned"),
assigneeAvatar: member?.avatarUrl,
projectId: task.projectId,
projectCode: project?.code ?? "—",
}
}
// Worst first, then soonest: a board is read left to right, but a column is
// read top down, and what is urgent or late belongs at the top of it.
const PRIORITY_RANK: Record<TaskPriority, number> = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }
/** Every task the team has, urgent first and soonest first inside that. */
export function taskRows(): TaskRow[] {
const read = lookups()
return db.tasks
.all()
.map((task) => toRow(task, read))
.sort((a, b) => {
if (PRIORITY_RANK[a.priority] !== PRIORITY_RANK[b.priority])
return PRIORITY_RANK[a.priority] - PRIORITY_RANK[b.priority]
return a.dueAt.getTime() - b.dueAt.getTime()
})
}
/** The line under the title: what is in flight, against how much there is. */
export function taskSummary(): string {
const rows = db.tasks.all()
const doing = rows.filter((task) => task.status === "in_progress" || task.status === "review").length
const late = rows.filter(
(task) => task.status !== "done" && task.dueAt.getTime() < REFERENCE_DATE.getTime()
).length
return `${rows.length} tasks · ${doing} in flight · ${late} late`
}
export type Option = { value: string; label: string }
/** Who can be filtered on, and who a new task can be given to. */
export function assigneeOptions(): Option[] {
const owners = new Set(db.tasks.all().map((task) => task.assignee))
return db.members
.all()
.filter((member) => owners.has(member.id))
.map((member) => ({ value: member.id, label: member.name }))
.sort((a, b) => a.label.localeCompare(b.label))
}
/** The projects a new task can be filed against: the ones still open. */
export function projectOptions(): Option[] {
return db.projects
.all()
.filter((project) => project.status !== "shipped")
.map((project) => ({ value: project.id, label: `${project.code} · ${project.name}` }))
.sort((a, b) => a.label.localeCompare(b.label))
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { REFERENCE_DATE, db, formText, invalidInput, isForm, oneOf, type Result } from "@/lib/sample-data"
import { toRow, type TaskRow, type TaskStatus } from "./data"
/**
* The two things this page changes. Server actions, so the page can stay a
* server component and still hand its client island something to call, and
* `Result` so every caller reads one success-or-error shape.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
const MAX_TITLE = 80
/** The board's columns: a task is filed in one of these or not at all. */
const STATUSES: readonly TaskStatus[] = ["backlog", "todo", "in_progress", "review", "done"]
/** Adds a task to a column. The form sends the title, the project and the column. */
export async function createTask(
_previous: Result<TaskRow> | null,
form: FormData
): Promise<Result<TaskRow>> {
if (!isForm(form)) return invalidInput("Send the task as the form sends it.")
const title = formText(form, "title")
const projectId = formText(form, "projectId")
const status = form.has("status") ? formText(form, "status") : "backlog"
if (!oneOf(status, STATUSES)) {
return { ok: false, error: { code: "invalid", message: "File the task in one of the board's columns.", field: "status" } }
}
if (title.length < 3) {
return {
ok: false,
error: { code: "invalid", message: "Give the task a title of at least three characters.", field: "title" },
}
}
if (title.length > MAX_TITLE) {
return {
ok: false,
error: { code: "invalid", message: `Keep the title under ${MAX_TITLE} characters.`, field: "title" },
}
}
const project = await db.projects.get(projectId)
if (!project) {
return {
ok: false,
error: { code: "not_found", message: "Pick a project to file this against.", field: "projectId" },
}
}
// Given to whoever leads the project, due in a week, and estimated at the
// middle of the scale: a task added from a board carries what the board
// knows and nothing it does not.
const created = await db.tasks.create({
title,
status,
priority: "medium",
assignee: project.lead,
projectId: project.id,
points: 3,
startAt: new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())),
dueAt: new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate() + 7)),
createdAt: REFERENCE_DATE,
updatedAt: REFERENCE_DATE,
labels: [],
})
if (!created.ok) return created
return { ok: true, data: toRow(created.data) }
}
/**
* Moves a task to another column. A finished task cannot be reopened from the
* board: closing one is a decision, and dragging is not how it is undone.
*/
export async function moveTask(id: string, status: TaskStatus): Promise<Result<TaskRow>> {
if (!oneOf(status, STATUSES)) return invalidInput("Move the task to one of the board's columns.", "status")
const task = await db.tasks.get(id)
if (!task) {
return { ok: false, error: { code: "not_found", message: `No task with id "${id}"` } }
}
if (task.status === "done" && status !== "done") {
return {
ok: false,
error: {
code: "not_allowed",
message: `"${task.title}" is done. Reopen it from the task itself rather than from the board.`,
},
}
}
const updated = await db.tasks.update(id, { status, updatedAt: REFERENCE_DATE })
if (!updated.ok) return updated
return { ok: true, data: toRow(updated.data) }
}"use client"
import * as React from "react"
import { PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { createTask } from "../actions"
import { type Option, type TaskRow } from "../data"
export type AddTaskFormProps = {
projects: Option[]
/** Where a new task lands; the first column of the board. */
status: string
onCreated: (row: TaskRow) => void
}
/**
* One line at the top of the board. The input is controlled because React
* resets an uncontrolled form once its action settles, which would wipe what
* was typed on every rejected submit; the project rides along in a hidden
* input so the action reads it out of FormData like every other field.
*/
export function AddTaskForm({ projects, status, onCreated }: AddTaskFormProps) {
const [state, formAction, pending] = React.useActionState(createTask, null)
const [title, setTitle] = React.useState("")
const [project, setProject] = React.useState(projects[0]?.value ?? "")
const seen = React.useRef<TaskRow | null>(null)
const errorId = React.useId()
// The board owns the rows, so a created one is handed up once — and only
// once, however many times this island re-renders on the same result.
React.useEffect(() => {
if (!state?.ok || seen.current === state.data) return
seen.current = state.data
setTitle("")
onCreated(state.data)
}, [state, onCreated])
const error = state && !state.ok ? state.error : undefined
return (
<form action={formAction} className="flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<input type="hidden" name="projectId" value={project} />
<input type="hidden" name="status" value={status} />
<Input
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Add a task…"
aria-label="Task title"
aria-invalid={error?.field === "title" || undefined}
aria-describedby={error ? errorId : undefined}
className="w-full sm:w-72"
/>
{/* `items` is what lets the closed trigger read "ATL · Atlas migration"
rather than the id it submits: Select.Value renders the raw value
unless it is given the labels to look one up in. */}
<Select
value={project}
onValueChange={(next) => setProject(next ?? "")}
items={projects.map(({ value, label }) => ({ value, label }))}
>
<SelectTrigger aria-label="Project" className="w-full sm:w-56">
<SelectValue placeholder="Project" />
</SelectTrigger>
<SelectContent>
{projects.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit" disabled={pending}>
<PlusIcon />
{pending ? "Adding…" : "Add task"}
</Button>
</div>
{error ? (
<p id={errorId} role="alert" className="text-xs text-danger">
{error.message}
</p>
) : null}
</form>
)
}import { type KanbanColumnSpec } from "@/components/ui/kanban-board"
/**
* What the board is made of, as the reader sees it: the columns, their WIP
* limits, and the words the filters offer. Vocabulary, not data — it lives
* beside the island that draws it rather than in `data.ts`, which reads `db`
* and must never reach the browser.
*/
export const COLUMNS: KanbanColumnSpec[] = [
{ id: "backlog", title: "Backlog" },
{ id: "todo", title: "To do" },
{ id: "in_progress", title: "In progress", limit: 8 },
{ id: "review", title: "In review", limit: 6 },
{ id: "done", title: "Done" },
]
export const STATUS_LABELS: Record<string, string> = Object.fromEntries(
COLUMNS.map((column) => [column.id, column.title])
)
export const PRIORITIES = [
{ value: "urgent", label: "Urgent" },
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
{ value: "none", label: "None" },
]
export const VIEWS = [
{ value: "board", label: "Board" },
{ value: "list", label: "List" },
{ value: "table", label: "Table" },
]"use client"
import * as React from "react"
import { formatDate } from "@/lib/format"
import {
DataTable,
DataTableColumnHeader,
type DataTableColumnDef,
} from "@/components/ui/data-table"
import { PriorityBadge, type Priority } from "@/components/ui/priority-badge"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { type TaskRow } from "../data"
import { STATUS_LABELS } from "./board-vocabulary"
/** A board row with the column it sits in, which a move may have changed. */
export type TableRow = TaskRow & { column: string }
/**
* The third view: the board's rows as a sortable, paged data table. It takes
* the rows the board's filters left, so the three views never disagree about
* what is on the board; sorting and paging are the table's own.
*/
export function TasksTable({ titleId, rows }: { titleId: string; rows: TableRow[] }) {
const columns = React.useMemo<DataTableColumnDef<TableRow>[]>(
() => [
{
accessorKey: "title",
header: ({ column }) => <DataTableColumnHeader column={column} title="Task" />,
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>,
meta: { label: "Task" },
},
{
accessorKey: "column",
header: ({ column }) => <DataTableColumnHeader column={column} title="Column" />,
cell: ({ row }) => (
<StatusBadge status={row.original.column} label={STATUS_LABELS[row.original.column]} size="sm" />
),
meta: { label: "Column" },
},
{
accessorKey: "priority",
header: ({ column }) => <DataTableColumnHeader column={column} title="Priority" />,
cell: ({ row }) => <PriorityBadge priority={row.original.priority as Priority} size="sm" />,
meta: { label: "Priority" },
},
{
accessorKey: "assigneeName",
header: ({ column }) => <DataTableColumnHeader column={column} title="Owner" />,
cell: ({ row }) => (
<UserCell size="sm" name={row.original.assigneeName} description={row.original.projectCode} src={row.original.assigneeAvatar} />
),
meta: { label: "Owner" },
},
{
accessorKey: "dueAt",
header: ({ column }) => <DataTableColumnHeader column={column} title="Due" />,
cell: ({ row }) => (
// The entity stores midnight UTC; a zone west of Greenwich would read
// it a day early, so the date is pinned the way the board pins it.
<time
dateTime={row.original.dueAt.toISOString()}
data-overdue={row.original.overdue || undefined}
className={row.original.overdue ? "text-danger" : "text-muted-foreground"}
>
{formatDate(row.original.dueAt, "short", { timeZone: "UTC" })}
</time>
),
meta: { align: "right", label: "Due" },
},
{
accessorKey: "points",
header: ({ column }) => <DataTableColumnHeader column={column} title="Points" />,
cell: ({ row }) => <span className="tabular-nums">{row.original.points}</span>,
meta: { align: "right", label: "Points" },
},
],
[]
)
return (
<DataTable
aria-labelledby={titleId}
size="sm"
columns={columns}
data={rows}
pageSize={20}
enableRowSelection={false}
getRowId={(task) => task.id}
emptyMessage="No task matches those filters."
/>
)
}"use client"
import * as React from "react"
import { formatDate } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Callout } from "@/components/ui/callout"
import { FilterBar, FilterMenu } from "@/components/ui/filter-bar"
import { KanbanBoard } from "@/components/ui/kanban-board"
import { PriorityBadge, type Priority } from "@/components/ui/priority-badge"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { SimpleTable, type SimpleTableColumn } from "@/components/ui/simple-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { moveTask } from "../actions"
import { type Option, type TaskRow, type TaskStatus } from "../data"
import { AddTaskForm } from "./add-task-form"
import { COLUMNS, PRIORITIES, STATUS_LABELS, VIEWS } from "./board-vocabulary"
import { TasksTable } from "./tasks-table"
export type TasksViewProps = {
/** The page's h1 id, which names the table. */
titleId: string
rows: TaskRow[]
assignees: Option[]
projects: Option[]
}
type Card = TaskRow & { column: string }
/**
* The board, the list and the table are the same rows read three ways, so the
* filters, the inline add and the move all sit here rather than being
* duplicated per view.
*
* A move is optimistic: the card lands where it was dropped straight away and
* the action writes it behind that. A refusal — the board will not reopen a
* finished task — puts the card back and says why, because a card that
* silently sprang back would read as a bug.
*/
export function TasksView({ titleId, rows, assignees, projects }: TasksViewProps) {
const [tasks, setTasks] = React.useState<Card[]>(() =>
rows.map((row) => ({ ...row, column: row.status }))
)
const [view, setView] = React.useState("board")
const [owners, setOwners] = React.useState<string[]>([])
const [priorities, setPriorities] = React.useState<string[]>([])
const [refusal, setRefusal] = React.useState<string | null>(null)
const filtered = owners.length > 0 || priorities.length > 0
const visible = tasks.filter(
(task) =>
(owners.length === 0 || owners.includes(task.assigneeId)) &&
(priorities.length === 0 || priorities.includes(task.priority))
)
const created = React.useCallback((row: TaskRow) => {
setTasks((current) => [{ ...row, column: row.status }, ...current])
}, [])
async function move(id: string, column: string, index: number) {
const before = tasks
// `index` counts the *visible* cards KanbanBoard was given — under an
// active filter that is a strict subset of the column. Splicing it into
// the unfiltered column directly would land the card among cards the
// filters are hiding, so find the visible card it belongs before instead,
// and place it there in the full array.
const visibleLanding = visible.filter((task) => task.column === column && task.id !== id)
const neighbourId = visibleLanding[index]?.id
setTasks((current) => {
const moving = current.find((task) => task.id === id)
if (!moving) return current
const rest = current.filter((task) => task.id !== id)
const landing = rest.filter((task) => task.column === column)
const at = neighbourId ? landing.findIndex((task) => task.id === neighbourId) : landing.length
landing.splice(at < 0 ? landing.length : at, 0, { ...moving, column, status: column as TaskStatus })
return COLUMNS.flatMap((spec) =>
spec.id === column ? landing : rest.filter((task) => task.column === spec.id)
)
})
setRefusal(null)
const result = await moveTask(id, column as TaskStatus)
if (result.ok) {
// The action recomputes more than the column — overdue changes with it —
// so the optimistic card is refreshed with what the action actually
// wrote rather than kept as the guess made before the move settled.
setTasks((current) => current.map((task) => (task.id === id ? { ...task, ...result.data, column } : task)))
} else {
setTasks(before)
setRefusal(result.error.message)
}
}
const columns: SimpleTableColumn<Card>[] = [
{ key: "title", header: "Task", cell: (task) => <span className="font-medium">{task.title}</span> },
{ key: "projectCode", header: "Project", cell: (task) => <span className="font-mono text-xs">{task.projectCode}</span> },
{ key: "status", header: "Column", cell: (task) => <StatusBadge status={task.column} label={STATUS_LABELS[task.column]} /> },
{ key: "priority", header: "Priority", cell: (task) => <PriorityBadge priority={task.priority as Priority} size="sm" /> },
{ key: "assigneeName", header: "Owner" },
{
key: "dueAt",
header: "Due",
align: "right",
cell: (task) => (
<span data-overdue={task.overdue || undefined} className={task.overdue ? "text-danger" : undefined}>
{formatDate(task.dueAt, "short", { timeZone: "UTC" })}
</span>
),
},
{ key: "points", header: "Points", align: "right" },
]
return (
<div className="flex flex-col gap-4">
<AddTaskForm projects={projects} status={COLUMNS[0].id} onCreated={created} />
<div className="flex flex-wrap items-center justify-between gap-3">
<FilterBar
activeCount={owners.length + priorities.length}
onClearAll={() => {
setOwners([])
setPriorities([])
}}
>
<FilterMenu label="Assignee" options={assignees} value={owners} onValueChange={setOwners} />
<FilterMenu label="Priority" options={PRIORITIES} value={priorities} onValueChange={setPriorities} />
</FilterBar>
<SegmentedControl aria-label="View" options={VIEWS} value={view} onValueChange={setView} size="sm" />
</div>
{refusal ? (
<Callout variant="danger" title="That move was refused" role="alert">
{refusal}
</Callout>
) : null}
{view === "board" ? (
<KanbanBoard<TaskRow>
aria-label="Tasks"
// The board sits straight under the page's h1, so its columns are
// the h2s; the default h3 skipped a level.
headingLevel={2}
columns={COLUMNS}
cards={visible}
cardLabel={(task) => task.title}
onMove={move}
emptyMessage={filtered ? "No tasks match those filters." : undefined}
renderCard={(task) => (
<div className="flex flex-col gap-2">
<p className="text-sm leading-snug">{task.title}</p>
<div className="flex items-center justify-between gap-2">
<span className="type-eyebrow font-mono text-faint-foreground">{task.projectCode}</span>
<span
data-overdue={task.overdue || undefined}
className={task.overdue ? "text-2xs text-danger" : "text-2xs text-muted-foreground"}
>
{formatDate(task.dueAt, "short", { timeZone: "UTC" })}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<PriorityBadge priority={task.priority as Priority} size="sm" />
<Avatar className="size-5">
{task.assigneeAvatar ? <AvatarImage src={task.assigneeAvatar} alt="" /> : null}
<AvatarFallback className="text-avatar">{task.assigneeInitials}</AvatarFallback>
</Avatar>
</div>
</div>
)}
/>
) : view === "table" ? (
<TasksTable titleId={titleId} rows={visible} />
) : (
<SimpleTable
columns={columns}
rows={visible}
rowKey="id"
size="sm"
hoverable
caption={`${visible.length} tasks`}
emptyMessage="No task matches those filters."
/>
)}
</div>
)
}