do to formatters

This commit is contained in:
2026-01-24 01:07:43 +01:00
parent ae59df61eb
commit 891b3cf0ee
140 changed files with 656 additions and 1159 deletions

View File

@@ -0,0 +1,21 @@
export class AchievementFormatter {
static getRarityVariant(rarity: string) {
switch (rarity.toLowerCase()) {
case 'common':
return { text: 'low' as const, surface: 'rarity-common' as const, iconIntent: 'low' as const };
case 'rare':
return { text: 'primary' as const, surface: 'rarity-rare' as const, iconIntent: 'primary' as const };
case 'epic':
return { text: 'primary' as const, surface: 'rarity-epic' as const, iconIntent: 'primary' as const };
case 'legendary':
return { text: 'warning' as const, surface: 'rarity-legendary' as const, iconIntent: 'warning' as const };
default:
return { text: 'low' as const, surface: 'rarity-common' as const, iconIntent: 'low' as const };
}
}
static formatDate(date: Date): string {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
}
}

View File

@@ -0,0 +1,33 @@
/**
* ActivityLevelDisplay
*
* Deterministic mapping of engagement rates to activity level labels.
*/
export class ActivityLevelFormatter {
/**
* Maps engagement rate to activity level label.
*/
static levelLabel(engagementRate: number): string {
if (engagementRate < 20) {
return 'Low';
} else if (engagementRate < 50) {
return 'Medium';
} else {
return 'High';
}
}
/**
* Maps engagement rate to activity level value.
*/
static levelValue(engagementRate: number): 'low' | 'medium' | 'high' {
if (engagementRate < 20) {
return 'low';
} else if (engagementRate < 50) {
return 'medium';
} else {
return 'high';
}
}
}

View File

@@ -0,0 +1,38 @@
/**
* AvatarDisplay
*
* Deterministic mapping of avatar-related data to display formats.
*/
export class AvatarFormatter {
/**
* Converts binary buffer to base64 string for display.
*/
static bufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Determines if avatar data is valid for display.
* Accepts base64-encoded string buffer.
*/
static hasValidData(buffer: string, contentType: string): boolean {
return buffer.length > 0 && contentType.length > 0;
}
/**
* Formats content type for display (e.g., "image/png" → "PNG").
*/
static formatContentType(contentType: string): string {
const parts = contentType.split('/');
if (parts.length === 2) {
return parts[1].toUpperCase();
}
return contentType;
}
}

View File

@@ -0,0 +1,21 @@
export class CountryFlagFormatter {
private constructor(private readonly value: string) {}
static fromCountryCode(countryCode: string | null | undefined): CountryFlagFormatter {
if (!countryCode) {
return new CountryFlagFormatter('🏁');
}
const code = countryCode.toUpperCase();
if (code.length !== 2) {
return new CountryFlagFormatter('🏁');
}
const codePoints = [...code].map((char) => 127397 + char.charCodeAt(0));
return new CountryFlagFormatter(String.fromCodePoint(...codePoints));
}
toString(): string {
return this.value;
}
}

View File

