Activity Goal Card
Goal tracker card for fitness and habit dashboards: a large adjustable target with minus and plus buttons and a recharts bar chart where entries that meet the goal fill in the accent above a dashed line. Requires recharts 3.8.0.
pnpm dlx shadcn add @01orbital/activity-goal-card"use client"
import type { ComponentProps, ReactNode } from "react"
import { MinusIcon, PlusIcon } from "@phosphor-icons/react"
import { cn } from "cn"
import { Bar, BarChart, Rectangle, ReferenceLine } from "recharts"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
import { Separator } from "@/components/ui/separator"
/** One observation in the activity chart, compared with the current goal. */
type ActivityGoalEntry = {
/** An identifier for the observation; it is not displayed by the card. */
id: string
/** A label for the observation; it is not displayed by the card. */
name: string
/** The measured amount, which sets the bar height. */
value: number
}
/** Content and controlled goal settings, plus the stock `Card` props. */
type ActivityGoalCardProps = Omit<
ComponentProps<typeof Card>,
"title" | "children"
> & {
/** The card's heading. */
title: ReactNode
/** Muted supporting text below the heading. */
description?: ReactNode
/** Observations in chart order; bars meeting the goal use the art accent. */
data: readonly ActivityGoalEntry[]
/** The current goal, shown above the chart and as its reference line. */
value: number
/** Called with the adjusted goal, clamped to `limits.min` and `limits.max`. */
onValueChange: (value: number) => void
/** Bounds and increment used by the decrease and increase buttons. */
limits: {
/** The lowest goal the buttons allow. */
min: number
/** The highest goal the buttons allow. */
max: number
/** The amount added or subtracted on each button press. */
step: number
}
/** Button names, chart labels and the goal summary text. */
labels: {
/** The accessible name of the decrease button. */
decrease: string
/** The accessible name of the increase button. */
increase: string
/** The small uppercase unit beside the goal value. */
unit: ReactNode
/** The period label below the chart, such as "Last 7 days". */
range: ReactNode
/** The series label supplied to the chart configuration. */
series: string
/** Returns summary text for the number of observations meeting the goal. */
met: (count: number) => ReactNode
}
/** Controls or links placed in the card's footer. */
actions?: ReactNode
}
/**
* A card with an adjustable activity goal and a bar chart of observations.
* The buttons request a new goal through `onValueChange`; the caller owns
* `value`. Bars meeting the goal are highlighted, with a summary below.
*
* It takes no children. `className` and other props land on the `Card`.
*/
function ActivityGoalCard({
title,
description,
data,
value,
onValueChange,
limits,
labels,
actions,
className,
...props
}: ActivityGoalCardProps) {
const met = data.filter((entry) => entry.value >= value).length
const chartConfig = { value: { label: labels.series } } satisfies ChartConfig
function adjust(by: number) {
onValueChange(Math.max(limits.min, Math.min(limits.max, value + by)))
}
return (
<Card
data-slot="activity-goal-card"
className={cn("gap-8", className)}
{...props}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
{description != null && (
<CardDescription>{description}</CardDescription>
)}
</CardHeader>
<CardContent className="grid gap-8">
<p className="flex items-center justify-between gap-4">
<Button
variant="ghost"
size="icon"
onClick={() => adjust(-limits.step)}
disabled={value <= limits.min}
focusableWhenDisabled
className="data-disabled:pointer-events-none data-disabled:opacity-50"
>
<MinusIcon aria-hidden="true" />
<span className="sr-only">{labels.decrease}</span>
</Button>
<span role="status" className="flex items-baseline gap-2">
<span className="font-display text-5xl tracking-tight tabular-nums">
{value}
</span>
<span className="font-mono text-xs tracking-widest text-muted-foreground uppercase">
{labels.unit}
</span>
</span>
<Button
variant="ghost"
size="icon"
onClick={() => adjust(limits.step)}
disabled={value >= limits.max}
focusableWhenDisabled
className="data-disabled:pointer-events-none data-disabled:opacity-50"
>
<PlusIcon aria-hidden="true" />
<span className="sr-only">{labels.increase}</span>
</Button>
</p>
<figure className="grid gap-3">
<ChartContainer config={chartConfig} className="h-28 w-full">
<BarChart
data={[...data]}
barCategoryGap={6}
margin={{ top: 4, bottom: 0 }}
>
<ReferenceLine
y={value}
ifOverflow="extendDomain"
stroke="var(--art-accent)"
strokeDasharray="2 4"
strokeWidth={1}
/>
<Bar
dataKey="value"
radius={0}
maxBarSize={3}
shape={(barProps) => (
<Rectangle
{...barProps}
fill={
typeof barProps.value === "number" &&
barProps.value >= value
? "var(--art-accent)"
: "var(--muted-foreground)"
}
/>
)}
/>
</BarChart>
</ChartContainer>
<Separator />
<figcaption className="flex items-baseline justify-between">
<span className="font-mono text-xs tracking-widest text-muted-foreground uppercase">
{labels.range}
</span>
<span className="text-sm/7 tabular-nums">{labels.met(met)}</span>
</figcaption>
</figure>
</CardContent>
{actions != null && <CardFooter>{actions}</CardFooter>}
</Card>
)
}
export { ActivityGoalCard }
Usage
import { ActivityGoalCard } from "@/components/card/activity-goal-card"Needs props to render
Pass
title, data, value, onValueChange, limits, labels; see the API reference below.Examples
New
activity-goal-card-volunteer-hours
API reference
A card with an adjustable activity goal and a bar chart of observations. The buttons request a new goal through onValueChange; the caller owns value. Bars meeting the goal are highlighted, with a summary below.
It takes no children. className and other props land on the Card.
Prop
Type
It also accepts the HTML attributes of the element it renders.
Dependencies
npm: @phosphor-icons/react, cn, recharts@3.8.0. The CLI installs them for you.
Registry pieces it installs with it: