do to formatters
This commit is contained in:
21
apps/website/lib/formatters/AchievementFormatter.ts
Normal file
21
apps/website/lib/formatters/AchievementFormatter.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
33
apps/website/lib/formatters/ActivityLevelFormatter.ts
Normal file
33
apps/website/lib/formatters/ActivityLevelFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/formatters/AvatarFormatter.ts
Normal file
38
apps/website/lib/formatters/AvatarFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
apps/website/lib/formatters/CountryFlagFormatter.ts
Normal file
21
apps/website/lib/formatters/CountryFlagFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
47
apps/website/lib/formatters/CurrencyFormatter.ts
Normal file
47
apps/website/lib/formatters/CurrencyFormatter.ts
Normal 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, ',')}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%');
|
||||
});
|
||||
});
|
||||
});
|
||||
11
apps/website/lib/formatters/DashboardConsistencyFormatter.ts
Normal file
11
apps/website/lib/formatters/DashboardConsistencyFormatter.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardConsistencyDisplay
|
||||
*
|
||||
* Deterministic consistency formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardConsistencyFormatter {
|
||||
static format(consistency: number): string {
|
||||
return `${consistency}%`;
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/formatters/DashboardCountFormatter.test.ts
Normal file
38
apps/website/lib/formatters/DashboardCountFormatter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/website/lib/formatters/DashboardCountFormatter.ts
Normal file
14
apps/website/lib/formatters/DashboardCountFormatter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
94
apps/website/lib/formatters/DashboardDateFormatter.test.ts
Normal file
94
apps/website/lib/formatters/DashboardDateFormatter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
53
apps/website/lib/formatters/DashboardDateFormatter.ts
Normal file
53
apps/website/lib/formatters/DashboardDateFormatter.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
22
apps/website/lib/formatters/DashboardRankFormatter.test.ts
Normal file
22
apps/website/lib/formatters/DashboardRankFormatter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
11
apps/website/lib/formatters/DashboardRankFormatter.ts
Normal file
11
apps/website/lib/formatters/DashboardRankFormatter.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* DashboardRankDisplay
|
||||
*
|
||||
* Deterministic rank formatting for dashboard display.
|
||||
*/
|
||||
|
||||
export class DashboardRankFormatter {
|
||||
static format(rank: number): string {
|
||||
return rank.toString();
|
||||
}
|
||||
}
|
||||
49
apps/website/lib/formatters/DateFormatter.ts
Normal file
49
apps/website/lib/formatters/DateFormatter.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
24
apps/website/lib/formatters/DurationFormatter.ts
Normal file
24
apps/website/lib/formatters/DurationFormatter.ts
Normal 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')}`;
|
||||
}
|
||||
}
|
||||
24
apps/website/lib/formatters/FinishFormatter.ts
Normal file
24
apps/website/lib/formatters/FinishFormatter.ts
Normal 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)}`;
|
||||
}
|
||||
}
|
||||
53
apps/website/lib/formatters/HealthAlertFormatter.ts
Normal file
53
apps/website/lib/formatters/HealthAlertFormatter.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
50
apps/website/lib/formatters/HealthComponentFormatter.ts
Normal file
50
apps/website/lib/formatters/HealthComponentFormatter.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
61
apps/website/lib/formatters/HealthMetricFormatter.ts
Normal file
61
apps/website/lib/formatters/HealthMetricFormatter.ts
Normal 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)}%`;
|
||||
}
|
||||
}
|
||||
65
apps/website/lib/formatters/HealthStatusFormatter.ts
Normal file
65
apps/website/lib/formatters/HealthStatusFormatter.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
14
apps/website/lib/formatters/LeagueCreationStatusFormatter.ts
Normal file
14
apps/website/lib/formatters/LeagueCreationStatusFormatter.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/formatters/LeagueFormatter.ts
Normal file
15
apps/website/lib/formatters/LeagueFormatter.ts
Normal 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'}`;
|
||||
}
|
||||
}
|
||||
32
apps/website/lib/formatters/LeagueRoleFormatter.ts
Normal file
32
apps/website/lib/formatters/LeagueRoleFormatter.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
39
apps/website/lib/formatters/LeagueTierFormatter.ts
Normal file
39
apps/website/lib/formatters/LeagueTierFormatter.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
32
apps/website/lib/formatters/MedalFormatter.ts
Normal file
32
apps/website/lib/formatters/MedalFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/website/lib/formatters/MemberFormatter.ts
Normal file
22
apps/website/lib/formatters/MemberFormatter.ts
Normal 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'}`;
|
||||
}
|
||||
}
|
||||
10
apps/website/lib/formatters/MembershipFeeTypeFormatter.ts
Normal file
10
apps/website/lib/formatters/MembershipFeeTypeFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
apps/website/lib/formatters/MemoryFormatter.ts
Normal file
21
apps/website/lib/formatters/MemoryFormatter.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
31
apps/website/lib/formatters/NumberFormatter.ts
Normal file
31
apps/website/lib/formatters/NumberFormatter.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/formatters/OnboardingStatusFormatter.ts
Normal file
38
apps/website/lib/formatters/OnboardingStatusFormatter.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
5
apps/website/lib/formatters/PayerTypeFormatter.ts
Normal file
5
apps/website/lib/formatters/PayerTypeFormatter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class PayerTypeFormatter {
|
||||
static format(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
}
|
||||
5
apps/website/lib/formatters/PaymentTypeFormatter.ts
Normal file
5
apps/website/lib/formatters/PaymentTypeFormatter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class PaymentTypeFormatter {
|
||||
static format(type: string): string {
|
||||
return type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
}
|
||||
25
apps/website/lib/formatters/PercentFormatter.ts
Normal file
25
apps/website/lib/formatters/PercentFormatter.ts
Normal 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)}%`;
|
||||
}
|
||||
}
|
||||
10
apps/website/lib/formatters/PrizeTypeFormatter.ts
Normal file
10
apps/website/lib/formatters/PrizeTypeFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
189
apps/website/lib/formatters/ProfileFormatter.ts
Normal file
189
apps/website/lib/formatters/ProfileFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
44
apps/website/lib/formatters/RaceStatusFormatter.ts
Normal file
44
apps/website/lib/formatters/RaceStatusFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
38
apps/website/lib/formatters/RatingFormatter.test.ts
Normal file
38
apps/website/lib/formatters/RatingFormatter.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
apps/website/lib/formatters/RatingFormatter.ts
Normal file
12
apps/website/lib/formatters/RatingFormatter.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
15
apps/website/lib/formatters/RatingTrendFormatter.ts
Normal file
15
apps/website/lib/formatters/RatingTrendFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
37
apps/website/lib/formatters/RelativeTimeFormatter.ts
Normal file
37
apps/website/lib/formatters/RelativeTimeFormatter.ts
Normal 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()}`;
|
||||
}
|
||||
}
|
||||
35
apps/website/lib/formatters/SeasonStatusFormatter.ts
Normal file
35
apps/website/lib/formatters/SeasonStatusFormatter.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
41
apps/website/lib/formatters/SkillLevelFormatter.ts
Normal file
41
apps/website/lib/formatters/SkillLevelFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
11
apps/website/lib/formatters/SkillLevelIconFormatter.ts
Normal file
11
apps/website/lib/formatters/SkillLevelIconFormatter.ts
Normal 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] || '🏁';
|
||||
}
|
||||
}
|
||||
44
apps/website/lib/formatters/StatusFormatter.ts
Normal file
44
apps/website/lib/formatters/StatusFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
apps/website/lib/formatters/TeamCreationStatusFormatter.ts
Normal file
14
apps/website/lib/formatters/TeamCreationStatusFormatter.ts
Normal 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.';
|
||||
}
|
||||
}
|
||||
13
apps/website/lib/formatters/TimeFormatter.ts
Normal file
13
apps/website/lib/formatters/TimeFormatter.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
5
apps/website/lib/formatters/TransactionTypeFormatter.ts
Normal file
5
apps/website/lib/formatters/TransactionTypeFormatter.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export class TransactionTypeFormatter {
|
||||
static format(type: string): string {
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
}
|
||||
}
|
||||
19
apps/website/lib/formatters/UserRoleFormatter.ts
Normal file
19
apps/website/lib/formatters/UserRoleFormatter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
52
apps/website/lib/formatters/UserStatusFormatter.ts
Normal file
52
apps/website/lib/formatters/UserStatusFormatter.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
12
apps/website/lib/formatters/WinRateFormatter.ts
Normal file
12
apps/website/lib/formatters/WinRateFormatter.ts
Normal 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)}%`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user