@@ -0,0 +1,47 @@
/**
* CurrencyDisplay
*
* Deterministic currency formatting for display.
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
*/
export class CurrencyFormatter {
/**
* Formats an amount as currency (e.g., "$10.00" or "€1.000,00").
* Default currency is USD.
*/
static format(amount: number, currency: string = 'USD'): string {
const symbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency + ' ';
const formattedAmount = amount.toFixed(2);
// Add thousands separators
const parts = formattedAmount.split('.');
// Use dot as thousands separator for EUR, comma for USD
if (currency === 'EUR') {
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, '.');
return `${symbol}${parts[0]},${parts[1]}`;
} else {
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return `${symbol}${parts.join('.')}`;
}
}
/**
* Formats an amount as a compact currency (e.g., "$10" or "€1.000").
*/
static formatCompact(amount: number, currency: string = 'USD'): string {
const symbol = currency === 'USD' ? '$' : currency === 'EUR' ? '€' : currency + ' ';
const roundedAmount = Math.round(amount);
// Add thousands separators
const formattedAmount = roundedAmount.toString();
// Use dot as thousands separator for EUR, comma for USD
if (currency === 'EUR') {
return `${symbol}${formattedAmount.replace(/\B(?=(\d{3})+(?!\d))/g, '.')}`;
} else {
return `${symbol}${formattedAmount.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
}
}
}

View File

@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { DashboardConsistencyDisplay } from './DashboardConsistencyFormatter';
describe('DashboardConsistencyDisplay', () => {
describe('happy paths', () => {
it('should format consistency correctly', () => {
expect(DashboardConsistencyDisplay.format(0)).toBe('0%');
expect(DashboardConsistencyDisplay.format(50)).toBe('50%');
expect(DashboardConsistencyDisplay.format(100)).toBe('100%');
});
});
describe('edge cases', () => {
it('should handle decimal consistency', () => {
expect(DashboardConsistencyDisplay.format(85.5)).toBe('85.5%');
expect(DashboardConsistencyDisplay.format(99.9)).toBe('99.9%');
});
it('should handle negative consistency', () => {
expect(DashboardConsistencyDisplay.format(-10)).toBe('-10%');
});
});
});

View File

@@ -0,0 +1,11 @@
/**
* DashboardConsistencyDisplay
*
* Deterministic consistency formatting for dashboard display.
*/
export class DashboardConsistencyFormatter {
static format(consistency: number): string {
return `${consistency}%`;
}
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { DashboardCountFormatter } from './DashboardCountFormatter';
describe('DashboardCountDisplay', () => {
describe('happy paths', () => {
it('should format positive numbers correctly', () => {
expect(DashboardCountFormatter.format(0)).toBe('0');
expect(DashboardCountFormatter.format(1)).toBe('1');
expect(DashboardCountFormatter.format(100)).toBe('100');
expect(DashboardCountFormatter.format(1000)).toBe('1000');
});
it('should handle null values', () => {
expect(DashboardCountFormatter.format(null)).toBe('0');
});
it('should handle undefined values', () => {
expect(DashboardCountFormatter.format(undefined)).toBe('0');
});
});
describe('edge cases', () => {
it('should handle negative numbers', () => {
expect(DashboardCountFormatter.format(-1)).toBe('-1');
expect(DashboardCountFormatter.format(-100)).toBe('-100');
});
it('should handle large numbers', () => {
expect(DashboardCountFormatter.format(999999)).toBe('999999');
expect(DashboardCountFormatter.format(1000000)).toBe('1000000');
});
it('should handle decimal numbers', () => {
expect(DashboardCountFormatter.format(1.5)).toBe('1.5');
expect(DashboardCountFormatter.format(100.99)).toBe('100.99');
});
});
});

View File

@@ -0,0 +1,14 @@
/**
* DashboardCountDisplay
*
* Deterministic count formatting for dashboard display.
*/
export class DashboardCountFormatter {
static format(count: number | null | undefined): string {
if (count === null || count === undefined) {
return '0';
}
return count.toString();
}
}

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { DashboardDateDisplay } from './DashboardDateDisplay';
describe('DashboardDateDisplay', () => {
describe('happy paths', () => {
it('should format future date correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24 hours from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.date).toMatch(/^[A-Za-z]{3}, [A-Za-z]{3} \d{1,2}, \d{4}$/);
expect(result.time).toMatch(/^\d{2}:\d{2}$/);
expect(result.relative).toBe('1d');
});
it('should format date less than 24 hours correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 6 * 60 * 60 * 1000); // 6 hours from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.relative).toBe('6h');
});
it('should format date more than 24 hours correctly', () => {
const now = new Date();
const futureDate = new Date(now.getTime() + 48 * 60 * 60 * 1000); // 2 days from now
const result = DashboardDateDisplay.format(futureDate);
expect(result.relative).toBe('2d');
});
it('should format past date correctly', () => {
const now = new Date();
const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 24 hours ago
const result = DashboardDateDisplay.format(pastDate);
expect(result.relative).toBe('Past');
});
it('should format current date correctly', () => {
const now = new Date();
const result = DashboardDateDisplay.format(now);
expect(result.relative).toBe('Now');
});
it('should format date with leading zeros in time', () => {
const date = new Date('2024-01-15T05:03:00');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('05:03');
});
});
describe('edge cases', () => {
it('should handle midnight correctly', () => {
const date = new Date('2024-01-15T00:00:00');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('00:00');
});
it('should handle end of day correctly', () => {
const date = new Date('2024-01-15T23:59:59');
const result = DashboardDateDisplay.format(date);
expect(result.time).toBe('23:59');
});
it('should handle different days of week', () => {
const date = new Date('2024-01-15'); // Monday
const result = DashboardDateDisplay.format(date);
expect(result.date).toContain('Mon');
});
it('should handle different months', () => {
const date = new Date('2024-01-15');
const result = DashboardDateDisplay.format(date);
expect(result.date).toContain('Jan');
});
});
});

View File

@@ -0,0 +1,53 @@
/**
* DashboardDateDisplay
*
* Deterministic date formatting for dashboard display.
* No Intl.* or toLocale* methods.
*/
export interface DashboardDateDisplayData {
date: string;
time: string;
relative: string;
}
/**
* Format date for display (deterministic, no Intl)
*/
export class DashboardDateFormatter {
static format(date: Date): DashboardDateDisplayData {
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const dayName = days[date.getDay()];
const month = months[date.getMonth()];
const day = date.getDate();
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
// Calculate relative time (deterministic)
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
let relative: string;
if (diffHours < 0) {
relative = 'Past';
} else if (diffHours === 0) {
relative = 'Now';
} else if (diffHours < 24) {
relative = `${diffHours}h`;
} else {
relative = `${diffDays}d`;
}
return {
date: `${dayName}, ${month} ${day}, ${year}`,
time: `${hours}:${minutes}`,
relative,
};
}
}

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { DashboardLeaguePositionDisplay } from './DashboardLeaguePositionFormatter';
describe('DashboardLeaguePositionDisplay', () => {
describe('happy paths', () => {
it('should format position correctly', () => {
expect(DashboardLeaguePositionDisplay.format(1)).toBe('#1');
expect(DashboardLeaguePositionDisplay.format(5)).toBe('#5');
expect(DashboardLeaguePositionDisplay.format(100)).toBe('#100');
});
it('should handle null values', () => {
expect(DashboardLeaguePositionDisplay.format(null)).toBe('-');
});
it('should handle undefined values', () => {
expect(DashboardLeaguePositionDisplay.format(undefined)).toBe('-');
});
});
describe('edge cases', () => {
it('should handle position 0', () => {
expect(DashboardLeaguePositionDisplay.format(0)).toBe('#0');
});
it('should handle large positions', () => {
expect(DashboardLeaguePositionDisplay.format(999)).toBe('#999');
});
});
});

View File

@@ -0,0 +1,14 @@
/**
* DashboardLeaguePositionDisplay
*
* Deterministic league position formatting for dashboard display.
*/
export class DashboardLeaguePositionFormatter {
static format(position: number | null | undefined): string {
if (position === null || position === undefined) {
return '-';
}
return `#${position}`;
}
}

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { DashboardRankDisplay } from './DashboardRankFormatter';
describe('DashboardRankDisplay', () => {
describe('happy paths', () => {
it('should format rank correctly', () => {
expect(DashboardRankDisplay.format(1)).toBe('1');
expect(DashboardRankDisplay.format(42)).toBe('42');
expect(DashboardRankDisplay.format(100)).toBe('100');
});
});
describe('edge cases', () => {
it('should handle rank 0', () => {
expect(DashboardRankDisplay.format(0)).toBe('0');
});
it('should handle large ranks', () => {
expect(DashboardRankDisplay.format(999999)).toBe('999999');
});
});
});

