5 Commits

6 changed files with 110 additions and 51 deletions

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Leaderboard, LeaderboardDriver } from '@/lib/schemas/leaderboard'; import { Leaderboard, LeaderboardDriver, FlPoints } from '@/lib/schemas/leaderboard';
import { saveLeaderboardAction } from '@/lib/actions/leaderboard'; import { saveLeaderboardAction } from '@/lib/actions/leaderboard';
import { LeaderboardStandings } from './LeaderboardStandings'; import { LeaderboardStandings } from './LeaderboardStandings';
import { DriversEditor } from './editors/DriversEditor'; import { DriversEditor } from './editors/DriversEditor';
@@ -24,13 +24,38 @@ interface LeaderboardManagerProps {
initialData: Leaderboard; initialData: Leaderboard;
} }
function isDirty(a: Leaderboard, b: Leaderboard) { // Extended type for internal UI state with full FL Points object
type LeaderboardDraft = Leaderboard & { flPointsData: FlPoints };
function toApiFormat(draft: LeaderboardDraft): Leaderboard {
return {
...draft,
flPoints: draft.flPointsData.points,
flColor: draft.flPointsData.color,
flTextColor: draft.flPointsData.textColor
};
}
function toUiFormat(api: Leaderboard): LeaderboardDraft {
return {
...api,
flPointsData: {
points: api.flPoints,
label: 'FL +' + api.flPoints,
color: api.flColor,
textColor: api.flTextColor,
priority: 3
}
};
}
function isDirty(a: LeaderboardDraft, b: LeaderboardDraft) {
return JSON.stringify(a) !== JSON.stringify(b); return JSON.stringify(a) !== JSON.stringify(b);
} }
export function LeaderboardManager({ serverId, initialData }: LeaderboardManagerProps) { export function LeaderboardManager({ serverId, initialData }: LeaderboardManagerProps) {
const [draft, setDraft] = useState<Leaderboard>(initialData); const [draft, setDraft] = useState<LeaderboardDraft>(toUiFormat(initialData));
const [saved, setSaved] = useState<Leaderboard>(initialData); const [saved, setSaved] = useState<LeaderboardDraft>(toUiFormat(initialData));
const [activeSection, setActiveSection] = useState<Section>('standings'); const [activeSection, setActiveSection] = useState<Section>('standings');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -38,15 +63,20 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
const dirty = isDirty(draft, saved); const dirty = isDirty(draft, saved);
const handleDriversChange = (newDrivers: LeaderboardDriver[]) => { const handleDriversChange = (newDrivers: LeaderboardDriver[]) => {
const oldDrivers = draft.drivers; const oldDriverIds = new Set(draft.drivers.map((d) => d.id));
// Build index map: for each position in newDrivers, what was the old index? // Remap tracks: keep results for drivers that still exist, remove results for deleted drivers
const indexMap = newDrivers.map((d) => oldDrivers.indexOf(d));
// If a driver was added (indexOf returns -1), it's new
const newTracks = draft.tracks.map((t) => ({ const newTracks = draft.tracks.map((t) => ({
...t, ...t,
results: indexMap.map((i) => (i === -1 ? 0 : (t.results[i] ?? 0))) // Filter existing results to only include drivers still in newDrivers, add DNS for new drivers
results: [
// Keep existing results for drivers still in the list
...t.results.filter((r) => newDrivers.some((d) => d.id === r.driverId)),
// Add DNS for new drivers
...newDrivers
.filter((d) => !oldDriverIds.has(d.id))
.map((d) => ({ driverId: d.id, score: 'DNS' as const }))
]
})); }));
setDraft({ ...draft, drivers: newDrivers, tracks: newTracks }); setDraft({ ...draft, drivers: newDrivers, tracks: newTracks });
@@ -56,7 +86,8 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
try { try {
const result = await saveLeaderboardAction(serverId, draft); const apiData = toApiFormat(draft);
const result = await saveLeaderboardAction(serverId, apiData);
if (result.success) { if (result.success) {
setSaved(draft); setSaved(draft);
} else { } else {
@@ -72,7 +103,7 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
const renderSection = () => { const renderSection = () => {
switch (activeSection) { switch (activeSection) {
case 'standings': case 'standings':
return <LeaderboardStandings leaderboard={draft} />; return <LeaderboardStandings leaderboard={toApiFormat(draft)} />;
case 'drivers': case 'drivers':
return <DriversEditor drivers={draft.drivers} onChange={handleDriversChange} />; return <DriversEditor drivers={draft.drivers} onChange={handleDriversChange} />;
case 'points': case 'points':
@@ -85,8 +116,8 @@ export function LeaderboardManager({ serverId, initialData }: LeaderboardManager
case 'fl': case 'fl':
return ( return (
<FlPointsEditor <FlPointsEditor
flPoints={draft.flPoints} flPoints={draft.flPointsData}
onChange={(flPoints) => setDraft({ ...draft, flPoints })} onChange={(flPointsData) => setDraft({ ...draft, flPointsData })}
/> />
); );
case 'tracks': case 'tracks':

View File

@@ -10,15 +10,17 @@ interface LeaderboardStandingsProps {
export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps) { export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps) {
const standings = useMemo<StandingsRow[]>(() => { const standings = useMemo<StandingsRow[]>(() => {
return leaderboard.drivers return leaderboard.drivers
.map((driver, idx) => { .map((driver) => {
const trackResults = leaderboard.tracks.map((t) => t.results[idx] ?? 0); const trackResults = leaderboard.tracks.map(
(t) => t.results.find((r) => r.driverId === driver.id)?.score ?? 'DNS'
);
const fastestLapCount = leaderboard.tracks.filter( const fastestLapCount = leaderboard.tracks.filter(
(t) => t.fastestLapInitials === driver.initials (t) => t.fastestLapDriverId === driver.id
).length; ).length;
const totalPoints = const totalPoints =
trackResults.reduce((sum, r) => sum + (typeof r === 'number' ? r : 0), 0) + trackResults.reduce((sum: number, r) => sum + (typeof r === 'number' ? r : 0), 0) +
fastestLapCount * leaderboard.flPoints.points; fastestLapCount * leaderboard.flPoints;
return { driver, driverIndex: idx, totalPoints, trackResults, fastestLapCount }; return { driver, driverId: driver.id, totalPoints, trackResults, fastestLapCount };
}) })
.sort((a, b) => b.totalPoints - a.totalPoints); .sort((a, b) => b.totalPoints - a.totalPoints);
}, [leaderboard]); }, [leaderboard]);
@@ -54,7 +56,7 @@ export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps)
</thead> </thead>
<tbody> <tbody>
{standings.map((row, pos) => ( {standings.map((row, pos) => (
<tr key={row.driverIndex} className="border-b border-gray-700/50"> <tr key={row.driver.id} className="border-b border-gray-700/50">
<td className="py-2 pr-4 text-gray-400">{pos + 1}</td> <td className="py-2 pr-4 text-gray-400">{pos + 1}</td>
<td className="py-2 pr-4"> <td className="py-2 pr-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -68,7 +70,7 @@ export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps)
</td> </td>
{row.trackResults.map((result, ti) => { {row.trackResults.map((result, ti) => {
const track = leaderboard.tracks[ti]; const track = leaderboard.tracks[ti];
const isFl = track.fastestLapInitials === row.driver.initials; const isFl = track.fastestLapDriverId === row.driver.id;
return ( return (
<td key={ti} className="px-2 py-2 text-center"> <td key={ti} className="px-2 py-2 text-center">
<span <span
@@ -85,10 +87,10 @@ export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps)
<span <span
className="ml-0.5 rounded px-0.5 text-xs" className="ml-0.5 rounded px-0.5 text-xs"
style={{ style={{
backgroundColor: leaderboard.flPoints.color, backgroundColor: leaderboard.flColor,
color: leaderboard.flPoints.textColor color: leaderboard.flTextColor
}} }}
title={`Fastest lap: ${leaderboard.flPoints.label}`} title="Fastest lap"
> >
FL FL
</span> </span>
@@ -100,9 +102,7 @@ export function LeaderboardStandings({ leaderboard }: LeaderboardStandingsProps)
<td className="px-2 py-2 text-center text-xs text-purple-400"> <td className="px-2 py-2 text-center text-xs text-purple-400">
{row.fastestLapCount > 0 ? `×${row.fastestLapCount}` : '—'} {row.fastestLapCount > 0 ? `×${row.fastestLapCount}` : '—'}
</td> </td>
<td className="px-2 py-2 text-center font-bold text-yellow-400"> <td className="px-2 py-2 text-center font-bold text-yellow-400">{row.totalPoints}</td>
{row.totalPoints}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -36,13 +36,13 @@ export function DriversEditor({ drivers, onChange }: DriversEditorProps) {
}; };
const add = () => { const add = () => {
onChange([...drivers, { name: 'New Driver', initials: 'ND', color: randomColor() }]); onChange([...drivers, { id: crypto.randomUUID(), name: 'New Driver', initials: 'ND', color: randomColor() }]);
}; };
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{drivers.map((driver, i) => ( {drivers.map((driver, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg bg-gray-700 p-3"> <div key={driver.id} className="flex items-center gap-3 rounded-lg bg-gray-700 p-3">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<button <button
onClick={() => moveUp(i)} onClick={() => moveUp(i)}

View File

@@ -20,7 +20,7 @@ export function PointsTableEditor({ pointsTable, onChange }: PointsTableEditorPr
const pos = pointsTable.length + 1; const pos = pointsTable.length + 1;
onChange([ onChange([
...pointsTable, ...pointsTable,
{ points: 0, label: `P${pos}`, color: '#374151', textColor: '#ffffff', priority: pos } { id: crypto.randomUUID(), points: 0, label: `P${pos}`, color: '#374151', textColor: '#ffffff', priority: pos }
]); ]);
}; };

View File

@@ -10,20 +10,27 @@ interface ResultInputProps {
function ResultInput({ value, pointsTable, onChange }: ResultInputProps) { function ResultInput({ value, pointsTable, onChange }: ResultInputProps) {
const knownPoints = Array.from(new Set(pointsTable.map((e) => e.points))).sort((a, b) => b - a); const knownPoints = Array.from(new Set(pointsTable.map((e) => e.points))).sort((a, b) => b - a);
// Fallback 0 to DNS if 0 is not a valid points option
const displayValue = value === 0 && !knownPoints.includes(0) ? 'DNS' : value;
// Ensure the current numeric value is always included in options
const allPoints = typeof displayValue === 'number'
? Array.from(new Set([...knownPoints, displayValue])).sort((a, b) => b - a)
: knownPoints;
const handleChange = (raw: string) => { const handleChange = (raw: string) => {
if (raw === 'DNF') return onChange('DNF'); if (raw === 'DNF') return onChange('DNF');
if (raw === 'DNS') return onChange('DNS'); if (raw === 'DNS') return onChange('DNS');
onChange(parseInt(raw) || 0); const parsed = parseInt(raw);
onChange(isNaN(parsed) ? 0 : parsed);
}; };
return ( return (
<select <select
value={String(value)} value={String(displayValue)}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
className="w-full rounded bg-gray-600 px-1 py-1 text-xs text-white" className="w-full rounded bg-gray-600 px-1 py-1 text-xs text-white"
> >
{knownPoints.map((p) => ( {allPoints.map((p) => (
<option key={p} value={String(p)}> <option key={p} value={String(p)}>
{p} {p}
</option> </option>
@@ -46,9 +53,14 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
onChange(tracks.map((t, i) => (i === index ? { ...t, ...patch } : t))); onChange(tracks.map((t, i) => (i === index ? { ...t, ...patch } : t)));
}; };
const updateResult = (trackIndex: number, driverIndex: number, value: ResultValue) => { const updateResult = (trackIndex: number, driverId: string, value: ResultValue) => {
const newResults = [...tracks[trackIndex].results]; const newResults = [...tracks[trackIndex].results];
newResults[driverIndex] = value; const existingIndex = newResults.findIndex((r) => r.driverId === driverId);
if (existingIndex >= 0) {
newResults[existingIndex] = { driverId, score: value };
} else {
newResults.push({ driverId, score: value });
}
updateTrack(trackIndex, { results: newResults }); updateTrack(trackIndex, { results: newResults });
}; };
@@ -60,9 +72,10 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
onChange([ onChange([
...tracks, ...tracks,
{ {
id: crypto.randomUUID(),
name: `Race ${tracks.length + 1}`, name: `Race ${tracks.length + 1}`,
results: drivers.map(() => 'DNS' as const), results: drivers.map((d) => ({ driverId: d.id, score: 'DNS' as const })),
fastestLapInitials: '' fastestLapDriverId: ''
} }
]); ]);
}; };
@@ -82,13 +95,13 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-400">FL Driver:</label> <label className="text-xs text-gray-400">FL Driver:</label>
<select <select
value={track.fastestLapInitials} value={track.fastestLapDriverId}
onChange={(e) => updateTrack(ti, { fastestLapInitials: e.target.value })} onChange={(e) => updateTrack(ti, { fastestLapDriverId: e.target.value })}
className="rounded bg-gray-600 px-2 py-1 text-sm text-white" className="rounded bg-gray-600 px-2 py-1 text-sm text-white"
> >
<option value="">—</option> <option value="">—</option>
{drivers.map((d) => ( {drivers.map((d) => (
<option key={d.initials} value={d.initials}> <option key={d.id} value={d.id}>
{d.initials} ({d.name}) {d.initials} ({d.name})
</option> </option>
))} ))}
@@ -107,8 +120,8 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
<p className="text-xs text-gray-500">Add drivers first to enter results.</p> <p className="text-xs text-gray-500">Add drivers first to enter results.</p>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
{drivers.map((driver, di) => ( {drivers.map((driver) => (
<div key={di} className="flex items-center gap-3"> <div key={driver.id} className="flex items-center gap-3">
<div className="flex w-40 items-center gap-2"> <div className="flex w-40 items-center gap-2">
<div <div
className="h-3 w-3 shrink-0 rounded-full" className="h-3 w-3 shrink-0 rounded-full"
@@ -118,9 +131,9 @@ export function TracksEditor({ tracks, drivers, pointsTable, onChange }: TracksE
</div> </div>
<div className="w-24"> <div className="w-24">
<ResultInput <ResultInput
value={track.results[di] ?? 0} value={track.results.find((r) => r.driverId === driver.id)?.score ?? 'DNS'}
pointsTable={pointsTable} pointsTable={pointsTable}
onChange={(v) => updateResult(ti, di, v)} onChange={(v) => updateResult(ti, driver.id, v)}
/> />
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ export const resultValueSchema = z.union([z.literal('DNF'), z.literal('DNS'), z.
export type ResultValue = z.infer<typeof resultValueSchema>; export type ResultValue = z.infer<typeof resultValueSchema>;
export const leaderboardDriverSchema = z.object({ export const leaderboardDriverSchema = z.object({
id: z.string(),
name: z.string().min(1), name: z.string().min(1),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/), color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
initials: z.string().min(1).max(10) initials: z.string().min(1).max(10)
@@ -11,6 +12,7 @@ export const leaderboardDriverSchema = z.object({
export type LeaderboardDriver = z.infer<typeof leaderboardDriverSchema>; export type LeaderboardDriver = z.infer<typeof leaderboardDriverSchema>;
const pointsEntryFields = { const pointsEntryFields = {
id: z.string(),
points: z.number().int().min(0), points: z.number().int().min(0),
label: z.string().min(1), label: z.string().min(1),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/), color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
@@ -21,20 +23,31 @@ const pointsEntryFields = {
export const pointsEntrySchema = z.object(pointsEntryFields); export const pointsEntrySchema = z.object(pointsEntryFields);
export type PointsEntry = z.infer<typeof pointsEntrySchema>; export type PointsEntry = z.infer<typeof pointsEntrySchema>;
export const flPointsSchema = z.object(pointsEntryFields); export const flPointsSchema = z.object({
points: z.number().int().min(0),
label: z.string().min(1),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
textColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
priority: z.number().int().min(0)
});
export type FlPoints = z.infer<typeof flPointsSchema>; export type FlPoints = z.infer<typeof flPointsSchema>;
export const leaderboardTrackSchema = z.object({ export const leaderboardTrackSchema = z.object({
id: z.string(),
name: z.string().min(1), name: z.string().min(1),
results: z.array(resultValueSchema), results: z.array(z.object({ driverId: z.string(), score: resultValueSchema })),
fastestLapInitials: z.string() fastestLapDriverId: z.string()
}); });
export type LeaderboardTrack = z.infer<typeof leaderboardTrackSchema>; export type LeaderboardTrack = z.infer<typeof leaderboardTrackSchema>;
export const leaderboardSchema = z.object({ export const leaderboardSchema = z.object({
id: z.string().optional(),
serverId: z.string().optional(),
drivers: z.array(leaderboardDriverSchema), drivers: z.array(leaderboardDriverSchema),
pointsTable: z.array(pointsEntrySchema), pointsTable: z.array(pointsEntrySchema),
flPoints: flPointsSchema, flPoints: z.number().int().min(0),
flColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
flTextColor: z.string().regex(/^#[0-9a-fA-F]{6}$/),
tracks: z.array(leaderboardTrackSchema) tracks: z.array(leaderboardTrackSchema)
}); });
export type Leaderboard = z.infer<typeof leaderboardSchema>; export type Leaderboard = z.infer<typeof leaderboardSchema>;
@@ -42,14 +55,16 @@ export type Leaderboard = z.infer<typeof leaderboardSchema>;
export const emptyLeaderboard: Leaderboard = { export const emptyLeaderboard: Leaderboard = {
drivers: [], drivers: [],
pointsTable: [], pointsTable: [],
flPoints: { points: 1, label: 'FL +1', color: '#8b5cf6', textColor: '#000000', priority: 3 }, flPoints: 1,
flColor: '#8b5cf6',
flTextColor: '#000000',
tracks: [] tracks: []
}; };
// Client-side derived type — never sent to API // Client-side derived type — never sent to API
export type StandingsRow = { export type StandingsRow = {
driver: LeaderboardDriver; driver: LeaderboardDriver;
driverIndex: number; driverId: string;
totalPoints: number; totalPoints: number;
trackResults: ResultValue[]; trackResults: ResultValue[];
fastestLapCount: number; fastestLapCount: number;