diff --git a/apps/web/package.json b/apps/web/package.json index 0ee4a5a..024c4ce 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-grid-layout": "^2.2.2", + "recharts": "^3.7.0", "socket.io-client": "^4.8.3" }, "devDependencies": { diff --git a/apps/web/src/app/(authenticated)/usage/page.test.tsx b/apps/web/src/app/(authenticated)/usage/page.test.tsx new file mode 100644 index 0000000..4d97ff6 --- /dev/null +++ b/apps/web/src/app/(authenticated)/usage/page.test.tsx @@ -0,0 +1,288 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import type { ReactNode } from "react"; +import UsagePage from "./page"; + +// ─── Component Prop Types ──────────────────────────────────────────── + +interface ChildrenProps { + children: ReactNode; +} + +interface StyledChildrenProps extends ChildrenProps { + className?: string; +} + +// ─── Mocks ─────────────────────────────────────────────────────────── + +// Mock @/components/ui/card — @mosaic/ui can't be resolved in vitest +vi.mock("@/components/ui/card", () => ({ + Card: ({ children, className }: StyledChildrenProps): React.JSX.Element => ( +
{children}
+ ), + CardHeader: ({ children }: ChildrenProps): React.JSX.Element =>
{children}
, + CardContent: ({ children, className }: StyledChildrenProps): React.JSX.Element => ( +
{children}
+ ), + CardFooter: ({ children }: ChildrenProps): React.JSX.Element =>
{children}
, + CardTitle: ({ children, className }: StyledChildrenProps): React.JSX.Element => ( +

{children}

+ ), + CardDescription: ({ children, className }: StyledChildrenProps): React.JSX.Element => ( +

{children}

+ ), +})); + +// Mock recharts — jsdom has no SVG layout engine, so we render stubs +vi.mock("recharts", () => ({ + LineChart: ({ children }: ChildrenProps): React.JSX.Element => ( +
{children}
+ ), + Line: (): React.JSX.Element =>
, + BarChart: ({ children }: ChildrenProps): React.JSX.Element => ( +
{children}
+ ), + Bar: (): React.JSX.Element =>
, + PieChart: ({ children }: ChildrenProps): React.JSX.Element => ( +
{children}
+ ), + Pie: (): React.JSX.Element =>
, + Cell: (): React.JSX.Element =>
, + XAxis: (): React.JSX.Element =>
, + YAxis: (): React.JSX.Element =>
, + CartesianGrid: (): React.JSX.Element =>
, + Tooltip: (): React.JSX.Element =>
, + ResponsiveContainer: ({ children }: ChildrenProps): React.JSX.Element =>
{children}
, + Legend: (): React.JSX.Element =>
, +})); + +// Mock the telemetry API module +vi.mock("@/lib/api/telemetry", () => ({ + fetchUsageSummary: vi.fn(), + fetchTokenUsage: vi.fn(), + fetchCostBreakdown: vi.fn(), + fetchTaskOutcomes: vi.fn(), +})); + +// Import mocked modules after vi.mock +import { + fetchUsageSummary, + fetchTokenUsage, + fetchCostBreakdown, + fetchTaskOutcomes, +} from "@/lib/api/telemetry"; + +// ─── Test Data ─────────────────────────────────────────────────────── + +const mockSummary = { + totalTokens: 245800, + totalCost: 3.42, + taskCount: 47, + avgQualityGatePassRate: 0.87, +}; + +const mockTokenUsage = [ + { date: "2026-02-08", inputTokens: 10000, outputTokens: 5000, totalTokens: 15000 }, + { date: "2026-02-09", inputTokens: 12000, outputTokens: 6000, totalTokens: 18000 }, +]; + +const mockCostBreakdown = [ + { model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 }, + { model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 }, +]; + +const mockTaskOutcomes = [ + { outcome: "Success", count: 312, color: "#6EBF8B" }, + { outcome: "Partial", count: 48, color: "#F5C862" }, +]; + +function setupMocks(overrides?: { empty?: boolean; error?: boolean }): void { + if (overrides?.error) { + vi.mocked(fetchUsageSummary).mockRejectedValue(new Error("Network error")); + vi.mocked(fetchTokenUsage).mockRejectedValue(new Error("Network error")); + vi.mocked(fetchCostBreakdown).mockRejectedValue(new Error("Network error")); + vi.mocked(fetchTaskOutcomes).mockRejectedValue(new Error("Network error")); + return; + } + + const summary = overrides?.empty ? { ...mockSummary, taskCount: 0 } : mockSummary; + + vi.mocked(fetchUsageSummary).mockResolvedValue(summary); + vi.mocked(fetchTokenUsage).mockResolvedValue(mockTokenUsage); + vi.mocked(fetchCostBreakdown).mockResolvedValue(mockCostBreakdown); + vi.mocked(fetchTaskOutcomes).mockResolvedValue(mockTaskOutcomes); +} + +// ─── Tests ─────────────────────────────────────────────────────────── + +describe("UsagePage", (): void => { + beforeEach((): void => { + vi.clearAllMocks(); + }); + + it("should render the page title and subtitle", (): void => { + setupMocks(); + render(); + + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Usage"); + expect(screen.getByText("Token usage and cost overview")).toBeInTheDocument(); + }); + + it("should have proper layout structure", (): void => { + setupMocks(); + const { container } = render(); + const main = container.querySelector("main"); + expect(main).toBeInTheDocument(); + }); + + it("should show loading skeleton initially", (): void => { + setupMocks(); + render(); + expect(screen.getByTestId("loading-skeleton")).toBeInTheDocument(); + }); + + it("should render summary cards after loading", async (): Promise => { + setupMocks(); + render(); + + await waitFor((): void => { + expect(screen.getByTestId("summary-cards")).toBeInTheDocument(); + }); + + // Check summary card values + expect(screen.getByText("Total Tokens")).toBeInTheDocument(); + expect(screen.getByText("245.8K")).toBeInTheDocument(); + expect(screen.getByText("Estimated Cost")).toBeInTheDocument(); + expect(screen.getByText("$3.42")).toBeInTheDocument(); + expect(screen.getByText("Task Count")).toBeInTheDocument(); + expect(screen.getByText("47")).toBeInTheDocument(); + expect(screen.getByText("Quality Gate Pass Rate")).toBeInTheDocument(); + expect(screen.getByText("87.0%")).toBeInTheDocument(); + }); + + it("should render all chart sections after loading", async (): Promise => { + setupMocks(); + render(); + + await waitFor((): void => { + expect(screen.getByTestId("token-usage-chart")).toBeInTheDocument(); + expect(screen.getByTestId("cost-breakdown-chart")).toBeInTheDocument(); + expect(screen.getByTestId("task-outcomes-chart")).toBeInTheDocument(); + }); + }); + + it("should render the time range selector with three options", (): void => { + setupMocks(); + render(); + + expect(screen.getByText("7 Days")).toBeInTheDocument(); + expect(screen.getByText("30 Days")).toBeInTheDocument(); + expect(screen.getByText("90 Days")).toBeInTheDocument(); + }); + + it("should have 30 Days selected by default", (): void => { + setupMocks(); + render(); + + const button30d = screen.getByText("30 Days"); + expect(button30d).toHaveAttribute("aria-pressed", "true"); + }); + + it("should change time range when a different option is clicked", async (): Promise => { + setupMocks(); + render(); + + // Wait for initial load + await waitFor((): void => { + expect(screen.getByTestId("summary-cards")).toBeInTheDocument(); + }); + + // Click 7 Days + const button7d = screen.getByText("7 Days"); + fireEvent.click(button7d); + + expect(button7d).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("30 Days")).toHaveAttribute("aria-pressed", "false"); + }); + + it("should refetch data when time range changes", async (): Promise => { + setupMocks(); + render(); + + // Wait for initial load (30d default) + await waitFor((): void => { + expect(screen.getByTestId("summary-cards")).toBeInTheDocument(); + }); + + // Initial call was with "30d" + expect(fetchUsageSummary).toHaveBeenCalledWith("30d"); + + // Change to 7d + fireEvent.click(screen.getByText("7 Days")); + + await waitFor((): void => { + expect(fetchUsageSummary).toHaveBeenCalledWith("7d"); + }); + }); + + it("should show empty state when no tasks exist", async (): Promise => { + setupMocks({ empty: true }); + render(); + + await waitFor((): void => { + expect(screen.getByTestId("empty-state")).toBeInTheDocument(); + }); + + expect(screen.getByText("No usage data yet")).toBeInTheDocument(); + }); + + it("should show error state on fetch failure", async (): Promise => { + setupMocks({ error: true }); + render(); + + await waitFor((): void => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + + expect(screen.getByText("Try again")).toBeInTheDocument(); + }); + + it("should retry loading when Try again button is clicked after error", async (): Promise => { + setupMocks({ error: true }); + render(); + + await waitFor((): void => { + expect(screen.getByText("Try again")).toBeInTheDocument(); + }); + + // Now set up success mocks and click retry + setupMocks(); + fireEvent.click(screen.getByText("Try again")); + + await waitFor((): void => { + expect(screen.getByTestId("summary-cards")).toBeInTheDocument(); + }); + }); + + it("should display chart section titles", async (): Promise => { + setupMocks(); + render(); + + await waitFor((): void => { + expect(screen.getByText("Token Usage Over Time")).toBeInTheDocument(); + expect(screen.getByText("Cost by Model")).toBeInTheDocument(); + expect(screen.getByText("Task Outcomes")).toBeInTheDocument(); + }); + }); + + it("should render recharts components within chart containers", async (): Promise => { + setupMocks(); + render(); + + await waitFor((): void => { + expect(screen.getByTestId("recharts-line-chart")).toBeInTheDocument(); + expect(screen.getByTestId("recharts-bar-chart")).toBeInTheDocument(); + expect(screen.getByTestId("recharts-pie-chart")).toBeInTheDocument(); + }); + }); +}); diff --git a/apps/web/src/app/(authenticated)/usage/page.tsx b/apps/web/src/app/(authenticated)/usage/page.tsx new file mode 100644 index 0000000..d90917b --- /dev/null +++ b/apps/web/src/app/(authenticated)/usage/page.tsx @@ -0,0 +1,427 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import type { ReactElement } from "react"; +import { + LineChart, + Line, + BarChart, + Bar, + PieChart, + Pie, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { Card, CardHeader, CardContent, CardTitle, CardDescription } from "@/components/ui/card"; +import { + fetchUsageSummary, + fetchTokenUsage, + fetchCostBreakdown, + fetchTaskOutcomes, +} from "@/lib/api/telemetry"; +import type { + TimeRange, + UsageSummary, + TokenUsagePoint, + CostBreakdownItem, + TaskOutcomeItem, +} from "@/lib/api/telemetry"; + +// ─── Constants ─────────────────────────────────────────────────────── + +const TIME_RANGES: { value: TimeRange; label: string }[] = [ + { value: "7d", label: "7 Days" }, + { value: "30d", label: "30 Days" }, + { value: "90d", label: "90 Days" }, +]; + +// Calm, PDA-friendly chart colors (no aggressive reds) +const CHART_COLORS = { + inputTokens: "#6366F1", // Indigo + outputTokens: "#38BDF8", // Sky blue + grid: "#E2E8F0", // Slate 200 + barFill: "#818CF8", // Indigo 400 +}; + +// ─── Helpers ───────────────────────────────────────────────────────── + +function formatNumber(value: number): string { + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(1)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(1)}K`; + } + return value.toFixed(0); +} + +function formatCurrency(value: number): string { + return `$${value.toFixed(2)}`; +} + +function formatPercent(value: number): string { + return `${(value * 100).toFixed(1)}%`; +} + +function formatDateLabel(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +} + +/** + * Map TaskOutcomeItem[] to recharts-compatible data with `fill` property. + * This replaces deprecated Cell component (removed in Recharts 4.0). + */ +function toFillData( + outcomes: TaskOutcomeItem[] +): { outcome: string; count: number; fill: string }[] { + return outcomes.map((item) => ({ + outcome: item.outcome, + count: item.count, + fill: item.color, + })); +} + +// ─── Sub-components ────────────────────────────────────────────────── + +function SummaryCard({ + title, + value, + subtitle, +}: { + title: string; + value: string; + subtitle?: string; +}): ReactElement { + return ( + + +

