(
{row.label}
@@ -1276,8 +1597,8 @@ function DashboardErrorDimensionTable({
),
},
{
- header: "Errors",
id: "errors",
+ header: "Errors",
renderCell: (row) => {
const highlightedRow =
highlightedDate != null
@@ -1292,8 +1613,8 @@ function DashboardErrorDimensionTable({
},
},
{
- header: "Avg / session",
id: "avg-session",
+ header: "Avg / session",
renderCell: (row) => {
const highlightedRow =
highlightedDate != null
@@ -1310,8 +1631,8 @@ function DashboardErrorDimensionTable({
},
},
{
- header: "Avg / interaction",
id: "avg-interaction",
+ header: "Avg / interaction",
renderCell: (row) => {
const highlightedRow =
highlightedDate != null
@@ -1329,8 +1650,8 @@ function DashboardErrorDimensionTable({
},
},
{
- header: "Active days",
id: "active-days",
+ header: "Active days",
renderCell: (row) => (
{row.activeDays}
@@ -1338,6 +1659,11 @@ function DashboardErrorDimensionTable({
),
},
]}
+ rows={visibleRows}
+ rowKey={(row) => row.id}
+ gridTemplateColumns="minmax(220px,12fr) 90px 120px 120px 96px"
+ minWidthClassName="min-w-[58rem]"
+ bodyClassName="gap-0"
footer={
hiddenRowCount > 0 ? (
@@ -1345,10 +1671,8 @@ function DashboardErrorDimensionTable({
) : null
}
- getHoverRowId={(row) => row.id}
- gridTemplateColumns="minmax(220px,12fr) 90px 120px 120px 96px"
- minWidthClassName="min-w-[58rem]"
onRowHoverChange={onHighlightDimensionChange}
+ getHoverRowId={(row) => row.id}
rowClassName={(row) =>
cn(
"transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
@@ -1362,8 +1686,6 @@ function DashboardErrorDimensionTable({
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
)
}
- rowKey={(row) => row.id}
- rows={visibleRows}
/>
);
}
diff --git a/apps/web/src/features/dashboard/components/DashboardFilterControls.tsx b/apps/web/src/features/dashboard/components/DashboardFilterControls.tsx
new file mode 100644
index 00000000..e6d761df
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardFilterControls.tsx
@@ -0,0 +1,186 @@
+import { Sparkles, Users } from "lucide-react";
+import type { Dispatch, ReactNode, SetStateAction } from "react";
+import { useMemo, useState } from "react";
+import { Button } from "@/app/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/ui/dropdown-menu";
+import { dashboardUserOptions } from "@/features/dashboard/data/dashboard-static-data";
+import { cn } from "@/lib/utils";
+
+const DASHBOARD_USER_OPTIONS = [...dashboardUserOptions];
+const DASHBOARD_MODEL_OPTIONS = ["Opus", "Sonnet 4", "Haiku"];
+
+function buildFilterButtonLabel(
+ baseLabel: string,
+ selectedValues: string[],
+ allValues: string[],
+) {
+ if (
+ selectedValues.length === 0 ||
+ selectedValues.length === allValues.length
+ ) {
+ return baseLabel;
+ }
+
+ if (selectedValues.length === 1) {
+ return selectedValues[0] ?? baseLabel;
+ }
+
+ return `${baseLabel} · ${selectedValues.length}`;
+}
+
+function setSelectionState(
+ currentValues: string[],
+ value: string,
+ checked: boolean,
+) {
+ if (checked) {
+ if (currentValues.includes(value)) {
+ return currentValues;
+ }
+
+ return [...currentValues, value];
+ }
+
+ return currentValues.filter((currentValue) => currentValue !== value);
+}
+
+function DashboardFilterMenu({
+ icon,
+ label,
+ options,
+ selectedValues,
+ setSelectedValues,
+ buttonClassName,
+}: {
+ icon: ReactNode;
+ label: string;
+ options: string[];
+ selectedValues: string[];
+ setSelectedValues: Dispatch>;
+ buttonClassName?: string;
+}) {
+ const buttonLabel = buildFilterButtonLabel(label, selectedValues, options);
+
+ return (
+
+
+ }
+ >
+ {icon}
+ {buttonLabel}
+
+
+
+ {label}
+ setSelectedValues(options)}>
+ All
+
+ setSelectedValues([])}>
+ None
+
+
+
+
+ {options.map((option) => {
+ const isSelected = selectedValues.includes(option);
+
+ return (
+
+ setSelectedValues((currentValues) =>
+ setSelectionState(currentValues, option, Boolean(checked)),
+ )
+ }
+ >
+ {option}
+
+ );
+ })}
+
+
+
+ );
+}
+
+export function DashboardFilterControls({
+ className,
+ buttonClassName,
+}: {
+ className?: string;
+ buttonClassName?: string;
+}) {
+ const [selectedUsers, setSelectedUsers] = useState(
+ DASHBOARD_USER_OPTIONS,
+ );
+ const [selectedModels, setSelectedModels] = useState(
+ DASHBOARD_MODEL_OPTIONS,
+ );
+
+ const controls = useMemo(
+ () => [
+ {
+ icon: (
+
+ ),
+ label: "Users",
+ options: DASHBOARD_USER_OPTIONS,
+ selectedValues: selectedUsers,
+ setSelectedValues: setSelectedUsers,
+ },
+ {
+ icon: (
+
+ ),
+ label: "Models",
+ options: DASHBOARD_MODEL_OPTIONS,
+ selectedValues: selectedModels,
+ setSelectedValues: setSelectedModels,
+ },
+ ],
+ [selectedModels, selectedUsers],
+ );
+
+ return (
+
+ {controls.map((control) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardImpactCards.tsx b/apps/web/src/features/dashboard/components/DashboardImpactCards.tsx
new file mode 100644
index 00000000..d5abcfdc
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardImpactCards.tsx
@@ -0,0 +1,41 @@
+import type { DashboardBinaryImpact } from "@/features/dashboard/data/dashboard-static-data";
+
+export function DashboardImpactCards({
+ items,
+}: {
+ items: DashboardBinaryImpact[];
+}) {
+ return (
+
+ {items.map((item) => (
+
+
+
{item.label}
+
{item.description}
+
+
+
+
+ {item.withLabel}
+
+
+ {item.withValue}
+
+
+
+
+ {item.withoutLabel}
+
+
+ {item.withoutValue}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardMeter.tsx b/apps/web/src/features/dashboard/components/DashboardMeter.tsx
new file mode 100644
index 00000000..c8ba1f65
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardMeter.tsx
@@ -0,0 +1,12 @@
+export function DashboardMeter({ value }: { value: number }) {
+ const clampedValue = Math.max(0, Math.min(100, value));
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardMetricDetailSection.tsx b/apps/web/src/features/dashboard/components/DashboardMetricDetailSection.tsx
new file mode 100644
index 00000000..e77b73bd
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardMetricDetailSection.tsx
@@ -0,0 +1,194 @@
+import { format, parseISO } from "date-fns";
+import { Badge } from "@/app/ui/badge";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/app/ui/table";
+import type {
+ DashboardGroupedDetailPoint,
+ DashboardMetricDetailData,
+ DashboardSingleDetailPoint,
+} from "@/features/dashboard/data/dashboard-static-data";
+
+function formatCompactValue(value: number) {
+ if (value >= 1000) {
+ return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}k`;
+ }
+
+ return `${value}`;
+}
+
+function formatDayLabel(date: string) {
+ return format(parseISO(date), "EEE");
+}
+
+function formatNullableValue(value: number | null) {
+ return value == null ? "—" : formatCompactValue(value);
+}
+
+function GroupedDetailTable({
+ points,
+ primaryLabel,
+ secondaryLabel,
+}: {
+ points: DashboardGroupedDetailPoint[];
+ primaryLabel: string;
+ secondaryLabel: string;
+}) {
+ return (
+
+
+
+ Day
+
+ {primaryLabel}
+
+
+ {secondaryLabel}
+
+
+
+
+ {points.map((point) => (
+
+
+ {formatDayLabel(point.date)}
+
+
+ {formatNullableValue(point.primary)}
+
+
+ {formatNullableValue(point.secondary)}
+
+
+ ))}
+
+
+ );
+}
+
+function SingleDetailTable({
+ points,
+ label,
+}: {
+ points: DashboardSingleDetailPoint[];
+ label: string;
+}) {
+ return (
+
+
+
+ Day
+
+ {label}
+
+
+
+
+ {points.map((point) => (
+
+
+ {formatDayLabel(point.date)}
+
+
+ {formatNullableValue(point.value)}
+
+
+ ))}
+
+
+ );
+}
+
+export function DashboardMetricDetailSection({
+ detail,
+}: {
+ detail: DashboardMetricDetailData;
+}) {
+ const totalPrimary = detail.grouped.points.reduce(
+ (sum, point) => sum + (point.primary ?? 0),
+ 0,
+ );
+ const totalSecondary = detail.grouped.points.reduce(
+ (sum, point) => sum + (point.secondary ?? 0),
+ 0,
+ );
+ const totalSingle = detail.single.points.reduce(
+ (sum, point) => sum + (point.value ?? 0),
+ 0,
+ );
+
+ return (
+
+
+
+
+
+ Activity mix
+
+
+ Daily {detail.grouped.primaryLabel} and{" "}
+ {detail.grouped.secondaryLabel}.
+
+
+
+
+ {totalPrimary} {detail.grouped.primaryLabel}
+
+
+ {totalSecondary} {detail.grouped.secondaryLabel}
+
+
+
+
+
+
+
+
+
+
+
+
+ {detail.single.label}
+
+
+ Daily totals for the selected range.
+
+
+
+ {formatCompactValue(totalSingle)}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx b/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx
index 20666fb8..006cdf35 100644
--- a/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardPerformanceChart.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useMemo } from "react";
+import { useMemo, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import {
Bar,
BarChart,
@@ -8,12 +9,17 @@ import {
XAxis,
YAxis,
} from "recharts";
+import { useMountEffect } from "@/app/hooks/useMountEffect";
import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart";
import { DashboardStackedTopRoundedBar } from "@/features/dashboard/components/DashboardStackedTopRoundedBar";
import {
getDashboardBarLabelWidth,
getDashboardBarSize,
} from "@/features/dashboard/components/dashboard-bar-chart-layout";
+import {
+ getSidebarShellDebugState,
+ SIDEBAR_NEWS_ACTIVE_ATTRIBUTE,
+} from "@/features/shell/config/sidebar-shell-debug";
import type { DashboardHighlightChangeHandler } from "@/features/dashboard/components/dashboard-highlight-state";
const chartConfig = {
@@ -97,6 +103,35 @@ function getAxisTicks(axisMax: number) {
return ticks.length > 1 ? ticks : [0, axisMax];
}
+function useSidebarNewsCardActive() {
+ const [isActive, setIsActive] = useState(false);
+
+ useMountEffect(() => {
+ const preview = document.querySelector(".dashboard-01-preview");
+ if (!(preview instanceof HTMLElement)) {
+ return;
+ }
+
+ const sync = () => {
+ setIsActive(
+ preview.getAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE) === "true",
+ );
+ };
+
+ sync();
+
+ const observer = new MutationObserver(sync);
+ observer.observe(preview, {
+ attributes: true,
+ attributeFilter: [SIDEBAR_NEWS_ACTIVE_ATTRIBUTE],
+ });
+
+ return () => observer.disconnect();
+ });
+
+ return isActive;
+}
+
function DashboardPerformanceTooltip({
active,
payload,
@@ -228,6 +263,13 @@ export function DashboardPerformanceChart({
highlightSource,
onHighlightUserChange,
}: DashboardPerformanceChartProps) {
+ const [searchParams] = useSearchParams();
+ const debugState = getSidebarShellDebugState(searchParams);
+ const isSidebarNewsCardActive = useSidebarNewsCardActive();
+ const shouldDisableInteractiveLayers =
+ debugState.tuning.newsDisableChartInteractiveLayersWhileActive &&
+ isSidebarNewsCardActive;
+
const chartData = useMemo(
() =>
data.map((entry) => ({
@@ -308,10 +350,12 @@ export function DashboardPerformanceChart({
fill: "#9A9A9A",
}}
/>
- }
- />
+ {!shouldDisableInteractiveLayers ? (
+ }
+ />
+ ) : null}
row.id}
rowClassName={(row) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -376,8 +376,6 @@ export function DashboardPerformanceRosterTable({
hasTableHighlight &&
highlightedUserId === row.id &&
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight && highlightedUserId !== row.id && "opacity-50",
- hasChartHighlight && highlightedUserId !== row.id && "opacity-50",
)
}
/>
diff --git a/apps/web/src/features/dashboard/components/DashboardRankedOutputTable.tsx b/apps/web/src/features/dashboard/components/DashboardRankedOutputTable.tsx
new file mode 100644
index 00000000..7dda665f
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardRankedOutputTable.tsx
@@ -0,0 +1,84 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/app/ui/table";
+import { DashboardMeter } from "@/features/dashboard/components/DashboardMeter";
+import type { DashboardRankedOutputRow } from "@/features/dashboard/data/dashboard-static-data";
+
+export function DashboardRankedOutputTable({
+ title,
+ description,
+ rows,
+ showHeader = true,
+}: {
+ title: string;
+ description: string;
+ rows: DashboardRankedOutputRow[];
+ showHeader?: boolean;
+}) {
+ const maxCommits = Math.max(...rows.map((row) => row.commits), 1);
+
+ return (
+
+ {showHeader ? (
+
+
+ {title}
+
+
{description}
+
+ ) : (
+
{description}
+ )}
+
+
+
+
+ Name
+
+ Volume
+
+
+ Rate
+
+
+
+
+ {rows.map((row) => (
+
+
+
+
{row.label}
+ {row.secondaryLabel ? (
+
+ {row.secondaryLabel}
+
+ ) : null}
+
+
+
+
+
+ {row.commits} commits / {row.sessions} sessions
+
+
+
+
+
+ {row.commitRate}%
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoriesView.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoriesView.tsx
index 1c9f7c01..9bd746e7 100644
--- a/apps/web/src/features/dashboard/components/DashboardRepositoriesView.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardRepositoriesView.tsx
@@ -8,13 +8,13 @@ import { DashboardDeveloperPanel } from "@/features/dashboard/components/Dashboa
import { DashboardRepositoryDailyOverviewTable } from "@/features/dashboard/components/DashboardRepositoryDailyOverviewTable";
import { DashboardRepositoryPanel } from "@/features/dashboard/components/DashboardRepositoryPanel";
import type { DashboardPerformanceUserComparison } from "@/features/dashboard/data/dashboard-performance-adapter";
+import { buildDashboardRepositorySummaryRows } from "@/features/dashboard/data/dashboard-repository-trend";
+import type { DashboardRankedOutputRow } from "@/features/dashboard/data/dashboard-static-data";
import {
buildDashboardDailyPatternFromRepositoryTrend,
buildDashboardRepositoryDailyOverviewRows,
buildDashboardRepositoryTabMetrics,
-} from "@/features/dashboard/data/dashboard-repository-adapters";
-import { buildDashboardRepositorySummaryRows } from "@/features/dashboard/data/dashboard-repository-trend";
-import type { DashboardRankedOutputRow } from "@/features/dashboard/data/dashboard-static-data";
+} from "@/features/dashboard/data/dashboard-tab-adapters";
export function DashboardRepositoriesView({
endDate,
diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryDailyOverviewTable.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryDailyOverviewTable.tsx
index 662a4a18..0779d482 100644
--- a/apps/web/src/features/dashboard/components/DashboardRepositoryDailyOverviewTable.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardRepositoryDailyOverviewTable.tsx
@@ -4,7 +4,7 @@ import {
DashboardGridTable,
DashboardTableFooterNote,
} from "@/features/dashboard/components/DashboardGridTable";
-import type { DashboardRepositoryDailyOverviewRow } from "@/features/dashboard/data/dashboard-repository-adapters";
+import type { DashboardRepositoryDailyOverviewRow } from "@/features/dashboard/data/dashboard-tab-adapters";
import { formatCompactNumber, formatPercent } from "@/lib/format";
import { cn } from "@/lib/utils";
@@ -108,7 +108,7 @@ export function DashboardRepositoryDailyOverviewTable({
minWidthClassName="min-w-[54rem]"
rowClassName={(row) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -117,8 +117,6 @@ export function DashboardRepositoryDailyOverviewTable({
hasTableHighlight &&
highlightedDate === row.date &&
"!bg-[color:var(--dashboardy-subsurface-strong)] odd:!bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight && highlightedDate !== row.date && "opacity-50",
- hasChartHighlight && highlightedDate !== row.date && "opacity-50",
)
}
footer={
diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx
index 74171fbd..79aea673 100644
--- a/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardRepositoryPanel.tsx
@@ -1,15 +1,11 @@
import type { RepositoryDailyTrendData } from "@rudel/api-routes";
import { FolderGit2Icon } from "lucide-react";
-import { useMemo, useState } from "react";
+import { lazy, Suspense, useMemo, useState } from "react";
import { Skeleton } from "@/app/ui/skeleton";
import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group";
import { DashboardAnalysisPanel } from "@/features/dashboard/components/DashboardAnalysisPanel";
-import {
- buildDashboardRepositoryChartData,
- DashboardRepositoryChart,
-} from "@/features/dashboard/components/DashboardRepositoryChart";
+import { buildDashboardRepositoryChartData } from "@/features/dashboard/components/DashboardRepositoryChart";
import { DashboardRepositoryTable } from "@/features/dashboard/components/DashboardRepositoryTable";
-import { DashboardRepositoryTrendChart } from "@/features/dashboard/components/DashboardRepositoryTrendChart";
import { useDashboardHighlightState } from "@/features/dashboard/components/dashboard-highlight-state";
import {
buildDashboardRepositorySummaryRows,
@@ -39,6 +35,22 @@ const CHART_FALLBACK_LABEL_KEYS = [
"repository-chart-label-5",
] as const;
+const DashboardRepositoryChart = lazy(async () => {
+ const module = await import(
+ "@/features/dashboard/components/DashboardRepositoryChart"
+ );
+
+ return { default: module.DashboardRepositoryChart };
+});
+
+const DashboardRepositoryTrendChart = lazy(async () => {
+ const module = await import(
+ "@/features/dashboard/components/DashboardRepositoryTrendChart"
+ );
+
+ return { default: module.DashboardRepositoryTrendChart };
+});
+
function DashboardRepositoryChartFallback() {
return (
@@ -160,33 +172,39 @@ export function DashboardRepositoryPanel({
) : chartView === "over-time" ? (
hasTrendData ? (
-
+
}>
+
+
) : (
No repository activity in the selected range.
)
) : hasChartData ? (
-
+
}>
+
+
) : (
No repository activity in the selected range.
diff --git a/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx b/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx
index 6c1d5559..b34a4f6e 100644
--- a/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardRepositoryTable.tsx
@@ -267,7 +267,7 @@ export function DashboardRepositoryTable({
getHoverRowId={(row) => row.id}
rowClassName={(row) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -276,12 +276,6 @@ export function DashboardRepositoryTable({
hasTableHighlight &&
highlightedRepositoryId === row.id &&
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight &&
- highlightedRepositoryId !== row.id &&
- "opacity-50",
- hasChartHighlight &&
- highlightedRepositoryId !== row.id &&
- "opacity-50",
)
}
footer={
diff --git a/apps/web/src/features/dashboard/components/DashboardSessionHourlyChart.tsx b/apps/web/src/features/dashboard/components/DashboardSessionHourlyChart.tsx
new file mode 100644
index 00000000..b85edbe6
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardSessionHourlyChart.tsx
@@ -0,0 +1,220 @@
+"use client";
+
+import { useMemo } from "react";
+import { Bar, BarChart, Cell, XAxis, YAxis } from "recharts";
+import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart";
+import { Skeleton } from "@/app/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+type SessionHourlyActivityDataPoint = {
+ hour: number;
+ label: string;
+ sessions: number;
+};
+
+const hourlyChartConfig = {
+ sessions: {
+ label: "Sessions",
+ color: "#1949A9",
+ },
+} satisfies ChartConfig;
+
+const VISIBLE_HOUR_TICKS = new Set([0, 6, 12, 18, 23]);
+
+function getAxisMax(data: SessionHourlyActivityDataPoint[]) {
+ const maxSessions = Math.max(...data.map((point) => point.sessions), 0);
+
+ return Math.max(4, Math.ceil(maxSessions / 4) * 4);
+}
+
+function HourlySessionsTooltip({
+ active,
+ payload,
+ totalSessions,
+}: {
+ active?: boolean;
+ payload?: Array<{
+ payload: SessionHourlyActivityDataPoint;
+ value?: number | string;
+ }>;
+ totalSessions: number;
+}) {
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ const point = payload[0]?.payload;
+
+ if (!point) {
+ return null;
+ }
+
+ const shareOfDay =
+ totalSessions > 0
+ ? Math.round((point.sessions / totalSessions) * 100)
+ : null;
+
+ return (
+
+
{point.label}
+
+ Sessions
+ {point.sessions}
+
+
+ Share of day
+
+ {shareOfDay == null ? "—" : `${shareOfDay}%`}
+
+
+
+ );
+}
+
+function DashboardSessionHourlyChartFallback() {
+ return (
+
+
+ {Array.from({ length: 12 }, (_, index) => (
+
+ ))}
+
+
+ {["12am", "6am", "12pm", "6pm", "11pm"].map((tick) => (
+
+ ))}
+
+
+ );
+}
+
+export function DashboardSessionHourlyChart({
+ activeHour,
+ className,
+ data,
+ isLoading = false,
+}: {
+ activeHour?: number | null;
+ className?: string;
+ data: SessionHourlyActivityDataPoint[] | undefined;
+ isLoading?: boolean;
+}) {
+ const resolvedData = data ?? [];
+ const axisMax = useMemo(() => getAxisMax(resolvedData), [resolvedData]);
+ const axisTicks = useMemo(
+ () =>
+ Array.from(
+ { length: Math.floor(axisMax / 4) + 1 },
+ (_, index) => index * 4,
+ ),
+ [axisMax],
+ );
+ const totalSessions = useMemo(
+ () => resolvedData.reduce((sum, point) => sum + point.sessions, 0),
+ [resolvedData],
+ );
+ const hasSessions = resolvedData.some((point) => point.sessions > 0);
+
+ return (
+
+ {isLoading ? (
+
+ ) : !hasSessions ? (
+
+ No session activity in the selected range.
+
+ ) : (
+
+
+ {
+ const hour = Number(value);
+ const point = resolvedData.find((entry) => entry.hour === hour);
+
+ if (!VISIBLE_HOUR_TICKS.has(hour) || !point) {
+ return "";
+ }
+
+ return point.label;
+ }}
+ tickLine={false}
+ tickMargin={4}
+ tick={{
+ fontSize: 12,
+ fontWeight: 500,
+ fill: "var(--dashboardy-muted)",
+ opacity: 0.42,
+ }}
+ />
+
+ }
+ />
+
+ {resolvedData.map((point) => {
+ const hasExternalHighlight = activeHour != null;
+ const isHighlighted = activeHour === point.hour;
+
+ return (
+ |
+ );
+ })}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardSessionHourlyOverviewTable.tsx b/apps/web/src/features/dashboard/components/DashboardSessionHourlyOverviewTable.tsx
new file mode 100644
index 00000000..4f730ab5
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardSessionHourlyOverviewTable.tsx
@@ -0,0 +1,143 @@
+import {
+ DashboardCellStack,
+ DashboardGridTable,
+ DashboardTableFooterNote,
+} from "@/features/dashboard/components/DashboardGridTable";
+import type { DashboardSessionHourlyOverviewRow } from "@/features/dashboard/data/dashboard-tab-adapters";
+import { formatCompactNumber, formatPercent } from "@/lib/format";
+import { cn } from "@/lib/utils";
+
+const MAX_HOURLY_ROWS = 10;
+
+function getBandToneClasses(
+ tone: DashboardSessionHourlyOverviewRow["bandTone"],
+) {
+ switch (tone) {
+ case "success":
+ return {
+ dotClassName: "bg-[color:var(--dashboardy-success-foreground)]",
+ textClassName: "text-[color:var(--dashboardy-success-foreground)]",
+ };
+ case "warning":
+ return {
+ dotClassName: "bg-[color:var(--dashboardy-warning-foreground)]",
+ textClassName: "text-[color:var(--dashboardy-warning-foreground)]",
+ };
+ case "danger":
+ return {
+ dotClassName: "bg-[color:var(--dashboardy-danger-foreground)]",
+ textClassName: "text-[color:var(--dashboardy-danger-foreground)]",
+ };
+ case "muted":
+ return {
+ dotClassName: "bg-[color:var(--dashboardy-subtle)]",
+ textClassName: "text-[color:var(--dashboardy-muted)]",
+ };
+ }
+}
+
+export function DashboardSessionHourlyOverviewTable({
+ highlightSource,
+ highlightedHour,
+ isLoading = false,
+ onHighlightHourChange,
+ rows,
+}: {
+ highlightSource?: "chart" | "table" | null;
+ highlightedHour?: string | null;
+ isLoading?: boolean;
+ onHighlightHourChange?: (hour: string | null) => void;
+ rows: DashboardSessionHourlyOverviewRow[];
+}) {
+ const visibleRows = rows.slice(0, MAX_HOURLY_ROWS);
+ const hiddenCount = Math.max(rows.length - visibleRows.length, 0);
+ const hasTableHighlight =
+ highlightSource === "table" && highlightedHour != null;
+
+ return (
+
(
+
+ ),
+ },
+ {
+ id: "sessions",
+ header: "Sessions",
+ renderCell: (row) => (
+
+ {formatCompactNumber(row.sessions)}
+
+ ),
+ },
+ {
+ id: "share",
+ header: "Share",
+ renderCell: (row) => (
+
+ {row.sharePct == null ? "—" : formatPercent(row.sharePct)}
+
+ ),
+ },
+ {
+ id: "band",
+ header: "Window",
+ renderCell: (row) => {
+ const tone = getBandToneClasses(row.bandTone);
+
+ return (
+
+
+
+ {row.bandLabel}
+
+
+ );
+ },
+ },
+ ]}
+ rows={visibleRows}
+ rowKey={(row) => row.hour.toString()}
+ getHoverRowId={(row) => row.hour.toString()}
+ onRowHoverChange={onHighlightHourChange}
+ gridTemplateColumns="minmax(160px,1.2fr) 100px 90px minmax(180px,1fr)"
+ minWidthClassName="min-w-[42rem]"
+ rowClassName={(row) =>
+ cn(
+ "transition-colors duration-200",
+ hasTableHighlight &&
+ highlightedHour === row.hour.toString() &&
+ "!bg-[color:var(--dashboardy-subsurface-strong)]",
+ )
+ }
+ loadingState={
+ isLoading ? (
+
+ Loading hourly session breakdown…
+
+ ) : undefined
+ }
+ emptyState={
+
+ No session activity in the selected range.
+
+ }
+ footer={
+ hiddenCount > 0 ? (
+
+ {hiddenCount} quieter hours not shown
+
+ ) : undefined
+ }
+ />
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardSessionProfileTable.tsx b/apps/web/src/features/dashboard/components/DashboardSessionProfileTable.tsx
new file mode 100644
index 00000000..eb23d7d0
--- /dev/null
+++ b/apps/web/src/features/dashboard/components/DashboardSessionProfileTable.tsx
@@ -0,0 +1,63 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/app/ui/table";
+import type { DashboardProfileComparisonRow } from "@/features/dashboard/data/dashboard-static-data";
+
+export function DashboardSessionProfileTable({
+ rows,
+}: {
+ rows: DashboardProfileComparisonRow[];
+}) {
+ return (
+
+
+
+ Committed vs uncommitted profile
+
+
+ What sessions that ship look like compared with sessions that do not.
+
+
+
+
+
+
+
+ Metric
+
+
+ Committed
+
+
+ Uncommitted
+
+
+
+
+ {rows.map((row) => (
+
+
+ {row.label}
+
+
+ {row.committed}
+
+
+ {row.uncommitted}
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/dashboard/components/DashboardSessionsSnapshotSection.tsx b/apps/web/src/features/dashboard/components/DashboardSessionsSnapshotSection.tsx
index fe69723e..5ee6d9ec 100644
--- a/apps/web/src/features/dashboard/components/DashboardSessionsSnapshotSection.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardSessionsSnapshotSection.tsx
@@ -1,11 +1,11 @@
import type { SessionAnalytics } from "@rudel/api-routes";
import { format } from "date-fns";
import { Skeleton } from "@/app/ui/skeleton";
-import { DashboardRecentSessionsTable } from "@/features/dashboard/components/DashboardRecentSessionsTable";
import {
DashboardSessionChart,
type DashboardSessionChartDatum,
} from "@/features/dashboard/components/DashboardSessionChart";
+import { DashboardTokenRecentSessionsTable } from "@/features/dashboard/components/DashboardTokenRecentSessionsTable";
import { DashboardInteractiveTopChartSection } from "@/features/dashboard/components/DashboardTopChartSection";
import type { DashboardHeadlineMetric } from "@/features/dashboard/data/dashboard-static-data";
@@ -129,7 +129,7 @@ export function DashboardSessionsSnapshotSection({
highlightSource,
onHighlightItemChange,
}) => (
- buildDashboardSessionTabMetrics(sessionSummaryComparison),
[sessionSummaryComparison],
);
-
+ const { data: recentSessions, isPending: isRecentSessionsPending } =
+ useAnalyticsQuery(
+ orpc.analytics.sessions.list.queryOptions({
+ input: {
+ endDate,
+ limit: 10,
+ startDate,
+ sortBy: "session_date",
+ sortOrder: "desc",
+ },
+ }),
+ );
+ const sortedRecentSessions = useMemo(
+ () =>
+ [...(recentSessions ?? [])].sort(
+ (left: SessionAnalytics, right: SessionAnalytics) =>
+ new Date(right.session_date).getTime() -
+ new Date(left.session_date).getTime(),
+ ),
+ [recentSessions],
+ );
return (
0;
- const hasTrendData = (performanceUserDailyTrend?.length ?? 0) > 0;
+ const hasTrendData = useMemo(
+ () => (performanceUserDailyTrend?.length ?? 0) > 0,
+ [performanceUserDailyTrend],
+ );
const trendSeries = useMemo(
() =>
buildDashboardPerformanceTrendSeries(
@@ -190,17 +193,19 @@ export function DashboardTokenDeveloperPanel({
) : chartView === "over-time" ? (
hasTrendData ? (
-
+ }>
+
+
) : (
No developer token activity in the selected range.
diff --git a/apps/web/src/features/dashboard/components/DashboardTokenDeveloperTable.tsx b/apps/web/src/features/dashboard/components/DashboardTokenDeveloperTable.tsx
index daba343f..07cd1e57 100644
--- a/apps/web/src/features/dashboard/components/DashboardTokenDeveloperTable.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokenDeveloperTable.tsx
@@ -216,7 +216,7 @@ export function DashboardTokenDeveloperTable({
getHoverRowId={(row) => row.id}
rowClassName={(row) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -225,8 +225,6 @@ export function DashboardTokenDeveloperTable({
hasTableHighlight &&
highlightedUserId === row.id &&
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight && highlightedUserId !== row.id && "opacity-50",
- hasChartHighlight && highlightedUserId !== row.id && "opacity-50",
)
}
/>
diff --git a/apps/web/src/features/dashboard/components/DashboardTokenModelTable.tsx b/apps/web/src/features/dashboard/components/DashboardTokenModelTable.tsx
index cdc916b9..055a4b3c 100644
--- a/apps/web/src/features/dashboard/components/DashboardTokenModelTable.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokenModelTable.tsx
@@ -126,7 +126,7 @@ export function DashboardTokenModelTable({
getHoverRowId={(row) => row.id}
rowClassName={(row) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -135,8 +135,6 @@ export function DashboardTokenModelTable({
hasTableHighlight &&
highlightedModelId === row.id &&
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight && highlightedModelId !== row.id && "opacity-50",
- hasChartHighlight && highlightedModelId !== row.id && "opacity-50",
)
}
footer={
diff --git a/apps/web/src/features/dashboard/components/DashboardTokenPatternChart.tsx b/apps/web/src/features/dashboard/components/DashboardTokenPatternChart.tsx
index 8bf8932d..9b71331a 100644
--- a/apps/web/src/features/dashboard/components/DashboardTokenPatternChart.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokenPatternChart.tsx
@@ -5,11 +5,10 @@ import { useMemo } from "react";
import { Bar, BarChart, Rectangle, XAxis, YAxis } from "recharts";
import { type ChartConfig, ChartContainer, ChartTooltip } from "@/app/ui/chart";
import type { DashboardHighlightChangeHandler } from "@/features/dashboard/components/dashboard-highlight-state";
-import type { DashboardTokenDailyPoint } from "@/features/dashboard/data/dashboard-token-adapters";
+import type { DashboardTokenDailyPoint } from "@/features/dashboard/data/dashboard-tab-adapters";
import { formatCompactWholeNumber } from "@/lib/format";
import { cn } from "@/lib/utils";
-const MAX_VISIBLE_MODEL_STACKS = 4;
const TOKEN_MODEL_COLORS = [
"#1949A9",
"#159C89",
@@ -18,7 +17,14 @@ const TOKEN_MODEL_COLORS = [
"#EA580C",
"#0F766E",
] as const;
+const MAX_VISIBLE_MODEL_STACKS = TOKEN_MODEL_COLORS.length;
const OTHER_MODEL_COLOR = "#D7DBE2";
+const TOKEN_CHART_CONFIG = {
+ totalTokens: {
+ label: "Tokens",
+ color: TOKEN_MODEL_COLORS[0],
+ },
+} satisfies ChartConfig;
type TokenModelSeries = {
color: string;
@@ -152,10 +158,11 @@ function getBarSize(total: number) {
function TokenPatternTooltip({
active,
+ modelSeries,
payload,
- seriesByKey,
}: {
active?: boolean;
+ modelSeries: TokenModelSeries[];
payload?: Array<{
color?: string;
dataKey?: string;
@@ -163,7 +170,6 @@ function TokenPatternTooltip({
payload: TokenChartRow;
value?: number | string;
}>;
- seriesByKey: Map
;
}) {
if (!active || !payload?.length) {
return null;
@@ -175,18 +181,12 @@ function TokenPatternTooltip({
return null;
}
- const modelRows = payload
- .map((item) => {
- const series = seriesByKey.get(String(item.dataKey ?? ""));
- const value =
- typeof item.value === "number" ? item.value : Number(item.value ?? 0);
-
- return {
- color: item.color,
- label: series?.label ?? String(item.name ?? item.dataKey ?? "Model"),
- value,
- };
- })
+ const modelRows = modelSeries
+ .map((series) => ({
+ color: series.color,
+ label: series.label,
+ value: Number(point[series.key] ?? 0),
+ }))
.filter((item) => Number.isFinite(item.value) && item.value > 0)
.sort((left, right) => right.value - left.value);
@@ -265,16 +265,15 @@ function TokenPatternTooltip({
function TokenBarShape(props: {
activeDate?: string | null;
activeSource?: "chart" | "table" | null;
- dataKey?: string;
fill?: string;
height?: number;
payload?: TokenChartRow;
- stackOrder: readonly string[];
+ segments: readonly TokenModelSeries[];
width?: number;
x?: number;
y?: number;
}) {
- const { fill, x, y, width, height, payload, dataKey } = props;
+ const { x, y, width, height, payload } = props;
if (
typeof x !== "number" ||
@@ -282,16 +281,28 @@ function TokenBarShape(props: {
typeof width !== "number" ||
typeof height !== "number" ||
!payload ||
- !dataKey ||
height <= 0
) {
return null;
}
- const keyIndex = props.stackOrder.indexOf(dataKey);
- const isTopSegment = props.stackOrder
- .slice(keyIndex + 1)
- .every((key) => Number(payload[key] ?? 0) === 0);
+ const visibleSegments = props.segments.filter(
+ (segment) => Number(payload[segment.key] ?? 0) > 0,
+ );
+
+ if (!visibleSegments.length) {
+ return null;
+ }
+
+ const totalValue = visibleSegments.reduce(
+ (sum, segment) => sum + Number(payload[segment.key] ?? 0),
+ 0,
+ );
+
+ if (totalValue <= 0) {
+ return null;
+ }
+
const isHighlighted = props.activeDate === payload.date;
const hasExternalHighlight = props.activeDate != null;
const isTableHighlight = props.activeSource === "table";
@@ -303,25 +314,45 @@ function TokenBarShape(props: {
? 0.16
: 0.26
: 1;
- const showStroke = isHighlighted && isTopSegment;
+ const showStroke = isHighlighted;
+ let segmentTop = y + height;
return (
-
+ >
+ {visibleSegments.map((segment, index) => {
+ const segmentValue = Number(payload[segment.key] ?? 0);
+ const isTopSegment = index === visibleSegments.length - 1;
+ const segmentHeight = isTopSegment
+ ? segmentTop - y
+ : (height * segmentValue) / totalValue;
+ const nextY = segmentTop - segmentHeight;
+
+ segmentTop = nextY;
+
+ return (
+
+ );
+ })}
+
);
}
@@ -342,81 +373,66 @@ export function DashboardTokenPatternChart({
}) {
const axisMax = getAxisMax(data);
const axisTicks = getAxisTicks(axisMax);
- const { chartConfig, chartData, modelSeries, seriesByKey, stackOrder } =
- useMemo(() => {
- const modelTotals = new Map();
- for (const point of data) {
- for (const [model, tokens] of Object.entries(point.modelTokens)) {
- if (tokens > 0) {
- modelTotals.set(model, (modelTotals.get(model) ?? 0) + tokens);
- }
+ const { chartData, modelSeries } = useMemo(() => {
+ const modelTotals = new Map();
+ for (const point of data) {
+ for (const [model, tokens] of Object.entries(point.modelTokens)) {
+ if (tokens > 0) {
+ modelTotals.set(model, (modelTotals.get(model) ?? 0) + tokens);
}
}
-
- const sortedModels = Array.from(modelTotals.entries()).sort(
- (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
- );
- const topModels = sortedModels.slice(0, MAX_VISIBLE_MODEL_STACKS);
- const topModelLabels = new Set(topModels.map(([label]) => label));
- const hasOther = sortedModels.length > MAX_VISIBLE_MODEL_STACKS;
- const modelSeries: TokenModelSeries[] =
- topModels.length > 0
- ? topModels.map(([label], index) => ({
- color: TOKEN_MODEL_COLORS[index % TOKEN_MODEL_COLORS.length],
- key: `model_${index + 1}`,
- label,
- }))
- : [
- {
- color: TOKEN_MODEL_COLORS[0],
- key: "unattributed",
- label: "Unattributed",
- },
- ];
-
- if (hasOther) {
- modelSeries.push({
- color: OTHER_MODEL_COLOR,
- key: "other_models",
- label: "Other",
- });
- }
-
- const seriesByKey = new Map(
- modelSeries.map((series) => [series.key, series] as const),
- );
- const chartData: TokenChartRow[] = data.map((point) => {
- const row: TokenChartRow = { ...point };
- for (const series of modelSeries) {
- if (series.key === "other_models") {
- row[series.key] = Object.entries(point.modelTokens)
- .filter(([model]) => !topModelLabels.has(model))
- .reduce((sum, [, tokens]) => sum + tokens, 0);
- } else if (series.key === "unattributed") {
- row[series.key] = point.totalTokens;
- } else {
- row[series.key] = point.modelTokens[series.label] ?? 0;
- }
- }
- return row;
- });
-
- return {
- chartConfig: Object.fromEntries(
- modelSeries.map((series) => [
- series.key,
+ }
+
+ const sortedModels = Array.from(modelTotals.entries()).sort(
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
+ );
+ const topModels = sortedModels.slice(0, MAX_VISIBLE_MODEL_STACKS);
+ const topModelLabels = new Set(topModels.map(([label]) => label));
+ const hasOther = sortedModels.length > MAX_VISIBLE_MODEL_STACKS;
+ const modelSeries: TokenModelSeries[] =
+ topModels.length > 0
+ ? topModels.map(([label], index) => ({
+ color: TOKEN_MODEL_COLORS[index % TOKEN_MODEL_COLORS.length],
+ key: `model_${index + 1}`,
+ label,
+ }))
+ : [
{
- label: shortenModelLabel(series.label),
- color: series.color,
+ color: TOKEN_MODEL_COLORS[0],
+ key: "unattributed",
+ label: "Unattributed",
},
- ]),
- ) satisfies ChartConfig,
- chartData,
- modelSeries,
- seriesByKey,
- stackOrder: modelSeries.map((series) => series.key),
- };
- }, [data]);
+ ];
+
+ if (hasOther) {
+ modelSeries.push({
+ color: OTHER_MODEL_COLOR,
+ key: "other_models",
+ label: "Other",
+ });
+ }
+
+ const chartData: TokenChartRow[] = data.map((point) => {
+ const row: TokenChartRow = { ...point };
+ for (const series of modelSeries) {
+ if (series.key === "other_models") {
+ row[series.key] = Object.entries(point.modelTokens)
+ .filter(([model]) => !topModelLabels.has(model))
+ .reduce((sum, [, tokens]) => sum + tokens, 0);
+ } else if (series.key === "unattributed") {
+ row[series.key] = point.totalTokens;
+ } else {
+ row[series.key] = point.modelTokens[series.label] ?? 0;
+ }
+ }
+ return row;
+ });
+
+ return {
+ chartData,
+ modelSeries,
+ };
+ }, [data]);
const barSize = getBarSize(chartData.length);
const chartInteractionProps = onHighlightDateChange
? {
@@ -433,7 +449,7 @@ export function DashboardTokenPatternChart({
return (
@@ -493,27 +509,22 @@ export function DashboardTokenPatternChart({
/>
}
+ content={ }
+ />
+
+ }
/>
- {modelSeries.map((series) => (
-
- }
- />
- ))}
diff --git a/apps/web/src/features/dashboard/components/DashboardRecentSessionsTable.tsx b/apps/web/src/features/dashboard/components/DashboardTokenRecentSessionsTable.tsx
similarity index 89%
rename from apps/web/src/features/dashboard/components/DashboardRecentSessionsTable.tsx
rename to apps/web/src/features/dashboard/components/DashboardTokenRecentSessionsTable.tsx
index ac105f40..f6d5e2d6 100644
--- a/apps/web/src/features/dashboard/components/DashboardRecentSessionsTable.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokenRecentSessionsTable.tsx
@@ -16,16 +16,111 @@ import {
import { formatRelativeTime } from "@/lib/time-utils";
import { cn } from "@/lib/utils";
-const SKELETON_ROW_COUNT = 5;
+const SKELETON_ROWS = 5;
const SKELETON_ROW_IDS = [
- "recent-session-skeleton-1",
- "recent-session-skeleton-2",
- "recent-session-skeleton-3",
- "recent-session-skeleton-4",
- "recent-session-skeleton-5",
+ "token-session-skeleton-1",
+ "token-session-skeleton-2",
+ "token-session-skeleton-3",
+ "token-session-skeleton-4",
+ "token-session-skeleton-5",
] as const;
-export function DashboardRecentSessionsTable({
+function formatSessionTimestamp(value: string) {
+ const normalizedValue = value.endsWith("Z") ? value : `${value}Z`;
+ const date = new Date(normalizedValue);
+
+ if (Number.isNaN(date.getTime())) {
+ return value;
+ }
+
+ return date.toLocaleString(undefined, {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ });
+}
+
+function getRepositoryLabel(session: SessionAnalytics) {
+ const primaryPath = session.repository || session.project_path;
+ const segments = primaryPath.split("/").filter(Boolean);
+
+ if (segments.length === 0) {
+ return "—";
+ }
+
+ return segments.slice(-2).join("/");
+}
+
+function getModelList(session: SessionAnalytics) {
+ return session.model_used ? [session.model_used] : [];
+}
+
+function formatTokenMix(session: SessionAnalytics) {
+ if (session.total_tokens <= 0) {
+ return "—";
+ }
+
+ const inputShare = Math.round(
+ (session.input_tokens / session.total_tokens) * 100,
+ );
+ const outputShare = Math.max(100 - inputShare, 0);
+
+ return `${inputShare} IN / ${outputShare} OUT`;
+}
+
+function DashboardTokenRecentSessionsTableSkeleton({
+ showHeader,
+}: {
+ showHeader: boolean;
+}) {
+ return (
+
+ {showHeader ? (
+
+
+ Latest sessions
+
+
+
+ ) : null}
+
+
+
+
Time
+
Developer
+
Repository
+
Model
+
Tokens
+
Cost
+
Duration
+
+
+ {SKELETON_ROW_IDS.slice(0, SKELETON_ROWS).map((rowId) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+export function DashboardTokenRecentSessionsTable({
highlightSource,
highlightedSessionId,
isLoading = false,
@@ -48,13 +143,15 @@ export function DashboardRecentSessionsTable({
totalSessionCount - recentSessions.length,
0,
);
- const hasChartHighlight =
- highlightSource === "chart" && highlightedSessionId != null;
const hasTableHighlight =
highlightSource === "table" && highlightedSessionId != null;
+ const hasChartHighlight =
+ highlightSource === "chart" && highlightedSessionId != null;
if (isLoading) {
- return ;
+ return (
+
+ );
}
if (recentSessions.length === 0) {
@@ -80,8 +177,8 @@ export function DashboardRecentSessionsTable({
(
(
{formatUsername(session.user_id, userMap)}
@@ -100,8 +197,8 @@ export function DashboardRecentSessionsTable({
),
},
{
- header: "Repository",
id: "repository",
+ header: "Repository",
renderCell: (session) => {
const repositoryLabel = getRepositoryLabel(session);
const fullRepositoryLabel =
@@ -118,15 +215,15 @@ export function DashboardRecentSessionsTable({
},
},
{
- header: "Model",
id: "model",
+ header: "Model",
renderCell: (session) => {
- const models = session.model_used ? [session.model_used] : [];
+ const modelList = getModelList(session);
return (
- {models.length > 0 ? (
-
+ {modelList.length > 0 ? (
+
) : (
—
@@ -137,8 +234,8 @@ export function DashboardRecentSessionsTable({
},
},
{
- header: "Tokens",
id: "tokens",
+ header: "Tokens",
renderCell: (session) => (
(
),
},
{
- header: "Duration",
id: "duration",
+ header: "Duration",
renderCell: (session) => (
{formatMinutes(session.duration_min)}
@@ -169,13 +266,15 @@ export function DashboardRecentSessionsTable({
),
},
]}
- getHoverRowId={(session) => session.session_id}
+ rows={recentSessions}
+ rowKey={(session) => session.session_id}
gridTemplateColumns="120px minmax(180px,11fr) minmax(180px,9fr) minmax(180px,9fr) 140px minmax(180px,0.95fr) 120px"
minWidthClassName="min-w-[82rem]"
onRowHoverChange={onHighlightSessionChange}
+ getHoverRowId={(session) => session.session_id}
rowClassName={(session) =>
cn(
- "w-full text-left transition-[opacity,background-color] duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
+ "w-full text-left transition-colors duration-300 [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]",
hasTableHighlight &&
"bg-[color:var(--dashboardy-surface)] odd:bg-[color:var(--dashboardy-surface)]",
hasChartHighlight &&
@@ -184,19 +283,12 @@ export function DashboardRecentSessionsTable({
hasTableHighlight &&
highlightedSessionId === session.session_id &&
"bg-[color:var(--dashboardy-subsurface-strong)] odd:bg-[color:var(--dashboardy-subsurface-strong)]",
- hasTableHighlight &&
- highlightedSessionId !== session.session_id &&
- "opacity-50",
- hasChartHighlight &&
- highlightedSessionId !== session.session_id &&
- "opacity-50",
)
}
- rowKey={(session) => session.session_id}
- rows={recentSessions}
footer={
hiddenSessionCount > 0 ? (
+ {/* TODO: Turn this footer count into a real drill-down affordance from the token tab. */}
{hiddenSessionCount} more
) : null
@@ -205,94 +297,3 @@ export function DashboardRecentSessionsTable({
);
}
-
-function DashboardRecentSessionsTableSkeleton({
- showHeader,
-}: {
- showHeader: boolean;
-}) {
- return (
-
- {showHeader ? (
-
-
- Latest sessions
-
-
-
- ) : null}
-
-
-
-
Time
-
Developer
-
Repository
-
Model
-
Tokens
-
Cost
-
Duration
-
-
- {SKELETON_ROW_IDS.slice(0, SKELETON_ROW_COUNT).map((rowId) => (
-
- ))}
-
-
-
-
- );
-}
-
-function formatSessionTimestamp(value: string) {
- const normalizedValue = value.endsWith("Z") ? value : `${value}Z`;
- const date = new Date(normalizedValue);
-
- if (Number.isNaN(date.getTime())) {
- return value;
- }
-
- return date.toLocaleString(undefined, {
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- month: "short",
- });
-}
-
-function getRepositoryLabel(session: SessionAnalytics) {
- const primaryPath = session.repository || session.project_path;
- const segments = primaryPath.split("/").filter(Boolean);
-
- if (segments.length === 0) {
- return "—";
- }
-
- return segments.slice(-2).join("/");
-}
-
-function formatTokenMix(session: SessionAnalytics) {
- if (session.total_tokens <= 0) {
- return "—";
- }
-
- const inputShare = Math.round(
- (session.input_tokens / session.total_tokens) * 100,
- );
- const outputShare = Math.max(100 - inputShare, 0);
-
- return `${inputShare} IN / ${outputShare} OUT`;
-}
diff --git a/apps/web/src/features/dashboard/components/DashboardTokenSnapshotSection.tsx b/apps/web/src/features/dashboard/components/DashboardTokenSnapshotSection.tsx
index 30739850..e493f720 100644
--- a/apps/web/src/features/dashboard/components/DashboardTokenSnapshotSection.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokenSnapshotSection.tsx
@@ -2,7 +2,7 @@ import { DashboardTokenDailyOverviewTable } from "@/features/dashboard/component
import { DashboardTokenPatternChart } from "@/features/dashboard/components/DashboardTokenPatternChart";
import { DashboardInteractiveTopChartSection } from "@/features/dashboard/components/DashboardTopChartSection";
import type { DashboardHeadlineMetric } from "@/features/dashboard/data/dashboard-static-data";
-import type { DashboardTokenDailyPoint } from "@/features/dashboard/data/dashboard-token-adapters";
+import type { DashboardTokenDailyPoint } from "@/features/dashboard/data/dashboard-tab-adapters";
export function DashboardTokenSnapshotSection({
dailyPattern,
diff --git a/apps/web/src/features/dashboard/components/DashboardTokensView.tsx b/apps/web/src/features/dashboard/components/DashboardTokensView.tsx
index 6174d1a4..8808cf77 100644
--- a/apps/web/src/features/dashboard/components/DashboardTokensView.tsx
+++ b/apps/web/src/features/dashboard/components/DashboardTokensView.tsx
@@ -11,7 +11,7 @@ import type { DashboardPerformanceUserComparison } from "@/features/dashboard/da
import {
buildDashboardTokenDailyPattern,
buildDashboardTokenTabMetrics,
-} from "@/features/dashboard/data/dashboard-token-adapters";
+} from "@/features/dashboard/data/dashboard-tab-adapters";
export function DashboardTokensView({
endDate,
diff --git a/apps/web/src/features/dashboard/components/dashboard-highlight-state.ts b/apps/web/src/features/dashboard/components/dashboard-highlight-state.ts
index bc92ffbd..08bf704c 100644
--- a/apps/web/src/features/dashboard/components/dashboard-highlight-state.ts
+++ b/apps/web/src/features/dashboard/components/dashboard-highlight-state.ts
@@ -1,4 +1,4 @@
-import { startTransition, useCallback, useRef, useState } from "react";
+import { useCallback, useRef, useState } from "react";
export type DashboardHighlightSource = "chart" | "table" | null;
@@ -41,9 +41,7 @@ export function useDashboardHighlightState() {
}
stateRef.current = nextState;
- startTransition(() => {
- setState(nextState);
- });
+ setState(nextState);
},
[],
);
diff --git a/apps/web/src/features/dashboard/dashboard-theme.css b/apps/web/src/features/dashboard/dashboard-theme.css
index 946dede5..5f7c6ce8 100644
--- a/apps/web/src/features/dashboard/dashboard-theme.css
+++ b/apps/web/src/features/dashboard/dashboard-theme.css
@@ -6,12 +6,9 @@
--dashboardy-border-strong: rgba(15, 23, 42, 0.12);
--dashboardy-divider: rgba(148, 163, 184, 0.22);
--dashboardy-heading: #111827;
- --dashboardy-subheading: #677489;
--dashboardy-muted: #667085;
--dashboardy-subtle: #98a2b3;
--dashboardy-accent: #2563eb;
- --dashboard-01-tone-blue: #2f5fe5;
- --dashboard-01-tone-teal: #25b5aa;
--dashboardy-card-shadow:
0 1px 2px rgba(15, 23, 42, 0.06), 0 22px 44px -34px rgba(15, 23, 42, 0.28);
--dashboardy-chip-surface: rgba(238, 242, 255, 0.95);
@@ -34,12 +31,9 @@
--dashboardy-border-strong: rgba(148, 163, 184, 0.24);
--dashboardy-divider: rgba(148, 163, 184, 0.18);
--dashboardy-heading: #f8fafc;
- --dashboardy-subheading: #b6c2d3;
--dashboardy-muted: #cbd5e1;
--dashboardy-subtle: #94a3b8;
--dashboardy-accent: #93c5fd;
- --dashboard-01-tone-blue: #6d8fff;
- --dashboard-01-tone-teal: #4fd3c9;
--dashboardy-card-shadow:
0 1px 2px rgba(0, 0, 0, 0.14), 0 20px 44px -34px rgba(0, 0, 0, 0.48);
--dashboardy-chip-surface: rgba(30, 41, 59, 0.98);
diff --git a/apps/web/src/features/dashboard/data/dashboard-error-adapters.test.ts b/apps/web/src/features/dashboard/data/dashboard-error-adapters.test.ts
deleted file mode 100644
index eba6935d..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-error-adapters.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { ErrorsDashboard, ErrorTrendDataPoint } from "@rudel/api-routes";
-import {
- buildErrorDailyPoints,
- buildErrorHeadlineMetrics,
-} from "./dashboard-error-adapters";
-
-describe("dashboard-error-adapters", () => {
- test("builds headline metrics from the errors dashboard summary", () => {
- const errorDashboard: ErrorsDashboard = {
- end_date: "2026-03-15",
- recurring: [],
- start_date: "2026-03-01",
- summary: {
- distinct_patterns: 12,
- high_severity_patterns: 3,
- max_affected_users: 9,
- top_error_pattern: "Timeout",
- total_errors: 48,
- },
- };
-
- expect(buildErrorHeadlineMetrics(errorDashboard)).toEqual([
- {
- description: "All error occurrences in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Total errors",
- valueLabel: "48",
- },
- {
- description: "Unique recurring error signatures.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Distinct patterns",
- valueLabel: "12",
- },
- {
- description: "Patterns crossing the high-severity threshold.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "commitRate",
- label: "High severity",
- valueLabel: "3",
- },
- ]);
- });
-
- test("aggregates daily error points and sorts error types by volume", () => {
- const rows: ErrorTrendDataPoint[] = [
- {
- avg_errors_per_interaction: 1.5,
- avg_errors_per_session: 3,
- date: "2026-03-10",
- dimension: "repo-a",
- error_type_occurrences: [3, 1],
- error_types: ["Timeout", "NotFound"],
- total_errors: 3,
- },
- {
- avg_errors_per_interaction: 2,
- avg_errors_per_session: 2,
- date: "2026-03-10",
- dimension: "repo-b",
- error_type_occurrences: [4],
- error_types: ["Timeout"],
- total_errors: 4,
- },
- ];
-
- expect(buildErrorDailyPoints("2026-03-10", "2026-03-11", rows)).toEqual([
- {
- activeDimensions: 2,
- avgErrorsPerInteraction: 1.75,
- avgErrorsPerSession: 2.33,
- axisLabel: "Tue",
- date: "2026-03-10",
- errorTypes: ["Timeout", "NotFound"],
- fullLabel: "Tuesday, Mar 10",
- totalErrors: 7,
- },
- {
- activeDimensions: 0,
- avgErrorsPerInteraction: null,
- avgErrorsPerSession: null,
- axisLabel: "Wed",
- date: "2026-03-11",
- errorTypes: [],
- fullLabel: "Wednesday, Mar 11",
- totalErrors: null,
- },
- ]);
- });
-});
diff --git a/apps/web/src/features/dashboard/data/dashboard-error-adapters.ts b/apps/web/src/features/dashboard/data/dashboard-error-adapters.ts
deleted file mode 100644
index 0afb2d3f..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-error-adapters.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-import type { ErrorsDashboard, ErrorTrendDataPoint } from "@rudel/api-routes";
-import { eachDayOfInterval, format, parseISO } from "date-fns";
-import type { DashboardHeadlineMetric } from "@/features/dashboard/data/dashboard-static-data";
-
-export type DashboardErrorMetric =
- | "total_errors"
- | "avg_errors_per_session"
- | "avg_errors_per_interaction";
-
-export type DashboardErrorDailyPoint = {
- activeDimensions: number;
- avgErrorsPerInteraction: number | null;
- avgErrorsPerSession: number | null;
- axisLabel: string;
- date: string;
- errorTypes: string[];
- fullLabel: string;
- totalErrors: number | null;
-};
-
-export type DashboardErrorDimensionRow = {
- activeDays: number;
- avgErrorsPerInteraction: number;
- avgErrorsPerSession: number;
- id: string;
- label: string;
- totalErrors: number;
-};
-
-export type DashboardErrorDimensionSeries = {
- color: string;
- id: string;
- label: string;
-};
-
-export type DashboardErrorTrendChartRow = {
- date: string;
- fullLabel: string;
-} & Record;
-
-export type DashboardErrorDimensionBarDatum = {
- activeDays: number;
- id: string;
- label: string;
- shortLabel: string;
- value: number;
-};
-
-export function buildErrorHeadlineMetrics(
- errorDashboard: ErrorsDashboard | undefined,
-): DashboardHeadlineMetric[] {
- const summary = errorDashboard?.summary;
-
- return [
- {
- description: "All error occurrences in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Total errors",
- valueLabel: String(summary?.total_errors ?? 0),
- },
- {
- description: "Unique recurring error signatures.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Distinct patterns",
- valueLabel: String(summary?.distinct_patterns ?? 0),
- },
- {
- description: "Patterns crossing the high-severity threshold.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "commitRate",
- label: "High severity",
- valueLabel: String(summary?.high_severity_patterns ?? 0),
- },
- ];
-}
-
-export function getErrorMetricLabel(metric: DashboardErrorMetric) {
- switch (metric) {
- case "avg_errors_per_session":
- return "Avg / session";
- case "avg_errors_per_interaction":
- return "Avg / interaction";
- default:
- return "Errors";
- }
-}
-
-export function getErrorMetricValue(
- row: DashboardErrorDimensionRow,
- metric: DashboardErrorMetric,
-) {
- switch (metric) {
- case "avg_errors_per_session":
- return row.avgErrorsPerSession;
- case "avg_errors_per_interaction":
- return row.avgErrorsPerInteraction;
- default:
- return row.totalErrors;
- }
-}
-
-export function formatErrorMetricValue(
- metric: DashboardErrorMetric,
- value: number,
-) {
- if (metric === "total_errors") {
- return value.toLocaleString();
- }
-
- return value.toFixed(2);
-}
-
-export function buildErrorDailyPoints(
- startDate: string,
- endDate: string,
- rows: ErrorTrendDataPoint[] | undefined,
-): DashboardErrorDailyPoint[] {
- const aggregateByDate = new Map<
- string,
- {
- activeDimensions: number;
- errorTypeCounts: Map;
- interactionEstimate: number;
- sessionEstimate: number;
- totalErrors: number;
- }
- >();
-
- for (const row of rows ?? []) {
- const current = aggregateByDate.get(row.date) ?? {
- activeDimensions: 0,
- errorTypeCounts: new Map(),
- interactionEstimate: 0,
- sessionEstimate: 0,
- totalErrors: 0,
- };
-
- current.activeDimensions += 1;
- current.totalErrors += row.total_errors;
- current.sessionEstimate += estimateDenominator(
- row.total_errors,
- row.avg_errors_per_session,
- );
- current.interactionEstimate += estimateDenominator(
- row.total_errors,
- row.avg_errors_per_interaction,
- );
-
- for (const [index, errorType] of row.error_types.entries()) {
- const occurrences = row.error_type_occurrences[index] ?? 0;
- current.errorTypeCounts.set(
- errorType,
- (current.errorTypeCounts.get(errorType) ?? 0) + occurrences,
- );
- }
-
- aggregateByDate.set(row.date, current);
- }
-
- return buildDateRange(startDate, endDate).map((date) => {
- const isoDate = format(date, "yyyy-MM-dd");
- const aggregate = aggregateByDate.get(isoDate);
-
- if (!aggregate) {
- return {
- activeDimensions: 0,
- avgErrorsPerInteraction: null,
- avgErrorsPerSession: null,
- axisLabel: format(date, "EEE"),
- date: isoDate,
- errorTypes: [],
- fullLabel: format(date, "EEEE, MMM d"),
- totalErrors: null,
- };
- }
-
- const errorTypes = Array.from(aggregate.errorTypeCounts.entries())
- .sort(
- (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
- )
- .map(([errorPattern]) => errorPattern);
-
- return {
- activeDimensions: aggregate.activeDimensions,
- avgErrorsPerInteraction:
- aggregate.interactionEstimate > 0
- ? Number(
- (aggregate.totalErrors / aggregate.interactionEstimate).toFixed(
- 2,
- ),
- )
- : null,
- avgErrorsPerSession:
- aggregate.sessionEstimate > 0
- ? Number(
- (aggregate.totalErrors / aggregate.sessionEstimate).toFixed(2),
- )
- : null,
- axisLabel: format(date, "EEE"),
- date: isoDate,
- errorTypes,
- fullLabel: format(date, "EEEE, MMM d"),
- totalErrors: aggregate.totalErrors,
- };
- });
-}
-
-export function buildErrorDimensionRows(
- rows: ErrorTrendDataPoint[] | undefined,
- splitBy: "project_path" | "user_id",
- userLabelById: Map,
-): DashboardErrorDimensionRow[] {
- const rowMap = new Map<
- string,
- {
- activeDays: Set;
- interactionEstimate: number;
- label: string;
- sessionEstimate: number;
- totalErrors: number;
- }
- >();
-
- for (const row of rows ?? []) {
- const current = rowMap.get(row.dimension) ?? {
- activeDays: new Set(),
- interactionEstimate: 0,
- label: formatDimensionLabel(row.dimension, splitBy, userLabelById),
- sessionEstimate: 0,
- totalErrors: 0,
- };
-
- current.activeDays.add(row.date);
- current.interactionEstimate += estimateDenominator(
- row.total_errors,
- row.avg_errors_per_interaction,
- );
- current.sessionEstimate += estimateDenominator(
- row.total_errors,
- row.avg_errors_per_session,
- );
- current.totalErrors += row.total_errors;
- rowMap.set(row.dimension, current);
- }
-
- return Array.from(rowMap.entries())
- .map(([id, row]) => ({
- activeDays: row.activeDays.size,
- avgErrorsPerInteraction:
- row.interactionEstimate > 0
- ? Number((row.totalErrors / row.interactionEstimate).toFixed(2))
- : 0,
- avgErrorsPerSession:
- row.sessionEstimate > 0
- ? Number((row.totalErrors / row.sessionEstimate).toFixed(2))
- : 0,
- id,
- label: row.label,
- totalErrors: row.totalErrors,
- }))
- .sort(
- (left, right) =>
- right.totalErrors - left.totalErrors ||
- right.activeDays - left.activeDays ||
- left.label.localeCompare(right.label),
- );
-}
-
-export function buildErrorTrendRows(
- startDate: string,
- endDate: string,
- rows: ErrorTrendDataPoint[] | undefined,
- series: DashboardErrorDimensionSeries[],
- metric: DashboardErrorMetric,
-): DashboardErrorTrendChartRow[] {
- const valueMap = new Map(
- (rows ?? []).map((row) => [`${row.dimension}:${row.date}`, row] as const),
- );
-
- return buildDateRange(startDate, endDate).map((date) => {
- const isoDate = format(date, "yyyy-MM-dd");
- const nextRow: DashboardErrorTrendChartRow = {
- date: isoDate,
- fullLabel: format(date, "EEEE, MMM d"),
- };
-
- for (const entry of series) {
- nextRow[entry.id] = valueMap.get(`${entry.id}:${isoDate}`)?.[metric] ?? 0;
- }
-
- return nextRow;
- });
-}
-
-export function buildErrorDimensionBarRows(
- rows: DashboardErrorDimensionRow[],
- metric: DashboardErrorMetric,
-): DashboardErrorDimensionBarDatum[] {
- return rows.slice(0, 10).map((row) => ({
- activeDays: row.activeDays,
- id: row.id,
- label: row.label,
- shortLabel:
- row.label.length > 14 ? `${row.label.slice(0, 12)}…` : row.label,
- value: getErrorMetricValue(row, metric),
- }));
-}
-
-function buildDateRange(startDate: string, endDate: string) {
- const parsedStartDate = parseISO(startDate);
- const parsedEndDate = parseISO(endDate);
-
- if (
- Number.isNaN(parsedStartDate.getTime()) ||
- Number.isNaN(parsedEndDate.getTime())
- ) {
- return [];
- }
-
- return eachDayOfInterval({
- start:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedStartDate
- : parsedEndDate,
- end:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedEndDate
- : parsedStartDate,
- });
-}
-
-function estimateDenominator(totalErrors: number, average: number) {
- if (average <= 0 || totalErrors <= 0) {
- return 0;
- }
-
- return totalErrors / average;
-}
-
-function formatDimensionLabel(
- value: string,
- splitBy: "project_path" | "user_id",
- userLabelById: Map,
-) {
- if (splitBy === "user_id") {
- return userLabelById.get(value) ?? value;
- }
-
- return value.split("/").at(-1) || value;
-}
diff --git a/apps/web/src/features/dashboard/data/dashboard-metric-colors.ts b/apps/web/src/features/dashboard/data/dashboard-metric-colors.ts
new file mode 100644
index 00000000..8ff6bea0
--- /dev/null
+++ b/apps/web/src/features/dashboard/data/dashboard-metric-colors.ts
@@ -0,0 +1,32 @@
+import type { DashboardMetricId } from "@/features/dashboard/data/dashboard-static-data";
+
+export interface DashboardMetricColorFamily {
+ chartMain: string;
+ seriesStrong: string;
+ seriesSoft: string;
+ seriesMid: string;
+ cardSurface: string;
+ cardBorder: string;
+ cardShadow: string;
+}
+
+function createMetricColorFamily(hue: number): DashboardMetricColorFamily {
+ return {
+ chartMain: `oklch(0.628 0.201 ${hue})`,
+ seriesStrong: `oklch(0.612 0.210 ${hue})`,
+ seriesSoft: `oklch(0.833 0.083 ${hue})`,
+ seriesMid: `oklch(0.720 0.145 ${hue})`,
+ cardSurface: `oklch(0.968 0.018 ${hue} / 0.42)`,
+ cardBorder: `oklch(0.812 0.062 ${hue} / 0.46)`,
+ cardShadow: `oklch(0.628 0.201 ${hue} / 0.18)`,
+ };
+}
+
+export const dashboardMetricColorFamilies = {
+ output: createMetricColorFamily(273.8),
+ quality: createMetricColorFamily(250),
+ efficiency: createMetricColorFamily(145),
+ speed: createMetricColorFamily(28),
+ craft: createMetricColorFamily(72),
+ consistency: createMetricColorFamily(205),
+} satisfies Record;
diff --git a/apps/web/src/features/dashboard/data/dashboard-repository-adapters.test.ts b/apps/web/src/features/dashboard/data/dashboard-repository-adapters.test.ts
deleted file mode 100644
index cef93aa9..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-repository-adapters.test.ts
+++ /dev/null
@@ -1,138 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { RepositoryDailyTrendData } from "@rudel/api-routes";
-import {
- buildDashboardDailyPatternFromRepositoryTrend,
- buildDashboardRepositoryDailyOverviewRows,
- buildDashboardRepositoryTabMetrics,
-} from "./dashboard-repository-adapters";
-
-describe("dashboard-repository-adapters", () => {
- test("aggregates repository trend into daily pattern points", () => {
- const rows: RepositoryDailyTrendData[] = [
- {
- date: "2026-04-01",
- repository: "alpha",
- sessions: 3,
- total_commits: 6,
- },
- {
- date: "2026-04-01",
- repository: "beta",
- sessions: 2,
- total_commits: 1,
- },
- ];
-
- const dailyPattern = buildDashboardDailyPatternFromRepositoryTrend(
- "2026-04-01",
- "2026-04-02",
- rows,
- );
-
- expect(dailyPattern).toHaveLength(2);
- expect(dailyPattern[0]).toMatchObject({
- commitRate: 140,
- commits: 7,
- date: "2026-04-01",
- sessions: 5,
- });
- expect(dailyPattern[1]).toMatchObject({
- commitRate: null,
- commits: null,
- date: "2026-04-02",
- sessions: null,
- });
- });
-
- test("builds repository overview rows and headline metrics", () => {
- const rows: RepositoryDailyTrendData[] = [
- {
- date: "2026-04-01",
- repository: "alpha",
- sessions: 3,
- total_commits: 6,
- },
- {
- date: "2026-04-01",
- repository: "beta",
- sessions: 5,
- total_commits: 2,
- },
- {
- date: "2026-04-02",
- repository: "alpha",
- sessions: 1,
- total_commits: 1,
- },
- ];
-
- const overviewRows = buildDashboardRepositoryDailyOverviewRows(
- "2026-04-01",
- "2026-04-02",
- rows,
- );
- const metrics = buildDashboardRepositoryTabMetrics([
- {
- activeDays: 2,
- commitRate: 175,
- commits: 7,
- id: "alpha",
- label: "alpha",
- sessions: 4,
- },
- {
- activeDays: 1,
- commitRate: 40,
- commits: 2,
- id: "beta",
- label: "beta",
- sessions: 5,
- },
- ]);
-
- expect(overviewRows).toEqual([
- {
- activeRepositories: 2,
- date: "2026-04-01",
- leadRepositoryLabel: "beta",
- leadRepositorySessions: 5,
- leadRepositoryShare: 63,
- sessions: 8,
- },
- {
- activeRepositories: 1,
- date: "2026-04-02",
- leadRepositoryLabel: "alpha",
- leadRepositorySessions: 1,
- leadRepositoryShare: 100,
- sessions: 1,
- },
- ]);
- expect(metrics).toEqual([
- {
- description: "Unique repositories active in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Repos touched",
- valueLabel: "2",
- },
- {
- description: "Average session volume across active repositories.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Avg sessions / repo",
- valueLabel: "4.5",
- },
- {
- description: "Committed sessions divided by all repository sessions.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "commitRate",
- label: "Repo commit rate",
- valueLabel: "100%",
- },
- ]);
- });
-});
diff --git a/apps/web/src/features/dashboard/data/dashboard-repository-adapters.ts b/apps/web/src/features/dashboard/data/dashboard-repository-adapters.ts
deleted file mode 100644
index 17d0300d..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-repository-adapters.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import type { RepositoryDailyTrendData } from "@rudel/api-routes";
-import { eachDayOfInterval, format, parseISO } from "date-fns";
-import type { DashboardRepositorySummaryRow } from "@/features/dashboard/data/dashboard-repository-trend";
-import type {
- DashboardDailyPatternPoint,
- DashboardHeadlineMetric,
-} from "@/features/dashboard/data/dashboard-static-data";
-
-export type DashboardRepositoryDailyOverviewRow = {
- activeRepositories: number;
- date: string;
- leadRepositoryLabel: string | null;
- leadRepositorySessions: number;
- leadRepositoryShare: number | null;
- sessions: number;
-};
-
-export function buildDashboardDailyPatternFromRepositoryTrend(
- startDate: string,
- endDate: string,
- rows: RepositoryDailyTrendData[] | undefined,
-): DashboardDailyPatternPoint[] {
- const rowsByDate = new Map();
-
- for (const row of rows ?? []) {
- const dateKey = normalizeDateKey(row.date);
- const currentRow = rowsByDate.get(dateKey) ?? {
- commits: 0,
- sessions: 0,
- };
-
- currentRow.commits += row.total_commits;
- currentRow.sessions += row.sessions;
- rowsByDate.set(dateKey, currentRow);
- }
-
- return buildDailyPattern(startDate, endDate, rowsByDate);
-}
-
-export function buildDashboardRepositoryDailyOverviewRows(
- startDate: string,
- endDate: string,
- rows: RepositoryDailyTrendData[] | undefined,
-): DashboardRepositoryDailyOverviewRow[] {
- const rowsByDate = new Map<
- string,
- {
- activeRepositories: number;
- leadRepositoryLabel: string | null;
- leadRepositorySessions: number;
- sessions: number;
- }
- >();
-
- for (const row of rows ?? []) {
- const dateKey = normalizeDateKey(row.date);
- const currentRow = rowsByDate.get(dateKey) ?? {
- activeRepositories: 0,
- leadRepositoryLabel: null,
- leadRepositorySessions: 0,
- sessions: 0,
- };
-
- currentRow.sessions += row.sessions;
-
- if (row.sessions > 0) {
- currentRow.activeRepositories += 1;
- }
-
- if (
- row.sessions > currentRow.leadRepositorySessions ||
- (row.sessions === currentRow.leadRepositorySessions &&
- row.sessions > 0 &&
- row.repository.localeCompare(currentRow.leadRepositoryLabel ?? "") < 0)
- ) {
- currentRow.leadRepositoryLabel = row.repository;
- currentRow.leadRepositorySessions = row.sessions;
- }
-
- rowsByDate.set(dateKey, currentRow);
- }
-
- return buildDateRange(startDate, endDate).map((date) => {
- const isoDate = format(date, "yyyy-MM-dd");
- const row = rowsByDate.get(isoDate);
- const sessions = row?.sessions ?? 0;
- const leadRepositorySessions = row?.leadRepositorySessions ?? 0;
-
- return {
- activeRepositories: row?.activeRepositories ?? 0,
- date: isoDate,
- leadRepositoryLabel: row?.leadRepositoryLabel ?? null,
- leadRepositorySessions,
- leadRepositoryShare:
- sessions > 0
- ? Math.round((leadRepositorySessions / sessions) * 100)
- : null,
- sessions,
- };
- });
-}
-
-export function buildDashboardRepositoryTabMetrics(
- rows: DashboardRepositorySummaryRow[],
-): DashboardHeadlineMetric[] {
- const totalSessions = rows.reduce((sum, row) => sum + row.sessions, 0);
- const totalCommits = rows.reduce((sum, row) => sum + row.commits, 0);
- const repositoryCount = rows.length;
- const averageSessionsPerRepository =
- repositoryCount > 0 ? totalSessions / repositoryCount : 0;
- const commitRate =
- totalSessions > 0 ? Math.round((totalCommits / totalSessions) * 100) : 0;
-
- return [
- {
- description: "Unique repositories active in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Repos touched",
- valueLabel: repositoryCount.toString(),
- },
- {
- description: "Average session volume across active repositories.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Avg sessions / repo",
- valueLabel:
- repositoryCount > 0 ? averageSessionsPerRepository.toFixed(1) : "0.0",
- },
- {
- description: "Committed sessions divided by all repository sessions.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "commitRate",
- label: "Repo commit rate",
- valueLabel: `${commitRate}%`,
- },
- ];
-}
-
-function buildDailyPattern(
- startDate: string,
- endDate: string,
- rowsByDate: Map,
-): DashboardDailyPatternPoint[] {
- return buildDateRange(startDate, endDate).map((date) => {
- const isoDate = format(date, "yyyy-MM-dd");
- const row = rowsByDate.get(isoDate);
- const sessions = row?.sessions ?? null;
- const commits = row?.commits ?? null;
-
- return {
- axisLabel: format(date, "EEE"),
- commitRate:
- sessions != null && commits != null && sessions > 0
- ? Math.round((commits / sessions) * 100)
- : null,
- commits,
- date: isoDate,
- fullLabel: format(date, "EEEE, MMM d"),
- sessions,
- };
- });
-}
-
-function buildDateRange(startDate: string, endDate: string) {
- const parsedStartDate = parseISO(startDate);
- const parsedEndDate = parseISO(endDate);
-
- if (
- Number.isNaN(parsedStartDate.getTime()) ||
- Number.isNaN(parsedEndDate.getTime())
- ) {
- return [];
- }
-
- return eachDayOfInterval({
- end:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedEndDate
- : parsedStartDate,
- start:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedStartDate
- : parsedEndDate,
- });
-}
-
-function normalizeDateKey(value: string) {
- if (/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
- return value;
- }
-
- const parsedDate = parseISO(value);
-
- if (Number.isNaN(parsedDate.getTime())) {
- return value;
- }
-
- return format(parsedDate, "yyyy-MM-dd");
-}
diff --git a/apps/web/src/features/dashboard/data/dashboard-session-adapters.test.ts b/apps/web/src/features/dashboard/data/dashboard-session-adapters.test.ts
deleted file mode 100644
index 3aecc4d6..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-session-adapters.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { SessionAnalyticsSummaryComparison } from "@rudel/api-routes";
-import { buildDashboardSessionTabMetrics } from "./dashboard-session-adapters";
-
-describe("dashboard-session-adapters", () => {
- test("builds session headline metrics from summary comparison", () => {
- const summaryComparison: SessionAnalyticsSummaryComparison = {
- changes: {
- avg_response_time_sec: -5.4,
- avg_session_duration_min: 10.5,
- total_sessions: 18,
- },
- current: {
- avg_response_time_sec: 24.3,
- avg_session_duration_min: 38.2,
- skills_adoption_rate: 42,
- slash_commands_adoption_rate: 35,
- subagents_adoption_rate: 12,
- total_sessions: 67,
- },
- previous: {
- avg_response_time_sec: 29.7,
- avg_session_duration_min: 27.7,
- skills_adoption_rate: 38,
- slash_commands_adoption_rate: 31,
- subagents_adoption_rate: 9,
- total_sessions: 49,
- },
- };
-
- expect(buildDashboardSessionTabMetrics(summaryComparison)).toEqual([
- {
- description: "Total AI sessions in the selected range.",
- deltaLabel: "+18%",
- deltaTone: "positive",
- id: "sessions",
- label: "Sessions run",
- valueLabel: "67",
- },
- {
- description: "Average session duration.",
- deltaLabel: "+11%",
- deltaTone: "negative",
- id: "uncommitted",
- label: "Avg duration",
- valueLabel: "38.2m",
- },
- {
- description: "Average time between session interactions.",
- deltaLabel: "-5.4%",
- deltaTone: "positive",
- id: "commitRate",
- label: "Avg response",
- valueLabel: "24.3s",
- },
- ]);
- });
-});
diff --git a/apps/web/src/features/dashboard/data/dashboard-session-adapters.ts b/apps/web/src/features/dashboard/data/dashboard-session-adapters.ts
deleted file mode 100644
index 811e6f25..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-session-adapters.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import type { SessionAnalyticsSummaryComparison } from "@rudel/api-routes";
-import type {
- DashboardDeltaTone,
- DashboardHeadlineMetric,
-} from "@/features/dashboard/data/dashboard-static-data";
-
-export function buildDashboardSessionTabMetrics(
- summaryComparison: SessionAnalyticsSummaryComparison | undefined,
-): DashboardHeadlineMetric[] {
- const current = summaryComparison?.current;
- const changes = summaryComparison?.changes;
- const totalSessions = current?.total_sessions ?? 0;
- const averageDuration = current?.avg_session_duration_min ?? 0;
- const averageResponseTime = current?.avg_response_time_sec ?? 0;
- const totalSessionChange = changes?.total_sessions ?? 0;
- const averageDurationChange = changes?.avg_session_duration_min ?? 0;
- const averageResponseTimeChange = changes?.avg_response_time_sec ?? 0;
-
- return [
- {
- description: "Total AI sessions in the selected range.",
- deltaLabel: formatSignedPercentChange(totalSessionChange),
- deltaTone: getDeltaTone(totalSessionChange),
- id: "sessions",
- label: "Sessions run",
- valueLabel: totalSessions.toString(),
- },
- {
- description: "Average session duration.",
- deltaLabel: formatSignedPercentChange(averageDurationChange),
- deltaTone: getDeltaTone(averageDurationChange, { inverse: true }),
- id: "uncommitted",
- label: "Avg duration",
- valueLabel: `${averageDuration.toFixed(1)}m`,
- },
- {
- description: "Average time between session interactions.",
- deltaLabel: formatSignedPercentChange(averageResponseTimeChange),
- deltaTone: getDeltaTone(averageResponseTimeChange, { inverse: true }),
- id: "commitRate",
- label: "Avg response",
- valueLabel: `${averageResponseTime.toFixed(1)}s`,
- },
- ];
-}
-
-function formatSignedPercentChange(value: number) {
- if (!Number.isFinite(value) || value === 0) {
- return "0%";
- }
-
- const roundedValue =
- Math.abs(value) >= 10 ? value.toFixed(0) : value.toFixed(1);
-
- return `${value > 0 ? "+" : ""}${roundedValue}%`;
-}
-
-function getDeltaTone(
- value: number,
- options?: {
- inverse?: boolean;
- },
-): DashboardDeltaTone {
- if (!Number.isFinite(value) || value === 0) {
- return "neutral";
- }
-
- const isPositive = options?.inverse ? value < 0 : value > 0;
- return isPositive ? "positive" : "negative";
-}
diff --git a/apps/web/src/features/dashboard/data/dashboard-tab-adapters.ts b/apps/web/src/features/dashboard/data/dashboard-tab-adapters.ts
new file mode 100644
index 00000000..38911348
--- /dev/null
+++ b/apps/web/src/features/dashboard/data/dashboard-tab-adapters.ts
@@ -0,0 +1,570 @@
+import type {
+ ModelTokensTrendData,
+ RepositoryDailyTrendData,
+ SessionAnalyticsSummaryComparison,
+ UserDailyTrendData,
+ UserTokenUsageData,
+} from "@rudel/api-routes";
+import { eachDayOfInterval, format, parseISO } from "date-fns";
+import type { DashboardRepositorySummaryRow } from "@/features/dashboard/data/dashboard-repository-trend";
+import type {
+ DashboardDailyPatternPoint,
+ DashboardDeltaTone,
+ DashboardHeadlineMetric,
+} from "@/features/dashboard/data/dashboard-static-data";
+import { calculateCost, formatCompactWholeCurrency } from "@/lib/format";
+
+export type DashboardTokenDailyPoint = {
+ activeModels: number;
+ avgTokensPerSession: number | null;
+ axisLabel: string;
+ date: string;
+ dominantModel: string | null;
+ dominantModelTokens: number;
+ fullLabel: string;
+ inputTokens: number;
+ modelTokens: Record;
+ outputTokens: number;
+ sessions: number;
+ totalTokens: number;
+};
+
+export type DashboardRepositoryDailyOverviewRow = {
+ activeRepositories: number;
+ date: string;
+ leadRepositoryLabel: string | null;
+ leadRepositorySessions: number;
+ leadRepositoryShare: number | null;
+ sessions: number;
+};
+
+export type DashboardSessionHourlyOverviewRow = {
+ bandLabel: string;
+ bandTone: "danger" | "muted" | "success" | "warning";
+ hour: number;
+ label: string;
+ sharePct: number | null;
+ sessions: number;
+};
+
+function formatSignedPercentChange(value: number) {
+ if (!Number.isFinite(value) || value === 0) {
+ return "0%";
+ }
+
+ const roundedValue =
+ Math.abs(value) >= 10 ? value.toFixed(0) : value.toFixed(1);
+ return `${value > 0 ? "+" : ""}${roundedValue}%`;
+}
+
+function formatCompactNumber(value: number) {
+ 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.toLocaleString();
+}
+
+function getDeltaTone(
+ value: number,
+ options?: {
+ inverse?: boolean;
+ },
+): DashboardDeltaTone {
+ if (!Number.isFinite(value) || value === 0) {
+ return "neutral";
+ }
+
+ const isPositive = options?.inverse ? value < 0 : value > 0;
+ return isPositive ? "positive" : "negative";
+}
+
+function buildDateRange(startDate: string, endDate: string) {
+ const parsedStartDate = parseISO(startDate);
+ const parsedEndDate = parseISO(endDate);
+
+ if (
+ Number.isNaN(parsedStartDate.getTime()) ||
+ Number.isNaN(parsedEndDate.getTime())
+ ) {
+ return [];
+ }
+
+ return eachDayOfInterval({
+ start:
+ parsedStartDate.getTime() <= parsedEndDate.getTime()
+ ? parsedStartDate
+ : parsedEndDate,
+ end:
+ parsedStartDate.getTime() <= parsedEndDate.getTime()
+ ? parsedEndDate
+ : parsedStartDate,
+ });
+}
+
+function normalizeDateKey(value: string) {
+ if (/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
+ return value;
+ }
+
+ const parsedDate = parseISO(value);
+
+ if (Number.isNaN(parsedDate.getTime())) {
+ return value;
+ }
+
+ return format(parsedDate, "yyyy-MM-dd");
+}
+
+function buildDailyPattern(
+ startDate: string,
+ endDate: string,
+ rowsByDate: Map,
+): DashboardDailyPatternPoint[] {
+ return buildDateRange(startDate, endDate).map((date) => {
+ const isoDate = format(date, "yyyy-MM-dd");
+ const row = rowsByDate.get(isoDate);
+ const sessions = row?.sessions ?? null;
+ const commits = row?.commits ?? null;
+
+ return {
+ date: isoDate,
+ axisLabel: format(date, "EEE"),
+ fullLabel: format(date, "EEEE, MMM d"),
+ commits,
+ sessions,
+ commitRate:
+ sessions != null && commits != null && sessions > 0
+ ? Math.round((commits / sessions) * 100)
+ : null,
+ };
+ });
+}
+
+export function buildDashboardDailyPatternFromUserTrend(
+ startDate: string,
+ endDate: string,
+ rows: UserDailyTrendData[] | undefined,
+): DashboardDailyPatternPoint[] {
+ const rowsByDate = new Map();
+
+ for (const row of rows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ const currentRow = rowsByDate.get(dateKey) ?? {
+ commits: 0,
+ sessions: 0,
+ };
+ currentRow.commits += row.total_commits;
+ currentRow.sessions += row.sessions;
+ rowsByDate.set(dateKey, currentRow);
+ }
+
+ return buildDailyPattern(startDate, endDate, rowsByDate);
+}
+
+export function buildDashboardDailyPatternFromRepositoryTrend(
+ startDate: string,
+ endDate: string,
+ rows: RepositoryDailyTrendData[] | undefined,
+): DashboardDailyPatternPoint[] {
+ const rowsByDate = new Map();
+
+ for (const row of rows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ const currentRow = rowsByDate.get(dateKey) ?? {
+ commits: 0,
+ sessions: 0,
+ };
+ currentRow.commits += row.total_commits;
+ currentRow.sessions += row.sessions;
+ rowsByDate.set(dateKey, currentRow);
+ }
+
+ return buildDailyPattern(startDate, endDate, rowsByDate);
+}
+
+export function buildDashboardRepositoryDailyOverviewRows(
+ startDate: string,
+ endDate: string,
+ rows: RepositoryDailyTrendData[] | undefined,
+): DashboardRepositoryDailyOverviewRow[] {
+ const rowsByDate = new Map<
+ string,
+ {
+ activeRepositories: number;
+ leadRepositoryLabel: string | null;
+ leadRepositorySessions: number;
+ sessions: number;
+ }
+ >();
+
+ for (const row of rows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ const currentRow = rowsByDate.get(dateKey) ?? {
+ activeRepositories: 0,
+ leadRepositoryLabel: null,
+ leadRepositorySessions: 0,
+ sessions: 0,
+ };
+
+ currentRow.sessions += row.sessions;
+
+ if (row.sessions > 0) {
+ currentRow.activeRepositories += 1;
+ }
+
+ if (
+ row.sessions > currentRow.leadRepositorySessions ||
+ (row.sessions === currentRow.leadRepositorySessions &&
+ row.sessions > 0 &&
+ row.repository.localeCompare(currentRow.leadRepositoryLabel ?? "") < 0)
+ ) {
+ currentRow.leadRepositoryLabel = row.repository;
+ currentRow.leadRepositorySessions = row.sessions;
+ }
+
+ rowsByDate.set(dateKey, currentRow);
+ }
+
+ return buildDateRange(startDate, endDate).map((date) => {
+ const isoDate = format(date, "yyyy-MM-dd");
+ const row = rowsByDate.get(isoDate);
+ const sessions = row?.sessions ?? 0;
+ const leadRepositorySessions = row?.leadRepositorySessions ?? 0;
+
+ return {
+ activeRepositories: row?.activeRepositories ?? 0,
+ date: isoDate,
+ leadRepositoryLabel: row?.leadRepositoryLabel ?? null,
+ leadRepositorySessions,
+ leadRepositoryShare:
+ sessions > 0
+ ? Math.round((leadRepositorySessions / sessions) * 100)
+ : null,
+ sessions,
+ };
+ });
+}
+
+function getHourlyActivityBand(
+ sessions: number,
+ maxSessions: number,
+): Pick {
+ if (sessions <= 0 || maxSessions <= 0) {
+ return {
+ bandLabel: "No activity",
+ bandTone: "muted",
+ };
+ }
+
+ const relativeLoad = sessions / maxSessions;
+
+ if (relativeLoad >= 0.9) {
+ return {
+ bandLabel: "Peak hour",
+ bandTone: "success",
+ };
+ }
+
+ if (relativeLoad >= 0.6) {
+ return {
+ bandLabel: "High activity",
+ bandTone: "warning",
+ };
+ }
+
+ if (relativeLoad >= 0.3) {
+ return {
+ bandLabel: "Steady",
+ bandTone: "warning",
+ };
+ }
+
+ return {
+ bandLabel: "Light activity",
+ bandTone: "danger",
+ };
+}
+
+export function buildDashboardSessionHourlyOverviewRows(
+ rows: { hour: number; label: string; sessions: number }[] | undefined,
+): DashboardSessionHourlyOverviewRow[] {
+ const resolvedRows = rows ?? [];
+ const totalSessions = resolvedRows.reduce(
+ (sum, row) => sum + row.sessions,
+ 0,
+ );
+ const maxSessions = Math.max(0, ...resolvedRows.map((row) => row.sessions));
+
+ return [...resolvedRows]
+ .map((row) => ({
+ ...getHourlyActivityBand(row.sessions, maxSessions),
+ hour: row.hour,
+ label: row.label,
+ sharePct:
+ totalSessions > 0
+ ? Math.round((row.sessions / totalSessions) * 100)
+ : null,
+ sessions: row.sessions,
+ }))
+ .sort(
+ (leftRow, rightRow) =>
+ rightRow.sessions - leftRow.sessions || leftRow.hour - rightRow.hour,
+ );
+}
+
+export function buildDashboardRepositoryTabMetrics(
+ rows: DashboardRepositorySummaryRow[],
+): DashboardHeadlineMetric[] {
+ const totalSessions = rows.reduce((sum, row) => sum + row.sessions, 0);
+ const totalCommits = rows.reduce((sum, row) => sum + row.commits, 0);
+ const repoCount = rows.length;
+ const averageSessionsPerRepo =
+ repoCount > 0 ? totalSessions / Math.max(repoCount, 1) : 0;
+ const commitRate =
+ totalSessions > 0 ? Math.round((totalCommits / totalSessions) * 100) : 0;
+
+ return [
+ {
+ id: "sessions",
+ label: "Repos touched",
+ valueLabel: repoCount.toString(),
+ deltaLabel: "0",
+ deltaTone: "neutral",
+ description: "Unique repositories active in the selected range.",
+ },
+ {
+ id: "uncommitted",
+ label: "Avg sessions / repo",
+ valueLabel: repoCount > 0 ? averageSessionsPerRepo.toFixed(1) : "0.0",
+ deltaLabel: "0",
+ deltaTone: "neutral",
+ description: "Average session volume across active repositories.",
+ },
+ {
+ id: "commitRate",
+ label: "Repo commit rate",
+ valueLabel: `${commitRate}%`,
+ deltaLabel: "0",
+ deltaTone: "neutral",
+ description: "Committed sessions divided by all repository sessions.",
+ },
+ ];
+}
+
+export function buildDashboardSessionTabMetrics(
+ summaryComparison: SessionAnalyticsSummaryComparison | undefined,
+): DashboardHeadlineMetric[] {
+ const current = summaryComparison?.current;
+ const changes = summaryComparison?.changes;
+ const totalSessions = current?.total_sessions ?? 0;
+ const avgDuration = current?.avg_session_duration_min ?? 0;
+ const avgResponseTime = current?.avg_response_time_sec ?? 0;
+ const totalSessionChange = changes?.total_sessions ?? 0;
+ const avgDurationChange = changes?.avg_session_duration_min ?? 0;
+ const avgResponseTimeChange = changes?.avg_response_time_sec ?? 0;
+
+ return [
+ {
+ id: "sessions",
+ label: "Sessions run",
+ valueLabel: totalSessions.toString(),
+ deltaLabel: formatSignedPercentChange(totalSessionChange),
+ deltaTone: getDeltaTone(totalSessionChange),
+ description: "Total AI sessions in the selected range.",
+ },
+ {
+ id: "uncommitted",
+ label: "Avg duration",
+ valueLabel: `${avgDuration.toFixed(1)}m`,
+ deltaLabel: formatSignedPercentChange(avgDurationChange),
+ deltaTone: getDeltaTone(avgDurationChange, { inverse: true }),
+ description: "Average session duration.",
+ },
+ {
+ id: "commitRate",
+ label: "Avg response",
+ valueLabel: `${avgResponseTime.toFixed(1)}s`,
+ deltaLabel: formatSignedPercentChange(avgResponseTimeChange),
+ deltaTone: getDeltaTone(avgResponseTimeChange, { inverse: true }),
+ description: "Average time between session interactions.",
+ },
+ ];
+}
+
+export function buildDashboardTokenDailyPattern(
+ startDate: string,
+ endDate: string,
+ userRows: UserDailyTrendData[] | undefined,
+ modelRows: ModelTokensTrendData[] | undefined,
+): DashboardTokenDailyPoint[] {
+ const sessionsByDate = new Map();
+ const tokensByDate = new Map<
+ string,
+ {
+ inputTokens: number;
+ outputTokens: number;
+ }
+ >();
+ const modelTokensByDate = new Map>();
+ let hasUserTokenBreakdown = false;
+ let hasModelTokenBreakdown = false;
+
+ for (const row of userRows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ sessionsByDate.set(
+ dateKey,
+ (sessionsByDate.get(dateKey) ?? 0) + row.sessions,
+ );
+
+ const currentRow = tokensByDate.get(dateKey) ?? {
+ inputTokens: 0,
+ outputTokens: 0,
+ };
+ currentRow.inputTokens += row.input_tokens;
+ currentRow.outputTokens += row.output_tokens;
+ tokensByDate.set(dateKey, currentRow);
+
+ if (row.input_tokens > 0 || row.output_tokens > 0) {
+ hasUserTokenBreakdown = true;
+ }
+ }
+
+ if (!hasUserTokenBreakdown) {
+ for (const row of modelRows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ const currentRow = tokensByDate.get(dateKey) ?? {
+ inputTokens: 0,
+ outputTokens: 0,
+ };
+ currentRow.inputTokens += row.input_tokens;
+ currentRow.outputTokens += row.output_tokens;
+ tokensByDate.set(dateKey, currentRow);
+ }
+ }
+
+ for (const row of modelRows ?? []) {
+ const dateKey = normalizeDateKey(row.date);
+ const currentBreakdown = modelTokensByDate.get(dateKey) ?? {};
+ currentBreakdown[row.model] =
+ (currentBreakdown[row.model] ?? 0) + row.total_tokens;
+ modelTokensByDate.set(dateKey, currentBreakdown);
+
+ if (row.total_tokens > 0) {
+ hasModelTokenBreakdown = true;
+ }
+ }
+
+ return buildDateRange(startDate, endDate).map((date) => {
+ const isoDate = format(date, "yyyy-MM-dd");
+ const sessions = sessionsByDate.get(isoDate) ?? 0;
+ const tokensRow = tokensByDate.get(isoDate) ?? {
+ inputTokens: 0,
+ outputTokens: 0,
+ };
+ const modelTokens = modelTokensByDate.get(isoDate) ?? {};
+ const totalTokens = tokensRow.inputTokens + tokensRow.outputTokens;
+ const sortedModels = Object.entries(modelTokens)
+ .filter(([, value]) => value > 0)
+ .sort(
+ (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
+ );
+ const [dominantModel, dominantModelTokens] = sortedModels[0] ?? [null, 0];
+
+ return {
+ activeModels: hasModelTokenBreakdown ? sortedModels.length : 0,
+ avgTokensPerSession:
+ sessions > 0 ? Math.round(totalTokens / sessions) : null,
+ axisLabel: format(date, "EEE"),
+ date: isoDate,
+ dominantModel,
+ dominantModelTokens,
+ fullLabel: format(date, "EEEE, MMM d"),
+ inputTokens: tokensRow.inputTokens,
+ modelTokens,
+ outputTokens: tokensRow.outputTokens,
+ sessions,
+ totalTokens,
+ };
+ });
+}
+
+export function buildDashboardTokenTabMetrics(
+ usersTokenUsage: UserTokenUsageData[] | undefined,
+ dailyPattern: DashboardTokenDailyPoint[],
+ modelRows?: ModelTokensTrendData[] | undefined,
+ userTrendRows?: UserDailyTrendData[] | undefined,
+): DashboardHeadlineMetric[] {
+ const totalTokensFromUsage = (usersTokenUsage ?? []).reduce(
+ (sum, row) => sum + row.total_tokens,
+ 0,
+ );
+ const totalTokensFromPattern = dailyPattern.reduce(
+ (sum, point) => sum + point.totalTokens,
+ 0,
+ );
+ const totalTokens =
+ totalTokensFromUsage > 0 ? totalTokensFromUsage : totalTokensFromPattern;
+ const totalCostFromUsage = (usersTokenUsage ?? []).reduce(
+ (sum, row) => sum + row.cost,
+ 0,
+ );
+ const totalCostFromModels = (modelRows ?? []).reduce(
+ (sum, row) =>
+ sum + calculateCost(row.input_tokens, row.output_tokens, row.model),
+ 0,
+ );
+ const totalCost =
+ totalCostFromUsage > 0 ? totalCostFromUsage : totalCostFromModels;
+ const activeDevelopersFromUsage = (usersTokenUsage ?? []).filter(
+ (row) => row.total_tokens > 0 || row.total_sessions > 0,
+ ).length;
+ const activeDevelopersFromTrend = new Set(
+ (userTrendRows ?? [])
+ .filter((row) => row.total_tokens > 0 || row.sessions > 0)
+ .map((row) => row.user_id),
+ ).size;
+ const activeDevelopers =
+ activeDevelopersFromUsage > 0
+ ? activeDevelopersFromUsage
+ : activeDevelopersFromTrend;
+ const activeDays = dailyPattern.filter(
+ (point) => point.totalTokens > 0,
+ ).length;
+ const averageTokensPerActiveDay =
+ activeDays > 0 ? Math.round(totalTokens / activeDays) : 0;
+
+ return [
+ {
+ id: "sessions",
+ label: "Tokens used",
+ valueLabel: formatCompactNumber(totalTokens),
+ deltaLabel: "0",
+ deltaTone: "neutral",
+ description: "Total input and output tokens in the selected range.",
+ },
+ {
+ id: "uncommitted",
+ label: "Est. spend",
+ valueLabel: formatCompactWholeCurrency(totalCost),
+ deltaLabel: "0",
+ deltaTone: "neutral",
+ description:
+ "Estimated token cost using the current model pricing catalog.",
+ },
+ {
+ id: "commitRate",
+ label: "Active developers",
+ valueLabel: activeDevelopers.toString(),
+ deltaLabel:
+ activeDays > 0 ? formatCompactNumber(averageTokensPerActiveDay) : "0",
+ deltaTone: "neutral",
+ description:
+ "Developers with token activity in the selected range. Delta shows average daily token load.",
+ },
+ ];
+}
diff --git a/apps/web/src/features/dashboard/data/dashboard-token-adapters.test.ts b/apps/web/src/features/dashboard/data/dashboard-token-adapters.test.ts
deleted file mode 100644
index 54103ab5..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-token-adapters.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type {
- ModelTokensTrendData,
- UserDailyTrendData,
- UserTokenUsageData,
-} from "@rudel/api-routes";
-import {
- buildDashboardTokenDailyPattern,
- buildDashboardTokenTabMetrics,
-} from "./dashboard-token-adapters";
-
-describe("dashboard-token-adapters", () => {
- test("falls back to model rows when user trend token breakdown is empty", () => {
- const userRows: UserDailyTrendData[] = [
- {
- avg_success_rate: 100,
- date: "2026-04-01",
- distinct_skills: 1,
- distinct_slash_commands: 0,
- input_tokens: 0,
- models_used: [],
- output_tokens: 0,
- repositories_touched: [],
- sessions: 3,
- total_commits: 2,
- total_hours: 1.5,
- total_tokens: 0,
- user_id: "user-1",
- },
- ];
- const modelRows: ModelTokensTrendData[] = [
- {
- date: "2026-04-01",
- input_tokens: 900,
- model: "claude-sonnet-4-6-20250514",
- output_tokens: 300,
- total_tokens: 1200,
- },
- {
- date: "2026-04-01",
- input_tokens: 200,
- model: "gpt-5.4-mini",
- output_tokens: 100,
- total_tokens: 300,
- },
- ];
-
- const dailyPattern = buildDashboardTokenDailyPattern(
- "2026-04-01",
- "2026-04-02",
- userRows,
- modelRows,
- );
-
- expect(dailyPattern).toHaveLength(2);
- expect(dailyPattern[0]).toMatchObject({
- activeModels: 2,
- avgTokensPerSession: 500,
- date: "2026-04-01",
- dominantModel: "claude-sonnet-4-6-20250514",
- dominantModelTokens: 1200,
- inputTokens: 1100,
- outputTokens: 400,
- sessions: 3,
- totalTokens: 1500,
- });
- expect(dailyPattern[1]).toMatchObject({
- activeModels: 0,
- avgTokensPerSession: null,
- date: "2026-04-02",
- totalTokens: 0,
- });
- });
-
- test("builds token headline metrics from aggregated usage", () => {
- const usersTokenUsage: UserTokenUsageData[] = [
- {
- cost: 12,
- distinct_skills: 1,
- distinct_slash_commands: 0,
- input_tokens: 1500,
- models_used: ["claude-sonnet-4-6-20250514"],
- output_tokens: 900,
- repositories_touched: ["rudel"],
- success_rate: 100,
- total_commits: 4,
- total_duration_min: 40,
- total_sessions: 3,
- total_tokens: 2400,
- user_id: "user-1",
- user_label: "Ada Lovelace",
- },
- ];
- const userTrendRows: UserDailyTrendData[] = [
- {
- avg_success_rate: 100,
- date: "2026-04-01",
- distinct_skills: 1,
- distinct_slash_commands: 0,
- input_tokens: 1500,
- models_used: ["claude-sonnet-4-6-20250514"],
- output_tokens: 900,
- repositories_touched: ["rudel"],
- sessions: 3,
- total_commits: 4,
- total_hours: 2,
- total_tokens: 2400,
- user_id: "user-1",
- },
- ];
- const dailyPattern = buildDashboardTokenDailyPattern(
- "2026-04-01",
- "2026-04-02",
- userTrendRows,
- undefined,
- );
-
- const metrics = buildDashboardTokenTabMetrics(
- usersTokenUsage,
- dailyPattern,
- undefined,
- userTrendRows,
- );
-
- expect(metrics).toEqual([
- {
- description: "Total input and output tokens in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Tokens used",
- valueLabel: "2.4K",
- },
- {
- description:
- "Estimated token cost using the current model pricing catalog.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Est. spend",
- valueLabel: "$12",
- },
- {
- description:
- "Developers with token activity in the selected range. Delta shows average daily token load.",
- deltaLabel: "2.4K",
- deltaTone: "neutral",
- id: "commitRate",
- label: "Active developers",
- valueLabel: "1",
- },
- ]);
- });
-});
diff --git a/apps/web/src/features/dashboard/data/dashboard-token-adapters.ts b/apps/web/src/features/dashboard/data/dashboard-token-adapters.ts
deleted file mode 100644
index cc9d5eca..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-token-adapters.ts
+++ /dev/null
@@ -1,239 +0,0 @@
-import type {
- ModelTokensTrendData,
- UserDailyTrendData,
- UserTokenUsageData,
-} from "@rudel/api-routes";
-import { eachDayOfInterval, format, parseISO } from "date-fns";
-import type { DashboardHeadlineMetric } from "@/features/dashboard/data/dashboard-static-data";
-import {
- calculateCost,
- formatCompactNumber,
- formatCompactWholeCurrency,
-} from "@/lib/format";
-
-export type DashboardTokenDailyPoint = {
- activeModels: number;
- avgTokensPerSession: number | null;
- axisLabel: string;
- date: string;
- dominantModel: string | null;
- dominantModelTokens: number;
- fullLabel: string;
- inputTokens: number;
- modelTokens: Record;
- outputTokens: number;
- sessions: number;
- totalTokens: number;
-};
-
-export function buildDashboardTokenDailyPattern(
- startDate: string,
- endDate: string,
- userRows: UserDailyTrendData[] | undefined,
- modelRows: ModelTokensTrendData[] | undefined,
-): DashboardTokenDailyPoint[] {
- const sessionsByDate = new Map();
- const tokensByDate = new Map<
- string,
- {
- inputTokens: number;
- outputTokens: number;
- }
- >();
- const modelTokensByDate = new Map>();
- let hasModelTokenBreakdown = false;
- let hasUserTokenBreakdown = false;
-
- for (const row of userRows ?? []) {
- const dateKey = normalizeDateKey(row.date);
- const currentTokens = tokensByDate.get(dateKey) ?? {
- inputTokens: 0,
- outputTokens: 0,
- };
-
- sessionsByDate.set(
- dateKey,
- (sessionsByDate.get(dateKey) ?? 0) + row.sessions,
- );
- currentTokens.inputTokens += row.input_tokens;
- currentTokens.outputTokens += row.output_tokens;
- tokensByDate.set(dateKey, currentTokens);
-
- if (row.input_tokens > 0 || row.output_tokens > 0) {
- hasUserTokenBreakdown = true;
- }
- }
-
- if (!hasUserTokenBreakdown) {
- for (const row of modelRows ?? []) {
- const dateKey = normalizeDateKey(row.date);
- const currentTokens = tokensByDate.get(dateKey) ?? {
- inputTokens: 0,
- outputTokens: 0,
- };
-
- currentTokens.inputTokens += row.input_tokens;
- currentTokens.outputTokens += row.output_tokens;
- tokensByDate.set(dateKey, currentTokens);
- }
- }
-
- for (const row of modelRows ?? []) {
- const dateKey = normalizeDateKey(row.date);
- const currentModelTokens = modelTokensByDate.get(dateKey) ?? {};
-
- currentModelTokens[row.model] =
- (currentModelTokens[row.model] ?? 0) + row.total_tokens;
- modelTokensByDate.set(dateKey, currentModelTokens);
-
- if (row.total_tokens > 0) {
- hasModelTokenBreakdown = true;
- }
- }
-
- return buildDateRange(startDate, endDate).map((date) => {
- const isoDate = format(date, "yyyy-MM-dd");
- const sessions = sessionsByDate.get(isoDate) ?? 0;
- const tokensRow = tokensByDate.get(isoDate) ?? {
- inputTokens: 0,
- outputTokens: 0,
- };
- const modelTokens = modelTokensByDate.get(isoDate) ?? {};
- const totalTokens = tokensRow.inputTokens + tokensRow.outputTokens;
- const sortedModels = Object.entries(modelTokens)
- .filter(([, value]) => value > 0)
- .sort(
- (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
- );
- const [dominantModel, dominantModelTokens] = sortedModels[0] ?? [null, 0];
-
- return {
- activeModels: hasModelTokenBreakdown ? sortedModels.length : 0,
- avgTokensPerSession:
- sessions > 0 ? Math.round(totalTokens / sessions) : null,
- axisLabel: format(date, "EEE"),
- date: isoDate,
- dominantModel,
- dominantModelTokens,
- fullLabel: format(date, "EEEE, MMM d"),
- inputTokens: tokensRow.inputTokens,
- modelTokens,
- outputTokens: tokensRow.outputTokens,
- sessions,
- totalTokens,
- };
- });
-}
-
-export function buildDashboardTokenTabMetrics(
- usersTokenUsage: UserTokenUsageData[] | undefined,
- dailyPattern: DashboardTokenDailyPoint[],
- modelRows?: ModelTokensTrendData[] | undefined,
- userTrendRows?: UserDailyTrendData[] | undefined,
-): DashboardHeadlineMetric[] {
- const totalTokensFromUsage = (usersTokenUsage ?? []).reduce(
- (sum, row) => sum + row.total_tokens,
- 0,
- );
- const totalTokensFromPattern = dailyPattern.reduce(
- (sum, point) => sum + point.totalTokens,
- 0,
- );
- const totalTokens =
- totalTokensFromUsage > 0 ? totalTokensFromUsage : totalTokensFromPattern;
- const totalCostFromUsage = (usersTokenUsage ?? []).reduce(
- (sum, row) => sum + row.cost,
- 0,
- );
- const totalCostFromModels = (modelRows ?? []).reduce(
- (sum, row) =>
- sum + calculateCost(row.input_tokens, row.output_tokens, row.model),
- 0,
- );
- const totalCost =
- totalCostFromUsage > 0 ? totalCostFromUsage : totalCostFromModels;
- const activeDevelopersFromUsage = (usersTokenUsage ?? []).filter(
- (row) => row.total_tokens > 0 || row.total_sessions > 0,
- ).length;
- const activeDevelopersFromTrend = new Set(
- (userTrendRows ?? [])
- .filter((row) => row.total_tokens > 0 || row.sessions > 0)
- .map((row) => row.user_id),
- ).size;
- const activeDevelopers =
- activeDevelopersFromUsage > 0
- ? activeDevelopersFromUsage
- : activeDevelopersFromTrend;
- const activeDays = dailyPattern.filter(
- (point) => point.totalTokens > 0,
- ).length;
- const averageTokensPerActiveDay =
- activeDays > 0 ? Math.round(totalTokens / activeDays) : 0;
-
- return [
- {
- description: "Total input and output tokens in the selected range.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "sessions",
- label: "Tokens used",
- valueLabel: formatCompactNumber(totalTokens),
- },
- {
- description:
- "Estimated token cost using the current model pricing catalog.",
- deltaLabel: "0",
- deltaTone: "neutral",
- id: "uncommitted",
- label: "Est. spend",
- valueLabel: formatCompactWholeCurrency(totalCost),
- },
- {
- description:
- "Developers with token activity in the selected range. Delta shows average daily token load.",
- deltaLabel:
- activeDays > 0 ? formatCompactNumber(averageTokensPerActiveDay) : "0",
- deltaTone: "neutral",
- id: "commitRate",
- label: "Active developers",
- valueLabel: activeDevelopers.toString(),
- },
- ];
-}
-
-function buildDateRange(startDate: string, endDate: string) {
- const parsedStartDate = parseISO(startDate);
- const parsedEndDate = parseISO(endDate);
-
- if (
- Number.isNaN(parsedStartDate.getTime()) ||
- Number.isNaN(parsedEndDate.getTime())
- ) {
- return [];
- }
-
- return eachDayOfInterval({
- end:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedEndDate
- : parsedStartDate,
- start:
- parsedStartDate.getTime() <= parsedEndDate.getTime()
- ? parsedStartDate
- : parsedEndDate,
- });
-}
-
-function normalizeDateKey(value: string) {
- if (/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
- return value;
- }
-
- const parsedDate = parseISO(value);
-
- if (Number.isNaN(parsedDate.getTime())) {
- return value;
- }
-
- return format(parsedDate, "yyyy-MM-dd");
-}
diff --git a/apps/web/src/features/dashboard/data/dashboard-token-model-adapter.test.ts b/apps/web/src/features/dashboard/data/dashboard-token-model-adapter.test.ts
deleted file mode 100644
index 5ce75b07..00000000
--- a/apps/web/src/features/dashboard/data/dashboard-token-model-adapter.test.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { ModelTokensTrendData } from "@rudel/api-routes";
-import {
- buildDashboardTokenModelChartData,
- buildDashboardTokenModelRows,
-} from "./dashboard-token-model-adapter";
-
-describe("dashboard-token-model-adapter", () => {
- test("aggregates model totals across dates and sorts by total tokens", () => {
- const modelTokensTrend: ModelTokensTrendData[] = [
- {
- date: "2026-04-01",
- input_tokens: 600_000,
- model: "claude-sonnet-4-6-20250514",
- output_tokens: 400_000,
- total_tokens: 1_000_000,
- },
- {
- date: "2026-04-02",
- input_tokens: 400_000,
- model: "claude-sonnet-4-6-20250514",
- output_tokens: 600_000,
- total_tokens: 1_000_000,
- },
- {
- date: "2026-04-01",
- input_tokens: 250_000,
- model: "gpt-5.4-mini",
- output_tokens: 250_000,
- total_tokens: 500_000,
- },
- ];
-
- const rows = buildDashboardTokenModelRows(modelTokensTrend);
-
- expect(rows).toEqual([
- {
- estimatedCost: 18,
- id: "claude-sonnet-4-6-20250514",
- inputTokens: 1_000_000,
- label: "claude-sonnet-4-6-20250514",
- outputTokens: 1_000_000,
- totalTokens: 2_000_000,
- },
- {
- estimatedCost: 1.3125,
- id: "gpt-5.4-mini",
- inputTokens: 250_000,
- label: "gpt-5.4-mini",
- outputTokens: 250_000,
- totalTokens: 500_000,
- },
- ]);
- });
-
- test("builds chart rows with shortened model labels", () => {
- const chartData = buildDashboardTokenModelChartData([
- {
- estimatedCost: 18,
- id: "claude-sonnet-4-6-20250514",
- inputTokens: 1_000_000,
- label: "claude-sonnet-4-6-20250514",
- outputTokens: 1_000_000,
- totalTokens: 2_000_000,
- },
- ]);
-
- expect(chartData).toEqual([
- {
- estimatedCost: 18,
- id: "claude-sonnet-4-6-20250514",
- inputTokens: 1_000_000,
- label: "claude-sonnet-4-6-20250514",
- outputTokens: 1_000_000,
- shortLabel: "sonnet-4-6",
- value: 2_000_000,
- },
- ]);
- });
-});
diff --git a/apps/web/src/features/dashboard/use-dashboard-errors-data.ts b/apps/web/src/features/dashboard/use-dashboard-errors-data.ts
deleted file mode 100644
index c9851777..00000000
--- a/apps/web/src/features/dashboard/use-dashboard-errors-data.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useMemo } from "react";
-import { useDateRange } from "@/contexts/DateRangeContext";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
-import { orpc } from "@/lib/orpc";
-
-type UseDashboardErrorsDataOptions = {
- enabled?: boolean;
-};
-
-export function useDashboardErrorsData(
- options: UseDashboardErrorsDataOptions = {},
-) {
- const { activeOrg } = useOrganization();
- const { endDate, startDate } = useDateRange();
- const { data: fullOrganization } = useFullOrganization(activeOrg?.id);
- const isEnabled = options.enabled ?? true;
-
- const errorDashboardQuery = useAnalyticsQuery({
- ...orpc.analytics.errors.dashboard.queryOptions({
- input: { endDate, startDate },
- }),
- enabled: isEnabled,
- });
- const errorProjectTrendQuery = useAnalyticsQuery({
- ...orpc.analytics.errors.trends.queryOptions({
- input: { endDate, startDate, splitBy: "project_path" },
- }),
- enabled: isEnabled,
- });
- const errorDeveloperTrendQuery = useAnalyticsQuery({
- ...orpc.analytics.errors.trends.queryOptions({
- input: { endDate, startDate, splitBy: "user_id" },
- }),
- enabled: isEnabled,
- });
-
- const userLabelById = useMemo(
- () =>
- new Map(
- (fullOrganization?.members ?? []).map((member) => [
- member.userId,
- member.user.name?.trim() ||
- member.user.email?.trim() ||
- member.userId,
- ]),
- ),
- [fullOrganization?.members],
- );
-
- return {
- endDate,
- errorDashboard: errorDashboardQuery.data,
- errorDeveloperTrend: errorDeveloperTrendQuery.data,
- errorProjectTrend: errorProjectTrendQuery.data,
- isPending:
- errorDashboardQuery.isPending ||
- errorProjectTrendQuery.isPending ||
- errorDeveloperTrendQuery.isPending,
- startDate,
- userLabelById,
- };
-}
diff --git a/apps/web/src/features/dashboard/use-dashboard-home-data.ts b/apps/web/src/features/dashboard/use-dashboard-home-data.ts
deleted file mode 100644
index c24ac5ce..00000000
--- a/apps/web/src/features/dashboard/use-dashboard-home-data.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import { useMemo } from "react";
-import { useDateRange } from "@/contexts/DateRangeContext";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { buildDashboardPerformanceUsers } from "@/features/dashboard/data/dashboard-performance-adapter";
-import { mergeDashboardSnapshotWithRoi } from "@/features/dashboard/data/dashboard-roi-adapter";
-import { createDashboardOutputSnapshot } from "@/features/dashboard/data/dashboard-static-data";
-import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
-import { orpc } from "@/lib/orpc";
-
-export function useDashboardHomeData() {
- const { activeOrg } = useOrganization();
- const { endDate, startDate } = useDateRange();
- const { data: fullOrganization } = useFullOrganization(activeOrg?.id);
- const roiDashboardQuery = useAnalyticsQuery(
- orpc.analytics.roi.dashboard.queryOptions({
- input: { startDate, endDate },
- }),
- );
- const usersTokenUsageQuery = useAnalyticsQuery(
- orpc.analytics.overview.usersTokenUsage.queryOptions({
- input: { startDate, endDate },
- }),
- );
- const usersDailyTrendQuery = useAnalyticsQuery(
- orpc.analytics.overview.usersDailyTrend.queryOptions({
- input: { startDate, endDate },
- }),
- );
- const repositoriesDailyTrendQuery = useAnalyticsQuery(
- orpc.analytics.overview.repositoriesDailyTrend.queryOptions({
- input: { startDate, endDate },
- }),
- );
-
- const userImageById = useMemo(
- () =>
- new Map(
- (fullOrganization?.members ?? []).map((member) => [
- member.userId,
- member.user.image,
- ]),
- ),
- [fullOrganization?.members],
- );
-
- const performanceUsers = useMemo(
- () =>
- buildDashboardPerformanceUsers(
- usersTokenUsageQuery.data,
- usersDailyTrendQuery.data,
- userImageById,
- fullOrganization?.members ?? [],
- ),
- [
- fullOrganization?.members,
- userImageById,
- usersDailyTrendQuery.data,
- usersTokenUsageQuery.data,
- ],
- );
-
- const snapshot = useMemo(() => {
- const baseSnapshot = createDashboardOutputSnapshot(startDate, endDate);
-
- return mergeDashboardSnapshotWithRoi(baseSnapshot, roiDashboardQuery.data);
- }, [endDate, roiDashboardQuery.data, startDate]);
-
- return {
- endDate,
- isDashboardSnapshotPending: roiDashboardQuery.isPending,
- isPerformanceChartPending:
- usersTokenUsageQuery.isPending || usersDailyTrendQuery.isPending,
- isRepositoryChartPending: repositoriesDailyTrendQuery.isPending,
- performanceUserDailyTrend: usersDailyTrendQuery.data,
- performanceUsers,
- repositoryDailyTrend: repositoriesDailyTrendQuery.data,
- snapshot,
- startDate,
- };
-}
diff --git a/apps/web/src/features/dashboard/use-dashboard-page-data.ts b/apps/web/src/features/dashboard/use-dashboard-page-data.ts
new file mode 100644
index 00000000..798f3472
--- /dev/null
+++ b/apps/web/src/features/dashboard/use-dashboard-page-data.ts
@@ -0,0 +1,177 @@
+import { useMemo } from "react";
+import { useDateRange } from "@/features/analytics/date-range/useDateRange";
+import { useAnalyticsQuery } from "@/features/analytics/queries/useAnalyticsQuery";
+import { buildDashboardPerformanceUsers } from "@/features/dashboard/data/dashboard-performance-adapter";
+import { mergeDashboardSnapshotWithRoi } from "@/features/dashboard/data/dashboard-roi-adapter";
+import { createDashboardOutputSnapshot } from "@/features/dashboard/data/dashboard-static-data";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+import { useFullOrganization } from "@/hooks/useFullOrganization";
+import { orpc } from "@/lib/orpc";
+
+export function useDashboardPageData() {
+ const { meta, state } = useDateRange();
+ const { state: workspaceState } = useOrganization();
+ const { data: fullOrganization } = useFullOrganization(
+ workspaceState.activeOrg?.id,
+ );
+ const { data: roiDashboard, isPending: isRoiDashboardPending } =
+ useAnalyticsQuery(
+ orpc.analytics.roi.dashboard.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const { data: usersTokenUsage, isPending: isUsersTokenUsagePending } =
+ useAnalyticsQuery(
+ orpc.analytics.overview.usersTokenUsage.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const { data: modelTokensTrend, isPending: isModelTokensTrendPending } =
+ useAnalyticsQuery(
+ orpc.analytics.overview.modelTokensTrend.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const { data: usersDailyTrend, isPending: isUsersDailyTrendPending } =
+ useAnalyticsQuery(
+ orpc.analytics.overview.usersDailyTrend.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const {
+ data: repositoriesDailyTrend,
+ isPending: isRepositoriesDailyTrendPending,
+ } = useAnalyticsQuery(
+ orpc.analytics.overview.repositoriesDailyTrend.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const { data: errorDashboard, isPending: isErrorDashboardPending } =
+ useAnalyticsQuery(
+ orpc.analytics.errors.dashboard.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ },
+ }),
+ );
+ const { data: errorProjectTrend, isPending: isErrorProjectTrendPending } =
+ useAnalyticsQuery(
+ orpc.analytics.errors.trends.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ splitBy: "project_path",
+ },
+ }),
+ );
+ const { data: errorDeveloperTrend, isPending: isErrorDeveloperTrendPending } =
+ useAnalyticsQuery(
+ orpc.analytics.errors.trends.queryOptions({
+ input: {
+ startDate: state.startDate,
+ endDate: state.endDate,
+ splitBy: "user_id",
+ },
+ }),
+ );
+ const {
+ data: sessionSummaryComparison,
+ isPending: isSessionSummaryComparisonPending,
+ } = useAnalyticsQuery(
+ orpc.analytics.sessions.summaryComparison.queryOptions({
+ input: {
+ days: meta.dayCount,
+ },
+ }),
+ );
+ const userImageById = useMemo(
+ () =>
+ new Map(
+ (fullOrganization?.members ?? []).map((member) => [
+ member.userId,
+ member.user.image,
+ ]),
+ ),
+ [fullOrganization?.members],
+ );
+ const userLabelById = useMemo(
+ () =>
+ new Map(
+ (fullOrganization?.members ?? []).map((member) => [
+ member.userId,
+ member.user.name?.trim() ||
+ member.user.email?.trim() ||
+ member.userId,
+ ]),
+ ),
+ [fullOrganization?.members],
+ );
+ const performanceUsers = useMemo(
+ () =>
+ buildDashboardPerformanceUsers(
+ usersTokenUsage,
+ usersDailyTrend,
+ userImageById,
+ fullOrganization?.members ?? [],
+ ),
+ [
+ fullOrganization?.members,
+ userImageById,
+ usersDailyTrend,
+ usersTokenUsage,
+ ],
+ );
+ const baseSnapshot = useMemo(
+ () => createDashboardOutputSnapshot(state.startDate, state.endDate),
+ [state.startDate, state.endDate],
+ );
+ const snapshot = useMemo(
+ () => mergeDashboardSnapshotWithRoi(baseSnapshot, roiDashboard),
+ [baseSnapshot, roiDashboard],
+ );
+
+ return {
+ endDate: state.endDate,
+ isDashboardSnapshotPending: isRoiDashboardPending,
+ isPerformanceChartPending:
+ isUsersTokenUsagePending || isUsersDailyTrendPending,
+ isTokenChartPending:
+ isUsersTokenUsagePending ||
+ isUsersDailyTrendPending ||
+ isModelTokensTrendPending,
+ isSessionSnapshotPending: isSessionSummaryComparisonPending,
+ isRepositoryChartPending: isRepositoriesDailyTrendPending,
+ isErrorDashboardPending:
+ isErrorDashboardPending ||
+ isErrorProjectTrendPending ||
+ isErrorDeveloperTrendPending,
+ errorDashboard,
+ errorProjectTrend,
+ errorDeveloperTrend,
+ modelTokensTrend,
+ performanceUserDailyTrend: usersDailyTrend,
+ performanceUsers,
+ repositoryDailyTrend: repositoriesDailyTrend,
+ sessionSummaryComparison,
+ startDate: state.startDate,
+ snapshot,
+ userLabelById,
+ usersTokenUsage,
+ };
+}
diff --git a/apps/web/src/features/dashboard/use-dashboard-sessions-data.ts b/apps/web/src/features/dashboard/use-dashboard-sessions-data.ts
deleted file mode 100644
index 894561cd..00000000
--- a/apps/web/src/features/dashboard/use-dashboard-sessions-data.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import type { SessionAnalytics } from "@rudel/api-routes";
-import { useMemo } from "react";
-import { useDateRange } from "@/contexts/DateRangeContext";
-import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
-import { orpc } from "@/lib/orpc";
-
-type UseDashboardSessionsDataOptions = {
- enabled?: boolean;
-};
-
-export function useDashboardSessionsData(
- options: UseDashboardSessionsDataOptions = {},
-) {
- const { calculateDays, endDate, startDate } = useDateRange();
- const isEnabled = options.enabled ?? true;
-
- const summaryComparisonQuery = useAnalyticsQuery({
- ...orpc.analytics.sessions.summaryComparison.queryOptions({
- input: {
- days: calculateDays(),
- },
- }),
- enabled: isEnabled,
- });
- const recentSessionsQuery = useAnalyticsQuery({
- ...orpc.analytics.sessions.list.queryOptions({
- input: {
- endDate,
- limit: 10,
- startDate,
- sortBy: "session_date",
- sortOrder: "desc",
- },
- }),
- enabled: isEnabled,
- });
-
- const recentSessions = useMemo(
- () =>
- [...(recentSessionsQuery.data ?? [])].sort(
- (left: SessionAnalytics, right: SessionAnalytics) =>
- new Date(right.session_date).getTime() -
- new Date(left.session_date).getTime(),
- ),
- [recentSessionsQuery.data],
- );
-
- return {
- isRecentSessionsPending: recentSessionsQuery.isPending,
- isSessionSummaryPending: summaryComparisonQuery.isPending,
- recentSessions,
- sessionSummaryComparison: summaryComparisonQuery.data,
- };
-}
diff --git a/apps/web/src/features/dashboard/use-dashboard-tokens-data.ts b/apps/web/src/features/dashboard/use-dashboard-tokens-data.ts
deleted file mode 100644
index a06ba1cd..00000000
--- a/apps/web/src/features/dashboard/use-dashboard-tokens-data.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { useMemo } from "react";
-import { useDateRange } from "@/contexts/DateRangeContext";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { buildDashboardPerformanceUsers } from "@/features/dashboard/data/dashboard-performance-adapter";
-import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
-import { orpc } from "@/lib/orpc";
-
-type UseDashboardTokensDataOptions = {
- enabled?: boolean;
-};
-
-export function useDashboardTokensData(
- options: UseDashboardTokensDataOptions = {},
-) {
- const { activeOrg } = useOrganization();
- const { endDate, startDate } = useDateRange();
- const { data: fullOrganization } = useFullOrganization(activeOrg?.id);
- const isEnabled = options.enabled ?? true;
-
- const usersTokenUsageQuery = useAnalyticsQuery({
- ...orpc.analytics.overview.usersTokenUsage.queryOptions({
- input: { endDate, startDate },
- }),
- enabled: isEnabled,
- });
- const modelTokensTrendQuery = useAnalyticsQuery({
- ...orpc.analytics.overview.modelTokensTrend.queryOptions({
- input: { endDate, startDate },
- }),
- enabled: isEnabled,
- });
- const usersDailyTrendQuery = useAnalyticsQuery({
- ...orpc.analytics.overview.usersDailyTrend.queryOptions({
- input: { endDate, startDate },
- }),
- enabled: isEnabled,
- });
-
- const userImageById = useMemo(
- () =>
- new Map(
- (fullOrganization?.members ?? []).map((member) => [
- member.userId,
- member.user.image,
- ]),
- ),
- [fullOrganization?.members],
- );
- const performanceUsers = useMemo(
- () =>
- buildDashboardPerformanceUsers(
- usersTokenUsageQuery.data,
- usersDailyTrendQuery.data,
- userImageById,
- fullOrganization?.members ?? [],
- ),
- [
- fullOrganization?.members,
- userImageById,
- usersDailyTrendQuery.data,
- usersTokenUsageQuery.data,
- ],
- );
-
- return {
- endDate,
- isDeveloperChartPending:
- usersTokenUsageQuery.isPending || usersDailyTrendQuery.isPending,
- isSnapshotPending:
- usersTokenUsageQuery.isPending ||
- usersDailyTrendQuery.isPending ||
- modelTokensTrendQuery.isPending,
- modelTokensTrend: modelTokensTrendQuery.data,
- performanceUserDailyTrend: usersDailyTrendQuery.data,
- performanceUsers,
- startDate,
- usersTokenUsage: usersTokenUsageQuery.data,
- };
-}
diff --git a/apps/web/src/features/invitations/AcceptInvitationPage.tsx b/apps/web/src/features/invitations/AcceptInvitationPage.tsx
new file mode 100644
index 00000000..62061162
--- /dev/null
+++ b/apps/web/src/features/invitations/AcceptInvitationPage.tsx
@@ -0,0 +1,165 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { Building2, Check, Loader2, X } from "lucide-react";
+import { useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+import { Button } from "@/app/ui/button";
+import { useMountEffect } from "@/app/hooks/useMountEffect";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
+import { USER_INVITATIONS_KEY } from "@/features/workspace/hooks/useUserInvitations";
+import { authClient } from "@/lib/auth-client";
+
+function InvitationLoginRedirectMount({
+ invitationId,
+}: {
+ invitationId: string;
+}) {
+ useMountEffect(() => {
+ const returnUrl = `/invitation/${invitationId}`;
+ window.location.href = `/?redirect=${encodeURIComponent(returnUrl)}`;
+ });
+
+ return null;
+}
+
+export function AcceptInvitationPage() {
+ const { invitationId } = useParams<{ invitationId: string }>();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { data: session, isPending: sessionLoading } = authClient.useSession();
+ const { trackAuthenticationAction } = useAnalyticsTracking({
+ pageName: "accept_invitation",
+ });
+ const [status, setStatus] = useState<"idle" | "accepting" | "accepted" | "error">(
+ "idle",
+ );
+ const [error, setError] = useState(null);
+
+ const handleAccept = async () => {
+ if (!invitationId) return;
+ trackAuthenticationAction({
+ actionName: "accept_invitation",
+ sourceComponent: "accept_invitation_page",
+ authMethod: "invitation",
+ targetId: invitationId,
+ userId:
+ session?.user && "id" in session.user
+ ? String(session.user.id)
+ : undefined,
+ });
+ setStatus("accepting");
+ setError(null);
+
+ const res = await authClient.organization.acceptInvitation({
+ invitationId,
+ });
+
+ if (res.error) {
+ setError(res.error.message ?? "Failed to accept invitation");
+ setStatus("error");
+ return;
+ }
+
+ if (res.data) {
+ await authClient.organization.setActive({
+ organizationId: res.data.member.organizationId,
+ });
+ }
+
+ setStatus("accepted");
+ queryClient.invalidateQueries({ queryKey: USER_INVITATIONS_KEY });
+ setTimeout(() => navigate("/dashboard"), 1500);
+ };
+
+ const handleReject = async () => {
+ if (!invitationId) return;
+ trackAuthenticationAction({
+ actionName: "decline_invitation",
+ sourceComponent: "accept_invitation_page",
+ authMethod: "invitation",
+ targetId: invitationId,
+ userId:
+ session?.user && "id" in session.user
+ ? String(session.user.id)
+ : undefined,
+ });
+ await authClient.organization.rejectInvitation({ invitationId });
+ navigate("/dashboard");
+ };
+
+ if (sessionLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!session && invitationId) {
+ return (
+ <>
+
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+ {status === "accepted" ? (
+
+ ) : (
+
+ )}
+
+
+ {status === "accepted" ? (
+ <>
+
+ Welcome to the team!
+
+
Redirecting to dashboard...
+ >
+ ) : (
+ <>
+
+ You've been invited
+
+
+ Accept this invitation to join the organization.
+
+
+ {error &&
{error}
}
+
+
+
+
+ Decline
+
+
+ {status === "accepting" ? (
+
+ ) : (
+
+ )}
+ Accept
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/settings/SettingsIndexRedirect.tsx b/apps/web/src/features/settings/SettingsIndexRedirect.tsx
new file mode 100644
index 00000000..7a66f52f
--- /dev/null
+++ b/apps/web/src/features/settings/SettingsIndexRedirect.tsx
@@ -0,0 +1,19 @@
+import { Navigate, useSearchParams } from "react-router-dom";
+import { getSettingsPathFromLegacyTab } from "@/features/settings/config/settings-routes";
+
+export function SettingsIndexRedirect() {
+ const [searchParams] = useSearchParams();
+ const nextSearchParams = new URLSearchParams(searchParams);
+ const nextPath = getSettingsPathFromLegacyTab(searchParams.get("tab"));
+
+ nextSearchParams.delete("tab");
+
+ const queryString = nextSearchParams.toString();
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/features/settings/SettingsLayout.tsx b/apps/web/src/features/settings/SettingsLayout.tsx
new file mode 100644
index 00000000..85e57fd5
--- /dev/null
+++ b/apps/web/src/features/settings/SettingsLayout.tsx
@@ -0,0 +1,5 @@
+import { Outlet } from "react-router-dom";
+
+export function SettingsLayout() {
+ return ;
+}
diff --git a/apps/web/src/features/settings/account/AccountSettingsSection.tsx b/apps/web/src/features/settings/account/AccountSettingsSection.tsx
index 64476f7b..57fab40e 100644
--- a/apps/web/src/features/settings/account/AccountSettingsSection.tsx
+++ b/apps/web/src/features/settings/account/AccountSettingsSection.tsx
@@ -1,88 +1,44 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
+import { appRoutes } from "@/app/routes";
import { Card, CardContent } from "@/app/ui/card";
import { Skeleton } from "@/app/ui/skeleton";
-import { ProfileActionsCard } from "@/features/settings/account/components/ProfileActionsCard";
-import { ProfileAppearanceCard } from "@/features/settings/account/components/ProfileAppearanceCard";
+import { PageViewTrackingMount } from "@/features/analytics/tracking/PageViewTrackingMount";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
import { ProfileLinkedAccountsCard } from "@/features/settings/account/components/ProfileLinkedAccountsCard";
-import { ProfileSummaryCard } from "@/features/settings/account/components/ProfileSummaryCard";
+import { ProfileOverviewCard } from "@/features/settings/account/components/ProfileOverviewCard";
import { useAccountSettingsData } from "@/features/settings/account/use-account-settings-data";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import {
- type DashboardMetric,
- type DashboardSection,
- useTrackDashboardView,
-} from "@/hooks/useTrackDashboardView";
import { authClient, signOut } from "@/lib/auth-client";
export function AccountSettingsSection() {
const navigate = useNavigate();
const data = useAccountSettingsData();
- const { trackAuthenticationAction } = useAnalyticsTracking({
- pageName: "profile",
- });
- const [isSigningOut, setIsSigningOut] = useState(false);
+ const { trackAuthenticationAction } = useAnalyticsTracking();
const [linkingProvider, setLinkingProvider] = useState(null);
- const metrics: DashboardMetric[] = [
- {
- id: "linked_accounts",
- value: data.linkedProviders.size,
- },
- ];
- const sections: DashboardSection[] = [
- {
- id: "profile_summary",
- state: data.state.hasData ? "populated" : "empty",
- },
- {
- id: "profile_appearance",
- state: data.state.isPending ? "hidden" : "populated",
- },
- {
- id: "profile_actions",
- state: data.state.isPending ? "hidden" : "populated",
- },
- {
- id: "linked_accounts",
- itemCount: data.linkedProviders.size,
- state: data.state.isPending
- ? "hidden"
- : data.linkedProviders.size > 0
- ? "populated"
- : "empty",
- },
- ];
-
- useTrackDashboardView({
- hasData: data.state.hasData,
- isLoading: data.state.isPending,
- metrics,
- sections,
- });
+ const [isSigningOut, setIsSigningOut] = useState(false);
- function handleLinkProvider(provider: "google" | "github") {
+ const handleLinkProvider = (provider: "google" | "github") => {
trackAuthenticationAction({
actionName: "link_provider",
- authMethod: provider,
sourceComponent: "account_settings_section",
targetId: provider,
+ authMethod: provider,
});
setLinkingProvider(provider);
authClient.linkSocial({
- callbackURL: `${window.location.origin}/dashboard/profile`,
provider,
+ callbackURL: `${window.location.origin}${appRoutes.settingsAccount()}`,
});
- }
+ };
- async function handleSignOut() {
+ const handleSignOut = async () => {
trackAuthenticationAction({
actionName: "sign_out",
- authMethod: "session",
sourceComponent: "account_settings_section",
+ authMethod: "session",
});
setIsSigningOut(true);
-
try {
await signOut();
navigate("/");
@@ -92,12 +48,37 @@ export function AccountSettingsSection() {
cause instanceof Error ? cause.message : "Failed to sign out",
);
}
- }
+ };
- if (data.state.isPending) {
- return (
-
-
+ return (
+ <>
+
0
+ ? "populated"
+ : "empty",
+ itemCount: data.linkedProviders.size,
+ },
+ ]}
+ />
+ {data.state.isPending ? (
+
-
-
-
-
+
-
+ {["provider-1", "provider-2", "provider-3"].map((key) => (
+
+ ))}
-
-
- {["provider-1", "provider-2", "provider-3"].map((key) => (
-
- ))}
-
-
-
- );
- }
-
- return (
-
-
-
-
- Account settings
-
-
- Manage your profile identity, browser appearance, and linked sign-in
- methods.
-
+ ) : (
+
+
void handleSignOut()}
+ />
+
-
-
-
-
void handleSignOut()}
- />
-
-
-
+ )}
+ >
);
}
diff --git a/apps/web/src/features/settings/account/components/ProfileActionsCard.tsx b/apps/web/src/features/settings/account/components/ProfileActionsCard.tsx
index ec279853..becab73b 100644
--- a/apps/web/src/features/settings/account/components/ProfileActionsCard.tsx
+++ b/apps/web/src/features/settings/account/components/ProfileActionsCard.tsx
@@ -18,17 +18,15 @@ export function ProfileActionsCard({
return (
- Session
-
- Sign out from this device without affecting other sessions.
-
+ Account actions
+ Session-level actions for this browser.
{isSigningOut ? "Signing out…" : "Sign out"}
diff --git a/apps/web/src/features/settings/account/components/ProfileAppearanceCard.tsx b/apps/web/src/features/settings/account/components/ProfileAppearanceCard.tsx
index c0845598..98e77a8b 100644
--- a/apps/web/src/features/settings/account/components/ProfileAppearanceCard.tsx
+++ b/apps/web/src/features/settings/account/components/ProfileAppearanceCard.tsx
@@ -1,64 +1,46 @@
+"use client";
+
import { MonitorCogIcon, MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "next-themes";
-import { useEffect, useState } from "react";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/app/ui/card";
-import { Skeleton } from "@/app/ui/skeleton";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/ui/card";
import { ToggleGroup, ToggleGroupItem } from "@/app/ui/toggle-group";
export function ProfileAppearanceCard() {
- const { setTheme, theme } = useTheme();
- const [isMounted, setIsMounted] = useState(false);
-
- useEffect(() => {
- setIsMounted(true);
- }, []);
-
- const selectedTheme = isMounted ? (theme ?? "system") : "system";
+ const { theme, setTheme } = useTheme();
+ const selectedTheme = theme ?? "light";
return (
Appearance
-
- Choose how the app looks in this browser.
-
+ Choose how the app looks in this browser.
- {isMounted ? (
- {
- const nextTheme = nextValue[0];
- if (nextTheme) {
- setTheme(nextTheme);
- }
- }}
- size="sm"
- value={[selectedTheme]}
- variant="outline"
- >
-
-
- Light
-
-
-
- Dark
-
-
-
- System
-
-
- ) : (
-
- )}
+ {
+ const nextTheme = nextValue[0];
+ if (nextTheme) {
+ setTheme(nextTheme);
+ }
+ }}
+ variant="outline"
+ size="sm"
+ className="w-full"
+ >
+
+
+ Light
+
+
+
+ Dark
+
+
+
+ System
+
+
);
diff --git a/apps/web/src/features/settings/account/components/ProfileLinkedAccountsCard.tsx b/apps/web/src/features/settings/account/components/ProfileLinkedAccountsCard.tsx
index 28301d0f..d14233c1 100644
--- a/apps/web/src/features/settings/account/components/ProfileLinkedAccountsCard.tsx
+++ b/apps/web/src/features/settings/account/components/ProfileLinkedAccountsCard.tsx
@@ -11,12 +11,12 @@ import {
} from "@/app/ui/card";
const providerDefinitions: Array<{
- icon: LucideIcon;
id: "google" | "github";
label: string;
+ icon: LucideIcon;
}> = [
- { icon: MailIcon, id: "google", label: "Google" },
- { icon: GithubIcon, id: "github", label: "GitHub" },
+ { id: "google", label: "Google", icon: MailIcon },
+ { id: "github", label: "GitHub", icon: GithubIcon },
];
export function ProfileLinkedAccountsCard({
@@ -68,8 +68,8 @@ export function ProfileLinkedAccountsCard({
return (
@@ -88,16 +88,16 @@ export function ProfileLinkedAccountsCard({
Connected
) : (
onLinkProvider(provider.id)}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => onLinkProvider(provider.id)}
+ disabled={isPending || linkingProvider !== null}
>
{isLinking ? (
) : null}
Link
diff --git a/apps/web/src/features/settings/account/components/ProfileOverviewCard.tsx b/apps/web/src/features/settings/account/components/ProfileOverviewCard.tsx
new file mode 100644
index 00000000..0f9ee34d
--- /dev/null
+++ b/apps/web/src/features/settings/account/components/ProfileOverviewCard.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { LogOutIcon } from "lucide-react";
+import { Avatar, AvatarFallback, AvatarImage } from "@/app/ui/avatar";
+import { Button } from "@/app/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/app/ui/card";
+
+function getInitials(name: string, email: string) {
+ const source = name.trim() || email.trim() || "R";
+ return source
+ .split(" ")
+ .map((part) => part[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+}
+
+export function ProfileOverviewCard({
+ email,
+ image,
+ isSigningOut,
+ name,
+ onSignOut,
+}: {
+ email: string;
+ image: string | null;
+ isSigningOut: boolean;
+ name: string;
+ onSignOut: () => void;
+}) {
+ return (
+
+
+ Profile
+
+ Your account identity and session access for this browser.
+
+
+
+
+
+ {image ? : null}
+ {getInitials(name, email)}
+
+
+
+
+
+
+
Session
+
+ Sign out from this device without affecting other sessions.
+
+
+
+
+ {isSigningOut ? "Signing out…" : "Sign out"}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/features/settings/account/components/ProfileSummaryCard.tsx b/apps/web/src/features/settings/account/components/ProfileSummaryCard.tsx
index 3dd0896b..cacec2d8 100644
--- a/apps/web/src/features/settings/account/components/ProfileSummaryCard.tsx
+++ b/apps/web/src/features/settings/account/components/ProfileSummaryCard.tsx
@@ -1,5 +1,8 @@
-import { Avatar, AvatarFallback, AvatarImage } from "@/app/ui/avatar";
-import { Badge } from "@/app/ui/badge";
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@/app/ui/avatar";
import {
Card,
CardContent,
@@ -10,7 +13,6 @@ import {
function getInitials(name: string, email: string) {
const source = name.trim() || email.trim() || "R";
-
return source
.split(" ")
.map((part) => part[0])
@@ -20,34 +22,31 @@ function getInitials(name: string, email: string) {
}
export function ProfileSummaryCard({
+ name,
email,
image,
- name,
}: {
+ name: string;
email: string;
image: string | null;
- name: string;
}) {
return (
Profile
-
- Your account identity in this workspace.
-
+ Your account identity in the workspace.
-
-
- {image ? : null}
- {getInitials(name, email)}
-
-
-
{name}
-
- {email}
-
+
+
+
+ {image ? : null}
+ {getInitials(name, email)}
+
+
+ {name}
+ {email}
+
- Active
);
diff --git a/apps/web/src/features/settings/account/use-account-settings-data.ts b/apps/web/src/features/settings/account/use-account-settings-data.ts
index f61a90ab..b92c0685 100644
--- a/apps/web/src/features/settings/account/use-account-settings-data.ts
+++ b/apps/web/src/features/settings/account/use-account-settings-data.ts
@@ -1,31 +1,33 @@
-import { useAccounts } from "@/hooks/useAccounts";
+import { useAccounts } from "@/features/workspace/hooks/useAccounts";
import { authClient } from "@/lib/auth-client";
-function readSessionString(value: unknown, fallback = "") {
+function readSessionString(
+ value: unknown,
+ fallback = "",
+): string {
return typeof value === "string" ? value : fallback;
}
export function useAccountSettingsData() {
- const { data: session, isPending: isSessionPending } =
- authClient.useSession();
+ const { data: session, isPending: isSessionPending } = authClient.useSession();
const { accounts, isLoading: areAccountsPending } = useAccounts();
const user = {
- email: readSessionString(session?.user?.email),
id: readSessionString(session?.user?.id),
- image: typeof session?.user?.image === "string" ? session.user.image : null,
name: readSessionString(session?.user?.name, "Your profile"),
+ email: readSessionString(session?.user?.email),
+ image:
+ typeof session?.user?.image === "string" ? session.user.image : null,
};
- const linkedProviders = new Set(
- accounts.map((account) => account.providerId),
- );
+
+ const linkedProviders = new Set(accounts.map((account) => account.providerId));
return {
+ user,
linkedProviders,
state: {
- hasData: Boolean(user.id),
isPending: isSessionPending || areAccountsPending,
+ hasData: Boolean(user.id),
},
- user,
};
}
diff --git a/apps/web/src/features/settings/config/settings-routes.ts b/apps/web/src/features/settings/config/settings-routes.ts
new file mode 100644
index 00000000..bf07a2e5
--- /dev/null
+++ b/apps/web/src/features/settings/config/settings-routes.ts
@@ -0,0 +1,73 @@
+import { appRoutes } from "@/app/routes";
+
+export type SettingsRouteId =
+ | "workspace"
+ | "invitations"
+ | "account"
+ | "create-workspace";
+
+export type SettingsRouteDefinition = {
+ id: SettingsRouteId;
+ label: string;
+ segment: string;
+ path: string;
+};
+
+export const settingsRouteMap: Record<
+ SettingsRouteId,
+ SettingsRouteDefinition
+> = {
+ workspace: {
+ id: "workspace",
+ label: "Workspace",
+ segment: "workspace",
+ path: appRoutes.settingsWorkspace(),
+ },
+ invitations: {
+ id: "invitations",
+ label: "Invitations",
+ segment: "invitations",
+ path: appRoutes.settingsInvitations(),
+ },
+ account: {
+ id: "account",
+ label: "Profile",
+ segment: "account",
+ path: appRoutes.settingsAccount(),
+ },
+ "create-workspace": {
+ id: "create-workspace",
+ label: "Create workspace",
+ segment: "create-workspace",
+ path: appRoutes.settingsCreateWorkspace(),
+ },
+};
+
+const settingsRoutes = Object.values(settingsRouteMap);
+
+export type PrimarySettingsRouteId = "workspace" | "account";
+
+export const primarySettingsRoutes = [
+ settingsRouteMap.workspace,
+ settingsRouteMap.account,
+] as const satisfies readonly SettingsRouteDefinition[];
+
+export function getActiveSettingsRouteId(
+ pathname: string,
+): PrimarySettingsRouteId {
+ const matchedRoute =
+ settingsRoutes.find(
+ (route) =>
+ pathname === route.path || pathname.startsWith(`${route.path}/`),
+ )?.id ?? "workspace";
+
+ return matchedRoute === "account" ? "account" : "workspace";
+}
+
+export function getSettingsPathFromLegacyTab(tab: string | null): string {
+ if (tab === "account") {
+ return settingsRouteMap.account.path;
+ }
+
+ return settingsRouteMap.workspace.path;
+}
diff --git a/apps/web/src/features/settings/create-workspace/CreateWorkspacePage.tsx b/apps/web/src/features/settings/create-workspace/CreateWorkspacePage.tsx
new file mode 100644
index 00000000..daf019af
--- /dev/null
+++ b/apps/web/src/features/settings/create-workspace/CreateWorkspacePage.tsx
@@ -0,0 +1,5 @@
+import { CreateWorkspaceSettingsSection } from "@/features/settings/workspace/CreateWorkspaceSettingsSection";
+
+export function CreateWorkspacePage() {
+ return
;
+}
diff --git a/apps/web/src/features/settings/invitations/InvitationsSettingsPage.tsx b/apps/web/src/features/settings/invitations/InvitationsSettingsPage.tsx
new file mode 100644
index 00000000..9e00f47b
--- /dev/null
+++ b/apps/web/src/features/settings/invitations/InvitationsSettingsPage.tsx
@@ -0,0 +1,5 @@
+import { InvitationsSettingsSection } from "@/features/settings/invitations/InvitationsSettingsSection";
+
+export function InvitationsSettingsPage() {
+ return
;
+}
diff --git a/apps/web/src/features/settings/invitations/InvitationsSettingsSection.tsx b/apps/web/src/features/settings/invitations/InvitationsSettingsSection.tsx
new file mode 100644
index 00000000..24934f76
--- /dev/null
+++ b/apps/web/src/features/settings/invitations/InvitationsSettingsSection.tsx
@@ -0,0 +1,152 @@
+import { useState } from "react";
+import { toast } from "sonner";
+import { Card, CardContent } from "@/app/ui/card";
+import { Skeleton } from "@/app/ui/skeleton";
+import { PageViewTrackingMount } from "@/features/analytics/tracking/PageViewTrackingMount";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
+import { InvitationsCards } from "@/features/settings/invitations/components/InvitationsCards";
+import { useInvitationsSettingsData } from "@/features/settings/invitations/use-invitations-settings-data";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+import { authClient } from "@/lib/auth-client";
+
+const invitationSkeletonKeys = [
+ "invitation-skeleton-1",
+ "invitation-skeleton-2",
+ "invitation-skeleton-3",
+] as const;
+
+export function InvitationsSettingsSection() {
+ const data = useInvitationsSettingsData();
+ const { actions } = useOrganization();
+ const { trackAuthenticationAction } = useAnalyticsTracking({
+ pageName: "invitations",
+ });
+ const [processingId, setProcessingId] = useState
(null);
+
+ const handleAccept = async (invitationId: string) => {
+ trackAuthenticationAction({
+ actionName: "accept_invitation",
+ sourceComponent: "invitations_settings_section",
+ authMethod: "invitation",
+ targetId: invitationId,
+ });
+ setProcessingId(invitationId);
+
+ try {
+ const response = await authClient.organization.acceptInvitation({
+ invitationId,
+ });
+ if (response.data) {
+ try {
+ await actions.switchOrganization(response.data.member.organizationId);
+ } catch (cause) {
+ toast.error(
+ cause instanceof Error
+ ? cause.message
+ : "Invitation accepted but workspace switch failed",
+ );
+ }
+ }
+ data.invalidate();
+ } catch (cause) {
+ toast.error(
+ cause instanceof Error ? cause.message : "Failed to accept invitation",
+ );
+ } finally {
+ setProcessingId(null);
+ }
+ };
+
+ const handleDecline = async (invitationId: string) => {
+ trackAuthenticationAction({
+ actionName: "decline_invitation",
+ sourceComponent: "invitations_settings_section",
+ authMethod: "invitation",
+ targetId: invitationId,
+ });
+ setProcessingId(invitationId);
+
+ try {
+ await authClient.organization.rejectInvitation({ invitationId });
+ data.invalidate();
+ } catch (cause) {
+ toast.error(
+ cause instanceof Error ? cause.message : "Failed to decline invitation",
+ );
+ } finally {
+ setProcessingId(null);
+ }
+ };
+
+ return (
+ <>
+
+
+ {data.state.isPending ? (
+
+ {invitationSkeletonKeys.map((key) => (
+
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : null}
+
+ {!data.state.isPending && !data.state.hasData ? (
+
+
+
+ No pending invitations
+
+ When another workspace invites you, it will show up here.
+
+
+ ) : null}
+
+ {!data.state.isPending && data.state.hasData ? (
+
void handleAccept(invitationId)}
+ onDecline={(invitationId) => void handleDecline(invitationId)}
+ />
+ ) : null}
+
+ >
+ );
+}
diff --git a/apps/web/src/features/settings/invitations/components/InvitationsCards.tsx b/apps/web/src/features/settings/invitations/components/InvitationsCards.tsx
new file mode 100644
index 00000000..27180630
--- /dev/null
+++ b/apps/web/src/features/settings/invitations/components/InvitationsCards.tsx
@@ -0,0 +1,92 @@
+import { Badge } from "@/app/ui/badge";
+import { Button } from "@/app/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/app/ui/card";
+import { Building2Icon, CheckIcon, XIcon } from "lucide-react";
+
+function formatDate(value: string | Date) {
+ const date = value instanceof Date ? value : new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "Unknown date";
+ }
+
+ return date.toLocaleDateString();
+}
+
+export function InvitationsCards({
+ invitations,
+ processingId,
+ onAccept,
+ onDecline,
+}: {
+ invitations: readonly {
+ id: string;
+ organizationName: string;
+ role: string;
+ createdAt: string | Date;
+ }[];
+ processingId: string | null;
+ onAccept: (invitationId: string) => void;
+ onDecline: (invitationId: string) => void;
+}) {
+ return (
+
+ {invitations.map((invitation) => {
+ const isProcessing = processingId === invitation.id;
+
+ return (
+
+
+
+
+
+
+
+
+ {invitation.organizationName}
+
+
+ Invited {formatDate(invitation.createdAt)}
+
+
+
+ {invitation.role}
+
+
+
+
+ onDecline(invitation.id)}
+ disabled={isProcessing}
+ >
+
+ {isProcessing ? "Working…" : "Decline"}
+
+ onAccept(invitation.id)}
+ disabled={isProcessing}
+ >
+
+ {isProcessing ? "Working…" : "Accept"}
+
+
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/web/src/features/settings/invitations/use-invitations-settings-data.ts b/apps/web/src/features/settings/invitations/use-invitations-settings-data.ts
index 50b8abb0..deccff89 100644
--- a/apps/web/src/features/settings/invitations/use-invitations-settings-data.ts
+++ b/apps/web/src/features/settings/invitations/use-invitations-settings-data.ts
@@ -1,7 +1,7 @@
-import { useUserInvitations } from "@/hooks/useUserInvitations";
+import { useUserInvitations } from "@/features/workspace/hooks/useUserInvitations";
export function useInvitationsSettingsData() {
- const { invitations, count, isLoading, invalidate } = useUserInvitations();
+ const { invitations, count, invalidate, isLoading } = useUserInvitations();
return {
invitations,
@@ -9,6 +9,7 @@ export function useInvitationsSettingsData() {
invalidate,
state: {
isPending: isLoading,
+ hasData: invitations.length > 0,
},
};
}
diff --git a/apps/web/src/features/settings/workspace/CreateWorkspaceSettingsSection.tsx b/apps/web/src/features/settings/workspace/CreateWorkspaceSettingsSection.tsx
new file mode 100644
index 00000000..4f375968
--- /dev/null
+++ b/apps/web/src/features/settings/workspace/CreateWorkspaceSettingsSection.tsx
@@ -0,0 +1,152 @@
+import { Building2Icon, Loader2Icon } from "lucide-react";
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { appRoutes } from "@/app/routes";
+import { Button } from "@/app/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/app/ui/card";
+import { Field, FieldLabel } from "@/app/ui/field";
+import { Input } from "@/app/ui/input";
+import { PageViewTrackingMount } from "@/features/analytics/tracking/PageViewTrackingMount";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+import { authClient } from "@/lib/auth-client";
+
+function slugify(value: string) {
+ return value
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .slice(0, 48);
+}
+
+export function CreateWorkspaceSettingsSection() {
+ const navigate = useNavigate();
+ const { actions } = useOrganization();
+ const { trackOrganizationAction } = useAnalyticsTracking({
+ pageName: "organization_create",
+ });
+ const [name, setName] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmedName = name.trim();
+ const slug = slugify(trimmedName);
+
+ if (!trimmedName || !slug) {
+ return;
+ }
+
+ trackOrganizationAction({
+ actionName: "create_organization",
+ targetType: "organization",
+ sourceComponent: "create_workspace_settings_section",
+ targetId: slug,
+ });
+ setIsCreating(true);
+ setError(null);
+
+ const response = await authClient.organization.create({
+ name: trimmedName,
+ slug,
+ });
+
+ if (response.error) {
+ setError(response.error.message ?? "Failed to create workspace");
+ setIsCreating(false);
+ return;
+ }
+
+ if (response.data) {
+ await actions.switchOrganization(response.data.id);
+ navigate(appRoutes.settingsWorkspace());
+ }
+
+ setIsCreating(false);
+ };
+
+ const slugPreview = slugify(name.trim());
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ Workspace identity
+
+ The URL slug is generated automatically from the name.
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/features/settings/workspace/WorkspaceSettingsSection.tsx b/apps/web/src/features/settings/workspace/WorkspaceSettingsSection.tsx
index ebe0b91f..a30af828 100644
--- a/apps/web/src/features/settings/workspace/WorkspaceSettingsSection.tsx
+++ b/apps/web/src/features/settings/workspace/WorkspaceSettingsSection.tsx
@@ -2,8 +2,15 @@ import { useState } from "react";
import { toast } from "sonner";
import { Card, CardContent } from "@/app/ui/card";
import { Skeleton } from "@/app/ui/skeleton";
+import {
+ type PageMetric,
+ type PageSection,
+ PageViewTrackingMount,
+} from "@/features/analytics/tracking/PageViewTrackingMount";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
import { useInvitationsSettingsData } from "@/features/settings/invitations/use-invitations-settings-data";
import { CreateWorkspaceCard } from "@/features/settings/workspace/components/CreateWorkspaceCard";
+import { WorkspaceDangerZoneCard } from "@/features/settings/workspace/components/WorkspaceDangerZoneCard";
import { WorkspaceEmptyStateCard } from "@/features/settings/workspace/components/WorkspaceEmptyStateCard";
import { WorkspaceIdentityCard } from "@/features/settings/workspace/components/WorkspaceIdentityCard";
import { WorkspaceIncomingInvitationsCard } from "@/features/settings/workspace/components/WorkspaceIncomingInvitationsCard";
@@ -12,85 +19,29 @@ import { WorkspaceMembersCard } from "@/features/settings/workspace/components/W
import { WorkspaceOutgoingInvitationsCard } from "@/features/settings/workspace/components/WorkspaceOutgoingInvitationsCard";
import { WorkspaceSummaryStrip } from "@/features/settings/workspace/components/WorkspaceSummaryStrip";
import { useWorkspaceSettingsData } from "@/features/settings/workspace/use-workspace-settings-data";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import {
- type DashboardMetric,
- type DashboardSection,
- useTrackDashboardView,
-} from "@/hooks/useTrackDashboardView";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
import { authClient } from "@/lib/auth-client";
export function WorkspaceSettingsSection() {
const data = useWorkspaceSettingsData();
const invitationsData = useInvitationsSettingsData();
+ const { actions } = useOrganization();
const { trackAuthenticationAction } = useAnalyticsTracking({
- organizationId: data.activeOrg?.id ?? null,
pageName: "organization",
+ organizationId: data.activeOrg?.id ?? null,
});
const [processingInvitationId, setProcessingInvitationId] = useState<
string | null
>(null);
-
const memberCount = data.fullOrg?.members.length ?? 0;
const pendingOutgoingInvitationCount = data.pendingInvitations.length;
const pendingIncomingInvitationCount = invitationsData.count;
- const trackingMetrics: DashboardMetric[] = [
- { id: "members", value: memberCount },
- {
- id: "pending_outgoing_invitations",
- value: pendingOutgoingInvitationCount,
- },
- {
- id: "pending_incoming_invitations",
- value: pendingIncomingInvitationCount,
- },
- ];
- const trackingSections: DashboardSection[] = [
- {
- id: "organization_identity",
- state: data.state.hasOrganization ? "populated" : "empty",
- },
- {
- id: "organization_members",
- state: data.state.isPending
- ? "hidden"
- : memberCount > 0
- ? "populated"
- : "empty",
- itemCount: memberCount,
- },
- {
- id: "organization_outgoing_invitations",
- state: data.state.isPending
- ? "hidden"
- : pendingOutgoingInvitationCount > 0
- ? "populated"
- : "empty",
- itemCount: pendingOutgoingInvitationCount,
- },
- {
- id: "incoming_invitations",
- state: invitationsData.state.isPending
- ? "hidden"
- : pendingIncomingInvitationCount > 0
- ? "populated"
- : "empty",
- itemCount: pendingIncomingInvitationCount,
- },
- ];
- useTrackDashboardView({
- isLoading: data.state.isPending || invitationsData.state.isPending,
- hasData: data.state.hasOrganization || pendingIncomingInvitationCount > 0,
- metrics: trackingMetrics,
- sections: trackingSections,
- });
-
- async function handleAcceptInvitation(invitationId: string) {
+ const handleAcceptInvitation = async (invitationId: string) => {
trackAuthenticationAction({
actionName: "accept_invitation",
- authMethod: "invitation",
sourceComponent: "workspace_settings_section",
+ authMethod: "invitation",
targetId: invitationId,
});
setProcessingInvitationId(invitationId);
@@ -99,11 +50,17 @@ export function WorkspaceSettingsSection() {
const response = await authClient.organization.acceptInvitation({
invitationId,
});
-
if (response.data) {
- await data.switchOrg(response.data.member.organizationId);
+ try {
+ await actions.switchOrganization(response.data.member.organizationId);
+ } catch (cause) {
+ toast.error(
+ cause instanceof Error
+ ? cause.message
+ : "Invitation accepted but workspace switch failed",
+ );
+ }
}
-
data.invalidate();
invitationsData.invalidate();
} catch (cause) {
@@ -113,13 +70,13 @@ export function WorkspaceSettingsSection() {
} finally {
setProcessingInvitationId(null);
}
- }
+ };
- async function handleDeclineInvitation(invitationId: string) {
+ const handleDeclineInvitation = async (invitationId: string) => {
trackAuthenticationAction({
actionName: "decline_invitation",
- authMethod: "invitation",
sourceComponent: "workspace_settings_section",
+ authMethod: "invitation",
targetId: invitationId,
});
setProcessingInvitationId(invitationId);
@@ -134,19 +91,80 @@ export function WorkspaceSettingsSection() {
} finally {
setProcessingInvitationId(null);
}
- }
+ };
+
+ const trackingMetrics: PageMetric[] = [
+ {
+ id: "members",
+ value: memberCount,
+ },
+ {
+ id: "pending_outgoing_invitations",
+ value: pendingOutgoingInvitationCount,
+ },
+ {
+ id: "pending_incoming_invitations",
+ value: pendingIncomingInvitationCount,
+ },
+ ];
+ const trackingSections: PageSection[] = [
+ {
+ id: "organization_identity",
+ state: data.state.hasOrganization ? "populated" : "empty",
+ },
+ {
+ id: "organization_members",
+ state: data.state.isPending
+ ? "hidden"
+ : memberCount > 0
+ ? "populated"
+ : "empty",
+ itemCount: memberCount,
+ },
+ {
+ id: "organization_outgoing_invitations",
+ state: data.state.isPending
+ ? "hidden"
+ : pendingOutgoingInvitationCount > 0
+ ? "populated"
+ : "empty",
+ itemCount: pendingOutgoingInvitationCount,
+ },
+ {
+ id: "incoming_invitations",
+ state: invitationsData.state.isPending
+ ? "hidden"
+ : pendingIncomingInvitationCount > 0
+ ? "populated"
+ : "empty",
+ itemCount: pendingIncomingInvitationCount,
+ },
+ ];
if (!data.state.hasOrganization) {
return (
-
-
-
-
+ <>
+
+
-
+ >
);
}
return (
-
-
-
- Workspace settings
-
-
- Manage the active workspace, invite teammates, and keep membership
- access organized in one place.
-
-
-
-
+
+
+
+
{data.state.isPending ? (
-
- {["workspace-loading-1", "workspace-loading-2"].map((key) => (
+
+ {["org-loading-1", "org-loading-2"].map((key) => (
-
-
+
+
@@ -200,13 +218,26 @@ export function WorkspaceSettingsSection() {
) : null}
- {!data.state.isPending && data.activeOrg ? (
+ {!data.state.isPending && data.state.isError ? (
+
+
+
+ Organization data couldn't be loaded right now.
+
+
+
+ ) : null}
+
+ {!data.state.isPending && !data.state.isError && data.activeOrg ? (
<>
-
+
-
+
-
-
- void handleAcceptInvitation(invitationId)
- }
- onDecline={(invitationId) =>
- void handleDeclineInvitation(invitationId)
- }
+
+
+
+ void handleAcceptInvitation(invitationId)
+ }
+ onDecline={(invitationId) =>
+ void handleDeclineInvitation(invitationId)
+ }
+ />
+
+
+
+
+
+
+
+
+
-
>
) : null}
-
+ >
);
}
diff --git a/apps/web/src/features/settings/workspace/components/CreateWorkspaceCard.tsx b/apps/web/src/features/settings/workspace/components/CreateWorkspaceCard.tsx
index c35c59a2..8357a053 100644
--- a/apps/web/src/features/settings/workspace/components/CreateWorkspaceCard.tsx
+++ b/apps/web/src/features/settings/workspace/components/CreateWorkspaceCard.tsx
@@ -1,5 +1,6 @@
import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
+import { appRoutes } from "@/app/routes";
import { Button } from "@/app/ui/button";
import {
Card,
@@ -8,15 +9,10 @@ import {
CardHeader,
CardTitle,
} from "@/app/ui/card";
-import {
- Field,
- FieldDescription,
- FieldError,
- FieldLabel,
-} from "@/app/ui/field";
+import { Field, FieldLabel } from "@/app/ui/field";
import { Input } from "@/app/ui/input";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
import { authClient } from "@/lib/auth-client";
function slugify(value: string) {
@@ -38,30 +34,31 @@ export function CreateWorkspaceCard({
title?: string;
}) {
const navigate = useNavigate();
- const { switchOrg } = useOrganization();
+ const { actions } = useOrganization();
const { trackOrganizationAction } = useAnalyticsTracking({
pageName: "organization_create",
});
- const [error, setError] = useState
(null);
- const [isCreating, setIsCreating] = useState(false);
const [name, setName] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+ const [error, setError] = useState(null);
- async function handleSubmit(event: FormEvent) {
+ const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
const trimmedName = name.trim();
const slug = slugify(trimmedName);
+
if (!trimmedName || !slug) {
return;
}
trackOrganizationAction({
actionName: "create_organization",
+ targetType: "organization",
sourceComponent: "create_workspace_card",
targetId: slug,
- targetType: "organization",
});
- setError(null);
setIsCreating(true);
+ setError(null);
const response = await authClient.organization.create({
name: trimmedName,
@@ -75,12 +72,12 @@ export function CreateWorkspaceCard({
}
if (response.data) {
- await switchOrg(response.data.id);
- navigate("/dashboard/organization");
+ await actions.switchOrganization(response.data.id);
+ navigate(appRoutes.settingsWorkspace());
}
setIsCreating(false);
- }
+ };
const slugPreview = slugify(name.trim());
@@ -91,33 +88,28 @@ export function CreateWorkspaceCard({
{description}
-
- );
+ )
}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceIncomingInvitationsCard.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceIncomingInvitationsCard.tsx
index bdaf5d14..7a70be68 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceIncomingInvitationsCard.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceIncomingInvitationsCard.tsx
@@ -99,25 +99,25 @@ export function WorkspaceIncomingInvitationsCard({
Invited {formatDate(invitation.createdAt)}
-
+
{invitation.role}
onDecline(invitation.id)}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => onDecline(invitation.id)}
+ disabled={isProcessing}
>
{isProcessing ? "Working…" : "Decline"}
onAccept(invitation.id)}
- size="sm"
type="button"
+ size="sm"
+ onClick={() => onAccept(invitation.id)}
+ disabled={isProcessing}
>
{isProcessing ? "Working…" : "Accept"}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceInviteMemberCard.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceInviteMemberCard.tsx
index 13b48bbe..3a95c395 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceInviteMemberCard.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceInviteMemberCard.tsx
@@ -1,22 +1,24 @@
+import { useState } from "react"
import {
CheckIcon,
CopyIcon,
MailIcon,
UserPlusIcon,
XIcon,
-} from "lucide-react";
-import { type FormEvent, useState } from "react";
-import { toast } from "sonner";
-import { Button } from "@/app/ui/button";
+} from "lucide-react"
+import { toast } from "sonner"
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking"
+import { authClient } from "@/lib/auth-client"
+import { Button } from "@/app/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
-} from "@/app/ui/card";
-import { Field, FieldLabel } from "@/app/ui/field";
-import { Input } from "@/app/ui/input";
+} from "@/app/ui/card"
+import { Field, FieldLabel } from "@/app/ui/field"
+import { Input } from "@/app/ui/input"
import {
Select,
SelectContent,
@@ -24,78 +26,76 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
-} from "@/app/ui/select";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import { authClient } from "@/lib/auth-client";
+} from "@/app/ui/select"
export function WorkspaceInviteMemberCard({
canManage,
onInvalidate,
}: {
- canManage: boolean;
- onInvalidate: () => void;
+ canManage: boolean
+ onInvalidate: () => void
}) {
const { trackOrganizationAction } = useAnalyticsTracking({
pageName: "organization",
- });
- const [copiedInviteLink, setCopiedInviteLink] = useState(false);
- const [inviteEmail, setInviteEmail] = useState("");
- const [inviteLink, setInviteLink] = useState
(null);
- const [inviteRole, setInviteRole] = useState<"member" | "admin">("member");
- const [invitedEmail, setInvitedEmail] = useState(null);
- const [isInviting, setIsInviting] = useState(false);
+ })
+ const [inviteEmail, setInviteEmail] = useState("")
+ const [inviteRole, setInviteRole] = useState<"member" | "admin">("member")
+ const [isInviting, setIsInviting] = useState(false)
+ const [inviteLink, setInviteLink] = useState(null)
+ const [invitedEmail, setInvitedEmail] = useState(null)
+ const [copiedInviteLink, setCopiedInviteLink] = useState(false)
- async function inviteMember(event: FormEvent) {
- event.preventDefault();
- const email = inviteEmail.trim();
+ const inviteMember = async (event: React.FormEvent) => {
+ event.preventDefault()
+ const email = inviteEmail.trim()
if (!email) {
- return;
+ return
}
trackOrganizationAction({
actionName: "invite_member",
- sourceComponent: "workspace_invite_member_card",
- targetRole: inviteRole,
targetType: "invitation",
- });
- setIsInviting(true);
+ sourceComponent: "workspace_settings_section",
+ targetRole: inviteRole,
+ })
+ setIsInviting(true)
const response = await authClient.organization.inviteMember({
email,
role: inviteRole,
- });
- setIsInviting(false);
+ })
+ setIsInviting(false)
if (response.error) {
- toast.error(response.error.message ?? "Failed to invite member");
- return;
+ toast.error(response.error.message ?? "Failed to invite member")
+ return
}
if (response.data) {
- setInviteLink(`${window.location.origin}/invitation/${response.data.id}`);
- setInvitedEmail(email);
- setInviteEmail("");
- setCopiedInviteLink(false);
- onInvalidate();
+ setInviteLink(`${window.location.origin}/invitation/${response.data.id}`)
+ setInvitedEmail(email)
+ setInviteEmail("")
+ setCopiedInviteLink(false)
+ onInvalidate()
}
}
- async function copyInviteLink() {
+ const copyInviteLink = async () => {
if (!inviteLink) {
- return;
+ return
}
trackOrganizationAction({
actionName: "copy_invite_link",
- sourceComponent: "workspace_invite_member_card",
targetType: "invitation",
- });
+ sourceComponent: "workspace_settings_section",
+ })
try {
- await navigator.clipboard.writeText(inviteLink);
- setCopiedInviteLink(true);
- toast.success("Invite link copied");
+ await navigator.clipboard.writeText(inviteLink)
+ setCopiedInviteLink(true)
+ toast.success("Invite link copied")
} catch {
- toast.error("Failed to copy invite link");
+ toast.error("Failed to copy invite link")
}
}
@@ -120,42 +120,36 @@ export function WorkspaceInviteMemberCard({
Only admins and owners can invite new members.
) : null}
-
{inviteLink ? (
-
+
-
- Invite ready
-
+ Invite ready
{invitedEmail
? `Share this link with ${invitedEmail}.`
@@ -198,10 +185,10 @@ export function WorkspaceInviteMemberCard({
void copyInviteLink()}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => void copyInviteLink()}
>
{copiedInviteLink ? (
@@ -211,14 +198,14 @@ export function WorkspaceInviteMemberCard({
{copiedInviteLink ? "Copied" : "Copy link"}
{
- setCopiedInviteLink(false);
- setInviteLink(null);
- setInvitedEmail(null);
- }}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => {
+ setInviteLink(null)
+ setInvitedEmail(null)
+ setCopiedInviteLink(false)
+ }}
>
Dismiss
@@ -229,5 +216,5 @@ export function WorkspaceInviteMemberCard({
) : null}
- );
+ )
}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceMembersCard.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceMembersCard.tsx
index d2811d95..ae8909cf 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceMembersCard.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceMembersCard.tsx
@@ -1,79 +1,79 @@
-import { useState } from "react";
-import { toast } from "sonner";
+import { useState } from "react"
+import { toast } from "sonner"
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking"
+import { authClient } from "@/lib/auth-client"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
-} from "@/app/ui/card";
-import { WorkspaceMembersTable } from "@/features/settings/workspace/components/WorkspaceMembersTable";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import { authClient } from "@/lib/auth-client";
+} from "@/app/ui/card"
+import { WorkspaceMembersTable } from "@/features/settings/workspace/components/WorkspaceMembersTable"
export function WorkspaceMembersCard({
- canManage,
members,
+ canManage,
onInvalidate,
}: {
- canManage: boolean;
members: readonly {
- id: string;
- role: string;
+ id: string
+ role: string
user: {
- email: string;
- id: string;
- image: string | null;
- name: string;
- };
- }[];
- onInvalidate: () => void;
+ id: string
+ name: string
+ email: string
+ image: string | null
+ }
+ }[]
+ canManage: boolean
+ onInvalidate: () => void
}) {
const { trackOrganizationAction } = useAnalyticsTracking({
pageName: "organization",
- });
- const [pendingMemberKey, setPendingMemberKey] = useState(null);
+ })
+ const [pendingMemberKey, setPendingMemberKey] = useState(null)
- async function removeMember(memberId: string) {
- setPendingMemberKey(`remove:${memberId}`);
+ const removeMember = async (memberId: string) => {
+ setPendingMemberKey(`remove:${memberId}`)
trackOrganizationAction({
actionName: "remove_member",
- sourceComponent: "workspace_members_card",
- targetId: memberId,
targetType: "member",
- });
+ sourceComponent: "workspace_settings_section",
+ targetId: memberId,
+ })
try {
- await authClient.organization.removeMember({ memberIdOrEmail: memberId });
- onInvalidate();
+ await authClient.organization.removeMember({ memberIdOrEmail: memberId })
+ onInvalidate()
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : "Failed to remove member",
- );
+ )
} finally {
- setPendingMemberKey(null);
+ setPendingMemberKey(null)
}
}
- async function updateRole(memberId: string, role: "member" | "admin") {
- setPendingMemberKey(`role:${memberId}`);
+ const updateRole = async (memberId: string, role: "member" | "admin") => {
+ setPendingMemberKey(`role:${memberId}`)
trackOrganizationAction({
actionName: "update_member_role",
- sourceComponent: "workspace_members_card",
+ targetType: "member",
+ sourceComponent: "workspace_settings_section",
targetId: memberId,
targetRole: role,
- targetType: "member",
- });
+ })
try {
- await authClient.organization.updateMemberRole({ memberId, role });
- onInvalidate();
+ await authClient.organization.updateMemberRole({ memberId, role })
+ onInvalidate()
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : "Failed to update member role",
- );
+ )
} finally {
- setPendingMemberKey(null);
+ setPendingMemberKey(null)
}
}
@@ -87,13 +87,13 @@ export function WorkspaceMembersCard({
void removeMember(memberId)}
- onRoleChange={(memberId, role) => void updateRole(memberId, role)}
+ canEdit={canManage}
pendingKey={pendingMemberKey}
+ onRoleChange={(memberId, role) => void updateRole(memberId, role)}
+ onRemove={(memberId) => void removeMember(memberId)}
/>
- );
+ )
}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceMembersTable.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceMembersTable.tsx
index ca474bcd..bd00b751 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceMembersTable.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceMembersTable.tsx
@@ -1,15 +1,10 @@
-import { Trash2Icon } from "lucide-react";
-import { Avatar, AvatarFallback, AvatarImage } from "@/app/ui/avatar";
+import {
+ Avatar,
+ AvatarFallback,
+ AvatarImage,
+} from "@/app/ui/avatar";
import { Badge } from "@/app/ui/badge";
import { Button } from "@/app/ui/button";
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/app/ui/select";
import {
Table,
TableBody,
@@ -18,10 +13,18 @@ import {
TableHeader,
TableRow,
} from "@/app/ui/table";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/app/ui/select";
+import { Trash2Icon } from "lucide-react";
function getInitials(name: string, email: string) {
const source = name.trim() || email.trim() || "R";
-
return source
.split(" ")
.map((part) => part[0])
@@ -31,26 +34,26 @@ function getInitials(name: string, email: string) {
}
export function WorkspaceMembersTable({
- canEdit,
members,
- onRemove,
- onRoleChange,
+ canEdit,
pendingKey,
+ onRoleChange,
+ onRemove,
}: {
- canEdit: boolean;
members: readonly {
id: string;
role: string;
user: {
- email: string;
id: string;
- image: string | null;
name: string;
+ email: string;
+ image: string | null;
};
}[];
- onRemove: (memberId: string) => void;
- onRoleChange: (memberId: string, role: "member" | "admin") => void;
+ canEdit: boolean;
pendingKey: string | null;
+ onRoleChange: (memberId: string, role: "member" | "admin") => void;
+ onRemove: (memberId: string) => void;
}) {
if (members.length === 0) {
return (
@@ -71,8 +74,10 @@ export function WorkspaceMembersTable({
{members.map((member) => {
- const isRemovePending = pendingKey === `remove:${member.id}`;
- const isRolePending = pendingKey === `role:${member.id}`;
+ const roleKey = `role:${member.id}`;
+ const removeKey = `remove:${member.id}`;
+ const isRolePending = pendingKey === roleKey;
+ const isRemovePending = pendingKey === removeKey;
return (
@@ -80,10 +85,7 @@ export function WorkspaceMembersTable({
{member.user.image ? (
-
+
) : null}
{getInitials(member.user.name, member.user.email)}
@@ -103,46 +105,42 @@ export function WorkspaceMembersTable({
{member.role === "owner" ? (
Owner
) : canEdit ? (
-
- {
- if (value === "member" || value === "admin") {
- onRoleChange(member.id, value);
- }
- }}
- value={member.role}
- >
-
-
-
-
-
- Member
- Admin
-
-
-
- {isRolePending ? (
-
- Saving…
-
- ) : null}
-
+
+ onRoleChange(member.id, value as "member" | "admin")
+ }
+ disabled={Boolean(pendingKey)}
+ >
+
+
+
+
+
+ Member
+ Admin
+
+
+
) : (
-
+
{member.role}
)}
+ {isRolePending ? (
+
+ Saving…
+
+ ) : null}
{canEdit && member.role !== "owner" ? (
onRemove(member.id)}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => onRemove(member.id)}
+ disabled={Boolean(pendingKey)}
>
{isRemovePending ? "Removing…" : "Remove"}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsCard.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsCard.tsx
index bca3ba43..30b4aadb 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsCard.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsCard.tsx
@@ -1,56 +1,56 @@
-import { useState } from "react";
-import { toast } from "sonner";
+import { useState } from "react"
+import { toast } from "sonner"
+import { useAnalyticsTracking } from "@/features/analytics/tracking/useAnalyticsTracking"
+import { authClient } from "@/lib/auth-client"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
-} from "@/app/ui/card";
-import { WorkspaceOutgoingInvitationsTable } from "@/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import { authClient } from "@/lib/auth-client";
+} from "@/app/ui/card"
+import { WorkspaceOutgoingInvitationsTable } from "@/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable"
export function WorkspaceOutgoingInvitationsCard({
- canCancel,
invitations,
+ canCancel,
onInvalidate,
}: {
- canCancel: boolean;
invitations: readonly {
- createdAt?: string;
- email: string;
- id: string;
- role: string | null;
- status: string;
- }[];
- onInvalidate: () => void;
+ id: string
+ email: string
+ role: string | null
+ status: string
+ createdAt?: string
+ }[]
+ canCancel: boolean
+ onInvalidate: () => void
}) {
const { trackOrganizationAction } = useAnalyticsTracking({
pageName: "organization",
- });
+ })
const [pendingInvitationId, setPendingInvitationId] = useState(
null,
- );
+ )
- async function cancelInvitation(invitationId: string) {
- setPendingInvitationId(invitationId);
+ const cancelInvitation = async (invitationId: string) => {
+ setPendingInvitationId(invitationId)
trackOrganizationAction({
actionName: "cancel_invitation",
- sourceComponent: "workspace_outgoing_invitations_card",
- targetId: invitationId,
targetType: "invitation",
- });
+ sourceComponent: "workspace_settings_section",
+ targetId: invitationId,
+ })
try {
- await authClient.organization.cancelInvitation({ invitationId });
- onInvalidate();
+ await authClient.organization.cancelInvitation({ invitationId })
+ onInvalidate()
} catch (cause) {
toast.error(
cause instanceof Error ? cause.message : "Failed to cancel invitation",
- );
+ )
} finally {
- setPendingInvitationId(null);
+ setPendingInvitationId(null)
}
}
@@ -64,12 +64,12 @@ export function WorkspaceOutgoingInvitationsCard({
void cancelInvitation(invitationId)}
+ canCancel={canCancel}
pendingInvitationId={pendingInvitationId}
+ onCancel={(invitationId) => void cancelInvitation(invitationId)}
/>
- );
+ )
}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable.tsx
index de6de870..283c1500 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceOutgoingInvitationsTable.tsx
@@ -1,4 +1,3 @@
-import { XIcon } from "lucide-react";
import { Badge } from "@/app/ui/badge";
import { Button } from "@/app/ui/button";
import {
@@ -9,6 +8,7 @@ import {
TableHeader,
TableRow,
} from "@/app/ui/table";
+import { XIcon } from "lucide-react";
function formatDate(value?: string) {
if (!value) {
@@ -24,21 +24,21 @@ function formatDate(value?: string) {
}
export function WorkspaceOutgoingInvitationsTable({
- canCancel,
invitations,
- onCancel,
+ canCancel,
pendingInvitationId,
+ onCancel,
}: {
- canCancel: boolean;
invitations: readonly {
- createdAt?: string;
- email: string;
id: string;
+ email: string;
role: string | null;
status: string;
+ createdAt?: string;
}[];
- onCancel: (invitationId: string) => void;
+ canCancel: boolean;
pendingInvitationId: string | null;
+ onCancel: (invitationId: string) => void;
}) {
if (invitations.length === 0) {
return (
@@ -67,7 +67,7 @@ export function WorkspaceOutgoingInvitationsTable({
{invitation.email}
{invitation.role ? (
-
+
{invitation.role}
) : (
@@ -80,11 +80,11 @@ export function WorkspaceOutgoingInvitationsTable({
{canCancel ? (
onCancel(invitation.id)}
- size="sm"
type="button"
variant="outline"
+ size="sm"
+ onClick={() => onCancel(invitation.id)}
+ disabled={pendingInvitationId !== null}
>
{isPending ? "Canceling…" : "Cancel"}
diff --git a/apps/web/src/features/settings/workspace/components/WorkspaceSummaryStrip.tsx b/apps/web/src/features/settings/workspace/components/WorkspaceSummaryStrip.tsx
index 4f3fca7a..9692f7ab 100644
--- a/apps/web/src/features/settings/workspace/components/WorkspaceSummaryStrip.tsx
+++ b/apps/web/src/features/settings/workspace/components/WorkspaceSummaryStrip.tsx
@@ -1,15 +1,17 @@
import { Skeleton } from "@/app/ui/skeleton";
export function WorkspaceSummaryStrip({
- isPending,
tiles,
+ isPending,
+ isError,
}: {
- isPending: boolean;
tiles: Array<{
- displayValue: string;
id: string;
label: string;
+ displayValue: string;
}>;
+ isPending: boolean;
+ isError: boolean;
}) {
return (
@@ -21,7 +23,7 @@ export function WorkspaceSummaryStrip({
) : (
- {tile.displayValue}
+ {isError ? "—" : tile.displayValue}
)}
diff --git a/apps/web/src/features/settings/workspace/use-workspace-settings-data.ts b/apps/web/src/features/settings/workspace/use-workspace-settings-data.ts
index 5f81c378..4205e6ee 100644
--- a/apps/web/src/features/settings/workspace/use-workspace-settings-data.ts
+++ b/apps/web/src/features/settings/workspace/use-workspace-settings-data.ts
@@ -1,48 +1,41 @@
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
+import { useFullOrganization } from "@/features/workspace/hooks/useFullOrganization";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
import { authClient } from "@/lib/auth-client";
function readSessionUserId(value: unknown) {
return typeof value === "string" ? value : "";
}
-function formatRoleLabel(value: string | null | undefined) {
- if (!value) {
- return "—";
- }
-
- return value.charAt(0).toUpperCase() + value.slice(1);
-}
-
export function useWorkspaceSettingsData() {
- const { activeOrg, organizations, isLoading, isOrgAdmin, switchOrg } =
- useOrganization();
+ const { state, meta } = useOrganization();
const { data: session } = authClient.useSession();
const {
data: fullOrg,
- isLoading: isFullOrgLoading,
+ isLoading: isFullOrgPending,
+ isError,
invalidate,
- } = useFullOrganization(activeOrg?.id);
+ } = useFullOrganization(state.activeOrg?.id);
const currentUserId = readSessionUserId(session?.user?.id);
- const currentMember =
- fullOrg?.members.find((member) => member.userId === currentUserId) ?? null;
const pendingInvitations =
fullOrg?.invitations.filter(
(invitation) => invitation.status === "pending",
) ?? [];
- const canManage = Boolean(activeOrg) && isOrgAdmin;
+ const currentMember = fullOrg?.members.find(
+ (member) => member.userId === currentUserId,
+ );
+ const canManage = meta.isOrgAdmin && Boolean(state.activeOrg);
const currentUserRole = currentMember?.role ?? null;
return {
- activeOrg,
- canManage,
+ activeOrg: state.activeOrg,
+ organizations: state.organizations,
+ fullOrg,
+ pendingInvitations,
currentUserId,
+ canManage,
currentUserRole,
- fullOrg,
invalidate,
- organizations,
- pendingInvitations,
summaryTiles: [
{
id: "members",
@@ -57,14 +50,16 @@ export function useWorkspaceSettingsData() {
{
id: "your_role",
label: "Your role",
- displayValue: formatRoleLabel(currentUserRole),
+ displayValue: currentUserRole
+ ? currentUserRole.charAt(0).toUpperCase() + currentUserRole.slice(1)
+ : "—",
},
],
- switchOrg,
state: {
- hasOrganization: Boolean(activeOrg),
+ hasOrganization: Boolean(state.activeOrg),
+ isPending: state.isLoading || isFullOrgPending,
+ isError,
hasData: Boolean(fullOrg),
- isPending: isLoading || isFullOrgLoading,
},
};
}
diff --git a/apps/web/src/features/shell/AppShellLayout.tsx b/apps/web/src/features/shell/AppShellLayout.tsx
index 24d58499..fbfb0aea 100644
--- a/apps/web/src/features/shell/AppShellLayout.tsx
+++ b/apps/web/src/features/shell/AppShellLayout.tsx
@@ -1,30 +1,218 @@
-import { Outlet } from "react-router-dom";
-import { Toaster } from "sonner";
-import { ChatwootBootstrap } from "@/components/support/ChatwootBootstrap";
-import { DateRangeProvider } from "@/contexts/DateRangeContext";
-import { FilterProvider } from "@/contexts/FilterContext";
-import { OrganizationProvider } from "@/contexts/OrganizationContext";
+import type { CSSProperties } from "react";
+import * as React from "react";
+import {
+ Outlet,
+ useLocation,
+ useNavigate,
+ useSearchParams,
+} from "react-router-dom";
+import { useMountEffect } from "@/app/hooks/useMountEffect";
+import { AppProviders } from "@/app/providers/AppProviders";
+import { AppToaster } from "@/app/ui/AppToaster";
+import "@/app/app-surface.css";
+import { SidebarInset, SidebarProvider } from "@/app/ui/sidebar";
+import { TooltipProvider } from "@/app/ui/tooltip";
import { AppSidebar } from "@/features/shell/components/AppSidebar";
+import { SidebarShellDebugPanel } from "@/features/shell/components/SidebarShellDebugPanel";
import { SiteHeader } from "@/features/shell/components/SiteHeader";
+import {
+ shellRouteMap,
+ shellRoutes,
+} from "@/features/shell/config/shell-routes";
+import { SHOW_SIDEBAR_NEWS_MODE } from "@/features/shell/config/sidebar-news";
+import {
+ appendSidebarShellDebugParams,
+ getSidebarShellDebugState,
+} from "@/features/shell/config/sidebar-shell-debug";
+
+const defaultDashboardChromeValues = {
+ turbulence: {
+ opacity: 0.18,
+ highlightOpacity: 0.15,
+ largeSize: 130,
+ smallSize: 136,
+ contrast: 190,
+ darkness: 0.8,
+ },
+ shadow: {
+ x: 0,
+ y: 0,
+ blur: 4,
+ spread: 0,
+ color: "#000000",
+ opacity: 0.13,
+ },
+} as const;
+
+function hexToRgba(hex: string, alpha: number) {
+ const sanitized = hex.replace("#", "").trim();
+ const normalized =
+ sanitized.length === 3
+ ? sanitized
+ .split("")
+ .map((char) => `${char}${char}`)
+ .join("")
+ : sanitized;
+
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
+ return `rgba(0, 0, 0, ${alpha})`;
+ }
+
+ const red = Number.parseInt(normalized.slice(0, 2), 16);
+ const green = Number.parseInt(normalized.slice(2, 4), 16);
+ const blue = Number.parseInt(normalized.slice(4, 6), 16);
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
+}
+
+const defaultChromeStyle = {
+ "--dashboard-01-chrome-turbulence-opacity": `${defaultDashboardChromeValues.turbulence.opacity}`,
+ "--dashboard-01-chrome-highlight-opacity": `${defaultDashboardChromeValues.turbulence.highlightOpacity}`,
+ "--dashboard-01-chrome-noise-large-size": `${defaultDashboardChromeValues.turbulence.largeSize}px`,
+ "--dashboard-01-chrome-noise-small-size": `${defaultDashboardChromeValues.turbulence.smallSize}px`,
+ "--dashboard-01-chrome-turbulence-contrast": `${defaultDashboardChromeValues.turbulence.contrast}%`,
+ "--dashboard-01-chrome-turbulence-darkness": `${defaultDashboardChromeValues.turbulence.darkness}`,
+ "--dashboard-01-window-shadow": `${defaultDashboardChromeValues.shadow.x}px ${defaultDashboardChromeValues.shadow.y}px ${defaultDashboardChromeValues.shadow.blur}px ${defaultDashboardChromeValues.shadow.spread}px ${hexToRgba(defaultDashboardChromeValues.shadow.color, defaultDashboardChromeValues.shadow.opacity)}`,
+} as CSSProperties;
+
+const shellShortcutRouteByKey = Object.fromEntries(
+ shellRoutes.map((route) => [route.shortcut.toLowerCase(), route.path]),
+) as Record;
+
+function isEditableTarget(target: EventTarget | null) {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+
+ if (target.isContentEditable) {
+ return true;
+ }
+
+ const editableContainer = target.closest(
+ 'input, textarea, select, [contenteditable="true"], [role="textbox"]',
+ );
+ return editableContainer instanceof HTMLElement;
+}
export function AppShellLayout() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams] = useSearchParams();
+ const isSidebarNewsModeEnabled = SHOW_SIDEBAR_NEWS_MODE;
+ const isSettingsShellRoute =
+ location.pathname === shellRouteMap.settings.path ||
+ location.pathname.startsWith(`${shellRouteMap.settings.path}/`);
+ const sidebarShellDebugState = getSidebarShellDebugState(searchParams);
+ const sidebarTuning = sidebarShellDebugState.tuning;
+ const handleShellShortcutKeyDown = React.useEffectEvent(
+ (event: KeyboardEvent) => {
+ if (
+ event.defaultPrevented ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.altKey ||
+ event.shiftKey ||
+ event.repeat ||
+ isEditableTarget(event.target)
+ ) {
+ return;
+ }
+
+ const nextPath = shellShortcutRouteByKey[event.key.toLowerCase()];
+ if (!nextPath || location.pathname === nextPath) {
+ return;
+ }
+
+ event.preventDefault();
+ navigate(appendSidebarShellDebugParams(nextPath, searchParams));
+ },
+ );
+
+ useMountEffect(() => {
+ window.addEventListener("keydown", handleShellShortcutKeyDown);
+ return () =>
+ window.removeEventListener("keydown", handleShellShortcutKeyDown);
+ });
+
return (
-
-
-
-
-
-
-
-
+
+
+
+
{}
+ : undefined
+ }
+ className="dashboard-01-chrome-frame h-full overflow-hidden"
+ style={
+ {
+ "--sidebar-width": `${sidebarTuning.expandedWidth}rem`,
+ "--sidebar-width-icon": `${sidebarTuning.collapsedWidth}rem`,
+ "--header-height": "calc(var(--spacing) * 12)",
+ "--sidebar-section-first-margin-top": `${sidebarTuning.sectionMarginTop}rem`,
+ "--sidebar-rail-inset-left": `${sidebarTuning.railInsetLeft}rem`,
+ "--sidebar-rail-inset-right": `${sidebarTuning.railInsetRight}rem`,
+ "--sidebar-collapsed-section-padding-x": `${sidebarTuning.collapsedSectionPaddingX}rem`,
+ "--sidebar-expanded-section-padding-x": `${sidebarTuning.expandedSectionPaddingX}rem`,
+ "--sidebar-collapsed-footer-padding-x": `${sidebarTuning.collapsedFooterPaddingX}rem`,
+ "--sidebar-expanded-footer-padding-x": `${sidebarTuning.expandedFooterPaddingX}rem`,
+ "--sidebar-expanded-footer-padding-bottom": `${sidebarTuning.expandedFooterPaddingBottom}rem`,
+ "--sidebar-collapsed-stack-gap": `${sidebarTuning.collapsedStackGap}rem`,
+ "--sidebar-expanded-stack-gap": `${sidebarTuning.expandedStackGap}rem`,
+ "--sidebar-row-height": `${sidebarTuning.rowHeight}rem`,
+ "--sidebar-row-radius": `${sidebarTuning.rowRadius}rem`,
+ "--sidebar-collapsed-row-padding-left": `${sidebarTuning.collapsedRowPaddingLeft}rem`,
+ "--sidebar-collapsed-row-padding-right": `${sidebarTuning.collapsedRowPaddingRight}rem`,
+ "--sidebar-row-padding-left": `${sidebarTuning.rowPaddingLeft}rem`,
+ "--sidebar-row-padding-right": `${sidebarTuning.rowPaddingRight}rem`,
+ "--sidebar-row-gap": `${sidebarTuning.rowGap}rem`,
+ "--sidebar-icon-lane-size": `${sidebarTuning.iconLaneSize}rem`,
+ "--sidebar-icon-size": `${sidebarTuning.iconSize}rem`,
+ "--sidebar-avatar-size": `${sidebarTuning.avatarSize}rem`,
+ "--sidebar-label-font-size": `${sidebarTuning.labelFontSize}rem`,
+ "--sidebar-shortcut-font-size": `${sidebarTuning.shortcutFontSize}rem`,
+ "--sidebar-row-idle-bg": sidebarTuning.rowIdleBg,
+ "--sidebar-row-hover-bg": sidebarTuning.rowHoverBg,
+ "--sidebar-row-active-bg": sidebarTuning.rowActiveBg,
+ "--sidebar-row-fg": sidebarTuning.rowFg,
+ "--sidebar-row-active-fg": sidebarTuning.rowActiveFg,
+ ...defaultChromeStyle,
+ } as CSSProperties
+ }
+ >
+
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
);
}
diff --git a/apps/web/src/features/shell/components/AppSidebar.tsx b/apps/web/src/features/shell/components/AppSidebar.tsx
index af240718..3df3c3fc 100644
--- a/apps/web/src/features/shell/components/AppSidebar.tsx
+++ b/apps/web/src/features/shell/components/AppSidebar.tsx
@@ -1,467 +1,376 @@
+"use client";
+
+import { ArrowLeftIcon, Building2Icon, UserIcon } from "lucide-react";
+import { AnimatePresence, motion } from "motion/react";
+import * as React from "react";
+import { useLocation } from "react-router-dom";
import {
- Building2,
- Check,
- ChevronsLeft,
- ChevronsRight,
- ChevronsUpDown,
- LogOut,
- Mail,
- Plus,
- Settings,
- Shield,
-} from "lucide-react";
-import { useTheme } from "next-themes";
-import { type ReactNode, useState } from "react";
-import { Link, useLocation } from "react-router-dom";
-import { Avatar, AvatarFallback, AvatarImage } from "@/app/ui/avatar";
+ SIDEBAR_SHELL_COLLAPSE_DURATION_MS,
+ Sidebar,
+ type SidebarShellMotionVariant,
+ useSidebar,
+} from "@/app/ui/sidebar";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/app/ui/dropdown-menu";
+ getActiveSettingsRouteId,
+ primarySettingsRoutes,
+} from "@/features/settings/config/settings-routes";
+import { SidebarNewsCard } from "@/features/shell/components/SidebarNewsCard";
+import { SidebarNewsPopover } from "@/features/shell/components/SidebarNewsPopover";
import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/app/ui/tooltip";
-import { ThemeToggle } from "@/components/analytics/ThemeToggle";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import { useUserInvitations } from "@/hooks/useUserInvitations";
-import { authClient, signOut } from "@/lib/auth-client";
-import { getAnalyticsPageName } from "@/lib/product-analytics";
-import { cn } from "@/lib/utils";
+ RailLink,
+ type SidebarRowDebugProps,
+ type SidebarRowMode,
+} from "@/features/shell/components/shell-rail";
+import { UserRailButton } from "@/features/shell/components/UserRailButton";
+import { WorkspaceMenuButton } from "@/features/shell/components/WorkspaceMenuButton";
+import {
+ shellRouteMap,
+ shellRoutes,
+} from "@/features/shell/config/shell-routes";
import {
- isShellRouteActive,
- primaryShellRoutes,
- type ShellRouteDefinition,
-} from "../config/shell-routes";
+ SHOW_SIDEBAR_NEWS_MODE,
+ SIDEBAR_NEWS_ITEM_ID,
+} from "@/features/shell/config/sidebar-news";
+import type { SidebarShellDebugState } from "@/features/shell/config/sidebar-shell-debug";
+import { useCurrentShellRoute } from "@/features/shell/hooks/useCurrentShellRoute";
+import { cn } from "@/lib/utils";
+
+type SidebarDisplayMode = SidebarRowMode;
+type SidebarNavigationMode = "app" | "settings";
+
+const SIDEBAR_NEWS_DISMISS_STORAGE_KEY = `sidebar-news-dismissed:${SIDEBAR_NEWS_ITEM_ID}`;
+
+const SIDEBAR_NEWS_VISIBILITY_TRANSITION = {
+ duration: 0.2,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+function getSettingsSidebarIcon(routeId: string) {
+ return routeId === "account" ? : ;
+}
+
+function getSidebarSectionClassName(mode: SidebarDisplayMode) {
+ return cn(
+ "mt-[var(--sidebar-section-first-margin-top)] flex w-full flex-col",
+ mode === "expanded"
+ ? "pl-[calc(var(--sidebar-rail-inset-left)+var(--sidebar-expanded-section-padding-x))] pr-[calc(var(--sidebar-rail-inset-right)+var(--sidebar-expanded-section-padding-x))]"
+ : "pl-[calc(var(--sidebar-rail-inset-left)+var(--sidebar-collapsed-section-padding-x))] pr-[calc(var(--sidebar-rail-inset-right)+var(--sidebar-collapsed-section-padding-x))]",
+ );
+}
-const ADMIN_ORGANIZATION_ID = (
- import.meta.env.VITE_ADMIN_ORGANIZATION_ID ?? ""
-).trim();
+function getSidebarTopClusterClassName(mode: SidebarDisplayMode) {
+ return mode === "expanded"
+ ? "flex w-full flex-col gap-[var(--sidebar-expanded-stack-gap)]"
+ : "flex w-full flex-col gap-[var(--sidebar-collapsed-stack-gap)]";
+}
+
+function getSidebarNavigationClusterClassName(mode: SidebarDisplayMode) {
+ return mode === "expanded"
+ ? "mt-[calc(var(--sidebar-expanded-stack-gap)+0.5rem)]"
+ : "mt-[calc(var(--sidebar-collapsed-stack-gap)+0.5rem)]";
+}
+
+function getSidebarFooterClassName(mode: SidebarDisplayMode) {
+ return cn(
+ "mt-auto w-full",
+ mode === "expanded"
+ ? "pl-[calc(var(--sidebar-rail-inset-left)+var(--sidebar-expanded-footer-padding-x))] pr-[calc(var(--sidebar-rail-inset-right)+var(--sidebar-expanded-footer-padding-x))] pb-[var(--sidebar-expanded-footer-padding-bottom)]"
+ : "pl-[calc(var(--sidebar-rail-inset-left)+var(--sidebar-collapsed-footer-padding-x))] pr-[calc(var(--sidebar-rail-inset-right)+var(--sidebar-collapsed-footer-padding-x))]",
+ );
+}
+
+function getSidebarFooterStackClassName(mode: SidebarDisplayMode) {
+ return mode === "expanded"
+ ? "flex w-full flex-col gap-[var(--sidebar-expanded-stack-gap)]"
+ : "flex w-full flex-col gap-[var(--sidebar-collapsed-stack-gap)]";
+}
+
+function getSidebarContentFrameClassName(mode: SidebarDisplayMode) {
+ return cn(
+ "relative flex h-full min-h-0 flex-col bg-transparent",
+ mode === "expanded"
+ ? "w-(--sidebar-width) overflow-x-clip overflow-y-auto text-clip whitespace-nowrap"
+ : "w-(--sidebar-width-icon) pb-1.5",
+ );
+}
-function getInitials(name: string) {
- return name
- .split(" ")
- .map((part) => part[0])
- .join("")
- .toUpperCase()
- .slice(0, 2);
+function CollapsedSidebarExpandSurface({ onClick }: { onClick: () => void }) {
+ return (
+
+ );
}
-function SidebarNavLink({
- badgeLabel,
- collapsed,
- isActive,
- label,
+function SidebarEdgeHotspot({
+ isExpanded,
onClick,
- to,
- icon,
}: {
- badgeLabel?: string;
- collapsed: boolean;
- isActive: boolean;
- label: string;
+ isExpanded: boolean;
onClick: () => void;
- to: string;
- icon: ReactNode;
}) {
- const link = (
-
-
- {icon}
- {badgeLabel ? (
-
- {badgeLabel}
-
- ) : null}
+
+
- {collapsed ? null : (
- {label}
- )}
-
- );
-
- if (!collapsed) {
- return link;
- }
-
- return (
-
-
-
- {badgeLabel ? `${label} (${badgeLabel})` : label}
-
-
+
);
}
-function OrgSwitcher({ collapsed }: { collapsed: boolean }) {
- const { activeOrg, organizations, switchOrg } = useOrganization();
- const { trackNavigation, trackOrganizationAction } = useAnalyticsTracking();
-
- async function handleSelect(orgId: string) {
- if (orgId === activeOrg?.id) {
- return;
- }
-
- trackOrganizationAction({
- actionName: "switch_organization",
- targetType: "organization",
- sourceComponent: "org_switcher",
- targetId: orgId,
- });
+function SidebarNavigation({
+ mode,
+ navigationMode,
+ debugShowBorders,
+ debugVariant,
+ forceShowLabels,
+}: {
+ mode: SidebarDisplayMode;
+ navigationMode: SidebarNavigationMode;
+} & SidebarRowDebugProps) {
+ const currentShellRoute = useCurrentShellRoute();
+ const location = useLocation();
+ const activeSettingsRouteId = getActiveSettingsRouteId(location.pathname);
- await switchOrg(orgId);
+ if (navigationMode === "settings") {
+ return (
+
+
+ {primarySettingsRoutes.map((route) => (
+
+ {getSettingsSidebarIcon(route.id)}
+
+ ))}
+
+
+ );
}
return (
-
-
-
- {collapsed ? null : (
- <>
-
- {activeOrg?.name ?? "Select org"}
-
-
- >
- )}
-
- }
- />
-
- {organizations.map((organization) => (
- void handleSelect(organization.id)}
+
+
+ {shellRoutes.map((route) => (
+
-
- {organization.name}
- {organization.id === activeOrg?.id ? (
-
- ) : null}
-
+ {route.icon}
+
))}
-
- {
- trackNavigation({
- navType: "organization_menu",
- sourceComponent: "org_switcher",
- targetPath: "/dashboard/organization",
- targetType: "page",
- toPageName: "organization",
- });
- }}
- />
- }
- >
-
- Manage organization
-
- {
- trackNavigation({
- navType: "organization_menu",
- sourceComponent: "org_switcher",
- targetPath: "/dashboard/organization/new",
- targetType: "page",
- toPageName: "organization_create",
- });
- }}
- />
- }
- >
-
- Create organization
-
-
-
+
+
);
}
-function buildRouteSourceComponent(route: ShellRouteDefinition) {
- return `sidebar_${route.id}`;
-}
+export function AppSidebar({
+ navigationMode = "app",
+ shellMotionShowBorders = true,
+ shellMotionVariant = "baseline",
+ shellMotionForceLabels = false,
+ shellDebugState,
+}: {
+ navigationMode?: SidebarNavigationMode;
+ shellMotionShowBorders?: boolean;
+ shellMotionVariant?: SidebarShellMotionVariant;
+ shellMotionForceLabels?: boolean;
+ shellDebugState: SidebarShellDebugState;
+}) {
+ const { state, isMobile, openMobile, toggleSidebar } = useSidebar();
+ const isSidebarExpanded = isMobile ? openMobile : state === "expanded";
+ const [displayMode, setDisplayMode] = React.useState(
+ isSidebarExpanded ? "expanded" : "collapsed",
+ );
+ const [isNewsDismissed, setIsNewsDismissed] = React.useState(() => {
+ if (typeof window === "undefined") {
+ return false;
+ }
-export function AppSidebar() {
- const { pathname } = useLocation();
- const { data: session } = authClient.useSession();
- const { count: invitationCount } = useUserInvitations();
- const { organizations } = useOrganization();
- const { resolvedTheme } = useTheme();
- const { trackAuthenticationAction, trackNavigation, trackUtility } =
- useAnalyticsTracking();
- const [collapsed, setCollapsed] = useState(false);
+ try {
+ return (
+ window.localStorage.getItem(SIDEBAR_NEWS_DISMISS_STORAGE_KEY) === "true"
+ );
+ } catch {
+ return false;
+ }
+ });
+ const newsDebugTuning = shellDebugState.tuning;
+ const showSidebarNewsFeatures =
+ SHOW_SIDEBAR_NEWS_MODE && navigationMode === "app";
- const logoSrc =
- resolvedTheme === "dark" ? "/logo-light.svg" : "/logo-dark.svg";
- const isAdmin =
- ADMIN_ORGANIZATION_ID !== "" &&
- organizations.some(
- (organization) => organization.id === ADMIN_ORGANIZATION_ID,
- );
- const isInvitationRouteActive = pathname === "/dashboard/invitations";
- const isAdminRouteActive =
- pathname === "/dashboard/admin" || pathname.startsWith("/dashboard/admin/");
+ const dismissNewsCard = React.useCallback(() => {
+ setIsNewsDismissed(true);
- function trackSidebarNavigation(sourceComponent: string, targetPath: string) {
- trackNavigation({
- navType: "sidebar",
- sourceComponent,
- targetPath,
- targetType: "page",
- toPageName: getAnalyticsPageName(targetPath) ?? undefined,
- });
- }
+ try {
+ window.localStorage.setItem(SIDEBAR_NEWS_DISMISS_STORAGE_KEY, "true");
+ } catch {}
+ }, []);
- function toggleCollapsed() {
- const nextCollapsed = !collapsed;
+ React.useEffect(() => {
+ if (isMobile) {
+ setDisplayMode("expanded");
+ return;
+ }
- trackUtility({
- utilityName: "sidebar_collapse",
- componentId: "app_sidebar",
- utilityState: nextCollapsed ? "collapsed" : "expanded",
- });
+ if (isSidebarExpanded) {
+ setDisplayMode("expanded");
+ return;
+ }
- setCollapsed(nextCollapsed);
- }
+ if (displayMode === "collapsed") {
+ return;
+ }
+
+ // Keep the expanded row variant mounted through the width collapse so
+ // clipping, not an immediate mode swap, hides the labels.
+ const timeoutId = window.setTimeout(() => {
+ React.startTransition(() => {
+ setDisplayMode("collapsed");
+ });
+ }, SIDEBAR_SHELL_COLLAPSE_DURATION_MS);
+
+ return () => window.clearTimeout(timeoutId);
+ }, [displayMode, isMobile, isSidebarExpanded]);
+
+ const isExpandedMode = displayMode === "expanded";
+ const showSidebarNewsCard =
+ showSidebarNewsFeatures && isSidebarExpanded && !isNewsDismissed;
return (
-
-
-
-
trackSidebarNavigation("sidebar_logo", "/dashboard")}
- className={cn(
- "flex h-10 shrink-0 items-center px-4",
- collapsed ? "px-3" : "",
- )}
- >
-
-
-
-
-
-
- {collapsed ? (
-
+
+
+
+
+ {navigationMode === "settings" ? (
+
+
+
+ ) : isExpandedMode ? (
+
) : (
-
+
)}
-
+ {showSidebarNewsFeatures && isExpandedMode ? (
+
+ ) : null}
+
+
+
+
-
-
- {primaryShellRoutes.map((route) => {
- const Icon = route.icon;
-
- return (
-
- trackSidebarNavigation(
- buildRouteSourceComponent(route),
- route.path,
- )
- }
- to={route.path}
- icon={ }
+ {isExpandedMode ? null : (
+
+ )}
+
+
+
+ {showSidebarNewsCard ? (
+
+
+
+ ) : null}
+
+ {isExpandedMode ? (
+
- );
- })}
-
- {invitationCount > 0 ? (
-
- trackSidebarNavigation(
- "sidebar_invitations",
- "/dashboard/invitations",
- )
- }
- to="/dashboard/invitations"
- icon={ }
- />
- ) : null}
-
- {isAdmin ?
: null}
- {isAdmin ? (
-
- trackSidebarNavigation("sidebar_admin", "/dashboard/admin")
- }
- to="/dashboard/admin"
- icon={ }
- />
- ) : null}
-
-
- {session?.user ? (
-
- {collapsed ? (
-
-
-
- A
-
- }
- />
-
- OPEN ALPHA Testing v{__APP_VERSION__}
-
-
-
) : (
-
-
- OPEN ALPHA Testing
-
-
+
)}
-
-
- {collapsed ? (
-
-
- trackSidebarNavigation(
- "sidebar_profile_avatar",
- "/dashboard/profile",
- )
- }
- className="flex min-w-0 items-center gap-2"
- >
-
- {session.user.image ? (
-
- ) : null}
-
- {getInitials(session.user.name)}
-
-
-
- }
- />
-
- {session.user.name}
-
-
- ) : (
- <>
-
- trackSidebarNavigation(
- "sidebar_profile",
- "/dashboard/profile",
- )
- }
- className="flex min-w-0 flex-1 items-center gap-2"
- >
-
- {session.user.image ? (
-
- ) : null}
-
- {getInitials(session.user.name)}
-
-
-
- {session.user.name}
-
-
-
-
{
- trackAuthenticationAction({
- actionName: "sign_out",
- sourceComponent: "sidebar_sign_out",
- authMethod: "session",
- });
- signOut();
- }}
- className="shrink-0 rounded-md p-1 text-muted transition-colors hover:bg-hover hover:text-foreground"
- title="Sign out"
- >
-
-
- >
- )}
-
+
+ {navigationMode === "app" ? (
+
) : null}
-
-
+
+
);
}
diff --git a/apps/web/src/features/shell/components/SidebarNewsCard.tsx b/apps/web/src/features/shell/components/SidebarNewsCard.tsx
new file mode 100644
index 00000000..7a86a729
--- /dev/null
+++ b/apps/web/src/features/shell/components/SidebarNewsCard.tsx
@@ -0,0 +1,612 @@
+"use client";
+
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import { ArrowUpRightIcon, Minus, XIcon } from "lucide-react";
+import { AnimatePresence, LayoutGroup, motion } from "motion/react";
+import * as React from "react";
+import { createPortal } from "react-dom";
+import { useSearchParams } from "react-router-dom";
+import { Button } from "@/app/ui/button";
+import {
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/app/ui/dialog";
+import {
+ getSidebarShellDebugState,
+ SIDEBAR_NEWS_ACTIVE_ATTRIBUTE,
+ type SidebarShellTuningState,
+} from "@/features/shell/config/sidebar-shell-debug";
+import { cn } from "@/lib/utils";
+
+/* ─────────────────────────────────────────────────────────
+ * ANIMATION STORYBOARD
+ *
+ * 0ms sidebar card expands into the portal popup
+ * 0ms close request measures popup and card geometry
+ * 0ms dialog closes immediately, but a fixed clone stays on top
+ * 280ms clone scales and translates back into the sidebar target
+ * 180ms backdrop fades out under the clone during the close pass
+ * ───────────────────────────────────────────────────────── */
+
+const CARD_LAYOUT_ID = "sidebar-footer-news-card";
+const CLOSE_BUTTON_LAYOUT_ID = "sidebar-footer-news-card-close";
+
+const CARD_SPRING = {
+ type: "spring" as const,
+ stiffness: 360,
+ damping: 34,
+ mass: 0.9,
+};
+
+const POPUP_TRANSITION = {
+ duration: 0.22,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+const BACKDROP_TRANSITION = {
+ duration: 0.18,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+const CONTENT_TRANSITION = {
+ duration: 0.2,
+ delay: 0.1,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+const NEWS_DATE = "Apr 6";
+
+const NEWS_PARAGRAPHS = [
+ "The shell has been redesigned with tighter spacing, calmer hover states, and more consistent motion across the sidebar.",
+ "Tooltips now use the stock Base UI Luma treatment, keyboard shortcuts navigate directly, and utility surfaces feel lighter and more deliberate.",
+];
+
+const NEWS_SECTIONS = [
+ {
+ title: "Highlights",
+ items: [
+ "Refined sidebar spacing and row geometry",
+ "Cleaner tooltip styling with integrated shortcuts",
+ "Collapsed utility actions now behave like the expanded menus",
+ ],
+ },
+ {
+ title: "Polish",
+ items: [
+ "Softer 2% hover fill and subtle press feedback",
+ "News card at the footer with modal expansion",
+ "Keyboard navigation for Dashboard, Team, and Settings",
+ ],
+ },
+] as const;
+
+type BoxSnapshot = {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+ borderRadius: number;
+};
+
+type CloseAnimationState = {
+ from: BoxSnapshot;
+ to: BoxSnapshot;
+};
+
+function measureBoxSnapshot(element: HTMLElement): BoxSnapshot {
+ const rect = element.getBoundingClientRect();
+ const computedStyles = window.getComputedStyle(element);
+
+ return {
+ top: rect.top,
+ left: rect.left,
+ width: rect.width,
+ height: rect.height,
+ borderRadius:
+ Number.parseFloat(computedStyles.borderTopLeftRadius || "0") || 0,
+ };
+}
+
+function NewsCardInner({ expanded = false }: { expanded?: boolean }) {
+ return (
+
+
+ New shell design is live.
+
+ {expanded ? null : (
+
+ )}
+
+ );
+}
+
+function NewsChromeCloseButton({
+ onClick,
+ label,
+ layoutId,
+ className,
+ iconClassName,
+ style,
+ animate,
+ icon = "close",
+}: {
+ onClick: (event: React.MouseEvent) => void;
+ label: string;
+ layoutId?: string;
+ className?: string;
+ iconClassName?: string;
+ style?: React.CSSProperties;
+ animate?: { opacity: number };
+ icon?: "close" | "minimize";
+}) {
+ return (
+
+ {icon === "minimize" ? (
+
+ ) : (
+
+ )}
+ {label}
+
+ );
+}
+
+function NewsDialogBody({
+ onRequestClose,
+ onAcknowledge,
+ semantic = false,
+ animate = true,
+ useSharedLayout = true,
+}: {
+ onRequestClose: () => void;
+ onAcknowledge?: () => void;
+ semantic?: boolean;
+ animate?: boolean;
+ useSharedLayout?: boolean;
+}) {
+ const TitleTag = semantic ? DialogTitle : "h2";
+ const DescriptionTag = semantic ? DialogDescription : "p";
+ const BodyWrapper = animate ? motion.div : "div";
+ const titleClassName = semantic
+ ? undefined
+ : "font-heading text-base leading-none font-medium";
+ const descriptionClassName = semantic
+ ? undefined
+ : "text-sm text-muted-foreground";
+
+ return (
+
+ onRequestClose()}
+ label="Minimize news"
+ layoutId={useSharedLayout ? CLOSE_BUTTON_LAYOUT_ID : undefined}
+ className="absolute top-4 right-4 size-8"
+ iconClassName="size-4"
+ icon="minimize"
+ />
+
+
+ {NEWS_DATE}
+
+ Changelog
+
+
+ New shell design
+
+ The modal now uses the stock Base UI Luma dialog treatment for the
+ header, body, footer, and close affordance.
+
+
+
+
+
What changed
+
+ {NEWS_PARAGRAPHS.map((paragraph) => (
+
+ {paragraph}
+
+ ))}
+
+
+ {NEWS_SECTIONS.map((section) => (
+
+
+ {section.title}
+
+
+ {section.items.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+ ))}
+
+
+
+ Minimize
+
+
+ Got it
+
+
+
+ );
+}
+
+function NewsCloseAnimationOverlay({
+ closeAnimation,
+ newsDebugTuning,
+ onComplete,
+}: {
+ closeAnimation: CloseAnimationState;
+ newsDebugTuning: SidebarShellTuningState;
+ onComplete: () => void;
+}) {
+ const scaleX = closeAnimation.to.width / closeAnimation.from.width;
+ const scaleY = closeAnimation.to.height / closeAnimation.from.height;
+ const promotedModalStyle = newsDebugTuning.newsPromoteModalCompositorLayer
+ ? {
+ willChange: "transform, opacity",
+ contain: "paint",
+ isolation: "isolate" as const,
+ backfaceVisibility: "hidden" as const,
+ }
+ : undefined;
+
+ return createPortal(
+ <>
+
+
+ {}}
+ onAcknowledge={() => {}}
+ useSharedLayout={false}
+ />
+
+ >,
+ document.body,
+ );
+}
+
+export function SidebarNewsCard({
+ onDismiss,
+}: {
+ onDismiss?: () => void;
+}) {
+ const [searchParams] = useSearchParams();
+ const debugState = getSidebarShellDebugState(searchParams);
+ const [open, setOpen] = React.useState(false);
+ const [isMorphing, setIsMorphing] = React.useState(false);
+ const [isAcknowledging, setIsAcknowledging] = React.useState(false);
+ const [closeAnimation, setCloseAnimation] =
+ React.useState(null);
+ const triggerRef = React.useRef(null);
+ const popupRef = React.useRef(null);
+ const newsDebugTuning = debugState.tuning;
+ const useSharedLayout = newsDebugTuning.newsUseSharedLayout;
+ const useMeasuredClose = newsDebugTuning.newsUseMeasuredClose;
+ const usePlainFixedPopup = newsDebugTuning.newsUsePlainFixedPopup;
+ const isCloseAnimating = closeAnimation != null;
+ const isTriggerHidden = open || isCloseAnimating;
+ const promotedModalStyle = newsDebugTuning.newsPromoteModalCompositorLayer
+ ? {
+ willChange: "transform, opacity",
+ contain: "paint",
+ isolation: "isolate" as const,
+ backfaceVisibility: "hidden" as const,
+ }
+ : undefined;
+
+ const startCloseAnimation = React.useCallback(() => {
+ if (isCloseAnimating) {
+ return;
+ }
+
+ if (!useMeasuredClose) {
+ setIsMorphing(false);
+ setOpen(false);
+ return;
+ }
+
+ const trigger = triggerRef.current;
+ const popup = popupRef.current;
+
+ if (!trigger || !popup) {
+ setOpen(false);
+ return;
+ }
+
+ setCloseAnimation({
+ from: measureBoxSnapshot(popup),
+ to: measureBoxSnapshot(trigger),
+ });
+ setIsMorphing(false);
+ setOpen(false);
+ }, [isCloseAnimating, useMeasuredClose]);
+
+ const acknowledgeNews = React.useCallback(() => {
+ setCloseAnimation(null);
+ setIsMorphing(false);
+ setIsAcknowledging(true);
+ setOpen(false);
+ }, []);
+
+ const handleOpenChange = React.useCallback(
+ (nextOpen: boolean) => {
+ if (nextOpen) {
+ setCloseAnimation(null);
+ setIsAcknowledging(false);
+ setOpen(true);
+ return;
+ }
+
+ if (open) {
+ startCloseAnimation();
+ }
+ },
+ [open, startCloseAnimation],
+ );
+
+ React.useEffect(() => {
+ const trigger = triggerRef.current;
+ if (!trigger) {
+ return;
+ }
+
+ const sidebarContainer = trigger.closest(".dashboard-01-chrome-sidebar");
+ const previewContainer = trigger.closest(".dashboard-01-preview");
+ if (!(sidebarContainer instanceof HTMLElement)) {
+ return;
+ }
+
+ const isActive = open || isMorphing || isCloseAnimating;
+ if (isActive) {
+ sidebarContainer.setAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE, "true");
+ if (previewContainer instanceof HTMLElement) {
+ previewContainer.setAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE, "true");
+ }
+ } else {
+ sidebarContainer.removeAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE);
+ if (previewContainer instanceof HTMLElement) {
+ previewContainer.removeAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE);
+ }
+ }
+
+ return () => {
+ sidebarContainer.removeAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE);
+ if (previewContainer instanceof HTMLElement) {
+ previewContainer.removeAttribute(SIDEBAR_NEWS_ACTIVE_ATTRIBUTE);
+ }
+ };
+ }, [isCloseAnimating, isMorphing, open]);
+
+ const popupMotionProps = useSharedLayout
+ && !isAcknowledging
+ ? {
+ layoutId: CARD_LAYOUT_ID,
+ transition: CARD_SPRING,
+ onLayoutAnimationStart: () => setIsMorphing(true),
+ onLayoutAnimationComplete: () => setIsMorphing(false),
+ }
+ : {
+ initial: { opacity: 0, y: 12, scale: 0.965 },
+ animate: { opacity: 1, y: 0, scale: 1 },
+ exit: { opacity: 0, y: 8, scale: 0.985 },
+ transition: POPUP_TRANSITION,
+ };
+
+ return (
+ <>
+
+
+
+ {closeAnimation ? (
+ setCloseAnimation(null)}
+ />
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/features/shell/components/SidebarNewsPopover.tsx b/apps/web/src/features/shell/components/SidebarNewsPopover.tsx
new file mode 100644
index 00000000..06cceac3
--- /dev/null
+++ b/apps/web/src/features/shell/components/SidebarNewsPopover.tsx
@@ -0,0 +1,181 @@
+"use client";
+
+import * as React from "react";
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";
+import {
+ AnimatePresence,
+ LayoutGroup,
+ motion,
+} from "motion/react";
+import {
+ ArrowUpRightIcon,
+ SparklesIcon,
+ XIcon,
+} from "lucide-react";
+import { Button } from "@/app/ui/button";
+import { cn } from "@/lib/utils";
+
+/* ─────────────────────────────────────────────────────────
+ * ANIMATION STORYBOARD
+ *
+ * Read top-to-bottom. Each `at` value is ms after trigger.
+ *
+ * 0ms sidebar card is clicked
+ * 40ms backdrop fades in
+ * 280ms card morphs from rail width -> centered modal
+ * 120ms modal copy fades in after the shell settles
+ * ───────────────────────────────────────────────────────── */
+
+const TIMING = {
+ backdropIn: 40,
+ cardMorph: 280,
+ contentIn: 120,
+} as const;
+
+const CARD_LAYOUT_ID = "sidebar-news-card";
+
+const CARD_SPRING = {
+ type: "spring" as const,
+ stiffness: 360,
+ damping: 34,
+ mass: 0.9,
+};
+
+const BACKDROP_TRANSITION = {
+ delay: TIMING.backdropIn / 1000,
+ duration: 0.18,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+const CONTENT_TRANSITION = {
+ duration: 0.18,
+ delay: TIMING.contentIn / 1000,
+ ease: [0.22, 1, 0.36, 1] as const,
+};
+
+function NewsCardInner({ expanded = false }: { expanded?: boolean }) {
+ return (
+ <>
+
+
+ What's new
+
+
+
+
+
+ Card morph into modal
+
+
+ Click to expand this sidebar card into a centered modal.
+
+
+ {expanded ? null : (
+
+ )}
+
+
+ >
+ );
+}
+
+export function SidebarNewsPopover() {
+ const [open, setOpen] = React.useState(false);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/features/shell/components/SidebarShellDebugPanel.tsx b/apps/web/src/features/shell/components/SidebarShellDebugPanel.tsx
new file mode 100644
index 00000000..dd7dc449
--- /dev/null
+++ b/apps/web/src/features/shell/components/SidebarShellDebugPanel.tsx
@@ -0,0 +1,829 @@
+"use client";
+
+import * as React from "react";
+import { useSearchParams } from "react-router-dom";
+import {
+ getDefaultSidebarShellTuningState,
+ getSidebarShellDebugSearchParams,
+ type SidebarShellBooleanTuningKey,
+ type SidebarShellDebugState,
+ type SidebarShellDebugUpdate,
+ type SidebarShellNumericTuningKey,
+ type SidebarShellStringTuningKey,
+ type SidebarShellTuningState,
+} from "@/features/shell/config/sidebar-shell-debug";
+import { cn } from "@/lib/utils";
+
+type SidebarShellTrace = {
+ gapWidth: string;
+ containerWidth: string;
+ innerWidth: string;
+ insetWidth: string;
+ firstNavRowWidth: string;
+ firstNavIconLaneWidth: string;
+ firstNavLabelWidth: string;
+ workspaceRowWidth: string;
+ userRowWidth: string;
+};
+
+type NumericControl = {
+ key: SidebarShellNumericTuningKey;
+ label: string;
+ min: number;
+ max: number;
+ step: number;
+ unit?: "rem" | "number";
+};
+
+type ColorControl = {
+ key: SidebarShellStringTuningKey;
+ label: string;
+ placeholder?: string;
+};
+
+type ToggleControl = {
+ key: SidebarShellBooleanTuningKey;
+ label: string;
+ description: string;
+};
+
+type TargetMeta = {
+ label: string;
+ className: string;
+};
+
+const numericSections: Array<{
+ title: string;
+ controls: NumericControl[];
+}> = [
+ {
+ title: "Shell",
+ controls: [
+ {
+ key: "collapsedWidth",
+ label: "Collapsed width",
+ min: 2.5,
+ max: 5,
+ step: 0.125,
+ },
+ {
+ key: "expandedWidth",
+ label: "Expanded width",
+ min: 10,
+ max: 18,
+ step: 0.25,
+ },
+ ],
+ },
+ {
+ title: "Sections",
+ controls: [
+ {
+ key: "sectionMarginTop",
+ label: "Top margin",
+ min: 0,
+ max: 1.5,
+ step: 0.0625,
+ },
+ {
+ key: "railInsetLeft",
+ label: "Rail inset left",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "railInsetRight",
+ label: "Rail inset right",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "collapsedSectionPaddingX",
+ label: "Collapsed top left/right",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "expandedSectionPaddingX",
+ label: "Expanded top left/right",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "collapsedFooterPaddingX",
+ label: "Collapsed bottom left/right",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "expandedFooterPaddingX",
+ label: "Expanded bottom left/right",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "expandedFooterPaddingBottom",
+ label: "Expanded bottom Y",
+ min: 0,
+ max: 1.5,
+ step: 0.0625,
+ },
+ {
+ key: "collapsedStackGap",
+ label: "Collapsed gap",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "expandedStackGap",
+ label: "Expanded gap",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ ],
+ },
+ {
+ title: "Rows",
+ controls: [
+ {
+ key: "rowHeight",
+ label: "Row height",
+ min: 1.75,
+ max: 3,
+ step: 0.0625,
+ },
+ {
+ key: "rowRadius",
+ label: "Row radius",
+ min: 0,
+ max: 1.25,
+ step: 0.0625,
+ },
+ {
+ key: "collapsedRowPaddingLeft",
+ label: "Collapsed hover left of icon",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "collapsedRowPaddingRight",
+ label: "Collapsed hover right of icon",
+ min: 0,
+ max: 1,
+ step: 0.0625,
+ },
+ {
+ key: "rowPaddingLeft",
+ label: "Expanded row padding left",
+ min: 0,
+ max: 1.5,
+ step: 0.0625,
+ },
+ {
+ key: "rowPaddingRight",
+ label: "Expanded row padding right",
+ min: 0,
+ max: 1.5,
+ step: 0.0625,
+ },
+ {
+ key: "rowGap",
+ label: "Row gap",
+ min: 0,
+ max: 1.5,
+ step: 0.0625,
+ },
+ {
+ key: "iconLaneSize",
+ label: "Icon lane",
+ min: 1.75,
+ max: 3,
+ step: 0.0625,
+ },
+ {
+ key: "iconSize",
+ label: "Icon size",
+ min: 0.875,
+ max: 1.75,
+ step: 0.0625,
+ },
+ {
+ key: "avatarSize",
+ label: "Avatar size",
+ min: 1,
+ max: 2,
+ step: 0.0625,
+ },
+ {
+ key: "labelFontSize",
+ label: "Label size",
+ min: 0.75,
+ max: 1.125,
+ step: 0.0625,
+ },
+ {
+ key: "shortcutFontSize",
+ label: "Shortcut size",
+ min: 0.5,
+ max: 0.875,
+ step: 0.03125,
+ },
+ ],
+ },
+ {
+ title: "News Modal",
+ controls: [
+ {
+ key: "newsCardTriggerZ",
+ label: "Card z-index",
+ min: 0,
+ max: 300,
+ step: 1,
+ unit: "number",
+ },
+ {
+ key: "newsBackdropZ",
+ label: "Backdrop z-index",
+ min: 0,
+ max: 300,
+ step: 1,
+ unit: "number",
+ },
+ {
+ key: "newsPopupZ",
+ label: "Popup z-index",
+ min: 0,
+ max: 300,
+ step: 1,
+ unit: "number",
+ },
+ {
+ key: "newsActiveSidebarZ",
+ label: "Active sidebar z-index",
+ min: 0,
+ max: 300,
+ step: 1,
+ unit: "number",
+ },
+ ],
+ },
+];
+
+const REM_IN_PX = 16;
+
+const colorControls: ColorControl[] = [
+ { key: "rowIdleBg", label: "Idle fill", placeholder: "transparent" },
+ {
+ key: "rowHoverBg",
+ label: "Hover fill",
+ placeholder: "var(--dashboard-01-rail-hover)",
+ },
+ { key: "rowActiveBg", label: "Active fill", placeholder: "white" },
+ {
+ key: "rowFg",
+ label: "Base text/icon",
+ placeholder: "var(--dashboard-01-rail-icon)",
+ },
+ {
+ key: "rowActiveFg",
+ label: "Active text/icon",
+ placeholder: "var(--dashboard-01-rail-icon-active)",
+ },
+];
+
+const toggleControls: ToggleControl[] = [
+ {
+ key: "newsPromoteSidebar",
+ label: "Promote sidebar while active",
+ description:
+ "Raises the sidebar container only while the news card modal or morph is active.",
+ },
+ {
+ key: "newsSidebarOverflowVisible",
+ label: "Sidebar overflow visible while active",
+ description:
+ "Disables sidebar clipping only while the news card modal or morph is active.",
+ },
+ {
+ key: "newsUseSharedLayout",
+ label: "Use shared layout morph",
+ description:
+ "Runs the open and close transition through Motion's shared layoutId handoff.",
+ },
+ {
+ key: "newsUseMeasuredClose",
+ label: "Use measured close overlay",
+ description:
+ "On close, measures the popup and card and animates a fixed clone back to the sidebar.",
+ },
+ {
+ key: "newsUsePlainFixedPopup",
+ label: "Use plain fixed popup",
+ description:
+ "Bypasses Base UI's Popup primitive and renders the open state as a plain fixed portal layer.",
+ },
+ {
+ key: "newsHidePerformanceChartWhileActive",
+ label: "Hide performance chart while active",
+ description:
+ "Temporarily hides the dashboard performance chart while the news card modal or morph is active.",
+ },
+ {
+ key: "newsDisableChartInteractiveLayersWhileActive",
+ label: "Disable chart tooltip layers while active",
+ description:
+ "Turns off the chart tooltip and foreignObject hover layer while the news card modal or morph is active.",
+ },
+ {
+ key: "newsPromoteModalCompositorLayer",
+ label: "Promote modal compositor layer",
+ description:
+ "Adds aggressive compositor hints to the modal popup and close clone while the news card animates.",
+ },
+];
+
+function formatPx(value?: number) {
+ return typeof value === "number" && Number.isFinite(value)
+ ? `${value.toFixed(1)}px`
+ : "n/a";
+}
+
+function getTargetMeta(key: keyof SidebarShellTuningState): TargetMeta {
+ switch (key) {
+ case "collapsedWidth":
+ case "expandedWidth":
+ case "railInsetLeft":
+ case "railInsetRight":
+ return {
+ label: "Container",
+ className:
+ "bg-violet-500/12 text-violet-700 ring-1 ring-violet-500/30 dark:text-violet-300",
+ };
+ case "iconLaneSize":
+ case "iconSize":
+ case "avatarSize":
+ return {
+ label: "Icon",
+ className:
+ "bg-sky-500/12 text-sky-700 ring-1 ring-sky-500/30 dark:text-sky-300",
+ };
+ case "labelFontSize":
+ case "shortcutFontSize":
+ return {
+ label: "Label",
+ className:
+ "bg-rose-500/12 text-rose-700 ring-1 ring-rose-500/30 dark:text-rose-300",
+ };
+ case "newsCardTriggerZ":
+ case "newsBackdropZ":
+ case "newsPopupZ":
+ case "newsActiveSidebarZ":
+ case "newsPromoteSidebar":
+ case "newsSidebarOverflowVisible":
+ return {
+ label: "Layer",
+ className:
+ "bg-emerald-500/12 text-emerald-700 ring-1 ring-emerald-500/30 dark:text-emerald-300",
+ };
+ case "newsUseSharedLayout":
+ case "newsUseMeasuredClose":
+ case "newsUsePlainFixedPopup":
+ case "newsHidePerformanceChartWhileActive":
+ case "newsDisableChartInteractiveLayersWhileActive":
+ case "newsPromoteModalCompositorLayer":
+ return {
+ label: "Experiment",
+ className:
+ "bg-cyan-500/12 text-cyan-700 ring-1 ring-cyan-500/30 dark:text-cyan-300",
+ };
+ default:
+ return {
+ label: "Row",
+ className:
+ "bg-amber-500/12 text-amber-700 ring-1 ring-amber-500/30 dark:text-amber-300",
+ };
+ }
+}
+
+function NumberField({
+ key,
+ label,
+ value,
+ min,
+ max,
+ step,
+ unit,
+ onChange,
+}: NumericControl & {
+ value: number;
+ onChange: (nextValue: number) => void;
+}) {
+ const target = getTargetMeta(key);
+ const usesRawValue = unit === "number";
+ const sliderValue = usesRawValue ? value : value * REM_IN_PX;
+ const sliderMin = usesRawValue ? min : min * REM_IN_PX;
+ const sliderMax = usesRawValue ? max : max * REM_IN_PX;
+ const sliderStep = usesRawValue ? step : step * REM_IN_PX;
+ const displayPrecision = Number.isInteger(sliderStep) ? 0 : 1;
+ const displayValue = usesRawValue
+ ? sliderValue.toFixed(displayPrecision)
+ : `${sliderValue.toFixed(displayPrecision)} px`;
+
+ return (
+
+
+
+ {label}
+
+ {target.label}
+
+
+
{displayValue}
+
+
+ onChange(
+ usesRawValue
+ ? Number.parseFloat(event.target.value)
+ : Number.parseFloat(event.target.value) / REM_IN_PX,
+ )
+ }
+ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-muted accent-foreground"
+ />
+
+ );
+}
+
+function TextField({
+ key,
+ label,
+ value,
+ placeholder,
+ onChange,
+}: ColorControl & {
+ value: string;
+ onChange: (nextValue: string) => void;
+}) {
+ const target = getTargetMeta(key);
+
+ return (
+
+
+ {label}
+
+ {target.label}
+
+
+ onChange(event.target.value)}
+ className="h-8 rounded-md border border-border/70 bg-background px-2 font-mono text-[11px] text-foreground outline-none ring-0 placeholder:text-muted-foreground"
+ />
+
+ );
+}
+
+function ToggleField({
+ control,
+ value,
+ onChange,
+}: {
+ control: ToggleControl;
+ value: boolean;
+ onChange: (nextValue: boolean) => void;
+}) {
+ const target = getTargetMeta(control.key);
+
+ return (
+
+
+
+
+ {control.label}
+
+
+ {target.label}
+
+
+
+ {control.description}
+
+
+
onChange(!value)}
+ className={cn(
+ "rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
+ value
+ ? "bg-foreground text-background"
+ : "bg-background text-foreground ring-1 ring-border",
+ )}
+ >
+ {value ? "On" : "Off"}
+
+
+ );
+}
+
+export function SidebarShellDebugPanel({
+ debugState,
+}: {
+ debugState: SidebarShellDebugState;
+}) {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [trace, setTrace] = React.useState(null);
+
+ const updateDebugState = React.useCallback(
+ (nextState: SidebarShellDebugUpdate) => {
+ setSearchParams(
+ getSidebarShellDebugSearchParams(searchParams, nextState),
+ {
+ replace: true,
+ },
+ );
+ },
+ [searchParams, setSearchParams],
+ );
+
+ React.useEffect(() => {
+ if (!debugState.enabled) {
+ setTrace(null);
+ return;
+ }
+
+ let frameId = 0;
+ const readTrace = () => {
+ const activeLayer = document.querySelector(
+ '[data-sidebar-layer-active="true"]',
+ );
+ const gap = document.querySelector(
+ '[data-slot="sidebar-gap"]',
+ );
+ const container = document.querySelector(
+ '[data-slot="sidebar-container"]',
+ );
+ const inner = document.querySelector(
+ '[data-slot="sidebar-inner"]',
+ );
+ const inset = document.querySelector(
+ '[data-slot="sidebar-inset"]',
+ );
+ const firstNavRow =
+ activeLayer?.querySelector("[data-sidebar-nav-row]") ??
+ document.querySelector("[data-sidebar-nav-row]");
+ const firstNavIconLane =
+ activeLayer?.querySelector(
+ "[data-sidebar-nav-icon-lane]",
+ ) ??
+ document.querySelector("[data-sidebar-nav-icon-lane]");
+ const firstNavLabel =
+ activeLayer?.querySelector("[data-sidebar-nav-label]") ??
+ document.querySelector("[data-sidebar-nav-label]");
+ const workspaceRow =
+ activeLayer?.querySelector(
+ "[data-sidebar-workspace-row]",
+ ) ??
+ document.querySelector("[data-sidebar-workspace-row]");
+ const userRow =
+ activeLayer?.querySelector("[data-sidebar-user-row]") ??
+ document.querySelector("[data-sidebar-user-row]");
+
+ setTrace({
+ gapWidth: formatPx(gap?.getBoundingClientRect().width),
+ containerWidth: formatPx(container?.getBoundingClientRect().width),
+ innerWidth: formatPx(inner?.getBoundingClientRect().width),
+ insetWidth: formatPx(inset?.getBoundingClientRect().width),
+ firstNavRowWidth: formatPx(firstNavRow?.getBoundingClientRect().width),
+ firstNavIconLaneWidth: formatPx(
+ firstNavIconLane?.getBoundingClientRect().width,
+ ),
+ firstNavLabelWidth: formatPx(
+ firstNavLabel?.getBoundingClientRect().width,
+ ),
+ workspaceRowWidth: formatPx(
+ workspaceRow?.getBoundingClientRect().width,
+ ),
+ userRowWidth: formatPx(userRow?.getBoundingClientRect().width),
+ });
+
+ frameId = window.requestAnimationFrame(readTrace);
+ };
+
+ frameId = window.requestAnimationFrame(readTrace);
+ return () => window.cancelAnimationFrame(frameId);
+ }, [debugState.enabled]);
+
+ if (!debugState.enabled) {
+ return (
+ updateDebugState({ enabled: true })}
+ className="pointer-events-auto fixed right-4 top-4 z-[120] rounded-full border border-foreground/15 bg-background/92 px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-foreground shadow-lg ring-1 ring-foreground/10 backdrop-blur-md transition-colors hover:bg-background"
+ >
+ Open Shell HUD
+
+ );
+ }
+
+ return (
+
+
+
+
+ Shell HUD
+
+
+ Live controls for the local sidebar island and news modal layering.
+
+
+
+
+ updateDebugState({ tuning: getDefaultSidebarShellTuningState() })
+ }
+ >
+ Reset
+
+ updateDebugState({ enabled: false })}
+ >
+ Close
+
+
+
+
+
+
+ Debug Borders
+
+
+ Toggle the paint map without losing measurements or tuning.
+
+
+
+ updateDebugState({ showBorders: !debugState.showBorders })
+ }
+ className={cn(
+ "rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
+ debugState.showBorders
+ ? "bg-foreground text-background"
+ : "bg-background text-foreground ring-1 ring-border",
+ )}
+ >
+ {debugState.showBorders ? "On" : "Off"}
+
+
+
+
+
+
+ Always Show Labels
+
+
+ Label
+
+
+
+ Keep labels rendered and let the shell clip them instead of the row.
+
+
+
+ updateDebugState({
+ alwaysShowLabels: !debugState.alwaysShowLabels,
+ })
+ }
+ className={cn(
+ "rounded-full px-3 py-1.5 text-xs font-medium transition-colors",
+ debugState.alwaysShowLabels
+ ? "bg-foreground text-background"
+ : "bg-background text-foreground ring-1 ring-border",
+ )}
+ >
+ {debugState.alwaysShowLabels ? "On" : "Off"}
+
+
+
+
+
+ News Modal Toggles
+
+ {toggleControls.map((control) => (
+
+ updateDebugState({
+ tuning: {
+ [control.key]: nextValue,
+ } as Partial,
+ })
+ }
+ />
+ ))}
+
+ {numericSections.map((section) => (
+
+
+ {section.title}
+
+ {section.controls.map((control) => {
+ const { key, ...fieldProps } = control;
+ return (
+
+ updateDebugState({
+ tuning: {
+ [key]: nextValue,
+ } as Partial,
+ })
+ }
+ />
+ );
+ })}
+
+ ))}
+
+
+ Fills
+
+ {colorControls.map((control) => {
+ const { key, ...fieldProps } = control;
+ return (
+
+ updateDebugState({
+ tuning: {
+ [key]: nextValue,
+ } as Partial,
+ })
+ }
+ />
+ );
+ })}
+
+ {trace ? (
+
+
gap.width: {trace.gapWidth}
+
container.width: {trace.containerWidth}
+
inner.width: {trace.innerWidth}
+
inset.width: {trace.insetWidth}
+
nav.row.width: {trace.firstNavRowWidth}
+
nav.icon.width: {trace.firstNavIconLaneWidth}
+
nav.label.width: {trace.firstNavLabelWidth}
+
workspace.width: {trace.workspaceRowWidth}
+
user.width: {trace.userRowWidth}
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/features/shell/components/SiteHeader.tsx b/apps/web/src/features/shell/components/SiteHeader.tsx
index b73fca8b..9446de4c 100644
--- a/apps/web/src/features/shell/components/SiteHeader.tsx
+++ b/apps/web/src/features/shell/components/SiteHeader.tsx
@@ -1,70 +1,32 @@
-import { ChevronRight } from "lucide-react";
-import { Link, useLocation } from "react-router-dom";
-import { useAnalyticsTracking } from "@/hooks/useDashboardAnalytics";
-import { useUserMap } from "@/hooks/useUserMap";
-import { formatUsername } from "@/lib/format";
-
-const segmentLabels: Record = {
- dashboard: "Overview",
- developers: "Developers",
- projects: "Projects",
- roi: "ROI Calculator",
- sessions: "Sessions",
- learnings: "Learnings",
- errors: "Errors",
- invitations: "Invitations",
- profile: "Profile",
- organization: "Organization",
- admin: "Admin",
- new: "Create organization",
-};
+import { useLocation } from "react-router-dom";
+import { Separator } from "@/app/ui/separator";
+import { SidebarTrigger } from "@/app/ui/sidebar";
+import {
+ getActiveSettingsRouteId,
+ settingsRouteMap,
+} from "@/features/settings/config/settings-routes";
+import { useCurrentShellRoute } from "@/features/shell/hooks/useCurrentShellRoute";
export function SiteHeader() {
- const { pathname } = useLocation();
- const { trackNavigation } = useAnalyticsTracking();
- const { userMap } = useUserMap();
- const segments = pathname.split("/").filter(Boolean);
-
- const crumbs = segments.map((segment, index) => {
- const href = `/${segments.slice(0, index + 1).join("/")}`;
- const previousSegment = index > 0 ? segments[index - 1] : null;
- const isDeveloperSegment = previousSegment === "developers";
- const label = isDeveloperSegment
- ? formatUsername(segment, userMap)
- : (segmentLabels[segment] ?? decodeURIComponent(segment));
-
- return {
- href,
- label,
- isLast: index === segments.length - 1,
- };
- });
+ const location = useLocation();
+ const currentShellRoute = useCurrentShellRoute();
+ const title =
+ currentShellRoute.id === "settings"
+ ? settingsRouteMap[getActiveSettingsRouteId(location.pathname)].label
+ : currentShellRoute.title;
return (
-
- {crumbs.map((crumb, index) => (
-
- {index > 0 ? : null}
- {crumb.isLast ? (
- {crumb.label}
- ) : (
- {
- trackNavigation({
- navType: "breadcrumb",
- sourceComponent: "site_header",
- targetPath: crumb.href,
- targetType: "page",
- });
- }}
- className="transition-colors hover:text-foreground"
- >
- {crumb.label}
-
- )}
-
- ))}
-
+
);
}
diff --git a/apps/web/src/features/shell/components/UserRailButton.tsx b/apps/web/src/features/shell/components/UserRailButton.tsx
new file mode 100644
index 00000000..477981c9
--- /dev/null
+++ b/apps/web/src/features/shell/components/UserRailButton.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { LogOutIcon, Settings2Icon } from "lucide-react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { appRoutes } from "@/app/routes";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/ui/dropdown-menu";
+import {
+ getInitials,
+ getSidebarIconLaneDebugClassName,
+ getSidebarLabelLaneDebugClassName,
+ getSidebarRowDebugClassName,
+ getUtilityRailItemClassName,
+ getUtilityRailLabelClassName,
+ type SidebarRowDebugProps,
+ type SidebarRowMode,
+} from "@/features/shell/components/shell-rail";
+import { appendSidebarShellDebugParams } from "@/features/shell/config/sidebar-shell-debug";
+import { authClient, signOut } from "@/lib/auth-client";
+import { cn } from "@/lib/utils";
+
+export function UserRailButton({
+ debugShowBorders,
+ debugVariant,
+ forceShowLabels,
+ mode = "expanded",
+}: SidebarRowDebugProps & { mode?: SidebarRowMode }) {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const { data: session } = authClient.useSession();
+
+ const name =
+ session?.user &&
+ "name" in session.user &&
+ typeof session.user.name === "string"
+ ? session.user.name
+ : undefined;
+ const email =
+ session?.user &&
+ "email" in session.user &&
+ typeof session.user.email === "string"
+ ? session.user.email
+ : undefined;
+ const image =
+ session?.user &&
+ "image" in session.user &&
+ typeof session.user.image === "string"
+ ? session.user.image
+ : undefined;
+ const accountLabel = name ?? email ?? "Account";
+
+ return (
+
+
+ }
+ >
+
+
+ {image ? (
+
+ ) : (
+
+ {getInitials(name, email)}
+
+ )}
+
+
+
+ {accountLabel}
+
+
+
+
+ navigate(
+ appendSidebarShellDebugParams(
+ appRoutes.settingsAccount(),
+ searchParams,
+ ),
+ )
+ }
+ >
+
+ Profile settings
+
+
+ {
+ await signOut();
+ navigate(appendSidebarShellDebugParams("/", searchParams));
+ }}
+ >
+
+ Log out
+
+
+
+ );
+}
diff --git a/apps/web/src/features/shell/components/WorkspaceMenuButton.tsx b/apps/web/src/features/shell/components/WorkspaceMenuButton.tsx
new file mode 100644
index 00000000..8513d6c0
--- /dev/null
+++ b/apps/web/src/features/shell/components/WorkspaceMenuButton.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { CheckIcon, CommandIcon, Settings2Icon } from "lucide-react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import { appRoutes } from "@/app/routes";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/app/ui/dropdown-menu";
+import {
+ getSidebarIconLaneDebugClassName,
+ getSidebarLabelLaneDebugClassName,
+ getSidebarRowDebugClassName,
+ getUtilityRailItemClassName,
+ getUtilityRailLabelClassName,
+ type SidebarRowDebugProps,
+ type SidebarRowMode,
+} from "@/features/shell/components/shell-rail";
+import { appendSidebarShellDebugParams } from "@/features/shell/config/sidebar-shell-debug";
+import workspaceIcon from "@/features/team/assets/team-lineup-workspace-icon-v5.png";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+import { cn } from "@/lib/utils";
+
+export function WorkspaceMenuButton({
+ debugShowBorders,
+ debugVariant,
+ forceShowLabels,
+ mode = "expanded",
+}: SidebarRowDebugProps & { mode?: SidebarRowMode }) {
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const { state, actions } = useOrganization();
+ const workspaceName = state.activeOrg?.name ?? "Workspace";
+
+ return (
+
+
+ }
+ >
+
+
+
+
+
+
+ {workspaceName}
+
+
+
+ {state.organizations.length > 0 ? (
+ state.organizations.map((organization) => (
+ void actions.switchOrganization(organization.id)}
+ >
+ {organization.name}
+ {organization.id === state.activeOrg?.id ? : null}
+
+ ))
+ ) : (
+ No workspaces yet
+ )}
+
+
+ navigate(
+ appendSidebarShellDebugParams(
+ appRoutes.settingsWorkspace(),
+ searchParams,
+ ),
+ )
+ }
+ >
+
+ Workspace settings
+
+
+ navigate(
+ `${appendSidebarShellDebugParams(
+ appRoutes.settingsWorkspace(),
+ searchParams,
+ )}#new-workspace`,
+ )
+ }
+ >
+
+ Create workspace
+
+
+
+ );
+}
diff --git a/apps/web/src/features/shell/components/shell-rail.tsx b/apps/web/src/features/shell/components/shell-rail.tsx
new file mode 100644
index 00000000..eecc658a
--- /dev/null
+++ b/apps/web/src/features/shell/components/shell-rail.tsx
@@ -0,0 +1,185 @@
+import type { ReactNode } from "react";
+import { Link, useSearchParams } from "react-router-dom";
+import { Badge } from "@/app/ui/badge";
+import { Kbd } from "@/app/ui/kbd";
+import type { SidebarShellMotionVariant } from "@/app/ui/sidebar";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/app/ui/tooltip";
+import { appendSidebarShellDebugParams } from "@/features/shell/config/sidebar-shell-debug";
+import { cn } from "@/lib/utils";
+
+export type SidebarRowMode = "collapsed" | "expanded";
+
+export type SidebarRowDebugProps = {
+ debugShowBorders?: boolean;
+ debugVariant?: SidebarShellMotionVariant;
+ forceShowLabels?: boolean;
+};
+
+const shellMenuButtonBaseClassName =
+ "relative flex w-full scale-100 items-center gap-[var(--sidebar-row-gap)] overflow-hidden h-[var(--sidebar-row-height)] rounded-full text-left !bg-[var(--sidebar-row-idle-bg)] text-[color:var(--sidebar-row-fg)] outline-none transition-[background-color,color,transform] duration-[160ms] ease-[cubic-bezier(0.23,1,0.32,1)] hover:!bg-[var(--sidebar-row-hover-bg)] hover:!text-[color:var(--sidebar-row-active-fg)] active:scale-[0.98] active:!bg-[var(--sidebar-row-hover-bg)] active:!text-[color:var(--sidebar-row-active-fg)] focus-visible:ring-3 focus-visible:ring-ring/50 data-[active=true]:!bg-[var(--sidebar-row-active-bg)] data-[active=true]:!text-[color:var(--sidebar-row-active-fg)]";
+
+function getShellMenuButtonClassName(mode: SidebarRowMode) {
+ return cn(
+ shellMenuButtonBaseClassName,
+ mode === "expanded"
+ ? "justify-start pl-[var(--sidebar-row-padding-left)] pr-[var(--sidebar-row-padding-right)]"
+ : "justify-start pl-[var(--sidebar-collapsed-row-padding-left)] pr-[var(--sidebar-collapsed-row-padding-right)]",
+ );
+}
+
+export function getUtilityRailItemClassName(
+ mode: SidebarRowMode,
+ forceShowLabels = false,
+) {
+ return cn(
+ getShellMenuButtonClassName(mode),
+ mode === "collapsed" && "!w-auto self-start",
+ forceShowLabels && "overflow-visible",
+ );
+}
+
+export function getSidebarRowDebugClassName({
+ debugShowBorders,
+ debugVariant,
+}: SidebarRowDebugProps) {
+ return debugVariant === "geometry-trace" && debugShowBorders
+ ? "after:pointer-events-none after:absolute after:inset-0 after:z-20 after:rounded-lg after:border-2 after:border-amber-500 after:content-['']"
+ : undefined;
+}
+
+export function getSidebarIconLaneDebugClassName(
+ debugShowBorders?: boolean,
+ debugVariant?: SidebarShellMotionVariant,
+) {
+ return debugVariant === "geometry-trace" && debugShowBorders
+ ? "relative before:pointer-events-none before:absolute before:inset-0 before:rounded-md before:bg-sky-500/10 before:content-[''] after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-md after:border-2 after:border-sky-500 after:content-['']"
+ : undefined;
+}
+
+export function getSidebarLabelLaneDebugClassName(
+ debugShowBorders?: boolean,
+ debugVariant?: SidebarShellMotionVariant,
+) {
+ return debugVariant === "geometry-trace" && debugShowBorders
+ ? "relative before:pointer-events-none before:absolute before:inset-0 before:rounded-md before:bg-rose-500/10 before:content-[''] after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-md after:border-2 after:border-rose-500 after:content-['']"
+ : undefined;
+}
+
+export function getRailLabelClassName(
+ mode: SidebarRowMode,
+ forceShowLabels = false,
+) {
+ return mode === "expanded" || forceShowLabels
+ ? "min-w-0 flex-1 truncate whitespace-nowrap text-[length:var(--sidebar-label-font-size)] font-medium"
+ : "sr-only";
+}
+
+export function getUtilityRailLabelClassName(
+ mode: SidebarRowMode,
+ forceShowLabels = false,
+) {
+ return mode === "expanded" || forceShowLabels
+ ? "min-w-0 flex-1 truncate whitespace-nowrap text-[length:var(--sidebar-label-font-size)] font-medium"
+ : "sr-only";
+}
+
+export function getInitials(name?: string | null, email?: string | null) {
+ const source = (name?.trim() || email?.trim() || "R").split(" ");
+ return source
+ .map((part) => part[0])
+ .join("")
+ .toUpperCase()
+ .slice(0, 2);
+}
+
+export function RailLink({
+ to,
+ label,
+ shortcut,
+ active,
+ badgeLabel,
+ children,
+ debugShowBorders,
+ debugVariant,
+ mode = "collapsed",
+ forceShowLabels,
+}: {
+ to: string;
+ label: string;
+ shortcut?: string;
+ active?: boolean;
+ badgeLabel?: string;
+ children: ReactNode;
+ mode?: SidebarRowMode;
+} & SidebarRowDebugProps) {
+ const [searchParams] = useSearchParams();
+ const resolvedTo = appendSidebarShellDebugParams(to, searchParams);
+ const link = (
+
+
+ {children}
+
+
+ {label}
+
+ {badgeLabel ? (
+
+ {badgeLabel}
+
+ ) : null}
+
+ );
+
+ return (
+
+ {mode === "collapsed" && !forceShowLabels ? (
+
+ {link}
+
+ {shortcut ? (
+
+ {label}
+
+ {shortcut}
+
+
+ ) : (
+ label
+ )}
+
+
+ ) : (
+ link
+ )}
+
+ );
+}
diff --git a/apps/web/src/features/shell/config/shell-routes.tsx b/apps/web/src/features/shell/config/shell-routes.tsx
index 14cef99a..27a0ce9b 100644
--- a/apps/web/src/features/shell/config/shell-routes.tsx
+++ b/apps/web/src/features/shell/config/shell-routes.tsx
@@ -1,99 +1,57 @@
-import type { LucideIcon } from "lucide-react";
-import {
- AlertCircle,
- BookOpen,
- Clock,
- DollarSign,
- FolderKanban,
- LayoutDashboard,
- UserCircle,
- Users,
-} from "lucide-react";
+import { Settings2Icon, StarIcon, UsersIcon } from "lucide-react";
+import type { ReactElement } from "react";
+import { appRoutes } from "@/app/routes";
-export type ShellRouteId =
- | "overview"
- | "team"
- | "developers"
- | "projects"
- | "sessions"
- | "learnings"
- | "errors"
- | "roi";
+export type ShellRouteId = "dashboard" | "team" | "settings";
+export type ShellRouteIcon = ReactElement<{ size?: number }>;
export type ShellRouteDefinition = {
id: ShellRouteId;
- label: string;
path: string;
- icon: LucideIcon;
- exact?: boolean;
+ title: string;
+ navLabel: string;
+ shortcut: string;
+ icon: ShellRouteIcon;
};
-export const primaryShellRoutes: readonly ShellRouteDefinition[] = [
+export const shellRoutes = [
{
- id: "overview",
- label: "Overview",
- path: "/dashboard",
- icon: LayoutDashboard,
- exact: true,
+ id: "dashboard",
+ path: appRoutes.dashboard(),
+ title: "Dashboard",
+ navLabel: "Dashboard",
+ shortcut: "D",
+ icon: ,
},
{
id: "team",
- label: "Team",
- path: "/dashboard/team",
- icon: Users,
+ path: appRoutes.team(),
+ title: "Team",
+ navLabel: "Team",
+ shortcut: "T",
+ icon: ,
},
{
- id: "developers",
- label: "Developers",
- path: "/dashboard/developers",
- icon: UserCircle,
+ id: "settings",
+ path: appRoutes.settings(),
+ title: "Settings",
+ navLabel: "Settings",
+ shortcut: "S",
+ icon: ,
},
- {
- id: "projects",
- label: "Projects",
- path: "/dashboard/projects",
- icon: FolderKanban,
- },
- {
- id: "sessions",
- label: "Sessions",
- path: "/dashboard/sessions",
- icon: Clock,
- },
- {
- id: "learnings",
- label: "Learnings",
- path: "/dashboard/learnings",
- icon: BookOpen,
- },
- {
- id: "errors",
- label: "Errors",
- path: "/dashboard/errors",
- icon: AlertCircle,
- },
- {
- id: "roi",
- label: "ROI Calculator",
- path: "/dashboard/roi",
- icon: DollarSign,
- },
-] as const;
-
-export function isShellRouteActive(
- pathname: string,
- route: ShellRouteDefinition,
-): boolean {
- if (route.exact) {
- return pathname === route.path;
- }
+] satisfies readonly ShellRouteDefinition[];
- return pathname === route.path || pathname.startsWith(`${route.path}/`);
-}
+export const shellRouteMap = {
+ dashboard: shellRoutes[0],
+ team: shellRoutes[1],
+ settings: shellRoutes[2],
+} as const;
export function getCurrentShellRoute(pathname: string): ShellRouteDefinition {
return (
- primaryShellRoutes.find((route) => isShellRouteActive(pathname, route)) ??
- primaryShellRoutes[0]
+ shellRoutes.find(
+ (route) =>
+ pathname === route.path || pathname.startsWith(`${route.path}/`),
+ ) ?? shellRouteMap.dashboard
);
}
diff --git a/apps/web/src/features/shell/config/sidebar-news.ts b/apps/web/src/features/shell/config/sidebar-news.ts
new file mode 100644
index 00000000..179cd7b1
--- /dev/null
+++ b/apps/web/src/features/shell/config/sidebar-news.ts
@@ -0,0 +1,6 @@
+// Temporary shell-owned flag for the special expanded sidebar treatment.
+// Flip this to true when there is active news to surface.
+export const SHOW_SIDEBAR_NEWS_MODE = false;
+
+// Bump this when the sidebar news content changes so dismissals reset cleanly.
+export const SIDEBAR_NEWS_ITEM_ID = "shell-redesign-apr-6";
diff --git a/apps/web/src/features/shell/config/sidebar-shell-debug.ts b/apps/web/src/features/shell/config/sidebar-shell-debug.ts
new file mode 100644
index 00000000..1887c6c3
--- /dev/null
+++ b/apps/web/src/features/shell/config/sidebar-shell-debug.ts
@@ -0,0 +1,416 @@
+export const SIDEBAR_SHELL_DEBUG_QUERY_PARAM = "__sidebar_shell_debug";
+export const SIDEBAR_SHELL_VARIANT_QUERY_PARAM = "__sidebar_shell";
+export const SIDEBAR_SHELL_BORDERS_QUERY_PARAM = "__sidebar_shell_borders";
+export const SIDEBAR_SHELL_FORCE_LABELS_QUERY_PARAM =
+ "__sidebar_shell_force_labels";
+export const SIDEBAR_NEWS_ACTIVE_ATTRIBUTE = "data-sidebar-news-card-active";
+
+export type SidebarShellDebugVariant = "baseline" | "geometry-trace";
+
+const DEFAULT_VARIANT: SidebarShellDebugVariant = "baseline";
+const DEFAULT_SHOW_BORDERS = true;
+
+const numericTuningConfig = {
+ collapsedWidth: {
+ key: "__sidebar_tune_collapsed_width",
+ defaultValue: 3.5,
+ },
+ expandedWidth: {
+ key: "__sidebar_tune_expanded_width",
+ defaultValue: 13,
+ },
+ sectionMarginTop: {
+ key: "__sidebar_tune_section_margin_top",
+ defaultValue: 0.8,
+ },
+ railInsetLeft: {
+ key: "__sidebar_tune_rail_inset_left",
+ defaultValue: 0.375,
+ },
+ railInsetRight: {
+ key: "__sidebar_tune_rail_inset_right",
+ defaultValue: 0.375,
+ },
+ collapsedSectionPaddingX: {
+ key: "__sidebar_tune_collapsed_section_px",
+ defaultValue: 0.25,
+ },
+ expandedSectionPaddingX: {
+ key: "__sidebar_tune_expanded_section_px",
+ defaultValue: 0,
+ },
+ collapsedFooterPaddingX: {
+ key: "__sidebar_tune_collapsed_footer_px",
+ defaultValue: 0.25,
+ },
+ expandedFooterPaddingX: {
+ key: "__sidebar_tune_expanded_footer_px",
+ defaultValue: 0,
+ },
+ expandedFooterPaddingBottom: {
+ key: "__sidebar_tune_expanded_footer_pb",
+ defaultValue: 0.375,
+ },
+ collapsedStackGap: {
+ key: "__sidebar_tune_collapsed_gap",
+ defaultValue: 0.25,
+ },
+ expandedStackGap: {
+ key: "__sidebar_tune_expanded_gap",
+ defaultValue: 0.25,
+ },
+ rowHeight: {
+ key: "__sidebar_tune_row_height",
+ defaultValue: 2.25,
+ },
+ rowRadius: {
+ key: "__sidebar_tune_row_radius",
+ defaultValue: 0.5,
+ },
+ collapsedRowPaddingLeft: {
+ key: "__sidebar_tune_collapsed_row_pl",
+ defaultValue: 0,
+ },
+ collapsedRowPaddingRight: {
+ key: "__sidebar_tune_collapsed_row_pr",
+ defaultValue: 0,
+ },
+ rowPaddingLeft: {
+ key: "__sidebar_tune_row_pl",
+ defaultValue: 0.25,
+ },
+ rowPaddingRight: {
+ key: "__sidebar_tune_row_pr",
+ defaultValue: 0.25,
+ },
+ rowGap: {
+ key: "__sidebar_tune_row_gap",
+ defaultValue: 0.5,
+ },
+ iconLaneSize: {
+ key: "__sidebar_tune_icon_lane",
+ defaultValue: 2.25,
+ },
+ iconSize: {
+ key: "__sidebar_tune_icon_size",
+ defaultValue: 1.25,
+ },
+ avatarSize: {
+ key: "__sidebar_tune_avatar_size",
+ defaultValue: 1.5,
+ },
+ labelFontSize: {
+ key: "__sidebar_tune_label_size",
+ defaultValue: 0.875,
+ },
+ shortcutFontSize: {
+ key: "__sidebar_tune_shortcut_size",
+ defaultValue: 0.6875,
+ },
+ newsCardTriggerZ: {
+ key: "__sidebar_tune_news_card_trigger_z",
+ defaultValue: 60,
+ },
+ newsBackdropZ: {
+ key: "__sidebar_tune_news_backdrop_z",
+ defaultValue: 50,
+ },
+ newsPopupZ: {
+ key: "__sidebar_tune_news_popup_z",
+ defaultValue: 51,
+ },
+ newsActiveSidebarZ: {
+ key: "__sidebar_tune_news_active_sidebar_z",
+ defaultValue: 10,
+ },
+} as const;
+
+const stringTuningConfig = {
+ rowIdleBg: {
+ key: "__sidebar_tune_idle_bg",
+ defaultValue: "transparent",
+ },
+ rowHoverBg: {
+ key: "__sidebar_tune_hover_bg",
+ defaultValue: "var(--dashboard-01-rail-hover)",
+ },
+ rowActiveBg: {
+ key: "__sidebar_tune_active_bg",
+ defaultValue: "white",
+ },
+ rowFg: {
+ key: "__sidebar_tune_fg",
+ defaultValue: "var(--dashboard-01-rail-icon)",
+ },
+ rowActiveFg: {
+ key: "__sidebar_tune_active_fg",
+ defaultValue: "var(--dashboard-01-rail-icon-active)",
+ },
+} as const;
+
+const booleanTuningConfig = {
+ newsPromoteSidebar: {
+ key: "__sidebar_tune_news_promote_sidebar",
+ defaultValue: false,
+ },
+ newsSidebarOverflowVisible: {
+ key: "__sidebar_tune_news_sidebar_overflow",
+ defaultValue: false,
+ },
+ newsUseSharedLayout: {
+ key: "__sidebar_tune_news_use_shared_layout",
+ defaultValue: true,
+ },
+ newsUseMeasuredClose: {
+ key: "__sidebar_tune_news_use_measured_close",
+ defaultValue: true,
+ },
+ newsUsePlainFixedPopup: {
+ key: "__sidebar_tune_news_use_plain_fixed_popup",
+ defaultValue: true,
+ },
+ newsHidePerformanceChartWhileActive: {
+ key: "__sidebar_tune_news_hide_performance_chart",
+ defaultValue: false,
+ },
+ newsDisableChartInteractiveLayersWhileActive: {
+ key: "__sidebar_tune_news_disable_chart_interactive_layers",
+ defaultValue: false,
+ },
+ newsPromoteModalCompositorLayer: {
+ key: "__sidebar_tune_news_promote_modal_compositor",
+ defaultValue: false,
+ },
+} as const;
+
+export type SidebarShellNumericTuningKey = keyof typeof numericTuningConfig;
+export type SidebarShellStringTuningKey = keyof typeof stringTuningConfig;
+export type SidebarShellBooleanTuningKey = keyof typeof booleanTuningConfig;
+
+export type SidebarShellTuningState = Record<
+ SidebarShellNumericTuningKey,
+ number
+> &
+ Record &
+ Record;
+
+const defaultSidebarShellTuningState = {
+ ...Object.fromEntries(
+ Object.entries(numericTuningConfig).map(([key, config]) => [
+ key,
+ config.defaultValue,
+ ]),
+ ),
+ ...Object.fromEntries(
+ Object.entries(stringTuningConfig).map(([key, config]) => [
+ key,
+ config.defaultValue,
+ ]),
+ ),
+ ...Object.fromEntries(
+ Object.entries(booleanTuningConfig).map(([key, config]) => [
+ key,
+ config.defaultValue,
+ ]),
+ ),
+} as SidebarShellTuningState;
+
+export type SidebarShellDebugState = {
+ enabled: boolean;
+ variant: SidebarShellDebugVariant;
+ showBorders: boolean;
+ alwaysShowLabels: boolean;
+ tuning: SidebarShellTuningState;
+};
+
+export type SidebarShellDebugUpdate = {
+ enabled?: boolean;
+ variant?: SidebarShellDebugVariant;
+ showBorders?: boolean;
+ alwaysShowLabels?: boolean;
+ tuning?: Partial;
+};
+
+function getNumericTuningValue(
+ searchParams: URLSearchParams,
+ key: string,
+ defaultValue: number,
+) {
+ const rawValue = searchParams.get(key);
+ if (rawValue == null) {
+ return defaultValue;
+ }
+
+ const parsedValue = Number.parseFloat(rawValue);
+ return Number.isFinite(parsedValue) ? parsedValue : defaultValue;
+}
+
+function getStringTuningValue(
+ searchParams: URLSearchParams,
+ key: string,
+ defaultValue: string,
+) {
+ return searchParams.get(key) ?? defaultValue;
+}
+
+function getBooleanTuningValue(
+ searchParams: URLSearchParams,
+ key: string,
+ defaultValue: boolean,
+) {
+ const rawValue = searchParams.get(key);
+ if (rawValue == null) {
+ return defaultValue;
+ }
+
+ return rawValue.toLowerCase() === "true";
+}
+
+export function getSidebarShellDebugState(
+ searchParams: URLSearchParams,
+): SidebarShellDebugState {
+ const enabled =
+ searchParams.get(SIDEBAR_SHELL_DEBUG_QUERY_PARAM)?.toLowerCase() === "true";
+ const variant = enabled ? "geometry-trace" : DEFAULT_VARIANT;
+ const rawShowBorders = searchParams.get(SIDEBAR_SHELL_BORDERS_QUERY_PARAM);
+ const showBorders =
+ rawShowBorders == null
+ ? DEFAULT_SHOW_BORDERS
+ : rawShowBorders.toLowerCase() !== "false";
+ const alwaysShowLabels =
+ searchParams.get(SIDEBAR_SHELL_FORCE_LABELS_QUERY_PARAM)?.toLowerCase() ===
+ "true";
+
+ const tuning = {
+ ...Object.fromEntries(
+ Object.entries(numericTuningConfig).map(([key, config]) => [
+ key,
+ getNumericTuningValue(searchParams, config.key, config.defaultValue),
+ ]),
+ ),
+ ...Object.fromEntries(
+ Object.entries(stringTuningConfig).map(([key, config]) => [
+ key,
+ getStringTuningValue(searchParams, config.key, config.defaultValue),
+ ]),
+ ),
+ ...Object.fromEntries(
+ Object.entries(booleanTuningConfig).map(([key, config]) => [
+ key,
+ getBooleanTuningValue(searchParams, config.key, config.defaultValue),
+ ]),
+ ),
+ } as SidebarShellTuningState;
+
+ return {
+ enabled,
+ variant,
+ showBorders,
+ alwaysShowLabels,
+ tuning,
+ };
+}
+
+function applyTuningSearchParams(
+ searchParams: URLSearchParams,
+ tuning: SidebarShellTuningState,
+) {
+ for (const [stateKey, config] of Object.entries(numericTuningConfig)) {
+ searchParams.set(
+ config.key,
+ `${tuning[stateKey as keyof SidebarShellTuningState]}`,
+ );
+ }
+
+ for (const [stateKey, config] of Object.entries(stringTuningConfig)) {
+ searchParams.set(
+ config.key,
+ `${tuning[stateKey as keyof SidebarShellTuningState]}`,
+ );
+ }
+
+ for (const [stateKey, config] of Object.entries(booleanTuningConfig)) {
+ searchParams.set(
+ config.key,
+ `${tuning[stateKey as keyof SidebarShellTuningState]}`,
+ );
+ }
+}
+
+export function appendSidebarShellDebugParams(
+ to: string,
+ searchParams: URLSearchParams,
+) {
+ const debugState = getSidebarShellDebugState(searchParams);
+
+ if (!debugState.enabled) {
+ return to;
+ }
+
+ const url = new URL(to, "http://sidebar-debug.local");
+
+ url.searchParams.set(SIDEBAR_SHELL_DEBUG_QUERY_PARAM, "true");
+ url.searchParams.set(SIDEBAR_SHELL_VARIANT_QUERY_PARAM, debugState.variant);
+ url.searchParams.set(
+ SIDEBAR_SHELL_BORDERS_QUERY_PARAM,
+ `${debugState.showBorders}`,
+ );
+ url.searchParams.set(
+ SIDEBAR_SHELL_FORCE_LABELS_QUERY_PARAM,
+ `${debugState.alwaysShowLabels}`,
+ );
+ applyTuningSearchParams(url.searchParams, debugState.tuning);
+
+ return `${url.pathname}${url.search}${url.hash}`;
+}
+
+export function getSidebarShellDebugSearchParams(
+ searchParams: URLSearchParams,
+ nextState: SidebarShellDebugUpdate,
+) {
+ const currentState = getSidebarShellDebugState(searchParams);
+ const debugState: SidebarShellDebugState = {
+ ...currentState,
+ ...nextState,
+ tuning: {
+ ...currentState.tuning,
+ ...nextState.tuning,
+ },
+ };
+ const nextSearchParams = new URLSearchParams(searchParams);
+
+ if (!debugState.enabled) {
+ nextSearchParams.delete(SIDEBAR_SHELL_DEBUG_QUERY_PARAM);
+ nextSearchParams.delete(SIDEBAR_SHELL_VARIANT_QUERY_PARAM);
+ nextSearchParams.delete(SIDEBAR_SHELL_BORDERS_QUERY_PARAM);
+ nextSearchParams.delete(SIDEBAR_SHELL_FORCE_LABELS_QUERY_PARAM);
+ for (const config of Object.values(numericTuningConfig)) {
+ nextSearchParams.delete(config.key);
+ }
+ for (const config of Object.values(stringTuningConfig)) {
+ nextSearchParams.delete(config.key);
+ }
+ for (const config of Object.values(booleanTuningConfig)) {
+ nextSearchParams.delete(config.key);
+ }
+ return nextSearchParams;
+ }
+
+ nextSearchParams.set(SIDEBAR_SHELL_DEBUG_QUERY_PARAM, "true");
+ nextSearchParams.set(SIDEBAR_SHELL_VARIANT_QUERY_PARAM, debugState.variant);
+ nextSearchParams.set(
+ SIDEBAR_SHELL_BORDERS_QUERY_PARAM,
+ `${debugState.showBorders}`,
+ );
+ nextSearchParams.set(
+ SIDEBAR_SHELL_FORCE_LABELS_QUERY_PARAM,
+ `${debugState.alwaysShowLabels}`,
+ );
+ applyTuningSearchParams(nextSearchParams, debugState.tuning);
+
+ return nextSearchParams;
+}
+
+export function getDefaultSidebarShellTuningState() {
+ return { ...defaultSidebarShellTuningState };
+}
diff --git a/apps/web/src/features/shell/hooks/useCurrentShellRoute.ts b/apps/web/src/features/shell/hooks/useCurrentShellRoute.ts
new file mode 100644
index 00000000..2066ee37
--- /dev/null
+++ b/apps/web/src/features/shell/hooks/useCurrentShellRoute.ts
@@ -0,0 +1,11 @@
+import { useLocation } from "react-router-dom";
+import {
+ getCurrentShellRoute,
+ type ShellRouteDefinition,
+} from "@/features/shell/config/shell-routes";
+
+export function useCurrentShellRoute(): ShellRouteDefinition {
+ const location = useLocation();
+
+ return getCurrentShellRoute(location.pathname);
+}
diff --git a/apps/web/src/features/support/chatwoot/ChatwootBootstrap.tsx b/apps/web/src/features/support/chatwoot/ChatwootBootstrap.tsx
new file mode 100644
index 00000000..0dfbc5f9
--- /dev/null
+++ b/apps/web/src/features/support/chatwoot/ChatwootBootstrap.tsx
@@ -0,0 +1,80 @@
+import { useMountEffect } from "@/app/hooks/useMountEffect";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+import { authClient } from "@/lib/auth-client";
+import { ensureChatwootLoaded, syncChatwootUser } from "@/lib/chatwoot";
+
+function ChatwootLoaderMount() {
+ useMountEffect(() => {
+ void ensureChatwootLoaded().catch(() => {
+ // Keep the dashboard usable even if Chatwoot is unavailable.
+ });
+ });
+
+ return null;
+}
+
+function ChatwootUserSyncMount({
+ avatarUrl,
+ email,
+ identifier,
+ name,
+ organizationName,
+}: {
+ avatarUrl?: string;
+ email?: string;
+ identifier: string;
+ name?: string;
+ organizationName?: string;
+}) {
+ useMountEffect(() => {
+ void syncChatwootUser({
+ identifier,
+ email,
+ name,
+ avatarUrl,
+ organizationName,
+ });
+ });
+
+ return null;
+}
+
+export function ChatwootBootstrap() {
+ const { data: session } = authClient.useSession();
+ const { state } = useOrganization();
+ const identifier =
+ session?.user &&
+ (("id" in session.user && typeof session.user.id === "string"
+ ? session.user.id
+ : undefined) ??
+ ("email" in session.user && typeof session.user.email === "string"
+ ? session.user.email
+ : undefined));
+ const email =
+ session?.user &&
+ "email" in session.user &&
+ typeof session.user.email === "string"
+ ? session.user.email
+ : undefined;
+ const name =
+ typeof session?.user?.name === "string" ? session.user.name : undefined;
+ const avatarUrl =
+ typeof session?.user?.image === "string" ? session.user.image : undefined;
+ const organizationName = state.activeOrg?.name;
+
+ return (
+ <>
+
+ {identifier ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/features/team/TeamPage.tsx b/apps/web/src/features/team/TeamPage.tsx
index d99c68ea..fc2c3cb5 100644
--- a/apps/web/src/features/team/TeamPage.tsx
+++ b/apps/web/src/features/team/TeamPage.tsx
@@ -9,6 +9,7 @@ import {
} from "@/app/ui/card";
import { Skeleton } from "@/app/ui/skeleton";
import { TeamMembersCardGrid } from "@/features/team/components/TeamMembersCardGrid";
+import { TeamRosterGallery } from "@/features/team/components/TeamRosterGallery";
import {
type TeamPageDiagnostics,
useTeamPageData,
@@ -34,7 +35,7 @@ const teamBoardMetricSkeletonKeys = [
function TeamPageSkeleton() {
return (
-
+
{teamBoardCardSkeletonKeys.map((cardKey) => (
@@ -207,26 +208,19 @@ function TeamPageError({
);
}
-function TeamPageEmpty({
- hasActiveOrganization,
-}: {
- hasActiveOrganization: boolean;
-}) {
- const heading = hasActiveOrganization
- ? "No team members available"
- : "No active workspace selected";
- const description = hasActiveOrganization
- ? "Add teammates to this workspace to populate the team cards."
- : "Switch to a workspace to view the team roster.";
-
+function TeamPageEmpty() {
return (
- {heading}
- {description}
+
+ No team members available
+
+
+ Add teammates to this workspace to populate the team cards.
+
);
@@ -236,45 +230,58 @@ export function TeamPage() {
const {
diagnostics,
error,
- hasActiveOrganization,
isError,
isPending,
teamMemberRows,
+ teamPlayers,
refetch,
teamCards,
} = useTeamPageData();
+ const showUnreleasedLineupCards = false;
- if (isPending) {
- return (
-
-
-
- );
- }
-
- if (isError) {
- return (
-
-
-
- );
- }
+ let content = ;
- if (teamMemberRows.length === 0 && (teamCards?.length ?? 0) === 0) {
- return (
-
-
-
+ if (isPending) {
+ content = ;
+ } else if (isError) {
+ content = (
+
);
+ } else if (teamMemberRows.length === 0 && (teamCards?.length ?? 0) === 0) {
+ content = ;
}
return (
-
-
-
+ <>
+ {content}
+
+ {/* Unreleased feature: keep the postponed lineup gallery in code only
+ until the team cards return to the roadmap. */}
+ {showUnreleasedLineupCards &&
+ !isPending &&
+ !isError &&
+ teamPlayers.length > 0 ? (
+
+
+
+ Postponed lineup cards ({teamPlayers.length})
+
+
+ Kept below the member cards for reference while this feature stays
+ unreleased.
+
+
+
+
+ ) : null}
+ >
);
}
diff --git a/apps/web/src/features/team/assets/evren-dombak-portrait-v4.png b/apps/web/src/features/team/assets/evren-dombak-portrait-v4.png
new file mode 100644
index 00000000..78744c38
Binary files /dev/null and b/apps/web/src/features/team/assets/evren-dombak-portrait-v4.png differ
diff --git a/apps/web/src/features/team/assets/team-lineup-alvaro-portrait.png b/apps/web/src/features/team/assets/team-lineup-alvaro-portrait.png
new file mode 100644
index 00000000..ec247053
Binary files /dev/null and b/apps/web/src/features/team/assets/team-lineup-alvaro-portrait.png differ
diff --git a/apps/web/src/features/team/assets/team-lineup-jose-portrait.png b/apps/web/src/features/team/assets/team-lineup-jose-portrait.png
new file mode 100644
index 00000000..f1dedbce
Binary files /dev/null and b/apps/web/src/features/team/assets/team-lineup-jose-portrait.png differ
diff --git a/apps/web/src/features/team/assets/team-lineup-marc-portrait.png b/apps/web/src/features/team/assets/team-lineup-marc-portrait.png
new file mode 100644
index 00000000..dcd58184
Binary files /dev/null and b/apps/web/src/features/team/assets/team-lineup-marc-portrait.png differ
diff --git a/apps/web/src/features/team/assets/team-lineup-rafa-portrait.png b/apps/web/src/features/team/assets/team-lineup-rafa-portrait.png
new file mode 100644
index 00000000..d1fe4a0e
Binary files /dev/null and b/apps/web/src/features/team/assets/team-lineup-rafa-portrait.png differ
diff --git a/apps/web/src/features/team/assets/team-lineup-workspace-icon-v5.png b/apps/web/src/features/team/assets/team-lineup-workspace-icon-v5.png
new file mode 100644
index 00000000..aebb848e
Binary files /dev/null and b/apps/web/src/features/team/assets/team-lineup-workspace-icon-v5.png differ
diff --git a/apps/web/src/features/team/components/TeamMembersCardGrid.tsx b/apps/web/src/features/team/components/TeamMembersCardGrid.tsx
index 8b139a40..d73a5d44 100644
--- a/apps/web/src/features/team/components/TeamMembersCardGrid.tsx
+++ b/apps/web/src/features/team/components/TeamMembersCardGrid.tsx
@@ -1,3 +1,5 @@
+import { DashboardModelBadges } from "@/features/dashboard/components/DashboardModelBadges";
+import type { TeamCardTone } from "@/features/team/data/team-card-types";
import type { TeamPageMemberRow } from "@/features/team/use-team-page-data";
import { cn } from "@/lib/utils";
@@ -20,22 +22,22 @@ const shortDateFormatter = new Intl.DateTimeFormat("en-US", {
});
const adaptedTeamCardShellClassName =
- "relative isolate flex h-[358px] w-[233px] flex-col overflow-hidden rounded-[18px] border border-[#ECECEC] bg-[linear-gradient(180deg,#fbfcfe_0%,#f0f3f7_100%)] px-[14px] pt-[15px] pb-[10px] text-[#302d2b] shadow-[0_0_10.1px_rgba(0,0,0,0.08)]";
+ "team-lineup-featured-card relative isolate flex h-[358px] w-[233px] flex-col overflow-hidden rounded-[18px] border border-[#ECECEC] bg-[linear-gradient(180deg,#fbfcfe_0%,#f0f3f7_100%)] px-[14px] pt-[15px] pb-[10px] text-[#302d2b] shadow-[0_0_10.1px_rgba(0,0,0,0.08)]";
const adaptedTeamCardHeaderValueClassName =
- "font-heading text-[17.07px] font-extrabold leading-none tracking-[-0.01em] tabular-nums text-[#272423]";
+ "[font-family:var(--dashboard-01-font-roster-display)] text-[17.07px] font-extrabold leading-none tracking-[-0.01em] tabular-nums text-[#272423]";
const adaptedTeamCardHeaderLabelClassName =
"ml-[5px] text-[10px] font-semibold leading-none tracking-[-0.03em] text-[#7b7671]";
const adaptedTeamCardNameClassName =
- "font-heading text-[19px] font-extrabold leading-[0.9] tracking-[-0.02em] text-[#252220]";
+ "[font-family:var(--dashboard-01-font-roster-display)] text-[19px] font-extrabold leading-[0.9] tracking-[-0.02em] text-[#252220]";
const adaptedTeamCardModelSlotClassName =
"flex flex-1 items-center justify-center";
const adaptedTeamCardMediaPanelClassName =
- "relative isolate mt-[12px] h-[158px] w-full rounded-[14px] border border-black/8 bg-white/86";
+ "team-lineup-featured-media-panel mt-[12px] h-[158px] w-full rounded-[14px] border border-black/8 bg-white/86";
const portraitPanelClassName =
"relative flex h-full w-full flex-col justify-between overflow-hidden rounded-[10px] px-[12px] py-[10px]";
@@ -51,9 +53,7 @@ const tonePortraitClassNames = {
violet: "bg-[linear-gradient(180deg,#ece8ff_0%,#c3b2f5_100%)] text-[#4c3977]",
rose: "bg-[linear-gradient(180deg,#ffe5ea_0%,#ec9eb0_100%)] text-[#71364d]",
slate: "bg-[linear-gradient(180deg,#e7edf2_0%,#bcc7d4_100%)] text-[#43515f]",
-} as const;
-
-type TeamCardTone = keyof typeof tonePortraitClassNames;
+} as const satisfies Record;
type TeamCardStatRow = {
label: string;
@@ -61,46 +61,6 @@ type TeamCardStatRow = {
value: string;
};
-function formatFavoriteModelLabel(model: string) {
- const normalizedModel = model.trim().toLowerCase();
-
- if (normalizedModel.includes("opus")) {
- return "Opus";
- }
-
- if (normalizedModel.includes("sonnet")) {
- return "Sonnet";
- }
-
- if (normalizedModel.includes("haiku")) {
- return "Haiku";
- }
-
- if (normalizedModel.includes("gpt") || normalizedModel.includes("codex")) {
- return "GPT";
- }
-
- return model
- .replaceAll(/[-_]+/g, " ")
- .replaceAll(/\s+/g, " ")
- .trim()
- .replaceAll(/\b\w/g, (character) => character.toUpperCase());
-}
-
-function getFavoriteModelBadgeClassName(model: string) {
- const normalizedModel = model.trim().toLowerCase();
-
- if (normalizedModel.includes("claude")) {
- return "border-transparent bg-[#CC7D5E] text-[#F9F9F7]";
- }
-
- if (normalizedModel.includes("gpt") || normalizedModel.includes("codex")) {
- return "border-black/10 bg-[#FFFFFF] text-[#111111]";
- }
-
- return "border-black/10 bg-[#FFFFFF]/90 text-[#272423]";
-}
-
function getAvatarInitials(name: string) {
const parts = name.split(/\s+/).filter(Boolean);
@@ -228,19 +188,6 @@ function buildCardStats(row: TeamPageMemberRow): TeamCardStatRow[] {
];
}
-function FavoriteModelBadge({ model }: { model: string }) {
- return (
-
- {formatFavoriteModelLabel(model)}
-
- );
-}
-
function TeamMemberCard({ row }: { row: TeamPageMemberRow }) {
const tone = getCardTone(row);
const isMissingProfileImage = !row.imageUrl;
@@ -295,12 +242,14 @@ function TeamMemberCard({ row }: { row: TeamPageMemberRow }) {
{row.displayName}
{row.favoriteModel ? (
-
+
+
+
) : null}
-
+
{stats.map((stat) => (
+
{rows.map((row) => (
diff --git a/apps/web/src/features/team/components/TeamPlayerCard.tsx b/apps/web/src/features/team/components/TeamPlayerCard.tsx
new file mode 100644
index 00000000..13cff32d
--- /dev/null
+++ b/apps/web/src/features/team/components/TeamPlayerCard.tsx
@@ -0,0 +1,592 @@
+import type { ReactNode } from "react";
+import workspaceIcon from "@/features/team/assets/team-lineup-workspace-icon-v5.png";
+import type {
+ TeamCardTone,
+ TeamPlayerCardData,
+} from "@/features/team/data/team-card-types";
+import { cn } from "@/lib/utils";
+
+const featuredMediaRailSlots = ["workspace", "model", "language"] as const;
+
+const roleModelCardShellClassName =
+ "team-lineup-featured-card relative isolate flex h-[358px] w-[233px] flex-col overflow-hidden rounded-[18px] px-[14px] pt-[15px] pb-[10px] text-[#302d2b] shadow-[0_0_10.1px_rgba(0,0,0,0.1)]";
+
+const roleModelCardHeaderValueClassName =
+ "[font-family:var(--dashboard-01-font-roster-display)] text-[17.07px] font-extrabold leading-none tracking-[-0.01em] tabular-nums";
+
+const roleModelCardHeaderLabelClassName =
+ "text-[10px] font-semibold leading-none tracking-[-0.03em]";
+
+const roleModelCardRoleClassName = "text-[12.36px] font-medium leading-none";
+
+const roleModelCardNameClassName =
+ "[font-family:var(--dashboard-01-font-roster-display)] text-[19px] font-extrabold leading-[0.9] tracking-[-0.02em]";
+
+const roleModelCardTitleClassName =
+ "mt-[8px] text-[18px] font-medium leading-[0.92] tracking-[-0.02em]";
+
+const mediaPanelContentClassName =
+ "team-lineup-featured-media-panel__content flex h-full gap-[10px] p-[10px]";
+
+const mediaRailClassName =
+ "flex w-[42px] flex-col items-center justify-center gap-[11px]";
+
+const mediaTileClassName =
+ "flex size-[31px] items-center justify-center rounded-[9px]";
+
+const neutralMediaTileClassName = cn(mediaTileClassName, "bg-[#f6f4ef]");
+
+const portraitPanelClassName =
+ "relative flex h-full w-full flex-col justify-between overflow-hidden rounded-[10px] px-[12px] py-[10px]";
+
+const portraitPanelArchetypeClassName =
+ "text-[10px] font-semibold uppercase tracking-[0.16em]";
+
+const portraitPanelSubtitleClassName =
+ "text-[11px] font-medium uppercase tracking-[0.12em]";
+
+const portraitPlaceholderInitialsClassName =
+ "text-[42px] font-extrabold leading-none tracking-[-0.06em]";
+
+const rosterAccentClassNames = {
+ blue: "text-[#4a7bc9]",
+ teal: "text-[#2f9e8f]",
+ orange: "text-[#d67d33]",
+ lime: "text-[#84b13b]",
+ violet: "text-[#8b6ddc]",
+ rose: "text-[#d06b87]",
+ slate: "text-[#79818c]",
+} as const satisfies Record;
+
+const rosterRailTileClassNames = {
+ blue: "bg-[linear-gradient(180deg,#deebff_0%,#9ec5fe_100%)] text-[#295ea8]",
+ teal: "bg-[linear-gradient(180deg,#d3faf4_0%,#8ce6d7_100%)] text-[#187d71]",
+ orange: "bg-[linear-gradient(180deg,#ffeedb_0%,#fdc27e_100%)] text-[#bf6419]",
+ lime: "bg-[linear-gradient(180deg,#effad4_0%,#c7eb7c_100%)] text-[#5f8f1d]",
+ violet: "bg-[linear-gradient(180deg,#efebff_0%,#ccbfff_100%)] text-[#7352d5]",
+ rose: "bg-[linear-gradient(180deg,#ffe7eb_0%,#f9b5bf_100%)] text-[#c24d70]",
+ slate: "bg-[linear-gradient(180deg,#edf1f5_0%,#cfd7e1_100%)] text-[#5e6978]",
+} as const satisfies Record;
+
+const rosterPortraitPanelClassNames = {
+ blue: "bg-[linear-gradient(180deg,#d8e8ff_0%,#8fb7ec_100%)] text-[#24466d]",
+ teal: "bg-[linear-gradient(180deg,#d7f6ef_0%,#87d8c7_100%)] text-[#174f48]",
+ orange: "bg-[linear-gradient(180deg,#ffe8d5_0%,#f2b780_100%)] text-[#6f3c11]",
+ lime: "bg-[linear-gradient(180deg,#ecf7d0_0%,#b6db72_100%)] text-[#475d1d]",
+ violet: "bg-[linear-gradient(180deg,#ece8ff_0%,#c3b2f5_100%)] text-[#4c3977]",
+ rose: "bg-[linear-gradient(180deg,#ffe5ea_0%,#ec9eb0_100%)] text-[#71364d]",
+ slate: "bg-[linear-gradient(180deg,#e7edf2_0%,#bcc7d4_100%)] text-[#43515f]",
+} as const satisfies Record;
+
+const rosterCardShellClassNames = {
+ blue: "bg-[linear-gradient(180deg,#fcfbf8_0%,#f4eee7_100%)]",
+ teal: "bg-[linear-gradient(180deg,#39E5E7_0%,#35E895_50.96%,#7AE762_100%)]",
+ orange: "bg-[#DEFDEB]",
+ lime: "bg-[#FEE9F4]",
+ violet: "bg-[#4F3A5A]",
+ rose: "bg-[#FEE9F4]",
+ slate: "bg-[linear-gradient(180deg,#39E5E7_0%,#35E895_50.96%,#7AE762_100%)]",
+} as const satisfies Record;
+
+const featuredShellTextClassNames = {
+ value: "text-[#faf3ff]",
+ accent: "text-[#e9d7ff]",
+ role: "text-white/72",
+ name: "text-[#faf3ff]",
+ title: "text-white/72",
+} as const;
+
+const defaultShellTextClassNames = {
+ value: "text-[#272423]",
+ role: "text-[#5d5955]",
+ name: "text-[#252220]",
+ title: "text-[#5f5a57]",
+} as const;
+
+type FeaturedPanelOffset = {
+ left: number;
+ top: number;
+};
+
+type FeaturedSharedSurfaceLayout = {
+ cardWidth: number;
+ cardHeight: number;
+ media: FeaturedPanelOffset;
+ stats: FeaturedPanelOffset;
+};
+
+type RoleModelCardStats = ReadonlyArray<{
+ id: "left" | "right";
+ rows: ReadonlyArray;
+}>;
+
+// The featured card shell uses fixed dimensions and panel spacing, so the
+// shared surface offsets can stay static instead of being measured on mount.
+const featuredSharedSurfaceLayout: FeaturedSharedSurfaceLayout = {
+ cardWidth: 233,
+ cardHeight: 358,
+ media: {
+ left: 14,
+ top: 44,
+ },
+ stats: {
+ left: 14,
+ top: 273,
+ },
+};
+
+export function TeamPlayerCard({
+ player,
+ className,
+}: {
+ player: TeamPlayerCardData;
+ className?: string;
+}) {
+ if (player.featured) {
+ return ;
+ }
+
+ return ;
+}
+
+function FeaturedTeamPlayerCard({
+ player,
+ className,
+}: {
+ player: TeamPlayerCardData;
+ className?: string;
+}) {
+ return (
+
+
+
+
+
+
+ }
+ />
+ );
+}
+
+function DefaultTeamPlayerCard({
+ player,
+ className,
+}: {
+ player: TeamPlayerCardData;
+ className?: string;
+}) {
+ return (
+
}
+ />
+ );
+}
+
+function FeaturedMediaRail() {
+ return (
+
+ {featuredMediaRailSlots.map((slot) => (
+
+ {slot === "workspace" ? (
+
+
+
+ ) : (
+
+ )}
+
+ ))}
+
+ );
+}
+
+function FeaturedPlayerPortrait({ player }: { player: TeamPlayerCardData }) {
+ if (player.portraitImageSrc) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {player.badgeInitials}
+
+
+ );
+}
+
+function DefaultMediaRail({
+ badgeInitials,
+ badgeTone,
+}: Pick
) {
+ return (
+
+
+ {badgeInitials}
+
+
+
+
+ );
+}
+
+function NeutralRectangleTile() {
+ return (
+
+ );
+}
+
+function NeutralCapsuleTile() {
+ return (
+
+ );
+}
+
+function PortraitPanel({
+ badgeInitials,
+ badgeTone,
+ archetype,
+ archetypeClassName,
+ children,
+ className,
+ initialsBadgeClassName,
+ subtitle,
+ subtitleClassName,
+}: {
+ badgeInitials: string;
+ badgeTone: TeamCardTone;
+ archetype: string;
+ archetypeClassName?: string;
+ children: ReactNode;
+ className?: string;
+ initialsBadgeClassName?: string;
+ subtitle: string;
+ subtitleClassName?: string;
+}) {
+ return (
+
+
+
+ {archetype}
+
+
+ {badgeInitials}
+
+
+
+ {children}
+
+
+ {subtitle}
+
+
+ );
+}
+
+function RoleModelTeamPlayerCardFrame({
+ className,
+ darkShell,
+ overall,
+ accentText,
+ accentClassName,
+ shellClassName,
+ topLabel,
+ displayName,
+ displayTitle,
+ stats,
+ mediaContent,
+}: {
+ className?: string;
+ darkShell?: boolean;
+ overall: string;
+ accentText: string;
+ accentClassName: string;
+ shellClassName?: string;
+ topLabel: string;
+ displayName: string;
+ displayTitle: string;
+ stats: RoleModelCardStats;
+ mediaContent: ReactNode;
+}) {
+ return (
+
+
+
+
+ {overall}
+
+
+ {accentText}
+
+
+
+ {topLabel}
+
+
+
+
+
+ {mediaContent}
+
+
+
+
+ {displayName}
+
+
+ {displayTitle}
+
+
+
+
+
+
+ {stats.map((column) => (
+
+ {column.rows.map(([value, label]) => (
+
+
+ {value}
+
+
{label}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
+
+function DefaultTeamPlayerCardMedia({
+ player,
+}: {
+ player: TeamPlayerCardData;
+}) {
+ if (player.portraitImageSrc) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ {player.badgeInitials}
+
+
+
+
+
+ );
+}
+
+function buildRoleModelStats(
+ stats: TeamPlayerCardData["stats"],
+): RoleModelCardStats {
+ return [
+ {
+ id: "left",
+ rows: [
+ [String(stats.OUT), "OUT"],
+ [String(stats.SPD), "SPE"],
+ [String(stats.CRA), "CRA"],
+ ],
+ },
+ {
+ id: "right",
+ rows: [
+ [String(stats.QUA), "QUA"],
+ [String(stats.EFF), "EFF"],
+ [String(stats.CON), "CON"],
+ ],
+ },
+ ];
+}
+
+function FeaturedSharedSurfaceFragment({
+ cardHeight,
+ cardWidth,
+ offsetLeft,
+ offsetTop,
+}: {
+ cardHeight: number;
+ cardWidth: number;
+ offsetLeft: number;
+ offsetTop: number;
+}) {
+ return (
+
+ {/* Hidden for now: the lattice overlay regressed frontend performance on the team screen. */}
+
+
+
+ );
+}
diff --git a/apps/web/src/features/team/components/TeamRosterGallery.tsx b/apps/web/src/features/team/components/TeamRosterGallery.tsx
new file mode 100644
index 00000000..fa8fc5ca
--- /dev/null
+++ b/apps/web/src/features/team/components/TeamRosterGallery.tsx
@@ -0,0 +1,36 @@
+import { TeamPlayerCard } from "@/features/team/components/TeamPlayerCard";
+import type { TeamPlayerCardData } from "@/features/team/data/team-card-types";
+
+const playerColumnStartClassNames = {
+ 1: "2xl:col-start-1",
+ 2: "2xl:col-start-2",
+ 3: "2xl:col-start-3",
+} as const;
+
+export function TeamRosterGallery({
+ players,
+}: {
+ players: TeamPlayerCardData[];
+}) {
+ return (
+
+
+ {players.map((player, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/features/team/data/team-card-templates.ts b/apps/web/src/features/team/data/team-card-templates.ts
new file mode 100644
index 00000000..fb9710ab
--- /dev/null
+++ b/apps/web/src/features/team/data/team-card-templates.ts
@@ -0,0 +1,88 @@
+import type { TeamPlayerCardData } from "@/features/team/data/team-card-types";
+
+export const teamCardTemplates: TeamPlayerCardData[] = [
+ {
+ overall: 84,
+ role: "Builder",
+ archetype: "ML",
+ badgeInitials: "ML",
+ badgeTone: "teal",
+ name: "Morgan Lee",
+ subtitle: "Sonnet main",
+ stats: { OUT: 88, SPD: 72, CRA: 85, QUA: 81, EFF: 91, CON: 82 },
+ featured: true,
+ },
+ {
+ overall: 79,
+ role: "Precision",
+ archetype: "RN",
+ badgeInitials: "RN",
+ badgeTone: "rose",
+ name: "Riley Nguyen",
+ subtitle: "Sonnet main",
+ stats: { OUT: 64, SPD: 68, CRA: 72, QUA: 82, EFF: 85, CON: 76 },
+ },
+ {
+ overall: 78,
+ role: "All-rounder",
+ archetype: "TC",
+ badgeInitials: "TC",
+ badgeTone: "orange",
+ name: "Taylor Chen",
+ subtitle: "Mixed",
+ stats: { OUT: 73, SPD: 78, CRA: 80, QUA: 76, EFF: 74, CON: 84 },
+ },
+ {
+ overall: 76,
+ role: "Architect",
+ archetype: "AK",
+ badgeInitials: "AK",
+ badgeTone: "violet",
+ name: "Alex Kim",
+ subtitle: "Claude main",
+ stats: { OUT: 58, SPD: 75, CRA: 78, QUA: 74, EFF: 72, CON: 80 },
+ },
+ {
+ overall: 73,
+ role: "Explorer",
+ archetype: "JR",
+ badgeInitials: "JR",
+ badgeTone: "orange",
+ shellTone: "teal",
+ name: "Jordan Rivera",
+ subtitle: "Mixed",
+ stats: { OUT: 61, SPD: 82, CRA: 88, QUA: 69, EFF: 68, CON: 71 },
+ },
+ {
+ overall: 55,
+ role: "Debugger",
+ archetype: "SP",
+ badgeInitials: "SP",
+ badgeTone: "blue",
+ name: "Sam Park",
+ subtitle: "Claude main",
+ stats: { OUT: 44, SPD: 62, CRA: 28, QUA: 58, EFF: 38, CON: 58 },
+ },
+ {
+ overall: 51,
+ role: "Debugger",
+ archetype: "DW",
+ badgeInitials: "DW",
+ badgeTone: "slate",
+ name: "Drew Wilson",
+ subtitle: "Claude main",
+ stats: { OUT: 60, SPD: 45, CRA: 22, QUA: 63, EFF: 35, CON: 38 },
+ columnStart2xl: 2,
+ },
+ {
+ overall: 68,
+ role: "New",
+ archetype: "CP",
+ badgeInitials: "CP",
+ badgeTone: "lime",
+ name: "Casey Patel",
+ subtitle: "Mixed",
+ stats: { OUT: 33, SPD: 70, CRA: 55, QUA: 71, EFF: 78, CON: 45 },
+ columnStart2xl: 3,
+ },
+];
diff --git a/apps/web/src/features/team/data/team-card-types.ts b/apps/web/src/features/team/data/team-card-types.ts
new file mode 100644
index 00000000..8226932f
--- /dev/null
+++ b/apps/web/src/features/team/data/team-card-types.ts
@@ -0,0 +1,30 @@
+export type TeamCardTone =
+ | "blue"
+ | "teal"
+ | "orange"
+ | "lime"
+ | "violet"
+ | "rose"
+ | "slate";
+
+export interface TeamPlayerCardData {
+ overall: number;
+ role: string;
+ archetype: string;
+ badgeInitials: string;
+ badgeTone: TeamCardTone;
+ shellTone?: TeamCardTone;
+ portraitImageSrc?: string;
+ name: string;
+ subtitle: string;
+ stats: {
+ OUT: number;
+ SPD: number;
+ CRA: number;
+ QUA: number;
+ EFF: number;
+ CON: number;
+ };
+ featured?: boolean;
+ columnStart2xl?: 1 | 2 | 3;
+}
diff --git a/apps/web/src/features/team/data/team-roster-data.ts b/apps/web/src/features/team/data/team-roster-data.ts
new file mode 100644
index 00000000..6a002c6a
--- /dev/null
+++ b/apps/web/src/features/team/data/team-roster-data.ts
@@ -0,0 +1,258 @@
+import type { DeveloperTeamCard } from "@rudel/api-routes";
+import alvaroPortrait from "@/features/team/assets/team-lineup-alvaro-portrait.png";
+import josePortrait from "@/features/team/assets/team-lineup-jose-portrait.png";
+import marcPortrait from "@/features/team/assets/team-lineup-marc-portrait.png";
+import rafaPortrait from "@/features/team/assets/team-lineup-rafa-portrait.png";
+import { teamCardTemplates } from "@/features/team/data/team-card-templates";
+import type { TeamPlayerCardData } from "@/features/team/data/team-card-types";
+
+export type TeamDeveloperCardSource = readonly DeveloperTeamCard[];
+
+export interface TeamRosterMemberSource {
+ userId: string;
+ displayName: string;
+ email?: string | null;
+ imageUrl?: string | null;
+ role?: string | null;
+}
+
+const featuredTemplate = teamCardTemplates.find((player) => player.featured);
+const rosterTemplates = teamCardTemplates.filter((player) => !player.featured);
+
+function normalizePortraitName(name: string) {
+ return name
+ .trim()
+ .toLowerCase()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "");
+}
+
+function getTeamPortraitImage(name: string) {
+ const normalizedName = normalizePortraitName(name);
+
+ if (normalizedName.includes("rafa")) {
+ return rafaPortrait;
+ }
+
+ if (normalizedName.includes("alvaro") || normalizedName.includes("alvro")) {
+ return alvaroPortrait;
+ }
+
+ if (normalizedName.includes("marc")) {
+ return marcPortrait;
+ }
+
+ if (normalizedName.includes("jose")) {
+ return josePortrait;
+ }
+
+ return undefined;
+}
+
+function getNameInitials(name: string) {
+ const parts = name.split(/\s+/).filter(Boolean);
+
+ if (parts.length === 0) {
+ return "TM";
+ }
+
+ if (parts.length === 1) {
+ return parts[0].slice(0, 2).toUpperCase();
+ }
+
+ return `${parts[0][0] ?? ""}${parts.at(-1)?.[0] ?? ""}`.toUpperCase();
+}
+
+function formatFavoriteModelLabel(model: string | null | undefined) {
+ if (!model) {
+ return null;
+ }
+
+ const normalizedModel = model.trim().toLowerCase();
+
+ if (normalizedModel.includes("opus")) {
+ return "Opus main";
+ }
+
+ if (normalizedModel.includes("sonnet")) {
+ return "Sonnet main";
+ }
+
+ if (normalizedModel.includes("haiku")) {
+ return "Haiku main";
+ }
+
+ if (normalizedModel.includes("gpt")) {
+ return "GPT main";
+ }
+
+ return model;
+}
+
+function buildPlayerSubtitle({
+ fallbackSubtitle,
+ favoriteModel,
+ topSkills,
+}: {
+ fallbackSubtitle: string;
+ favoriteModel?: string | null;
+ topSkills?: readonly { name: string; count: number }[];
+}) {
+ const favoriteModelLabel = formatFavoriteModelLabel(favoriteModel);
+ if (favoriteModelLabel) {
+ return favoriteModelLabel;
+ }
+
+ const topSkill = topSkills?.[0]?.name?.trim();
+ if (topSkill) {
+ return topSkill;
+ }
+
+ return fallbackSubtitle;
+}
+
+function createPlayerFromTemplate(
+ template: TeamPlayerCardData,
+ playerSource: {
+ displayName: string;
+ fallbackSubtitle?: string;
+ favoriteModel?: string | null;
+ imageUrl?: string | null;
+ topSkills?: readonly { name: string; count: number }[];
+ },
+ featured: boolean,
+): TeamPlayerCardData {
+ const displayName = playerSource.displayName.trim();
+ const portraitImageSrc =
+ playerSource.imageUrl?.trim() || getTeamPortraitImage(displayName);
+
+ return {
+ ...template,
+ featured,
+ name: displayName,
+ badgeInitials: getNameInitials(displayName),
+ portraitImageSrc,
+ subtitle: buildPlayerSubtitle({
+ fallbackSubtitle: playerSource.fallbackSubtitle ?? template.subtitle,
+ favoriteModel: playerSource.favoriteModel,
+ topSkills: playerSource.topSkills,
+ }),
+ columnStart2xl: featured ? undefined : template.columnStart2xl,
+ };
+}
+
+function buildPlayerFromMember(
+ template: TeamPlayerCardData,
+ member: TeamRosterMemberSource,
+ featured: boolean,
+) {
+ return createPlayerFromTemplate(
+ template,
+ {
+ displayName: member.displayName,
+ fallbackSubtitle: "Workspace member",
+ imageUrl: member.imageUrl,
+ },
+ featured,
+ );
+}
+
+function buildPlayerFromTeamCard(
+ template: TeamPlayerCardData,
+ teamCard: DeveloperTeamCard,
+ featured: boolean,
+ member?: TeamRosterMemberSource,
+) {
+ return createPlayerFromTemplate(
+ template,
+ {
+ displayName: teamCard.display_name,
+ favoriteModel: teamCard.favorite_model,
+ imageUrl: member?.imageUrl,
+ topSkills: teamCard.top_skills,
+ },
+ featured,
+ );
+}
+
+export function buildTeamRosterPlayers(
+ teamCards: TeamDeveloperCardSource | undefined,
+ members: readonly TeamRosterMemberSource[] = [],
+): TeamPlayerCardData[] {
+ if (!featuredTemplate || rosterTemplates.length === 0) {
+ return [];
+ }
+
+ const realPlayers = (teamCards ?? []).filter(
+ (teamCard) => teamCard.display_name.trim().length > 0,
+ );
+ const validMembers = members.filter(
+ (member) => member.displayName.trim().length > 0,
+ );
+
+ if (validMembers.length === 0 && realPlayers.length === 0) {
+ return [];
+ }
+
+ if (validMembers.length === 0) {
+ return realPlayers.map((teamCard, index) => {
+ if (index === 0) {
+ return buildPlayerFromTeamCard(featuredTemplate, teamCard, true);
+ }
+
+ const template = rosterTemplates[(index - 1) % rosterTemplates.length];
+ return buildPlayerFromTeamCard(template, teamCard, false);
+ });
+ }
+
+ const analyticsByUserId = new Map(
+ realPlayers.map((teamCard) => [teamCard.user_id, teamCard] as const),
+ );
+
+ const sortedMembers = [...validMembers].sort((leftMember, rightMember) => {
+ const leftAnalytics = analyticsByUserId.get(leftMember.userId);
+ const rightAnalytics = analyticsByUserId.get(rightMember.userId);
+
+ if (leftAnalytics && rightAnalytics) {
+ return (
+ rightAnalytics.total_tokens - leftAnalytics.total_tokens ||
+ leftMember.displayName.localeCompare(rightMember.displayName)
+ );
+ }
+
+ if (leftAnalytics) {
+ return -1;
+ }
+
+ if (rightAnalytics) {
+ return 1;
+ }
+
+ return leftMember.displayName.localeCompare(rightMember.displayName);
+ });
+
+ return sortedMembers.map((member, index) => {
+ const teamCard = analyticsByUserId.get(member.userId);
+
+ if (index === 0) {
+ if (teamCard) {
+ return buildPlayerFromTeamCard(
+ featuredTemplate,
+ teamCard,
+ true,
+ member,
+ );
+ }
+
+ return buildPlayerFromMember(featuredTemplate, member, true);
+ }
+
+ const template = rosterTemplates[(index - 1) % rosterTemplates.length];
+
+ if (teamCard) {
+ return buildPlayerFromTeamCard(template, teamCard, false, member);
+ }
+
+ return buildPlayerFromMember(template, member, false);
+ });
+}
diff --git a/apps/web/src/features/team/use-team-page-data.ts b/apps/web/src/features/team/use-team-page-data.ts
index 9bbff474..3ee39aad 100644
--- a/apps/web/src/features/team/use-team-page-data.ts
+++ b/apps/web/src/features/team/use-team-page-data.ts
@@ -1,10 +1,15 @@
-import type { DeveloperTeamCard } from "@rudel/api-routes";
+import type { DeveloperSummary, DeveloperTeamCard } from "@rudel/api-routes";
+import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
-import { useOrganization } from "@/contexts/OrganizationContext";
import { useDateRange } from "@/features/analytics/date-range/useDateRange";
-import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
+import { useAnalyticsQuery } from "@/features/analytics/queries/useAnalyticsQuery";
+import {
+ buildTeamRosterPlayers,
+ type TeamRosterMemberSource,
+} from "@/features/team/data/team-roster-data";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
import { MAX_ANALYTICS_DAYS } from "@/lib/analytics-date-range";
+import { authClient } from "@/lib/auth-client";
import { orpc } from "@/lib/orpc";
export interface TeamPageDiagnostics {
@@ -35,14 +40,6 @@ export interface TeamPageMemberRow {
hasActivity: boolean;
}
-interface TeamRosterMemberSource {
- userId: string;
- displayName: string;
- email?: string | null;
- imageUrl?: string | null;
- role?: string | null;
-}
-
function formatMemberRole(role: string | null | undefined) {
if (!role) {
return "Member";
@@ -58,6 +55,7 @@ function formatMemberRole(role: string | null | undefined) {
function buildTeamMemberRows(
members: readonly TeamRosterMemberSource[],
teamCards: readonly DeveloperTeamCard[] | undefined,
+ developerSummaries: readonly DeveloperSummary[] | undefined,
) {
const memberByUserId = new Map(
members.map((member) => [member.userId, member] as const),
@@ -65,26 +63,41 @@ function buildTeamMemberRows(
const analyticsByUserId = new Map(
(teamCards ?? []).map((teamCard) => [teamCard.user_id, teamCard] as const),
);
+ const summaryByUserId = new Map(
+ (developerSummaries ?? []).map(
+ (summary) => [summary.user_id, summary] as const,
+ ),
+ );
const memberIds = new Set([
...memberByUserId.keys(),
...analyticsByUserId.keys(),
+ ...summaryByUserId.keys(),
]);
return Array.from(memberIds)
.map((userId) => {
const member = memberByUserId.get(userId);
const teamCard = analyticsByUserId.get(userId);
+ const developerSummary = summaryByUserId.get(userId);
const displayName =
member?.displayName.trim() ||
teamCard?.display_name.trim() ||
"Unknown teammate";
- const totalSessions = teamCard?.total_sessions ?? 0;
- const activeDays = teamCard?.active_days ?? 0;
- const inputTokens = teamCard?.input_tokens ?? 0;
- const outputTokens = teamCard?.output_tokens ?? 0;
- const totalTokens = teamCard?.total_tokens ?? 0;
- const cost = teamCard?.cost ?? 0;
- const lastActiveDate = teamCard?.last_active_date ?? null;
+ const totalSessions =
+ developerSummary?.total_sessions ?? teamCard?.total_sessions ?? 0;
+ const activeDays =
+ developerSummary?.active_days ?? teamCard?.active_days ?? 0;
+ const inputTokens =
+ developerSummary?.input_tokens ?? teamCard?.input_tokens ?? 0;
+ const outputTokens =
+ developerSummary?.output_tokens ?? teamCard?.output_tokens ?? 0;
+ const totalTokens =
+ developerSummary?.total_tokens ?? teamCard?.total_tokens ?? 0;
+ const cost = developerSummary?.cost ?? teamCard?.cost ?? 0;
+ const lastActiveDate =
+ developerSummary?.last_active_date ??
+ teamCard?.last_active_date ??
+ null;
return {
userId,
@@ -95,7 +108,8 @@ function buildTeamMemberRows(
: "Tracked collaborator",
imageUrl: member?.imageUrl,
cost,
- favoriteModel: teamCard?.favorite_model ?? null,
+ favoriteModel:
+ teamCard?.favorite_model ?? developerSummary?.favorite_model ?? null,
inputTokens,
outputTokens,
totalSessions,
@@ -115,64 +129,102 @@ function buildTeamMemberRows(
}
export function useTeamPageData() {
- const { meta: dateRangeMeta, state: dateRangeState } = useDateRange();
- const { activeOrg } = useOrganization();
- const {
- data: fullOrganization,
- invalidate: invalidateFullOrganization,
- isLoading: isOrganizationPending,
- } = useFullOrganization(activeOrg?.id);
- const hasActiveOrganization = Boolean(activeOrg?.id);
+ const { state: dateRangeState, meta: dateRangeMeta } = useDateRange();
+ const { state: workspaceState } = useOrganization();
const selectedDays = dateRangeMeta.dayCount;
const requestedDays = MAX_ANALYTICS_DAYS;
- const members = useMemo(
- () =>
- (fullOrganization?.members ?? []).map((member) => ({
+ const {
+ data: members = [],
+ isLoading: isOrganizationPending,
+ isError: isOrganizationError,
+ refetch: refetchMembers,
+ } = useQuery({
+ queryKey: ["team-page-members", workspaceState.activeOrg?.id],
+ queryFn: async () => {
+ const response = await authClient.organization.getFullOrganization({
+ query: { organizationId: workspaceState.activeOrg?.id ?? "" },
+ });
+ const fullOrganization =
+ (response.data as {
+ members?: Array<{
+ userId: string;
+ role: string;
+ user: {
+ image: string | null;
+ name: string;
+ email: string;
+ };
+ }>;
+ } | null) ?? null;
+
+ return (fullOrganization?.members ?? []).map((member) => ({
displayName: member.user.name,
email: member.user.email,
imageUrl: member.user.image,
role: member.role,
userId: member.userId,
- })) satisfies readonly TeamRosterMemberSource[],
- [fullOrganization?.members],
- );
+ }));
+ },
+ enabled: Boolean(workspaceState.activeOrg?.id),
+ });
const teamCardsQuery = useAnalyticsQuery({
...orpc.analytics.developers.teamCards.queryOptions({
input: { days: requestedDays },
}),
});
+ const developersQuery = useAnalyticsQuery({
+ ...orpc.analytics.developers.list.queryOptions({
+ input: { days: requestedDays },
+ }),
+ });
const teamCards = teamCardsQuery.data;
- const teamMemberRows = useMemo(
- () => buildTeamMemberRows(members, teamCards),
+ const developerSummaries = developersQuery.data;
+ const teamPlayers = useMemo(
+ () => buildTeamRosterPlayers(teamCards, members),
[members, teamCards],
);
- const hasRosterData = teamMemberRows.length > 0;
+ const teamMemberRows = useMemo(
+ () => buildTeamMemberRows(members, teamCards, developerSummaries),
+ [members, teamCards, developerSummaries],
+ );
+ const hasRosterData = teamMemberRows.length > 0 || teamPlayers.length > 0;
const diagnostics: TeamPageDiagnostics = {
endDate: dateRangeState.endDate,
endpoint: "analytics.developers.teamCards",
maxDays: MAX_ANALYTICS_DAYS,
startDate: dateRangeState.startDate,
- organizationId: activeOrg?.id ?? null,
- organizationName: activeOrg?.name ?? null,
+ organizationId: workspaceState.activeOrg?.id ?? null,
+ organizationName: workspaceState.activeOrg?.name ?? null,
days: selectedDays,
requestedDays,
};
return {
diagnostics,
- error: teamCardsQuery.error,
- hasActiveOrganization,
- isError: hasActiveOrganization && !hasRosterData && teamCardsQuery.isError,
+ error:
+ teamCardsQuery.error ??
+ developersQuery.error ??
+ (isOrganizationError
+ ? new Error("Failed to load workspace members.")
+ : null),
+ isError:
+ !hasRosterData &&
+ (teamCardsQuery.isError ||
+ developersQuery.isError ||
+ isOrganizationError),
isPending:
- hasActiveOrganization &&
!hasRosterData &&
- (teamCardsQuery.isPending || isOrganizationPending),
+ (teamCardsQuery.isPending ||
+ developersQuery.isPending ||
+ isOrganizationPending),
teamMemberRows,
+ teamPlayers,
requestedDays,
refetch: async () => {
await Promise.all([
teamCardsQuery.refetch(),
- invalidateFullOrganization(),
+ developersQuery.refetch(),
+ refetchMembers(),
]);
},
teamCards,
diff --git a/apps/web/src/features/workspace/hooks/useAccounts.ts b/apps/web/src/features/workspace/hooks/useAccounts.ts
new file mode 100644
index 00000000..9407dcde
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useAccounts.ts
@@ -0,0 +1,21 @@
+import { useQuery } from "@tanstack/react-query";
+import { authClient } from "@/lib/auth-client";
+
+interface Account {
+ id: string;
+ providerId: string;
+}
+
+const ACCOUNTS_KEY = ["accounts"] as const;
+
+export function useAccounts() {
+ const { data, isLoading } = useQuery({
+ queryKey: ACCOUNTS_KEY,
+ queryFn: async () => {
+ const res = await authClient.listAccounts();
+ return (res.data as readonly Account[]) ?? [];
+ },
+ });
+
+ return { accounts: data ?? [], isLoading };
+}
diff --git a/apps/web/src/features/workspace/hooks/useCanViewSession.ts b/apps/web/src/features/workspace/hooks/useCanViewSession.ts
new file mode 100644
index 00000000..9542a452
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useCanViewSession.ts
@@ -0,0 +1,13 @@
+import { authClient } from "@/lib/auth-client";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+
+export function useCanViewSession() {
+ const { meta } = useOrganization();
+ const { data: session } = authClient.useSession();
+ const currentUserId = session?.user.id;
+
+ return (sessionUserId: string) => {
+ if (meta.isOrgAdmin) return true;
+ return currentUserId === sessionUserId;
+ };
+}
diff --git a/apps/web/src/features/workspace/hooks/useFullOrganization.ts b/apps/web/src/features/workspace/hooks/useFullOrganization.ts
new file mode 100644
index 00000000..7de93145
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useFullOrganization.ts
@@ -0,0 +1,44 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { authClient } from "@/lib/auth-client";
+
+interface FullOrg {
+ id: string;
+ name: string;
+ slug: string;
+ members: readonly {
+ id: string;
+ userId: string;
+ role: string;
+ user: { id: string; name: string; email: string; image: string | null };
+ }[];
+ invitations: readonly {
+ id: string;
+ email: string;
+ role: string | null;
+ status: string;
+ createdAt?: string;
+ }[];
+}
+
+export function useFullOrganization(orgId: string | undefined) {
+ const queryClient = useQueryClient();
+ const queryKey = ["full-organization", orgId] as const;
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey,
+ queryFn: async () => {
+ const res = await authClient.organization.getFullOrganization({
+ query: { organizationId: orgId as string },
+ });
+ return (res.data as unknown as FullOrg) ?? null;
+ },
+ enabled: !!orgId,
+ });
+
+ const invalidate = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey });
+ }, [queryClient, queryKey]);
+
+ return { data: data ?? null, isLoading, isError, invalidate };
+}
diff --git a/apps/web/src/features/workspace/hooks/useUserInvitations.ts b/apps/web/src/features/workspace/hooks/useUserInvitations.ts
new file mode 100644
index 00000000..ad7a57ee
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useUserInvitations.ts
@@ -0,0 +1,25 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback } from "react";
+import { authClient } from "@/lib/auth-client";
+
+export const USER_INVITATIONS_KEY = ["user-invitations"] as const;
+
+export function useUserInvitations() {
+ const queryClient = useQueryClient();
+
+ const { data, isLoading } = useQuery({
+ queryKey: USER_INVITATIONS_KEY,
+ queryFn: async () => {
+ const res = await authClient.organization.listUserInvitations();
+ return res.data ?? [];
+ },
+ });
+
+ const invitations = data ?? [];
+
+ const invalidate = useCallback(() => {
+ queryClient.invalidateQueries({ queryKey: USER_INVITATIONS_KEY });
+ }, [queryClient]);
+
+ return { invitations, count: invitations.length, isLoading, invalidate };
+}
diff --git a/apps/web/src/features/workspace/hooks/useUserMap.ts b/apps/web/src/features/workspace/hooks/useUserMap.ts
new file mode 100644
index 00000000..28f9fabf
--- /dev/null
+++ b/apps/web/src/features/workspace/hooks/useUserMap.ts
@@ -0,0 +1,20 @@
+import { useMemo } from "react";
+import { useFullOrganization } from "@/features/workspace/hooks/useFullOrganization";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
+
+export function useUserMap() {
+ const { state } = useOrganization();
+ const { data: fullOrg, isLoading } = useFullOrganization(state.activeOrg?.id);
+
+ const userMap = useMemo(() => {
+ const record: Record = {};
+ if (fullOrg?.members) {
+ for (const member of fullOrg.members) {
+ record[member.userId] = member.user.name;
+ }
+ }
+ return record;
+ }, [fullOrg]);
+
+ return { userMap, isLoading };
+}
diff --git a/apps/web/src/features/workspace/organization/OrganizationProvider.tsx b/apps/web/src/features/workspace/organization/OrganizationProvider.tsx
new file mode 100644
index 00000000..a2a0a1f1
--- /dev/null
+++ b/apps/web/src/features/workspace/organization/OrganizationProvider.tsx
@@ -0,0 +1,118 @@
+import { createContext, type ReactNode, useContext, useState } from "react";
+import { useMountEffect } from "@/app/hooks/useMountEffect";
+import { authClient } from "@/lib/auth-client";
+import type { WorkspaceContextValue } from "@/features/workspace/organization/types";
+
+const OrganizationContext = createContext(
+ undefined,
+);
+
+function OrganizationAutoSelectMount({
+ onAttempted,
+ onSettled,
+ organizationId,
+}: {
+ onAttempted: (organizationId: string) => void;
+ onSettled: () => void;
+ organizationId: string;
+}) {
+ useMountEffect(() => {
+ let isCancelled = false;
+
+ onAttempted(organizationId);
+ void authClient.organization
+ .setActive({ organizationId })
+ .finally(() => {
+ if (isCancelled) {
+ return;
+ }
+
+ onSettled();
+ });
+
+ return () => {
+ isCancelled = true;
+ };
+ });
+
+ return null;
+}
+
+export function OrganizationProvider({ children }: { children: ReactNode }) {
+ const { data: activeOrg, isPending: activeLoading } =
+ authClient.useActiveOrganization();
+ const { data: orgs, isPending: listLoading } =
+ authClient.useListOrganizations();
+ const { data: activeMember } = authClient.useActiveMember();
+ const [switching, setSwitching] = useState(false);
+ const [attemptedAutoSelectOrgId, setAttemptedAutoSelectOrgId] = useState<
+ string | null
+ >(null);
+
+ const firstOrganizationId = orgs?.[0]?.id ?? null;
+ const shouldAutoSelect =
+ !activeLoading &&
+ !listLoading &&
+ !activeOrg &&
+ !switching &&
+ firstOrganizationId !== null &&
+ attemptedAutoSelectOrgId !== firstOrganizationId;
+
+ const switchOrganization = async (orgId: string) => {
+ setSwitching(true);
+ try {
+ await authClient.organization.setActive({ organizationId: orgId });
+ } finally {
+ setSwitching(false);
+ }
+ };
+
+ const memberRole = activeMember?.role;
+ const contextValue: WorkspaceContextValue = {
+ state: {
+ activeOrg: activeOrg ?? null,
+ organizations: orgs ?? [],
+ isLoading: activeLoading || listLoading || switching,
+ },
+ actions: {
+ switchOrganization,
+ },
+ meta: {
+ isOrgAdmin:
+ !activeOrg || memberRole === "owner" || memberRole === "admin",
+ },
+ };
+
+ return (
+ <>
+ {shouldAutoSelect ? (
+ {
+ setAttemptedAutoSelectOrgId(organizationId);
+ setSwitching(true);
+ }}
+ onSettled={() => setSwitching(false)}
+ />
+ ) : null}
+
+ {children}
+
+ >
+ );
+}
+
+export function useOrganization() {
+ const context = useOptionalOrganization();
+ if (context === undefined) {
+ throw new Error(
+ "useOrganization must be used within an OrganizationProvider",
+ );
+ }
+ return context;
+}
+
+export function useOptionalOrganization() {
+ return useContext(OrganizationContext);
+}
diff --git a/apps/web/src/features/workspace/organization/types.ts b/apps/web/src/features/workspace/organization/types.ts
new file mode 100644
index 00000000..a319bbdc
--- /dev/null
+++ b/apps/web/src/features/workspace/organization/types.ts
@@ -0,0 +1,26 @@
+export interface Organization {
+ id: string;
+ name: string;
+ slug: string;
+ logo?: string | null | undefined;
+}
+
+export interface WorkspaceState {
+ activeOrg: Organization | null;
+ organizations: readonly Organization[];
+ isLoading: boolean;
+}
+
+export interface WorkspaceActions {
+ switchOrganization: (orgId: string) => Promise;
+}
+
+export interface WorkspaceMeta {
+ isOrgAdmin: boolean;
+}
+
+export interface WorkspaceContextValue {
+ state: WorkspaceState;
+ actions: WorkspaceActions;
+ meta: WorkspaceMeta;
+}
diff --git a/apps/web/src/features/workspace/organization/useOrganization.ts b/apps/web/src/features/workspace/organization/useOrganization.ts
new file mode 100644
index 00000000..13ab3e2c
--- /dev/null
+++ b/apps/web/src/features/workspace/organization/useOrganization.ts
@@ -0,0 +1,4 @@
+export {
+ useOptionalOrganization,
+ useOrganization,
+} from "@/features/workspace/organization/OrganizationProvider";
diff --git a/apps/web/src/hooks/useUserMap.ts b/apps/web/src/hooks/useUserMap.ts
index 86edc2b0..28f9fabf 100644
--- a/apps/web/src/hooks/useUserMap.ts
+++ b/apps/web/src/hooks/useUserMap.ts
@@ -1,16 +1,16 @@
import { useMemo } from "react";
-import { useOrganization } from "@/contexts/OrganizationContext";
-import { useFullOrganization } from "@/hooks/useFullOrganization";
+import { useFullOrganization } from "@/features/workspace/hooks/useFullOrganization";
+import { useOrganization } from "@/features/workspace/organization/useOrganization";
export function useUserMap() {
- const { activeOrg } = useOrganization();
- const { data: fullOrg, isLoading } = useFullOrganization(activeOrg?.id);
+ const { state } = useOrganization();
+ const { data: fullOrg, isLoading } = useFullOrganization(state.activeOrg?.id);
const userMap = useMemo(() => {
const record: Record = {};
if (fullOrg?.members) {
- for (const m of fullOrg.members) {
- record[m.userId] = m.user.name;
+ for (const member of fullOrg.members) {
+ record[member.userId] = member.user.name;
}
}
return record;
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index f9aac756..17c86cfc 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -1,282 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
+@import "@fontsource-variable/inter";
@import "./app/preset-extensions.css";
-@import "./app/luma.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
- --radius-sm: calc(var(--radius) - 4px);
- --radius-md: calc(var(--radius) - 2px);
- --radius-lg: var(--radius);
- --radius-xl: calc(var(--radius) + 4px);
- --radius-2xl: calc(var(--radius) + 8px);
- --radius-3xl: calc(var(--radius) + 12px);
- --radius-4xl: calc(var(--radius) + 16px);
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
- --color-sidebar: var(--sidebar);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
-
- /* Analytics design tokens */
- --color-surface: var(--surface);
- --color-heading: var(--heading);
- --color-subheading: var(--subheading);
- --color-hover: var(--hover);
- --color-accent-hover: var(--accent-hover);
- --color-accent-light: var(--accent-light);
- --color-accent-text: var(--accent-text);
-
- /* Status colors */
- --color-status-success-bg: var(--status-success-bg);
- --color-status-success-border: var(--status-success-border);
- --color-status-success-text: var(--status-success-text);
- --color-status-success-icon: var(--status-success-icon);
- --color-status-error-bg: var(--status-error-bg);
- --color-status-error-border: var(--status-error-border);
- --color-status-error-text: var(--status-error-text);
- --color-status-error-icon: var(--status-error-icon);
- --color-status-warning-bg: var(--status-warning-bg);
- --color-status-warning-border: var(--status-warning-border);
- --color-status-warning-text: var(--status-warning-text);
- --color-status-warning-icon: var(--status-warning-icon);
- --color-status-info-bg: var(--status-info-bg);
- --color-status-info-border: var(--status-info-border);
- --color-status-info-text: var(--status-info-text);
- --color-status-info-icon: var(--status-info-icon);
-
- /* Shadows: none for flat design */
- --shadow-sm: none;
- --shadow: none;
- --shadow-md: none;
- --shadow-lg: none;
- --shadow-xl: none;
- --shadow-2xl: none;
- --shadow-inner: none;
-
- /* Near-square corners */
- --radius: 0.125rem;
- --radius-full: 9999px;
+ --font-heading: var(--font-sans);
+ --font-sans: 'Inter Variable', sans-serif;
+ --color-sidebar-ring: var(--sidebar-ring);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar: var(--sidebar);
+ --color-chart-5: var(--chart-5);
+ --color-chart-4: var(--chart-4);
+ --color-chart-3: var(--chart-3);
+ --color-chart-2: var(--chart-2);
+ --color-chart-1: var(--chart-1);
+ --color-ring: var(--ring);
+ --color-input: var(--input);
+ --color-border: var(--border);
+ --color-destructive: var(--destructive);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-secondary: var(--secondary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-primary: var(--primary);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-foreground: var(--foreground);
+ --color-background: var(--background);
+ --radius-sm: calc(var(--radius) * 0.6);
+ --radius-md: calc(var(--radius) * 0.8);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) * 1.4);
+ --radius-2xl: calc(var(--radius) * 1.8);
+ --radius-3xl: calc(var(--radius) * 2.2);
+ --radius-4xl: calc(var(--radius) * 2.6);
}
:root {
- --background: #faf9f5;
- --foreground: #191415;
- --card: #ffffff;
- --card-foreground: #191415;
- --popover: #ffffff;
- --popover-foreground: #191415;
- --primary: #191415;
- --primary-foreground: #faf9f5;
- --secondary: #f0eee7;
- --secondary-foreground: #191415;
- --muted: #73726c;
- --muted-foreground: #73726c;
- --accent: #2563eb;
- --accent-foreground: #ffffff;
- --destructive: #dc2626;
- --border: #dededd;
- --input: #ffffff;
- --ring: #2563eb;
- --chart-1: #2563eb;
- --chart-2: #16a34a;
- --chart-3: #d97706;
- --chart-4: #dc2626;
- --chart-5: #7c3aed;
- --sidebar: #faf9f5;
- --sidebar-foreground: #191415;
- --sidebar-primary: #191415;
- --sidebar-primary-foreground: #faf9f5;
- --sidebar-accent: #f0eee7;
- --sidebar-accent-foreground: #191415;
- --sidebar-border: #dededd;
- --sidebar-ring: #2563eb;
-
- /* Analytics tokens */
- --surface: #faf9f5;
- --heading: #191415;
- --subheading: #3d3d3a;
- --hover: #f0eee7;
- --accent-hover: #1d4ed8;
- --accent-light: #dbeafe;
- --accent-text: #1e40af;
-
- /* Status: Success */
- --status-success-bg: #f0fdf4;
- --status-success-border: #bbf7d0;
- --status-success-text: #166534;
- --status-success-icon: #16a34a;
-
- /* Status: Error */
- --status-error-bg: #fef2f2;
- --status-error-border: #fecaca;
- --status-error-text: #991b1b;
- --status-error-icon: #dc2626;
-
- /* Status: Warning */
- --status-warning-bg: #fffbeb;
- --status-warning-border: #fde68a;
- --status-warning-text: #92400e;
- --status-warning-icon: #d97706;
-
- /* Status: Info */
- --status-info-bg: #eff6ff;
- --status-info-border: #bfdbfe;
- --status-info-text: #1e40af;
- --status-info-icon: #2563eb;
-
- /* Chart */
- --chart-tooltip-bg: #ffffff;
- --chart-tooltip-border: #dededd;
- --chart-grid: #f0f0f0;
- --chart-axis: #73726c;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
}
.dark {
- --background: #262624;
- --foreground: #faf9f5;
- --card: #30302e;
- --card-foreground: #faf9f5;
- --popover: #30302e;
- --popover-foreground: #faf9f5;
- --primary: #faf9f5;
- --primary-foreground: #262624;
- --secondary: #3a3a38;
- --secondary-foreground: #faf9f5;
- --muted: #8e9991;
- --muted-foreground: #8e9991;
- --accent: #3b82f6;
- --accent-foreground: #ffffff;
- --destructive: #f87171;
- --border: #4a4a46;
- --input: #30302e;
- --ring: #3b82f6;
- --chart-1: #3b82f6;
- --chart-2: #4ade80;
- --chart-3: #fbbf24;
- --chart-4: #f87171;
- --chart-5: #a78bfa;
- --sidebar: #262624;
- --sidebar-foreground: #faf9f5;
- --sidebar-primary: #3b82f6;
- --sidebar-primary-foreground: #faf9f5;
- --sidebar-accent: #3a3a38;
- --sidebar-accent-foreground: #faf9f5;
- --sidebar-border: #4a4a46;
- --sidebar-ring: #3b82f6;
-
- /* Analytics tokens */
- --surface: #262624;
- --heading: #faf9f5;
- --subheading: #b5baa9;
- --hover: #141413;
- --accent-hover: #60a5fa;
- --accent-light: #1e3a5f;
- --accent-text: #93c5fd;
-
- /* Status: Success */
- --status-success-bg: #052e16;
- --status-success-border: #166534;
- --status-success-text: #86efac;
- --status-success-icon: #4ade80;
-
- /* Status: Error */
- --status-error-bg: #450a0a;
- --status-error-border: #991b1b;
- --status-error-text: #fca5a5;
- --status-error-icon: #f87171;
-
- /* Status: Warning */
- --status-warning-bg: #451a03;
- --status-warning-border: #92400e;
- --status-warning-text: #fde68a;
- --status-warning-icon: #fbbf24;
-
- /* Status: Info */
- --status-info-bg: #1e1b4b;
- --status-info-border: #3730a3;
- --status-info-text: #a5b4fc;
- --status-info-icon: #818cf8;
-
- /* Chart */
- --chart-tooltip-bg: #30302e;
- --chart-tooltip-border: #4a4a46;
- --chart-grid: #3a3a38;
- --chart-axis: #8e9991;
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.87 0 0);
+ --chart-2: oklch(0.556 0 0);
+ --chart-3: oklch(0.439 0 0);
+ --chart-4: oklch(0.371 0 0);
+ --chart-5: oklch(0.269 0 0);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- html {
- @apply h-full;
- }
- body {
- @apply bg-background text-foreground h-full;
- }
- #root {
- @apply h-full;
- isolation: isolate;
- }
-}
-
-body .woot-widget-bubble.woot-widget-bubble {
- width: 52px;
- height: 52px;
- bottom: 16px;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-body .woot-widget-bubble.woot-widget-bubble svg {
- width: 20px;
- height: 20px;
- margin: 0;
-}
-
-body .woot-widget-bubble.woot-widget-bubble.woot--close::before,
-body .woot-widget-bubble.woot-widget-bubble.woot--close::after {
- top: 16px;
- left: 25px;
- height: 20px;
-}
-
-body .woot-widget-holder.woot-widget-holder {
- width: min(352px, calc(100vw - 24px));
- max-height: min(540px, calc(100vh - 88px));
- bottom: 80px;
-}
-
-body .woot-widget-holder.woot-widget-holder iframe {
- border-radius: 16px;
-}
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+ html {
+ @apply font-sans;
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/layouts/DashboardLayout.tsx b/apps/web/src/layouts/DashboardLayout.tsx
new file mode 100644
index 00000000..42857f40
--- /dev/null
+++ b/apps/web/src/layouts/DashboardLayout.tsx
@@ -0,0 +1,30 @@
+import { Outlet } from "react-router-dom";
+import { Toaster } from "sonner";
+import { Breadcrumb } from "../components/analytics/Breadcrumb";
+import { Sidebar } from "../components/analytics/Sidebar";
+import { ChatwootBootstrap } from "../components/support/ChatwootBootstrap";
+import { DateRangeProvider } from "../contexts/DateRangeContext";
+import { FilterProvider } from "../contexts/FilterContext";
+import { OrganizationProvider } from "../contexts/OrganizationContext";
+
+export function DashboardLayout() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/analytics-date-range.test.ts b/apps/web/src/lib/analytics-date-range.test.ts
deleted file mode 100644
index 37e9c5bb..00000000
--- a/apps/web/src/lib/analytics-date-range.test.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- getInclusiveDateRangeDays,
- getSupportedAnalyticsDateRange,
- isAnalyticsRangeTooLarge,
- MAX_ANALYTICS_DAYS,
-} from "./analytics-date-range";
-
-describe("analytics-date-range", () => {
- test("getInclusiveDateRangeDays counts both endpoints", () => {
- expect(getInclusiveDateRangeDays("2026-04-01", "2026-04-08")).toBe(8);
- });
-
- test("getInclusiveDateRangeDays falls back for invalid dates", () => {
- expect(getInclusiveDateRangeDays("invalid", "2026-04-08")).toBe(1);
- });
-
- test("isAnalyticsRangeTooLarge enforces the max supported span", () => {
- expect(isAnalyticsRangeTooLarge(MAX_ANALYTICS_DAYS)).toBe(false);
- expect(isAnalyticsRangeTooLarge(MAX_ANALYTICS_DAYS + 1)).toBe(true);
- });
-
- test("getSupportedAnalyticsDateRange returns a 365-day inclusive window", () => {
- const endDate = new Date("2026-04-08T00:00:00.000Z");
- const supportedRange = getSupportedAnalyticsDateRange(endDate);
-
- expect(supportedRange.end.toISOString().slice(0, 10)).toBe("2026-04-08");
- expect(supportedRange.start.toISOString().slice(0, 10)).toBe("2025-04-09");
- });
-});
diff --git a/apps/web/src/lib/chart-theme-tokens.ts b/apps/web/src/lib/chart-theme-tokens.ts
new file mode 100644
index 00000000..67cd7f21
--- /dev/null
+++ b/apps/web/src/lib/chart-theme-tokens.ts
@@ -0,0 +1,6 @@
+export const chartThemeTokens = {
+ tooltipBg: "var(--chart-tooltip-bg, #ffffff)",
+ tooltipBorder: "var(--chart-tooltip-border, #DEDEDD)",
+ gridStroke: "var(--chart-grid, #f0f0f0)",
+ axisStroke: "var(--chart-axis, #73726C)",
+} as const;
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index 45cbc3ec..aa454867 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -1,107 +1,100 @@
import { calculateEstimatedCost as calculateEstimatedModelCost } from "@rudel/api-routes";
-const compactFormatter = new Intl.NumberFormat("en-US", {
- maximumFractionDigits: 1,
+const numberFormatter = new Intl.NumberFormat();
+
+const compactNumberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact",
+ maximumFractionDigits: 1,
});
-const compactWholeFormatter = new Intl.NumberFormat("en-US", {
- maximumFractionDigits: 0,
+const compactWholeNumberFormatter = new Intl.NumberFormat(undefined, {
notation: "compact",
+ maximumFractionDigits: 0,
+});
+
+const decimalFormatter = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 2,
});
const currencyFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
currency: "USD",
- maximumFractionDigits: 2,
minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+});
+
+const wholeCurrencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
+ currency: "USD",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
});
const fineCurrencyFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
currency: "USD",
- maximumFractionDigits: 4,
minimumFractionDigits: 4,
- style: "currency",
+ maximumFractionDigits: 4,
});
-const wholeCurrencyFormatter = new Intl.NumberFormat("en-US", {
+const compactCurrencyFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
currency: "USD",
- maximumFractionDigits: 0,
+ notation: "compact",
minimumFractionDigits: 0,
- style: "currency",
+ maximumFractionDigits: 1,
});
const compactWholeCurrencyFormatter = new Intl.NumberFormat("en-US", {
+ style: "currency",
currency: "USD",
- maximumFractionDigits: 0,
- minimumFractionDigits: 0,
notation: "compact",
- style: "currency",
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
});
-const shortMonthDayFormatter = new Intl.DateTimeFormat("en-US", {
- day: "numeric",
- month: "short",
+const minuteFormatter = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 1,
});
-const shortMonthDayYearFormatter = new Intl.DateTimeFormat("en-US", {
+const percentFormatter = new Intl.NumberFormat(undefined, {
+ maximumFractionDigits: 1,
+});
+
+const shortDateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: "short",
day: "numeric",
+});
+
+const fullDateFormatter = new Intl.DateTimeFormat(undefined, {
month: "short",
+ day: "numeric",
year: "numeric",
});
-export function calculateCost(
- inputTokens: number,
- outputTokens: number,
- options?:
- | string
- | null
- | {
- cacheCreationInputTokens?: number;
- cacheReadInputTokens?: number;
- model?: string | null;
- },
-): number {
- const model =
- typeof options === "string" ? options : (options?.model ?? null);
-
- return calculateEstimatedModelCost({
- cacheCreationInputTokens:
- typeof options === "string"
- ? 0
- : (options?.cacheCreationInputTokens ?? 0),
- cacheReadInputTokens:
- typeof options === "string" ? 0 : (options?.cacheReadInputTokens ?? 0),
- inputTokens,
- model,
- outputTokens,
- });
-}
-
-export function formatIsoDate(date: Date) {
- return date.toISOString().slice(0, 10);
-}
-
-export function formatDateRangeLabel(startDate: string, endDate: string) {
- const start = new Date(`${startDate}T00:00:00`);
- const end = new Date(`${endDate}T00:00:00`);
-
- if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
- return "Pick a date";
+function parseDateValue(value: string) {
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
+ const [year, month, day] = value.split("-").map(Number);
+ return new Date(year, (month ?? 1) - 1, day ?? 1);
}
- const startLabel = shortMonthDayFormatter.format(start);
- const endLabel = shortMonthDayYearFormatter.format(end);
+ return new Date(value);
+}
- return `${startLabel} - ${endLabel}`;
+export function formatNumber(value: number) {
+ return numberFormatter.format(value);
}
export function formatCompactNumber(value: number) {
- return compactFormatter.format(value);
+ return compactNumberFormatter.format(value);
}
export function formatCompactWholeNumber(value: number) {
- return compactWholeFormatter.format(value);
+ return compactWholeNumberFormatter.format(value);
+}
+
+export function formatDecimal(value: number) {
+ return decimalFormatter.format(value);
}
export function formatCurrency(value: number) {
@@ -112,6 +105,14 @@ export function formatCurrency(value: number) {
return currencyFormatter.format(value);
}
+export function formatCompactCurrency(value: number) {
+ if (Math.abs(value) < 1_000) {
+ return formatCurrency(value);
+ }
+
+ return compactCurrencyFormatter.format(value);
+}
+
export function formatWholeCurrency(value: number) {
return wholeCurrencyFormatter.format(value);
}
@@ -125,27 +126,91 @@ export function formatCompactWholeCurrency(value: number) {
}
export function formatMinutes(value: number) {
- return `${value.toFixed(1)} min`;
+ return `${minuteFormatter.format(value)} min`;
}
export function formatPercent(value: number) {
- return `${Math.round(value)}%`;
+ return `${percentFormatter.format(value)}%`;
+}
+
+export function formatSignedPercent(value: number) {
+ const sign = value > 0 ? "+" : "";
+ return `${sign}${percentFormatter.format(value)}%`;
+}
+
+export function formatDateLabel(value: string) {
+ return shortDateFormatter.format(parseDateValue(value));
+}
+
+export function formatFullDateLabel(value: string) {
+ return fullDateFormatter.format(parseDateValue(value));
+}
+
+export function formatDateRangeLabel(startDate: string, endDate: string) {
+ return `${formatDateLabel(startDate)} - ${formatFullDateLabel(endDate)}`;
+}
+
+export function formatIsoDate(date: Date) {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+export function clampToPositiveZero(value: number) {
+ return Number.isFinite(value) ? Math.max(0, value) : 0;
}
export function formatUsername(
userId: string,
- userMap?: Record,
-): string {
- if (userMap?.[userId]) {
- return userMap[userId];
+ userMap?: Map | Record,
+) {
+ if (userMap instanceof Map) {
+ return userMap.get(userId) ?? userId;
+ }
+
+ if (userMap && typeof userMap === "object") {
+ return userMap[userId] ?? userId;
}
+
return userId;
}
-export function encodeProjectPath(path: string): string {
- return encodeURIComponent(path);
+export function calculateCost(
+ inputTokens: number,
+ outputTokens: number,
+ options?:
+ | string
+ | null
+ | {
+ model?: string | null;
+ cacheReadInputTokens?: number;
+ cacheCreationInputTokens?: number;
+ },
+) {
+ const model =
+ typeof options === "string" ? options : (options?.model ?? null);
+ return calculateEstimatedModelCost({
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadInputTokens:
+ typeof options === "string" ? 0 : (options?.cacheReadInputTokens ?? 0),
+ cacheCreationInputTokens:
+ typeof options === "string"
+ ? 0
+ : (options?.cacheCreationInputTokens ?? 0),
+ });
}
-export function decodeProjectPath(encoded: string): string {
- return decodeURIComponent(encoded);
+export function encodeProjectPath(projectPath: string) {
+ return encodeURIComponent(projectPath);
+}
+
+export function decodeProjectPath(projectPath: string) {
+ try {
+ return decodeURIComponent(projectPath);
+ } catch {
+ return projectPath;
+ }
}
diff --git a/apps/web/src/lib/product-analytics.ts b/apps/web/src/lib/product-analytics.ts
index 34d77886..08316891 100644
--- a/apps/web/src/lib/product-analytics.ts
+++ b/apps/web/src/lib/product-analytics.ts
@@ -355,37 +355,51 @@ const ANALYTICS_PAGE_MATCHERS: ReadonlyArray<{
{ pageName: "overview", matches: (pathname) => pathname === "/dashboard" },
{
pageName: "developer_detail",
- matches: (pathname) => pathname.startsWith("/dashboard/developers/"),
+ matches: (pathname) =>
+ pathname.startsWith("/legacy/developers/") ||
+ pathname.startsWith("/dashboard/developers/"),
},
{
pageName: "developers",
- matches: (pathname) => pathname === "/dashboard/developers",
+ matches: (pathname) =>
+ pathname === "/legacy/developers" || pathname === "/dashboard/developers",
},
{
pageName: "project_detail",
- matches: (pathname) => pathname.startsWith("/dashboard/projects/"),
+ matches: (pathname) =>
+ pathname.startsWith("/legacy/projects/") ||
+ pathname.startsWith("/dashboard/projects/"),
},
{
pageName: "projects",
- matches: (pathname) => pathname === "/dashboard/projects",
+ matches: (pathname) =>
+ pathname === "/legacy/projects" || pathname === "/dashboard/projects",
},
{
pageName: "session_detail",
- matches: (pathname) => pathname.startsWith("/dashboard/sessions/"),
+ matches: (pathname) =>
+ pathname.startsWith("/legacy/sessions/") ||
+ pathname.startsWith("/dashboard/sessions/"),
},
{
pageName: "sessions",
- matches: (pathname) => pathname === "/dashboard/sessions",
+ matches: (pathname) =>
+ pathname === "/legacy/sessions" || pathname === "/dashboard/sessions",
},
{
pageName: "errors",
- matches: (pathname) => pathname === "/dashboard/errors",
+ matches: (pathname) =>
+ pathname === "/legacy/errors" || pathname === "/dashboard/errors",
},
{
pageName: "learnings",
- matches: (pathname) => pathname === "/dashboard/learnings",
+ matches: (pathname) =>
+ pathname === "/legacy/learnings" || pathname === "/dashboard/learnings",
+ },
+ {
+ pageName: "roi",
+ matches: (pathname) => pathname === "/legacy/roi" || pathname === "/dashboard/roi",
},
- { pageName: "roi", matches: (pathname) => pathname === "/dashboard/roi" },
{
pageName: "organization_create",
matches: (pathname) => pathname === "/dashboard/organization/new",
diff --git a/apps/web/src/lib/session-paths.ts b/apps/web/src/lib/session-paths.ts
new file mode 100644
index 00000000..51fbea90
--- /dev/null
+++ b/apps/web/src/lib/session-paths.ts
@@ -0,0 +1,3 @@
+export function getSessionDetailPath(sessionId: string): string {
+ return `/dashboard/sessions/${encodeURIComponent(sessionId)}`;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 40224c97..47e5ec36 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,9 +1,34 @@
-import { StrictMode, useEffect } from "react";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { lazy, StrictMode, Suspense } from "react";
import { createRoot } from "react-dom/client";
-import { AppProviders } from "@/app/providers/AppProviders";
+import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
+import { useMountEffect } from "./app/hooks/useMountEffect";
import "./index.css";
import { initProductAnalytics } from "./lib/product-analytics";
+import { queryClient } from "./lib/query-client";
+import { ThemeProvider } from "./app/providers/ThemeProvider";
+
+const DevTools = import.meta.env.DEV
+ ? lazy(async () => {
+ const module = await import("./DevTools.tsx");
+ return {
+ default: module.DevTools,
+ };
+ })
+ : null;
+
+function GlobalLumaScope() {
+ useMountEffect(() => {
+ document.body.classList.add("style-luma");
+
+ return () => {
+ document.body.classList.remove("style-luma");
+ };
+ });
+
+ return null;
+}
function deferProductAnalyticsInit() {
if (typeof window === "undefined") {
@@ -22,27 +47,24 @@ function deferProductAnalyticsInit() {
}, 0);
}
-function GlobalLumaScope() {
- useEffect(() => {
- document.body.classList.add("style-luma");
-
- return () => {
- document.body.classList.remove("style-luma");
- };
- }, []);
-
- return null;
-}
-
// biome-ignore lint/style/noNonNullAssertion: root element always exists
createRoot(document.getElementById("root")!).render(
-
-
-
-
+
+
+
+
+
+
+ {DevTools ? (
+
+
+
+ ) : null}
+
+
+
+
,
);
diff --git a/apps/web/src/pages/dashboard/DeveloperDetailPage.tsx b/apps/web/src/pages/dashboard/DeveloperDetailPage.tsx
index 16842adb..fb5f6d08 100644
--- a/apps/web/src/pages/dashboard/DeveloperDetailPage.tsx
+++ b/apps/web/src/pages/dashboard/DeveloperDetailPage.tsx
@@ -46,6 +46,144 @@ import {
} from "@/lib/analytics";
import { formatUsername } from "@/lib/format";
import { orpc } from "@/lib/orpc";
+import { getSessionDetailPath } from "@/lib/session-paths";
+
+type DeveloperTimelinePoint = {
+ avg30Sessions: number;
+ avg7Sessions: number;
+ avgHoursPerSession: number;
+ date: string;
+ fullDate: string;
+ hours: number;
+ sessions: number;
+};
+
+type DeveloperProjectPoint = {
+ avgHoursPerSession: number;
+ hours: number;
+ name: string;
+ sessions: number;
+};
+
+function DeveloperTimelineTooltip({
+ active,
+ payload,
+ tooltipBg,
+ tooltipBorder,
+}: {
+ active?: boolean;
+ payload?: ReadonlyArray<{ payload?: DeveloperTimelinePoint }>;
+ tooltipBg: string;
+ tooltipBorder: string;
+}) {
+ const point = payload?.[0]?.payload;
+
+ if (!active || !point) {
+ return null;
+ }
+
+ return (
+
+
+ {point.fullDate}
+
+
+
+ Sessions
+
+ {point.sessions.toLocaleString()}
+
+
+
+ Hours
+
+ {point.hours.toFixed(1)}h
+
+
+
+ Avg / session
+
+ {point.sessions > 0
+ ? `${point.avgHoursPerSession.toFixed(1)}h`
+ : "—"}
+
+
+
+ 7-day baseline
+
+ {point.avg7Sessions.toFixed(1)}
+
+
+
+ 30-day baseline
+
+ {point.avg30Sessions.toFixed(1)}
+
+
+
+
+ );
+}
+
+function DeveloperProjectTooltip({
+ active,
+ payload,
+ tooltipBg,
+ tooltipBorder,
+}: {
+ active?: boolean;
+ payload?: ReadonlyArray<{ payload?: DeveloperProjectPoint }>;
+ tooltipBg: string;
+ tooltipBorder: string;
+}) {
+ const point = payload?.[0]?.payload;
+
+ if (!active || !point) {
+ return null;
+ }
+
+ return (
+
+
+ {point.name}
+
+
+
+ Sessions
+
+ {point.sessions.toLocaleString()}
+
+
+
+ Hours
+
+ {point.hours.toFixed(1)}h
+
+
+
+ Avg / session
+
+ {point.sessions > 0
+ ? `${point.avgHoursPerSession.toFixed(1)}h`
+ : "—"}
+
+
+
+
+ );
+}
function resolveProjectName(row: {
git_remote?: string;
@@ -141,8 +279,18 @@ export function DeveloperDetailPage() {
month: "short",
day: "numeric",
}),
+ fullDate: new Date(day.date).toLocaleDateString("en-US", {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }),
sessions: day.sessions,
hours: parseFloat((day.total_duration_min / 60).toFixed(1)),
+ avgHoursPerSession:
+ day.sessions > 0
+ ? parseFloat((day.total_duration_min / 60 / day.sessions).toFixed(1))
+ : 0,
avg7Sessions: rollingAvg7[index],
avg30Sessions: rollingAvg30[index],
}));
@@ -174,6 +322,10 @@ export function DeveloperDetailPage() {
name: resolveProjectName(p).name,
sessions: p.sessions,
hours: parseFloat((p.total_duration_min / 60).toFixed(1)),
+ avgHoursPerSession:
+ p.sessions > 0
+ ? parseFloat((p.total_duration_min / 60 / p.sessions).toFixed(1))
+ : 0,
}));
}, [projects]);
@@ -420,17 +572,25 @@ export function DeveloperDetailPage() {
stroke={chartTheme.gridStroke}
/>
-
+
(
+
+ )}
/>
12 ? `${v.slice(0, 12)}...` : v
}
/>
-
+
(
+
+ )}
/>
row.session_id}
onRowClick={
userId && canViewSession(userId)
- ? (row) => navigate(`/dashboard/sessions/${row.session_id}`)
+ ? (row) => navigate(getSessionDetailPath(row.session_id))
: undefined
}
/>
diff --git a/apps/web/src/pages/dashboard/OverviewPage.tsx b/apps/web/src/pages/dashboard/OverviewPage.tsx
new file mode 100644
index 00000000..03a11d89
--- /dev/null
+++ b/apps/web/src/pages/dashboard/OverviewPage.tsx
@@ -0,0 +1,342 @@
+import {
+ Activity,
+ Bot,
+ FolderKanban,
+ Sparkles,
+ Terminal,
+ Users,
+} from "lucide-react";
+import { useEffect, useRef } from "react";
+import { AnalyticsCard } from "@/components/analytics/AnalyticsCard";
+import { CliSetupHint } from "@/components/analytics/CliSetupHint";
+import { DatePicker } from "@/components/analytics/DatePicker";
+import { InsightCard } from "@/components/analytics/InsightCard";
+import { NoSessionsInRange } from "@/components/analytics/NoSessionsInRange";
+import { PageHeader } from "@/components/analytics/PageHeader";
+import { StatCard } from "@/components/analytics/StatCard";
+import { ModelTokensChart } from "@/components/charts/ModelTokensChart";
+import { UsageTrendChart } from "@/components/charts/UsageTrendChart";
+import { Spinner } from "@/components/ui/spinner";
+import { useDateRange } from "@/contexts/DateRangeContext";
+import { useAnalyticsQuery } from "@/hooks/useAnalyticsQuery";
+import { useDashboardAnalytics } from "@/hooks/useDashboardAnalytics";
+import {
+ type DashboardSection,
+ useTrackDashboardView,
+} from "@/hooks/useTrackDashboardView";
+import { orpc } from "@/lib/orpc";
+import {
+ captureDashboardLoadFailed,
+ getHttpStatusFromError,
+ normalizeWebErrorCode,
+} from "@/lib/product-analytics";
+
+function deriveInsightKey(insight: {
+ type: "trend" | "performer" | "alert" | "info";
+ message: string;
+ link?: string | null;
+}) {
+ return `${insight.type}:${insight.message}:${insight.link ?? "/dashboard"}`
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .slice(0, 96);
+}
+
+export function OverviewPage() {
+ const { startDate, endDate, setStartDate, setEndDate } = useDateRange();
+ const failedRangeKeyRef = useRef(null);
+ const { organizationId, userId, pageName, dateRangeDays } =
+ useDashboardAnalytics();
+
+ const {
+ data: kpis,
+ isPending: kpisLoading,
+ isError: kpisError,
+ error: kpisQueryError,
+ } = useAnalyticsQuery(
+ orpc.analytics.overview.kpis.queryOptions({
+ input: { startDate, endDate },
+ }),
+ );
+
+ const {
+ data: usageTrendData,
+ isPending: usageTrendLoading,
+ isError: usageTrendError,
+ } = useAnalyticsQuery(
+ orpc.analytics.overview.usageTrend.queryOptions({
+ input: { startDate, endDate },
+ }),
+ );
+
+ const {
+ data: modelTokensData,
+ isPending: modelTokensLoading,
+ isError: modelTokensError,
+ } = useAnalyticsQuery(
+ orpc.analytics.overview.modelTokensTrend.queryOptions({
+ input: { startDate, endDate },
+ }),
+ );
+
+ const {
+ data: insights,
+ isPending: insightsLoading,
+ isError: insightsError,
+ } = useAnalyticsQuery(
+ orpc.analytics.overview.insights.queryOptions({
+ input: { startDate, endDate },
+ }),
+ );
+
+ const hasData = !kpisLoading && kpis && kpis.distinct_sessions > 0;
+ const hasAnySessions = kpis && kpis.total_sessions > 0;
+ const showDatePicker = hasData || (!kpisLoading && hasAnySessions);
+ const overviewIsLoading =
+ kpisLoading || usageTrendLoading || modelTokensLoading || insightsLoading;
+ const overviewSections: DashboardSection[] = [
+ {
+ id: "kpi_cards",
+ state: kpisError ? "error" : hasData ? "populated" : "empty",
+ itemCount: hasData ? 6 : 0,
+ },
+ {
+ id: "quick_insights",
+ state: insightsError
+ ? "error"
+ : (insights?.length ?? 0) > 0
+ ? "populated"
+ : "empty",
+ itemCount: insights?.length ?? 0,
+ },
+ {
+ id: "usage_trend",
+ state: usageTrendError
+ ? "error"
+ : (usageTrendData?.length ?? 0) > 0
+ ? "populated"
+ : "empty",
+ itemCount: usageTrendData?.length ?? 0,
+ },
+ {
+ id: "model_tokens",
+ state: modelTokensError
+ ? "error"
+ : (modelTokensData?.length ?? 0) > 0
+ ? "populated"
+ : "empty",
+ itemCount: modelTokensData?.length ?? 0,
+ },
+ ];
+ const overviewMetrics = [
+ { id: "distinct_users", value: kpis?.distinct_users },
+ { id: "distinct_sessions", value: kpis?.distinct_sessions },
+ { id: "distinct_projects", value: kpis?.distinct_projects },
+ { id: "distinct_subagents", value: kpis?.distinct_subagents },
+ { id: "distinct_slash_commands", value: kpis?.distinct_slash_commands },
+ { id: "distinct_skills", value: kpis?.distinct_skills },
+ ];
+
+ useTrackDashboardView({
+ isLoading: overviewIsLoading,
+ isError: kpisError,
+ hasData: Boolean(hasData),
+ insightCount: insightsError ? 0 : (insights?.length ?? 0),
+ sections: overviewSections,
+ metrics: overviewMetrics,
+ });
+
+ useEffect(() => {
+ if (
+ !organizationId ||
+ !userId ||
+ pageName !== "overview" ||
+ dateRangeDays == null
+ ) {
+ return;
+ }
+
+ if (!kpisLoading && kpisError) {
+ const failedRangeKey = `${organizationId}:${pageName}:${startDate}:${endDate}`;
+ if (failedRangeKeyRef.current === failedRangeKey) {
+ return;
+ }
+
+ failedRangeKeyRef.current = failedRangeKey;
+ captureDashboardLoadFailed({
+ organization_id: organizationId,
+ user_id: userId,
+ page_name: pageName,
+ query_name: "overview_kpis",
+ error_code: normalizeWebErrorCode(kpisQueryError),
+ date_range_days: dateRangeDays,
+ is_blocking: true,
+ http_status: getHttpStatusFromError(kpisQueryError),
+ });
+ }
+ }, [
+ dateRangeDays,
+ endDate,
+ kpisError,
+ kpisLoading,
+ kpisQueryError,
+ organizationId,
+ pageName,
+ startDate,
+ userId,
+ ]);
+
+ return (
+
+
+ ) : undefined
+ }
+ />
+
+ {kpisLoading && (
+
+
+
+
Loading dashboard data...
+
+
+ )}
+
+ {!kpisLoading && !hasData && hasAnySessions &&
}
+
+ {!kpisLoading && (kpisError || (kpis && kpis.total_sessions === 0)) && (
+
+ )}
+
+ {hasData && (
+ <>
+
+
+
+
+
+
+
+
+
+ {organizationId && userId && insights && insights.length > 0 && (
+
+
+ Quick Insights
+
+
+ {insights.map((insight, index) => (
+
+ ))}
+
+
+ )}
+
+ {usageTrendData && usageTrendData.length > 0 && (
+
+
+ Usage Trends
+
+
+ Track key metrics over time - switch between metric pairs to see
+ different views
+
+
+
+ )}
+
+ {modelTokensData && modelTokensData.length > 0 && (
+
+
+ Tokens by Model
+
+
+ Token consumption broken down by model type over time
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/apps/web/src/pages/dashboard/ProjectDetailPage.tsx b/apps/web/src/pages/dashboard/ProjectDetailPage.tsx
index d849c0ad..01eda397 100644
--- a/apps/web/src/pages/dashboard/ProjectDetailPage.tsx
+++ b/apps/web/src/pages/dashboard/ProjectDetailPage.tsx
@@ -29,6 +29,74 @@ import { useUserMap } from "@/hooks/useUserMap";
import { decodeProjectPath, formatUsername } from "@/lib/format";
import { orpc } from "@/lib/orpc";
+type ContributorChartPoint = {
+ avgHoursPerSession: number;
+ contributionPercentage: number;
+ hours: number;
+ name: string;
+ sessions: number;
+};
+
+function ContributorTooltip({
+ active,
+ payload,
+ tooltipBg,
+ tooltipBorder,
+}: {
+ active?: boolean;
+ payload?: ReadonlyArray<{ payload?: ContributorChartPoint }>;
+ tooltipBg: string;
+ tooltipBorder: string;
+}) {
+ const point = payload?.[0]?.payload;
+
+ if (!active || !point) {
+ return null;
+ }
+
+ return (
+
+
+ {point.name}
+
+
+
+ Sessions
+
+ {point.sessions.toLocaleString()}
+
+
+
+ Hours
+
+ {point.hours.toFixed(1)}h
+
+
+
+ Avg / session
+
+ {point.sessions > 0
+ ? `${point.avgHoursPerSession.toFixed(1)}h`
+ : "—"}
+
+
+
+ Contribution
+
+ {point.contributionPercentage.toFixed(0)}%
+
+
+
+
+ );
+}
+
export function ProjectDetailPage() {
const { projectPath: encodedProjectPath } = useParams<{
projectPath: string;
@@ -76,6 +144,11 @@ export function ProjectDetailPage() {
name: formatUsername(c.user_id, userMap),
sessions: c.sessions,
hours: parseFloat((c.total_duration_min / 60).toFixed(1)),
+ avgHoursPerSession:
+ c.sessions > 0
+ ? parseFloat((c.total_duration_min / 60 / c.sessions).toFixed(1))
+ : 0,
+ contributionPercentage: c.contribution_percentage,
}));
}, [contributors, userMap]);
@@ -301,17 +374,25 @@ export function ProjectDetailPage() {
v.length > 12 ? `${v.slice(0, 12)}…` : v
}
/>
-
+
(
+
+ )}
/>
;
+ tooltipBg: string;
+ tooltipBorder: string;
+}) {
+ const point = payload?.[0]?.payload;
+
+ if (!active || !point) {
+ return null;
+ }
+
+ const costPerCommit =
+ point.total_commits > 0 ? point.total_cost / point.total_commits : null;
+
+ return (
+
+
+ {formatRoiWeekLabel(point.week_start)}
+
+
+
+ Spend
+
+ {formatRoiCurrency(point.total_cost)}
+
+
+
+ Commits
+
+ {point.total_commits.toLocaleString()}
+
+
+
+ Cost / commit
+
+ {costPerCommit == null ? "—" : formatRoiCurrency(costPerCommit)}
+
+
+
+ Active devs
+
+ {point.active_developers.toLocaleString()}
+
+
+
+
+ );
+}
+
+function ROIProductivityTooltip({
+ active,
+ payload,
+ tooltipBg,
+ tooltipBorder,
+}: {
+ active?: boolean;
+ payload?: ReadonlyArray<{ payload?: RoiTrendPoint }>;
+ tooltipBg: string;
+ tooltipBorder: string;
+}) {
+ const point = payload?.[0]?.payload;
+
+ if (!active || !point) {
+ return null;
+ }
+
+ return (
+
+
+ {formatRoiWeekLabel(point.week_start)}
+
+
+
+ Productivity
+
+ {point.productivity_score.toFixed(1)}
+
+
+
+ Commits
+
+ {point.total_commits.toLocaleString()}
+
+
+
+ Spend
+
+ {formatRoiCurrency(point.total_cost)}
+
+
+
+ Avg success
+
+ {point.avg_success_score.toFixed(0)}%
+
+
+
+
+ );
+}
+
export function ROIPage() {
const { startDate, endDate, setStartDate, setEndDate, calculateDays } =
useDateRange();
@@ -588,22 +729,19 @@ export function ROIPage() {
}
/>
`$${value}`}
/>
[
- `$${((value as number) ?? 0).toFixed(2)}`,
- "Cost",
- ]}
- labelFormatter={(label) =>
- `Week of ${new Date(label).toLocaleDateString()}`
- }
+ content={(props) => (
+
+ )}
/>
-
+
- `Week of ${new Date(label).toLocaleDateString()}`
- }
+ content={(props) => (
+
+ )}
/>
typeof item === "string")
+ : [];
+}
+
+function toOptionalString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+function toContentString(value: unknown): string {
+ if (typeof value === "string") {
+ return value;
+ }
+
+ if (value == null) {
+ return "";
+ }
+
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return "";
+ }
+}
+
+function toSubagentMap(value: unknown): Record {
+ if (Array.isArray(value)) {
+ return Object.fromEntries(
+ value.filter(
+ (item): item is [string, string] =>
+ Array.isArray(item) &&
+ item.length >= 2 &&
+ typeof item[0] === "string" &&
+ typeof item[1] === "string",
+ ),
+ );
+ }
+
+ if (!value || typeof value !== "object") {
+ return {};
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).filter(
+ ([key, entryValue]) =>
+ typeof key === "string" && typeof entryValue === "string",
+ ),
+ );
+}
+
+class SessionDetailErrorBoundary extends Component<
+ { children: ReactNode },
+ { hasError: boolean }
+> {
+ override state = { hasError: false };
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ override componentDidCatch(error: unknown) {
+ console.error("[SessionDetailPage] Failed to render session detail", error);
+ }
+
+ override render() {
+ if (this.state.hasError) {
+ return (
+
+
+
+ Unable to render this session
+
+
+ The transcript payload for this session uses an unexpected shape.
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+function SessionDetailPageContent() {
const { sessionId } = useParams<{ sessionId: string }>();
const { userMap } = useUserMap();
const { trackUtility } = useAnalyticsTracking();
@@ -70,11 +170,12 @@ export function SessionDetailPage() {
data: session,
isLoading,
error,
- } = useQuery(
- orpc.analytics.sessions.detail.queryOptions({
+ } = useQuery({
+ ...orpc.analytics.sessions.detail.queryOptions({
input: { sessionId: sessionId as string },
}),
- );
+ enabled: Boolean(sessionId),
+ });
useTrackDashboardView({
isLoading,
@@ -138,6 +239,34 @@ export function SessionDetailPage() {
);
}
+ const safeSessionId = session.session_id || "unknown-session";
+ const safeSessionDate = toOptionalString(session.session_date) ?? "";
+ const safeUserId = toOptionalString(session.user_id) ?? "unknown-user";
+ const safeInputTokens = toNumber(session.input_tokens);
+ const safeOutputTokens = toNumber(session.output_tokens);
+ const safeDurationMin =
+ session.duration_min === undefined
+ ? undefined
+ : toNumber(session.duration_min);
+ const safeTotalInteractions =
+ session.total_interactions === undefined
+ ? undefined
+ : toNumber(session.total_interactions);
+ const safeSuccessScore =
+ session.success_score === undefined
+ ? undefined
+ : toNumber(session.success_score);
+ const safeSkills = toStringArray(session.skills);
+ const safeSlashCommands = toStringArray(session.slash_commands);
+ const safeSubagents = toSubagentMap(session.subagents);
+ const safeRepository = toOptionalString(session.repository);
+ const safeGitBranch = toOptionalString(session.git_branch);
+ const safeGitSha = toOptionalString(session.git_sha);
+ const safeModelUsed = toOptionalString(session.model_used);
+ const safeSessionArchetype =
+ toOptionalString(session.session_archetype) ?? undefined;
+ const safeContent = toContentString(session.content);
+
return (
{/* Session Header — pinned, never scrolls */}
@@ -148,10 +277,10 @@ export function SessionDetailPage() {
Session Details
- {session.session_archetype &&
+ {safeSessionArchetype &&
(() => {
const style =
- archetypeStyles[session.session_archetype] ??
+ archetypeStyles[safeSessionArchetype] ??
archetypeStyles.standard;
return (
- {session.session_id.slice(0, 8)}...
+ {safeSessionId.slice(0, 8)}...
- {formatRelativeTime(session.session_date)}
+ {formatRelativeTime(safeSessionDate)}
- {formatUsername(session.user_id, userMap)}
+ {formatUsername(safeUserId, userMap)}
@@ -196,22 +325,20 @@ export function SessionDetailPage() {
Duration
- {session.duration_min !== undefined
- ? `${session.duration_min} min`
- : "—"}
+ {safeDurationMin !== undefined ? `${safeDurationMin} min` : "—"}
Interactions
- {session.total_interactions ?? "—"}
+ {safeTotalInteractions ?? "—"}
Tokens
- {session.input_tokens.toLocaleString()} /{" "}
- {session.output_tokens.toLocaleString()}
+ {safeInputTokens.toLocaleString()} /{" "}
+ {safeOutputTokens.toLocaleString()}
@@ -219,12 +346,13 @@ export function SessionDetailPage() {
$
{calculateCost(
- session.input_tokens,
- session.output_tokens,
+ safeInputTokens,
+ safeOutputTokens,
+ safeModelUsed,
).toFixed(4)}
- {session.success_score !== undefined && (
+ {safeSuccessScore !== undefined && (
Score
@@ -232,22 +360,22 @@ export function SessionDetailPage() {
= 70
+ safeSuccessScore >= 70
? "text-status-success-icon"
- : session.success_score >= 40
+ : safeSuccessScore >= 40
? "text-status-warning-icon"
: "text-status-error-icon"
}`}
>
- {session.success_score.toFixed(0)}/100
+ {safeSuccessScore.toFixed(0)}/100
)}
- {Object.keys(session.subagents).length > 0 && (
+ {Object.keys(safeSubagents).length > 0 && (
Subagents
- {Object.keys(session.subagents).length}
+ {Object.keys(safeSubagents).length}
)}
@@ -255,41 +383,39 @@ export function SessionDetailPage() {
- {session.repository && (
+ {safeRepository && (
- {session.repository}
+ {safeRepository}
)}
- {session.git_branch && (
+ {safeGitBranch && (
- {session.git_branch}
+ {safeGitBranch}
)}
- {session.git_sha && (
+ {safeGitSha && (
- {session.git_sha.slice(0, 8)}
+ {safeGitSha.slice(0, 8)}
- {session.git_sha}
+ {safeGitSha}
- navigator.clipboard.writeText(session.git_sha as string)
- }
+ onClick={() => navigator.clipboard.writeText(safeGitSha)}
className="p-1 hover:bg-hover rounded"
title="Copy commit SHA"
>
@@ -299,16 +425,16 @@ export function SessionDetailPage() {
)}
- {session.model_used && (
+ {safeModelUsed && (
- {session.model_used}
+ {safeModelUsed}
)}
- {[...new Set(session.skills)].map((skill) => (
+ {[...new Set(safeSkills)].map((skill) => (
))}
- {[...new Set(session.slash_commands)].map((cmd) => (
+ {[...new Set(safeSlashCommands)].map((cmd) => (
))}
- {Object.keys(session.subagents).map((agent) => (
+ {Object.keys(safeSubagents).map((agent) => (
@@ -375,3 +501,11 @@ export function SessionDetailPage() {
);
}
+
+export function SessionDetailPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/dashboard/SessionsListPage.tsx b/apps/web/src/pages/dashboard/SessionsListPage.tsx
index 0405e3a4..5b984161 100644
--- a/apps/web/src/pages/dashboard/SessionsListPage.tsx
+++ b/apps/web/src/pages/dashboard/SessionsListPage.tsx
@@ -35,6 +35,7 @@ import {
import { useUserMap } from "@/hooks/useUserMap";
import { calculateCost, formatUsername } from "@/lib/format";
import { orpc } from "@/lib/orpc";
+import { getSessionDetailPath } from "@/lib/session-paths";
export function SessionsListPage() {
const navigate = useNavigate();
@@ -209,7 +210,8 @@ export function SessionsListPage() {
},
},
{
- accessorFn: (row) => calculateCost(row.input_tokens, row.output_tokens),
+ accessorFn: (row) =>
+ calculateCost(row.input_tokens, row.output_tokens, row.model_used),
id: "cost",
header: "Cost",
cell: ({ row }) => (
@@ -218,6 +220,7 @@ export function SessionsListPage() {
{calculateCost(
row.original.input_tokens,
row.original.output_tokens,
+ row.original.model_used,
).toFixed(4)}
),
@@ -564,9 +567,7 @@ export function SessionsListPage() {
analyticsId="sessions_list"
defaultSorting={[{ id: "date", desc: true }]}
getRowAnalyticsValue={(row) => row.session_id}
- onRowClick={(row) =>
- navigate(`/dashboard/sessions/${row.session_id}`)
- }
+ onRowClick={(row) => navigate(getSessionDetailPath(row.session_id))}
isRowClickable={(row) => canViewSession(row.user_id)}
/>
diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts
new file mode 100644
index 00000000..f149f27a
--- /dev/null
+++ b/apps/web/src/test/setup.ts
@@ -0,0 +1 @@
+import "@testing-library/jest-dom/vitest";
diff --git a/bun.lock b/bun.lock
index b8629ad2..c77f2a34 100644
--- a/bun.lock
+++ b/bun.lock
@@ -66,21 +66,36 @@
"name": "@rudel/web",
"version": "0.0.0",
"dependencies": {
+ "@base-ui-components/react": "^1.0.0-rc.0",
"@base-ui/react": "^1.3.0",
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/modifiers": "^9.0.0",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@fontsource-variable/inter": "^5.2.8",
"@fontsource/geist-mono": "^5.2.7",
+ "@fontsource/instrument-serif": "^5.2.8",
+ "@fontsource/manrope": "^5.2.8",
"@fontsource/nunito": "^5.2.7",
+ "@hugeicons/core-free-icons": "^4.0.0",
+ "@hugeicons/react": "^1.1.6",
+ "@nivo/bar": "^0.99.0",
+ "@nivo/core": "^0.99.0",
"@orpc/client": "latest",
"@orpc/contract": "latest",
"@orpc/tanstack-query": "latest",
"@rudel/api-routes": "workspace:*",
+ "@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "^5.80.0",
"@tanstack/react-table": "^8.21.3",
"better-auth": "^1.5.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
+ "dialkit": "^1.1.0",
"html-to-image": "^1.11.13",
"lucide-react": "^0.564.0",
+ "motion": "^12.38.0",
"next-themes": "^0.4.6",
"posthog-js": "^1.292.0",
"radix-ui": "^1.4.3",
@@ -93,23 +108,30 @@
"recharts": "^3.7.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
- "tailwind-merge": "^3.4.1",
+ "tailwind-merge": "^3.5.0",
+ "vaul": "^1.1.2",
"zod": "^3.25.0",
},
"devDependencies": {
"@rudel/typescript-config": "workspace:*",
"@tailwindcss/vite": "^4.1.0",
+ "@testing-library/jest-dom": "6.6.3",
+ "@testing-library/react": "16.3.0",
+ "@testing-library/user-event": "14.6.1",
"@types/node": "^22.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react": "^5.1.1",
+ "agentation": "^2.3.3",
"bun-types": "latest",
- "shadcn": "^3.8.5",
+ "jsdom": "26.1.0",
+ "shadcn": "^4.1.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"vite": "^7.3.1",
+ "vitest": "3.2.4",
},
},
"packages/agent-adapters": {
@@ -187,7 +209,9 @@
"chkit": "0.1.0-beta.16",
},
"packages": {
- "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="],
+ "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
+
+ "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -251,6 +275,10 @@
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
+ "@base-ui-components/react": ["@base-ui-components/react@1.0.0-rc.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@base-ui-components/utils": "0.2.2", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "tabbable": "^6.3.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-9lhUFbJcbXvc9KulLev1WTFxS/alJRBWDH/ibKSQaNvmDwMFS2gKp1sTeeldYSfKuS/KC1w2MZutc0wHu2hRHQ=="],
+
+ "@base-ui-components/utils": ["@base-ui-components/utils@0.2.2", "", { "dependencies": { "@babel/runtime": "^7.28.4", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-rNJCD6TFy3OSRDKVHJDzLpxO3esTV1/drRtWNUpe7rCpPN9HZVHUCuP+6rdDYDGWfXnQHbqi05xOyRP2iZAlkw=="],
+
"@base-ui/react": ["@base-ui/react@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.6", "@floating-ui/react-dom": "^2.1.8", "@floating-ui/utils": "^0.2.11", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA=="],
"@base-ui/utils": ["@base-ui/utils@0.2.6", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.11", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw=="],
@@ -325,8 +353,28 @@
"@clickhouse/client-web": ["@clickhouse/client-web@1.18.2", "", { "dependencies": { "@clickhouse/client-common": "1.18.2" } }, "sha512-+iFOVZKdsdqdpqc0pupcYuYJBA87f0pH3vROkmJ/xs7QY5C3kCEhcoipjPDjKkyfmYJNq3Ygv0ePbfYhWrmP9Q=="],
+ "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="],
+
+ "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="],
+
+ "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="],
+
+ "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="],
+
+ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
+
"@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="],
+ "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
+
+ "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
+
+ "@dnd-kit/modifiers": ["@dnd-kit/modifiers@9.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw=="],
+
+ "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="],
+
+ "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
+
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.54.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-41gU3q7v05GM92QPuPUf4CmUw+mmF8p4wLUh6MCRlxpCkJ9ByLcY9jUf6MwrMNmiKyG/rIckNxj9SCfmNCmCqw=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
@@ -403,12 +451,22 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="],
+
"@fontsource/geist-mono": ["@fontsource/geist-mono@5.2.7", "", {}, "sha512-xVPVFISJg/K0VVd+aQN0Y7X/sw9hUcJPyDWFJ5GpyU3bHELhoRsJkPSRSHXW32mOi0xZCUQDOaPj1sqIFJ1FGg=="],
+ "@fontsource/instrument-serif": ["@fontsource/instrument-serif@5.2.8", "", {}, "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="],
+
+ "@fontsource/manrope": ["@fontsource/manrope@5.2.8", "", {}, "sha512-gJHJmcuUk7qWcNCfcAri/DJQtXtBYqi9yKratr4jXhSo0I3xUtNNKI+igQIcw5c+m95g0vounk8ZnX/kb8o0TA=="],
+
"@fontsource/nunito": ["@fontsource/nunito@5.2.7", "", {}, "sha512-pmtBq0H9ex9nk+RtJYEJOD9pag393iHETnl/PVKleF4i06cd0ttngK5ZCTgYb5eOqR3Xdlrjtev8m7bmgYprew=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
+ "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@4.0.0", "", {}, "sha512-bzfbKumv3ke3ajbe2MyXi9i0I/cdsZ6n/mO9EfIPNSL++pHLqs7nSGRIVUtjF4xrrEyVkfhxssv4Jek8DPA6gA=="],
+
+ "@hugeicons/react": ["@hugeicons/react@1.1.6", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-c2LhXJMAW5wN1pC/smBXG0YPqUON6ceR/ZdXHCjEI9KvB+hjtqYjmzIxok5hAQOeXGz0WtORgCQMzqewFKAZwg=="],
+
"@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="],
"@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="],
@@ -441,6 +499,28 @@
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="],
+ "@nivo/annotations": ["@nivo/annotations@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g=="],
+
+ "@nivo/axes": ["@nivo/axes@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/scales": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-format": "^1.4.1", "@types/d3-time-format": "^2.3.1", "d3-format": "^1.4.4", "d3-time-format": "^3.0.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-3KschnmEL0acRoa7INSSOSEFwJLm54aZwSev7/r8XxXlkgRBriu6ReZy/FG0wfN+ljZ4GMvx+XyIIf6kxzvrZg=="],
+
+ "@nivo/bar": ["@nivo/bar@0.99.0", "", { "dependencies": { "@nivo/annotations": "0.99.0", "@nivo/axes": "0.99.0", "@nivo/canvas": "0.99.0", "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/legends": "0.99.0", "@nivo/scales": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-scale": "^4.0.8", "@types/d3-shape": "^3.1.6", "d3-scale": "^4.0.2", "d3-shape": "^3.2.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-9yfMn7H6UF/TqtCwVZ/vihVAXUff9wWvSaeF2Z1DCfgr5S07qs31Qb2p0LZA+YgCWpaU7zqkeb3VZ4WCpZbrDA=="],
+
+ "@nivo/canvas": ["@nivo/canvas@0.99.0", "", {}, "sha512-UxA8zb+NPwqmNm81hoyUZSMAikgjU1ukLf4KybVNyV8ejcJM+BUFXsb8DxTcLdt4nmCFHqM56GaJQv2hnAHmzg=="],
+
+ "@nivo/colors": ["@nivo/colors@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-color": "^3.0.0", "@types/d3-scale": "^4.0.8", "@types/d3-scale-chromatic": "^3.0.0", "d3-color": "^3.1.0", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w=="],
+
+ "@nivo/core": ["@nivo/core@0.99.0", "", { "dependencies": { "@nivo/theming": "0.99.0", "@nivo/tooltip": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0", "@types/d3-shape": "^3.1.6", "d3-color": "^3.1.0", "d3-format": "^1.4.4", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-scale-chromatic": "^3.0.0", "d3-shape": "^3.2.0", "d3-time-format": "^3.0.0", "lodash": "^4.17.21", "react-virtualized-auto-sizer": "^1.0.26", "use-debounce": "^10.0.4" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g=="],
+
+ "@nivo/legends": ["@nivo/legends@0.99.0", "", { "dependencies": { "@nivo/colors": "0.99.0", "@nivo/core": "0.99.0", "@nivo/text": "0.99.0", "@nivo/theming": "0.99.0", "@types/d3-scale": "^4.0.8", "d3-scale": "^4.0.2" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-P16FjFqNceuTTZphINAh5p0RF0opu3cCKoWppe2aRD9IuVkvRm/wS5K1YwMCxDzKyKh5v0AuTlu9K6o3/hk8hA=="],
+
+ "@nivo/scales": ["@nivo/scales@0.99.0", "", { "dependencies": { "@types/d3-interpolate": "^3.0.4", "@types/d3-scale": "^4.0.8", "@types/d3-time": "^1.1.1", "@types/d3-time-format": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-time": "^1.0.11", "d3-time-format": "^3.0.0", "lodash": "^4.17.21" } }, "sha512-g/2K4L6L8si6E2BWAHtFVGahtDKbUcO6xHJtlIZMwdzaJc7yB16EpWLK8AfI/A42KadLhJSJqBK3mty+c7YZ+w=="],
+
+ "@nivo/text": ["@nivo/text@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-ho3oZpAZApsJNjsIL5WJSAdg/wjzTBcwo1KiHBlRGUmD+yUWO8qp7V+mnYRhJchwygtRVALlPgZ/rlcW2Xr/MQ=="],
+
+ "@nivo/theming": ["@nivo/theming@0.99.0", "", { "dependencies": { "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg=="],
+
+ "@nivo/tooltip": ["@nivo/tooltip@0.99.0", "", { "dependencies": { "@nivo/core": "0.99.0", "@nivo/theming": "0.99.0", "@react-spring/web": "9.4.5 || ^9.7.2 || ^10.0" }, "peerDependencies": { "react": "^16.14 || ^17.0 || ^18.0 || ^19.0" } }, "sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg=="],
+
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
"@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
@@ -671,6 +751,18 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
+ "@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="],
+
+ "@react-spring/core": ["@react-spring/core@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/rafz": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-83u3FzfQmGMJFwZLAJSwF24/ZJctwUkWtyPD7KYtNagrFeQKUH1I05ZuhmCmqW+2w1KDW1SFWQ43RawqfXKiiQ=="],
+
+ "@react-spring/rafz": ["@react-spring/rafz@9.4.5", "", {}, "sha512-swGsutMwvnoyTRxvqhfJBtGM8Ipx6ks0RkIpNX9F/U7XmyPvBMGd3GgX/mqxZUpdlsuI1zr/jiYw+GXZxAlLcQ=="],
+
+ "@react-spring/shared": ["@react-spring/shared@9.4.5", "", { "dependencies": { "@react-spring/rafz": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-JhMh3nFKsqyag0KM5IIM8BQANGscTdd0mMv3BXsUiMZrcjQTskyfnv5qxEeGWbJGGar52qr5kHuBHtCjQOzniA=="],
+
+ "@react-spring/types": ["@react-spring/types@9.4.5", "", {}, "sha512-mpRIamoHwql0ogxEUh9yr4TP0xU5CWyZxVQeccGkHHF8kPMErtDXJlxyo0lj+telRF35XNihtPTWoflqtyARmg=="],
+
+ "@react-spring/web": ["@react-spring/web@9.4.5", "", { "dependencies": { "@react-spring/animated": "~9.4.5", "@react-spring/core": "~9.4.5", "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0", "react-dom": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-NGAkOtKmOzDEctL7MzRlQGv24sRce++0xAY7KlcxmeVkR7LRSGkoXHaIfm9ObzxPMcPHQYQhf3+X9jepIFNHQA=="],
+
"@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/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
@@ -753,6 +845,10 @@
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
+ "@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=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
@@ -791,8 +887,18 @@
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
+ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
+
+ "@testing-library/jest-dom": ["@testing-library/jest-dom@6.6.3", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "chalk": "^3.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "lodash": "^4.17.21", "redent": "^3.0.0" } }, "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA=="],
+
+ "@testing-library/react": ["@testing-library/react@16.3.0", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw=="],
+
+ "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="],
+
"@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
+ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="],
+
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
@@ -801,26 +907,36 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@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-format": ["@types/d3-format@1.4.5", "", {}, "sha512-mLxrC1MSWupOSncXN/HOlWUAAIffAEBaI4+PKy2uMPsKe4FNZlk7qrbTjmzJXITQQqBHivaks4Td18azgqnotA=="],
+
"@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-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
+
"@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-time": ["@types/d3-time@1.1.4", "", {}, "sha512-JIvy2HjRInE+TXOmIGN5LCmeO0hkFZx5f9FZ7kiN+D+YTcc8pptsiLiuHsvwxwC7VVKmJ2ExHUgNlAiV7vQM9g=="],
+
+ "@types/d3-time-format": ["@types/d3-time-format@2.3.4", "", {}, "sha512-xdDXbpVO74EvadI3UDxjxTdR6QIxm1FKzEA/+F8tL4GWWUg/hgvBqf6chql64U5A9ZUGWo7pEu4eNlyLwbKdhg=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -859,24 +975,42 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="],
+ "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
+
+ "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
+
+ "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
+
+ "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+
+ "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
+
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+ "agentation": ["agentation@2.3.3", "", { "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-AUZgFCdBQ/nAohlFsHByM9S2Dp7ECMNqVjlOke4hv/90v+wTiwrGladEkgWS60RDQp+CJ5p97meeCthYgTFlKQ=="],
+
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
- "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
- "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
-
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
+
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
@@ -911,6 +1045,8 @@
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
+ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
+
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
@@ -921,7 +1057,9 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
- "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+ "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
+
+ "chalk": ["chalk@3.0.0", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
@@ -931,6 +1069,8 @@
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
+ "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
+
"chevrotain": ["chevrotain@10.5.0", "", { "dependencies": { "@chevrotain/cst-dts-gen": "10.5.0", "@chevrotain/gast": "10.5.0", "@chevrotain/types": "10.5.0", "@chevrotain/utils": "10.5.0", "lodash": "4.17.21", "regexp-to-ast": "0.5.0" } }, "sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A=="],
"chkit": ["chkit@0.1.0-beta.16", "", { "dependencies": { "@chkit/clickhouse": "0.1.0-beta.16", "@chkit/codegen": "0.1.0-beta.16", "@chkit/core": "0.1.0-beta.16", "@clickhouse/client": "^1.11.0", "fast-glob": "^3.3.2" }, "bin": { "chkit": "dist/bin/chkit.js" } }, "sha512-urk2xM+3iGpQfPX4hDvPRRqgiEm5LwAaif4qtDcKGbnPo+BS/iVSEeyWQcvIum77CiuW8Z/QNlGEsOHl7pWLUA=="],
@@ -983,8 +1123,12 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+ "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
+ "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="],
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
@@ -993,7 +1137,7 @@
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
- "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+ "d3-format": ["d3-format@1.4.5", "", {}, "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
@@ -1001,28 +1145,36 @@
"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-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
+
"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": ["d3-time@1.1.0", "", {}, "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="],
- "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+ "d3-time-format": ["d3-time-format@3.0.0", "", { "dependencies": { "d3-time": "1 - 2" } }, "sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag=="],
"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=="],
+ "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="],
+
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
+ "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
+
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
@@ -1049,8 +1201,12 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
+ "dialkit": ["dialkit@1.1.0", "", { "peerDependencies": { "motion": ">=11.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0", "solid-js": ">=1.6.0", "svelte": ">=5.8.0" }, "optionalPeers": ["react", "react-dom", "solid-js", "svelte"] }, "sha512-xbVQT+c5kasLKpH5MzIzTdYjlGtj4Jjyo51gsUTcgV5UF/d24g0SBJqw4XPF9euhTWkie305RLqEPBgOEErSjQ=="],
+
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
+ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="],
+
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
@@ -1077,6 +1233,8 @@
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
+ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="],
@@ -1085,6 +1243,8 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
"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=="],
@@ -1103,6 +1263,8 @@
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
@@ -1113,6 +1275,8 @@
"execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="],
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
@@ -1155,6 +1319,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
+ "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="],
+
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.4", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA=="],
@@ -1165,8 +1331,6 @@
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
- "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
-
"generate-function": ["generate-function@2.3.1", "", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
@@ -1203,6 +1367,8 @@
"graphql": ["graphql@16.13.1", "", {}, "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ=="],
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
@@ -1223,12 +1389,16 @@
"hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
+ "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
+
"html-to-image": ["html-to-image@1.11.13", "", {}, "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
"http-status-codes": ["http-status-codes@2.3.0", "", {}, "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
@@ -1243,6 +1413,8 @@
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
+ "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="],
+
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
@@ -1285,6 +1457,8 @@
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
+
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
@@ -1307,6 +1481,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+ "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
@@ -1359,6 +1535,8 @@
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
+ "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
+
"lowlight": ["lowlight@1.20.0", "", { "dependencies": { "fault": "^1.0.0", "highlight.js": "~10.7.0" } }, "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -1367,6 +1545,8 @@
"lucide-react": ["lucide-react@0.564.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-JJ8GVTQqFwuliifD48U6+h7DXEHdkhJ/E87kksGByII3qHxtPciVb8T8woQONHBQgHVOl7rSMrrip3SeVNy7Fg=="],
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
@@ -1479,6 +1659,8 @@
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
+ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
+
"minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
@@ -1487,6 +1669,12 @@
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
+ "motion": ["motion@12.38.0", "", { "dependencies": { "framer-motion": "^12.38.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w=="],
+
+ "motion-dom": ["motion-dom@12.38.0", "", { "dependencies": { "motion-utils": "^12.36.0" } }, "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA=="],
+
+ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="],
@@ -1515,6 +1703,8 @@
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
+ "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="],
+
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1541,8 +1731,6 @@
"p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
- "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
-
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
@@ -1551,6 +1739,8 @@
"parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="],
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
@@ -1561,6 +1751,8 @@
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
+
"perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -1587,6 +1779,8 @@
"preact": ["preact@10.29.0", "", {}, "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg=="],
+ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="],
+
"pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="],
"prisma": ["prisma@7.5.0", "", { "dependencies": { "@prisma/config": "7.5.0", "@prisma/dev": "0.20.0", "@prisma/engines": "7.5.0", "@prisma/studio-core": "0.21.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg=="],
@@ -1649,12 +1843,16 @@
"react-syntax-highlighter": ["react-syntax-highlighter@16.1.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA=="],
+ "react-virtualized-auto-sizer": ["react-virtualized-auto-sizer@1.0.26", "", { "peerDependencies": { "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A=="],
+
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"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=="],
+ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="],
+
"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=="],
@@ -1699,6 +1897,8 @@
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+ "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="],
+
"rudel": ["rudel@workspace:apps/cli"],
"run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
@@ -1707,6 +1907,8 @@
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+ "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1721,7 +1923,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
- "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="],
+ "shadcn": ["shadcn@4.1.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-3zETJ+0Ezj69FS6RL0HOkLKKAR5yXisXx1iISJdfLQfrUqj/VIQlanQi1Ukk+9OE+XHZVj4FQNTBSfbr2CyCYg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -1735,6 +1937,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
@@ -1755,6 +1959,8 @@
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
@@ -1777,12 +1983,20 @@
"strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="],
+ "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
+
+ "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
+
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
"svix": ["svix@1.86.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ=="],
+ "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
+
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
@@ -1795,19 +2009,27 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
- "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
- "tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
+ "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
+
+ "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
+
+ "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
- "tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
+ "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
+
+ "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
- "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
+ "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="],
"tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
@@ -1869,6 +2091,8 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
+ "use-debounce": ["use-debounce@10.1.1", "", { "peerDependencies": { "react": "*" } }, "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ=="],
+
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
@@ -1883,6 +2107,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+ "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=="],
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
@@ -1891,22 +2117,40 @@
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
+ "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
+
+ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
+
+ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
+
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"web-vitals": ["web-vitals@5.1.0", "", {}, "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
+ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
+
"whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
+
"wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="],
+ "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
+
+ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
+
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
@@ -1927,6 +2171,8 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
"@better-auth/api-key/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
@@ -1941,6 +2187,8 @@
"@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "@nivo/scales/@types/d3-time-format": ["@types/d3-time-format@3.0.4", "", {}, "sha512-or9DiDnYI1h38J9hxKEsw513+KVuFbEVhl7qdxcaudoiqWWepapUen+2vAriFGexr6W5+P4l9+HJrB39GG+oRg=="],
+
"@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="],
@@ -2001,6 +2249,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
+
+ "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
+
+ "@types/d3-scale/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
"better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -2013,22 +2267,40 @@
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+ "d3-scale/d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+
+ "d3-scale/d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-scale/d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
"eciesjs/@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"eciesjs/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+ "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "msw/tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="],
+
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
+ "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+
+ "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="],
+
+ "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
+
"prisma/postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
@@ -2045,8 +2317,18 @@
"rudel/@orpc/contract": ["@orpc/contract@1.13.6", "", { "dependencies": { "@orpc/client": "1.13.6", "@orpc/shared": "1.13.6", "@standard-schema/spec": "^1.1.0", "openapi-types": "^12.1.3" } }, "sha512-wjnpKMsCBbUE7MxdS+9by1BIDTJ4vnfUk9he4GmxKQ8fvK/MRNHUR5jkNhsBCoLnigBrsAedHrr9AIqNgqquyQ=="],
+ "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
+
+ "victory-vendor/@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
+ "victory-vendor/d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
"vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
+ "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2111,10 +2393,10 @@
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
- "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+ "msw/tough-cookie/tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
+
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -2169,12 +2451,10 @@
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
- "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
-
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
- "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+ "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
}
}