feat: redesign dashboard stats cards with sparklines, icons, and period selection

- Replace horizontal scrolling stats list on native with a 2×2 grid layout; each card gets a color-coded icon, tint background, and an inline sparkline
- Add `Sparkline` component to `apps/native/src/components/ui/` (path-based SVG rendered via `react-native-svg`)
- Upgrade `StatsCard` (native) to support `icon`, `color`/`tintColor`/`bgColor` theming, `sparklineData`, and a `zeego` dropdown menu for switching the time period (today / this week / this month / this year)
- Wire `moviePeriod` / `episodePeriod` state in the home screen and fetch `dashboard.watchHistory` for each so counts and sparklines react to the selected period
- Rewrite `apps/web/src/components/dashboard/sparkline.tsx` as a self-contained SVG sparkline (no Recharts), and update `stats-display.tsx` accordingly
- Remove `apps/web/src/components/ui/chart.tsx` (Recharts wrapper) and drop the `recharts` dependency from `apps/web/package.json`
This commit is contained in:
2026-03-19 11:31:15 -04:00
parent f4c6b27cd9
commit 1050a0de00
9 changed files with 440 additions and 462 deletions
+93 -31
View File
@@ -1,11 +1,19 @@
import { useLingui } from "@lingui/react/macro";
import { FlashList } from "@shopify/flash-list";
import { IconBooks, IconPlayerPlay, IconThumbUp } from "@tabler/icons-react-native";
import {
IconBooks,
IconCheck,
IconDeviceTvOld,
IconMovie,
IconPlayerPlay,
IconThumbUp,
} from "@tabler/icons-react-native";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import { useCallback, useMemo } from "react";
import { useCallback, useState } from "react";
import { RefreshControl, ScrollView, View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card";
import { HorizontalPosterRow } from "@/components/dashboard/horizontal-poster-row";
@@ -20,6 +28,7 @@ import { SectionHeader } from "@/components/ui/section-header";
import { orpc } from "@/lib/orpc";
import { queryClient } from "@/lib/query-client";
import { authClient } from "@/lib/server";
import type { TimePeriod } from "@sofa/api/schemas";
const dashboardContentContainerStyle = {
paddingTop: 8,
@@ -31,12 +40,37 @@ export default function DashboardScreen() {
const { push } = useRouter();
authClient.useSession();
const [moviePeriod, setMoviePeriod] = useState<TimePeriod>("this_month");
const [episodePeriod, setEpisodePeriod] = useState<TimePeriod>("this_week");
const [primaryColor, watchingColor, watchlistColor, completedColor] = useCSSVariable([
"--color-primary",
"--color-status-watching",
"--color-status-watchlist",
"--color-status-completed",
]) as [string, string, string, string];
const stats = useQuery(orpc.dashboard.stats.queryOptions());
const movieHistory = useQuery(
orpc.dashboard.watchHistory.queryOptions({
input: { type: "movie", period: moviePeriod },
}),
);
const episodeHistory = useQuery(
orpc.dashboard.watchHistory.queryOptions({
input: { type: "episode", period: episodePeriod },
}),
);
const continueWatching = useQuery(orpc.dashboard.continueWatching.queryOptions());
const library = useQuery(orpc.dashboard.library.queryOptions({ input: {} }));
const recommendations = useQuery(orpc.dashboard.recommendations.queryOptions());
const isRefreshing = stats.isRefetching || continueWatching.isRefetching || library.isRefetching;
const isRefreshing =
stats.isRefetching ||
continueWatching.isRefetching ||
library.isRefetching ||
movieHistory.isRefetching ||
episodeHistory.isRefetching;
const onRefresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: orpc.dashboard.key() });
@@ -46,22 +80,16 @@ export default function DashboardScreen() {
const hasContinueWatching = (continueWatching.data?.items?.length ?? 0) > 0;
const hasRecommendations = (recommendations.data?.items?.length ?? 0) > 0;
const statsData = useMemo(
() => [
{ label: t`Movies this month`, value: stats.data?.moviesThisMonth },
{ label: t`Episodes this week`, value: stats.data?.episodesThisWeek },
{ label: t`In library`, value: stats.data?.librarySize },
{ label: t`Completed`, value: stats.data?.completed },
],
[stats.data, t],
);
const movieCount = movieHistory.data?.count ?? stats.data?.moviesThisMonth;
const episodeCount = episodeHistory.data?.count ?? stats.data?.episodesThisWeek;
const periodLabels: Record<TimePeriod, string> = {
today: t`today`,
this_week: t`this week`,
this_month: t`this month`,
this_year: t`this year`,
};
const renderStatItem = useCallback(
({ item }: { item: (typeof statsData)[number] }) => (
<StatsCard label={item.label} value={item.value} />
),
[],
);
const renderContinueWatchingItem = useCallback(
({ item }: { item: NonNullable<typeof continueWatching.data>["items"][number] }) => (
<ContinueWatchingCard item={item} />
@@ -77,19 +105,53 @@ export default function DashboardScreen() {
scrollToOverflowEnabled
refreshControl={<RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />}
>
<View className="gap-8">
{/* Stats */}
<Animated.View entering={FadeInDown.duration(300).delay(100)}>
<FlashList
horizontal
showsHorizontalScrollIndicator={false}
data={statsData}
keyExtractor={(item) => item.label}
renderItem={renderStatItem}
ItemSeparatorComponent={HorizontalListSeparator}
contentContainerStyle={horizontalListContentStyle}
style={horizontalListStyle}
/>
<View className="gap-6">
{/* Stats Grid */}
<Animated.View entering={FadeInDown.duration(300).delay(100)} className="px-4">
<View className="gap-3">
<View className="flex-row gap-3">
<StatsCard
label={t`Movies ${periodLabels[moviePeriod]}`}
value={movieCount}
icon={IconMovie}
color="text-primary"
tintColor={primaryColor}
bgColor="bg-primary/10"
sparklineData={movieHistory.data?.history}
period={moviePeriod}
onPeriodChange={setMoviePeriod}
/>
<StatsCard
label={t`Episodes ${periodLabels[episodePeriod]}`}
value={episodeCount}
icon={IconDeviceTvOld}
color="text-status-watching"
tintColor={watchingColor}
bgColor="bg-status-watching/10"
sparklineData={episodeHistory.data?.history}
period={episodePeriod}
onPeriodChange={setEpisodePeriod}
/>
</View>
<View className="flex-row gap-3">
<StatsCard
label={t`In library`}
value={stats.data?.librarySize}
icon={IconBooks}
color="text-status-watchlist"
tintColor={watchlistColor}
bgColor="bg-status-watchlist/10"
/>
<StatsCard
label={t`Completed`}
value={stats.data?.completed}
icon={IconCheck}
color="text-status-completed"
tintColor={completedColor}
bgColor="bg-status-completed/10"
/>
</View>
</View>
</Animated.View>
{/* Continue Watching */}
@@ -1,20 +1,114 @@
import { View } from "react-native";
import { useLingui } from "@lingui/react/macro";
import type { Icon } from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import * as DropdownMenu from "zeego/dropdown-menu";
import { ScaledIcon } from "@/components/ui/scaled-icon";
import { Sparkline } from "@/components/ui/sparkline";
import { Text } from "@/components/ui/text";
import * as Haptics from "@/utils/haptics";
import type { HistoryBucket, TimePeriod } from "@sofa/api/schemas";
export function StatsCard({ label, value }: { label: string; value: number | undefined }) {
interface StatsCardProps {
label: string;
value: number | undefined;
icon: Icon;
color: string;
tintColor: string;
bgColor: string;
sparklineData?: HistoryBucket[];
period?: TimePeriod;
onPeriodChange?: (period: TimePeriod) => void;
}
const periods: TimePeriod[] = ["today", "this_week", "this_month", "this_year"];
function usePeriodLabels() {
const { t } = useLingui();
return {
today: t`Today`,
this_week: t`This Week`,
this_month: t`This Month`,
this_year: t`This Year`,
} as Record<TimePeriod, string>;
}
const cardStyle = { flex: 1 } as const;
function CardInner({
label,
value,
icon: IconComponent,
color,
tintColor,
bgColor,
sparklineData,
}: Omit<StatsCardProps, "period" | "onPeriodChange">) {
return (
<View
className="bg-card min-w-[120px] rounded-xl border border-white/[0.06] px-4 py-3"
className="bg-card overflow-hidden rounded-xl border border-white/[0.06] p-2.5"
style={{ borderCurve: "continuous" }}
>
{sparklineData && <Sparkline data={sparklineData} color={tintColor} />}
<View className="z-10 flex-row items-center gap-1.5">
<View className={`items-center justify-center rounded-md p-[5px] ${bgColor}`}>
<ScaledIcon icon={IconComponent} size={12} color={tintColor} />
</View>
<Text
className="text-muted-foreground text-[10px] font-medium tracking-wider uppercase"
maxFontSizeMultiplier={1.5}
>
{label}
</Text>
</View>
<Text
className="text-primary font-sans text-2xl font-bold"
className={`font-display z-10 mt-2 text-2xl tracking-tight ${color}`}
style={{ fontVariant: ["tabular-nums"] }}
maxFontSizeMultiplier={1.5}
>
{value ?? "—"}
</Text>
<Text className="text-muted-foreground mt-0.5 text-xs">{label}</Text>
</View>
);
}
export function StatsCard(props: StatsCardProps) {
const { period, onPeriodChange } = props;
const periodLabels = usePeriodLabels();
if (!onPeriodChange) {
return (
<View style={cardStyle}>
<CardInner {...props} />
</View>
);
}
return (
<View style={cardStyle}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
accessibilityRole="button"
>
<CardInner {...props} />
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
{periods.map((p) => (
<DropdownMenu.CheckboxItem
key={p}
value={p === period ? "on" : "off"}
onValueChange={() => onPeriodChange(p)}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle>{periodLabels[p]}</DropdownMenu.ItemTitle>
</DropdownMenu.CheckboxItem>
))}
</DropdownMenu.Content>
</DropdownMenu.Root>
</View>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { useId, useMemo } from "react";
import { useCallback, useState } from "react";
import { StyleSheet, View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
interface Point {
x: number;
y: number;
}
/**
* Fritsch-Carlson monotone cubic interpolation.
* Produces smooth SVG paths that never overshoot between data points.
*/
function computeMonotonePath(
points: Point[],
height: number,
): { strokePath: string; areaPath: string } | null {
const n = points.length;
if (n < 2) return null;
// Step 1: compute secants between adjacent points
const deltas: number[] = [];
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
deltas[k] = dx === 0 ? 0 : (points[k + 1].y - points[k].y) / dx;
}
// Step 2: compute initial tangent slopes
const tangents: number[] = Array.from({ length: n });
tangents[0] = deltas[0];
tangents[n - 1] = deltas[n - 2];
for (let k = 1; k < n - 1; k++) {
if (Math.sign(deltas[k - 1]) !== Math.sign(deltas[k])) {
tangents[k] = 0;
} else {
tangents[k] = (deltas[k - 1] + deltas[k]) / 2;
}
}
// Step 3: Fritsch-Carlson monotonicity fix
for (let k = 0; k < n - 1; k++) {
if (deltas[k] === 0) {
tangents[k] = 0;
tangents[k + 1] = 0;
} else {
const alpha = tangents[k] / deltas[k];
const beta = tangents[k + 1] / deltas[k];
const s = alpha * alpha + beta * beta;
if (s > 9) {
const tau = 3 / Math.sqrt(s);
tangents[k] = tau * alpha * deltas[k];
tangents[k + 1] = tau * beta * deltas[k];
}
}
}
// Step 4: build SVG path with cubic Bezier segments
let strokePath = `M${points[0].x},${points[0].y}`;
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
const cp1x = points[k].x + dx / 3;
const cp1y = points[k].y + (tangents[k] * dx) / 3;
const cp2x = points[k + 1].x - dx / 3;
const cp2y = points[k + 1].y - (tangents[k + 1] * dx) / 3;
strokePath += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${points[k + 1].x},${points[k + 1].y}`;
}
// Close the area path along the bottom edge
const areaPath = `${strokePath} L${points[n - 1].x},${height} L${points[0].x},${height} Z`;
return { strokePath, areaPath };
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
const [size, setSize] = useState({ width: 0, height: 0 });
const handleLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
if (width > 0 && height > 0) {
setSize({ width, height });
}
}, []);
const paths = useMemo(() => {
if (!data.some((d) => d.count > 0)) return null;
if (size.width === 0 || size.height === 0) return null;
const counts = data.map((d) => d.count);
const maxCount = Math.max(...counts);
if (maxCount === 0) return null;
const n = data.length;
const topPadding = size.height * 0.05;
const points: Point[] = [];
for (let i = 0; i < n; i++) {
const x = n === 1 ? size.width / 2 : (i / (n - 1)) * size.width;
const y = topPadding + (1 - counts[i] / maxCount) * (size.height - topPadding);
points.push({ x, y });
}
return computeMonotonePath(points, size.height);
}, [data, size.width, size.height]);
if (!data.some((d) => d.count > 0)) return null;
return (
<View style={StyleSheet.absoluteFill} onLayout={handleLayout} pointerEvents="none">
{paths && (
<Svg width={size.width} height={size.height}>
<Defs>
<LinearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2={String(size.height)}
gradientUnits="userSpaceOnUse"
>
<Stop offset="0" stopColor={color} stopOpacity={0.08} />
<Stop offset="1" stopColor={color} stopOpacity={0} />
</LinearGradient>
</Defs>
<Path d={paths.areaPath} fill={`url(#${gradientId})`} />
<Path
d={paths.strokePath}
fill="none"
stroke={color}
strokeWidth={1}
strokeOpacity={0.15}
/>
</Svg>
)}
</View>
);
}
-1
View File
@@ -37,7 +37,6 @@
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"recharts": "3.8.0",
"shadcn": "4.0.8",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
+91 -16
View File
@@ -1,11 +1,74 @@
import { useEffect, useId, useRef, useState } from "react";
import { Area, AreaChart, YAxis } from "recharts";
import { useEffect, useId, useMemo, useRef, useState } from "react";
interface SparklineProps {
data: Array<{ bucket: string; count: number }>;
color: string;
}
interface Point {
x: number;
y: number;
}
/**
* Fritsch-Carlson monotone cubic interpolation.
* Produces smooth SVG paths that never overshoot between data points.
*/
function computeMonotonePath(
points: Point[],
height: number,
): { strokePath: string; areaPath: string } | null {
const n = points.length;
if (n < 2) return null;
const deltas: number[] = [];
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
deltas[k] = dx === 0 ? 0 : (points[k + 1].y - points[k].y) / dx;
}
const tangents: number[] = Array.from({ length: n });
tangents[0] = deltas[0];
tangents[n - 1] = deltas[n - 2];
for (let k = 1; k < n - 1; k++) {
if (Math.sign(deltas[k - 1]) !== Math.sign(deltas[k])) {
tangents[k] = 0;
} else {
tangents[k] = (deltas[k - 1] + deltas[k]) / 2;
}
}
for (let k = 0; k < n - 1; k++) {
if (deltas[k] === 0) {
tangents[k] = 0;
tangents[k + 1] = 0;
} else {
const alpha = tangents[k] / deltas[k];
const beta = tangents[k + 1] / deltas[k];
const s = alpha * alpha + beta * beta;
if (s > 9) {
const tau = 3 / Math.sqrt(s);
tangents[k] = tau * alpha * deltas[k];
tangents[k + 1] = tau * beta * deltas[k];
}
}
}
let strokePath = `M${points[0].x},${points[0].y}`;
for (let k = 0; k < n - 1; k++) {
const dx = points[k + 1].x - points[k].x;
const cp1x = points[k].x + dx / 3;
const cp1y = points[k].y + (tangents[k] * dx) / 3;
const cp2x = points[k + 1].x - dx / 3;
const cp2y = points[k + 1].y - (tangents[k + 1] * dx) / 3;
strokePath += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${points[k + 1].x},${points[k + 1].y}`;
}
const areaPath = `${strokePath} L${points[n - 1].x},${height} L${points[0].x},${height} Z`;
return { strokePath, areaPath };
}
export function Sparkline({ data, color }: SparklineProps) {
const uniqueId = useId();
const gradientId = `sparkline-${uniqueId.replace(/:/g, "")}`;
@@ -27,6 +90,25 @@ export function Sparkline({ data, color }: SparklineProps) {
return () => observer.disconnect();
}, []);
const paths = useMemo(() => {
if (size.width === 0 || size.height === 0) return null;
const counts = data.map((d) => d.count);
const maxCount = Math.max(...counts);
if (maxCount === 0) return null;
const n = data.length;
const topPadding = size.height * 0.05;
const points: Point[] = [];
for (let i = 0; i < n; i++) {
const x = n === 1 ? size.width / 2 : (i / (n - 1)) * size.width;
const y = topPadding + (1 - counts[i] / maxCount) * (size.height - topPadding);
points.push({ x, y });
}
return computeMonotonePath(points, size.height);
}, [data, size.width, size.height]);
if (!data.some((d) => d.count > 0)) return null;
return (
@@ -34,30 +116,23 @@ export function Sparkline({ data, color }: SparklineProps) {
ref={containerRef}
className={`pointer-events-none absolute inset-0 overflow-hidden ${color}`}
>
{size.width > 0 && size.height > 0 && (
<AreaChart
data={data}
width={size.width}
height={size.height}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
{paths && (
<svg width={size.width} height={size.height}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="currentColor" stopOpacity={0.08} />
<stop offset="100%" stopColor="currentColor" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis domain={[0, "auto"]} hide />
<Area
type="monotone"
dataKey="count"
<path d={paths.areaPath} fill={`url(#${gradientId})`} />
<path
d={paths.strokePath}
fill="none"
stroke="currentColor"
strokeWidth={1}
strokeOpacity={0.15}
fill={`url(#${gradientId})`}
isAnimationActive={false}
/>
</AreaChart>
</svg>
)}
</div>
);
@@ -1,7 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { IconCheck, IconLibrary, IconMovie, IconPlayerPlay } from "@tabler/icons-react";
import { IconBooks, IconCheck, IconDeviceTvOld, IconMovie } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { Suspense, lazy, useState } from "react";
import { useState } from "react";
import {
Select,
@@ -14,7 +14,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { orpc } from "@/lib/orpc/client";
import type { DashboardStats, HistoryBucket, TimePeriod } from "@sofa/api/schemas";
const Sparkline = lazy(() => import("./sparkline").then((m) => ({ default: m.Sparkline })));
import { Sparkline } from "./sparkline";
function StatCardSkeleton() {
return (
@@ -66,11 +66,7 @@ function StatCard({
className="animate-stagger-item border-border/30 bg-card/50 relative overflow-hidden rounded-xl border p-4"
style={{ "--stagger-index": index } as React.CSSProperties}
>
{sparklineData && (
<Suspense>
<Sparkline data={sparklineData} color={color} />
</Suspense>
)}
{sparklineData && <Sparkline data={sparklineData} color={color} />}
<div className="relative z-10 flex items-center gap-2">
<div className={`flex h-6 w-6 items-center justify-center rounded-md ${bgColor}`}>
<Icon aria-hidden={true} className={`size-[13px] ${color}`} />
@@ -192,7 +188,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
}
/>
<StatCard
icon={IconPlayerPlay}
icon={IconDeviceTvOld}
color="text-status-watching"
bgColor="bg-status-watching/10"
value={episodeCount}
@@ -207,7 +203,7 @@ export function StatsDisplay({ stats }: { stats: DashboardStats }) {
}
/>
<StatCard
icon={IconLibrary}
icon={IconBooks}
color="text-status-watchlist"
bgColor="bg-status-watchlist/10"
value={stats.librarySize}
-328
View File
@@ -1,328 +0,0 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
const contextValue = React.useMemo(() => ({ config }), [config]);
return (
<ChartContext.Provider value={contextValue}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.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-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 flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([, entry]) => entry.theme || entry.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: RechartsPrimitive.TooltipContentProps<
number | string | ReadonlyArray<number | string>,
number | string
> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs/relaxed shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={key}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
payload?: RechartsPrimitive.LegendPayload[];
verticalAlign?: "top" | "bottom" | "middle";
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
};
+6
View File
@@ -123,6 +123,12 @@
cursor: pointer;
}
/* Text selection */
::selection {
background: oklch(0.8 0.14 65 / 50%);
color: oklch(0.93 0.015 80);
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
+2 -71
View File
@@ -165,7 +165,6 @@
"motion": "12.38.0",
"react": "catalog:",
"react-dom": "catalog:",
"recharts": "3.8.0",
"shadcn": "4.0.8",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
@@ -1122,8 +1121,6 @@
"@react-navigation/routers": ["@react-navigation/routers@7.5.3", "", { "dependencies": { "nanoid": "^3.3.11" } }, "sha512-1tJHg4KKRJuQ1/EvJxatrMef3NZXEPzwUIUZ3n1yJ2t7Q97siwRtbynRpQG9/69ebbtiZ8W3ScOZF/OmhvM4Rg=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.9", "", { "os": "android", "cpu": "arm64" }, "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ=="],
@@ -1208,8 +1205,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@tabler/icons": ["@tabler/icons@3.40.0", "", {}, "sha512-V/Q4VgNPKubRTiLdmWjV/zscYcj5IIk+euicUtaVVqF6luSC9rDngYWgST5/yh3Mrg/mYUwRv1YVTk71Jp0twQ=="],
"@tabler/icons-react": ["@tabler/icons-react@3.40.0", "", { "dependencies": { "@tabler/icons": "3.40.0" }, "peerDependencies": { "react": ">= 16" } }, "sha512-oO5+6QCnna4a//mYubx4euZfECtzQZFDGsDMIdzZUhbdyBCT+3bRVFBPueGIcemWld4Vb/0UQ39C/cmGfGylAg=="],
@@ -1338,24 +1333,6 @@
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/graceful-fs": ["@types/graceful-fs@4.1.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ=="],
"@types/hammerjs": ["@types/hammerjs@2.0.46", "", {}, "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw=="],
@@ -1380,8 +1357,6 @@
"@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
@@ -1644,28 +1619,6 @@
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"date-fns": ["date-fns@3.6.0", "", {}, "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww=="],
@@ -1678,8 +1631,6 @@
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
@@ -1756,8 +1707,6 @@
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
@@ -1774,8 +1723,6 @@
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
@@ -2000,8 +1947,6 @@
"image-size": ["image-size@1.2.1", "", { "dependencies": { "queue": "6.0.2" }, "bin": { "image-size": "bin/image-size.js" } }, "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
@@ -2010,8 +1955,6 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
@@ -2482,7 +2425,7 @@
"react-freeze": ["react-freeze@1.0.4", "", { "peerDependencies": { "react": ">=17.0.0" } }, "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA=="],
"react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"react-native": ["react-native@0.83.2", "", { "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.83.2", "@react-native/codegen": "0.83.2", "@react-native/community-cli-plugin": "0.83.2", "@react-native/gradle-plugin": "0.83.2", "@react-native/js-polyfills": "0.83.2", "@react-native/normalize-colors": "0.83.2", "@react-native/virtualized-lists": "0.83.2", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", "babel-jest": "^29.7.0", "babel-plugin-syntax-hermes-parser": "0.32.0", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", "glob": "^7.1.1", "hermes-compiler": "0.14.1", "invariant": "^2.2.4", "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", "metro-runtime": "^0.83.3", "metro-source-map": "^0.83.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", "whatwg-fetch": "^3.0.0", "ws": "^7.5.10", "yargs": "^17.6.2" }, "peerDependencies": { "@types/react": "^19.1.1", "react": "^19.2.0" }, "optionalPeers": ["@types/react"], "bin": { "react-native": "cli.js" } }, "sha512-ZDma3SLkRN2U2dg0/EZqxNBAx4of/oTnPjXAQi299VLq2gdnbZowGy9hzqv+O7sTA62g+lM1v+2FM5DUnJ/6hg=="],
@@ -2510,8 +2453,6 @@
"react-native-worklets": ["react-native-worklets@0.7.2", "", { "dependencies": { "@babel/plugin-transform-arrow-functions": "7.27.1", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-classes": "7.28.4", "@babel/plugin-transform-nullish-coalescing-operator": "7.27.1", "@babel/plugin-transform-optional-chaining": "7.27.1", "@babel/plugin-transform-shorthand-properties": "7.27.1", "@babel/plugin-transform-template-literals": "7.27.1", "@babel/plugin-transform-unicode-regex": "7.27.1", "@babel/preset-typescript": "7.27.1", "convert-source-map": "2.0.0", "semver": "7.7.3" }, "peerDependencies": { "@babel/core": "*", "react": "*", "react-native": "*" } }, "sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog=="],
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -2530,12 +2471,6 @@
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
"recharts": ["recharts@3.8.0", "", { "dependencies": { "@reduxjs/toolkit": "^1.9.0 || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
@@ -2836,8 +2771,6 @@
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="],
"vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="],
@@ -3026,7 +2959,7 @@
"@react-native/dev-middleware/open": ["open@7.4.2", "", { "dependencies": { "is-docker": "^2.0.0", "is-wsl": "^2.1.1" } }, "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q=="],
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
"@react-navigation/core/react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="],
"@tailwindcss/node/lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
@@ -3192,8 +3125,6 @@
"pixelmatch/pngjs": ["pngjs@3.4.0", "", {}, "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="],
"pretty-format/react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],