{title}

+

{value}

+ {subtitle ?

{subtitle}

: null} +
+
+ ); +} + +function LoadingSkeleton(): ReactElement { + return ( +
+ {/* Summary cards skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+ + + ))} +
+ {/* Chart skeletons */} +
+ {Array.from({ length: 3 }).map((_, i) => ( + + +
+ + +
+ + + ))} +
+
+ ); +} + +function EmptyState(): ReactElement { + return ( +
+
📊
+

No usage data yet

+

+ Once you start using AI-powered features, your token usage and cost data will appear here. +

+
+ ); +} + +// ─── Main Page Component ───────────────────────────────────────────── + +export default function UsagePage(): ReactElement { + const [timeRange, setTimeRange] = useState("30d"); + const [isLoading, setIsLoading] = useState(true); + const [isEmpty, setIsEmpty] = useState(false); + const [error, setError] = useState(null); + + const [summary, setSummary] = useState(null); + const [tokenUsage, setTokenUsage] = useState([]); + const [costBreakdown, setCostBreakdown] = useState([]); + const [taskOutcomes, setTaskOutcomes] = useState([]); + + const loadData = useCallback(async (range: TimeRange): Promise => { + setIsLoading(true); + setError(null); + + try { + const [summaryData, tokenData, costData, outcomeData] = await Promise.all([ + fetchUsageSummary(range), + fetchTokenUsage(range), + fetchCostBreakdown(range), + fetchTaskOutcomes(range), + ]); + + setSummary(summaryData); + setTokenUsage(tokenData); + setCostBreakdown(costData); + setTaskOutcomes(outcomeData); + + // Check if there's any meaningful data + setIsEmpty(summaryData.taskCount === 0); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "We had trouble loading usage data. Please try again when you're ready." + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadData(timeRange); + }, [timeRange, loadData]); + + function handleTimeRangeChange(range: TimeRange): void { + setTimeRange(range); + } + + return ( +
+ {/* Header */} +
+
+

