mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Number and currency formatters (#26)
This commit is contained in:
parent
910900027d
commit
d7a2997bac
13 changed files with 296 additions and 174 deletions
|
|
@ -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",
|
||||
|
|
|
|||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
</div>
|
||||
<ul className="flex items-center gap-2 [&_li[data-fade]:hover]:opacity-100 [&_li[data-fade]]:opacity-60">
|
||||
<li data-fade>
|
||||
<LocaleSwitcher />
|
||||
</li>
|
||||
<li data-fade>
|
||||
<ul className="flex items-center gap-2">
|
||||
<li>
|
||||
<ThemeSwitcher />
|
||||
</li>
|
||||
<li>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={setTheme}>
|
||||
<DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={resolvedTheme === 'dark'}
|
||||
onCheckedChange={(checked) => setTheme(checked ? 'dark' : 'light')}
|
||||
/>
|
||||
<div className="relative h-[1.2rem] w-[1.2rem]">
|
||||
<Sun className="absolute top-0 left-0 size-4.5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute top-0 left-0 size-4.5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
34
src/components/ui/switch.tsx
Normal file
34
src/components/ui/switch.tsx
Normal file
|
|
@ -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<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary',
|
||||
'data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
|
||||
'cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
25
src/components/usage/Metric.tsx
Normal file
25
src/components/usage/Metric.tsx
Normal file
|
|
@ -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) => (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-secondary bg-background px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="font-mono font-semibold text-2xl h-8">
|
||||
{isLoading ? (
|
||||
<div>
|
||||
<Skeleton className="h-6 w-12 my-1" />
|
||||
</div>
|
||||
) : typeof value === 'number' ? (
|
||||
formatNumber(value)
|
||||
) : value ? (
|
||||
value
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -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 = () => {
|
|||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5">
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('analytics_active_developers')}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{usage.data?.[TelemetryEventName.LLM_COMPLETION]?.userCount ??
|
||||
'-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('analytics_tasks_started')}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{usage.data?.[TelemetryEventName.TASK_CREATED]?.eventCount ??
|
||||
'-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('analytics_tasks_completed')}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{usage.data?.[TelemetryEventName.TASK_COMPLETED]?.eventCount ??
|
||||
'-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('analytics_tokens_consumed')}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{usage.data?.[TelemetryEventName.LLM_COMPLETION]?.inputTokens ??
|
||||
'-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('analytics_llm_costs')}
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{usage.data?.[TelemetryEventName.LLM_COMPLETION]?.cost?.toFixed(
|
||||
2,
|
||||
) ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<Metric
|
||||
label={t('analytics_active_developers')}
|
||||
value={usage.data?.[TelemetryEventName.LLM_COMPLETION]?.userCount}
|
||||
isLoading={usage.isLoading}
|
||||
/>
|
||||
<Metric
|
||||
label={t('analytics_tasks_started')}
|
||||
value={usage.data?.[TelemetryEventName.TASK_CREATED]?.eventCount}
|
||||
isLoading={usage.isLoading}
|
||||
/>
|
||||
<Metric
|
||||
label={t('analytics_tasks_completed')}
|
||||
value={
|
||||
usage.data?.[TelemetryEventName.TASK_COMPLETED]?.eventCount
|
||||
}
|
||||
isLoading={usage.isLoading}
|
||||
/>
|
||||
<Metric
|
||||
label={t('analytics_tokens_consumed')}
|
||||
value={
|
||||
usage.data?.[TelemetryEventName.LLM_COMPLETION]?.inputTokens
|
||||
}
|
||||
isLoading={usage.isLoading}
|
||||
/>
|
||||
<Metric
|
||||
label={t('analytics_llm_costs')}
|
||||
value={formatCurrency(
|
||||
usage.data?.[TelemetryEventName.LLM_COMPLETION]?.cost,
|
||||
)}
|
||||
isLoading={usage.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
96
src/lib/formatters.test.ts
Normal file
96
src/lib/formatters.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
52
src/lib/formatters.ts
Normal file
52
src/lib/formatters.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue