diff --git a/frontend/index.html b/frontend/index.html index 115e885..c694184 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -32,6 +32,35 @@ rel="manifest" href="/favicon/site.webmanifest" /> + + + + + diff --git a/frontend/src/components/dashboard/chart-card.tsx b/frontend/src/components/dashboard/chart-card.tsx new file mode 100644 index 0000000..0f115f9 --- /dev/null +++ b/frontend/src/components/dashboard/chart-card.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react'; + +import { Loader2 } from 'lucide-react'; +import { ResponsiveContainer } from 'recharts'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +export const ChartCard = ({ + children, + className, + description, + height = 300, + loading, + title, +}: { + children: ReactNode; + className?: string; + description?: ReactNode; + height?: number; + loading?: boolean; + title: ReactNode; +}) => ( + + + {title} + {description && {description}} + + + {loading ? ( +
+ +
+ ) : ( + + {children} + + )} +
+
+); diff --git a/frontend/src/components/dashboard/chart-tooltip.tsx b/frontend/src/components/dashboard/chart-tooltip.tsx new file mode 100644 index 0000000..56bbd73 --- /dev/null +++ b/frontend/src/components/dashboard/chart-tooltip.tsx @@ -0,0 +1,48 @@ +import { formatNumber } from '@/lib/utils/format'; + +export type ChartTooltipPayloadEntry = { + color: string; + name: string; + value: number; +}; + +export const ChartTooltip = ({ + active, + formatter, + label, + labelFormatter, + payload, +}: { + active?: boolean; + formatter?: (value: number, name: string) => string; + label?: string; + labelFormatter?: (label: string) => string; + payload?: Array; +}) => { + if (!active || !payload?.length) { + return null; + } + + const renderedLabel = label ? (labelFormatter ? labelFormatter(label) : label) : ''; + + return ( +
+ {renderedLabel &&

{renderedLabel}

} + {payload.map((entry) => ( +
+ + {entry.name}: + + {formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)} + +
+ ))} +
+ ); +}; diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts new file mode 100644 index 0000000..45ed375 --- /dev/null +++ b/frontend/src/components/dashboard/index.ts @@ -0,0 +1,3 @@ +export { ChartCard } from './chart-card'; +export { ChartTooltip, type ChartTooltipPayloadEntry } from './chart-tooltip'; +export { MetricCard } from './metric-card'; diff --git a/frontend/src/components/dashboard/metric-card.tsx b/frontend/src/components/dashboard/metric-card.tsx new file mode 100644 index 0000000..38269af --- /dev/null +++ b/frontend/src/components/dashboard/metric-card.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from 'react'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +export const MetricCard = ({ + className, + description, + icon, + loading, + title, + value, +}: { + className?: string; + description?: ReactNode; + icon?: ReactNode; + loading?: boolean; + title: ReactNode; + value: ReactNode; +}) => ( + + + {loading ? : title} + {loading ? : icon} + + + {loading ? :
{value}
} + {description && + (loading ? ( + + ) : ( +

{description}

+ ))} +
+
+); diff --git a/frontend/src/components/icons/flow-status-badge.tsx b/frontend/src/components/icons/flow-status-badge.tsx new file mode 100644 index 0000000..8e39ad8 --- /dev/null +++ b/frontend/src/components/icons/flow-status-badge.tsx @@ -0,0 +1,24 @@ +import { FlowStatusIcon } from '@/components/icons/flow-status-icon'; +import { Badge } from '@/components/ui/badge'; +import { StatusType } from '@/graphql/types'; + +const STATUS_LABELS: Record = { + [StatusType.Created]: 'Created', + [StatusType.Failed]: 'Failed', + [StatusType.Finished]: 'Finished', + [StatusType.Running]: 'Running', + [StatusType.Waiting]: 'Waiting', +}; + +export const FlowStatusBadge = ({ className, status }: { className?: string; status: StatusType }) => ( + + + {STATUS_LABELS[status]} + +); diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx index e0336b0..757f988 100644 --- a/frontend/src/components/ui/badge.tsx +++ b/frontend/src/components/ui/badge.tsx @@ -11,10 +11,17 @@ const badgeVariants = cva( }, variants: { variant: { + blue: 'border-blue-500/20 bg-blue-500/10 text-blue-600', default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + green: 'border-green-500/20 bg-green-500/10 text-green-600', + orange: 'border-orange-500/20 bg-orange-500/10 text-orange-600', outline: 'text-foreground', + pink: 'border-pink-500/20 bg-pink-500/10 text-pink-600', + purple: 'border-purple-500/20 bg-purple-500/10 text-purple-600', + red: 'border-red-500/20 bg-red-500/10 text-red-600', secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + yellow: 'border-yellow-500/20 bg-yellow-500/10 text-yellow-600', }, }, }, @@ -22,6 +29,8 @@ const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes, VariantProps {} +export type BadgeVariant = NonNullable['variant']>; + function Badge({ className, variant, ...props }: BadgeProps) { return (
( - - - {title} - {icon} - - - {loading ? :
{value}
} -

{description}

-
-
-); +import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format'; const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => ( @@ -126,33 +102,33 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => { return (
- } + } loading={anyLoading} - title="Cost" - value={formatCost(totalCost)} + title="Tasks" + value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'} /> - } - loading={anyLoading} - title="Tokens" - value={formatTokenCount(totalTokens)} - /> - } loading={anyLoading} title="Tool Calls" value={toolcalls ? formatNumber(toolcalls.totalCount) : '0'} /> - } + } loading={anyLoading} - title="Tasks" - value={flowStats ? formatNumber(flowStats.totalTasksCount) : '0'} + title="Tokens" + value={formatTokenCount(totalTokens)} + /> + } + loading={anyLoading} + title="Cost" + value={formatCost(totalCost)} />
@@ -219,15 +195,9 @@ export const FlowDashboardOverview = ({ flowId }: { flowId: string }) => { {item.functionName} - + {item.isAgent ? 'Agent' : 'Tool'} - + {formatNumber(item.totalCount)} diff --git a/frontend/src/lib/storage-keys.ts b/frontend/src/lib/storage-keys.ts index 61ee323..b6936c1 100644 --- a/frontend/src/lib/storage-keys.ts +++ b/frontend/src/lib/storage-keys.ts @@ -1,6 +1,6 @@ const STORAGE_KEY_SEPARATOR = '_4_'; -export type LocalStorageKeyType = 'column' | 'page' | 'sorting'; +export type LocalStorageKeyType = 'column' | 'page' | 'period' | 'sorting'; export function getColumnStorageKey(urlPath?: string): string { return getStorageKey('column', urlPath); @@ -10,6 +10,10 @@ export function getPageStorageKey(urlPath?: string): string { return getStorageKey('page', urlPath); } +export function getPeriodStorageKey(urlPath?: string): string { + return getStorageKey('period', urlPath); +} + export function getSortingStorageKey(urlPath?: string): string { return getStorageKey('sorting', urlPath); } diff --git a/frontend/src/lib/utils/format.ts b/frontend/src/lib/utils/format.ts index 2d26c97..6d08840 100644 --- a/frontend/src/lib/utils/format.ts +++ b/frontend/src/lib/utils/format.ts @@ -18,3 +18,59 @@ export const formatDate = (date: Date) => { return format(date, 'HH:mm, d MMM yyyy', { locale: enUS }); }; + +export const formatNumber = (value: number): string => new Intl.NumberFormat('en-US').format(value); + +export const formatTokenCount = (count: number): string => { + if (count >= 1_000_000_000) { + return `${(count / 1_000_000_000).toFixed(1)}B`; + } + + if (count >= 1_000_000) { + return `${(count / 1_000_000).toFixed(1)}M`; + } + + if (count >= 1_000) { + return `${(count / 1_000).toFixed(1)}K`; + } + + return count.toString(); +}; + +export const formatCost = (cost: number): string => { + if (!cost) { + return '$0'; + } + + if (cost >= 1) { + return `$${cost.toFixed(2)}`; + } + + if (cost >= 0.01) { + return `$${cost.toFixed(3)}`; + } + + return `$${cost.toFixed(4)}`; +}; + +export const formatDuration = (seconds: number): string => { + if (seconds >= 3600) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + return `${hours}h ${minutes}m`; + } + + if (seconds >= 60) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + + return `${minutes}m ${remainingSeconds}s`; + } + + if (seconds >= 1) { + return `${seconds.toFixed(1)}s`; + } + + return `${(seconds * 1000).toFixed(0)}ms`; +}; diff --git a/frontend/src/pages/dashboard/dashboard-analytics.tsx b/frontend/src/pages/dashboard/dashboard-analytics.tsx index ead664b..78194a4 100644 --- a/frontend/src/pages/dashboard/dashboard-analytics.tsx +++ b/frontend/src/pages/dashboard/dashboard-analytics.tsx @@ -1,19 +1,23 @@ import { format } from 'date-fns'; import { ChevronRight, Clock, Loader2, Wrench } from 'lucide-react'; -import { useState } from 'react'; -import { Area, AreaChart, Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { useMemo, useState } from 'react'; +import { Area, AreaChart, Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts'; -import type { UsageStatsPeriod } from '@/graphql/types'; +import type { FlowFragmentFragment, UsageStatsPeriod } from '@/graphql/types'; +import { ChartCard, ChartTooltip } from '@/components/dashboard'; +import { FlowStatusBadge } from '@/components/icons/flow-status-badge'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { useFlowsExecutionStatsByPeriodQuery, + useFlowsQuery, useFlowsStatsByPeriodQuery, useToolcallsStatsByPeriodQuery, useUsageStatsByPeriodQuery, } from '@/graphql/types'; -import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils'; +import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format'; const CHART_COLORS = { area1: 'var(--color-chart-1)', @@ -31,48 +35,7 @@ const formatDateLabel = (dateString: string): string => { } }; -const ChartLoading = () => ( -
- -
-); - -const CustomTooltip = ({ - active, - formatter, - label, - payload, -}: { - active?: boolean; - formatter?: (value: number, name: string) => string; - label?: string; - payload?: Array<{ color: string; name: string; value: number }>; -}) => { - if (!active || !payload?.length) { - return null; - } - - return ( -
-

{label ? formatDateLabel(label) : ''}

- {payload.map((entry) => ( -
- - {entry.name}: - - {formatter ? formatter(entry.value, entry.name) : formatNumber(entry.value)} - -
- ))} -
- ); -}; +const axisTickStyle = { fill: 'var(--color-muted-foreground)', fontSize: 12 }; export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => { const { data: usageByPeriodData, loading: usageByPeriodLoading } = useUsageStatsByPeriodQuery({ @@ -87,12 +50,22 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => const { data: executionStatsData, loading: executionStatsLoading } = useFlowsExecutionStatsByPeriodQuery({ variables: { period }, }); + const { data: flowsData } = useFlowsQuery(); + + const flowsById = useMemo(() => { + const map = new Map(); + (flowsData?.flows ?? []).forEach((flow) => { + map.set(flow.id, flow); + }); + + return map; + }, [flowsData?.flows]); const usageChartData = [...(usageByPeriodData?.usageStatsByPeriod ?? [])].reverse().map((item) => ({ cacheIn: item.stats.totalUsageCacheIn, costIn: item.stats.totalUsageCostIn, costOut: item.stats.totalUsageCostOut, - date: formatDateLabel(item.date), + date: item.date, tokensIn: item.stats.totalUsageIn, tokensOut: item.stats.totalUsageOut, totalCost: item.stats.totalUsageCostIn + item.stats.totalUsageCostOut, @@ -100,13 +73,13 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => const toolcallsChartData = [...(toolcallsByPeriodData?.toolcallsStatsByPeriod ?? [])].reverse().map((item) => ({ count: item.stats.totalCount, - date: formatDateLabel(item.date), + date: item.date, duration: item.stats.totalDurationSeconds, })); const flowsChartData = [...(flowsByPeriodData?.flowsStatsByPeriod ?? [])].reverse().map((item) => ({ assistants: item.stats.totalAssistantsCount, - date: formatDateLabel(item.date), + date: item.date, flows: item.stats.totalFlowsCount, subtasks: item.stats.totalSubtasksCount, tasks: item.stats.totalTasksCount, @@ -116,210 +89,184 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => return (
+ + + + + + } + cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }} + /> + + + + + +
- - - Token Usage Over Time - Input and output tokens processed daily - - - {usageByPeriodLoading ? ( - - ) : ( - - - - - - formatTokenCount(value)} />} - /> - - - - - )} - - + + + + + + } + cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }} + /> + + + - - - Cost Over Time - LLM spending per day - - - {usageByPeriodLoading ? ( - - ) : ( - - - - - formatCost(value)} - tickMargin={8} - /> - formatCost(value)} />} /> - - - - - )} - - - - - - Tool Calls Over Time - Number of tool executions per day - - - {toolcallsByPeriodLoading ? ( - - ) : ( - - - - - - } - cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }} - /> - - - - )} - - - - - - Flows Activity Over Time - Flows, tasks, and subtasks created per day - - - {flowsByPeriodLoading ? ( - - ) : ( - - - - - - } - cursor={{ fill: 'var(--color-muted-foreground)', fillOpacity: 0.1 }} - /> - - - - - - )} - - + + + + + + formatTokenCount(value)} + labelFormatter={formatDateLabel} + /> + } + /> + + + +
+ + + + + formatCost(value)} + tickMargin={8} + /> + formatCost(value)} + labelFormatter={formatDateLabel} + /> + } + /> + + + + + Flow Execution Details @@ -339,6 +286,7 @@ export const DashboardAnalytics = ({ period }: { period: UsageStatsPeriod }) => {executionStats.map((flow) => ( ))} @@ -370,25 +318,39 @@ type FlowExecution = { totalToolcallsCount: number; }; -const FlowExecutionItem = ({ flow }: { flow: FlowExecution }) => { +const FlowExecutionItem = ({ flow, flowMeta }: { flow: FlowExecution; flowMeta?: FlowFragmentFragment }) => { const [isOpen, setIsOpen] = useState(false); + const taskCount = flow.tasks.length; + const subtaskCount = flow.tasks.reduce((sum, task) => sum + task.subtasks.length, 0); return ( - - -
{flow.flowTitle || `Flow #${flow.flowId}`}
-
+ + +
+
+ {flow.flowTitle || `Flow #${flow.flowId}`} + {flowMeta?.status && } + {flowMeta?.provider?.name && {flowMeta.provider.name}} +
+
+ {taskCount} {taskCount === 1 ? 'task' : 'tasks'} + {subtaskCount > 0 && ` · ${subtaskCount} ${subtaskCount === 1 ? 'subtask' : 'subtasks'}`} + {flow.totalAssistantsCount > 0 && + ` · ${flow.totalAssistantsCount} ${flow.totalAssistantsCount === 1 ? 'assistant' : 'assistants'}`} +
+
+
{formatDuration(flow.totalDurationSeconds)} - {flow.totalToolcallsCount} + {formatNumber(flow.totalToolcallsCount)}
@@ -432,7 +394,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) = - {task.totalToolcallsCount} + {formatNumber(task.totalToolcallsCount)}
@@ -454,7 +416,7 @@ const TaskExecutionItem = ({ task }: { task: FlowExecution['tasks'][number] }) = - {subtask.totalToolcallsCount} + {formatNumber(subtask.totalToolcallsCount)}
diff --git a/frontend/src/pages/dashboard/dashboard-overview.tsx b/frontend/src/pages/dashboard/dashboard-overview.tsx index 67ba6af..5901a5a 100644 --- a/frontend/src/pages/dashboard/dashboard-overview.tsx +++ b/frontend/src/pages/dashboard/dashboard-overview.tsx @@ -2,8 +2,9 @@ import { Activity, CircleDollarSign, Cpu, GitFork, Loader2 } from 'lucide-react' import type { UsageStatsFragmentFragment } from '@/graphql/types'; +import { MetricCard } from '@/components/dashboard'; +import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Skeleton } from '@/components/ui/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useFlowsStatsTotalQuery, @@ -14,32 +15,7 @@ import { useUsageStatsByProviderQuery, useUsageStatsTotalQuery, } from '@/graphql/types'; -import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/pages/dashboard/format-utils'; - -const StatCard = ({ - description, - icon, - loading, - title, - value, -}: { - description: string; - icon: React.ReactNode; - loading: boolean; - title: string; - value: string; -}) => ( - - - {title} - {icon} - - - {loading ? :
{value}
} -

{description}

-
-
-); +import { formatCost, formatDuration, formatNumber, formatTokenCount } from '@/lib/utils/format'; const UsageStatsRow = ({ label, stats }: { label: string; stats: UsageStatsFragmentFragment }) => ( @@ -124,33 +100,33 @@ export const DashboardOverview = () => { return (
- } - loading={usageTotalLoading} - title="Total Cost" - value={formatCost(totalCost)} + } + loading={flowsTotalLoading} + title="Total Flows" + value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'} /> - } - loading={usageTotalLoading} - title="Total Tokens" - value={formatTokenCount(totalTokens)} - /> - } loading={toolcallsTotalLoading} title="Tool Calls" value={toolcallsTotal ? formatNumber(toolcallsTotal.totalCount) : '0'} /> - } - loading={flowsTotalLoading} - title="Total Flows" - value={flowsTotal ? formatNumber(flowsTotal.totalFlowsCount) : '0'} + } + loading={usageTotalLoading} + title="Total Tokens" + value={formatTokenCount(totalTokens)} + /> + } + loading={usageTotalLoading} + title="Total Cost" + value={formatCost(totalCost)} />
@@ -208,15 +184,9 @@ export const DashboardOverview = () => { {item.functionName} - + {item.isAgent ? 'Agent' : 'Tool'} - + {formatNumber(item.totalCount)} diff --git a/frontend/src/pages/dashboard/dashboard.tsx b/frontend/src/pages/dashboard/dashboard.tsx index 7971ce2..1fd023a 100644 --- a/frontend/src/pages/dashboard/dashboard.tsx +++ b/frontend/src/pages/dashboard/dashboard.tsx @@ -1,11 +1,12 @@ import { LayoutDashboard } from 'lucide-react'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Separator } from '@/components/ui/separator'; import { SidebarTrigger } from '@/components/ui/sidebar'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { UsageStatsPeriod } from '@/graphql/types'; +import { getPeriodStorageKey } from '@/lib/storage-keys'; import { DashboardAnalytics } from '@/pages/dashboard/dashboard-analytics'; import { DashboardOverview } from '@/pages/dashboard/dashboard-overview'; @@ -15,9 +16,40 @@ const periodOptions: { label: string; value: UsageStatsPeriod }[] = [ { label: 'Quarter', value: UsageStatsPeriod.Quarter }, ]; +const VALID_PERIODS = new Set(Object.values(UsageStatsPeriod)); + +const loadPeriod = (storageKey: string): UsageStatsPeriod => { + try { + const stored = localStorage.getItem(storageKey); + + if (stored && VALID_PERIODS.has(stored)) { + return stored as UsageStatsPeriod; + } + } catch { + /* localStorage may be unavailable */ + } + + return UsageStatsPeriod.Week; +}; + +const savePeriod = (storageKey: string, value: UsageStatsPeriod): void => { + try { + localStorage.setItem(storageKey, value); + } catch { + /* localStorage may be unavailable */ + } +}; + const Dashboard = () => { + const periodStorageKey = useMemo(() => getPeriodStorageKey(), []); const [activeTab, setActiveTab] = useState('analytics'); - const [period, setPeriod] = useState(UsageStatsPeriod.Week); + const [period, setPeriod] = useState(() => loadPeriod(periodStorageKey)); + + const handlePeriodChange = (value: string) => { + const next = value as UsageStatsPeriod; + setPeriod(next); + savePeriod(periodStorageKey, next); + }; return ( <> @@ -53,7 +85,7 @@ const Dashboard = () => { {activeTab === 'analytics' && ( setPeriod(value as UsageStatsPeriod)} + onValueChange={handlePeriodChange} value={period} > diff --git a/frontend/src/pages/dashboard/format-utils.ts b/frontend/src/pages/dashboard/format-utils.ts deleted file mode 100644 index 953543a..0000000 --- a/frontend/src/pages/dashboard/format-utils.ts +++ /dev/null @@ -1,57 +0,0 @@ -export const formatTokenCount = (count: number): string => { - if (count >= 1_000_000_000) { - return `${(count / 1_000_000_000).toFixed(1)}B`; - } - - if (count >= 1_000_000) { - return `${(count / 1_000_000).toFixed(1)}M`; - } - - if (count >= 1_000) { - return `${(count / 1_000).toFixed(1)}K`; - } - - return count.toString(); -}; - -export const formatCost = (cost: number): string => { - if (!cost) { - return '$0'; - } - - if (cost >= 1) { - return `$${cost.toFixed(2)}`; - } - - if (cost >= 0.01) { - return `$${cost.toFixed(3)}`; - } - - return `$${cost.toFixed(4)}`; -}; - -export const formatDuration = (seconds: number): string => { - if (seconds >= 3600) { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - return `${hours}h ${minutes}m`; - } - - if (seconds >= 60) { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); - - return `${minutes}m ${remainingSeconds}s`; - } - - if (seconds >= 1) { - return `${seconds.toFixed(1)}s`; - } - - return `${(seconds * 1000).toFixed(0)}ms`; -}; - -export const formatNumber = (value: number): string => { - return new Intl.NumberFormat('en-US').format(value); -}; diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index fd27f2f..516de02 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -4,6 +4,15 @@ @custom-variant dark (&:is(.dark *)); +@font-face { + font-family: 'Inter Fallback'; + src: local('Arial'); + ascent-override: 90%; + descent-override: 22.43%; + line-gap-override: 0%; + size-adjust: 107.64%; +} + @font-face { font-family: 'Inter'; font-style: normal; @@ -297,9 +306,9 @@ --sidebar-accent-foreground: oklch(0.32 0 0); --sidebar-border: oklch(0.93 0.01 240); --sidebar-ring: oklch(0.25 0.14 245); - --font-sans: Inter, sans-serif; - --font-serif: Inter, serif; - --font-mono: Roboto Mono, monospace; + --font-sans: Inter, 'Inter Fallback', sans-serif; + --font-serif: Inter, 'Inter Fallback', serif; + --font-mono: 'Roboto Mono', monospace; --radius: 0.375rem; --shadow-x: 0; --shadow-y: 1px; @@ -352,8 +361,8 @@ --sidebar-accent-foreground: oklch(0.92 0.02 245); --sidebar-border: oklch(0.3 0.04 245); --sidebar-ring: oklch(0.5 0.16 245); - --font-sans: Inter, sans-serif; - --font-serif: Inter, serif; + --font-sans: Inter, 'Inter Fallback', sans-serif; + --font-serif: Inter, 'Inter Fallback', serif; --font-mono: 'Roboto Mono', monospace; --radius: 0.5rem; --shadow-x: 0;