Task cards
Work items as cards: the title, the assignees, the due date and the progress.
Kanban cards on their board
task-cards-01The busiest open project's work in flight as To do, In progress and In review: each card with its code and labels, the title, its steps as a bar with the count, priority, due in words and its faces; moved by pointer or keyboard.
import * as React from "react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { TaskBoard } from "./task-cards-01.client"
import { TASKS_HREF, projectBoard } from "./task-cards-01.data"
/**
* Kanban cards on their board: the work in flight on the busiest open
* project, as To do, In progress and In review. Each card carries its
* project code and labels, the title, its steps as a bar with the count, its
* priority, when it is due — late said in words — and who is on it. A card
* moves by pointer or from the keyboard, and the board reads every move out.
*
* A server component: it reads the board through `./task-cards-01.data` and
* hands the island plain rows.
*/
export function TaskCards01() {
const titleId = React.useId()
const board = projectBoard()
return (
<section
data-section="task-cards-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{board ? board.project.name : "Project board"}</h2>
</CardTitle>
<CardDescription>
{board
? "The work in flight, soonest due first. Drag a card, or focus it and move it with the arrow keys."
: "The busiest open project’s work in flight."}
</CardDescription>
<CardAction>
<Link
href={board ? `${TASKS_HREF}?project=${board.project.id}` : TASKS_HREF}
className={buttonVariants({ variant: "outline", size: "sm" })}
>
All tasks
</Link>
</CardAction>
</CardHeader>
{board ? (
<CardContent className="p-3">
<TaskBoard label={`${board.project.name} board`} columns={board.columns} cards={board.cards} />
</CardContent>
) : (
<CardContent>
<EmptyState size="sm" title="No open project" description="Every project has shipped. A new one’s board shows here." />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default TaskCards01"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { KanbanBoard, type KanbanCard } from "@/components/ui/kanban-board"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { Progress } from "@/components/ui/progress"
import { TagList } from "@/components/ui/tag-list"
import type { BoardCard, BoardColumn, ColumnId } from "./task-cards-01.data"
type Card = KanbanCard<BoardCard>
/** A task on the board: project and labels, the title, its steps as a bar, then priority, due and who is on it. */
function TaskCardBody({ card }: { card: Card }) {
const total = card.subtasks.length
const done = card.subtasks.filter((step) => step.done).length
return (
<div data-task-id={card.id} className="flex flex-col gap-2.5">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-mono text-2xs text-muted-foreground">{card.project.code}</span>
{card.labels.length > 0 ? <TagList tags={card.labels} size="sm" /> : null}
</div>
<p className="text-sm leading-5 font-medium text-pretty">{card.title}</p>
<div className="flex items-center gap-2">
<Progress
value={card.progress}
aria-label={`Subtasks of ${card.title}`}
aria-valuetext={`${done} of ${total} done`}
className="min-w-0 flex-1"
/>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{done}/{total}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
<PriorityBadge priority={card.priority} size="sm" />
<time
dateTime={card.due.iso}
data-overdue={card.due.overdue || undefined}
className={cn("text-xs tabular-nums", card.due.overdue ? "font-medium text-danger" : "text-muted-foreground")}
>
{card.due.words}
</time>
</div>
<AvatarGroup size="xs" max={3} users={card.assignees.map((person) => ({ name: person.name, src: person.avatarUrl }))} />
</div>
</div>
)
}
/**
* Puts a card at `index` among the cards of `column`, counted once it has
* left wherever it was, showing what that column's rules make of it: in
* review, every step done but the last and a reviewer on it.
*/
function place(rows: Card[], id: string, column: string, index: number): Card[] {
const card = rows.find((row) => row.id === id)
if (!card) return rows
const rest = rows.filter((row) => row.id !== id)
const moved: Card = { ...card, ...card.views[column as ColumnId], column, status: column as Card["status"] }
const members = rest.filter((row) => row.column === column)
const anchor = members[index]
const at = anchor ? rest.indexOf(anchor) : members.length > 0 ? rest.indexOf(members[members.length - 1]) + 1 : rest.length
return [...rest.slice(0, at), moved, ...rest.slice(at)]
}
/**
* The board: the kit's KanbanBoard with a task card for each row. A card
* moves by pointer or by keyboard — the arrows move a focused card, Space
* picks it up and puts it down, Escape puts it back — and every move is
* read out by the board. The column counts and their points follow the
* cards, and each card's steps and people follow its column; In progress
* says when it is over its limit.
*/
export function TaskBoard({ columns, cards, label }: { columns: BoardColumn[]; cards: BoardCard[]; label: string }) {
const [rows, setRows] = React.useState<Card[]>(() => cards.map((card) => ({ ...card, column: card.status })))
const specs = columns.map((column) => {
const points = rows.filter((row) => row.column === column.id).reduce((sum, row) => sum + row.points, 0)
return { ...column, meta: `${points} ${points === 1 ? "point" : "points"}` }
})
return (
<KanbanBoard
aria-label={label}
columns={specs}
cards={rows}
renderCard={(card) => <TaskCardBody card={card} />}
cardLabel={(card) => card.title}
onMove={(id, column, index) => setRows((current) => place(current, id, column, index))}
emptyMessage="Nothing here yet"
/>
)
}/**
* What `task-cards-01` reads: one project's board — the project with the
* most work in flight across To do, In progress and In review — with up to
* `PER_COLUMN` of its tasks in each, soonest due first. Every task is a
* `db.tasks` row read through `board-projects`, which says who is on it, its
* steps and how far they are, and when it is due in words, the way every
* task band does.
*
* A card can move, and the board's rules follow it: a task in review has
* every step done but the last and a reviewer on it, one in progress is
* part way. So each card carries what it shows in every column —
* `boardTask` read as if the task stood there — and the island swaps to
* the column a card lands in, without reading the store.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { TASKS_HREF, TASK_STATUS_LABELS, boardTask, type BoardTask } from "@/lib/dashboards/projects/board"
import { db, type Task } from "@/lib/sample-data"
export { TASKS_HREF }
export type { BoardTask }
/** The columns the board shows, in order. */
export const COLUMNS = ["todo", "in_progress", "review"] as const
export type ColumnId = (typeof COLUMNS)[number]
/** How many cards each column starts with. */
export const PER_COLUMN = 3
/** In progress takes no more than this at once; the board says when it is over. */
export const WIP_LIMIT = 3
export type BoardColumn = { id: ColumnId; title: string; limit?: number }
/** What a card shows that depends on its column: its steps and their bar, and who is on it. */
export type ColumnView = Pick<BoardTask, "subtasks" | "progress" | "assignees">
/** A card, with what it shows in each column by the board's rules. */
export type BoardCard = BoardTask & { views: Record<ColumnId, ColumnView> }
export type ProjectBoard = {
project: BoardTask["project"]
columns: BoardColumn[]
cards: BoardCard[]
}
/** A task read as if it stood in `column`: the board's rules for that column, applied to it. */
function viewIn(task: Task, column: ColumnId): ColumnView {
const { subtasks, progress, assignees } = boardTask({ ...task, status: column })
return { subtasks, progress, assignees }
}
function boardCard(task: Task): BoardCard {
const views = Object.fromEntries(COLUMNS.map((column) => [column, viewIn(task, column)])) as Record<ColumnId, ColumnView>
return { ...boardTask(task), views }
}
/** The busiest project that is still open, its columns, and its cards column by column; null with no open project. */
export function projectBoard(): ProjectBoard | null {
const open = db.projects.all().filter((project) => project.status !== "shipped")
const inFlight = (projectId: string) =>
db.tasks.all().filter((task) => task.projectId === projectId && (COLUMNS as readonly string[]).includes(task.status))
const busiest = [...open].sort((a, b) => inFlight(b.id).length - inFlight(a.id).length || a.name.localeCompare(b.name))[0]
if (!busiest) return null
const tasks = inFlight(busiest.id).sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.id.localeCompare(b.id))
const cards = COLUMNS.flatMap((column) => tasks.filter((task) => task.status === column).slice(0, PER_COLUMN)).map(boardCard)
return {
project: { id: busiest.id, code: busiest.code, name: busiest.name },
columns: COLUMNS.map((id) => ({ id, title: TASK_STATUS_LABELS[id], ...(id === "in_progress" ? { limit: WIP_LIMIT } : {}) })),
cards,
}
}npx shadcn@latest add @vibra/task-cards-01Task with a subtask checklist
task-cards-02The reader's most pressing task opened: its steps as a checklist whose bar and count follow every tick, beside who is on it, when it is due in words, its priority, project, labels and estimate.
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { StatusBadge } from "@/components/ui/status-badge"
import { TagList } from "@/components/ui/tag-list"
import { SubtaskChecklist } from "./task-cards-02.client"
import { TASKS_HREF, focusTask, type BoardTask } from "./task-cards-02.data"
/** One fact about the task: a quiet term over its value. */
function Fact({ term, children }: { term: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<dt className="text-xs text-muted-foreground">{term}</dt>
<dd className="text-sm">{children}</dd>
</div>
)
}
/** The task itself: its steps to tick off, and beside them the facts about it. */
function Task({ task }: { task: BoardTask }) {
const taskTitleId = React.useId()
const people = task.assignees.map((person) => ({ name: person.name, src: person.avatarUrl }))
return (
<article aria-labelledby={taskTitleId} className="grid lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="flex min-w-0 flex-col gap-5 p-5 md:p-6">
<div className="flex flex-col gap-2">
<p className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{task.project.code}</span>
<StatusBadge status={task.status} label={task.statusLabel} size="sm" />
</p>
<h3 id={taskTitleId} className="type-display text-xl text-balance">
{task.title}
</h3>
</div>
<SubtaskChecklist steps={task.subtasks} />
</div>
<dl className="grid content-start gap-5 border-t border-border p-5 sm:grid-cols-2 md:p-6 lg:grid-cols-1 lg:border-t-0 lg:border-s">
<Fact term="Assignees">
<span className="flex items-center gap-2.5">
<AvatarGroup users={people} size="sm" />
<span className="min-w-0 text-sm">{task.assignees.map((person) => person.name).join(", ")}</span>
</span>
</Fact>
<Fact term="Due">
<span className="flex flex-wrap items-baseline gap-x-2">
<time dateTime={task.due.iso} className="tabular-nums">
{task.due.label}
</time>
<span className={cn("text-xs", task.due.overdue ? "font-medium text-danger" : "text-muted-foreground")}>
{task.due.words}
</span>
</span>
</Fact>
<Fact term="Priority">
<PriorityBadge priority={task.priority} />
</Fact>
<Fact term="Project">
<Link
href={`${TASKS_HREF}?project=${task.project.id}`}
className="rounded-sm hover:underline focus-ring"
>
{task.project.name}
</Link>
</Fact>
{task.labels.length > 0 ? (
<Fact term="Labels">
<TagList tags={task.labels} />
</Fact>
) : null}
<Fact term="Estimate">
<span className="tabular-nums">
{task.points} {task.points === 1 ? "point" : "points"}
</span>
</Fact>
</dl>
</article>
)
}
/**
* One task opened: the reader's most pressing piece of work, its steps as a
* checklist they can tick off — the bar and the count above it follow every
* tick — and beside it, from `lg`, who is on it, when it is due (late said
* in words, not only in red), its priority, its project, its labels and its
* estimate.
*
* A server component: it reads the task through `./task-cards-02.data` and
* hands the island the steps.
*/
export function TaskCards02() {
const titleId = React.useId()
const task = focusTask()
return (
<section
data-section="task-cards-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Up next for you</h2>
</CardTitle>
<CardDescription>Your task in progress that is due soonest</CardDescription>
<CardAction>
<Link href={task ? task.href : TASKS_HREF} className={buttonVariants({ variant: "outline", size: "sm" })}>
{task ? "Open task" : "All tasks"}
</Link>
</CardAction>
</CardHeader>
{task ? (
<CardContent className="p-0">
<Task task={task} />
</CardContent>
) : (
<CardContent>
<EmptyState size="sm" title="Nothing on the board" description="When a task is on the board, the one due soonest waits here." />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default TaskCards02"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import type { Subtask } from "./task-cards-02.data"
export type SubtaskChecklistProps = {
steps: Subtask[]
}
const subtasks = (count: number) => `${count} ${count === 1 ? "subtask" : "subtasks"}`
/**
* A task's steps as a checklist, with the bar and the count above it that
* follow every tick. The status line under the list says what the last tick
* did, and says so plainly once every step is done. Ticking is the island's
* own state: wire `toggle` to your task store to keep it.
*/
export function SubtaskChecklist({ steps }: SubtaskChecklistProps) {
const uid = React.useId()
const labelId = `${uid}-label`
const [done, setDone] = React.useState<ReadonlySet<string>>(() => new Set(steps.filter((step) => step.done).map((step) => step.id)))
const [message, setMessage] = React.useState<string | null>(null)
const total = steps.length
const percent = total === 0 ? 0 : Math.round((done.size / total) * 100)
function toggle(step: Subtask, checked: boolean) {
const next = new Set(done)
if (checked) next.add(step.id)
else next.delete(step.id)
setDone(next)
const tally = `${next.size} of ${subtasks(total)} done.`
setMessage(
next.size === total
? `${step.title}: done. All ${subtasks(total)} done, ready for review.`
: `${step.title}: ${checked ? "done" : "not done"}. ${tally}`
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span id={labelId} className="font-medium">
Subtasks
</span>
<span className="text-muted-foreground tabular-nums">
{done.size} of {total} · {percent}%
</span>
</div>
<Progress value={percent} aria-labelledby={labelId} aria-valuetext={`${done.size} of ${total} done`} />
</div>
<ul aria-labelledby={labelId} className="flex flex-col divide-y divide-border border-y border-border">
{steps.map((step, index) => {
const id = `${uid}-step-${index}`
const checked = done.has(step.id)
return (
<li key={step.id} className="flex items-center gap-3 py-2.5">
<Checkbox id={id} checked={checked} onCheckedChange={(value) => toggle(step, value === true)} />
<Label
htmlFor={id}
className={cn("flex-1 leading-5 font-normal", checked && "text-muted-foreground line-through")}
>
{step.title}
</Label>
</li>
)
})}
</ul>
{/* Always mounted, so each tick is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="text-xs text-muted-foreground tabular-nums">
{message ?? `${done.size} of ${subtasks(total)} done. Tick a step as you finish it.`}
</p>
</div>
)
}/**
* What `task-cards-02` reads: the reader's most pressing task — of the work
* the reader (from `team-projects`: the first member, the workspace's owner)
* has in progress, the one due soonest — with its steps to tick off. The task is a `db.tasks` row read
* through `board-projects`, which says who is on it, its steps and how far
* they are, and when it is due in words, the way every task band does.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { TASKS_HREF, boardTask, type BoardTask, type Subtask } from "@/lib/dashboards/projects/board"
import { reader } from "@/lib/dashboards/projects/team"
import { db } from "@/lib/sample-data"
export { TASKS_HREF }
export type { BoardTask, Subtask }
/**
* The reader's in-progress task due soonest; a tie goes to the higher
* priority. With nothing in progress, their soonest task not yet done;
* with nothing of theirs open, the board's first; with no task at all, null.
*/
export function focusTask(): BoardTask | null {
const who = reader()
const rank = { urgent: 0, high: 1, medium: 2, low: 3, none: 4 }
const mine = who
? db.tasks
.all()
.filter((task) => task.assignee === who.id && task.status !== "done")
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || rank[a.priority] - rank[b.priority])
: []
const task = mine.find((row) => row.status === "in_progress") ?? mine[0] ?? db.tasks.all()[0]
return task ? boardTask(task) : null
}npx shadcn@latest add @vibra/task-cards-02Review cards with comments and files
task-cards-03The soonest-due tasks in review as cards: priority and project code, the title, labels and steps, and along the foot the faces, due in words, and how many comments and files each has.
import * as React from "react"
import Link from "next/link"
import { MessageSquareIcon, PaperclipIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { Progress } from "@/components/ui/progress"
import { TagList } from "@/components/ui/tag-list"
import { TASKS_HREF, inReview, reviewTotal, type BoardTask } from "./task-cards-03.data"
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/** One task as a card: its priority and code, the title, labels, steps, then who, when, and the talk and files on it. */
function ReviewCard({ task }: { task: BoardTask }) {
const done = task.subtasks.filter((step) => step.done).length
const total = task.subtasks.length
return (
<li className="group/task relative flex flex-col gap-3 bg-card p-4 has-[a:focus-visible]:focus-outline-inset">
<div className="flex items-center justify-between gap-2">
<PriorityBadge priority={task.priority} size="sm" />
<span className="font-mono text-2xs text-muted-foreground">{task.project.code}</span>
</div>
<h3 className="text-sm leading-5 font-semibold text-pretty">
{/* The stretched link: the whole card opens the task. */}
<Link
href={task.href}
className="outline-none after:absolute after:inset-0 group-hover/task:underline group-hover/task:underline-offset-4"
>
{task.title}
</Link>
</h3>
{task.labels.length > 0 ? <TagList tags={task.labels} size="sm" /> : null}
<div className="flex items-center gap-2">
<Progress
value={task.progress}
aria-label={`Subtasks of ${task.title}`}
aria-valuetext={`${done} of ${total} done`}
className="min-w-0 flex-1"
/>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{done}/{total}
</span>
</div>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border pt-3">
<AvatarGroup size="xs" users={task.assignees.map((person) => ({ name: person.name, src: person.avatarUrl }))} />
<div className="flex items-center gap-3 text-xs text-muted-foreground tabular-nums">
<time
dateTime={task.due.iso}
data-overdue={task.due.overdue || undefined}
className={cn(task.due.overdue && "font-medium text-danger")}
>
{task.due.words}
</time>
<span className="inline-flex items-center gap-1">
<MessageSquareIcon aria-hidden="true" className="size-3.5" />
{task.comments}
<span className="sr-only">{task.comments === 1 ? " comment" : " comments"}</span>
</span>
<span className="inline-flex items-center gap-1">
<PaperclipIcon aria-hidden="true" className="size-3.5" />
{task.attachments}
<span className="sr-only">{task.attachments === 1 ? " file" : " files"}</span>
</span>
</div>
</div>
</li>
)
}
/**
* Task cards waiting on review: the soonest due of the In review column, each
* with its priority, project code, title, labels and steps, and along its
* foot who is on it, when it is due — late said in words — and how many
* comments and files it has gathered. Each card opens its task. One across
* on a phone, two from `sm`, four from `xl`, the cells ruled rather than
* boxed.
*
* A server component: it reads the tasks through `./task-cards-03.data`.
*/
export function TaskCards03() {
const titleId = React.useId()
const tasks = inReview()
const total = reviewTotal()
return (
<section
data-section="task-cards-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Waiting on review</h2>
</CardTitle>
<CardDescription className="tabular-nums">
{tasks.length < total ? `The ${tasks.length} due soonest of ${plural(total, "task", "tasks")} in review` : `${plural(total, "task", "tasks")} in review`}
</CardDescription>
<CardAction>
<Link href={`${TASKS_HREF}?status=review`} className={buttonVariants({ variant: "outline", size: "sm" })}>
All in review
</Link>
</CardAction>
</CardHeader>
{tasks.length > 0 ? (
<CardContent className="p-0">
{/* The grid's ground shows through the one-pixel gaps as the rules between cards. */}
<ul className="grid gap-px bg-border sm:grid-cols-2 xl:grid-cols-4">
{tasks.map((task) => (
<ReviewCard key={task.id} task={task} />
))}
</ul>
</CardContent>
) : (
<CardContent>
<EmptyState size="sm" title="Nothing waiting on review" description="Tasks moved to In review wait here, the soonest due first." />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default TaskCards03/**
* What `task-cards-03` reads: the work waiting on review, soonest due first —
* the tasks in the In review column, `REVIEW_COUNT` of them. Every task is a
* `db.tasks` row read through `board-projects`, which says who is on it (the
* reviewer among them), its steps and how far they are, when it is due in
* words, and how many comments and files it has gathered, the way every
* task band does.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { TASKS_HREF, boardTasks, type BoardTask } from "@/lib/dashboards/projects/board"
export { TASKS_HREF }
export type { BoardTask }
/** How many cards the band shows. */
export const REVIEW_COUNT = 4
/** The tasks in review, soonest due first. */
export function inReview(count = REVIEW_COUNT): BoardTask[] {
return boardTasks()
.filter((task) => task.status === "review")
.slice(0, count)
}
/** How many tasks are in review altogether. */
export function reviewTotal(): number {
return boardTasks().filter((task) => task.status === "review").length
}npx shadcn@latest add @vibra/task-cards-03Compact rows for the week
task-cards-04The reader's tasks that are late or due within seven days as compact rows: a box to tick it off, the title, code, labels, due in words, a steps bar with its share, priority and faces.
import * as React from "react"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmptyState } from "@/components/ui/empty-state"
import { WeekList } from "./task-cards-04.client"
import { TASKS_HREF, WEEK_DAYS, myWeek, readerFirst } from "./task-cards-04.data"
/**
* The reader's week as a compact list: every task of theirs that is late or
* due within the next seven days, one row each, soonest first, with a box
* to tick it off.
*
* A server component: it reads the tasks through `./task-cards-04.data` and
* hands the island plain rows.
*/
export function TaskCards04() {
const titleId = React.useId()
const tasks = myWeek()
const late = tasks.filter((task) => task.due.overdue).length
const first = readerFirst()
return (
<section
data-section="task-cards-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{first ? `${first}’s week` : "Your week"}</h2>
</CardTitle>
<CardDescription className="tabular-nums">
Late or due in the next {WEEK_DAYS} days · {late} late
</CardDescription>
<CardAction>
<Link href={`${TASKS_HREF}?assignee=me`} className={buttonVariants({ variant: "outline", size: "sm" })}>
All my tasks
</Link>
</CardAction>
</CardHeader>
{tasks.length > 0 ? (
<WeekList tasks={tasks} />
) : (
<CardContent>
<EmptyState size="sm" title="Nothing due this week" description={`No task of yours is late or due in the next ${WEEK_DAYS} days.`} />
</CardContent>
)}
</Card>
</div>
</section>
)
}
export default TaskCards04"use client"
import * as React from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { CardContent, CardFooter } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { Progress } from "@/components/ui/progress"
import { TagList } from "@/components/ui/tag-list"
import type { BoardTask } from "./task-cards-04.data"
const left = (count: number) => `${count} ${count === 1 ? "task" : "tasks"} left this week`
/**
* The reader's week as compact rows: a box that marks the task done, the
* title as a link to it, its code and — from `md` — its labels, when it is
* due (late said in words), its steps as a small bar with the share done,
* its priority — the word from `sm`, the icon alone on a phone with the word
* read out — and who is on it. Each is drawn once: the row is a grid that
* moves the bar under the words on a phone. A ticked row is struck through
* and says Done; the line under the list says what the tick did and how
* many are left.
*/
export function WeekList({ tasks }: { tasks: BoardTask[] }) {
const [done, setDone] = React.useState<ReadonlySet<string>>(() => new Set())
const [message, setMessage] = React.useState<string | null>(null)
function toggle(task: BoardTask, checked: boolean) {
const next = new Set(done)
if (checked) next.add(task.id)
else next.delete(task.id)
setDone(next)
setMessage(`${checked ? "Marked" : "Reopened"} “${task.title}”${checked ? " done" : ""}. ${left(tasks.length - next.size)}.`)
}
return (
<>
<CardContent className="p-0">
<ul className="divide-y divide-border">
{tasks.map((task) => {
const finished = done.has(task.id)
const steps = task.subtasks.filter((step) => step.done).length
return (
// One grid, two shapes. On a phone the steps' bar sits under the words, and the box,
// the priority and the faces span both lines; from `sm` everything is one line,
// the bar, the priority and the faces each in a column of their own.
<li
key={task.id}
data-done={finished || undefined}
className="grid grid-cols-[auto_minmax(0,1fr)_auto_3.5rem] items-center gap-x-3 gap-y-1 px-4 py-3 sm:grid-cols-[auto_minmax(0,1fr)_7rem_4.5rem_4rem] md:px-5"
>
<Checkbox
checked={finished}
onCheckedChange={(value) => toggle(task, value === true)}
aria-label={`Mark ${task.title} done`}
className="col-start-1 row-span-2 row-start-1 sm:row-span-1"
/>
<div className="col-start-2 row-start-1 flex min-w-0 flex-col gap-1 md:flex-row md:items-center md:gap-3">
<Link
href={task.href}
className={cn(
"truncate rounded-sm text-sm font-medium hover:underline hover:underline-offset-4 focus-ring",
finished && "text-muted-foreground line-through"
)}
>
{task.title}
</Link>
<div className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{task.project.code}</span>
{task.labels.length > 0 ? <TagList tags={task.labels} size="sm" className="hidden flex-nowrap md:flex" /> : null}
<time
dateTime={task.due.iso}
data-overdue={(!finished && task.due.overdue) || undefined}
className={cn("tabular-nums", !finished && task.due.overdue && "font-medium text-danger")}
>
{finished ? "Done" : task.due.words}
</time>
</div>
</div>
<div className="col-start-2 row-start-2 flex min-w-0 items-center gap-2 sm:col-start-3 sm:row-start-1">
<Progress
value={task.progress}
aria-label={`Subtasks of ${task.title}`}
aria-valuetext={`${steps} of ${task.subtasks.length} done`}
className="min-w-0 flex-1"
/>
<span className="w-8 text-right text-xs text-muted-foreground tabular-nums">{task.progress}%</span>
</div>
{/* The word from `sm`; on a phone the icon alone, the word still read out. */}
<PriorityBadge
priority={task.priority}
size="sm"
className="col-start-3 row-span-2 row-start-1 justify-self-start max-sm:px-1 max-sm:[&>span]:sr-only sm:col-start-4 sm:row-span-1"
/>
<AvatarGroup
size="xs"
className="col-start-4 row-span-2 row-start-1 justify-self-end sm:col-start-5 sm:row-span-1"
users={task.assignees.map((person) => ({ name: person.name, src: person.avatarUrl }))}
/>
</li>
)
})}
</ul>
</CardContent>
<CardFooter className="text-xs text-muted-foreground">
{/* Always mounted, so each tick is read out rather than a node appearing. */}
<p role="status" aria-live="polite" className="text-pretty tabular-nums">
{message ?? `${left(tasks.length - done.size)}. Tick one off when it is finished.`}
</p>
</CardFooter>
</>
)
}/**
* What `task-cards-04` reads: the reader's week — the tasks the reader (from
* `team-projects`: the first member, the workspace's owner) has not finished
* that are late or due within `WEEK_DAYS`, soonest first. Every task is a `db.tasks` row read through
* `board-projects`, which says who is on it, its steps and how far they are,
* and when it is due in words, the way every task band does.
*
* A selector only, reading `db` inside a function and run on the server.
*/
import { TASKS_HREF, boardTask, dueOf, type BoardTask } from "@/lib/dashboards/projects/board"
import { reader } from "@/lib/dashboards/projects/team"
import { db } from "@/lib/sample-data"
export { TASKS_HREF }
export type { BoardTask }
/** How far ahead the week looks, in days. */
export const WEEK_DAYS = 7
/** Whose week it is: the reader's first name, or null in a store with no members. */
export function readerFirst(): string | null {
return reader()?.first ?? null
}
/** The reader's unfinished tasks that are late or due within the week, soonest first. */
export function myWeek(): BoardTask[] {
const who = reader()
if (!who) return []
return db.tasks
.all()
.filter((task) => task.assignee === who.id && task.status !== "done" && dueOf(task).days <= WEEK_DAYS)
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime() || a.id.localeCompare(b.id))
.map(boardTask)
}npx shadcn@latest add @vibra/task-cards-04