View File

@@ -0,0 +1,11 @@
/**
* DashboardRankDisplay
*
* Deterministic rank formatting for dashboard display.
*/
export class DashboardRankFormatter {
static format(rank: number): string {
return rank.toString();
}
}

View File

@@ -0,0 +1,49 @@
export class DateFormatter {
/**
* Formats a date as "Jan 18, 2026" using UTC.
*/
static formatShort(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}
/**
* Formats a date as "Jan 2026" using UTC.
*/
static formatMonthYear(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
}
/**
* Formats a date as "15:00" using UTC.
*/
static formatTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const hours = d.getUTCHours().toString().padStart(2, '0');
const minutes = d.getUTCMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
/**
* Formats a date as "Jan 18" using UTC.
*/
static formatMonthDay(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
}
/**
* Formats a date as "Jan 18, 15:00" using UTC.
*/
static formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const hours = d.getUTCHours().toString().padStart(2, '0');
const minutes = d.getUTCMinutes().toString().padStart(2, '0');
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${hours}:${minutes}`;
}
}

View File

@@ -0,0 +1,20 @@
/**
* DriverRegistrationStatusDisplay
*
* Deterministic mapping of driver registration boolean state
* to UI labels and variants.
*/
export class DriverRegistrationStatusFormatter {
static statusMessage(isRegistered: boolean): string {
return isRegistered ? "Registered for this race" : "Not registered";
}
static statusBadgeVariant(isRegistered: boolean): string {
return isRegistered ? "success" : "warning";
}
static registrationButtonText(isRegistered: boolean): string {
return isRegistered ? "Withdraw" : "Register";
}
}

View File

@@ -0,0 +1,24 @@
/**
* DurationDisplay
*
* Deterministic formatting for time durations.
*/
export class DurationFormatter {
/**
* Formats milliseconds as "123.45ms".
*/
static formatMs(ms: number): string {
return `${ms.toFixed(2)}ms`;
}
/**
* Formats seconds as "M:SS.mmm".
* Example: 65.123 -> "1:05.123"
*/
static formatSeconds(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = (seconds % 60).toFixed(3);
return `${minutes}:${remainingSeconds.padStart(6, '0')}`;
}
}

View File

@@ -0,0 +1,24 @@
/**
* FinishDisplay
*
* Deterministic formatting for race finish positions.
*/
export class FinishFormatter {
/**
* Formats a finish position as "P1", "P2", etc.
*/
static format(position: number | null | undefined): string {
if (position === null || position === undefined) return '—';
return `P${position.toFixed(0)}`;
}
/**
* Formats an average finish position with one decimal place.
* Example: 5.4 -> "P5.4"
*/
static formatAverage(avg: number | null | undefined): string {
if (avg === null || avg === undefined) return '—';
return `P${avg.toFixed(1)}`;
}
}

View File

@@ -0,0 +1,53 @@
/**
* Health Alert Display Object
*
* Provides formatting and display logic for health alerts.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthAlertFormatter {
static formatSeverity(type: 'critical' | 'warning' | 'info'): string {
const severities: Record<string, string> = {
critical: 'Critical',
warning: 'Warning',
info: 'Info',
};
return severities[type] || 'Info';
}
static formatSeverityColor(type: 'critical' | 'warning' | 'info'): string {
const colors: Record<string, string> = {
critical: '#ef4444', // red-500
warning: '#f59e0b', // amber-500
info: '#3b82f6', // blue-500
};
return colors[type] || '#3b82f6';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
}
}

View File

@@ -0,0 +1,50 @@
/**
* Health Component Display Object
*
* Provides formatting and display logic for health components.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthComponentFormatter {
static formatStatusLabel(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const labels: Record<string, string> = {
ok: 'Healthy',
degraded: 'Degraded',
error: 'Error',
unknown: 'Unknown',
};
return labels[status] || 'Unknown';
}
static formatStatusColor(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const colors: Record<string, string> = {
ok: '#10b981', // green-500
degraded: '#f59e0b', // amber-500
error: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
};
return colors[status] || '#6b7280';
}
static formatStatusIcon(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const icons: Record<string, string> = {
ok: '✓',
degraded: '⚠',
error: '✕',
unknown: '?',
};
return icons[status] || '?';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
}

View File

@@ -0,0 +1,61 @@
/**
* Health Metric Display Object
*
* Provides formatting and display logic for health metrics.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthMetricFormatter {
static formatUptime(uptime?: number): string {
if (uptime === undefined || uptime === null) return 'N/A';
if (uptime < 0) return 'N/A';
// Format as percentage with 2 decimal places
return `${uptime.toFixed(2)}%`;
}
static formatResponseTime(responseTime?: number): string {
if (responseTime === undefined || responseTime === null) return 'N/A';
if (responseTime < 0) return 'N/A';
// Format as milliseconds with appropriate units
if (responseTime < 1000) {
return `${responseTime.toFixed(0)}ms`;
} else if (responseTime < 60000) {
return `${(responseTime / 1000).toFixed(2)}s`;
} else {
return `${(responseTime / 60000).toFixed(2)}m`;
}
}
static formatErrorRate(errorRate?: number): string {
if (errorRate === undefined || errorRate === null) return 'N/A';
if (errorRate < 0) return 'N/A';
// Format as percentage with 2 decimal places
return `${errorRate.toFixed(2)}%`;
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatSuccessRate(checksPassed?: number, checksFailed?: number): string {
const passed = checksPassed || 0;
const failed = checksFailed || 0;
const total = passed + failed;
if (total === 0) return 'N/A';
const successRate = (passed / total) * 100;
return `${successRate.toFixed(1)}%`;
}
}

View File

@@ -0,0 +1,65 @@
/**
* Health Status Display Object
*
* Provides formatting and display logic for health status data.
* This display object isolates UI-specific formatting from business logic.
*/
export class HealthStatusFormatter {
static formatStatusLabel(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const labels: Record<string, string> = {
ok: 'Healthy',
degraded: 'Degraded',
error: 'Error',
unknown: 'Unknown',
};
return labels[status] || 'Unknown';
}
static formatStatusColor(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const colors: Record<string, string> = {
ok: '#10b981', // green-500
degraded: '#f59e0b', // amber-500
error: '#ef4444', // red-500
unknown: '#6b7280', // gray-500
};
return colors[status] || '#6b7280';
}
static formatStatusIcon(status: 'ok' | 'degraded' | 'error' | 'unknown'): string {
const icons: Record<string, string> = {
ok: '✓',
degraded: '⚠',
error: '✕',
unknown: '?',
};
return icons[status] || '?';
}
static formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
static formatRelativeTime(timestamp: string): string {
const now = new Date();
const date = new Date(timestamp);
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return `${Math.floor(diffDays / 7)}w ago`;
}
}

View File

@@ -0,0 +1,14 @@
/**
* LeagueCreationStatusDisplay
*
* Deterministic mapping of league creation status to display messages.
*/
export class LeagueCreationStatusFormatter {
/**
* Maps league creation success status to display message.
*/
static statusMessage(success: boolean): string {
return success ? 'League created successfully!' : 'Failed to create league.';
}
}

View File

@@ -0,0 +1,15 @@
/**
* LeagueDisplay
*
* Deterministic display logic for leagues.
*/
export class LeagueFormatter {
/**
* Formats a league count with pluralization.
* Example: 1 -> "1 league", 2 -> "2 leagues"
*/
static formatCount(count: number): string {
return `${count} ${count === 1 ? 'league' : 'leagues'}`;
}
}

View File

@@ -0,0 +1,32 @@
export type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
export interface LeagueRoleDisplayData {
text: string;
badgeClasses: string;
}
export const leagueRoleDisplay: Record<LeagueRole, LeagueRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;
// For backward compatibility, also export the class with static method
export class LeagueRoleFormatter {
static getLeagueRoleDisplay(role: LeagueRole) {
return leagueRoleDisplay[role];
}
}

View File

@@ -0,0 +1,39 @@
/**
* LeagueTierDisplay
*
* Deterministic display logic for league tiers.
*/
export interface LeagueTierDisplayData {
color: string;
bgColor: string;
border: string;
icon: string;
}
export class LeagueTierFormatter {
private static readonly CONFIG: Record<string, LeagueTierDisplayData> = {
premium: {
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/10',
border: 'border-yellow-500/30',
icon: '⭐'
},
standard: {
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
icon: '🏆'
},
starter: {
color: 'text-gray-400',
bgColor: 'bg-gray-500/10',
border: 'border-gray-500/30',
icon: '🚀'
},
};
static getDisplay(tier: 'premium' | 'standard' | 'starter'): LeagueTierDisplayData {
return this.CONFIG[tier];
}
}

View File

@@ -0,0 +1,15 @@
// TODO this file has no clear meaning
export class LeagueWizardValidationMessages {
static readonly LEAGUE_NAME_REQUIRED = 'League name is required';
static readonly LEAGUE_NAME_TOO_SHORT = 'League name must be at least 3 characters';
static readonly LEAGUE_NAME_TOO_LONG = 'League name must be less than 100 characters';
static readonly DESCRIPTION_TOO_LONG = 'Description must be less than 500 characters';
static readonly VISIBILITY_REQUIRED = 'Visibility is required';
static readonly MAX_DRIVERS_INVALID_SOLO = 'Max drivers must be greater than 0 for solo leagues';
static readonly MAX_DRIVERS_TOO_HIGH = 'Max drivers cannot exceed 100';
static readonly MAX_TEAMS_INVALID_TEAM = 'Max teams must be greater than 0 for team leagues';
static readonly DRIVERS_PER_TEAM_INVALID = 'Drivers per team must be greater than 0';
static readonly QUALIFYING_DURATION_INVALID = 'Qualifying duration must be greater than 0 minutes';
static readonly MAIN_RACE_DURATION_INVALID = 'Main race duration must be greater than 0 minutes';
static readonly SCORING_PRESET_OR_CUSTOM_REQUIRED = 'Select a scoring preset or enable custom scoring';
}

View File

@@ -0,0 +1,32 @@
export class MedalFormatter {
static getVariant(position: number): 'warning' | 'low' | 'high' {
switch (position) {
case 1: return 'warning';
case 2: return 'high';
case 3: return 'warning';
default: return 'low';
}
}
static getMedalIcon(position: number): string | null {
return position <= 3 ? '🏆' : null;
}
static getBg(position: number): string {
switch (position) {
case 1: return 'bg-warning-amber';
case 2: return 'bg-gray-300';
case 3: return 'bg-orange-700';
default: return 'bg-gray-800';
}
}
static getColor(position: number): string {
switch (position) {
case 1: return 'text-warning-amber';
case 2: return 'text-gray-300';
case 3: return 'text-orange-700';
default: return 'text-gray-400';
}
}
}

View File

@@ -0,0 +1,22 @@
/**
* MemberDisplay
*
* Deterministic display logic for members.
*/
export class MemberFormatter {
/**
* Formats a member count with pluralization.
* Example: 1 -> "1 member", 2 -> "2 members"
*/
static formatCount(count: number): string {
return `${count} ${count === 1 ? 'member' : 'members'}`;
}
/**
* Formats a member count as "Units" (used in some contexts).
*/
static formatUnits(count: number): string {
return `${count} ${count === 1 ? 'Unit' : 'Units'}`;
}
}

View File

@@ -0,0 +1,10 @@
export class MembershipFeeTypeFormatter {
static format(type: string): string {
switch (type) {
case 'season': return 'Per Season';
case 'monthly': return 'Monthly';
case 'per_race': return 'Per Race';
default: return type;
}
}
}

View File

@@ -0,0 +1,21 @@
/**
* MemoryDisplay
*
* Deterministic formatting for memory usage.
*/
export class MemoryFormatter {
/**
* Formats bytes as "123.4MB".
*/
static formatMB(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
/**
* Formats bytes as "123.4KB".
*/
static formatKB(bytes: number): string {
return `${(bytes / 1024).toFixed(1)}KB`;
}
}

View File

@@ -0,0 +1,31 @@
/**
* NumberDisplay
*
* Deterministic number formatting for display.
* Avoids Intl and toLocaleString to prevent SSR/hydration mismatches.
*/
export class NumberFormatter {
/**
* Formats a number with thousands separators (commas).
* Example: 1234567 -> "1,234,567"
*/
static format(value: number): string {
const parts = value.toString().split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return parts.join('.');
}
/**
* Formats a number in compact form (e.g., 1.2k, 1.5M).
*/
static formatCompact(value: number): string {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(1)}k`;
}
return value.toString();
}
}

View File

@@ -0,0 +1,38 @@
/**
* OnboardingStatusDisplay
*
* Deterministic mapping of onboarding status to display labels and variants.
*/
export class OnboardingStatusFormatter {
/**
* Maps onboarding success status to display label.
*/
static statusLabel(success: boolean): string {
return success ? 'Onboarding Complete' : 'Onboarding Failed';
}
/**
* Maps onboarding success status to badge variant.
*/
static statusVariant(success: boolean): string {
return success ? 'performance-green' : 'racing-red';
}
/**
* Maps onboarding success status to icon.
*/
static statusIcon(success: boolean): string {
return success ? '✅' : '❌';
}
/**
* Maps onboarding success status to message.
*/
static statusMessage(success: boolean, errorMessage?: string): string {
if (success) {
return 'Your onboarding has been completed successfully.';
}
return errorMessage || 'Failed to complete onboarding. Please try again.';
}
}

View File

@@ -0,0 +1,5 @@
export class PayerTypeFormatter {
static format(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
}

View File

@@ -0,0 +1,5 @@
export class PaymentTypeFormatter {
static format(type: string): string {
return type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
}
}

View File

@@ -0,0 +1,25 @@
/**
* PercentDisplay
*
* Deterministic formatting for percentages.
*/
export class PercentFormatter {
/**
* Formats a decimal value as a percentage string.
* Example: 0.1234 -> "12.3%"
*/
static format(value: number | null | undefined): string {
if (value === null || value === undefined) return '0.0%';
return `${(value * 100).toFixed(1)}%`;
}
/**
* Formats a whole number as a percentage string.
* Example: 85 -> "85%"
*/
static formatWhole(value: number | null | undefined): string {
if (value === null || value === undefined) return '0%';
return `${Math.round(value)}%`;
}
}

View File

@@ -0,0 +1,10 @@
export class PrizeTypeFormatter {
static format(type: string): string {
switch (type) {
case 'cash': return 'Cash Prize';
case 'merchandise': return 'Merchandise';
case 'other': return 'Other';
default: return type;
}
}
}

View File

@@ -0,0 +1,189 @@
/**
* Profile Display Objects
*
* Deterministic formatting for profile data.
* NO Intl.*, NO Date.toLocale*, NO dynamic formatting.
*/
export interface CountryFlagDisplayData {
flag: string;
label: string;
}
export interface AchievementRarityDisplayData {
text: string;
badgeClasses: string;
borderClasses: string;
}
export interface AchievementIconDisplayData {
name: string;
}
export interface SocialPlatformDisplayData {
name: string;
hoverClasses: string;
}
export interface TeamRoleDisplayData {
text: string;
badgeClasses: string;
}
export class ProfileFormatter {
private static readonly countryFlagDisplay: Record<string, CountryFlagDisplayData> = {
US: { flag: '🇺🇸', label: 'United States' },
GB: { flag: '🇬🇧', label: 'United Kingdom' },
DE: { flag: '🇩🇪', label: 'Germany' },
FR: { flag: '🇫🇷', label: 'France' },
IT: { flag: '🇮🇹', label: 'Italy' },
ES: { flag: '🇪🇸', label: 'Spain' },
JP: { flag: '🇯🇵', label: 'Japan' },
AU: { flag: '🇦🇺', label: 'Australia' },
CA: { flag: '🇨🇦', label: 'Canada' },
BR: { flag: '🇧🇷', label: 'Brazil' },
DEFAULT: { flag: '🏁', label: 'Unknown' },
};
static getCountryFlag(countryCode: string): CountryFlagDisplayData {
const code = countryCode.toUpperCase();
return ProfileFormatter.countryFlagDisplay[code] || ProfileFormatter.countryFlagDisplay.DEFAULT;
}
private static readonly achievementRarityDisplay: Record<string, AchievementRarityDisplayData> = {
common: {
text: 'Common',
badgeClasses: 'bg-gray-400/10 text-gray-400',
borderClasses: 'border-gray-400/30',
},
rare: {
text: 'Rare',
badgeClasses: 'bg-primary-blue/10 text-primary-blue',
borderClasses: 'border-primary-blue/30',
},
epic: {
text: 'Epic',
badgeClasses: 'bg-purple-400/10 text-purple-400',
borderClasses: 'border-purple-400/30',
},
legendary: {
text: 'Legendary',
badgeClasses: 'bg-yellow-400/10 text-yellow-400',
borderClasses: 'border-yellow-400/30',
},
};
static getAchievementRarity(rarity: string): AchievementRarityDisplayData {
return ProfileFormatter.achievementRarityDisplay[rarity] || ProfileFormatter.achievementRarityDisplay.common;
}
private static readonly achievementIconDisplay: Record<string, AchievementIconDisplayData> = {
trophy: { name: 'Trophy' },
medal: { name: 'Medal' },
star: { name: 'Star' },
crown: { name: 'Crown' },
target: { name: 'Target' },
zap: { name: 'Zap' },
};
static getAchievementIcon(icon: string): AchievementIconDisplayData {
return ProfileFormatter.achievementIconDisplay[icon] || ProfileFormatter.achievementIconDisplay.trophy;
}
private static readonly socialPlatformDisplay: Record<string, SocialPlatformDisplayData> = {
twitter: {
name: 'Twitter',
hoverClasses: 'hover:text-sky-400 hover:bg-sky-400/10',
},
youtube: {
name: 'YouTube',
hoverClasses: 'hover:text-red-500 hover:bg-red-500/10',
},
twitch: {
name: 'Twitch',
hoverClasses: 'hover:text-purple-400 hover:bg-purple-400/10',
},
discord: {
name: 'Discord',
hoverClasses: 'hover:text-indigo-400 hover:bg-indigo-400/10',
},
};
static getSocialPlatform(platform: string): SocialPlatformDisplayData {
return ProfileFormatter.socialPlatformDisplay[platform] || ProfileFormatter.socialPlatformDisplay.discord;
}
static formatMonthYear(dateString: string): string {
const date = new Date(dateString);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getUTCMonth()];
const year = date.getUTCFullYear();
return `${month} ${year}`;
}
static formatMonthDayYear(dateString: string): string {
const date = new Date(dateString);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const month = months[date.getUTCMonth()];
const day = date.getUTCDate();
const year = date.getUTCFullYear();
return `${month} ${day}, ${year}`;
}
static formatPercentage(value: number | null | undefined): string {
if (value === null || value === undefined) return '0.0%';
return `${(value * 100).toFixed(1)}%`;
}
static formatFinishPosition(position: number | null | undefined): string {
if (position === null || position === undefined) return 'P-';
return `P${position}`;
}
static formatAvgFinish(avg: number | null | undefined): string {
if (avg === null || avg === undefined) return 'P-';
return `P${avg.toFixed(1)}`;
}
static formatRating(rating: number | null | undefined): string {
if (rating === null || rating === undefined) return '0';
return Math.round(rating).toString();
}
static formatConsistency(consistency: number | null | undefined): string {
if (consistency === null || consistency === undefined) return '0%';
return `${Math.round(consistency)}%`;
}
static formatPercentile(percentile: number | null | undefined): string {
if (percentile === null || percentile === undefined) return 'Top -%';
return `Top ${Math.round(percentile)}%`;
}
private static readonly teamRoleDisplay: Record<string, TeamRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
manager: {
text: 'Manager',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
};
static getTeamRole(role: string): TeamRoleDisplayData {
return ProfileFormatter.teamRoleDisplay[role] || ProfileFormatter.teamRoleDisplay.member;
}
}

