Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6ecdfa8
feat(training): add training timing page with hooks and components
Copilot Apr 8, 2026
058aab6
merge: integrate main branch (driver management) and resolve conflicts
Copilot Apr 8, 2026
7ead418
fix: remove unused ButtonGroup import in training page; add i18n files
Copilot Apr 8, 2026
c02a46d
fix: address code review feedback (JSDoc, locale in date formatting, …
Copilot Apr 8, 2026
17f1049
fix: add correct icons
timderes Apr 8, 2026
f513dd6
chore: run format
timderes Apr 8, 2026
b84d8ba
feat(training): live timing redesign — F1 colors, gaps, fault buttons…
Copilot Apr 8, 2026
bd95cbb
feat: bug fix, tests, weather input, decrement faults, class rankings…
Copilot Apr 8, 2026
68116a3
feat: show milliseconds (3 digits) instead of centiseconds (2 digits)…
Copilot Apr 8, 2026
813f528
fix: add createdAt index to trainings table (v3 schema) to fix Dexie …
Copilot Apr 8, 2026
09c2628
chore: run format
timderes Apr 8, 2026
635157d
fix: SessionLog component
timderes Apr 8, 2026
8615a87
fix: removed unnecessary type assertions
timderes Apr 8, 2026
d7c45d0
fix: handle PDF export load errors and tidy imports
timderes Apr 8, 2026
dca93e8
fix: standardize color spelling
timderes Apr 8, 2026
7005d61
fix: german translation typo in training.json
timderes Apr 8, 2026
c0a7580
fix: improved german translations in training.json
timderes Apr 8, 2026
ee7b601
fix: removed not used mode prop
timderes Apr 9, 2026
a4a5785
chore: removed no longer needed dummy test case
timderes Apr 9, 2026
44156e8
feat: add mode-specific penalty calculation
timderes Apr 9, 2026
19591c4
chore: remove variant='light' from Mantine components
timderes Apr 10, 2026
ceb0f47
fix: Swap fault icons; remove header and '×'
timderes Apr 10, 2026
de18855
fix: remove session elapsed display from stopwatch
timderes Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@
"electron-log": "^5.4.3",
"electron-serve": "^3.0.1",
"electron-store": "^11.0.2",
"electron-updater": "^6.8.3"
"electron-updater": "^6.8.3",
"react-use-precision-timer": "^3.5.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
Expand Down
171 changes: 166 additions & 5 deletions renderer/components/content/driver/DriverDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,57 @@
import getDriverClass from "@/lib/driver/getDriverClass";
import { Badge, Button, Group, Stack, Text, Title } from "@mantine/core";
import {
Accordion,
Badge,
Button,
Divider,
Group,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { IconTrophy } from "@tabler/icons-react";
import {
formatTime,
getDriverBestLap,
getOverallBestLapTime,
} from "@utils/timing";
import { useTranslation } from "next-i18next";
import { useRouter } from "next/router";

type DriverDetailProps = {
driver: Driver;
clubName?: string;
trainings?: Training[];
};

/**
* Displays detailed information about a driver in a read-only format.
* Used on the driver view page.
* Also shows training history if `trainings` are provided.
*/
const DriverDetail = ({ driver, clubName }: DriverDetailProps) => {
const DriverDetail = ({
driver,
clubName,
trainings = [],
}: DriverDetailProps) => {
const {
t,
i18n: { language: locale },
} = useTranslation(["common", "driver"]);
} = useTranslation(["common", "driver", "training"]);
const router = useRouter();

const { JKS, SKS } = getDriverClass(driver.birthDate);

const formattedBirthDate = new Date(driver.birthDate).toLocaleDateString(
undefined,
locale,
{ day: "2-digit", month: "2-digit", year: "numeric" },
);

// Filter to trainings where this driver actually participated
const driverTrainings = trainings.filter((tr) =>
tr.drivers.some((d) => d.uuid === driver.uuid),
);

return (
<Stack>
<Title order={2}>{driver.name}</Title>
Expand Down Expand Up @@ -97,6 +123,141 @@ const DriverDetail = ({ driver, clubName }: DriverDetailProps) => {
</Group>
</Stack>

{/* Training History */}
{driverTrainings.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} fz="sm" tt="uppercase" c="dimmed">
{t("training:driverHistory.title")}
</Text>
<Accordion variant="separated" radius="md">
{driverTrainings.map((training) => {
const driverData = training.drivers.find(
(d) => d.uuid === driver.uuid,
);
const personalBest = driverData
? getDriverBestLap(driverData)
: null;
const overallBest = getOverallBestLapTime(training.drivers);
const isSessionBest =
personalBest !== null &&
overallBest !== null &&
personalBest === overallBest;

const date = new Date(training.createdAt).toLocaleDateString(
locale,
{ day: "2-digit", month: "2-digit", year: "numeric" },
);

return (
<Accordion.Item key={training.uuid} value={training.uuid}>
<Accordion.Control>
<Group justify="space-between" pr="sm">
<Group gap="xs">
<Badge size="sm">{training.mode}</Badge>
<Text fz="sm">{date}</Text>
{training.location && (
<Text fz="sm" c="dimmed">
{training.location}
</Text>
)}
</Group>
{personalBest !== null && (
<Group gap={4} wrap="nowrap">
{isSessionBest && (
<IconTrophy size={14} color="gold" />
)}
<Badge
ff="monospace"
color={isSessionBest ? "grape" : "green"}
size="sm"
>
{formatTime(personalBest)}
</Badge>
</Group>
)}
</Group>
</Accordion.Control>
<Accordion.Panel>
{driverData && driverData.stints.length > 0 ? (
<Table fz="xs" withColumnBorders withTableBorder>
<Table.Thead>
<Table.Tr>
<Table.Th>{t("training:stints")}</Table.Th>
<Table.Th>{t("training:lapNumber")}</Table.Th>
<Table.Th>{t("training:lapTime")}</Table.Th>
<Table.Th>{t("training:pylonFaults")}</Table.Th>
<Table.Th>{t("training:gateFaults")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{driverData.stints.flatMap((stint, si) =>
stint.laps.map((lap, li) => (
<Table.Tr key={`${si}-${li}`}>
<Table.Td>{si + 1}</Table.Td>
<Table.Td>{li + 1}</Table.Td>
<Table.Td>
<Badge
ff="monospace"
size="sm"
variant={
personalBest !== null &&
lap.time_with_penalties === personalBest
? "light"
: "outline"
}
color={
personalBest !== null &&
lap.time_with_penalties === personalBest
? "green"
: "gray"
}
>
{formatTime(lap.time_with_penalties)}
</Badge>
</Table.Td>
<Table.Td>
{lap.pylonFaults > 0
? `${lap.pylonFaults} (+${lap.pylonFaults * 3}s)`
: "—"}
</Table.Td>
<Table.Td>
{lap.gateFaults > 0
? `${lap.gateFaults} (+${lap.gateFaults * 10}s)`
: "—"}
</Table.Td>
</Table.Tr>
)),
)}
</Table.Tbody>
</Table>
) : (
<Text c="dimmed" fz="sm">
{t("training:noLaps")}
</Text>
)}
<Group mt="sm" justify="flex-end">
<Button
size="compact-xs"
onClick={() =>
void router.push(
`/${locale}/training/view?uuid=${training.uuid}`,
)
}
>
{t("training:list.view")}
</Button>
</Group>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</Stack>
</>
)}

<Group mt="md">
<Button
onClick={() =>
Expand Down
120 changes: 120 additions & 0 deletions renderer/components/content/training/DriverQueue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
Badge,
Group,
Paper,
ScrollArea,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconChevronRight, IconUser } from "@tabler/icons-react";
import { formatGap, formatTime, getDriverBestLap } from "@utils/timing";
import { useTranslation } from "next-i18next";

type DriverQueueProps = {
drivers: DriverWithStints[];
currentDriverIndex: number;
overallBestTime: number | null;
};

/**
* Starting-order driver list with live personal best times.
* Colors follow F1 conventions:
* - Grape / purple = overall session best
* - Green = driver personal best (not session best)
* - Gray = no time set yet
*/
const DriverQueue = ({
drivers,
currentDriverIndex,
overallBestTime,
}: DriverQueueProps) => {
const { t } = useTranslation("training");

if (drivers.length === 0) {
return (
<Paper withBorder p="md" radius="md">
<Text c="dimmed" fz="sm" ta="center">
{t("noDrivers")}
</Text>
</Paper>
);
}

return (
<ScrollArea>
<Stack gap="xs">
{drivers.map((driver, index) => {
const isCurrent = index === currentDriverIndex;
const bestLap = getDriverBestLap(driver);
const isSessionBest =
bestLap !== null &&
overallBestTime !== null &&
bestLap === overallBestTime;

const timeColor = isSessionBest
? "grape"
: bestLap !== null
? "green"
: "gray";

return (
<Paper
key={driver.uuid}
withBorder
p="sm"
radius="md"
bg={isCurrent ? "blue.0" : undefined}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm" wrap="nowrap">
<ThemeIcon
size="sm"
variant={isCurrent ? "filled" : "light"}
color={isCurrent ? "blue" : "gray"}
radius="xl"
>
{isCurrent ? (
<IconChevronRight size={12} />
) : (
<IconUser size={12} />
)}
</ThemeIcon>
<Stack gap={0}>
<Text fw={isCurrent ? 700 : 400} fz="sm">
{driver.name}
</Text>
<Text fz="xs" c="dimmed">
{t("stints")}: {driver.stints.length}
</Text>
</Stack>
</Group>

<Stack gap={2} align="flex-end">
{bestLap !== null ? (
<>
<Badge color={timeColor} ff="monospace" size="sm">
{formatTime(bestLap)}
</Badge>
{overallBestTime !== null && (
<Text fz="xs" c="dimmed" ff="monospace">
{formatGap(bestLap, overallBestTime)}
</Text>
)}
</>
) : (
<Badge color="gray" size="sm">
</Badge>
)}
</Stack>
</Group>
</Paper>
);
})}
</Stack>
</ScrollArea>
);
};

export default DriverQueue;
Loading
Loading