view data fixes

This commit is contained in:
2026-01-23 15:30:23 +01:00
parent e22033be38
commit f8099f04bc
213 changed files with 3466 additions and 3003 deletions

View File

@@ -9,21 +9,19 @@ import { ActivityItemViewData } from "../view-data/ActivityItemViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
export class ActivityItemViewModel extends ViewModel {
readonly id: string;
readonly type: string;
readonly message: string;
readonly time: string;
readonly impressions?: number;
private readonly data: ActivityItemViewData;
constructor(viewData: ActivityItemViewData) {
constructor(data: ActivityItemViewData) {
super();
this.id = viewData.id;
this.type = viewData.type;
this.message = viewData.message;
this.time = viewData.time;
this.impressions = viewData.impressions;
this.data = data;
}
get id(): string { return this.data.id; }
get type(): string { return this.data.type; }
get message(): string { return this.data.message; }
get time(): string { return this.data.time; }
get impressions(): number | undefined { return this.data.impressions; }
get typeColor(): string {
const colors: Record<string, string> = {
race: 'bg-warning-amber',
@@ -36,6 +34,7 @@ export class ActivityItemViewModel extends ViewModel {
}
get formattedImpressions(): string | null {
// Client-only formatting
return this.impressions ? this.impressions.toLocaleString() : null;
}
}

View File

@@ -1,10 +1,8 @@
import type { AdminUserViewData } from '@/lib/view-data/AdminUserViewData';
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { UserStatusDisplay } from "../display-objects/UserStatusDisplay";
import { UserRoleDisplay } from "../display-objects/UserRoleDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { ActivityLevelDisplay } from "../display-objects/ActivityLevelDisplay";
import { UserStatusDisplay } from "@/lib/display-objects/UserStatusDisplay";
import { UserRoleDisplay } from "@/lib/display-objects/UserRoleDisplay";
import { DateDisplay } from "@/lib/display-objects/DateDisplay";
/**
* AdminUserViewModel
@@ -13,159 +11,48 @@ import { ActivityLevelDisplay } from "../display-objects/ActivityLevelDisplay";
* Transforms API DTO into UI-ready state with formatting and derived fields.
*/
export class AdminUserViewModel extends ViewModel {
id: string;
email: string;
displayName: string;
roles: string[];
status: string;
isSystemAdmin: boolean;
createdAt: Date;
updatedAt: Date;
lastLoginAt?: Date;
primaryDriverId?: string;
private readonly data: AdminUserViewData;
// UI-specific derived fields (primitive outputs only)
readonly roleBadges: string[];
readonly statusBadgeLabel: string;
readonly statusBadgeVariant: string;
readonly lastLoginFormatted: string;
readonly createdAtFormatted: string;
constructor(viewData: AdminUserViewData) {
constructor(data: AdminUserViewData) {
super();
this.id = viewData.id;
this.email = viewData.email;
this.displayName = viewData.displayName;
this.roles = viewData.roles;
this.status = viewData.status;
this.isSystemAdmin = viewData.isSystemAdmin;
this.createdAt = new Date(viewData.createdAt);
this.updatedAt = new Date(viewData.updatedAt);
this.lastLoginAt = viewData.lastLoginAt ? new Date(viewData.lastLoginAt) : undefined;
this.primaryDriverId = viewData.primaryDriverId;
this.data = data;
}
// Derive role badges using Display Object
this.roleBadges = this.roles.map(role => UserRoleDisplay.roleLabel(role));
get id(): string { return this.data.id; }
get email(): string { return this.data.email; }
get displayName(): string { return this.data.displayName; }
get roles(): string[] { return this.data.roles; }
get status(): string { return this.data.status; }
get isSystemAdmin(): boolean { return this.data.isSystemAdmin; }
get createdAt(): string { return this.data.createdAt; }
get updatedAt(): string { return this.data.updatedAt; }
get lastLoginAt(): string | undefined { return this.data.lastLoginAt; }
get primaryDriverId(): string | undefined { return this.data.primaryDriverId; }
// Derive status badge using Display Object
this.statusBadgeLabel = UserStatusDisplay.statusLabel(this.status);
this.statusBadgeVariant = UserStatusDisplay.statusVariant(this.status);
/** UI-specific: Role badges using Display Object */
get roleBadges(): string[] {
return this.roles.map(role => UserRoleDisplay.roleLabel(role));
}
// Format dates using Display Object
this.lastLoginFormatted = this.lastLoginAt
/** UI-specific: Status badge label using Display Object */
get statusBadgeLabel(): string {
return UserStatusDisplay.statusLabel(this.status);
}
/** UI-specific: Status badge variant using Display Object */
get statusBadgeVariant(): string {
return UserStatusDisplay.statusVariant(this.status);
}
/** UI-specific: Formatted last login date */
get lastLoginFormatted(): string {
return this.lastLoginAt
? DateDisplay.formatShort(this.lastLoginAt)
: 'Never';
this.createdAtFormatted = DateDisplay.formatShort(this.createdAt);
}
/** UI-specific: Formatted creation date */
get createdAtFormatted(): string {
return DateDisplay.formatShort(this.createdAt);
}
}
/**
* DashboardStatsViewModel
*
* View Model for admin dashboard statistics.
* Provides formatted statistics and derived metrics for UI.
*/
export class DashboardStatsViewModel extends ViewModel {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
// UI-specific derived fields (primitive outputs only)
readonly activeRate: number;
readonly activeRateFormatted: string;
readonly adminRatio: string;
readonly activityLevelLabel: string;
readonly activityLevelValue: 'low' | 'medium' | 'high';
constructor(viewData: DashboardStatsViewData) {
super();
this.totalUsers = viewData.totalUsers;
this.activeUsers = viewData.activeUsers;
this.suspendedUsers = viewData.suspendedUsers;
this.deletedUsers = viewData.deletedUsers;
this.systemAdmins = viewData.systemAdmins;
this.recentLogins = viewData.recentLogins;
this.newUsersToday = viewData.newUsersToday;
this.userGrowth = viewData.userGrowth;
this.roleDistribution = viewData.roleDistribution;
this.statusDistribution = viewData.statusDistribution;
this.activityTimeline = viewData.activityTimeline;
// Derive active rate
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
this.activeRateFormatted = `${Math.round(this.activeRate)}%`;
// Derive admin ratio
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
// Derive activity level using Display Object
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
this.activityLevelLabel = ActivityLevelDisplay.levelLabel(engagementRate);
this.activityLevelValue = ActivityLevelDisplay.levelValue(engagementRate);
}
}
/**
* UserListViewModel
*
* View Model for user list with pagination and filtering state.
*/
export class UserListViewModel extends ViewModel {
users: AdminUserViewModel[];
total: number;
page: number;
limit: number;
totalPages: number;
// UI-specific derived fields (primitive outputs only)
readonly hasUsers: boolean;
readonly showPagination: boolean;
readonly startIndex: number;
readonly endIndex: number;
constructor(data: {
users: AdminUserViewData[];
total: number;
page: number;
limit: number;
totalPages: number;
}) {
super();
this.users = data.users.map(viewData => new AdminUserViewModel(viewData));
this.total = data.total;
this.page = data.page;
this.limit = data.limit;
this.totalPages = data.totalPages;
// Derive UI state
this.hasUsers = this.users.length > 0;
this.showPagination = this.totalPages > 1;
this.startIndex = this.users.length > 0 ? (this.page - 1) * this.limit + 1 : 0;
this.endIndex = this.users.length > 0 ? (this.page - 1) * this.limit + this.users.length : 0;
}
}

View File

@@ -9,19 +9,18 @@ import { AnalyticsDashboardInputViewData } from "../view-data/AnalyticsDashboard
import { ViewModel } from "../contracts/view-models/ViewModel";
export class AnalyticsDashboardViewModel extends ViewModel {
readonly totalUsers: number;
readonly activeUsers: number;
readonly totalRaces: number;
readonly totalLeagues: number;
private readonly data: AnalyticsDashboardInputViewData;
constructor(viewData: AnalyticsDashboardInputViewData) {
constructor(data: AnalyticsDashboardInputViewData) {
super();
this.totalUsers = viewData.totalUsers;
this.activeUsers = viewData.activeUsers;
this.totalRaces = viewData.totalRaces;
this.totalLeagues = viewData.totalLeagues;
this.data = data;
}
get totalUsers(): number { return this.data.totalUsers; }
get activeUsers(): number { return this.data.activeUsers; }
get totalRaces(): number { return this.data.totalRaces; }
get totalLeagues(): number { return this.data.totalLeagues; }
/** UI-specific: User engagement rate */
get userEngagementRate(): number {
return this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;

View File

@@ -6,24 +6,23 @@
*/
import { AnalyticsMetricsViewData } from "../view-data/AnalyticsMetricsViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
import { NumberDisplay } from "../display-objects/NumberDisplay";
import { DurationDisplay } from "../display-objects/DurationDisplay";
import { PercentDisplay } from "../display-objects/PercentDisplay";
import { NumberDisplay } from "@/lib/display-objects/NumberDisplay";
import { DurationDisplay } from "@/lib/display-objects/DurationDisplay";
import { PercentDisplay } from "@/lib/display-objects/PercentDisplay";
export class AnalyticsMetricsViewModel extends ViewModel {
readonly pageViews: number;
readonly uniqueVisitors: number;
readonly averageSessionDuration: number;
readonly bounceRate: number;
private readonly data: AnalyticsMetricsViewData;
constructor(viewData: AnalyticsMetricsViewData) {
constructor(data: AnalyticsMetricsViewData) {
super();
this.pageViews = viewData.pageViews;
this.uniqueVisitors = viewData.uniqueVisitors;
this.averageSessionDuration = viewData.averageSessionDuration;
this.bounceRate = viewData.bounceRate;
this.data = data;
}
get pageViews(): number { return this.data.pageViews; }
get uniqueVisitors(): number { return this.data.uniqueVisitors; }
get averageSessionDuration(): number { return this.data.averageSessionDuration; }
get bounceRate(): number { return this.data.bounceRate; }
/** UI-specific: Formatted page views */
get formattedPageViews(): string {
return NumberDisplay.format(this.pageViews);

View File

@@ -13,44 +13,37 @@ import { LeagueTierDisplay } from "../display-objects/LeagueTierDisplay";
import { SeasonStatusDisplay } from "../display-objects/SeasonStatusDisplay";
export class AvailableLeaguesViewModel extends ViewModel {
private readonly data: AvailableLeaguesViewData;
readonly leagues: AvailableLeagueViewModel[];
constructor(viewData: AvailableLeaguesViewData) {
constructor(data: AvailableLeaguesViewData) {
super();
this.leagues = viewData.leagues.map(league => new AvailableLeagueViewModel(league));
this.data = data;
this.leagues = data.leagues.map(league => new AvailableLeagueViewModel(league));
}
}
export class AvailableLeagueViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly game: string;
readonly drivers: number;
readonly avgViewsPerRace: number;
readonly mainSponsorSlot: { available: boolean; price: number };
readonly secondarySlots: { available: number; total: number; price: number };
readonly rating: number;
readonly tier: 'premium' | 'standard' | 'starter';
readonly nextRace?: string;
readonly seasonStatus: 'active' | 'upcoming' | 'completed';
readonly description: string;
private readonly data: AvailableLeagueViewData;
constructor(viewData: AvailableLeagueViewData) {
constructor(data: AvailableLeagueViewData) {
super();
this.id = viewData.id;
this.name = viewData.name;
this.game = viewData.game;
this.drivers = viewData.drivers;
this.avgViewsPerRace = viewData.avgViewsPerRace;
this.mainSponsorSlot = viewData.mainSponsorSlot;
this.secondarySlots = viewData.secondarySlots;
this.rating = viewData.rating;
this.tier = viewData.tier;
this.nextRace = viewData.nextRace;
this.seasonStatus = viewData.seasonStatus;
this.description = viewData.description;
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get game(): string { return this.data.game; }
get drivers(): number { return this.data.drivers; }
get avgViewsPerRace(): number { return this.data.avgViewsPerRace; }
get mainSponsorSlot() { return this.data.mainSponsorSlot; }
get secondarySlots() { return this.data.secondarySlots; }
get rating(): number { return this.data.rating; }
get tier(): 'premium' | 'standard' | 'starter' { return this.data.tier; }
get nextRace(): string | undefined { return this.data.nextRace; }
get seasonStatus(): 'active' | 'upcoming' | 'completed' { return this.data.seasonStatus; }
get description(): string { return this.data.description; }
/** UI-specific: Formatted average views */
get formattedAvgViews(): string {
return NumberDisplay.formatCompact(this.avgViewsPerRace);

View File

@@ -9,14 +9,14 @@ import { AvatarGenerationViewData } from "../view-data/AvatarGenerationViewData"
* Accepts AvatarGenerationViewData as input and produces UI-ready data.
*/
export class AvatarGenerationViewModel extends ViewModel {
readonly success: boolean;
readonly avatarUrls: string[];
readonly errorMessage?: string;
private readonly data: AvatarGenerationViewData;
constructor(viewData: AvatarGenerationViewData) {
constructor(data: AvatarGenerationViewData) {
super();
this.success = viewData.success;
this.avatarUrls = viewData.avatarUrls;
this.errorMessage = viewData.errorMessage;
this.data = data;
}
get success(): boolean { return this.data.success; }
get avatarUrls(): string[] { return this.data.avatarUrls; }
get errorMessage(): string | undefined { return this.data.errorMessage; }
}

View File

@@ -9,21 +9,25 @@ import { AvatarViewData } from "@/lib/view-data/AvatarViewData";
* Transforms AvatarViewData into UI-ready state with formatting and derived fields.
*/
export class AvatarViewModel extends ViewModel {
// UI-specific derived fields (primitive outputs only)
readonly bufferBase64: string;
readonly contentTypeLabel: string;
readonly hasValidData: boolean;
private readonly data: AvatarViewData;
constructor(viewData: AvatarViewData) {
constructor(data: AvatarViewData) {
super();
this.data = data;
}
// Buffer is already base64 encoded in ViewData
this.bufferBase64 = viewData.buffer;
/** UI-specific: Buffer is already base64 encoded in ViewData */
get bufferBase64(): string {
return this.data.buffer;
}
// Derive content type label using Display Object
this.contentTypeLabel = AvatarDisplay.formatContentType(viewData.contentType);
/** UI-specific: Derive content type label using Display Object */
get contentTypeLabel(): string {
return AvatarDisplay.formatContentType(this.data.contentType);
}
// Derive validity check using Display Object
this.hasValidData = AvatarDisplay.hasValidData(viewData.buffer, viewData.contentType);
/** UI-specific: Derive validity check using Display Object */
get hasValidData(): boolean {
return AvatarDisplay.hasValidData(this.data.buffer, this.data.contentType);
}
}

View File

@@ -0,0 +1,23 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { BillingStatsViewData } from "@/lib/view-data/BillingViewData";
export class BillingStatsViewModel extends ViewModel {
private readonly data: BillingStatsViewData;
constructor(data: BillingStatsViewData) {
super();
this.data = data;
}
get totalSpent(): number { return this.data.totalSpent; }
get pendingAmount(): number { return this.data.pendingAmount; }
get nextPaymentDate(): string { return this.data.nextPaymentDate; }
get nextPaymentAmount(): number { return this.data.nextPaymentAmount; }
get activeSponsorships(): number { return this.data.activeSponsorships; }
get averageMonthlySpend(): number { return this.data.averageMonthlySpend; }
get totalSpentDisplay(): string { return this.data.formattedTotalSpent; }
get pendingAmountDisplay(): string { return this.data.formattedPendingAmount; }
get nextPaymentAmountDisplay(): string { return this.data.formattedNextPaymentAmount; }
get averageMonthlySpendDisplay(): string { return this.data.formattedAverageMonthlySpend; }
get nextPaymentDateDisplay(): string { return this.data.formattedNextPaymentDate; }
}

View File

@@ -6,8 +6,9 @@
*/
import type { BillingViewData } from '@/lib/view-data/BillingViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { PaymentMethodViewModel } from "./PaymentMethodViewModel";
import { InvoiceViewModel } from "./InvoiceViewModel";
import { BillingStatsViewModel } from "./BillingStatsViewModel";
/**
* BillingViewModel
@@ -16,170 +17,16 @@ import { DateDisplay } from "../display-objects/DateDisplay";
* Transforms BillingViewData into UI-ready state with formatting and derived fields.
*/
export class BillingViewModel extends ViewModel {
paymentMethods: PaymentMethodViewModel[];
invoices: InvoiceViewModel[];
stats: BillingStatsViewModel;
private readonly data: BillingViewData;
readonly paymentMethods: PaymentMethodViewModel[];
readonly invoices: InvoiceViewModel[];
readonly stats: BillingStatsViewModel;
constructor(viewData: BillingViewData) {
constructor(data: BillingViewData) {
super();
this.paymentMethods = viewData.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
this.invoices = viewData.invoices.map(inv => new InvoiceViewModel(inv));
this.stats = new BillingStatsViewModel(viewData.stats);
this.data = data;
this.paymentMethods = data.paymentMethods.map(pm => new PaymentMethodViewModel(pm));
this.invoices = data.invoices.map(inv => new InvoiceViewModel(inv));
this.stats = new BillingStatsViewModel(data.stats);
}
}
/**
* PaymentMethodViewModel
*
* View Model for payment method data.
* Provides formatted display labels and expiry information.
*/
export class PaymentMethodViewModel extends ViewModel {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
// UI-specific derived fields (primitive outputs only)
readonly displayLabel: string;
readonly expiryDisplay: string | null;
constructor(viewData: {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
displayLabel: string;
expiryDisplay: string | null;
}) {
super();
this.id = viewData.id;
this.type = viewData.type;
this.last4 = viewData.last4;
this.brand = viewData.brand;
this.isDefault = viewData.isDefault;
this.expiryMonth = viewData.expiryMonth;
this.expiryYear = viewData.expiryYear;
this.bankName = viewData.bankName;
this.displayLabel = viewData.displayLabel;
this.expiryDisplay = viewData.expiryDisplay;
}
}
/**
* InvoiceViewModel
*
* View Model for invoice data.
* Provides formatted amounts, dates, and derived status flags.
*/
export class InvoiceViewModel extends ViewModel {
id: string;
invoiceNumber: string;
date: Date;
dueDate: Date;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
// UI-specific derived fields (primitive outputs only)
readonly formattedTotalAmount: string;
readonly formattedVatAmount: string;
readonly formattedDate: string;
readonly isOverdue: boolean;
constructor(viewData: {
id: string;
invoiceNumber: string;
date: string;
dueDate: string;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
formattedTotalAmount: string;
formattedVatAmount: string;
formattedDate: string;
isOverdue: boolean;
}) {
super();
this.id = viewData.id;
this.invoiceNumber = viewData.invoiceNumber;
this.date = new Date(viewData.date);
this.dueDate = new Date(viewData.dueDate);
this.amount = viewData.amount;
this.vatAmount = viewData.vatAmount;
this.totalAmount = viewData.totalAmount;
this.status = viewData.status;
this.description = viewData.description;
this.sponsorshipType = viewData.sponsorshipType;
this.pdfUrl = viewData.pdfUrl;
this.formattedTotalAmount = viewData.formattedTotalAmount;
this.formattedVatAmount = viewData.formattedVatAmount;
this.formattedDate = viewData.formattedDate;
this.isOverdue = viewData.isOverdue;
}
}
/**
* BillingStatsViewModel
*
* View Model for billing statistics.
* Provides formatted monetary fields and derived metrics.
*/
export class BillingStatsViewModel extends ViewModel {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: Date;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
// UI-specific derived fields (primitive outputs only)
readonly formattedTotalSpent: string;
readonly formattedPendingAmount: string;
readonly formattedNextPaymentAmount: string;
readonly formattedAverageMonthlySpend: string;
readonly formattedNextPaymentDate: string;
constructor(viewData: {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: string;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
formattedTotalSpent: string;
formattedPendingAmount: string;
formattedNextPaymentAmount: string;
formattedAverageMonthlySpend: string;
formattedNextPaymentDate: string;
}) {
super();
this.totalSpent = viewData.totalSpent;
this.pendingAmount = viewData.pendingAmount;
this.nextPaymentDate = new Date(viewData.nextPaymentDate);
this.nextPaymentAmount = viewData.nextPaymentAmount;
this.activeSponsorships = viewData.activeSponsorships;
this.averageMonthlySpend = viewData.averageMonthlySpend;
this.formattedTotalSpent = viewData.formattedTotalSpent;
this.formattedPendingAmount = viewData.formattedPendingAmount;
this.formattedNextPaymentAmount = viewData.formattedNextPaymentAmount;
this.formattedAverageMonthlySpend = viewData.formattedAverageMonthlySpend;
this.formattedNextPaymentDate = viewData.formattedNextPaymentDate;
}
}

View File

@@ -1,7 +1,6 @@
import type { CompleteOnboardingViewData } from '@/lib/builders/view-data/CompleteOnboardingViewData';
import { OnboardingStatusDisplay } from '../display-objects/OnboardingStatusDisplay';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { OnboardingStatusDisplay } from '../display-objects/OnboardingStatusDisplay';
import type { CompleteOnboardingViewData } from '../view-data/CompleteOnboardingViewData';
/**
* Complete onboarding view model
@@ -10,27 +9,35 @@ import { ViewModel } from "../contracts/view-models/ViewModel";
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CompleteOnboardingViewModel extends ViewModel {
success: boolean;
driverId?: string;
errorMessage?: string;
private readonly data: CompleteOnboardingViewData;
// UI-specific derived fields (primitive outputs only)
readonly statusLabel: string;
readonly statusVariant: string;
readonly statusIcon: string;
readonly statusMessage: string;
constructor(viewData: CompleteOnboardingViewData) {
constructor(data: CompleteOnboardingViewData) {
super();
this.success = viewData.success;
if (viewData.driverId !== undefined) this.driverId = viewData.driverId;
if (viewData.errorMessage !== undefined) this.errorMessage = viewData.errorMessage;
this.data = data;
}
// Derive UI-specific fields using Display Object
this.statusLabel = OnboardingStatusDisplay.statusLabel(this.success);
this.statusVariant = OnboardingStatusDisplay.statusVariant(this.success);
this.statusIcon = OnboardingStatusDisplay.statusIcon(this.success);
this.statusMessage = OnboardingStatusDisplay.statusMessage(this.success, this.errorMessage);
get success(): boolean { return this.data.success; }
get driverId(): string | undefined { return this.data.driverId; }
get errorMessage(): string | undefined { return this.data.errorMessage; }
/** UI-specific: Status label using Display Object */
get statusLabel(): string {
return OnboardingStatusDisplay.statusLabel(this.success);
}
/** UI-specific: Status variant using Display Object */
get statusVariant(): string {
return OnboardingStatusDisplay.statusVariant(this.success);
}
/** UI-specific: Status icon using Display Object */
get statusIcon(): string {
return OnboardingStatusDisplay.statusIcon(this.success);
}
/** UI-specific: Status message using Display Object */
get statusMessage(): string {
return OnboardingStatusDisplay.statusMessage(this.success, this.errorMessage);
}
/** UI-specific: Whether onboarding was successful */

View File

@@ -9,19 +9,19 @@ import { LeagueCreationStatusDisplay } from '../display-objects/LeagueCreationSt
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CreateLeagueViewModel extends ViewModel {
readonly leagueId: string;
readonly success: boolean;
private readonly data: CreateLeagueViewData;
// UI-specific derived fields (primitive outputs only)
readonly successMessage: string;
constructor(viewData: CreateLeagueViewData) {
constructor(data: CreateLeagueViewData) {
super();
this.leagueId = viewData.leagueId;
this.success = viewData.success;
this.data = data;
}
// Derive UI-specific fields using Display Object
this.successMessage = LeagueCreationStatusDisplay.statusMessage(this.success);
get leagueId(): string { return this.data.leagueId; }
get success(): boolean { return this.data.success; }
/** UI-specific: Success message using Display Object */
get successMessage(): string {
return LeagueCreationStatusDisplay.statusMessage(this.success);
}
/** UI-specific: Whether league creation was successful */

View File

@@ -9,19 +9,19 @@ import { TeamCreationStatusDisplay } from '../display-objects/TeamCreationStatus
* Composes Display Objects and transforms ViewData for UI consumption.
*/
export class CreateTeamViewModel extends ViewModel {
readonly teamId: string;
readonly success: boolean;
private readonly data: CreateTeamViewData;
// UI-specific derived fields (primitive outputs only)
readonly successMessage: string;
constructor(viewData: CreateTeamViewData) {
constructor(data: CreateTeamViewData) {
super();
this.teamId = viewData.teamId;
this.success = viewData.success;
this.data = data;
}
// Derive UI-specific fields using Display Object
this.successMessage = TeamCreationStatusDisplay.statusMessage(this.success);
get teamId(): string { return this.data.teamId; }
get success(): boolean { return this.data.success; }
/** UI-specific: Success message using Display Object */
get successMessage(): string {
return TeamCreationStatusDisplay.statusMessage(this.success);
}
/** UI-specific: Whether team creation was successful */

View File

@@ -0,0 +1,74 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import { ActivityLevelDisplay } from "@/lib/display-objects/ActivityLevelDisplay";
import type { DashboardStatsViewData } from '@/lib/view-data/DashboardStatsViewData';
/**
* DashboardStatsViewModel
*
* View Model for admin dashboard statistics.
* Provides formatted statistics and derived metrics for UI.
*/
export class DashboardStatsViewModel extends ViewModel {
totalUsers: number;
activeUsers: number;
suspendedUsers: number;
deletedUsers: number;
systemAdmins: number;
recentLogins: number;
newUsersToday: number;
userGrowth: {
label: string;
value: number;
color: string;
}[];
roleDistribution: {
label: string;
value: number;
color: string;
}[];
statusDistribution: {
active: number;
suspended: number;
deleted: number;
};
activityTimeline: {
date: string;
newUsers: number;
logins: number;
}[];
// UI-specific derived fields (primitive outputs only)
readonly activeRate: number;
readonly activeRateFormatted: string;
readonly adminRatio: string;
readonly activityLevelLabel: string;
readonly activityLevelValue: 'low' | 'medium' | 'high';
constructor(viewData: DashboardStatsViewData) {
super();
this.totalUsers = viewData.totalUsers;
this.activeUsers = viewData.activeUsers;
this.suspendedUsers = viewData.suspendedUsers;
this.deletedUsers = viewData.deletedUsers;
this.systemAdmins = viewData.systemAdmins;
this.recentLogins = viewData.recentLogins;
this.newUsersToday = viewData.newUsersToday;
this.userGrowth = viewData.userGrowth;
this.roleDistribution = viewData.roleDistribution;
this.statusDistribution = viewData.statusDistribution;
this.activityTimeline = viewData.activityTimeline;
// Derive active rate
this.activeRate = this.totalUsers > 0 ? (this.activeUsers / this.totalUsers) * 100 : 0;
this.activeRateFormatted = `${Math.round(this.activeRate)}%`;
// Derive admin ratio
const nonAdmins = Math.max(1, this.totalUsers - this.systemAdmins);
this.adminRatio = `1:${Math.floor(nonAdmins / Math.max(1, this.systemAdmins))}`;
// Derive activity level using Display Object
const engagementRate = this.totalUsers > 0 ? (this.recentLogins / this.totalUsers) * 100 : 0;
this.activityLevelLabel = ActivityLevelDisplay.levelLabel(engagementRate);
this.activityLevelValue = ActivityLevelDisplay.levelValue(engagementRate);
}
}

View File

@@ -1,5 +1,5 @@
import type { DeleteMediaViewData } from '@/lib/builders/view-data/DeleteMediaViewData';
import { ViewModel } from '../contracts/view-models/ViewModel';
import type { DeleteMediaViewData } from '../view-data/DeleteMediaViewData';
/**
* Delete Media View Model
@@ -8,17 +8,16 @@ import { ViewModel } from '../contracts/view-models/ViewModel';
* Composes ViewData for UI consumption.
*/
export class DeleteMediaViewModel extends ViewModel {
success: boolean;
error?: string;
private readonly data: DeleteMediaViewData;
constructor(viewData: DeleteMediaViewData) {
constructor(data: DeleteMediaViewData) {
super();
this.success = viewData.success;
if (viewData.error !== undefined) {
this.error = viewData.error;
}
this.data = data;
}
get success(): boolean { return this.data.success; }
get error(): string | undefined { return this.data.error; }
/** UI-specific: Whether the deletion was successful */
get isSuccessful(): boolean {
return this.success;

View File

@@ -1,41 +1,32 @@
import type { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeaderboardDriverItem } from '../view-data/LeaderboardDriverItem';
import { SkillLevelDisplay } from "../display-objects/SkillLevelDisplay";
import { SkillLevelIconDisplay } from "../display-objects/SkillLevelIconDisplay";
import { WinRateDisplay } from "../display-objects/WinRateDisplay";
import { RatingTrendDisplay } from "../display-objects/RatingTrendDisplay";
export class DriverLeaderboardItemViewModel extends ViewModel {
id: string;
name: string;
rating: number;
skillLevel: string;
nationality: string;
racesCompleted: number;
wins: number;
podiums: number;
rank: number;
avatarUrl: string;
position: number;
private previousRating: number | undefined;
private readonly data: LeaderboardDriverItem;
private readonly previousRating: number | undefined;
constructor(viewData: LeaderboardDriverItem, previousRating?: number) {
constructor(data: LeaderboardDriverItem, previousRating?: number) {
super();
this.id = viewData.id;
this.name = viewData.name;
this.rating = viewData.rating;
this.skillLevel = viewData.skillLevel;
this.nationality = viewData.nationality;
this.racesCompleted = viewData.racesCompleted;
this.wins = viewData.wins;
this.podiums = viewData.podiums;
this.rank = viewData.rank;
this.avatarUrl = viewData.avatarUrl;
this.position = viewData.position;
this.data = data;
this.previousRating = previousRating;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get rating(): number { return this.data.rating; }
get skillLevel(): string { return this.data.skillLevel; }
get nationality(): string { return this.data.nationality; }
get racesCompleted(): number { return this.data.racesCompleted; }
get wins(): number { return this.data.wins; }
get podiums(): number { return this.data.podiums; }
get rank(): number { return this.data.rank; }
get avatarUrl(): string { return this.data.avatarUrl; }
get position(): number { return this.data.position; }
/** UI-specific: Skill level color */
get skillLevelColor(): string {
return SkillLevelDisplay.getColor(this.skillLevel);

View File

@@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest';
import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
import { describe, expect, it } from 'vitest';
import { LeaderboardDriverItem } from '../view-data/LeaderboardDriverItem';
import { LeaderboardsViewData } from '../view-data/LeaderboardsViewData';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { LeaderboardDriverItem } from '@/lib/view-data/LeaderboardDriverItem';
import { DriverLeaderboardViewModel } from './DriverLeaderboardViewModel';
const createDriverViewData = (overrides: Partial<LeaderboardDriverItem> = {}): LeaderboardDriverItem => ({
id: 'driver-1',

View File

@@ -1,17 +1,18 @@
import { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { DriverLeaderboardItemViewModel } from './DriverLeaderboardItemViewModel';
import type { LeaderboardsViewData } from '../view-data/LeaderboardsViewData';
export class DriverLeaderboardViewModel extends ViewModel {
drivers: DriverLeaderboardItemViewModel[];
private readonly data: LeaderboardsViewData;
readonly drivers: DriverLeaderboardItemViewModel[];
constructor(
viewData: LeaderboardsViewData,
data: LeaderboardsViewData,
previousRatings?: Record<string, number>,
) {
super();
this.drivers = viewData.drivers.map((driver) => {
this.data = data;
this.drivers = data.drivers.map((driver) => {
const previousRating = previousRatings?.[driver.id];
return new DriverLeaderboardItemViewModel(driver, previousRating);
});

View File

@@ -1,5 +1,5 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverSummaryData } from "../view-data/LeagueDetailViewData";
import type { DriverSummaryData } from "../view-data/DriverSummaryData";
import { NumberDisplay } from "../display-objects/NumberDisplay";
import { RatingDisplay } from "../display-objects/RatingDisplay";

View File

@@ -5,35 +5,26 @@
* Note: client-only ViewModel created from ViewData (never DTO).
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { DriverData } from "../view-data/LeagueStandingsViewData";
type DriverViewModelViewData = DriverData & {
bio?: string;
joinedAt?: string;
};
import { RatingDisplay } from "../display-objects/RatingDisplay";
import type { DriverViewData } from "../view-data/DriverViewData";
export class DriverViewModel extends ViewModel {
id: string;
name: string;
avatarUrl: string | null;
iracingId?: string;
rating?: number;
country?: string;
bio?: string;
joinedAt?: string;
private readonly data: DriverViewData;
constructor(viewData: DriverViewModelViewData) {
constructor(data: DriverViewData) {
super();
this.id = viewData.id;
this.name = viewData.name;
this.avatarUrl = viewData.avatarUrl ?? null;
if (viewData.iracingId !== undefined) this.iracingId = viewData.iracingId;
if (viewData.rating !== undefined) this.rating = viewData.rating;
if (viewData.country !== undefined) this.country = viewData.country;
if (viewData.bio !== undefined) this.bio = viewData.bio;
if (viewData.joinedAt !== undefined) this.joinedAt = viewData.joinedAt;
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get avatarUrl(): string { return this.data.avatarUrl || ''; }
get iracingId(): string | undefined { return this.data.iracingId; }
get rating(): number | undefined { return this.data.rating; }
get country(): string | undefined { return this.data.country; }
get bio(): string | undefined { return this.data.bio; }
get joinedAt(): string | undefined { return this.data.joinedAt; }
/** UI-specific: Whether driver has an iRacing ID */
get hasIracingId(): boolean {
return !!this.iracingId;
@@ -41,6 +32,6 @@ export class DriverViewModel extends ViewModel {
/** UI-specific: Formatted rating */
get formattedRating(): string {
return this.rating ? this.rating.toFixed(0) : "Unrated";
return this.rating !== undefined ? RatingDisplay.format(this.rating) : "Unrated";
}
}

View File

@@ -7,20 +7,23 @@ import { ViewModel } from "../contracts/view-models/ViewModel";
import type { EmailSignupViewData } from "../view-data/EmailSignupViewData";
export class EmailSignupViewModel extends ViewModel {
constructor(private readonly viewData: EmailSignupViewData) {
private readonly data: EmailSignupViewData;
constructor(data: EmailSignupViewData) {
super();
this.data = data;
}
get email(): string {
return this.viewData.email;
return this.data.email;
}
get message(): string {
return this.viewData.message;
return this.data.message;
}
get status(): EmailSignupViewData["status"] {
return this.viewData.status;
return this.data.status;
}
get isSuccess(): boolean {

View File

@@ -7,17 +7,17 @@ import type { HomeDiscoveryViewData } from '@/lib/view-data/HomeDiscoveryViewDat
import { ViewModel } from '../contracts/view-models/ViewModel';
export class HomeDiscoveryViewModel extends ViewModel {
readonly topLeagues: HomeDiscoveryViewData['topLeagues'];
readonly teams: HomeDiscoveryViewData['teams'];
readonly upcomingRaces: HomeDiscoveryViewData['upcomingRaces'];
private readonly data: HomeDiscoveryViewData;
constructor(viewData: HomeDiscoveryViewData) {
constructor(data: HomeDiscoveryViewData) {
super();
this.topLeagues = viewData.topLeagues;
this.teams = viewData.teams;
this.upcomingRaces = viewData.upcomingRaces;
this.data = data;
}
get topLeagues() { return this.data.topLeagues; }
get teams() { return this.data.teams; }
get upcomingRaces() { return this.data.upcomingRaces; }
get hasTopLeagues(): boolean {
return this.topLeagues.length > 0;
}

View File

@@ -2,28 +2,31 @@ import { ViewModel } from "../contracts/view-models/ViewModel";
import type { ImportRaceResultsSummaryViewData } from "../view-data/ImportRaceResultsSummaryViewData";
export class ImportRaceResultsSummaryViewModel extends ViewModel {
constructor(private readonly viewData: ImportRaceResultsSummaryViewData) {
private readonly data: ImportRaceResultsSummaryViewData;
constructor(data: ImportRaceResultsSummaryViewData) {
super();
this.data = data;
}
get success(): boolean {
return this.viewData.success;
return this.data.success;
}
get raceId(): string {
return this.viewData.raceId;
return this.data.raceId;
}
get driversProcessed(): number {
return this.viewData.driversProcessed;
return this.data.driversProcessed;
}
get resultsRecorded(): number {
return this.viewData.resultsRecorded;
return this.data.resultsRecorded;
}
get errors(): string[] {
return this.viewData.errors;
return this.data.errors;
}
get hasErrors(): boolean {

View File

@@ -0,0 +1,27 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { InvoiceViewData } from "@/lib/view-data/BillingViewData";
export class InvoiceViewModel extends ViewModel {
private readonly data: InvoiceViewData;
constructor(data: InvoiceViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get invoiceNumber(): string { return this.data.invoiceNumber; }
get date(): string { return this.data.date; }
get dueDate(): string { return this.data.dueDate; }
get amount(): number { return this.data.amount; }
get vatAmount(): number { return this.data.vatAmount; }
get totalAmount(): number { return this.data.totalAmount; }
get status(): string { return this.data.status; }
get description(): string { return this.data.description; }
get sponsorshipType(): string { return this.data.sponsorshipType; }
get pdfUrl(): string { return this.data.pdfUrl; }
get totalAmountFormatted(): string { return this.data.formattedTotalAmount; }
get vatAmountFormatted(): string { return this.data.formattedVatAmount; }
get dateFormatted(): string { return this.data.formattedDate; }
get isOverdue(): boolean { return this.data.isOverdue; }
}

View File

@@ -1,10 +1,18 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueAdminRosterJoinRequestViewData } from "../view-data/LeagueAdminRosterJoinRequestViewData";
export interface LeagueAdminRosterJoinRequestViewModel extends ViewModel {
id: string;
leagueId: string;
driverId: string;
driverName: string;
requestedAtIso: string;
message?: string;
export class LeagueAdminRosterJoinRequestViewModel extends ViewModel {
private readonly data: LeagueAdminRosterJoinRequestViewData;
constructor(data: LeagueAdminRosterJoinRequestViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get leagueId(): string { return this.data.leagueId; }
get driverId(): string { return this.data.driverId; }
get driverName(): string { return this.data.driverName; }
get requestedAtIso(): string { return this.data.requestedAtIso; }
get message(): string | undefined { return this.data.message; }
}

View File

@@ -1,10 +1,17 @@
import type { MembershipRole } from '@/lib/types/MembershipRole';
import type { MembershipRole } from '../types/MembershipRole';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueAdminRosterMemberViewData } from "../view-data/LeagueAdminRosterMemberViewData";
export interface LeagueAdminRosterMemberViewModel extends ViewModel {
driverId: string;
driverName: string;
role: MembershipRole;
joinedAtIso: string;
export class LeagueAdminRosterMemberViewModel extends ViewModel {
private readonly data: LeagueAdminRosterMemberViewData;
constructor(data: LeagueAdminRosterMemberViewData) {
super();
this.data = data;
}
get driverId(): string { return this.data.driverId; }
get driverName(): string { return this.data.driverName; }
get role(): MembershipRole { return this.data.role; }
get joinedAtIso(): string { return this.data.joinedAtIso; }
}

View File

@@ -1,15 +1,15 @@
import type { LeagueScheduleRaceViewModel } from './LeagueScheduleViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueAdminScheduleViewData } from "../view-data/LeagueAdminScheduleViewData";
export class LeagueAdminScheduleViewModel extends ViewModel {
readonly seasonId: string;
readonly published: boolean;
readonly races: LeagueScheduleRaceViewModel[];
private readonly data: LeagueAdminScheduleViewData;
constructor(input: { seasonId: string; published: boolean; races: LeagueScheduleRaceViewModel[] }) {
this.seasonId = input.seasonId;
this.published = input.published;
this.races = input.races;
constructor(data: LeagueAdminScheduleViewData) {
super();
this.data = data;
}
get seasonId(): string { return this.data.seasonId; }
get published(): boolean { return this.data.published; }
get races(): any[] { return this.data.races; }
}

View File

@@ -1,27 +1,20 @@
import type { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueJoinRequestViewModel } from './LeagueJoinRequestViewModel';
/**
* League admin view model
* Transform from DTO to ViewModel with UI fields
*/
import { LeagueMemberViewModel } from './LeagueMemberViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueAdminViewData } from "../view-data/LeagueAdminViewData";
export class LeagueAdminViewModel extends ViewModel {
config: unknown;
members: LeagueMemberViewModel[];
joinRequests: LeagueJoinRequestViewModel[];
private readonly data: LeagueAdminViewData;
readonly members: LeagueMemberViewModel[];
constructor(dto: {
config: unknown;
members: LeagueMemberViewModel[];
joinRequests: LeagueJoinRequestViewModel[];
}) {
this.config = dto.config;
this.members = dto.members;
this.joinRequests = dto.joinRequests;
constructor(data: LeagueAdminViewData) {
super();
this.data = data;
this.members = data.members.map(m => new LeagueMemberViewModel(m));
}
get config(): unknown { return this.data.config; }
get joinRequests(): any[] { return this.data.joinRequests; }
/** UI-specific: Total pending requests count */
get pendingRequestsCount(): number {
return this.joinRequests.length;

View File

@@ -1,25 +1,15 @@
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
interface LeagueCardDTO {
id: string;
name: string;
description?: string;
}
/**
* League card view model
* UI representation of a league on the landing page.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueCardViewData } from "../view-data/LeagueCardViewData";
export class LeagueCardViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly description: string;
private readonly data: LeagueCardViewData;
constructor(dto: LeagueCardDTO | LeagueSummaryDTO & { description?: string }) {
this.id = dto.id;
this.name = dto.name;
this.description = dto.description ?? 'Competitive iRacing league';
constructor(data: LeagueCardViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get description(): string { return this.data.description ?? 'Competitive iRacing league'; }
}

View File

@@ -0,0 +1,19 @@
import { DriverViewModel } from "./DriverViewModel";
import type { DriverViewData } from "../view-data/DriverViewData";
export class LeagueDetailDriverViewModel extends DriverViewModel {
private readonly detailData: DriverViewData & { impressions: number };
constructor(data: DriverViewData & { impressions: number }) {
super(data);
this.detailData = data;
}
get impressions(): number {
return this.detailData.impressions;
}
get formattedImpressions(): string {
return this.impressions.toLocaleString();
}
}

View File

@@ -1,192 +1,79 @@
import { LeagueWithCapacityAndScoringDTO } from '@/lib/types/generated/LeagueWithCapacityAndScoringDTO';
import { LeagueStatsDTO } from '@/lib/types/generated/LeagueStatsDTO';
import { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceViewModel } from './RaceViewModel';
import { DriverViewModel } from './DriverViewModel';
import type { LeagueDetailPageViewData, LeagueMembershipWithRole, SponsorInfo } from '../view-data/LeagueDetailPageViewData';
// Sponsor info type
export interface SponsorInfo {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
tier: 'main' | 'secondary';
tagline?: string;
}
// Driver summary for management section
export interface DriverSummary {
driver: DriverViewModel;
rating: number | null;
rank: number | null;
}
// League membership with role
export interface LeagueMembershipWithRole {
driverId: string;
role: 'owner' | 'admin' | 'steward' | 'member';
status: 'active' | 'inactive';
joinedAt: string;
}
// Helper interfaces for type narrowing
interface LeagueSettings {
maxDrivers?: number;
}
interface SocialLinks {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
}
interface LeagueStatsExtended {
averageSOF?: number;
averageRating?: number;
completedRaces?: number;
totalRaces?: number;
}
interface MembershipsContainer {
members?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>;
memberships?: Array<{ driverId: string; role: string; status?: 'active' | 'inactive'; joinedAt: string }>;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailPageViewModel extends ViewModel {
// League basic info
id: string;
name: string;
description?: string;
ownerId: string;
createdAt: string;
settings: {
maxDrivers?: number;
};
socialLinks: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
} | undefined;
private readonly data: LeagueDetailPageViewData;
readonly allRaces: RaceViewModel[];
readonly runningRaces: RaceViewModel[];
readonly ownerSummary: DriverSummary | null;
readonly adminSummaries: DriverSummary[];
readonly stewardSummaries: DriverSummary[];
// Owner info
owner: GetDriverOutputDTO | null;
// Scoring configuration
scoringConfig: LeagueScoringConfigDTO | null;
// Drivers and memberships
drivers: GetDriverOutputDTO[];
memberships: LeagueMembershipWithRole[];
// Races
allRaces: RaceViewModel[];
runningRaces: RaceViewModel[];
// Stats
averageSOF: number | null;
completedRacesCount: number;
// Sponsors
sponsors: SponsorInfo[];
// Sponsor insights data
sponsorInsights: {
avgViewsPerRace: number;
totalImpressions: number;
engagementRate: string;
estimatedReach: number;
mainSponsorAvailable: boolean;
secondarySlotsAvailable: number;
mainSponsorPrice: number;
secondaryPrice: number;
tier: 'premium' | 'standard' | 'starter';
trustScore: number;
discordMembers: number;
monthlyActivity: number;
};
// Driver summaries for management
ownerSummary: DriverSummary | null;
adminSummaries: DriverSummary[];
stewardSummaries: DriverSummary[];
constructor(
league: LeagueWithCapacityAndScoringDTO,
owner: GetDriverOutputDTO | null,
scoringConfig: LeagueScoringConfigDTO | null,
drivers: GetDriverOutputDTO[],
memberships: LeagueMembershipsDTO,
allRaces: RaceViewModel[],
leagueStats: LeagueStatsDTO,
sponsors: SponsorInfo[]
) {
this.id = league.id;
this.name = league.name;
this.description = league.description ?? '';
this.ownerId = league.ownerId;
this.createdAt = league.createdAt;
constructor(data: LeagueDetailPageViewData) {
super();
this.data = data;
// Handle settings with proper type narrowing
const settings = league.settings as LeagueSettings | undefined;
const maxDrivers = settings?.maxDrivers;
this.settings = {
maxDrivers: maxDrivers,
};
// Handle social links with proper type narrowing
const socialLinks = league.socialLinks as SocialLinks | undefined;
const discordUrl = socialLinks?.discordUrl;
const youtubeUrl = socialLinks?.youtubeUrl;
const websiteUrl = socialLinks?.websiteUrl;
this.socialLinks = {
discordUrl,
youtubeUrl,
websiteUrl,
this.allRaces = data.allRaces.map(r => r instanceof RaceViewModel ? r : new RaceViewModel(r));
this.runningRaces = this.allRaces.filter(r => r.status === 'running');
// Build driver summaries
this.ownerSummary = this.buildDriverSummary(data.ownerId);
this.adminSummaries = data.memberships
.filter(m => m.role === 'admin')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
this.stewardSummaries = data.memberships
.filter(m => m.role === 'steward')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
}
private buildDriverSummary(driverId: string): DriverSummary | null {
const driverData = this.data.drivers.find(d => d.id === driverId) ||
(this.data.owner?.id === driverId ? this.data.owner : null);
if (!driverData) return null;
const driver = new DriverViewModel(driverData);
return {
driver,
rating: null,
rank: null,
};
}
this.owner = owner;
this.scoringConfig = scoringConfig;
this.drivers = drivers;
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get description(): string { return this.data.description ?? ''; }
get ownerId(): string { return this.data.ownerId; }
get createdAt(): string { return this.data.createdAt; }
get settings() { return this.data.settings; }
get socialLinks() { return this.data.socialLinks; }
get owner() { return this.data.owner; }
get scoringConfig() { return this.data.scoringConfig; }
get drivers() { return this.data.drivers; }
get memberships() { return this.data.memberships; }
get averageSOF(): number | null { return this.data.averageSOF; }
get completedRacesCount(): number { return this.data.completedRacesCount; }
get sponsors(): SponsorInfo[] { return this.data.sponsors; }
// Handle memberships with proper type narrowing
const membershipsContainer = memberships as MembershipsContainer;
const membershipDtos = membershipsContainer.members ??
membershipsContainer.memberships ??
[];
this.memberships = membershipDtos.map((m) => ({
driverId: m.driverId,
role: m.role as 'owner' | 'admin' | 'steward' | 'member',
status: m.status ?? 'active',
joinedAt: m.joinedAt,
}));
this.allRaces = allRaces;
this.runningRaces = allRaces.filter(r => r.status === 'running');
// Calculate SOF from available data with proper type narrowing
const statsExtended = leagueStats as LeagueStatsExtended;
const averageSOF = statsExtended.averageSOF ??
statsExtended.averageRating ?? undefined;
const completedRaces = statsExtended.completedRaces ??
statsExtended.totalRaces ?? undefined;
this.averageSOF = typeof averageSOF === 'number' ? averageSOF : null;
this.completedRacesCount = typeof completedRaces === 'number' ? completedRaces : 0;
this.sponsors = sponsors;
// Calculate sponsor insights
get sponsorInsights() {
const memberCount = this.memberships.length;
const mainSponsorTaken = this.sponsors.some(s => s.tier === 'main');
const secondaryTaken = this.sponsors.filter(s => s.tier === 'secondary').length;
this.sponsorInsights = {
return {
avgViewsPerRace: 5400 + memberCount * 50,
totalImpressions: 45000 + memberCount * 500,
engagementRate: (3.5 + (memberCount / 50)).toFixed(1),
@@ -200,59 +87,17 @@ export class LeagueDetailPageViewModel extends ViewModel {
discordMembers: memberCount * 3,
monthlyActivity: Math.min(100, 40 + this.completedRacesCount * 2),
};
// Build driver summaries
this.ownerSummary = this.buildDriverSummary(this.ownerId);
this.adminSummaries = this.memberships
.filter(m => m.role === 'admin')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
this.stewardSummaries = this.memberships
.filter(m => m.role === 'steward')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
}
private buildDriverSummary(driverId: string): DriverSummary | null {
const driverDto = this.drivers.find(d => d.id === driverId);
if (!driverDto) return null;
// Handle avatarUrl with proper type checking
const driverAny = driverDto as { avatarUrl?: unknown };
const avatarUrl = typeof driverAny.avatarUrl === 'string' ? driverAny.avatarUrl : null;
const driver = new DriverViewModel({
id: driverDto.id,
name: driverDto.name,
avatarUrl: avatarUrl,
iracingId: driverDto.iracingId,
});
// Detailed rating and rank data are not wired from the analytics services yet;
// expose the driver identity only so the UI can still render role assignments.
return {
driver,
rating: null,
rank: null,
};
}
// UI helper methods
get isSponsorMode(): boolean {
// League detail pages are rendered in organizer mode in this build; sponsor-specific
// mode switches will be introduced once sponsor dashboards share this view model.
return false;
}
get currentUserMembership(): LeagueMembershipWithRole | null {
// Current user identity is not available in this view model context yet; callers must
// pass an explicit membership if they need per-user permissions.
return null;
}
get canEndRaces(): boolean {
return this.currentUserMembership?.role === 'admin' || this.currentUserMembership?.role === 'owner';
}
}
}

View File

@@ -0,0 +1,19 @@
import { RaceViewModel } from "./RaceViewModel";
import type { RaceViewData } from "../view-data/RaceViewData";
export class LeagueDetailRaceViewModel extends RaceViewModel {
private readonly detailData: RaceViewData & { views: number };
constructor(data: RaceViewData & { views: number }) {
super(data);
this.detailData = data;
}
get views(): number {
return this.detailData.views;
}
get formattedViews(): string {
return this.views.toLocaleString();
}
}

View File

@@ -1,135 +1,20 @@
import { DriverViewModel as SharedDriverViewModel } from "./DriverViewModel";
import { RaceViewModel as SharedRaceViewModel } from "./RaceViewModel";
/**
* League Detail View Model
*
* View model for detailed league information for sponsors.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { LeagueViewModel } from "./LeagueViewModel";
import { LeagueDetailDriverViewModel } from "./LeagueDetailDriverViewModel";
import { LeagueDetailRaceViewModel } from "./LeagueDetailRaceViewModel";
import type { LeagueDetailViewData } from "../view-data/LeagueDetailViewData";
export class LeagueDetailViewModel extends ViewModel {
league: LeagueViewModel;
drivers: LeagueDetailDriverViewModel[];
races: LeagueDetailRaceViewModel[];
private readonly data: LeagueDetailViewData;
readonly league: LeagueViewModel;
readonly drivers: LeagueDetailDriverViewModel[];
readonly races: LeagueDetailRaceViewModel[];
constructor(data: { league: unknown; drivers: unknown[]; races: unknown[] }) {
constructor(data: LeagueDetailViewData) {
super();
this.data = data;
this.league = new LeagueViewModel(data.league);
this.drivers = data.drivers.map(driver => new LeagueDetailDriverViewModel(driver as any));
this.races = data.races.map(race => new LeagueDetailRaceViewModel(race as any));
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailDriverViewModel extends SharedDriverViewModel extends ViewModel {
impressions: number;
constructor(dto: any) {
super(dto);
this.impressions = dto.impressions || 0;
}
get formattedImpressions(): string {
return this.impressions.toLocaleString();
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueDetailRaceViewModel extends SharedRaceViewModel extends ViewModel {
views: number;
constructor(dto: any) {
super(dto);
this.views = dto.views || 0;
}
get formattedViews(): string {
return this.views.toLocaleString();
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueViewModel extends ViewModel {
id: string;
name: string;
game: string;
tier: 'premium' | 'standard' | 'starter';
season: string;
description: string;
drivers: number;
races: number;
completedRaces: number;
totalImpressions: number;
avgViewsPerRace: number;
engagement: number;
rating: number;
seasonStatus: 'active' | 'upcoming' | 'completed';
seasonDates: { start: string; end: string };
nextRace?: { name: string; date: string };
sponsorSlots: {
main: { available: boolean; price: number; benefits: string[] };
secondary: { available: number; total: number; price: number; benefits: string[] };
};
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.id = d.id;
this.name = d.name;
this.game = d.game;
this.tier = d.tier;
this.season = d.season;
this.description = d.description;
this.drivers = d.drivers;
this.races = d.races;
this.completedRaces = d.completedRaces;
this.totalImpressions = d.totalImpressions;
this.avgViewsPerRace = d.avgViewsPerRace;
this.engagement = d.engagement;
this.rating = d.rating;
this.seasonStatus = d.seasonStatus;
this.seasonDates = d.seasonDates;
this.nextRace = d.nextRace;
this.sponsorSlots = d.sponsorSlots;
}
get formattedTotalImpressions(): string {
return this.totalImpressions.toLocaleString();
}
get formattedAvgViewsPerRace(): string {
return this.avgViewsPerRace.toLocaleString();
}
get projectedTotalViews(): number {
return Math.round(this.avgViewsPerRace * this.races);
}
get formattedProjectedTotal(): string {
return this.projectedTotalViews.toLocaleString();
}
get mainSponsorCpm(): number {
return Math.round((this.sponsorSlots.main.price / this.projectedTotalViews) * 1000);
}
get formattedMainSponsorCpm(): string {
return `$${this.mainSponsorCpm.toFixed(2)}`;
}
get racesLeft(): number {
return this.races - this.completedRaces;
}
get tierConfig() {
const configs = {
premium: { color: 'text-yellow-400', bgColor: 'bg-yellow-500/10', border: 'border-yellow-500/30' },
standard: { color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', border: 'border-primary-blue/30' },
starter: { color: 'text-gray-400', bgColor: 'bg-gray-500/10', border: 'border-gray-500/30' },
};
return configs[this.tier];
this.drivers = data.drivers.map(driver => new LeagueDetailDriverViewModel(driver));
this.races = data.races.map(race => new LeagueDetailRaceViewModel(race));
}
}

View File

@@ -1,27 +1,19 @@
import type { LeagueJoinRequestDTO } from '@/lib/types/generated/LeagueJoinRequestDTO';
/**
* League join request view model
* Transform from DTO to ViewModel with UI fields
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueJoinRequestViewData } from "../view-data/LeagueJoinRequestViewData";
export class LeagueJoinRequestViewModel extends ViewModel {
id: string;
leagueId: string;
driverId: string;
requestedAt: string;
private readonly data: LeagueJoinRequestViewData;
private isAdmin: boolean;
constructor(dto: LeagueJoinRequestDTO, currentUserId: string, isAdmin: boolean) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.driverId = dto.driverId;
this.requestedAt = dto.requestedAt;
this.isAdmin = isAdmin;
constructor(data: LeagueJoinRequestViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get leagueId(): string { return this.data.leagueId; }
get driverId(): string { return this.data.driverId; }
get requestedAt(): string { return this.data.requestedAt; }
/** UI-specific: Formatted request date */
get formattedRequestedAt(): string {
return new Date(this.requestedAt).toLocaleString();
@@ -29,11 +21,11 @@ export class LeagueJoinRequestViewModel extends ViewModel {
/** UI-specific: Whether the request can be approved by current user */
get canApprove(): boolean {
return this.isAdmin;
return this.data.isAdmin;
}
/** UI-specific: Whether the request can be rejected by current user */
get canReject(): boolean {
return this.isAdmin;
return this.data.isAdmin;
}
}

View File

@@ -1,36 +1,29 @@
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import { DriverViewModel } from './DriverViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { LeagueRoleDisplay, LeagueRole } from "../display-objects/LeagueRoleDisplay";
import type { LeagueMemberViewData } from "../view-data/LeagueMemberViewData";
export class LeagueMemberViewModel extends ViewModel {
driverId: string;
private readonly data: LeagueMemberViewData;
currentUserId: string;
constructor(dto: LeagueMemberDTO, currentUserId: string) {
this.driverId = dto.driverId;
this.currentUserId = currentUserId;
constructor(data: LeagueMemberViewData) {
super();
this.data = data;
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: DriverViewModel;
role: string = 'member';
joinedAt: string = new Date().toISOString();
get driverId(): string { return this.data.driverId; }
get driver(): any { return this.data.driver; }
get role(): string { return this.data.role; }
get joinedAt(): string { return this.data.joinedAt; }
/** UI-specific: Formatted join date */
get formattedJoinedAt(): string {
// Client-only formatting
return new Date(this.joinedAt).toLocaleDateString();
}
/** UI-specific: Badge variant for role */
get roleBadgeVariant(): string {
switch (this.role) {
case 'owner': return 'primary';
case 'admin': return 'secondary';
default: return 'default';
}
/** UI-specific: Badge classes for role */
get roleBadgeClasses(): string {
return LeagueRoleDisplay.getLeagueRoleDisplay(this.role as LeagueRole)?.badgeClasses || '';
}
/** UI-specific: Whether this member is the owner */
@@ -40,6 +33,6 @@ export class LeagueMemberViewModel extends ViewModel {
/** UI-specific: Whether this is the current user */
get isCurrentUser(): boolean {
return this.driverId === this.currentUserId;
return this.driverId === this.data.currentUserId;
}
}

View File

@@ -1,19 +1,15 @@
import { LeagueMemberViewModel } from './LeagueMemberViewModel';
import type { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
/**
* View Model for League Memberships
*
* Represents the league's memberships in a UI-ready format.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueMembershipsViewData } from "../view-data/LeagueMembershipsViewData";
export class LeagueMembershipsViewModel extends ViewModel {
memberships: LeagueMemberViewModel[];
private readonly data: LeagueMembershipsViewData;
readonly memberships: LeagueMemberViewModel[];
constructor(dto: { members?: LeagueMemberDTO[]; memberships?: LeagueMemberDTO[] }, currentUserId: string) {
const memberships = dto.members ?? dto.memberships ?? [];
this.memberships = memberships.map((membership) => new LeagueMemberViewModel(membership, currentUserId));
constructor(data: LeagueMembershipsViewData) {
super();
this.data = data;
this.memberships = data.memberships.map((m) => new LeagueMemberViewModel(m));
}
/** UI-specific: Number of members */

View File

@@ -4,6 +4,7 @@
* View model for league page details.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeaguePageDetailViewData } from "../view-data/LeaguePageDetailViewData";
export class LeaguePageDetailViewModel extends ViewModel {
id: string;
@@ -14,7 +15,8 @@ export class LeaguePageDetailViewModel extends ViewModel {
isAdmin: boolean;
mainSponsor: { name: string; logoUrl: string; websiteUrl: string } | null;
constructor(data: any) {
constructor(data: LeaguePageDetailViewData) {
super();
this.id = data.id;
this.name = data.name;
this.description = data.description;
@@ -23,4 +25,4 @@ export class LeaguePageDetailViewModel extends ViewModel {
this.isAdmin = data.isAdmin;
this.mainSponsor = data.mainSponsor;
}
}
}

View File

@@ -0,0 +1,16 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueScheduleRaceViewModel extends ViewModel {
id: string;
name: string;
scheduledAt: Date;
formattedDate: string;
formattedTime: string;
isPast: boolean;
isUpcoming: boolean;
status: string;
track?: string;
car?: string;
sessionType?: string;
isRegistered?: boolean;
}

View File

@@ -1,32 +1,15 @@
/**
* View Model for League Schedule
*
* Service layer maps DTOs into these shapes; UI consumes ViewModels only.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface LeagueScheduleRaceViewModel extends ViewModel {
id: string;
name: string;
scheduledAt: Date;
formattedDate: string;
formattedTime: string;
isPast: boolean;
isUpcoming: boolean;
status: string;
track?: string;
car?: string;
sessionType?: string;
isRegistered?: boolean;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueScheduleRaceViewModel } from "./LeagueScheduleRaceViewModel";
import type { LeagueScheduleViewData } from "../view-data/LeagueScheduleViewData";
export class LeagueScheduleViewModel extends ViewModel {
private readonly data: LeagueScheduleViewData;
readonly races: LeagueScheduleRaceViewModel[];
constructor(races: LeagueScheduleRaceViewModel[]) {
this.races = races;
constructor(data: LeagueScheduleViewData) {
super();
this.data = data;
this.races = data.races;
}
get raceCount(): number {
@@ -36,4 +19,4 @@ export class LeagueScheduleViewModel extends ViewModel {
get hasRaces(): boolean {
return this.raceCount > 0;
}
}
}

View File

@@ -1,19 +1,5 @@
export type LeagueScoringChampionshipViewModelInput = {
id: string;
name: string;
type: string;
sessionTypes: string[];
pointsPreview?: Array<{ sessionType: string; position: number; points: number }> | null;
bonusSummary?: string[] | null;
dropPolicyDescription?: string;
};
/**
* LeagueScoringChampionshipViewModel
*
* View model for league scoring championship
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueScoringChampionshipViewData } from "../view-data/LeagueScoringChampionshipViewData";
export class LeagueScoringChampionshipViewModel extends ViewModel {
readonly id: string;
@@ -24,13 +10,14 @@ export class LeagueScoringChampionshipViewModel extends ViewModel {
readonly bonusSummary: string[];
readonly dropPolicyDescription?: string;
constructor(input: LeagueScoringChampionshipViewModelInput) {
this.id = input.id;
this.name = input.name;
this.type = input.type;
this.sessionTypes = input.sessionTypes;
this.pointsPreview = input.pointsPreview ?? [];
this.bonusSummary = input.bonusSummary ?? [];
this.dropPolicyDescription = input.dropPolicyDescription;
constructor(data: LeagueScoringChampionshipViewData) {
super();
this.id = data.id;
this.name = data.name;
this.type = data.type;
this.sessionTypes = data.sessionTypes;
this.pointsPreview = data.pointsPreview ?? [];
this.bonusSummary = data.bonusSummary ?? [];
this.dropPolicyDescription = data.dropPolicyDescription;
}
}
}

View File

@@ -1,23 +1,16 @@
import { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO';
/**
* LeagueScoringConfigViewModel
*
* View model for league scoring configuration
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueScoringConfigViewData } from "../view-data/LeagueScoringConfigViewData";
export class LeagueScoringConfigViewModel extends ViewModel {
readonly gameName: string;
readonly scoringPresetName?: string;
readonly dropPolicySummary?: string;
readonly championships?: LeagueScoringChampionshipDTO[];
private readonly data: LeagueScoringConfigViewData;
constructor(dto: LeagueScoringConfigDTO) {
this.gameName = dto.gameName;
this.scoringPresetName = dto.scoringPresetName;
this.dropPolicySummary = dto.dropPolicySummary;
this.championships = dto.championships;
constructor(data: LeagueScoringConfigViewData) {
super();
this.data = data;
}
get gameName(): string { return this.data.gameName; }
get scoringPresetName(): string | undefined { return this.data.scoringPresetName; }
get dropPolicySummary(): string | undefined { return this.data.dropPolicySummary; }
get championships(): any[] | undefined { return this.data.championships; }
}

View File

@@ -1,40 +1,19 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
export type LeagueScoringPresetTimingDefaultsViewModel = ViewModel & {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
};
export type LeagueScoringPresetViewModelInput = {
id: string;
name: string;
sessionSummary: string;
bonusSummary?: string;
defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
};
/**
* LeagueScoringPresetViewModel
*
* View model for league scoring preset configuration
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueScoringPresetViewData } from "../view-data/LeagueScoringPresetViewData";
export class LeagueScoringPresetViewModel extends ViewModel {
readonly id: string;
readonly name: string;
readonly sessionSummary: string;
readonly bonusSummary?: string;
readonly defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
readonly defaultTimings: LeagueScoringPresetViewData['defaultTimings'];
constructor(input: LeagueScoringPresetViewModelInput) {
this.id = input.id;
this.name = input.name;
this.sessionSummary = input.sessionSummary;
this.bonusSummary = input.bonusSummary;
this.defaultTimings = input.defaultTimings;
constructor(data: LeagueScoringPresetViewData) {
super();
this.id = data.id;
this.name = data.name;
this.sessionSummary = data.sessionSummary;
this.bonusSummary = data.bonusSummary;
this.defaultTimings = data.defaultTimings;
}
}
}

View File

@@ -1,20 +1,16 @@
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
/**
* View Model for league scoring presets
* Transform from DTO to ViewModel with UI fields
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueScoringPresetsViewData } from "../view-data/LeagueScoringPresetsViewData";
export class LeagueScoringPresetsViewModel extends ViewModel {
presets: LeagueScoringPresetDTO[];
presets: any[];
totalCount: number;
constructor(dto: {
presets: LeagueScoringPresetDTO[];
totalCount?: number;
}) {
this.presets = dto.presets;
this.totalCount = dto.totalCount ?? dto.presets.length;
constructor(data: LeagueScoringPresetsViewData) {
super();
this.presets = data.presets;
this.totalCount = data.totalCount ?? data.presets.length;
}
}
}

View File

@@ -1,16 +1,9 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { CustomPointsConfig } from '@/lib/view-models/ScoringConfigurationViewModel';
/**
* LeagueScoringSectionViewModel
*
* View model for the league scoring section UI state and operations
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { LeagueScoringPresetViewModel } from './LeagueScoringPresetViewModel';
import type { CustomPointsConfig } from "../view-data/ScoringConfigurationViewData";
import type { LeagueScoringSectionViewData } from "../view-data/LeagueScoringSectionViewData";
export class LeagueScoringSectionViewModel extends ViewModel {
readonly form: LeagueConfigFormModel;
readonly presets: LeagueScoringPresetViewModel[];
readonly readOnly: boolean;
readonly patternOnly: boolean;
@@ -18,28 +11,24 @@ export class LeagueScoringSectionViewModel extends ViewModel {
readonly disabled: boolean;
readonly currentPreset: LeagueScoringPresetViewModel | null;
readonly isCustom: boolean;
private readonly data: LeagueScoringSectionViewData;
constructor(
form: LeagueConfigFormModel,
presets: LeagueScoringPresetViewModel[],
options: {
readOnly?: boolean;
patternOnly?: boolean;
championshipsOnly?: boolean;
} = {}
) {
this.form = form;
this.presets = presets;
this.readOnly = options.readOnly || false;
this.patternOnly = options.patternOnly || false;
this.championshipsOnly = options.championshipsOnly || false;
constructor(data: LeagueScoringSectionViewData) {
super();
this.data = data;
this.presets = data.presets.map(p => new LeagueScoringPresetViewModel(p));
this.readOnly = data.options?.readOnly || false;
this.patternOnly = data.options?.patternOnly || false;
this.championshipsOnly = data.options?.championshipsOnly || false;
this.disabled = this.readOnly;
this.currentPreset = form.scoring.patternId
? presets.find(p => p.id === form.scoring.patternId) || null
this.currentPreset = data.form.scoring.patternId
? this.presets.find(p => p.id === data.form.scoring.patternId) || null
: null;
this.isCustom = form.scoring.customScoringEnabled || false;
this.isCustom = data.form.scoring.customScoringEnabled || false;
}
get form() { return this.data.form; }
/**
* Get default custom points configuration
*/
@@ -120,8 +109,6 @@ export class LeagueScoringSectionViewModel extends ViewModel {
* Get the active custom points configuration
*/
getActiveCustomPoints(): CustomPointsConfig {
// This would be stored separately in the form model
// For now, return defaults
return LeagueScoringSectionViewModel.getDefaultCustomPoints();
}
}
}

View File

@@ -9,17 +9,16 @@ export type LeagueSeasonSummaryViewModelInput = {
import { ViewModel } from "../contracts/view-models/ViewModel";
export class LeagueSeasonSummaryViewModel extends ViewModel {
readonly seasonId: string;
readonly name: string;
readonly status: string;
readonly isPrimary: boolean;
readonly isParallelActive: boolean;
private readonly data: LeagueSeasonSummaryViewModelInput;
constructor(input: LeagueSeasonSummaryViewModelInput) {
this.seasonId = input.seasonId;
this.name = input.name;
this.status = input.status;
this.isPrimary = input.isPrimary;
this.isParallelActive = input.isParallelActive;
constructor(data: LeagueSeasonSummaryViewModelInput) {
super();
this.data = data;
}
get seasonId(): string { return this.data.seasonId; }
get name(): string { return this.data.name; }
get status(): string { return this.data.status; }
get isPrimary(): boolean { return this.data.isPrimary; }
get isParallelActive(): boolean { return this.data.isParallelActive; }
}

View File

@@ -1,40 +1,20 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
/**
* View Model for league settings page
* Combines league config, presets, owner, and members
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueSettingsViewData } from "../view-data/LeagueSettingsViewData";
export class LeagueSettingsViewModel extends ViewModel {
league: {
id: string;
name: string;
ownerId: string;
};
config: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
owner: DriverSummaryViewModel | null;
members: DriverSummaryViewModel[];
private readonly data: LeagueSettingsViewData;
readonly owner: DriverSummaryViewModel | null;
readonly members: DriverSummaryViewModel[];
constructor(dto: {
league: {
id: string;
name: string;
ownerId: string;
createdAt: string;
};
config: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
owner: DriverSummaryViewModel | null;
members: DriverSummaryViewModel[];
}) {
this.league = dto.league;
this.config = dto.config;
this.presets = dto.presets;
this.owner = dto.owner;
this.members = dto.members;
constructor(data: LeagueSettingsViewData) {
super();
this.data = data;
this.owner = data.owner ? new DriverSummaryViewModel(data.owner) : null;
this.members = data.members.map(m => new DriverSummaryViewModel(m));
}
get league() { return this.data.league; }
get config() { return this.data.config; }
get presets() { return this.data.presets; }
}

View File

@@ -1,23 +1,17 @@
import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
import { StandingEntryViewModel } from './StandingEntryViewModel';
import { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import { LeagueMembership } from '@/lib/types/LeagueMembership';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueStandingsViewData } from "../view-data/LeagueStandingsViewData";
export class LeagueStandingsViewModel extends ViewModel {
standings: StandingEntryViewModel[];
drivers: GetDriverOutputDTO[];
memberships: LeagueMembership[];
private readonly data: LeagueStandingsViewData;
readonly standings: StandingEntryViewModel[];
constructor(dto: { standings: LeagueStandingDTO[]; drivers: GetDriverOutputDTO[]; memberships: LeagueMembership[] }, currentUserId: string, previousStandings?: LeagueStandingDTO[]) {
const leaderPoints = dto.standings[0]?.points || 0;
this.standings = dto.standings.map((entry, index) => {
const nextPoints = dto.standings[index + 1]?.points || entry.points;
const previousPosition = previousStandings?.find(p => p.driverId === entry.driverId)?.position;
return new StandingEntryViewModel(entry, leaderPoints, nextPoints, currentUserId, previousPosition);
});
this.drivers = dto.drivers;
this.memberships = dto.memberships;
constructor(data: LeagueStandingsViewData) {
super();
this.data = data;
this.standings = data.standings.map(s => new StandingEntryViewModel(s));
}
get drivers(): any[] { return this.data.drivers; }
get memberships(): any[] { return this.data.memberships; }
}

View File

@@ -4,16 +4,21 @@
* Represents the total number of leagues in a UI-ready format.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueStatsViewData } from "../view-data/LeagueStatsViewData";
export class LeagueStatsViewModel extends ViewModel {
totalLeagues: number;
private readonly data: LeagueStatsViewData;
constructor(dto: { totalLeagues: number }) {
this.totalLeagues = dto.totalLeagues;
constructor(data: LeagueStatsViewData) {
super();
this.data = data;
}
get totalLeagues(): number { return this.data.totalLeagues; }
/** UI-specific: Formatted total leagues display */
get formattedTotalLeagues(): string {
// Client-only formatting
return this.totalLeagues.toLocaleString();
}
}

View File

@@ -4,14 +4,26 @@
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
/**
* ViewData for LeagueStewarding
* This is the JSON-serializable input for the Template.
*/
export interface LeagueStewardingViewData {
racesWithData: RaceWithProtests[];
driverMap: Record<string, { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }>;
}
export class LeagueStewardingViewModel extends ViewModel {
constructor(
public readonly racesWithData: RaceWithProtests[],
public readonly driverMap: Record<string, { id: string; name: string; avatarUrl?: string; iracingId?: string; rating?: number }>
) {
private readonly data: LeagueStewardingViewData;
constructor(data: LeagueStewardingViewData) {
super();
this.data = data;
}
get racesWithData(): RaceWithProtests[] { return this.data.racesWithData; }
get driverMap() { return this.data.driverMap; }
/** UI-specific: Total pending protests count */
get totalPending(): number {
return this.racesWithData.reduce((sum, r) => sum + r.pendingProtests.length, 0);

View File

@@ -1,29 +1,29 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { LeagueSummaryViewData } from "../view-data/LeagueSummaryViewData";
export interface LeagueSummaryViewModel extends ViewModel {
id: string;
name: string;
description: string | null;
logoUrl: string | null;
ownerId: string;
createdAt: string;
maxDrivers: number;
usedDriverSlots: number;
activeDriversCount?: number;
nextRaceAt?: string;
maxTeams?: number;
usedTeamSlots?: number;
structureSummary: string;
scoringPatternSummary?: string;
timingSummary: string;
category?: string | null;
scoring?: {
gameId: string;
gameName: string;
primaryChampionshipType: 'driver' | 'team' | 'nations' | 'trophy';
scoringPresetId: string;
scoringPresetName: string;
dropPolicySummary: string;
scoringPatternSummary: string;
};
export class LeagueSummaryViewModel extends ViewModel {
private readonly data: LeagueSummaryViewData;
constructor(data: LeagueSummaryViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get description(): string | null { return this.data.description; }
get logoUrl(): string | null { return this.data.logoUrl; }
get ownerId(): string { return this.data.ownerId; }
get createdAt(): string { return this.data.createdAt; }
get maxDrivers(): number { return this.data.maxDrivers; }
get usedDriverSlots(): number { return this.data.usedDriverSlots; }
get activeDriversCount(): number | undefined { return this.data.activeDriversCount; }
get nextRaceAt(): string | undefined { return this.data.nextRaceAt; }
get maxTeams(): number | undefined { return this.data.maxTeams; }
get usedTeamSlots(): number | undefined { return this.data.usedTeamSlots; }
get structureSummary(): string { return this.data.structureSummary; }
get scoringPatternSummary(): string | undefined { return this.data.scoringPatternSummary; }
get timingSummary(): string { return this.data.timingSummary; }
get category(): string | null | undefined { return this.data.category; }
get scoring() { return this.data.scoring; }
}

View File

@@ -0,0 +1,63 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { LeagueTierDisplay } from "../display-objects/LeagueTierDisplay";
import type { LeagueViewData } from "../view-data/LeagueDetailViewData";
export class LeagueViewModel extends ViewModel {
private readonly data: LeagueViewData;
constructor(data: LeagueViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get game(): string { return this.data.game; }
get tier(): 'premium' | 'standard' | 'starter' { return this.data.tier; }
get season(): string { return this.data.season; }
get description(): string { return this.data.description; }
get drivers(): number { return this.data.drivers; }
get races(): number { return this.data.races; }
get completedRaces(): number { return this.data.completedRaces; }
get totalImpressions(): number { return this.data.totalImpressions; }
get avgViewsPerRace(): number { return this.data.avgViewsPerRace; }
get engagement(): number { return this.data.engagement; }
get rating(): number { return this.data.rating; }
get seasonStatus(): 'active' | 'upcoming' | 'completed' { return this.data.seasonStatus; }
get seasonDates() { return this.data.seasonDates; }
get nextRace() { return this.data.nextRace; }
get sponsorSlots() { return this.data.sponsorSlots; }
get formattedTotalImpressions(): string {
return this.totalImpressions.toLocaleString();
}
get formattedAvgViewsPerRace(): string {
return this.avgViewsPerRace.toLocaleString();
}
get projectedTotalViews(): number {
return Math.round(this.avgViewsPerRace * this.races);
}
get formattedProjectedTotal(): string {
return this.projectedTotalViews.toLocaleString();
}
get mainSponsorCpm(): number {
return Math.round((this.sponsorSlots.main.price / this.projectedTotalViews) * 1000);
}
get formattedMainSponsorCpm(): string {
return CurrencyDisplay.format(this.mainSponsorCpm);
}
get racesLeft(): number {
return this.races - this.completedRaces;
}
get tierConfig() {
return LeagueTierDisplay.getDisplay(this.tier);
}
}

View File

@@ -1,63 +1,49 @@
import { WalletTransactionViewModel } from './WalletTransactionViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import type { LeagueWalletViewData } from "../view-data/LeagueWalletViewData";
export class LeagueWalletViewModel extends ViewModel {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
transactions: WalletTransactionViewModel[];
canWithdraw: boolean;
withdrawalBlockReason?: string;
private readonly data: LeagueWalletViewData;
readonly transactions: WalletTransactionViewModel[];
constructor(dto: {
balance: number;
currency: string;
totalRevenue: number;
totalFees: number;
totalWithdrawals: number;
pendingPayouts: number;
transactions: WalletTransactionViewModel[];
canWithdraw: boolean;
withdrawalBlockReason?: string;
}) {
constructor(data: LeagueWalletViewData) {
super();
this.balance = dto.balance;
this.currency = dto.currency;
this.totalRevenue = dto.totalRevenue;
this.totalFees = dto.totalFees;
this.totalWithdrawals = dto.totalWithdrawals;
this.pendingPayouts = dto.pendingPayouts;
this.transactions = dto.transactions;
this.canWithdraw = dto.canWithdraw;
this.withdrawalBlockReason = dto.withdrawalBlockReason;
this.data = data;
this.transactions = data.transactions.map(t => new WalletTransactionViewModel(t));
}
get balance(): number { return this.data.balance; }
get currency(): string { return this.data.currency; }
get totalRevenue(): number { return this.data.totalRevenue; }
get totalFees(): number { return this.data.totalFees; }
get totalWithdrawals(): number { return this.data.totalWithdrawals; }
get pendingPayouts(): number { return this.data.pendingPayouts; }
get canWithdraw(): boolean { return this.data.canWithdraw; }
get withdrawalBlockReason(): string | undefined { return this.data.withdrawalBlockReason; }
/** UI-specific: Formatted balance */
get formattedBalance(): string {
return `$${this.balance.toFixed(2)}`;
return CurrencyDisplay.format(this.balance, this.currency);
}
/** UI-specific: Formatted total revenue */
get formattedTotalRevenue(): string {
return `$${this.totalRevenue.toFixed(2)}`;
return CurrencyDisplay.format(this.totalRevenue, this.currency);
}
/** UI-specific: Formatted total fees */
get formattedTotalFees(): string {
return `$${this.totalFees.toFixed(2)}`;
return CurrencyDisplay.format(this.totalFees, this.currency);
}
/** UI-specific: Formatted pending payouts */
get formattedPendingPayouts(): string {
return `$${this.pendingPayouts.toFixed(2)}`;
return CurrencyDisplay.format(this.pendingPayouts, this.currency);
}
/** UI-specific: Filtered transactions by type */
getFilteredTransactions(type: 'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'): WalletTransactionViewModel[] {
return type === 'all' ? this.transactions : this.transactions.filter(t => t.type === type);
}
}
}

View File

@@ -1,8 +1,5 @@
import type { MediaViewData } from '@/lib/view-data/MediaViewData';
import { ViewModel } from "../contracts/view-models/ViewModel";
type MediaAssetViewData = MediaViewData['assets'][number];
import type { MediaAssetViewData } from "../view-data/MediaViewData";
/**
* Media View Model
@@ -11,23 +8,20 @@ type MediaAssetViewData = MediaViewData['assets'][number];
* Represents a single media asset card in the UI.
*/
export class MediaViewModel extends ViewModel {
id: string;
src: string;
title: string;
category: string;
date?: string;
dimensions?: string;
private readonly data: MediaAssetViewData;
constructor(viewData: MediaAssetViewData) {
constructor(data: MediaAssetViewData) {
super();
this.id = viewData.id;
this.src = viewData.src;
this.title = viewData.title;
this.category = viewData.category;
if (viewData.date !== undefined) this.date = viewData.date;
if (viewData.dimensions !== undefined) this.dimensions = viewData.dimensions;
this.data = data;
}
get id(): string { return this.data.id; }
get src(): string { return this.data.src; }
get title(): string { return this.data.title; }
get category(): string { return this.data.category; }
get date(): string | undefined { return this.data.date; }
get dimensions(): string | undefined { return this.data.dimensions; }
/** UI-specific: Combined subtitle used by MediaCard */
get subtitle(): string {
return `${this.category}${this.dimensions ? `${this.dimensions}` : ''}`;

View File

@@ -1,34 +1,34 @@
import type { MembershipFeeDTO } from '@/lib/types/generated';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { MembershipFeeTypeDisplay } from "../display-objects/MembershipFeeTypeDisplay";
import type { MembershipFeeViewData } from "../view-data/MembershipFeeViewData";
export class MembershipFeeViewModel extends ViewModel {
id!: string;
leagueId!: string;
seasonId?: string;
type!: string;
amount!: number;
enabled!: boolean;
createdAt!: Date;
updatedAt!: Date;
private readonly data: MembershipFeeViewData;
constructor(dto: MembershipFeeDTO) {
Object.assign(this, dto);
constructor(data: MembershipFeeViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get leagueId(): string { return this.data.leagueId; }
get seasonId(): string | undefined { return this.data.seasonId; }
get type(): string { return this.data.type; }
get amount(): number { return this.data.amount; }
get enabled(): boolean { return this.data.enabled; }
get createdAt(): string { return this.data.createdAt; }
get updatedAt(): string { return this.data.updatedAt; }
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.amount.toFixed(2)}`; // Assuming EUR
return CurrencyDisplay.format(this.amount, 'EUR');
}
/** UI-specific: Type display */
get typeDisplay(): string {
switch (this.type) {
case 'season': return 'Per Season';
case 'monthly': return 'Monthly';
case 'per_race': return 'Per Race';
default: return this.type;
}
return MembershipFeeTypeDisplay.format(this.type);
}
/** UI-specific: Status display */
@@ -43,11 +43,11 @@ export class MembershipFeeViewModel extends ViewModel {
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return this.createdAt.toLocaleString();
return DateDisplay.formatShort(this.createdAt);
}
/** UI-specific: Formatted updated date */
get formattedUpdatedAt(): string {
return this.updatedAt.toLocaleString();
return DateDisplay.formatShort(this.updatedAt);
}
}
}

View File

@@ -0,0 +1,21 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { NotificationSettingsViewData } from "../view-data/NotificationSettingsViewData";
export class NotificationSettingsViewModel extends ViewModel {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
constructor(data: NotificationSettingsViewData) {
super();
this.emailNewSponsorships = data.emailNewSponsorships;
this.emailWeeklyReport = data.emailWeeklyReport;
this.emailRaceAlerts = data.emailRaceAlerts;
this.emailPaymentAlerts = data.emailPaymentAlerts;
this.emailNewOpportunities = data.emailNewOpportunities;
this.emailContractExpiry = data.emailContractExpiry;
}
}

View File

@@ -1,5 +1,22 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface OnboardingViewModel extends ViewModel {
/**
* ViewData for Onboarding
* This is the JSON-serializable input for the Template.
*/
export interface OnboardingViewData {
isAlreadyOnboarded: boolean;
}
export class OnboardingViewModel extends ViewModel {
private readonly data: OnboardingViewData;
constructor(data: OnboardingViewData) {
super();
this.data = data;
}
get isAlreadyOnboarded(): boolean {
return this.data.isAlreadyOnboarded;
}
}

View File

@@ -0,0 +1,22 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { PaymentMethodViewData } from "@/lib/view-data/BillingViewData";
export class PaymentMethodViewModel extends ViewModel {
private readonly data: PaymentMethodViewData;
constructor(data: PaymentMethodViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get type(): string { return this.data.type; }
get last4(): string { return this.data.last4; }
get brand(): string | undefined { return this.data.brand; }
get isDefault(): boolean { return this.data.isDefault; }
get expiryMonth(): number | undefined { return this.data.expiryMonth; }
get expiryYear(): number | undefined { return this.data.expiryYear; }
get bankName(): string | undefined { return this.data.bankName; }
get displayLabel(): string { return this.data.displayLabel; }
get expiryDisplay(): string | null { return this.data.expiryDisplay; }
}

View File

@@ -1,33 +1,40 @@
import type { PaymentDTO } from '@/lib/types/generated/PaymentDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { PaymentTypeDisplay } from "../display-objects/PaymentTypeDisplay";
import { PayerTypeDisplay } from "../display-objects/PayerTypeDisplay";
import { StatusDisplay } from "../display-objects/StatusDisplay";
import type { PaymentViewData } from "../view-data/PaymentViewData";
export class PaymentViewModel extends ViewModel {
id!: string;
type!: string;
amount!: number;
platformFee!: number;
netAmount!: number;
payerId!: string;
payerType!: string;
leagueId!: string;
seasonId?: string;
status!: string;
createdAt!: Date;
completedAt?: Date;
private readonly data: PaymentViewData;
constructor(dto: PaymentDTO) {
Object.assign(this, dto);
constructor(data: PaymentViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get type(): string { return this.data.type; }
get amount(): number { return this.data.amount; }
get platformFee(): number { return this.data.platformFee; }
get netAmount(): number { return this.data.netAmount; }
get payerId(): string { return this.data.payerId; }
get payerType(): string { return this.data.payerType; }
get leagueId(): string { return this.data.leagueId; }
get seasonId(): string | undefined { return this.data.seasonId; }
get status(): string { return this.data.status; }
get createdAt(): string { return this.data.createdAt; }
get completedAt(): string | undefined { return this.data.completedAt; }
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.amount.toFixed(2)}`; // Assuming EUR currency
return CurrencyDisplay.format(this.amount, 'EUR');
}
/** UI-specific: Formatted net amount */
get formattedNetAmount(): string {
return `${this.netAmount.toFixed(2)}`;
return CurrencyDisplay.format(this.netAmount, 'EUR');
}
/** UI-specific: Status color */
@@ -43,26 +50,26 @@ export class PaymentViewModel extends ViewModel {
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return this.createdAt.toLocaleString();
return DateDisplay.formatShort(this.createdAt);
}
/** UI-specific: Formatted completed date */
get formattedCompletedAt(): string {
return this.completedAt ? this.completedAt.toLocaleString() : 'Not completed';
return this.completedAt ? DateDisplay.formatShort(this.completedAt) : 'Not completed';
}
/** UI-specific: Status display */
get statusDisplay(): string {
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
return StatusDisplay.transactionStatus(this.status);
}
/** UI-specific: Type display */
get typeDisplay(): string {
return this.type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
return PaymentTypeDisplay.format(this.type);
}
/** UI-specific: Payer type display */
get payerTypeDisplay(): string {
return this.payerType.charAt(0).toUpperCase() + this.payerType.slice(1);
return PayerTypeDisplay.format(this.payerType);
}
}
}

View File

@@ -0,0 +1,17 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { PrivacySettingsViewData } from "../view-data/PrivacySettingsViewData";
export class PrivacySettingsViewModel extends ViewModel {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
constructor(data: PrivacySettingsViewData) {
super();
this.publicProfile = data.publicProfile;
this.showStats = data.showStats;
this.showActiveSponsorships = data.showActiveSponsorships;
this.allowDirectContact = data.allowDirectContact;
}
}

View File

@@ -1,59 +1,44 @@
import type { PrizeDTO } from '@/lib/types/generated/PrizeDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { FinishDisplay } from "../display-objects/FinishDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import { PrizeTypeDisplay } from "../display-objects/PrizeTypeDisplay";
import type { PrizeViewData } from "../view-data/PrizeViewData";
export class PrizeViewModel extends ViewModel {
id!: string;
leagueId!: string;
seasonId!: string;
position!: number;
name!: string;
amount!: number;
type!: string;
description?: string;
awarded!: boolean;
awardedTo?: string;
awardedAt?: Date;
createdAt!: Date;
private readonly data: PrizeViewData;
constructor(dto: PrizeDTO) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.seasonId = dto.seasonId;
this.position = dto.position;
this.name = dto.name;
this.amount = dto.amount;
this.type = dto.type;
this.description = dto.description;
this.awarded = dto.awarded;
this.awardedTo = dto.awardedTo;
this.awardedAt = dto.awardedAt ? new Date(dto.awardedAt) : undefined;
this.createdAt = new Date(dto.createdAt);
constructor(data: PrizeViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get leagueId(): string { return this.data.leagueId; }
get seasonId(): string { return this.data.seasonId; }
get position(): number { return this.data.position; }
get name(): string { return this.data.name; }
get amount(): number { return this.data.amount; }
get type(): string { return this.data.type; }
get description(): string | undefined { return this.data.description; }
get awarded(): boolean { return this.data.awarded; }
get awardedTo(): string | undefined { return this.data.awardedTo; }
get awardedAt(): string | undefined { return this.data.awardedAt; }
get createdAt(): string { return this.data.createdAt; }
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.amount.toFixed(2)}`; // Assuming EUR
return CurrencyDisplay.format(this.amount, 'EUR');
}
/** UI-specific: Position display */
get positionDisplay(): string {
switch (this.position) {
case 1: return '1st Place';
case 2: return '2nd Place';
case 3: return '3rd Place';
default: return `${this.position}th Place`;
}
return FinishDisplay.format(this.position);
}
/** UI-specific: Type display */
get typeDisplay(): string {
switch (this.type) {
case 'cash': return 'Cash Prize';
case 'merchandise': return 'Merchandise';
case 'other': return 'Other';
default: return this.type;
}
return PrizeTypeDisplay.format(this.type);
}
/** UI-specific: Status display */
@@ -73,11 +58,11 @@ export class PrizeViewModel extends ViewModel {
/** UI-specific: Formatted awarded date */
get formattedAwardedAt(): string {
return this.awardedAt ? this.awardedAt.toLocaleString() : 'Not awarded';
return this.awardedAt ? DateDisplay.formatShort(this.awardedAt) : 'Not awarded';
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return this.createdAt.toLocaleString();
return DateDisplay.formatShort(this.createdAt);
}
}

View File

@@ -0,0 +1,93 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewDriverSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface ProfileOverviewStatsViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface ProfileOverviewFinishDistributionViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface ProfileOverviewTeamMembershipViewModel extends ViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
export interface ProfileOverviewSocialFriendSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface ProfileOverviewSocialSummaryViewModel extends ViewModel {
friendsCount: number;
friends: ProfileOverviewSocialFriendSummaryViewModel[];
}
export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface ProfileOverviewAchievementViewModel extends ViewModel {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: ProfileOverviewAchievementRarity;
earnedAt: string;
}
export interface ProfileOverviewSocialHandleViewModel extends ViewModel {
platform: ProfileOverviewSocialPlatform;
handle: string;
url: string;
}
export interface ProfileOverviewExtendedProfileViewModel extends ViewModel {
socialHandles: ProfileOverviewSocialHandleViewModel[];
achievements: ProfileOverviewAchievementViewModel[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}

View File

@@ -1,120 +1,26 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { ProfileOverviewViewData } from "../view-data/ProfileOverviewViewData";
import type {
ProfileOverviewDriverSummaryViewModel,
ProfileOverviewStatsViewModel,
ProfileOverviewFinishDistributionViewModel,
ProfileOverviewTeamMembershipViewModel,
ProfileOverviewSocialSummaryViewModel,
ProfileOverviewExtendedProfileViewModel
} from "./ProfileOverviewSubViewModels";
export interface ProfileOverviewDriverSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
export class ProfileOverviewViewModel extends ViewModel {
private readonly data: ProfileOverviewViewData;
constructor(data: ProfileOverviewViewData) {
super();
this.data = data;
}
get currentDriver(): ProfileOverviewDriverSummaryViewModel | null { return this.data.currentDriver; }
get stats(): ProfileOverviewStatsViewModel | null { return this.data.stats; }
get finishDistribution(): ProfileOverviewFinishDistributionViewModel | null { return this.data.finishDistribution; }
get teamMemberships(): ProfileOverviewTeamMembershipViewModel[] { return this.data.teamMemberships; }
get socialSummary(): ProfileOverviewSocialSummaryViewModel { return this.data.socialSummary; }
get extendedProfile(): ProfileOverviewExtendedProfileViewModel | null { return this.data.extendedProfile; }
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewStatsViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewFinishDistributionViewModel extends ViewModel {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewTeamMembershipViewModel extends ViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialFriendSummaryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialSummaryViewModel extends ViewModel {
friendsCount: number;
friends: ProfileOverviewSocialFriendSummaryViewModel[];
}
export type ProfileOverviewSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type ProfileOverviewAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewAchievementViewModel extends ViewModel {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: ProfileOverviewAchievementRarity;
earnedAt: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewSocialHandleViewModel extends ViewModel {
platform: ProfileOverviewSocialPlatform;
handle: string;
url: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewExtendedProfileViewModel extends ViewModel {
socialHandles: ProfileOverviewSocialHandleViewModel[];
achievements: ProfileOverviewAchievementViewModel[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export interface ProfileOverviewViewModel extends ViewModel {
currentDriver: ProfileOverviewDriverSummaryViewModel | null;
stats: ProfileOverviewStatsViewModel | null;
finishDistribution: ProfileOverviewFinishDistributionViewModel | null;
teamMemberships: ProfileOverviewTeamMembershipViewModel[];
socialSummary: ProfileOverviewSocialSummaryViewModel;
extendedProfile: ProfileOverviewExtendedProfileViewModel | null;
}

View File

@@ -1,15 +1,16 @@
import { DriverSummaryDTO } from '@/lib/types/generated/DriverSummaryDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { ProtestDriverViewData } from "../view-data/ProtestDriverViewData";
export class ProtestDriverViewModel extends ViewModel {
constructor(private readonly dto: DriverSummaryDTO) {}
constructor(private readonly data: ProtestDriverViewData) {
super();
}
get id(): string {
return this.dto.id;
return this.data.id;
}
get name(): string {
return this.dto.name;
return this.data.name;
}
}
}

View File

@@ -1,103 +1,30 @@
import { ProtestDTO } from '@/lib/types/generated/ProtestDTO';
import { RaceProtestDTO } from '@/lib/types/generated/RaceProtestDTO';
import { DateDisplay } from '../display-objects/DateDisplay';
import { StatusDisplay } from '../display-objects/StatusDisplay';
/**
* Protest view model
* Represents a race protest
*/
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { StatusDisplay } from '@/lib/display-objects/StatusDisplay';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { ProtestViewData } from "@/lib/view-data/ProtestViewData";
export class ProtestViewModel extends ViewModel {
id: string;
raceId: string;
protestingDriverId: string;
accusedDriverId: string;
description: string;
submittedAt: string;
filedAt?: string;
status: string;
reviewedAt?: string;
decisionNotes?: string;
incident?: { lap?: number; description?: string } | null;
proofVideoUrl?: string | null;
comment?: string | null;
private readonly data: ProtestViewData;
constructor(dto: ProtestDTO | RaceProtestDTO) {
this.id = dto.id;
// Type narrowing for raceId
if ('raceId' in dto) {
this.raceId = dto.raceId;
} else {
this.raceId = '';
}
this.protestingDriverId = dto.protestingDriverId;
this.accusedDriverId = dto.accusedDriverId;
// Type narrowing for description
if ('description' in dto && typeof dto.description === 'string') {
this.description = dto.description;
} else {
this.description = '';
}
// Type narrowing for submittedAt and filedAt
if ('submittedAt' in dto && typeof dto.submittedAt === 'string') {
this.submittedAt = dto.submittedAt;
} else if ('filedAt' in dto && typeof dto.filedAt === 'string') {
this.submittedAt = dto.filedAt;
} else {
this.submittedAt = '';
}
if ('filedAt' in dto && typeof dto.filedAt === 'string') {
this.filedAt = dto.filedAt;
} else if ('submittedAt' in dto && typeof dto.submittedAt === 'string') {
this.filedAt = dto.submittedAt;
}
// Handle different DTO structures
if ('status' in dto && typeof dto.status === 'string') {
this.status = dto.status;
} else {
this.status = 'pending';
}
// Handle incident data
if ('incident' in dto && dto.incident) {
const incident = dto.incident as { lap?: number; description?: string };
this.incident = {
lap: typeof incident.lap === 'number' ? incident.lap : undefined,
description: typeof incident.description === 'string' ? incident.description : undefined
};
} else if (('lap' in dto && typeof (dto as { lap?: number }).lap === 'number') ||
('description' in dto && typeof (dto as { description?: string }).description === 'string')) {
this.incident = {
lap: 'lap' in dto ? (dto as { lap?: number }).lap : undefined,
description: 'description' in dto ? (dto as { description?: string }).description : undefined
};
} else {
this.incident = null;
}
if ('proofVideoUrl' in dto) {
this.proofVideoUrl = (dto as { proofVideoUrl?: string }).proofVideoUrl || null;
}
if ('comment' in dto) {
this.comment = (dto as { comment?: string }).comment || null;
}
// Status and decision metadata are not part of the protest DTO in this build; they default to a pending, unreviewed protest
if (!('status' in dto)) {
this.status = 'pending';
}
this.reviewedAt = undefined;
this.decisionNotes = undefined;
constructor(data: ProtestViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get raceId(): string { return this.data.raceId; }
get protestingDriverId(): string { return this.data.protestingDriverId; }
get accusedDriverId(): string { return this.data.accusedDriverId; }
get description(): string { return this.data.description; }
get submittedAt(): string { return this.data.submittedAt; }
get filedAt(): string | undefined { return this.data.filedAt; }
get status(): string { return this.data.status; }
get reviewedAt(): string | undefined { return this.data.reviewedAt; }
get decisionNotes(): string | undefined { return this.data.decisionNotes; }
get incident(): { lap?: number; description?: string } | null | undefined { return this.data.incident; }
get proofVideoUrl(): string | null | undefined { return this.data.proofVideoUrl; }
get comment(): string | null | undefined { return this.data.comment; }
/** UI-specific: Formatted submitted date */
get formattedSubmittedAt(): string {
return DateDisplay.formatShort(this.submittedAt);

View File

@@ -1,21 +1,18 @@
import { RaceDetailEntryDTO } from '@/lib/types/generated/RaceDetailEntryDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceDetailEntryViewData } from "../view-data/RaceDetailEntryViewData";
export class RaceDetailEntryViewModel extends ViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
isCurrentUser: boolean;
rating: number | null;
private readonly data: RaceDetailEntryViewData;
constructor(dto: RaceDetailEntryDTO, currentDriverId: string, rating?: number) {
this.id = dto.id;
this.name = dto.name;
this.country = dto.country;
this.avatarUrl = dto.avatarUrl || '';
this.isCurrentUser = dto.id === currentDriverId;
this.rating = rating ?? null;
constructor(data: RaceDetailEntryViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get country(): string { return this.data.country; }
get avatarUrl(): string { return this.data.avatarUrl; }
get isCurrentUser(): boolean { return this.data.isCurrentUser; }
get rating(): number | null { return this.data.rating; }
}

View File

@@ -1,28 +1,24 @@
import { RaceDetailUserResultDTO } from '@/lib/types/generated/RaceDetailUserResultDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { DurationDisplay } from "../display-objects/DurationDisplay";
import type { RaceDetailUserResultViewData } from "../view-data/RaceDetailUserResultViewData";
export class RaceDetailUserResultViewModel extends ViewModel {
position!: number;
startPosition!: number;
incidents!: number;
fastestLap!: number;
positionChange!: number;
isPodium!: boolean;
isClean!: boolean;
ratingChange!: number;
private readonly data: RaceDetailUserResultViewData;
constructor(dto: RaceDetailUserResultDTO) {
this.position = dto.position;
this.startPosition = dto.startPosition;
this.incidents = dto.incidents;
this.fastestLap = dto.fastestLap;
this.positionChange = dto.positionChange;
this.isPodium = dto.isPodium;
this.isClean = dto.isClean;
this.ratingChange = dto.ratingChange ?? 0;
constructor(data: RaceDetailUserResultViewData) {
super();
this.data = data;
}
get position(): number { return this.data.position; }
get startPosition(): number { return this.data.startPosition; }
get incidents(): number { return this.data.incidents; }
get fastestLap(): number { return this.data.fastestLap; }
get positionChange(): number { return this.data.positionChange; }
get isPodium(): boolean { return this.data.isPodium; }
get isClean(): boolean { return this.data.isClean; }
get ratingChange(): number { return this.data.ratingChange; }
/** UI-specific: Display for position change */
get positionChangeDisplay(): string {
if (this.positionChange > 0) return `+${this.positionChange}`;
@@ -58,9 +54,6 @@ export class RaceDetailUserResultViewModel extends ViewModel {
/** UI-specific: Formatted lap time */
get lapTimeFormatted(): string {
if (this.fastestLap <= 0) return '--:--.---';
const minutes = Math.floor(this.fastestLap / 60);
const seconds = Math.floor(this.fastestLap % 60);
const milliseconds = Math.floor((this.fastestLap % 1) * 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
return DurationDisplay.formatSeconds(this.fastestLap);
}
}
}

View File

@@ -0,0 +1,11 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceDetailsLeagueViewData } from "../view-data/RaceDetailsViewData";
export class RaceDetailsLeagueViewModel extends ViewModel {
private readonly data: RaceDetailsLeagueViewData;
constructor(data: RaceDetailsLeagueViewData) { super(); this.data = data; }
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get description(): string | null | undefined { return this.data.description; }
get settings(): unknown { return this.data.settings; }
}

View File

@@ -0,0 +1,13 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceDetailsRaceViewData } from "../view-data/RaceDetailsViewData";
export class RaceDetailsRaceViewModel extends ViewModel {
private readonly data: RaceDetailsRaceViewData;
constructor(data: RaceDetailsRaceViewData) { super(); this.data = data; }
get id(): string { return this.data.id; }
get track(): string { return this.data.track; }
get car(): string { return this.data.car; }
get scheduledAt(): string { return this.data.scheduledAt; }
get status(): string { return this.data.status; }
get sessionType(): string { return this.data.sessionType; }
}

View File

@@ -0,0 +1,9 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceDetailsRegistrationViewData } from "../view-data/RaceDetailsViewData";
export class RaceDetailsRegistrationViewModel extends ViewModel {
private readonly data: RaceDetailsRegistrationViewData;
constructor(data: RaceDetailsRegistrationViewData) { super(); this.data = data; }
get canRegister(): boolean { return this.data.canRegister; }
get isUserRegistered(): boolean { return this.data.isUserRegistered; }
}

View File

@@ -1,41 +1,29 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel';
import { RaceDetailsRaceViewModel } from './RaceDetailsRaceViewModel';
import { RaceDetailsLeagueViewModel } from './RaceDetailsLeagueViewModel';
import { RaceDetailsRegistrationViewModel } from './RaceDetailsRegistrationViewModel';
import type { RaceDetailsViewData } from "../view-data/RaceDetailsViewData";
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RaceDetailsViewModel extends ViewModel {
private readonly data: RaceDetailsViewData;
readonly race: RaceDetailsRaceViewModel | null;
readonly league: RaceDetailsLeagueViewModel | null;
readonly entryList: RaceDetailEntryViewModel[];
readonly registration: RaceDetailsRegistrationViewModel;
readonly userResult: RaceDetailUserResultViewModel | null;
export type RaceDetailsRaceViewModel = ViewModel & {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
sessionType: string;
};
constructor(data: RaceDetailsViewData) {
super();
this.data = data;
this.race = data.race ? new RaceDetailsRaceViewModel(data.race) : null;
this.league = data.league ? new RaceDetailsLeagueViewModel(data.league) : null;
this.entryList = data.entryList.map(e => new RaceDetailEntryViewModel(e));
this.registration = new RaceDetailsRegistrationViewModel(data.registration);
this.userResult = data.userResult ? new RaceDetailUserResultViewModel(data.userResult) : null;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsLeagueViewModel = ViewModel & {
id: string;
name: string;
description?: string | null;
settings?: unknown;
};
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsRegistrationViewModel = ViewModel & {
canRegister: boolean;
isUserRegistered: boolean;
};
import { ViewModel } from "../contracts/view-models/ViewModel";
export type RaceDetailsViewModel = ViewModel & {
race: RaceDetailsRaceViewModel | null;
league: RaceDetailsLeagueViewModel | null;
entryList: RaceDetailEntryViewModel[];
registration: RaceDetailsRegistrationViewModel;
userResult: RaceDetailUserResultViewModel | null;
canReopenRace: boolean;
error?: string;
};
get canReopenRace(): boolean { return this.data.canReopenRace; }
get error(): string | undefined { return this.data.error; }
}

View File

@@ -1,65 +1,40 @@
// DTO matching the backend RacesPageDataRaceDTO
export interface RaceListItemDTO {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
strengthOfField?: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceStatusDisplay } from "../display-objects/RaceStatusDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { RaceListItemViewData } from "../view-data/RaceListItemViewData";
export class RaceListItemViewModel extends ViewModel {
id: string;
track: string;
car: string;
scheduledAt: string;
status: string;
leagueId: string;
leagueName: string;
strengthOfField: number | null;
isUpcoming: boolean;
isLive: boolean;
isPast: boolean;
private readonly data: RaceListItemViewData;
constructor(dto: RaceListItemDTO) {
this.id = dto.id;
this.track = dto.track;
this.car = dto.car;
this.scheduledAt = dto.scheduledAt;
this.status = dto.status;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
this.strengthOfField = dto.strengthOfField ?? null;
this.isUpcoming = dto.isUpcoming;
this.isLive = dto.isLive;
this.isPast = dto.isPast;
constructor(data: RaceListItemViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get track(): string { return this.data.track; }
get car(): string { return this.data.car; }
get scheduledAt(): string { return this.data.scheduledAt; }
get status(): string { return this.data.status; }
get leagueId(): string { return this.data.leagueId; }
get leagueName(): string { return this.data.leagueName; }
get strengthOfField(): number | null { return this.data.strengthOfField; }
get isUpcoming(): boolean { return this.data.isUpcoming; }
get isLive(): boolean { return this.data.isLive; }
get isPast(): boolean { return this.data.isPast; }
get title(): string {
return `${this.track} - ${this.car}`;
}
/** UI-specific: Formatted scheduled time */
get formattedScheduledTime(): string {
return new Date(this.scheduledAt).toLocaleString();
return DateDisplay.formatDateTime(this.scheduledAt);
}
/** UI-specific: Badge variant for status */
get statusBadgeVariant(): string {
switch (this.status) {
case 'scheduled': return 'info';
case 'running': return 'success';
case 'completed': return 'secondary';
case 'cancelled': return 'danger';
default: return 'default';
}
return RaceStatusDisplay.getVariant(this.status);
}
/** UI-specific: Time until start in minutes */

View File

@@ -1,24 +1,29 @@
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { FinishDisplay } from '../display-objects/FinishDisplay';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { FinishDisplay } from '../display-objects/FinishDisplay';
import { DurationDisplay } from '../display-objects/DurationDisplay';
import type { RaceResultViewData } from "../view-data/RaceResultViewData";
export class RaceResultViewModel extends ViewModel {
driverId!: string;
driverName!: string;
avatarUrl!: string;
position!: number;
startPosition!: number;
incidents!: number;
fastestLap!: number;
positionChange!: number;
isPodium!: boolean;
isClean!: boolean;
private readonly data: RaceResultViewData;
constructor(dto: RaceResultDTO) {
Object.assign(this, dto);
constructor(data: RaceResultViewData) {
super();
this.data = data;
}
get driverId(): string { return this.data.driverId; }
get driverName(): string { return this.data.driverName; }
get avatarUrl(): string { return this.data.avatarUrl; }
get position(): number { return this.data.position; }
get startPosition(): number { return this.data.startPosition; }
get incidents(): number { return this.data.incidents; }
get fastestLap(): number { return this.data.fastestLap; }
get positionChange(): number { return this.data.positionChange; }
get isPodium(): boolean { return this.data.isPodium; }
get isClean(): boolean { return this.data.isClean; }
get id(): string { return this.data.id; }
get raceId(): string { return this.data.raceId; }
/** UI-specific: Display for position change */
get positionChangeDisplay(): string {
if (this.positionChange > 0) return `+${this.positionChange}`;
@@ -58,10 +63,7 @@ export class RaceResultViewModel extends ViewModel {
/** UI-specific: Formatted lap time */
get lapTimeFormatted(): string {
if (this.fastestLap <= 0) return '--:--.---';
const minutes = Math.floor(this.fastestLap / 60);
const seconds = Math.floor(this.fastestLap % 60);
const milliseconds = Math.floor((this.fastestLap % 1) * 1000);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
return DurationDisplay.formatSeconds(this.fastestLap);
}
/** Required by ResultsTable */
@@ -87,9 +89,4 @@ export class RaceResultViewModel extends ViewModel {
}
return undefined;
}
// Note: The generated DTO doesn't have id or raceId
// These will need to be added when the OpenAPI spec is updated
id: string = '';
raceId: string = '';
}
}

View File

@@ -1,36 +1,27 @@
import { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { RaceResultsDetailDTO } from '@/lib/types/generated/RaceResultsDetailDTO';
import { RaceResultViewModel } from './RaceResultViewModel';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceResultViewModel } from './RaceResultViewModel';
import type { RaceResultsDetailViewData } from "../view-data/RaceResultsDetailViewData";
export class RaceResultsDetailViewModel extends ViewModel {
raceId: string;
track: string;
currentUserId: string;
private readonly data: RaceResultsDetailViewData;
readonly results: RaceResultViewModel[];
constructor(dto: RaceResultsDetailDTO & { results?: RaceResultDTO[] }, currentUserId: string) {
constructor(data: RaceResultsDetailViewData) {
super();
this.raceId = dto.raceId;
this.track = dto.track;
this.currentUserId = currentUserId;
// Map results if provided
if (dto.results) {
this.results = dto.results.map(r => new RaceResultViewModel(r));
}
this.data = data;
this.results = data.results.map(r => new RaceResultViewModel(r));
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
results: RaceResultViewModel[] = [];
league?: { id: string; name: string };
race?: { id: string; track: string; scheduledAt: string };
drivers: { id: string; name: string }[] = [];
pointsSystem: Record<number, number> = {};
fastestLapTime: number = 0;
penalties: { driverId: string; type: string; value?: number }[] = [];
currentDriverId: string = '';
get raceId(): string { return this.data.raceId; }
get track(): string { return this.data.track; }
get currentUserId(): string { return this.data.currentUserId; }
get league() { return this.data.league; }
get race() { return this.data.race; }
get drivers() { return this.data.drivers; }
get pointsSystem() { return this.data.pointsSystem; }
get fastestLapTime(): number { return this.data.fastestLapTime; }
get penalties() { return this.data.penalties; }
get currentDriverId(): string { return this.data.currentDriverId; }
/** UI-specific: Results sorted by position */
get resultsByPosition(): RaceResultViewModel[] {
@@ -63,4 +54,4 @@ export class RaceResultsDetailViewModel extends ViewModel {
averageIncidents: total > 0 ? totalIncidents / total : 0
};
}
}
}

View File

@@ -1,20 +1,19 @@
import type { RaceStatsDTO } from '@/lib/types/generated/RaceStatsDTO';
/**
* Race stats view model
* Represents race statistics for display
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceStatsViewData } from "../view-data/RaceStatsViewData";
export class RaceStatsViewModel extends ViewModel {
totalRaces: number;
private readonly data: RaceStatsViewData;
constructor(dto: RaceStatsDTO) {
this.totalRaces = dto.totalRaces;
constructor(data: RaceStatsViewData) {
super();
this.data = data;
}
get totalRaces(): number { return this.data.totalRaces; }
/** UI-specific: Formatted total races */
get formattedTotalRaces(): string {
// Client-only formatting
return this.totalRaces.toLocaleString();
}
}

View File

@@ -1,75 +1,20 @@
// DTO interfaces matching the API responses
interface RaceDetailDTO {
race: {
id: string;
track: string;
scheduledAt: string;
status: string;
} | null;
league: {
id: string;
name: string;
} | null;
}
interface RaceProtestsDTO {
protests: Array<{
id: string;
protestingDriverId: string;
accusedDriverId: string;
incident: {
lap: number;
description: string;
};
filedAt: string;
status: string;
decisionNotes?: string;
proofVideoUrl?: string;
}>;
driverMap: Record<string, { id: string; name: string }>;
}
interface RacePenaltiesDTO {
penalties: Array<{
id: string;
driverId: string;
type: string;
value: number;
reason: string;
notes?: string;
}>;
driverMap: Record<string, { id: string; name: string }>;
}
interface RaceStewardingDTO {
raceDetail: RaceDetailDTO;
protests: RaceProtestsDTO;
penalties: RacePenaltiesDTO;
}
/**
* Race Stewarding View Model
* Represents all data needed for race stewarding (protests, penalties, race info)
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceStewardingViewData } from "../view-data/RaceStewardingViewData";
export class RaceStewardingViewModel extends ViewModel {
race: RaceDetailDTO['race'];
league: RaceDetailDTO['league'];
protests: RaceProtestsDTO['protests'];
penalties: RacePenaltiesDTO['penalties'];
driverMap: Record<string, { id: string; name: string }>;
private readonly data: RaceStewardingViewData;
constructor(dto: RaceStewardingDTO) {
this.race = dto.raceDetail.race;
this.league = dto.raceDetail.league;
this.protests = dto.protests.protests;
this.penalties = dto.penalties.penalties;
// Merge driver maps from protests and penalties
this.driverMap = { ...dto.protests.driverMap, ...dto.penalties.driverMap };
constructor(data: RaceStewardingViewData) {
super();
this.data = data;
}
get race() { return this.data.race; }
get league() { return this.data.league; }
get protests() { return this.data.protests; }
get penalties() { return this.data.penalties; }
get driverMap() { return this.data.driverMap; }
/** UI-specific: Pending protests */
get pendingProtests() {
return this.protests.filter(p => p.status === 'pending' || p.status === 'under_review');

View File

@@ -1,63 +1,27 @@
import { RaceDTO } from '@/lib/types/generated/RaceDTO';
import { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RaceViewData } from "../view-data/RaceViewData";
export class RaceViewModel extends ViewModel {
constructor(
private readonly dto: RaceDTO | RacesPageDataRaceDTO,
private readonly _status?: string,
private readonly _registeredCount?: number,
private readonly _strengthOfField?: number
) {}
private readonly data: RaceViewData;
get id(): string {
return this.dto.id;
constructor(data: RaceViewData) {
super();
this.data = data;
}
get name(): string {
if ('name' in this.dto) {
return this.dto.name;
}
return '';
}
get date(): string {
if ('date' in this.dto) {
return this.dto.date;
}
if ('scheduledAt' in this.dto) {
return this.dto.scheduledAt;
}
return '';
}
get scheduledAt(): string {
return this.date;
}
get track(): string {
return 'track' in this.dto ? this.dto.track || '' : '';
}
get car(): string {
return 'car' in this.dto ? this.dto.car || '' : '';
}
get status(): string | undefined {
return this._status || ('status' in this.dto ? this.dto.status : undefined);
}
get registeredCount(): number | undefined {
return this._registeredCount;
}
get strengthOfField(): number | undefined {
return this._strengthOfField || ('strengthOfField' in this.dto ? this.dto.strengthOfField : undefined);
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get date(): string { return this.data.date; }
get scheduledAt(): string { return this.data.date; }
get track(): string { return this.data.track; }
get car(): string { return this.data.car; }
get status(): string | undefined { return this.data.status; }
get registeredCount(): number | undefined { return this.data.registeredCount; }
get strengthOfField(): number | undefined { return this.data.strengthOfField; }
/** UI-specific: Formatted date */
get formattedDate(): string {
// Client-only formatting
return new Date(this.date).toLocaleDateString();
}
}

View File

@@ -1,16 +1,15 @@
import { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RaceWithSOFViewData } from "../view-data/RaceWithSOFViewData";
export class RaceWithSOFViewModel extends ViewModel {
id: string;
track: string;
strengthOfField: number | null;
private readonly data: RaceWithSOFViewData;
constructor(dto: RaceWithSOFDTO) {
constructor(data: RaceWithSOFViewData) {
super();
this.id = dto.id;
this.track = dto.track;
this.strengthOfField = 'strengthOfField' in dto ? dto.strengthOfField ?? null : null;
this.data = data;
}
get id(): string { return this.data.id; }
get track(): string { return this.data.track; }
get strengthOfField(): number | null { return this.data.strengthOfField; }
}

View File

@@ -1,56 +1,19 @@
import { RaceListItemViewModel } from './RaceListItemViewModel';
import type { RacesPageDataRaceDTO } from '@/lib/types/generated/RacesPageDataRaceDTO';
// DTO matching the backend RacesPageDataDTO
interface RacesPageDTO {
races: RacesPageDataRaceDTO[];
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import { RacesPageViewData } from '../view-data/RacesPageViewData';
/**
* Races page view model
* Represents the races page data with all races in a single list
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RacesPageViewModel extends ViewModel {
races: RaceListItemViewModel[];
private readonly data: RacesPageViewData;
readonly races: RaceListItemViewModel[];
constructor(dto: RacesPageDTO) {
this.races = dto.races.map((r) => {
const status = 'status' in r ? r.status : 'unknown';
const isUpcoming =
'isUpcoming' in r ? r.isUpcoming :
(status === 'upcoming' || status === 'scheduled');
const isLive =
'isLive' in r ? r.isLive :
(status === 'live' || status === 'running');
const isPast =
'isPast' in r ? r.isPast :
(status === 'completed' || status === 'finished' || status === 'cancelled');
// Build the RaceListItemDTO from the input with proper type checking
const scheduledAt = 'scheduledAt' in r ? r.scheduledAt :
('date' in r ? (r as { date?: string }).date : '');
const normalized = {
id: r.id,
track: 'track' in r ? r.track : '',
car: 'car' in r ? r.car : '',
scheduledAt: scheduledAt || '',
status: status,
leagueId: 'leagueId' in r ? r.leagueId : '',
leagueName: 'leagueName' in r ? r.leagueName : '',
strengthOfField: 'strengthOfField' in r ? (r as { strengthOfField?: number }).strengthOfField ?? null : null,
isUpcoming: Boolean(isUpcoming),
isLive: Boolean(isLive),
isPast: Boolean(isPast),
};
return new RaceListItemViewModel(normalized);
});
constructor(data: RacesPageViewData) {
super();
this.data = data;
this.races = data.races.map((r) => new RaceListItemViewModel(r));
}
/** UI-specific: Total races */
@@ -87,4 +50,4 @@ export class RacesPageViewModel extends ViewModel {
get completedRaces(): RaceListItemViewModel[] {
return this.races.filter(r => r.status === 'completed');
}
}
}

View File

@@ -1,17 +1,17 @@
/**
* Record engagement input view model
* Represents input data for recording an engagement event
*
* Note: No matching generated DTO available yet
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RecordEngagementInputViewData } from "../view-data/RecordEngagementInputViewData";
export class RecordEngagementInputViewModel extends ViewModel {
eventType: string;
userId?: string;
metadata?: Record<string, unknown>;
constructor(data: { eventType: string; userId?: string; metadata?: Record<string, unknown> }) {
constructor(data: RecordEngagementInputViewData) {
super();
this.eventType = data.eventType;
this.userId = data.userId;
this.metadata = data.metadata;
@@ -31,4 +31,4 @@ export class RecordEngagementInputViewModel extends ViewModel {
get metadataKeysCount(): number {
return this.metadata ? Object.keys(this.metadata).length : 0;
}
}
}

View File

@@ -1,18 +1,13 @@
import type { RecordEngagementOutputDTO } from '@/lib/types/generated/RecordEngagementOutputDTO';
/**
* Record engagement output view model
* Represents the result of recording an engagement event for UI consumption
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class RecordEngagementOutputViewModel extends ViewModel {
eventId: string;
engagementWeight: number;
constructor(dto: RecordEngagementOutputDTO) {
this.eventId = dto.eventId;
this.engagementWeight = dto.engagementWeight;
constructor(eventId: string, engagementWeight: number) {
super();
this.eventId = eventId;
this.engagementWeight = engagementWeight;
}
/** UI-specific: Formatted event ID for display */

View File

@@ -1,16 +1,16 @@
/**
* Record page view input view model
* Represents input data for recording a page view
*
* Note: No matching generated DTO available yet
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RecordPageViewInputViewData } from "../view-data/RecordPageViewInputViewData";
export class RecordPageViewInputViewModel extends ViewModel {
path: string;
userId?: string;
constructor(data: { path: string; userId?: string }) {
constructor(data: RecordPageViewInputViewData) {
super();
this.path = data.path;
this.userId = data.userId;
}
@@ -24,4 +24,4 @@ export class RecordPageViewInputViewModel extends ViewModel {
get hasUserContext(): boolean {
return this.userId !== undefined && this.userId !== '';
}
}
}

View File

@@ -1,16 +1,12 @@
import type { RecordPageViewOutputDTO } from '@/lib/types/generated/RecordPageViewOutputDTO';
/**
* Record page view output view model
* Represents the result of recording a page view for UI consumption
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RecordPageViewOutputViewData } from "../view-data/RecordPageViewOutputViewData";
export class RecordPageViewOutputViewModel extends ViewModel {
pageViewId: string;
constructor(dto: RecordPageViewOutputDTO) {
this.pageViewId = dto.pageViewId;
constructor(data: RecordPageViewOutputViewData) {
super();
this.pageViewId = data.pageViewId;
}
/** UI-specific: Formatted page view ID for display */

View File

@@ -1,21 +1,21 @@
import { RemoveLeagueMemberOutputDTO } from '@/lib/types/generated/RemoveLeagueMemberOutputDTO';
/**
* View Model for Remove Member Result
*
* Represents the result of removing a member from a league in a UI-ready format.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { RemoveMemberViewData } from "../view-data/RemoveMemberViewData";
export class RemoveMemberViewModel extends ViewModel {
success: boolean;
constructor(dto: RemoveLeagueMemberOutputDTO) {
this.success = dto.success;
constructor(data: RemoveMemberViewData) {
super();
this.success = data.success;
}
/** UI-specific: Success message */
get successMessage(): string {
return this.success ? 'Member removed successfully!' : 'Failed to remove member.';
}
}
}

View File

@@ -1,9 +1,7 @@
/**
* Renewal Alert View Model
*
* View model for upcoming renewal alerts.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { RenewalAlertViewData } from "../view-data/RenewalAlertViewData";
export class RenewalAlertViewModel extends ViewModel {
id: string;
@@ -12,7 +10,8 @@ export class RenewalAlertViewModel extends ViewModel {
renewDate: Date;
price: number;
constructor(data: any) {
constructor(data: RenewalAlertViewData) {
super();
this.id = data.id;
this.name = data.name;
this.type = data.type;
@@ -21,11 +20,11 @@ export class RenewalAlertViewModel extends ViewModel {
}
get formattedPrice(): string {
return `$${this.price}`;
return CurrencyDisplay.format(this.price);
}
get formattedRenewDate(): string {
return this.renewDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return DateDisplay.formatShort(this.renewDate);
}
get typeIcon() {
@@ -48,4 +47,4 @@ export class RenewalAlertViewModel extends ViewModel {
get isUrgent(): boolean {
return this.daysUntilRenewal <= 30;
}
}
}

View File

@@ -1,19 +1,6 @@
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
export interface CustomPointsConfig {
racePoints: number[];
poleBonusPoints: number;
fastestLapPoints: number;
leaderLapPoints: number;
}
/**
* ScoringConfigurationViewModel
*
* View model for scoring configuration including presets and custom points
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { LeagueScoringPresetViewModel } from './LeagueScoringPresetViewModel';
import type { ScoringConfigurationViewData, CustomPointsConfig } from "../view-data/ScoringConfigurationViewData";
export class ScoringConfigurationViewModel extends ViewModel {
readonly patternId?: string;
@@ -21,16 +8,13 @@ export class ScoringConfigurationViewModel extends ViewModel {
readonly customPoints?: CustomPointsConfig;
readonly currentPreset?: LeagueScoringPresetViewModel;
constructor(
config: LeagueConfigFormModel['scoring'],
presets: LeagueScoringPresetViewModel[],
customPoints?: CustomPointsConfig
) {
this.patternId = config.patternId;
this.customScoringEnabled = config.customScoringEnabled || false;
this.customPoints = customPoints;
this.currentPreset = config.patternId
? presets.find(p => p.id === config.patternId)
constructor(data: ScoringConfigurationViewData) {
super();
this.patternId = data.config.patternId;
this.customScoringEnabled = data.config.customScoringEnabled || false;
this.customPoints = data.customPoints;
this.currentPreset = data.config.patternId
? new LeagueScoringPresetViewModel(data.presets.find(p => p.id === data.config.patternId)!)
: undefined;
}
@@ -49,4 +33,4 @@ export class ScoringConfigurationViewModel extends ViewModel {
leaderLapPoints: 0,
};
}
}
}

View File

@@ -1,19 +1,19 @@
import { SponsorDashboardDTO } from '@/lib/types/generated/SponsorDashboardDTO';
/**
* Sponsor Dashboard View Model
*
* Represents dashboard data for a sponsor with UI-specific transformations.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { SponsorDashboardViewData } from "../view-data/SponsorDashboardViewData";
export class SponsorDashboardViewModel extends ViewModel {
sponsorId: string;
sponsorName: string;
constructor(dto: SponsorDashboardDTO) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
constructor(data: SponsorDashboardViewData) {
super();
this.sponsorId = data.sponsorId;
this.sponsorName = data.sponsorName;
}
/** UI-specific: Welcome message */

View File

@@ -0,0 +1,44 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { SponsorProfileViewData } from "../view-data/SponsorProfileViewData";
export class SponsorProfileViewModel extends ViewModel {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
logoUrl: string | null;
industry: string;
address: {
street: string;
city: string;
country: string;
postalCode: string;
};
taxId: string;
socialLinks: {
twitter: string;
linkedin: string;
instagram: string;
};
constructor(data: SponsorProfileViewData) {
super();
this.companyName = data.companyName;
this.contactName = data.contactName;
this.contactEmail = data.contactEmail;
this.contactPhone = data.contactPhone;
this.website = data.website;
this.description = data.description;
this.logoUrl = data.logoUrl;
this.industry = data.industry;
this.address = data.address;
this.taxId = data.taxId;
this.socialLinks = data.socialLinks;
}
get fullAddress(): string {
return `${this.address.street}, ${this.address.city}, ${this.address.postalCode}, ${this.address.country}`;
}
}

View File

@@ -4,100 +4,20 @@
* View model for sponsor settings data.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { SponsorProfileViewModel } from "./SponsorProfileViewModel";
import { NotificationSettingsViewModel } from "./NotificationSettingsViewModel";
import { PrivacySettingsViewModel } from "./PrivacySettingsViewModel";
import type { SponsorSettingsViewData } from "../view-data/SponsorSettingsViewData";
export class SponsorSettingsViewModel extends ViewModel {
profile: SponsorProfileViewModel;
notifications: NotificationSettingsViewModel;
privacy: PrivacySettingsViewModel;
constructor(data: { profile: unknown; notifications: unknown; privacy: unknown }) {
constructor(data: SponsorSettingsViewData) {
super();
this.profile = new SponsorProfileViewModel(data.profile);
this.notifications = new NotificationSettingsViewModel(data.notifications);
this.privacy = new PrivacySettingsViewModel(data.privacy);
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorProfileViewModel extends ViewModel {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
logoUrl: string | null;
industry: string;
address: {
street: string;
city: string;
country: string;
postalCode: string;
};
taxId: string;
socialLinks: {
twitter: string;
linkedin: string;
instagram: string;
};
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.companyName = d.companyName;
this.contactName = d.contactName;
this.contactEmail = d.contactEmail;
this.contactPhone = d.contactPhone;
this.website = d.website;
this.description = d.description;
this.logoUrl = d.logoUrl;
this.industry = d.industry;
this.address = d.address;
this.taxId = d.taxId;
this.socialLinks = d.socialLinks;
}
get fullAddress(): string {
return `${this.address.street}, ${this.address.city}, ${this.address.postalCode}, ${this.address.country}`;
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class NotificationSettingsViewModel extends ViewModel {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.emailNewSponsorships = d.emailNewSponsorships;
this.emailWeeklyReport = d.emailWeeklyReport;
this.emailRaceAlerts = d.emailRaceAlerts;
this.emailPaymentAlerts = d.emailPaymentAlerts;
this.emailNewOpportunities = d.emailNewOpportunities;
this.emailContractExpiry = d.emailContractExpiry;
}
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class PrivacySettingsViewModel extends ViewModel {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
constructor(data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
this.publicProfile = d.publicProfile;
this.showStats = d.showStats;
this.showActiveSponsorships = d.showActiveSponsorships;
this.allowDirectContact = d.allowDirectContact;
}
}

View File

@@ -1,26 +1,19 @@
import type { SponsorSponsorshipsDTO } from '@/lib/types/generated/SponsorSponsorshipsDTO';
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
/**
* Sponsor Sponsorships View Model
*
* View model for sponsor sponsorships data with UI-specific transformations.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { SponsorshipDetailViewModel } from './SponsorshipDetailViewModel';
import type { SponsorSponsorshipsViewData } from "../view-data/SponsorSponsorshipsViewData";
export class SponsorSponsorshipsViewModel extends ViewModel {
sponsorId: string;
sponsorName: string;
sponsorships: SponsorshipDetailViewModel[];
constructor(dto: SponsorSponsorshipsDTO) {
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
constructor(data: SponsorSponsorshipsViewData) {
super();
this.sponsorId = data.sponsorId;
this.sponsorName = data.sponsorName;
this.sponsorships = (data.sponsorships || []).map(s => new SponsorshipDetailViewModel(s));
}
// Note: The generated DTO doesn't have sponsorships array
// This will need to be added when the OpenAPI spec is updated
sponsorships: SponsorshipDetailViewModel[] = [];
/** UI-specific: Total sponsorships count */
get totalCount(): number {
return this.sponsorships.length;
@@ -51,4 +44,4 @@ export class SponsorSponsorshipsViewModel extends ViewModel {
const firstCurrency = this.sponsorships[0]?.currency || 'USD';
return `${firstCurrency} ${this.totalInvestment.toLocaleString()}`;
}
}
}

View File

@@ -1,26 +1,19 @@
// Note: No generated DTO available for Sponsor yet
interface SponsorDTO {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { SponsorViewData } from "../view-data/SponsorViewData";
export class SponsorViewModel extends ViewModel {
id: string;
name: string;
declare logoUrl?: string;
declare websiteUrl?: string;
private readonly data: SponsorViewData;
constructor(dto: SponsorDTO) {
this.id = dto.id;
this.name = dto.name;
if (dto.logoUrl !== undefined) this.logoUrl = dto.logoUrl;
if (dto.websiteUrl !== undefined) this.websiteUrl = dto.websiteUrl;
constructor(data: SponsorViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get logoUrl(): string | undefined { return this.data.logoUrl; }
get websiteUrl(): string | undefined { return this.data.websiteUrl; }
/** UI-specific: Display name */
get displayName(): string {
return this.name;
@@ -35,4 +28,4 @@ export class SponsorViewModel extends ViewModel {
get websiteLinkText(): string {
return 'Visit Website';
}
}
}

View File

@@ -1,6 +1,6 @@
import { SponsorshipDetailDTO } from '@/lib/types/generated/SponsorshipDetailDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import type { SponsorshipDetailViewData } from "../view-data/SponsorshipDetailViewData";
export class SponsorshipDetailViewModel extends ViewModel {
id: string;
@@ -8,29 +8,35 @@ export class SponsorshipDetailViewModel extends ViewModel {
leagueName: string;
seasonId: string;
seasonName: string;
tier: 'main' | 'secondary';
status: string;
amount: number;
currency: string;
type: string;
entityName: string;
price: number;
impressions: number;
constructor(dto: SponsorshipDetailDTO) {
this.id = dto.id;
this.leagueId = dto.leagueId;
this.leagueName = dto.leagueName;
this.seasonId = dto.seasonId;
this.seasonName = dto.seasonName;
constructor(data: SponsorshipDetailViewData) {
super();
this.id = data.id;
this.leagueId = data.leagueId;
this.leagueName = data.leagueName;
this.seasonId = data.seasonId;
this.seasonName = data.seasonName;
this.tier = data.tier;
this.status = data.status;
this.amount = data.amount;
this.currency = data.currency;
this.type = data.type;
this.entityName = data.entityName;
this.price = data.price;
this.impressions = data.impressions;
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
tier: 'main' | 'secondary' = 'secondary';
status: string = 'active';
amount: number = 0;
currency: string = 'USD';
type: string = 'league';
entityName: string = '';
price: number = 0;
impressions: number = 0;
/** UI-specific: Formatted amount */
get formattedAmount(): string {
return `${this.currency} ${this.amount.toLocaleString()}`;
return CurrencyDisplay.format(this.amount, this.currency);
}
/** UI-specific: Tier badge variant */
@@ -52,4 +58,4 @@ export class SponsorshipDetailViewModel extends ViewModel {
get statusDisplay(): string {
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
}
}
}

View File

@@ -1,36 +1,27 @@
// Note: No generated DTO available for SponsorshipPricing yet
interface SponsorshipPricingDTO {
mainSlotPrice: number;
secondarySlotPrice: number;
currency: string;
}
/**
* Sponsorship Pricing View Model
*
* View model for sponsorship pricing data with UI-specific transformations.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import type { SponsorshipPricingViewData } from "../view-data/SponsorshipPricingViewData";
export class SponsorshipPricingViewModel extends ViewModel {
mainSlotPrice: number;
secondarySlotPrice: number;
currency: string;
constructor(dto: SponsorshipPricingDTO) {
this.mainSlotPrice = dto.mainSlotPrice;
this.secondarySlotPrice = dto.secondarySlotPrice;
this.currency = dto.currency;
constructor(data: SponsorshipPricingViewData) {
super();
this.mainSlotPrice = data.mainSlotPrice;
this.secondarySlotPrice = data.secondarySlotPrice;
this.currency = data.currency;
}
/** UI-specific: Formatted main slot price */
get formattedMainSlotPrice(): string {
return `${this.currency} ${this.mainSlotPrice.toLocaleString()}`;
return CurrencyDisplay.format(this.mainSlotPrice, this.currency);
}
/** UI-specific: Formatted secondary slot price */
get formattedSecondarySlotPrice(): string {
return `${this.currency} ${this.secondarySlotPrice.toLocaleString()}`;
return CurrencyDisplay.format(this.secondarySlotPrice, this.currency);
}
/** UI-specific: Price difference */
@@ -40,7 +31,7 @@ export class SponsorshipPricingViewModel extends ViewModel {
/** UI-specific: Formatted price difference */
get formattedPriceDifference(): string {
return `${this.currency} ${this.priceDifference.toLocaleString()}`;
return CurrencyDisplay.format(this.priceDifference, this.currency);
}
/** UI-specific: Discount percentage for secondary slot */
@@ -48,4 +39,4 @@ export class SponsorshipPricingViewModel extends ViewModel {
if (this.mainSlotPrice === 0) return 0;
return Math.round((1 - this.secondarySlotPrice / this.mainSlotPrice) * 100);
}
}
}

View File

@@ -1,6 +1,7 @@
import type { SponsorshipRequestDTO } from '@/lib/types/generated/SponsorshipRequestDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from "../display-objects/CurrencyDisplay";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { SponsorshipRequestViewData } from "../view-data/SponsorshipRequestViewData";
export class SponsorshipRequestViewModel extends ViewModel {
id: string;
@@ -16,33 +17,30 @@ export class SponsorshipRequestViewModel extends ViewModel {
platformFee: number;
netAmount: number;
constructor(dto: SponsorshipRequestDTO) {
this.id = dto.id;
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
if (dto.sponsorLogo !== undefined) this.sponsorLogo = dto.sponsorLogo;
// Backend currently returns tier as string; normalize to our supported tiers.
this.tier = dto.tier === 'main' ? 'main' : 'secondary';
this.offeredAmount = dto.offeredAmount;
this.currency = dto.currency;
this.formattedAmount = dto.formattedAmount;
if (dto.message !== undefined) this.message = dto.message;
this.createdAt = new Date(dto.createdAt);
this.platformFee = dto.platformFee;
this.netAmount = dto.netAmount;
constructor(data: SponsorshipRequestViewData) {
super();
this.id = data.id;
this.sponsorId = data.sponsorId;
this.sponsorName = data.sponsorName;
this.sponsorLogo = data.sponsorLogo;
this.tier = data.tier;
this.offeredAmount = data.offeredAmount;
this.currency = data.currency;
this.formattedAmount = data.formattedAmount;
this.message = data.message;
this.createdAt = new Date(data.createdAt);
this.platformFee = data.platformFee;
this.netAmount = data.netAmount;
}
/** UI-specific: Formatted date */
get formattedDate(): string {
return this.createdAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
return DateDisplay.formatMonthDay(this.createdAt);
}
/** UI-specific: Net amount in dollars */
get netAmountDollars(): string {
return `$${(this.netAmount / 100).toFixed(2)}`;
return CurrencyDisplay.format(this.netAmount / 100, 'USD');
}
/** UI-specific: Tier display */

View File

@@ -1,38 +1,14 @@
import { ViewModel } from "../contracts/view-models/ViewModel";
import { CurrencyDisplay } from '../display-objects/CurrencyDisplay';
import { DateDisplay } from '../display-objects/DateDisplay';
import { NumberDisplay } from '../display-objects/NumberDisplay';
/**
* Interface for sponsorship data input
*/
export interface SponsorshipDataInput {
id: string;
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
entityId: string;
entityName: string;
tier?: 'main' | 'secondary';
status: 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
applicationDate?: string | Date;
approvalDate?: string | Date;
rejectionReason?: string;
startDate: string | Date;
endDate: string | Date;
price: number;
impressions: number;
impressionsChange?: number;
engagement?: number;
details?: string;
entityOwner?: string;
applicationMessage?: string;
}
import type { SponsorshipViewData } from "../view-data/SponsorshipViewData";
/**
* Sponsorship View Model
*
* View model for individual sponsorship data.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class SponsorshipViewModel extends ViewModel {
id: string;
type: 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
@@ -53,7 +29,8 @@ export class SponsorshipViewModel extends ViewModel {
entityOwner?: string;
applicationMessage?: string;
constructor(data: SponsorshipDataInput) {
constructor(data: SponsorshipViewData) {
super();
this.id = data.id;
this.type = data.type;
this.entityId = data.entityId;
@@ -119,4 +96,4 @@ export class SponsorshipViewModel extends ViewModel {
const end = DateDisplay.formatMonthYear(this.endDate);
return `${start} - ${end}`;
}
}
}

View File

@@ -1,62 +1,48 @@
import { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { FinishDisplay } from "../display-objects/FinishDisplay";
import type { StandingEntryViewData } from "../view-data/StandingEntryViewData";
export class StandingEntryViewModel extends ViewModel {
driverId: string;
position: number;
points: number;
wins: number;
podiums: number;
races: number;
private readonly data: StandingEntryViewData;
private leaderPoints: number;
private nextPoints: number;
private currentUserId: string;
private previousPosition?: number;
constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
this.driverId = dto.driverId;
this.position = dto.position;
this.points = dto.points;
this.wins = dto.wins ?? 0;
this.podiums = dto.podiums ?? 0;
this.races = dto.races ?? 0;
this.leaderPoints = leaderPoints;
this.nextPoints = nextPoints;
this.currentUserId = currentUserId;
this.previousPosition = previousPosition;
constructor(data: StandingEntryViewData) {
super();
this.data = data;
}
get driverId(): string { return this.data.driverId; }
get position(): number { return this.data.position; }
get points(): number { return this.data.points; }
get wins(): number { return this.data.wins; }
get podiums(): number { return this.data.podiums; }
get races(): number { return this.data.races; }
get driver(): any { return this.data.driver; }
/** UI-specific: Badge for position display */
get positionBadge(): string {
return this.position.toString();
return FinishDisplay.format(this.position);
}
// Note: The generated DTO is incomplete
// These fields will need to be added when the OpenAPI spec is updated
driver?: any;
/** UI-specific: Points difference to leader */
get pointsGapToLeader(): number {
return this.points - this.leaderPoints;
return this.points - this.data.leaderPoints;
}
/** UI-specific: Points difference to next position */
get pointsGapToNext(): number {
return this.points - this.nextPoints;
return this.points - this.data.nextPoints;
}
/** UI-specific: Whether this entry is the current user */
get isCurrentUser(): boolean {
return this.driverId === this.currentUserId;
return this.driverId === this.data.currentUserId;
}
/** UI-specific: Trend compared to previous */
get trend(): 'up' | 'down' | 'same' {
if (!this.previousPosition) return 'same';
if (this.position < this.previousPosition) return 'up';
if (this.position > this.previousPosition) return 'down';
if (!this.data.previousPosition) return 'same';
if (this.position < this.data.previousPosition) return 'up';
if (this.position > this.data.previousPosition) return 'down';
return 'same';
}

View File

@@ -1,18 +1,9 @@
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
interface TeamCardDTO {
id: string;
name: string;
tag: string;
description: string;
logoUrl?: string;
}
/**
* Team card view model
* UI representation of a team on the landing page.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { TeamCardViewData } from "@/lib/view-data/TeamCardViewData";
export class TeamCardViewModel extends ViewModel {
readonly id: string;
@@ -21,11 +12,12 @@ export class TeamCardViewModel extends ViewModel {
readonly description: string;
readonly logoUrl?: string;
constructor(dto: TeamCardDTO | TeamListItemDTO) {
this.id = dto.id;
this.name = dto.name;
this.tag = dto.tag;
this.description = dto.description;
this.logoUrl = 'logoUrl' in dto ? dto.logoUrl : undefined;
constructor(data: TeamCardViewData) {
super();
this.id = data.id;
this.name = data.name;
this.tag = data.tag;
this.description = data.description;
this.logoUrl = data.logoUrl;
}
}

View File

@@ -1,52 +1,28 @@
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { TeamDetailsViewData } from "../view-data/TeamDetailsViewData";
export class TeamDetailsViewModel extends ViewModel {
id!: string;
name!: string;
tag!: string;
description?: string;
ownerId!: string;
leagues!: string[];
createdAt: string | undefined;
specialization: string | undefined;
region: string | undefined;
languages: string[] | undefined;
category: string | undefined;
membership: { role: string; joinedAt: string; isActive: boolean } | null;
private _canManage: boolean;
private currentUserId: string;
private readonly data: TeamDetailsViewData;
constructor(dto: GetTeamDetailsOutputDTO, currentUserId: string) {
this.id = dto.team.id;
this.name = dto.team.name;
this.tag = dto.team.tag;
this.description = dto.team.description;
this.ownerId = dto.team.ownerId;
this.leagues = dto.team.leagues;
this.createdAt = dto.team.createdAt;
const teamExtras = dto.team as typeof dto.team & {
specialization?: string;
region?: string;
languages?: string[];
category?: string;
};
this.specialization = teamExtras.specialization ?? undefined;
this.region = teamExtras.region ?? undefined;
this.languages = teamExtras.languages ?? undefined;
this.category = teamExtras.category ?? undefined;
this.membership = dto.membership ? {
role: dto.membership.role,
joinedAt: dto.membership.joinedAt,
isActive: dto.membership.isActive
} : null;
this._canManage = dto.canManage;
this.currentUserId = currentUserId;
constructor(data: TeamDetailsViewData) {
super();
this.data = data;
}
get id(): string { return this.data.team.id; }
get name(): string { return this.data.team.name; }
get tag(): string { return this.data.team.tag; }
get description(): string | undefined { return this.data.team.description; }
get ownerId(): string { return this.data.team.ownerId; }
get leagues(): string[] { return this.data.team.leagues; }
get createdAt(): string | undefined { return this.data.team.createdAt; }
get specialization(): string | undefined { return this.data.team.specialization; }
get region(): string | undefined { return this.data.team.region; }
get languages(): string[] | undefined { return this.data.team.languages; }
get category(): string | undefined { return this.data.team.category; }
get membership() { return this.data.membership; }
get currentUserId(): string { return this.data.currentUserId; }
/** UI-specific: Whether current user is owner */
get isOwner(): boolean {
return this.membership?.role === 'owner';
@@ -54,7 +30,7 @@ export class TeamDetailsViewModel extends ViewModel {
/** UI-specific: Whether can manage team */
get canManage(): boolean {
return this._canManage;
return this.data.canManage;
}
/** UI-specific: Whether current user is member */
@@ -66,4 +42,4 @@ export class TeamDetailsViewModel extends ViewModel {
get userRole(): string {
return this.membership?.role || 'none';
}
}
}

View File

@@ -1,34 +1,25 @@
import type { TeamJoinRequestDTO } from '@/lib/types/generated/TeamJoinRequestDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { TeamJoinRequestViewData } from "../view-data/TeamJoinRequestViewData";
export class TeamJoinRequestViewModel extends ViewModel {
requestId: string;
driverId: string;
driverName: string;
teamId: string;
requestStatus: string;
requestedAt: string;
avatarUrl: string;
private readonly data: TeamJoinRequestViewData;
private readonly currentUserId: string;
private readonly isOwner: boolean;
constructor(dto: TeamJoinRequestDTO, currentUserId: string, isOwner: boolean) {
this.requestId = dto.requestId;
this.driverId = dto.driverId;
this.driverName = dto.driverName;
this.teamId = dto.teamId;
this.requestStatus = dto.status;
this.requestedAt = dto.requestedAt;
this.avatarUrl = dto.avatarUrl || '';
this.currentUserId = currentUserId;
this.isOwner = isOwner;
constructor(data: TeamJoinRequestViewData) {
super();
this.data = data;
}
get id(): string {
return this.requestId;
}
get id(): string { return this.data.requestId; }
get requestId(): string { return this.data.requestId; }
get driverId(): string { return this.data.driverId; }
get driverName(): string { return this.data.driverName; }
get teamId(): string { return this.data.teamId; }
get requestStatus(): string { return this.data.status; }
get requestedAt(): string { return this.data.requestedAt; }
get avatarUrl(): string { return this.data.avatarUrl || ''; }
get currentUserId(): string { return this.data.currentUserId; }
get isOwner(): boolean { return this.data.isOwner; }
get status(): string {
if (this.requestStatus === 'pending') return 'Pending';
@@ -44,7 +35,7 @@ export class TeamJoinRequestViewModel extends ViewModel {
/** UI-specific: Formatted requested date */
get formattedRequestedAt(): string {
return new Date(this.requestedAt).toLocaleString();
return DateDisplay.formatDateTime(this.requestedAt);
}
/** UI-specific: Status color */

View File

@@ -1,6 +1,6 @@
import type { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO';
type TeamMemberRole = 'owner' | 'manager' | 'member';
import { ViewModel } from "../contracts/view-models/ViewModel";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { TeamMemberViewData, TeamMemberRole } from "../view-data/TeamMemberViewData";
function normalizeTeamRole(role: string): TeamMemberRole {
if (role === 'owner' || role === 'manager' || role === 'member') return role;
@@ -9,30 +9,23 @@ function normalizeTeamRole(role: string): TeamMemberRole {
return 'member';
}
import { ViewModel } from "../contracts/view-models/ViewModel";
export class TeamMemberViewModel extends ViewModel {
driverId: string;
driverName: string;
role: TeamMemberRole;
joinedAt: string;
isActive: boolean;
avatarUrl: string;
private readonly data: TeamMemberViewData;
private currentUserId: string;
private teamOwnerId: string;
constructor(dto: TeamMemberDTO, currentUserId: string, teamOwnerId: string) {
this.driverId = dto.driverId;
this.driverName = dto.driverName;
this.role = normalizeTeamRole(dto.role);
this.joinedAt = dto.joinedAt;
this.isActive = dto.isActive;
this.avatarUrl = dto.avatarUrl || '';
this.currentUserId = currentUserId;
this.teamOwnerId = teamOwnerId;
constructor(data: TeamMemberViewData) {
super();
this.data = data;
}
get driverId(): string { return this.data.driverId; }
get driverName(): string { return this.data.driverName; }
get role(): TeamMemberRole { return normalizeTeamRole(this.data.role); }
get joinedAt(): string { return this.data.joinedAt; }
get isActive(): boolean { return this.data.isActive; }
get avatarUrl(): string { return this.data.avatarUrl || ''; }
get currentUserId(): string { return this.data.currentUserId; }
get teamOwnerId(): string { return this.data.teamOwnerId; }
/** UI-specific: Role badge variant */
get roleBadgeVariant(): string {
switch (this.role) {
@@ -60,6 +53,6 @@ export class TeamMemberViewModel extends ViewModel {
/** UI-specific: Formatted joined date */
get formattedJoinedAt(): string {
return new Date(this.joinedAt).toLocaleDateString();
return DateDisplay.formatShort(this.joinedAt);
}
}

View File

@@ -1,48 +1,32 @@
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
import { ViewModel } from "../contracts/view-models/ViewModel";
import type { TeamSummaryViewData } from "../view-data/TeamSummaryViewData";
export class TeamSummaryViewModel extends ViewModel {
id: string;
name: string;
tag: string;
memberCount: number;
description?: string;
totalWins: number = 0;
totalRaces: number = 0;
performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro' = 'intermediate';
isRecruiting: boolean = false;
specialization: 'endurance' | 'sprint' | 'mixed' | undefined;
region: string | undefined;
languages: string[] = [];
leagues: string[] = [];
logoUrl: string | undefined;
rating: number | undefined;
category: string | undefined;
private readonly data: TeamSummaryViewData;
private readonly maxMembers = 10; // Assuming max members
private maxMembers = 10; // Assuming max members
constructor(dto: TeamListItemDTO) {
this.id = dto.id;
this.name = dto.name;
this.tag = dto.tag;
this.memberCount = dto.memberCount;
this.description = dto.description;
this.specialization = dto.specialization as 'endurance' | 'sprint' | 'mixed' | undefined;
this.region = dto.region;
this.languages = dto.languages ?? [];
this.leagues = dto.leagues;
// Map stats fields from DTO
this.totalWins = dto.totalWins ?? 0;
this.totalRaces = dto.totalRaces ?? 0;
this.performanceLevel = (dto.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
this.logoUrl = dto.logoUrl;
this.rating = dto.rating;
this.category = dto.category;
this.isRecruiting = dto.isRecruiting ?? false;
constructor(data: TeamSummaryViewData) {
super();
this.data = data;
}
get id(): string { return this.data.id; }
get name(): string { return this.data.name; }
get tag(): string { return this.data.tag; }
get memberCount(): number { return this.data.memberCount; }
get description(): string | undefined { return this.data.description; }
get totalWins(): number { return this.data.totalWins; }
get totalRaces(): number { return this.data.totalRaces; }
get performanceLevel(): string { return this.data.performanceLevel; }
get isRecruiting(): boolean { return this.data.isRecruiting; }
get specialization(): string | undefined { return this.data.specialization; }
get region(): string | undefined { return this.data.region; }
get languages(): string[] { return this.data.languages; }
get leagues(): string[] { return this.data.leagues; }
get logoUrl(): string | undefined { return this.data.logoUrl; }
get rating(): number | undefined { return this.data.rating; }
get category(): string | undefined { return this.data.category; }
/** UI-specific: Whether team is full */
get isFull(): boolean {
return this.memberCount >= this.maxMembers;

View File

@@ -1,34 +1,27 @@
interface UpcomingRaceCardDTO {
id: string;
track: string;
car: string;
scheduledAt: string;
}
import { ViewModel } from "../contracts/view-models/ViewModel";
import { DateDisplay } from "../display-objects/DateDisplay";
import type { UpcomingRaceCardViewData } from "../view-data/UpcomingRaceCardViewData";
/**
* Upcoming race card view model
* UI representation of an upcoming race on the landing page.
*/
import { ViewModel } from "../contracts/view-models/ViewModel";
export class UpcomingRaceCardViewModel extends ViewModel {
readonly id: string;
readonly track: string;
readonly car: string;
readonly scheduledAt: string;
constructor(dto: UpcomingRaceCardDTO) {
this.id = dto.id;
this.track = dto.track;
this.car = dto.car;
this.scheduledAt = dto.scheduledAt;
constructor(data: UpcomingRaceCardViewData) {
super();
this.id = data.id;
this.track = data.track;
this.car = data.car;
this.scheduledAt = data.scheduledAt;
}
/** UI-specific: formatted date label */
get formattedDate(): string {
return new Date(this.scheduledAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
return DateDisplay.formatMonthDay(this.scheduledAt);
}
}

Some files were not shown because too many files have changed in this diff Show More