diff --git a/package.json b/package.json
index e37e04361e..631c50df6f 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"@radix-ui/react-separator": "^1.1.6",
"@radix-ui/react-slider": "^1.3.4",
"@radix-ui/react-slot": "^1.2.2",
+ "@radix-ui/react-switch": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.11",
"@radix-ui/react-toast": "^1.2.13",
"@radix-ui/react-tooltip": "^1.1.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7f522066ea..4bc4717340 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.2
version: 1.2.2(@types/react@19.1.3)(react@19.1.0)
+ '@radix-ui/react-switch':
+ specifier: ^1.2.4
+ version: 1.2.4(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tabs':
specifier: ^1.1.11
version: 1.1.11(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -2568,6 +2571,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-switch@1.2.4':
+ resolution: {integrity: sha512-yZCky6XZFnR7pcGonJkr9VyNRu46KcYAbyg1v/gVVCZUr8UJ4x+RpncC27hHtiZ15jC+3WS8Yg/JSgyIHnYYsQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-tabs@1.1.11':
resolution: {integrity: sha512-4FiKSVoXqPP/KfzlB7lwwqoFV6EPwkrrqGp9cUYXjwDYHhvpnqq79P+EPHKcdoTE7Rl8w/+6s9rTlsfXHES9GA==}
peerDependencies:
@@ -11225,6 +11241,20 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.3
+ '@radix-ui/react-switch@1.2.4(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.2
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.3)(react@19.1.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.1.3)(react@19.1.0)
+ '@radix-ui/react-primitive': 2.1.2(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.3)(react@19.1.0)
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.3)(react@19.1.0)
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.3)(react@19.1.0)
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+ optionalDependencies:
+ '@types/react': 19.1.3
+
'@radix-ui/react-tabs@1.1.11(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
diff --git a/src/components/layout/NavbarHeader.tsx b/src/components/layout/NavbarHeader.tsx
index cdad665bf1..bbd14b99fc 100644
--- a/src/components/layout/NavbarHeader.tsx
+++ b/src/components/layout/NavbarHeader.tsx
@@ -1,11 +1,7 @@
import Link from 'next/link';
import { OrganizationSwitcher, UserButton } from '@clerk/nextjs';
-import {
- LocaleSwitcher,
- ThemeSwitcher,
- HoppingLogo,
-} from '@/components/layout';
+import { ThemeSwitcher, HoppingLogo } from '@/components/layout';
import { Section } from './Section';
@@ -25,11 +21,8 @@ export const NavbarHeader = (props: NavbarHeaderProps) => (
hidePersonal
/>
-
- -
-
-
- -
+
+ -
-
diff --git a/src/components/layout/ThemeSwitcher.tsx b/src/components/layout/ThemeSwitcher.tsx
index a92dbb2d11..47226d02b6 100644
--- a/src/components/layout/ThemeSwitcher.tsx
+++ b/src/components/layout/ThemeSwitcher.tsx
@@ -1,35 +1,23 @@
'use client';
-import { Moon, Sun } from 'lucide-react';
import { useTheme } from 'next-themes';
+import { Moon, Sun } from 'lucide-react';
-import {
- Button,
- DropdownMenu,
- DropdownMenuTrigger,
- DropdownMenuContent,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
-} from '@/components/ui';
+import { Switch } from '@/components/ui';
export function ThemeSwitcher() {
- const { theme, setTheme } = useTheme();
+ const { resolvedTheme, setTheme } = useTheme();
return (
-
-
-
-
-
-
- Light
- Dark
- System
-
-
-
+
+
setTheme(checked ? 'dark' : 'light')}
+ />
+
+
+
+
+
);
}
diff --git a/src/components/ui/ecosystem/tabs.tsx b/src/components/ui/ecosystem/tabs.tsx
index 4b24bc7578..9f9eaff39e 100644
--- a/src/components/ui/ecosystem/tabs.tsx
+++ b/src/components/ui/ecosystem/tabs.tsx
@@ -98,7 +98,7 @@ const TabsTrigger = React.forwardRef<
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 z-10',
- 'transition-colors duration-500 data-[state=active]:text-primary-foreground cursor-pointer',
+ 'transition-colors duration-500 text-sm font-medium data-[state=active]:text-primary-foreground cursor-pointer',
className,
)}
{...props}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 70420c8d82..cf6b70162f 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -8,7 +8,9 @@ export * from './form';
export * from './input';
export * from './label';
export * from './separator';
+export * from './skeleton';
export * from './slider';
export * from './sonner';
+export * from './switch';
export * from './table';
export * from './tabs';
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
new file mode 100644
index 0000000000..46ef8eb90a
--- /dev/null
+++ b/src/components/ui/switch.tsx
@@ -0,0 +1,34 @@
+'use client';
+
+import * as React from 'react';
+import * as SwitchPrimitive from '@radix-ui/react-switch';
+
+import { cn } from '@/lib/utils';
+
+function Switch({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { Switch };
diff --git a/src/components/usage/Metric.tsx b/src/components/usage/Metric.tsx
new file mode 100644
index 0000000000..f2b2bcc387
--- /dev/null
+++ b/src/components/usage/Metric.tsx
@@ -0,0 +1,25 @@
+import { Skeleton } from '../ui';
+import { formatNumber } from '@/lib/formatters';
+
+type MetricProps = {
+ label: string;
+ value?: number | string;
+ isLoading: boolean;
+};
+
+export const Metric = ({ label, value, isLoading }: MetricProps) => (
+
+
{label}
+
+ {isLoading ? (
+
+
+
+ ) : typeof value === 'number' ? (
+ formatNumber(value)
+ ) : value ? (
+ value
+ ) : null}
+
+
+);
diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx
index cf6e908493..985bb1a7ad 100644
--- a/src/components/usage/UsageCard.tsx
+++ b/src/components/usage/UsageCard.tsx
@@ -12,6 +12,7 @@ import { TelemetryEventName } from '@roo-code/types';
import { type TimePeriod, timePeriods } from '@/schemas';
import { getUsage } from '@/actions/analytics';
+import { formatCurrency } from '@/lib/formatters';
import {
Button,
@@ -23,6 +24,8 @@ import {
} from '@/components/ui';
import { Button as EnhancedButton } from '@/components/ui/ecosystem';
+import { Metric } from './Metric';
+
export const UsageCard = () => {
const t = useTranslations('DashboardIndex');
const { orgId } = useAuth();
@@ -74,52 +77,37 @@ export const UsageCard = () => {
))}
-
-
- {t('analytics_active_developers')}
-
-
- {usage.data?.[TelemetryEventName.LLM_COMPLETION]?.userCount ??
- '-'}
-
-
-
-
- {t('analytics_tasks_started')}
-
-
- {usage.data?.[TelemetryEventName.TASK_CREATED]?.eventCount ??
- '-'}
-
-
-
-
- {t('analytics_tasks_completed')}
-
-
- {usage.data?.[TelemetryEventName.TASK_COMPLETED]?.eventCount ??
- '-'}
-
-
-
-
- {t('analytics_tokens_consumed')}
-
-
- {usage.data?.[TelemetryEventName.LLM_COMPLETION]?.inputTokens ??
- '-'}
-
-
-
-
- {t('analytics_llm_costs')}
-
-
- {usage.data?.[TelemetryEventName.LLM_COMPLETION]?.cost?.toFixed(
- 2,
- ) ?? '-'}
-
-
+
+
+
+
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 0ca4ead310..1468e77a08 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -3,20 +3,7 @@
"meta_title": "Roo Code Cloud",
"meta_description": "Cloud management for Roo Code."
},
- "Footer": {
- "terms_of_service": "Terms Of Service",
- "privacy_policy": "Privacy Policy"
- },
- "DashboardLayout": {
- "home": "Home",
- "analytics": "Analytics",
- "members": "Members",
- "audit_logs": "Audit Logs",
- "settings": "Settings",
- "provider_whitelist": "Provider Whitelist"
- },
"DashboardIndex": {
- "title_bar": "Dashboard",
"analytics_title": "Usage Analytics",
"analytics_description": "Organization usage metrics and statistics",
"analytics_period_7_days": "Last 7 days",
@@ -25,17 +12,10 @@
"analytics_tasks_started": "Tasks Started",
"analytics_tasks_completed": "Tasks Completed",
"analytics_tokens_consumed": "Tokens Consumed",
- "analytics_costs": "Costs (USD)",
- "analytics_llm_costs": "LLM Model Costs (USD)",
+ "analytics_llm_costs": "Model Costs (USD)",
"analytics_active_developers": "Active Developers"
},
- "UserProfile": {
- "title_bar": "User Profile",
- "title_bar_description": "View and manage your user profile"
- },
"OrganizationProfile": {
- "title_bar": "Organization Management",
- "title_bar_description": "Manage your organization",
"provider_whitelist": "Provider Whitelist",
"default_parameters": "Default Parameters"
},
@@ -48,32 +28,9 @@
"parameters_section_description": "Set global default parameters for AI model calls"
},
"Analytics": {
- "title_bar": "Usage Analytics",
- "title_bar_description": "Detailed analytics for your organization",
- "summary_title": "Usage Summary",
- "period_7_days": "Last 7 days",
- "period_30_days": "Last 30 days",
- "period_90_days": "Last 90 days",
- "active_developers": "Active Developers",
- "tasks_started": "Tasks Started",
- "tasks_completed": "Tasks Completed",
- "tokens_consumed": "Tokens Consumed",
- "llm_costs": "LLM Model Costs (USD)",
"view_mode_title": "View By",
"view_mode_developers": "Developers",
"view_mode_models": "Models",
- "view_mode_tasks": "Tasks",
- "filter_title": "Filters",
- "filter_developer": "Developer",
- "filter_model": "Model",
- "filter_clear_all": "Clear All",
- "task_details_title": "Task Details",
- "task_details_close": "Close",
- "pagination_prev": "Previous",
- "pagination_next": "Next",
- "pagination_of": "of",
- "pagination_page_size": "Page size",
- "no_data": "No data available",
- "loading": "Loading data..."
+ "view_mode_tasks": "Tasks"
}
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index de490c95f3..d1964d8e4b 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -3,20 +3,7 @@
"meta_title": "Roo Code Cloud",
"meta_description": "Gestion cloud pour Roo Code."
},
- "Footer": {
- "terms_of_service": "Conditions d'utilisation",
- "privacy_policy": "Politique de confidentialité"
- },
- "DashboardLayout": {
- "home": "Accueil",
- "analytics": "Analytiques",
- "members": "Membres",
- "audit_logs": "Journaux d'audit",
- "settings": "Réglages",
- "provider_whitelist": "Liste blanche des fournisseurs"
- },
"DashboardIndex": {
- "title_bar": "Tableau de bord",
"analytics_title": "Analytiques d'utilisation",
"analytics_description": "Métriques et statistiques d'utilisation de l'organisation",
"analytics_period_7_days": "7 derniers jours",
@@ -25,18 +12,10 @@
"analytics_tasks_started": "Tâches commencées",
"analytics_tasks_completed": "Tâches terminées",
"analytics_tokens_consumed": "Tokens consommés",
- "analytics_costs": "Coûts (USD)",
- "analytics_llm_costs": "Coûts des modèles LLM (USD)",
- "analytics_active_developers": "Développeurs actifs",
- "analytics_view_details": "Voir les analytiques détaillées"
- },
- "UserProfile": {
- "title_bar": "Profil utilisateur",
- "title_bar_description": "Afficher et gérer votre profil utilisateur"
+ "analytics_llm_costs": "Coûts des modèles (USD)",
+ "analytics_active_developers": "Développeurs actifs"
},
"OrganizationProfile": {
- "title_bar": "Gestion de l'organisation",
- "title_bar_description": "Gérer votre organisation",
"provider_whitelist": "Liste blanche des fournisseurs",
"default_parameters": "Paramètres par défaut"
},
@@ -49,32 +28,9 @@
"parameters_section_description": "Définissez les paramètres par défaut globaux pour les appels aux modèles d'IA"
},
"Analytics": {
- "title_bar": "Analytiques d'utilisation",
- "title_bar_description": "Analytiques détaillées pour votre organisation",
- "summary_title": "Résumé d'utilisation",
- "period_7_days": "7 derniers jours",
- "period_30_days": "30 derniers jours",
- "period_90_days": "90 derniers jours",
- "active_developers": "Développeurs actifs",
- "tasks_started": "Tâches commencées",
- "tasks_completed": "Tâches terminées",
- "tokens_consumed": "Tokens consommés",
- "llm_costs": "Coûts des modèles LLM (USD)",
"view_mode_title": "Afficher Par",
"view_mode_developers": "Développeurs",
"view_mode_models": "Modèles",
- "view_mode_tasks": "Tâches",
- "filter_title": "Filtres",
- "filter_developer": "Développeur",
- "filter_model": "Modèle",
- "filter_clear_all": "Effacer tout",
- "task_details_title": "Détails de la tâche",
- "task_details_close": "Fermer",
- "pagination_prev": "Précédent",
- "pagination_next": "Suivant",
- "pagination_of": "sur",
- "pagination_page_size": "Taille de page",
- "no_data": "Aucune donnée disponible",
- "loading": "Chargement des données..."
+ "view_mode_tasks": "Tâches"
}
}
diff --git a/src/lib/formatters.test.ts b/src/lib/formatters.test.ts
new file mode 100644
index 0000000000..e9c2a3bba8
--- /dev/null
+++ b/src/lib/formatters.test.ts
@@ -0,0 +1,96 @@
+// npx vitest run src/lib/formatters.test.ts
+
+import { formatNumber, formatCurrency } from './formatters';
+
+describe('formatNumber', () => {
+ it('should return empty string for undefined or null values', () => {
+ expect(formatNumber(undefined)).toBe('');
+ expect(formatNumber(null as unknown as undefined)).toBe('');
+ });
+
+ it('should return "0" for zero', () => {
+ expect(formatNumber(0)).toBe('0');
+ });
+
+ it('should return the number as string for values less than 1000', () => {
+ expect(formatNumber(1)).toBe('1');
+ expect(formatNumber(123)).toBe('123');
+ expect(formatNumber(999)).toBe('999');
+ });
+
+ it('should format thousands with K suffix', () => {
+ expect(formatNumber(1000)).toBe('1.0K');
+ expect(formatNumber(1500)).toBe('1.5K');
+ expect(formatNumber(2345)).toBe('2.3K');
+ expect(formatNumber(999999)).toBe('1000.0K');
+ });
+
+ it('should format millions with M suffix', () => {
+ expect(formatNumber(1000000)).toBe('1.0M');
+ expect(formatNumber(1500000)).toBe('1.5M');
+ expect(formatNumber(2345678)).toBe('2.3M');
+ expect(formatNumber(999999999)).toBe('1000.0M');
+ });
+
+ it('should format billions with B suffix', () => {
+ expect(formatNumber(1000000000)).toBe('1.0B');
+ expect(formatNumber(1500000000)).toBe('1.5B');
+ expect(formatNumber(2345678901)).toBe('2.3B');
+ expect(formatNumber(999999999999)).toBe('1000.0B');
+ });
+
+ it('should format trillions with T suffix', () => {
+ expect(formatNumber(1000000000000)).toBe('1.0T');
+ expect(formatNumber(1500000000000)).toBe('1.5T');
+ expect(formatNumber(2345678901234)).toBe('2.3T');
+ });
+
+ it('should handle negative numbers correctly', () => {
+ expect(formatNumber(-1000)).toBe('-1.0K');
+ expect(formatNumber(-1500000)).toBe('-1.5M');
+ expect(formatNumber(-2345678901)).toBe('-2.3B');
+ expect(formatNumber(-1500000000000)).toBe('-1.5T');
+ });
+});
+
+describe('formatCurrency', () => {
+ it('should return empty string for undefined or null values', () => {
+ expect(formatCurrency(undefined)).toBe('');
+ expect(formatCurrency(null as unknown as undefined)).toBe('');
+ });
+
+ it('should format USD currency correctly', () => {
+ expect(formatCurrency(0)).toBe('$0.00');
+ expect(formatCurrency(1)).toBe('$1.00');
+ expect(formatCurrency(1.5)).toBe('$1.50');
+ expect(formatCurrency(1234.56)).toBe('$1,234.56');
+ expect(formatCurrency(1000000)).toBe('$1,000,000.00');
+ });
+
+ it('should handle negative currency values correctly', () => {
+ expect(formatCurrency(-1)).toBe('-$1.00');
+ expect(formatCurrency(-1234.56)).toBe('-$1,234.56');
+ });
+
+ it('should format different currencies correctly', () => {
+ const eurFormat = formatCurrency(1234.56, 'EUR', 'de-DE');
+ expect(eurFormat).toContain('€');
+ expect(eurFormat).toContain('1.234,56');
+
+ const jpyFormat = formatCurrency(1234.56, 'JPY', 'ja-JP');
+ expect(jpyFormat).toContain('¥');
+ expect(jpyFormat).toMatch(
+ /1,234\.56|1,235|¥1,234\.56|¥1,235|1,234\.56¥|1,235¥/,
+ );
+
+ const gbpFormat = formatCurrency(1234.56, 'GBP', 'en-GB');
+ expect(gbpFormat).toContain('£');
+ expect(gbpFormat).toContain('1,234.56');
+ });
+
+ it('should round to 2 decimal places', () => {
+ expect(formatCurrency(1.234)).toBe('$1.23');
+ expect(formatCurrency(1.235)).toBe('$1.24');
+ expect(formatCurrency(1.2)).toBe('$1.20');
+ });
+});
diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts
new file mode 100644
index 0000000000..0ac0d59b18
--- /dev/null
+++ b/src/lib/formatters.ts
@@ -0,0 +1,52 @@
+/**
+ * Formats a number to be more readable (e.g., 2300 → 2.3K, 6700000 → 6.7M)
+ * @param value The number to format
+ * @returns Formatted string with appropriate suffix (K, M, B, T)
+ */
+export function formatNumber(value: number | undefined): string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ if (value === 0) {
+ return '0';
+ }
+
+ const absValue = Math.abs(value);
+
+ if (absValue < 1000) {
+ return value.toString();
+ } else if (absValue < 1000000) {
+ return `${(value / 1000).toFixed(1)}K`;
+ } else if (absValue < 1000000000) {
+ return `${(value / 1000000).toFixed(1)}M`;
+ } else if (absValue < 1000000000000) {
+ return `${(value / 1000000000).toFixed(1)}B`;
+ } else {
+ return `${(value / 1000000000000).toFixed(1)}T`;
+ }
+}
+
+/**
+ * Formats a number as currency (USD by default)
+ * @param value The number to format as currency
+ * @param currency The currency code (default: 'USD')
+ * @param locale The locale to use for formatting (default: 'en-US')
+ * @returns Formatted currency string
+ */
+export function formatCurrency(
+ value: number | undefined,
+ currency: string = 'USD',
+ locale: string = 'en-US',
+): string {
+ if (value === undefined || value === null) {
+ return '';
+ }
+
+ return new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(value);
+}