View File

@@ -0,0 +1,44 @@
/**
* RaceStatusDisplay
*
* Deterministic display logic for race statuses.
*/
export type RaceStatusVariant = 'info' | 'success' | 'neutral' | 'warning' | 'primary' | 'default';
export class RaceStatusFormatter {
private static readonly CONFIG: Record<string, { variant: RaceStatusVariant; label: string; icon: string }> = {
scheduled: {
variant: 'primary',
label: 'Scheduled',
icon: 'Clock',
},
running: {
variant: 'success',
label: 'LIVE',
icon: 'PlayCircle',
},
completed: {
variant: 'default',
label: 'Completed',
icon: 'CheckCircle2',
},
cancelled: {
variant: 'warning',
label: 'Cancelled',
icon: 'XCircle',
},
};
static getLabel(status: string): string {
return this.CONFIG[status]?.label || status.toUpperCase();
}
static getVariant(status: string): RaceStatusVariant {
return this.CONFIG[status]?.variant || 'neutral';
}
static getIconName(status: string): string {
return this.CONFIG[status]?.icon || 'HelpCircle';
}
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { RatingDisplay } from './RatingDisplay';
describe('RatingDisplay', () => {
describe('happy paths', () => {
it('should format rating correctly', () => {
expect(RatingDisplay.format(0)).toBe('0');
expect(RatingDisplay.format(1234.56)).toBe('1,235');
expect(RatingDisplay.format(9999.99)).toBe('10,000');
});
it('should handle null values', () => {
expect(RatingDisplay.format(null)).toBe('—');
});
it('should handle undefined values', () => {
expect(RatingDisplay.format(undefined)).toBe('—');
});
});
describe('edge cases', () => {
it('should round down correctly', () => {
expect(RatingDisplay.format(1234.4)).toBe('1,234');
});
it('should round up correctly', () => {
expect(RatingDisplay.format(1234.6)).toBe('1,235');
});
it('should handle decimal ratings', () => {
expect(RatingDisplay.format(1234.5)).toBe('1,235');
});
it('should handle large ratings', () => {
expect(RatingDisplay.format(999999.99)).toBe('1,000,000');
});
});
});

View File

@@ -0,0 +1,12 @@
import { NumberFormatter } from './NumberFormatter';
export class RatingFormatter {
/**
* Formats a rating as a rounded number with thousands separators.
* Example: 1234.56 -> "1,235"
*/
static format(rating: number | null | undefined): string {
if (rating === null || rating === undefined) return '—';
return NumberFormatter.format(Math.round(rating));
}
}

View File

@@ -0,0 +1,15 @@
export class RatingTrendFormatter {
static getTrend(currentRating: number, previousRating: number | undefined): 'up' | 'down' | 'same' {
if (!previousRating) return 'same';
if (currentRating > previousRating) return 'up';
if (currentRating < previousRating) return 'down';
return 'same';
}
static getChangeIndicator(currentRating: number, previousRating: number | undefined): string {
const change = previousRating ? currentRating - previousRating : 0;
if (change > 0) return `+${change}`;
if (change < 0) return `${change}`;
return '0';
}
}

View File

@@ -0,0 +1,37 @@
/**
* RelativeTimeDisplay
*
* Deterministic relative time formatting.
*/
export class RelativeTimeFormatter {
/**
* Formats a date relative to "now".
* "now" must be passed as an argument for determinism.
*/
static format(date: string | Date, now: Date): string {
const d = typeof date === 'string' ? new Date(date) : date;
const diffMs = d.getTime() - now.getTime();
const diffHours = Math.floor(Math.abs(diffMs) / (1000 * 60 * 60));
const diffDays = Math.floor(Math.abs(diffMs) / (1000 * 60 * 60 * 24));
if (diffMs < 0) {
if (diffHours < 1) return 'Just now';
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays}d ago`;
return this.formatAbsolute(d);
} else {
if (diffHours < 1) return 'Starting soon';
if (diffHours < 24) return `In ${diffHours}h`;
if (diffDays === 1) return 'Tomorrow';
if (diffDays < 7) return `In ${diffDays} days`;
return this.formatAbsolute(d);
}
}
private static formatAbsolute(date: Date): string {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[date.getUTCMonth()]} ${date.getUTCDate()}`;
}
}

View File

@@ -0,0 +1,35 @@
/**
* SeasonStatusDisplay
*
* Deterministic display logic for season status.
*/
export interface SeasonStatusDisplayData {
color: string;
bg: string;
label: string;
}
export class SeasonStatusFormatter {
private static readonly CONFIG: Record<string, SeasonStatusDisplayData> = {
active: {
color: 'text-performance-green',
bg: 'bg-performance-green/10',
label: 'Active Season'
},
upcoming: {
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
label: 'Starting Soon'
},
completed: {
color: 'text-gray-400',
bg: 'bg-gray-400/10',
label: 'Season Ended'
},
};
static getDisplay(status: 'active' | 'upcoming' | 'completed'): SeasonStatusDisplayData {
return this.CONFIG[status];
}
}

View File

@@ -0,0 +1,41 @@
export class SkillLevelFormatter {
static getLabel(skillLevel: string): string {
const levels: Record<string, string> = {
pro: 'Pro',
advanced: 'Advanced',
intermediate: 'Intermediate',
beginner: 'Beginner',
};
return levels[skillLevel] || skillLevel;
}
static getColor(skillLevel: string): string {
const colors: Record<string, string> = {
pro: 'text-yellow-400',
advanced: 'text-purple-400',
intermediate: 'text-primary-blue',
beginner: 'text-green-400',
};
return colors[skillLevel] || 'text-gray-400';
}
static getBgColor(skillLevel: string): string {
const colors: Record<string, string> = {
pro: 'bg-yellow-400/10',
advanced: 'bg-purple-400/10',
intermediate: 'bg-primary-blue/10',
beginner: 'bg-green-400/10',
};
return colors[skillLevel] || 'bg-gray-400/10';
}
static getBorderColor(skillLevel: string): string {
const colors: Record<string, string> = {
pro: 'border-yellow-400/30',
advanced: 'border-purple-400/30',
intermediate: 'border-primary-blue/30',
beginner: 'border-green-400/30',
};
return colors[skillLevel] || 'border-gray-400/30';
}
}

View File

@@ -0,0 +1,11 @@
export class SkillLevelIconFormatter {
static getIcon(skillLevel: string): string {
const icons: Record<string, string> = {
beginner: '🥉',
intermediate: '🥈',
advanced: '🥇',
expert: '👑',
};
return icons[skillLevel] || '🏁';
}
}

View File

@@ -0,0 +1,44 @@
/**
* StatusDisplay
*
* Deterministic mapping of status codes to human-readable labels.
*/
export class StatusFormatter {
/**
* Maps transaction status to label.
*/
static transactionStatus(status: string): string {
const map: Record<string, string> = {
paid: 'Paid',
pending: 'Pending',
overdue: 'Overdue',
failed: 'Failed',
};
return map[status] || status;
}
/**
* Maps race status to label.
*/
static raceStatus(status: string): string {
const map: Record<string, string> = {
scheduled: 'Scheduled',
running: 'Live',
completed: 'Completed',
};
return map[status] || status;
}
/**
* Maps protest status to label.
*/
static protestStatus(status: string): string {
const map: Record<string, string> = {
pending: 'Pending',
under_review: 'Under Review',
resolved: 'Resolved',
};
return map[status] || status;
}
}

View File

@@ -0,0 +1,14 @@
/**
* TeamCreationStatusDisplay
*
* Deterministic mapping of team creation status to display messages.
*/
export class TeamCreationStatusFormatter {
/**
* Maps team creation success status to display message.
*/
static statusMessage(success: boolean): string {
return success ? 'Team created successfully!' : 'Failed to create team.';
}
}

View File

@@ -0,0 +1,13 @@
export class TimeFormatter {
static timeAgo(timestamp: Date | string): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60000);
if (diffMinutes < 1) return 'Just now';
if (diffMinutes < 60) return `${diffMinutes} min ago`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} d ago`;
}
}

View File

@@ -0,0 +1,5 @@
export class TransactionTypeFormatter {
static format(type: string): string {
return type.charAt(0).toUpperCase() + type.slice(1);
}
}

View File

@@ -0,0 +1,19 @@
/**
* UserRoleDisplay
*
* Deterministic mapping of user role codes to display labels.
*/
export class UserRoleFormatter {
/**
* Maps user role to display label.
*/
static roleLabel(role: string): string {
const map: Record<string, string> = {
owner: 'Owner',
admin: 'Admin',
user: 'User',
};
return map[role] || role;
}
}

View File

@@ -0,0 +1,52 @@
/**
* UserStatusDisplay
*
* Deterministic mapping of user status codes to display labels and variants.
*/
export class UserStatusFormatter {
/**
* Maps user status to display label.
*/
static statusLabel(status: string): string {
const map: Record<string, string> = {
active: 'Active',
suspended: 'Suspended',
deleted: 'Deleted',
};
return map[status] || status;
}
/**
* Maps user status to badge variant.
*/
static statusVariant(status: string): string {
const map: Record<string, string> = {
active: 'performance-green',
suspended: 'yellow-500',
deleted: 'racing-red',
};
return map[status] || 'gray-500';
}
/**
* Determines if a user can be suspended.
*/
static canSuspend(status: string): boolean {
return status === 'active';
}
/**
* Determines if a user can be activated.
*/
static canActivate(status: string): boolean {
return status === 'suspended';
}
/**
* Determines if a user can be deleted.
*/
static canDelete(status: string): boolean {
return status !== 'deleted';
}
}

View File

@@ -0,0 +1,12 @@
export class WinRateFormatter {
static calculate(racesCompleted: number, wins: number): string {
if (racesCompleted === 0) return '0.0';
const rate = (wins / racesCompleted) * 100;
return rate.toFixed(1);
}
static format(rate: number | null | undefined): string {
if (rate === null || rate === undefined) return '0.0%';
return `${rate.toFixed(1)}%`;
}
}