Skip to content

Commit 9fc278d

Browse files
author
belledw
committed
added health section with example health data, updated responsive design
1 parent 0fcada4 commit 9fc278d

8 files changed

Lines changed: 911 additions & 16 deletions

File tree

client/package-lock.json

Lines changed: 337 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"react": "19.1.0",
3434
"react-day-picker": "^9.13.0",
3535
"react-dom": "19.1.0",
36+
"recharts": "^2.15.4",
3637
"tailwind-merge": "^3.3.1",
3738
"tailwindcss-animate": "^1.0.7"
3839
},

client/src/components/ui/chart.tsx

Lines changed: 367 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import * as React from "react";
2+
import * as RechartsPrimitive from "recharts";
3+
4+
import { cn } from "@/lib/utils";
5+
6+
// Format: { THEME_NAME: CSS_SELECTOR }
7+
const THEMES = { light: "", dark: ".dark" } as const;
8+
9+
export type ChartConfig = {
10+
[k in string]: {
11+
label?: React.ReactNode;
12+
icon?: React.ComponentType;
13+
} & (
14+
| { color?: string; theme?: never }
15+
| { color?: never; theme: Record<keyof typeof THEMES, string> }
16+
);
17+
};
18+
19+
type ChartContextProps = {
20+
config: ChartConfig;
21+
};
22+
23+
const ChartContext = React.createContext<ChartContextProps | null>(null);
24+
25+
function useChart() {
26+
const context = React.useContext(ChartContext);
27+
28+
if (!context) {
29+
throw new Error("useChart must be used within a <ChartContainer />");
30+
}
31+
32+
return context;
33+
}
34+
35+
const ChartContainer = React.forwardRef<
36+
HTMLDivElement,
37+
React.ComponentProps<"div"> & {
38+
config: ChartConfig;
39+
children: React.ComponentProps<
40+
typeof RechartsPrimitive.ResponsiveContainer
41+
>["children"];
42+
}
43+
>(({ id, className, children, config, ...props }, ref) => {
44+
const uniqueId = React.useId();
45+
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
46+
47+
return (
48+
<ChartContext.Provider value={{ config }}>
49+
<div
50+
data-chart={chartId}
51+
ref={ref}
52+
className={cn(
53+
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
54+
className,
55+
)}
56+
{...props}
57+
>
58+
<ChartStyle id={chartId} config={config} />
59+
<RechartsPrimitive.ResponsiveContainer>
60+
{children}
61+
</RechartsPrimitive.ResponsiveContainer>
62+
</div>
63+
</ChartContext.Provider>
64+
);
65+
});
66+
ChartContainer.displayName = "Chart";
67+
68+
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
69+
const colorConfig = Object.entries(config).filter(
70+
([, config]) => config.theme || config.color,
71+
);
72+
73+
if (!colorConfig.length) {
74+
return null;
75+
}
76+
77+
return (
78+
<style
79+
dangerouslySetInnerHTML={{
80+
__html: Object.entries(THEMES)
81+
.map(
82+
([theme, prefix]) => `
83+
${prefix} [data-chart=${id}] {
84+
${colorConfig
85+
.map(([key, itemConfig]) => {
86+
const color =
87+
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
88+
itemConfig.color;
89+
return color ? ` --color-${key}: ${color};` : null;
90+
})
91+
.join("\n")}
92+
}
93+
`,
94+
)
95+
.join("\n"),
96+
}}
97+
/>
98+
);
99+
};
100+
101+
const ChartTooltip = RechartsPrimitive.Tooltip;
102+
103+
const ChartTooltipContent = React.forwardRef<
104+
HTMLDivElement,
105+
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
106+
React.ComponentProps<"div"> & {
107+
hideLabel?: boolean;
108+
hideIndicator?: boolean;
109+
indicator?: "line" | "dot" | "dashed";
110+
nameKey?: string;
111+
labelKey?: string;
112+
}
113+
>(
114+
(
115+
{
116+
active,
117+
payload,
118+
className,
119+
indicator = "dot",
120+
hideLabel = false,
121+
hideIndicator = false,
122+
label,
123+
labelFormatter,
124+
labelClassName,
125+
formatter,
126+
color,
127+
nameKey,
128+
labelKey,
129+
},
130+
ref,
131+
) => {
132+
const { config } = useChart();
133+
134+
const tooltipLabel = React.useMemo(() => {
135+
if (hideLabel || !payload?.length) {
136+
return null;
137+
}
138+
139+
const [item] = payload;
140+
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
141+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
142+
const value =
143+
!labelKey && typeof label === "string"
144+
? config[label as keyof typeof config]?.label || label
145+
: itemConfig?.label;
146+
147+
if (labelFormatter) {
148+
return (
149+
<div className={cn("font-medium", labelClassName)}>
150+
{labelFormatter(value, payload)}
151+
</div>
152+
);
153+
}
154+
155+
if (!value) {
156+
return null;
157+
}
158+
159+
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
160+
}, [
161+
label,
162+
labelFormatter,
163+
payload,
164+
hideLabel,
165+
labelClassName,
166+
config,
167+
labelKey,
168+
]);
169+
170+
if (!active || !payload?.length) {
171+
return null;
172+
}
173+
174+
const nestLabel = payload.length === 1 && indicator !== "dot";
175+
176+
return (
177+
<div
178+
ref={ref}
179+
className={cn(
180+
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
181+
className,
182+
)}
183+
>
184+
{!nestLabel ? tooltipLabel : null}
185+
<div className="grid gap-1.5">
186+
{payload
187+
.filter((item) => item.type !== "none")
188+
.map((item, index) => {
189+
const key = `${nameKey || item.name || item.dataKey || "value"}`;
190+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
191+
const indicatorColor = color || item.payload.fill || item.color;
192+
193+
return (
194+
<div
195+
key={item.dataKey}
196+
className={cn(
197+
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
198+
indicator === "dot" && "items-center",
199+
)}
200+
>
201+
{formatter && item?.value !== undefined && item.name ? (
202+
formatter(item.value, item.name, item, index, item.payload)
203+
) : (
204+
<>
205+
{itemConfig?.icon ? (
206+
<itemConfig.icon />
207+
) : (
208+
!hideIndicator && (
209+
<div
210+
className={cn(
211+
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
212+
{
213+
"h-2.5 w-2.5": indicator === "dot",
214+
"w-1": indicator === "line",
215+
"w-0 border-[1.5px] border-dashed bg-transparent":
216+
indicator === "dashed",
217+
"my-0.5": nestLabel && indicator === "dashed",
218+
},
219+
)}
220+
style={
221+
{
222+
"--color-bg": indicatorColor,
223+
"--color-border": indicatorColor,
224+
} as React.CSSProperties
225+
}
226+
/>
227+
)
228+
)}
229+
<div
230+
className={cn(
231+
"flex flex-1 justify-between leading-none",
232+
nestLabel ? "items-end" : "items-center",
233+
)}
234+
>
235+
<div className="grid gap-1.5">
236+
{nestLabel ? tooltipLabel : null}
237+
<span className="text-muted-foreground">
238+
{itemConfig?.label || item.name}
239+
</span>
240+
</div>
241+
{item.value && (
242+
<span className="font-mono font-medium tabular-nums text-foreground">
243+
{item.value.toLocaleString()}
244+
</span>
245+
)}
246+
</div>
247+
</>
248+
)}
249+
</div>
250+
);
251+
})}
252+
</div>
253+
</div>
254+
);
255+
},
256+
);
257+
ChartTooltipContent.displayName = "ChartTooltip";
258+
259+
const ChartLegend = RechartsPrimitive.Legend;
260+
261+
const ChartLegendContent = React.forwardRef<
262+
HTMLDivElement,
263+
React.ComponentProps<"div"> &
264+
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
265+
hideIcon?: boolean;
266+
nameKey?: string;
267+
}
268+
>(
269+
(
270+
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
271+
ref,
272+
) => {
273+
const { config } = useChart();
274+
275+
if (!payload?.length) {
276+
return null;
277+
}
278+
279+
return (
280+
<div
281+
ref={ref}
282+
className={cn(
283+
"flex items-center justify-center gap-4",
284+
verticalAlign === "top" ? "pb-3" : "pt-3",
285+
className,
286+
)}
287+
>
288+
{payload
289+
.filter((item) => item.type !== "none")
290+
.map((item) => {
291+
const key = `${nameKey || item.dataKey || "value"}`;
292+
const itemConfig = getPayloadConfigFromPayload(config, item, key);
293+
294+
return (
295+
<div
296+
key={item.value}
297+
className={cn(
298+
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
299+
)}
300+
>
301+
{itemConfig?.icon && !hideIcon ? (
302+
<itemConfig.icon />
303+
) : (
304+
<div
305+
className="h-2 w-2 shrink-0 rounded-[2px]"
306+
style={{
307+
backgroundColor: item.color,
308+
}}
309+
/>
310+
)}
311+
{itemConfig?.label}
312+
</div>
313+
);
314+
})}
315+
</div>
316+
);
317+
},
318+
);
319+
ChartLegendContent.displayName = "ChartLegend";
320+
321+
// Helper to extract item config from a payload.
322+
function getPayloadConfigFromPayload(
323+
config: ChartConfig,
324+
payload: unknown,
325+
key: string,
326+
) {
327+
if (typeof payload !== "object" || payload === null) {
328+
return undefined;
329+
}
330+
331+
const payloadPayload =
332+
"payload" in payload &&
333+
typeof payload.payload === "object" &&
334+
payload.payload !== null
335+
? payload.payload
336+
: undefined;
337+
338+
let configLabelKey: string = key;
339+
340+
if (
341+
key in payload &&
342+
typeof payload[key as keyof typeof payload] === "string"
343+
) {
344+
configLabelKey = payload[key as keyof typeof payload] as string;
345+
} else if (
346+
payloadPayload &&
347+
key in payloadPayload &&
348+
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
349+
) {
350+
configLabelKey = payloadPayload[
351+
key as keyof typeof payloadPayload
352+
] as string;
353+
}
354+
355+
return configLabelKey in config
356+
? config[configLabelKey]
357+
: config[key as keyof typeof config];
358+
}
359+
360+
export {
361+
ChartContainer,
362+
ChartLegend,
363+
ChartLegendContent,
364+
ChartStyle,
365+
ChartTooltip,
366+
ChartTooltipContent,
367+
};

0 commit comments

Comments
 (0)