Compare the current and next sample planning periods. Select a workstream to inspect its hours and share, while the donut and the table retain all four workstreams. The values are planned hours, not time-tracking records.
"use client";
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartSelect,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const periods = {
Current: [48, 72, 36, 24],
Next: [36, 90, 54, 30],
} as const;
const workstreams = ["Design", "Build", "Test", "Operations"];
const config = {
hours: { label: "Hours" },
Design: { label: "Design", color: "var(--chart-1)" },
Build: { label: "Build", color: "var(--chart-2)" },
Test: { label: "Test", color: "var(--chart-3)" },
Operations: { label: "Operations", color: "var(--chart-4)" },
} satisfies ChartConfig;
export default function ChartWorkAllocation() {
const [period, setPeriod] = useState<keyof typeof periods>("Current");
const [selected, setSelected] = useState("Design");
const data = workstreams.map((work, index) => ({
work,
hours: periods[period][index],
fill: `var(--color-${work})`,
}));
const total = data.reduce((sum, row) => sum + row.hours, 0);
const selectedRow = data.find((row) => row.work === selected)!;
return (
<Card className="min-w-0" data-chart-recipe="allocation">
<CardHeader>
<p className="font-mono text-xs uppercase tracking-widest">
Allocation / Read the whole and its parts
</p>
<CardTitle>
<h3>Work allocation</h3>
</CardTitle>
<CardDescription>
Planned hours across four workstreams. Illustrative planning data, not tracked time.
</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
<div className="flex flex-wrap gap-4">
<ChartSelect
label="Allocation period"
value={period}
onValueChange={(value) => setPeriod(value as keyof typeof periods)}
options={Object.keys(periods).map((value) => ({ value, label: value }))}
/>
<ChartSelect
label="Inspect workstream"
value={selected}
onValueChange={setSelected}
options={workstreams.map((value) => ({ value, label: value }))}
/>
</div>
<dl className="grid gap-4 border-y-2 border-border py-4 sm:grid-cols-3" aria-live="polite">
{[
["Total planned", `${total} h`],
[selected, `${selectedRow.hours} h`],
["Share of total", `${((selectedRow.hours / total) * 100).toFixed(1)}%`],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs">{label}</dt>
<dd className="mt-1 text-2xl font-heading tabular-nums">{value}</dd>
</div>
))}
</dl>
<figure aria-label="Planned hours by workstream" className="min-w-0">
<ChartContainer
config={config}
className="mx-auto h-72 w-full max-w-md min-w-0 aspect-auto"
>
<PieChart accessibilityLayer>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="work"
hideLabel
formatter={(value, _name, item) => `${item.payload.work}: ${value} h`}
/>
}
/>
<Pie
data={data}
dataKey="hours"
nameKey="work"
startAngle={0}
endAngle={-360}
innerRadius="55%"
outerRadius="85%"
paddingAngle={3}
stroke="var(--border)"
strokeWidth={2}
isAnimationActive={false}
/>
</PieChart>
</ChartContainer>
<figcaption className="mt-3 text-xs leading-5">
Clockwise from the right: Design, Build, Test, Operations. Use the inspector or table
for exact values without relying on color.
</figcaption>
</figure>
<output className="border-l-4 border-main pl-3 text-sm leading-6">
{selected} accounts for {selectedRow.hours} of {total} planned hours. Changing the
inspector does not remove other workstreams from the total.
</output>
<details className="min-w-0 border-t-2 border-border pt-4">
<summary className="cursor-pointer text-sm font-heading">View allocation data</summary>
<table className="mt-3 w-full text-left text-xs tabular-nums">
<caption className="pb-3 text-left">
{period} period. Shares use the total of all four workstreams.
</caption>
<thead>
<tr>
{["Workstream", "Hours", "Share"].map((label) => (
<th key={label} scope="col" className="border-b-2 border-border py-2 pr-2">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.work} className={selected === row.work ? "bg-background" : undefined}>
<th scope="row" className="py-3 pr-2">
{row.work}
{selected === row.work && <span className="sr-only">, selected</span>}
</th>
<td>{row.hours}</td>
<td>{((row.hours / total) * 100).toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</details>
</CardContent>
</Card>
);
}
#Installation
Complete the base setup. The recipe installs Chart and Card and keeps the application's selected theme.
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/chart-work-allocation.jsonnpm install recharts@^3.9.2Install Chart and Card, then create components/ui/chart-work-allocation.tsx:
"use client";
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
ChartContainer,
ChartSelect,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
const periods = {
Current: [48, 72, 36, 24],
Next: [36, 90, 54, 30],
} as const;
const workstreams = ["Design", "Build", "Test", "Operations"];
const config = {
hours: { label: "Hours" },
Design: { label: "Design", color: "var(--chart-1)" },
Build: { label: "Build", color: "var(--chart-2)" },
Test: { label: "Test", color: "var(--chart-3)" },
Operations: { label: "Operations", color: "var(--chart-4)" },
} satisfies ChartConfig;
export default function ChartWorkAllocation() {
const [period, setPeriod] = useState<keyof typeof periods>("Current");
const [selected, setSelected] = useState("Design");
const data = workstreams.map((work, index) => ({
work,
hours: periods[period][index],
fill: `var(--color-${work})`,
}));
const total = data.reduce((sum, row) => sum + row.hours, 0);
const selectedRow = data.find((row) => row.work === selected)!;
return (
<Card className="min-w-0" data-chart-recipe="allocation">
<CardHeader>
<p className="font-mono text-xs uppercase tracking-widest">
Allocation / Read the whole and its parts
</p>
<CardTitle>
<h3>Work allocation</h3>
</CardTitle>
<CardDescription>
Planned hours across four workstreams. Illustrative planning data, not tracked time.
</CardDescription>
</CardHeader>
<CardContent className="grid min-w-0 gap-6">
<div className="flex flex-wrap gap-4">
<ChartSelect
label="Allocation period"
value={period}
onValueChange={(value) => setPeriod(value as keyof typeof periods)}
options={Object.keys(periods).map((value) => ({ value, label: value }))}
/>
<ChartSelect
label="Inspect workstream"
value={selected}
onValueChange={setSelected}
options={workstreams.map((value) => ({ value, label: value }))}
/>
</div>
<dl className="grid gap-4 border-y-2 border-border py-4 sm:grid-cols-3" aria-live="polite">
{[
["Total planned", `${total} h`],
[selected, `${selectedRow.hours} h`],
["Share of total", `${((selectedRow.hours / total) * 100).toFixed(1)}%`],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs">{label}</dt>
<dd className="mt-1 text-2xl font-heading tabular-nums">{value}</dd>
</div>
))}
</dl>
<figure aria-label="Planned hours by workstream" className="min-w-0">
<ChartContainer
config={config}
className="mx-auto h-72 w-full max-w-md min-w-0 aspect-auto"
>
<PieChart accessibilityLayer>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="work"
hideLabel
formatter={(value, _name, item) => `${item.payload.work}: ${value} h`}
/>
}
/>
<Pie
data={data}
dataKey="hours"
nameKey="work"
startAngle={0}
endAngle={-360}
innerRadius="55%"
outerRadius="85%"
paddingAngle={3}
stroke="var(--border)"
strokeWidth={2}
isAnimationActive={false}
/>
</PieChart>
</ChartContainer>
<figcaption className="mt-3 text-xs leading-5">
Clockwise from the right: Design, Build, Test, Operations. Use the inspector or table
for exact values without relying on color.
</figcaption>
</figure>
<output className="border-l-4 border-main pl-3 text-sm leading-6">
{selected} accounts for {selectedRow.hours} of {total} planned hours. Changing the
inspector does not remove other workstreams from the total.
</output>
<details className="min-w-0 border-t-2 border-border pt-4">
<summary className="cursor-pointer text-sm font-heading">View allocation data</summary>
<table className="mt-3 w-full text-left text-xs tabular-nums">
<caption className="pb-3 text-left">
{period} period. Shares use the total of all four workstreams.
</caption>
<thead>
<tr>
{["Workstream", "Hours", "Share"].map((label) => (
<th key={label} scope="col" className="border-b-2 border-border py-2 pr-2">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((row) => (
<tr key={row.work} className={selected === row.work ? "bg-background" : undefined}>
<th scope="row" className="py-3 pr-2">
{row.work}
{selected === row.work && <span className="sr-only">, selected</span>}
</th>
<td>{row.hours}</td>
<td>{((row.hours / total) * 100).toFixed(1)}%</td>
</tr>
))}
</tbody>
</table>
</details>
</CardContent>
</Card>
);
}
#Usage
import ChartWorkAllocation from "@/components/ui/chart-work-allocation";
export default function AllocationReport() {
return <ChartWorkAllocation />;
}#API reference
This source-editable recipe requires no props.
| Source / control | Purpose |
|---|---|
periods | Four nonnegative hour values per sample planning period. |
workstreams | The corresponding ordered category names. |
config | Names and shared-theme colors. |
| Allocation period | Switches all values and the denominator together. |
| Inspect workstream | Changes the readout, not the total population. |
| View allocation data | Hours and shares for all categories, including the selected one. |
Keep category order aligned with the data arrays. Handle zero totals or empty data before computing shares when integrating live data. Rounded shares may not sum to exactly 100%.
#Accessibility
The workstream inspector uses a labeled Base UI Select with keyboard support. A textual readout, category order caption, and exact table provide a color-independent reading path. The selected row includes an accessible selected marker. The chart is labeled and animations are disabled.