Usage

+

Token usage and cost overview

+
+ + {/* Time range selector */} +
+ {TIME_RANGES.map(({ value, label }) => ( + + ))} +
+
+ + {/* Error state */} + {error !== null ? ( +
+

{error}

+ +
+ ) : isLoading ? ( + + ) : isEmpty ? ( + + ) : ( +
+ {/* Summary Cards */} +
+ + + + +
+ + {/* Charts */} +
+ {/* Token Usage Over Time — Full width */} + + + Token Usage Over Time + Input and output tokens by day + + +
+ + + + + + [ + formatNumber(value), + name === "inputTokens" ? "Input Tokens" : "Output Tokens", + ]} + labelFormatter={formatDateLabel} + contentStyle={{ + borderRadius: "8px", + border: "1px solid #E2E8F0", + boxShadow: "0 2px 8px rgba(0,0,0,0.08)", + }} + /> + + value === "inputTokens" ? "Input Tokens" : "Output Tokens" + } + /> + + + + +
+
+
+ + {/* Cost Breakdown by Model */} + + + Cost by Model + Estimated cost breakdown + + +
+ + + + formatCurrency(v)} + tick={{ fontSize: 12, fill: "#64748B" }} + /> + + [formatCurrency(value), "Cost"]} + contentStyle={{ + borderRadius: "8px", + border: "1px solid #E2E8F0", + boxShadow: "0 2px 8px rgba(0,0,0,0.08)", + }} + /> + + + +
+
+
+ + {/* Task Outcomes */} + + + Task Outcomes + Distribution of task completion results + + +
+ + + + `${outcome}: ${String(count)}` + } + /> + [value, name]} + contentStyle={{ + borderRadius: "8px", + border: "1px solid #E2E8F0", + boxShadow: "0 2px 8px rgba(0,0,0,0.08)", + }} + /> + + + +
+
+
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/Navigation.tsx b/apps/web/src/components/layout/Navigation.tsx index e961e47..5757717 100644 --- a/apps/web/src/components/layout/Navigation.tsx +++ b/apps/web/src/components/layout/Navigation.tsx @@ -16,6 +16,7 @@ export function Navigation(): React.JSX.Element { { href: "/tasks", label: "Tasks" }, { href: "/calendar", label: "Calendar" }, { href: "/knowledge", label: "Knowledge" }, + { href: "/usage", label: "Usage" }, ]; // Global keyboard shortcut for search (Cmd+K or Ctrl+K) diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index 8c1629c..5877de4 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -12,3 +12,4 @@ export * from "./knowledge"; export * from "./domains"; export * from "./teams"; export * from "./personalities"; +export * from "./telemetry"; diff --git a/apps/web/src/lib/api/telemetry.ts b/apps/web/src/lib/api/telemetry.ts new file mode 100644 index 0000000..49cb779 --- /dev/null +++ b/apps/web/src/lib/api/telemetry.ts @@ -0,0 +1,187 @@ +/** + * Telemetry API Client + * Handles telemetry data fetching for the usage dashboard. + * + * NOTE: Currently returns mock/placeholder data since the telemetry API + * aggregation endpoints don't exist yet. The important thing is the UI structure. + * When the backend endpoints are ready, replace mock calls with real apiGet() calls. + */ + +import { apiGet, type ApiResponse } from "./client"; + +// ─── Types ─────────────────────────────────────────────────────────── + +export type TimeRange = "7d" | "30d" | "90d"; + +export interface UsageSummary { + totalTokens: number; + totalCost: number; + taskCount: number; + avgQualityGatePassRate: number; +} + +export interface TokenUsagePoint { + date: string; + inputTokens: number; + outputTokens: number; + totalTokens: number; +} + +export interface CostBreakdownItem { + model: string; + provider: string; + cost: number; + taskCount: number; +} + +export interface TaskOutcomeItem { + outcome: string; + count: number; + color: string; +} + +export interface EstimateParams { + taskType: string; + model: string; + provider: string; + complexity: string; +} + +export interface EstimateResponse { + prediction: { + input_tokens: { median: number; p75: number; p90: number }; + output_tokens: { median: number; p75: number; p90: number }; + cost_usd_micros: Record; + quality: { gate_pass_rate: number; success_rate: number }; + } | null; + metadata: { + sample_size: number; + confidence: "none" | "low" | "medium" | "high"; + }; +} + +// ─── Mock Data Generators ──────────────────────────────────────────── + +function generateDateRange(range: TimeRange): string[] { + const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; + const dates: string[] = []; + const now = new Date(); + + for (let i = days - 1; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i); + dates.push(d.toISOString().split("T")[0]); + } + + return dates; +} + +function generateMockTokenUsage(range: TimeRange): TokenUsagePoint[] { + const dates = generateDateRange(range); + + return dates.map((date) => { + const baseInput = 8000 + Math.floor(Math.random() * 12000); + const baseOutput = 3000 + Math.floor(Math.random() * 7000); + return { + date, + inputTokens: baseInput, + outputTokens: baseOutput, + totalTokens: baseInput + baseOutput, + }; + }); +} + +function generateMockSummary(range: TimeRange): UsageSummary { + const multiplier = range === "7d" ? 1 : range === "30d" ? 4 : 12; + return { + totalTokens: 245_800 * multiplier, + totalCost: 3.42 * multiplier, + taskCount: 47 * multiplier, + avgQualityGatePassRate: 0.87, + }; +} + +function generateMockCostBreakdown(): CostBreakdownItem[] { + return [ + { model: "claude-sonnet-4-5", provider: "anthropic", cost: 18.5, taskCount: 124 }, + { model: "gpt-4o", provider: "openai", cost: 12.3, taskCount: 89 }, + { model: "claude-haiku-3.5", provider: "anthropic", cost: 4.2, taskCount: 156 }, + { model: "llama-3.3-70b", provider: "ollama", cost: 0, taskCount: 67 }, + { model: "gemini-2.0-flash", provider: "google", cost: 2.8, taskCount: 42 }, + ]; +} + +// PDA-friendly colors: calm, no aggressive reds +function generateMockTaskOutcomes(): TaskOutcomeItem[] { + return [ + { outcome: "Success", count: 312, color: "#6EBF8B" }, + { outcome: "Partial", count: 48, color: "#F5C862" }, + { outcome: "Timeout", count: 18, color: "#94A3B8" }, + { outcome: "Incomplete", count: 22, color: "#C4A5DE" }, + ]; +} + +// ─── API Functions ─────────────────────────────────────────────────── + +/** + * Fetch usage summary data (total tokens, cost, task count, quality rate) + */ +export async function fetchUsageSummary(timeRange: TimeRange): Promise { + // TODO: Replace with real API call when backend aggregation endpoints are ready + // const response = await apiGet>(`/api/telemetry/summary?range=${timeRange}`); + // return response.data; + void apiGet; // suppress unused import warning in the meantime + await new Promise((resolve) => setTimeout(resolve, 200)); + return generateMockSummary(timeRange); +} + +/** + * Fetch token usage time series for charts + */ +export async function fetchTokenUsage(timeRange: TimeRange): Promise { + // TODO: Replace with real API call + // const response = await apiGet>(`/api/telemetry/tokens?range=${timeRange}`); + // return response.data; + await new Promise((resolve) => setTimeout(resolve, 250)); + return generateMockTokenUsage(timeRange); +} + +/** + * Fetch cost breakdown by model + */ +export async function fetchCostBreakdown(timeRange: TimeRange): Promise { + // TODO: Replace with real API call + // const response = await apiGet>(`/api/telemetry/costs?range=${timeRange}`); + // return response.data; + await new Promise((resolve) => setTimeout(resolve, 200)); + void timeRange; + return generateMockCostBreakdown(); +} + +/** + * Fetch task outcome distribution + */ +export async function fetchTaskOutcomes(timeRange: TimeRange): Promise { + // TODO: Replace with real API call + // const response = await apiGet>(`/api/telemetry/outcomes?range=${timeRange}`); + // return response.data; + await new Promise((resolve) => setTimeout(resolve, 150)); + void timeRange; + return generateMockTaskOutcomes(); +} + +/** + * Fetch cost/token estimate for a given task configuration. + * Uses the real GET /api/telemetry/estimate endpoint from TEL-006. + */ +export async function fetchEstimate(params: EstimateParams): Promise { + const query = new URLSearchParams({ + taskType: params.taskType, + model: params.model, + provider: params.provider, + complexity: params.complexity, + }).toString(); + + const response = await apiGet>(`/api/telemetry/estimate?${query}`); + return response.data; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8450600..6678c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,7 +391,7 @@ importers: version: 3.2.0 '@xyflow/react': specifier: ^12.5.3 - version: 12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) better-auth: specifier: ^1.4.17 version: 1.4.17(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(better-sqlite3@12.6.2)(drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(next@16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -422,6 +422,9 @@ importers: react-grid-layout: specifier: ^2.2.2 version: 2.2.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + recharts: + specifier: ^3.7.0 + version: 3.7.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1) socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -2431,6 +2434,17 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@reduxjs/toolkit@2.11.2': + resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -2586,6 +2600,9 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@swc/core-darwin-arm64@1.15.11': resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} engines: {node: '>=10'} @@ -2980,6 +2997,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -3989,6 +4009,9 @@ packages: supports-color: optional: true + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -4299,6 +4322,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es-toolkit@1.44.0: + resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -4708,6 +4734,12 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.4: + resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -5650,6 +5682,18 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-redux@9.2.0: + resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} + peerDependencies: + '@types/react': ^18.2.25 || ^19 + react: ^18.0 || ^19 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} @@ -5686,6 +5730,14 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + recharts@3.7.0: + resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==} + engines: {node: '>=18'} + 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 + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -5698,6 +5750,14 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redux-thunk@3.1.0: + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + + redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5720,6 +5780,9 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -6115,6 +6178,9 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6366,6 +6432,9 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -7047,7 +7116,7 @@ snapshots: chalk: 5.6.2 commander: 12.1.0 dotenv: 17.2.4 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) open: 10.2.0 pg: 8.17.2 prettier: 3.8.1 @@ -8904,6 +8973,18 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1))(react@19.2.4)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@standard-schema/utils': 0.3.0 + immer: 11.1.4 + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + optionalDependencies: + react: 19.2.4 + react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1) + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.3.0(rollup@4.57.0)': @@ -9002,6 +9083,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} + '@swc/core-darwin-arm64@1.15.11': optional: true @@ -9449,6 +9532,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/use-sync-external-store@0.0.6': {} + '@types/validator@13.15.10': {} '@types/ws@8.18.1': @@ -9768,13 +9853,13 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.10.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@xyflow/react@12.10.0(@types/react@19.2.10)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@xyflow/system': 0.0.74 classcat: 5.0.5 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - zustand: 4.5.7(@types/react@19.2.10)(react@19.2.4) + zustand: 4.5.7(@types/react@19.2.10)(immer@11.1.4)(react@19.2.4) transitivePeerDependencies: - '@types/react' - immer @@ -9978,7 +10063,7 @@ snapshots: optionalDependencies: '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -10003,7 +10088,7 @@ snapshots: optionalDependencies: '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) better-sqlite3: 12.6.2 - drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) @@ -10626,6 +10711,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} decompress-response@6.0.0: @@ -10758,6 +10845,16 @@ snapshots: dotenv@17.2.4: {} + drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)) + '@types/pg': 8.16.0 + better-sqlite3: 12.6.2 + kysely: 0.28.10 + pg: 8.17.2 + prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)): optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -10767,6 +10864,7 @@ snapshots: kysely: 0.28.10 pg: 8.17.2 prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3) + optional: true dunder-proto@1.0.1: dependencies: @@ -10865,6 +10963,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es-toolkit@1.44.0: {} + esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -11349,6 +11449,10 @@ snapshots: ignore@7.0.5: {} + immer@10.2.0: {} + + immer@11.1.4: {} + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 @@ -12271,6 +12375,15 @@ snapshots: react-is@17.0.2: {} + react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1): + dependencies: + '@types/use-sync-external-store': 0.0.6 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.10 + redux: 5.0.1 + react-refresh@0.17.0: {} react-resizable@3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -12314,6 +12427,26 @@ snapshots: readdirp@5.0.0: {} + recharts@3.7.0(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react-is@17.0.2)(react@19.2.4)(redux@5.0.1): + dependencies: + '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1))(react@19.2.4) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.44.0 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-is: 17.0.2 + react-redux: 9.2.0(@types/react@19.2.10)(react@19.2.4)(redux@5.0.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@19.2.4) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - redux + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -12325,6 +12458,12 @@ snapshots: dependencies: redis-errors: 1.2.0 + redux-thunk@3.1.0(redux@5.0.1): + dependencies: + redux: 5.0.1 + + redux@5.0.1: {} + reflect-metadata@0.2.2: {} regexp-to-ast@0.5.0: {} @@ -12343,6 +12482,8 @@ snapshots: transitivePeerDependencies: - supports-color + reselect@5.1.1: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -12863,6 +13004,8 @@ snapshots: transitivePeerDependencies: - react-native-b4a + tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -13087,6 +13230,23 @@ snapshots: vary@1.1.2: {} + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-node@3.2.4(@types/node@22.19.7)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -13388,9 +13548,10 @@ snapshots: zod@4.3.6: {} - zustand@4.5.7(@types/react@19.2.10)(react@19.2.4): + zustand@4.5.7(@types/react@19.2.10)(immer@11.1.4)(react@19.2.4): dependencies: use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: '@types/react': 19.2.10 + immer: 11.1.4 react: 19.2.4