mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Compare commits
2 Commits
feat/issue
...
feat/issue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66bf649729 | ||
|
|
2ddc930efe |
@@ -1,6 +1,8 @@
|
|||||||
import type {
|
import type {
|
||||||
ArticleContent,
|
ArticleContent,
|
||||||
|
CarDataSample,
|
||||||
ChampionshipHub,
|
ChampionshipHub,
|
||||||
|
LapsComparisonResponse,
|
||||||
LiveStateResponse,
|
LiveStateResponse,
|
||||||
LiveSessionMeta,
|
LiveSessionMeta,
|
||||||
Meeting,
|
Meeting,
|
||||||
@@ -126,3 +128,32 @@ export async function markNewsRead(articleUrl: string): Promise<void> {
|
|||||||
body: JSON.stringify({ url: articleUrl }),
|
body: JSON.stringify({ url: articleUrl }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTelemetry(
|
||||||
|
sessionKey: number,
|
||||||
|
driverNumber: number,
|
||||||
|
): Promise<CarDataSample[]> {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/telemetry?session_key=${sessionKey}&driver_number=${driverNumber}`,
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
return Array.isArray(data) ? data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchLapsComparison(
|
||||||
|
sessionKey: number,
|
||||||
|
drivers?: number[],
|
||||||
|
): Promise<LapsComparisonResponse> {
|
||||||
|
const params = new URLSearchParams({ session_key: String(sessionKey) })
|
||||||
|
if (drivers?.length) {
|
||||||
|
params.set('drivers', drivers.join(','))
|
||||||
|
}
|
||||||
|
const res = await fetch(`/api/v1/laps/comparison?${params}`)
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API ${res.status}: ${res.statusText}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|||||||
244
frontend/src/components/CompareView.tsx
Normal file
244
frontend/src/components/CompareView.tsx
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { fetchLapsComparison, fetchTelemetry } from '../api'
|
||||||
|
import {
|
||||||
|
buildBestLapTraceSeries,
|
||||||
|
compareDriverOptions,
|
||||||
|
comparisonToDeltaSeries,
|
||||||
|
defaultCompareDriverNumbers,
|
||||||
|
formatPitLapsCaption,
|
||||||
|
} from '../lib/compare'
|
||||||
|
import type { Driver, EnrichedResult } from '../types'
|
||||||
|
import { teamColor } from '../utils'
|
||||||
|
import { DriverCell } from './DriverCell'
|
||||||
|
import { TelemetryTraceChart } from './charts/TelemetryTraceChart'
|
||||||
|
import { DeltaTimeGraph } from './charts/DeltaTimeGraph'
|
||||||
|
import '../styles/compare-view.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
sessionKey: number
|
||||||
|
results: EnrichedResult[]
|
||||||
|
drivers: Driver[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionState({
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
empty,
|
||||||
|
emptyMessage,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
loading: boolean
|
||||||
|
error: Error | null
|
||||||
|
empty: boolean
|
||||||
|
emptyMessage: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <div className="loading-state">loading…</div>
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <div className="error-box">{error.message}</div>
|
||||||
|
}
|
||||||
|
if (empty) {
|
||||||
|
return <div className="missing-notice">{emptyMessage}</div>
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompareView({ sessionKey, results, drivers }: Props) {
|
||||||
|
const driverOptions = useMemo(
|
||||||
|
() => compareDriverOptions(drivers, results),
|
||||||
|
[drivers, results],
|
||||||
|
)
|
||||||
|
|
||||||
|
const initialPair = useMemo(
|
||||||
|
() => defaultCompareDriverNumbers(results, drivers),
|
||||||
|
[results, drivers],
|
||||||
|
)
|
||||||
|
|
||||||
|
const [driverA, setDriverA] = useState<number | null>(initialPair?.[0] ?? null)
|
||||||
|
const [driverB, setDriverB] = useState<number | null>(initialPair?.[1] ?? null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (driverA != null && driverB != null) return
|
||||||
|
const pair = defaultCompareDriverNumbers(results, drivers)
|
||||||
|
if (!pair) return
|
||||||
|
setDriverA(pair[0])
|
||||||
|
setDriverB(pair[1])
|
||||||
|
}, [results, drivers, driverA, driverB])
|
||||||
|
|
||||||
|
const pair = useMemo((): [number, number] | null => {
|
||||||
|
if (driverA == null || driverB == null || driverA === driverB) return null
|
||||||
|
return [driverA, driverB]
|
||||||
|
}, [driverA, driverB])
|
||||||
|
|
||||||
|
const comparisonQuery = useQuery({
|
||||||
|
queryKey: ['laps-comparison', sessionKey, pair?.[0], pair?.[1]],
|
||||||
|
queryFn: () => fetchLapsComparison(sessionKey, pair!),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryAQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[0]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![0]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const telemetryBQuery = useQuery({
|
||||||
|
queryKey: ['telemetry', sessionKey, pair?.[1]],
|
||||||
|
queryFn: () => fetchTelemetry(sessionKey, pair![1]),
|
||||||
|
enabled: pair != null,
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const comparison = comparisonQuery.data
|
||||||
|
const referenceLabel = useMemo(() => {
|
||||||
|
if (!pair) return undefined
|
||||||
|
const meta = comparison?.drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
const session = drivers.find((d) => d.driver_number === pair[0])
|
||||||
|
return meta?.name_acronym || session?.name_acronym
|
||||||
|
}, [pair, comparison, drivers])
|
||||||
|
|
||||||
|
const deltaSeries = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return []
|
||||||
|
return comparisonToDeltaSeries(comparison, pair, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const pitCaption = useMemo(() => {
|
||||||
|
if (!comparison || !pair) return null
|
||||||
|
return formatPitLapsCaption(comparison.pit_laps, pair, comparison, drivers)
|
||||||
|
}, [comparison, pair, drivers])
|
||||||
|
|
||||||
|
const traceSeries = useMemo(() => {
|
||||||
|
if (!pair || !comparison) return []
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const dn of pair) {
|
||||||
|
const comp = comparison.drivers.find((d) => d.driver_number === dn)
|
||||||
|
const session = drivers.find((d) => d.driver_number === dn)
|
||||||
|
const label = comp?.name_acronym || session?.name_acronym || `#${dn}`
|
||||||
|
const color = teamColor(comp?.team_colour || session?.team_colour)
|
||||||
|
|
||||||
|
const carData =
|
||||||
|
dn === pair[0] ? (telemetryAQuery.data ?? []) : (telemetryBQuery.data ?? [])
|
||||||
|
const series = buildBestLapTraceSeries(carData, comp?.laps ?? [], label, color)
|
||||||
|
if (series) out.push(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [pair, comparison, drivers, telemetryAQuery.data, telemetryBQuery.data])
|
||||||
|
|
||||||
|
const telemetryLoading = telemetryAQuery.isLoading || telemetryBQuery.isLoading
|
||||||
|
const telemetryError = telemetryAQuery.error ?? telemetryBQuery.error
|
||||||
|
|
||||||
|
if (driverOptions.length < 2) {
|
||||||
|
return (
|
||||||
|
<div className="missing-notice" data-testid="compare-view-empty">
|
||||||
|
Need at least two drivers in this session to compare.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverAInfo = driverOptions.find((d) => d.driver_number === driverA)
|
||||||
|
const driverBInfo = driverOptions.find((d) => d.driver_number === driverB)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compare-view" data-testid="compare-view">
|
||||||
|
<div className="compare-pickers">
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Reference</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverA ?? ''}
|
||||||
|
onChange={(e) => setDriverA(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-a"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverAInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverAInfo.name_acronym}
|
||||||
|
number={driverAInfo.driver_number}
|
||||||
|
colour={driverAInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="compare-picker">
|
||||||
|
<span className="compare-picker-label">Challenger</span>
|
||||||
|
<select
|
||||||
|
className="compare-picker-select"
|
||||||
|
value={driverB ?? ''}
|
||||||
|
onChange={(e) => setDriverB(Number(e.target.value))}
|
||||||
|
data-testid="compare-picker-b"
|
||||||
|
>
|
||||||
|
{driverOptions.map((d) => (
|
||||||
|
<option key={d.driver_number} value={d.driver_number}>
|
||||||
|
{d.name_acronym} · #{d.driver_number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{driverBInfo && (
|
||||||
|
<DriverCell
|
||||||
|
acronym={driverBInfo.name_acronym}
|
||||||
|
number={driverBInfo.driver_number}
|
||||||
|
colour={driverBInfo.team_colour}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{driverA === driverB && (
|
||||||
|
<div className="analysis-notice">
|
||||||
|
<strong>Pick two different drivers</strong> to run a comparison.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-telemetry-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Best lap telemetry</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Speed, throttle, and brake traces for each driver's fastest lap
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={telemetryLoading}
|
||||||
|
error={telemetryError instanceof Error ? telemetryError : null}
|
||||||
|
empty={!telemetryLoading && !telemetryError && traceSeries.length === 0}
|
||||||
|
emptyMessage="No telemetry samples for the best laps. Car data may not be available for this session."
|
||||||
|
>
|
||||||
|
<TelemetryTraceChart series={traceSeries} />
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="compare-section" data-testid="compare-pace-section">
|
||||||
|
<div>
|
||||||
|
<div className="compare-section-title">Race pace</div>
|
||||||
|
<div className="compare-section-meta">
|
||||||
|
Cumulative lap-time delta vs {referenceLabel ?? 'reference'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SectionState
|
||||||
|
loading={comparisonQuery.isLoading}
|
||||||
|
error={comparisonQuery.error instanceof Error ? comparisonQuery.error : null}
|
||||||
|
empty={!comparisonQuery.isLoading && !comparisonQuery.error && deltaSeries.length < 2}
|
||||||
|
emptyMessage="No lap comparison data for the selected drivers."
|
||||||
|
>
|
||||||
|
<DeltaTimeGraph series={deltaSeries} referenceLabel={referenceLabel} />
|
||||||
|
{pitCaption && (
|
||||||
|
<p className="compare-pit-caption" data-testid="compare-pit-caption">
|
||||||
|
{pitCaption}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SectionState>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export type Tab =
|
|||||||
| 'overview'
|
| 'overview'
|
||||||
| 'race_story'
|
| 'race_story'
|
||||||
| 'strategy'
|
| 'strategy'
|
||||||
|
| 'compare'
|
||||||
| 'lap_data'
|
| 'lap_data'
|
||||||
| 'conditions'
|
| 'conditions'
|
||||||
| 'race_control'
|
| 'race_control'
|
||||||
@@ -11,6 +12,7 @@ const TABS: { id: Tab; label: string }[] = [
|
|||||||
{ id: 'overview', label: 'Overview' },
|
{ id: 'overview', label: 'Overview' },
|
||||||
{ id: 'race_story', label: 'Race Story' },
|
{ id: 'race_story', label: 'Race Story' },
|
||||||
{ id: 'strategy', label: 'Strategy' },
|
{ id: 'strategy', label: 'Strategy' },
|
||||||
|
{ id: 'compare', label: 'Compare' },
|
||||||
{ id: 'lap_data', label: 'Lap Data' },
|
{ id: 'lap_data', label: 'Lap Data' },
|
||||||
{ id: 'conditions', label: 'Conditions' },
|
{ id: 'conditions', label: 'Conditions' },
|
||||||
{ id: 'race_control', label: 'Race Control' },
|
{ id: 'race_control', label: 'Race Control' },
|
||||||
|
|||||||
202
frontend/src/lib/compare.ts
Normal file
202
frontend/src/lib/compare.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
// Driver comparison mapping — pure functions for telemetry traces and lap deltas.
|
||||||
|
|
||||||
|
import type { TelemetryTraceSeries } from '../components/charts/TelemetryTraceChart'
|
||||||
|
import type { DeltaSeries } from '../lib/delta'
|
||||||
|
import type {
|
||||||
|
CarDataSample,
|
||||||
|
ComparisonLap,
|
||||||
|
Driver,
|
||||||
|
EnrichedResult,
|
||||||
|
LapsComparisonResponse,
|
||||||
|
} from '../types'
|
||||||
|
import { compareFinishPosition, teamColor } from '../utils'
|
||||||
|
|
||||||
|
function sortDriversByResults(drivers: Driver[], results: EnrichedResult[]): Driver[] {
|
||||||
|
const order = new Map(
|
||||||
|
[...results]
|
||||||
|
.filter((r) => r.position > 0)
|
||||||
|
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||||
|
.map((r, i) => [r.driver_number, i]),
|
||||||
|
)
|
||||||
|
return [...drivers].sort((a, b) => {
|
||||||
|
const ao = order.get(a.driver_number) ?? 999
|
||||||
|
const bo = order.get(b.driver_number) ?? 999
|
||||||
|
if (ao !== bo) return ao - bo
|
||||||
|
return a.driver_number - b.driver_number
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default compare pair: top two classified finishers, else first two session drivers. */
|
||||||
|
export function defaultCompareDriverNumbers(
|
||||||
|
results: EnrichedResult[],
|
||||||
|
drivers: Driver[],
|
||||||
|
): [number, number] | null {
|
||||||
|
const classified = [...results]
|
||||||
|
.filter((r) => r.position > 0)
|
||||||
|
.sort((a, b) => compareFinishPosition(a.position, b.position))
|
||||||
|
|
||||||
|
if (classified.length >= 2) {
|
||||||
|
return [classified[0].driver_number, classified[1].driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drivers.length >= 2) {
|
||||||
|
const sorted = sortDriversByResults(drivers, results)
|
||||||
|
return [sorted[0].driver_number, sorted[1].driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (classified.length === 1 && drivers.length >= 1) {
|
||||||
|
const other = drivers.find((d) => d.driver_number !== classified[0].driver_number)
|
||||||
|
if (other) return [classified[0].driver_number, other.driver_number]
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lap records → per-lap duration array (index 0 = lap 1); null for pit/out laps. */
|
||||||
|
export function lapsToLapTimes(laps: ComparisonLap[]): (number | null)[] {
|
||||||
|
if (laps.length === 0) return []
|
||||||
|
|
||||||
|
const maxLap = laps.reduce((max, lap) => Math.max(max, lap.lap_number), 0)
|
||||||
|
const times: (number | null)[] = Array.from({ length: maxLap }, () => null)
|
||||||
|
|
||||||
|
for (const lap of laps) {
|
||||||
|
const idx = lap.lap_number - 1
|
||||||
|
if (idx < 0) continue
|
||||||
|
if (lap.is_pit_out_lap || lap.lap_duration == null || lap.lap_duration <= 0) {
|
||||||
|
times[idx] = null
|
||||||
|
} else {
|
||||||
|
times[idx] = lap.lap_duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return times
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBestLap(laps: ComparisonLap[]): ComparisonLap | null {
|
||||||
|
let best: ComparisonLap | null = null
|
||||||
|
for (const lap of laps) {
|
||||||
|
if (lap.lap_duration == null || lap.lap_duration <= 0) continue
|
||||||
|
if (!best || (best.lap_duration ?? Infinity) > lap.lap_duration) {
|
||||||
|
best = lap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep car-data samples whose timestamps fall within a lap window. */
|
||||||
|
export function filterCarDataToLap(
|
||||||
|
samples: CarDataSample[],
|
||||||
|
lap: ComparisonLap,
|
||||||
|
): CarDataSample[] {
|
||||||
|
if (!lap.date_start || samples.length === 0) return samples
|
||||||
|
|
||||||
|
const start = new Date(lap.date_start).getTime()
|
||||||
|
if (!Number.isFinite(start)) return samples
|
||||||
|
|
||||||
|
const end = start + (lap.lap_duration ?? 0) * 1000 + 500
|
||||||
|
|
||||||
|
return samples.filter((s) => {
|
||||||
|
const t = new Date(s.date).getTime()
|
||||||
|
return Number.isFinite(t) && t >= start && t <= end
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function carDataToTraceSeries(
|
||||||
|
samples: CarDataSample[],
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
): TelemetryTraceSeries {
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
color,
|
||||||
|
samples: samples.map((s) => ({
|
||||||
|
speed: s.speed,
|
||||||
|
throttle: s.throttle,
|
||||||
|
brake: s.brake,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildBestLapTraceSeries(
|
||||||
|
carData: CarDataSample[],
|
||||||
|
laps: ComparisonLap[],
|
||||||
|
label: string,
|
||||||
|
color: string,
|
||||||
|
): TelemetryTraceSeries | null {
|
||||||
|
const best = findBestLap(laps)
|
||||||
|
if (!best) return null
|
||||||
|
|
||||||
|
const filtered = filterCarDataToLap(carData, best)
|
||||||
|
if (filtered.length === 0) return null
|
||||||
|
|
||||||
|
return carDataToTraceSeries(filtered, label, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
function driverMeta(
|
||||||
|
driverNumber: number,
|
||||||
|
comparison: LapsComparisonResponse | undefined,
|
||||||
|
drivers: Driver[],
|
||||||
|
): { label: string; color: string; laps: ComparisonLap[] } {
|
||||||
|
const comp = comparison?.drivers.find((d) => d.driver_number === driverNumber)
|
||||||
|
const session = drivers.find((d) => d.driver_number === driverNumber)
|
||||||
|
return {
|
||||||
|
label: comp?.name_acronym || session?.name_acronym || `#${driverNumber}`,
|
||||||
|
color: teamColor(comp?.team_colour || session?.team_colour),
|
||||||
|
laps: comp?.laps ?? [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function comparisonToDeltaSeries(
|
||||||
|
comparison: LapsComparisonResponse,
|
||||||
|
driverNumbers: [number, number],
|
||||||
|
drivers: Driver[],
|
||||||
|
): DeltaSeries[] {
|
||||||
|
return driverNumbers.map((dn) => {
|
||||||
|
const meta = driverMeta(dn, comparison, drivers)
|
||||||
|
return {
|
||||||
|
label: meta.label,
|
||||||
|
color: meta.color,
|
||||||
|
lapTimes: lapsToLapTimes(meta.laps),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPitLapsCaption(
|
||||||
|
pitLaps: Record<string, number[]>,
|
||||||
|
driverNumbers: [number, number],
|
||||||
|
comparison: LapsComparisonResponse | undefined,
|
||||||
|
drivers: Driver[],
|
||||||
|
): string | null {
|
||||||
|
const parts: string[] = []
|
||||||
|
|
||||||
|
for (const dn of driverNumbers) {
|
||||||
|
const laps = pitLaps[String(dn)]
|
||||||
|
if (!laps?.length) continue
|
||||||
|
const meta = driverMeta(dn, comparison, drivers)
|
||||||
|
parts.push(`${meta.label}: L${laps.join(', L')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? `Pit stops — ${parts.join(' · ')}` : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareDriverOptions(
|
||||||
|
drivers: Driver[],
|
||||||
|
results: EnrichedResult[],
|
||||||
|
): Driver[] {
|
||||||
|
if (drivers.length > 0) {
|
||||||
|
return sortDriversByResults(drivers, results)
|
||||||
|
}
|
||||||
|
return results.map((r) => ({
|
||||||
|
driver_number: r.driver_number,
|
||||||
|
name_acronym: r.name_acronym,
|
||||||
|
full_name: r.full_name,
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
team_name: r.team_name,
|
||||||
|
team_colour: r.team_colour,
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: r.full_name,
|
||||||
|
session_key: r.session_key,
|
||||||
|
meeting_key: r.meeting_key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { TabBar, type Tab } from '../components/TabBar'
|
|||||||
import { DatasetStatusView } from '../components/DatasetStatusView'
|
import { DatasetStatusView } from '../components/DatasetStatusView'
|
||||||
import { StrategyView } from '../components/StrategyView'
|
import { StrategyView } from '../components/StrategyView'
|
||||||
import { LapsView } from '../components/LapsView'
|
import { LapsView } from '../components/LapsView'
|
||||||
|
import { CompareView } from '../components/CompareView'
|
||||||
import { RaceControlView } from '../components/RaceControlView'
|
import { RaceControlView } from '../components/RaceControlView'
|
||||||
import { WeatherView } from '../components/WeatherView'
|
import { WeatherView } from '../components/WeatherView'
|
||||||
import { OverviewView } from '../components/OverviewView'
|
import { OverviewView } from '../components/OverviewView'
|
||||||
@@ -307,6 +308,19 @@ export function RaceHubPage({ sessionKey }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'compare' && (
|
||||||
|
<div className="data-section">
|
||||||
|
<div className="sec-header">
|
||||||
|
<span className="sec-title">Driver Compare</span>
|
||||||
|
</div>
|
||||||
|
<CompareView
|
||||||
|
sessionKey={sessionKey}
|
||||||
|
results={data.results}
|
||||||
|
drivers={data.drivers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'lap_data' && (
|
{activeTab === 'lap_data' && (
|
||||||
<div className="data-section">
|
<div className="data-section">
|
||||||
<div className="sec-header">
|
<div className="sec-header">
|
||||||
|
|||||||
78
frontend/src/styles/compare-view.css
Normal file
78
frontend/src/styles/compare-view.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
.compare-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pickers {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s4);
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-label {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-select {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-picker-select:focus {
|
||||||
|
outline: 2px solid var(--gp-accent, var(--red));
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section-title {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-section-meta {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-pit-caption {
|
||||||
|
font-family: var(--f-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compare-stale-notice {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--amber, #f59e0b);
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
border-radius: var(--r1);
|
||||||
|
padding: var(--s2) var(--s3);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ describe('TabBar', () => {
|
|||||||
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Story' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Strategy' })).toBeInTheDocument()
|
||||||
|
expect(screen.getByRole('tab', { name: 'Compare' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Lap Data' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Conditions' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
expect(screen.getByRole('tab', { name: 'Race Control' })).toBeInTheDocument()
|
||||||
|
|||||||
341
frontend/src/test/compare-view.test.tsx
Normal file
341
frontend/src/test/compare-view.test.tsx
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { CompareView } from '../components/CompareView'
|
||||||
|
import type { Driver, EnrichedResult, LapsComparisonResponse } from '../types'
|
||||||
|
import {
|
||||||
|
buildBestLapTraceSeries,
|
||||||
|
carDataToTraceSeries,
|
||||||
|
comparisonToDeltaSeries,
|
||||||
|
defaultCompareDriverNumbers,
|
||||||
|
filterCarDataToLap,
|
||||||
|
findBestLap,
|
||||||
|
formatPitLapsCaption,
|
||||||
|
lapsToLapTimes,
|
||||||
|
} from '../lib/compare'
|
||||||
|
import { computeCumulativeDeltas } from '../lib/delta'
|
||||||
|
|
||||||
|
vi.mock('../api', () => ({
|
||||||
|
fetchTelemetry: vi.fn(),
|
||||||
|
fetchLapsComparison: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { fetchTelemetry, fetchLapsComparison } from '../api'
|
||||||
|
|
||||||
|
const mockFetchTelemetry = vi.mocked(fetchTelemetry)
|
||||||
|
const mockFetchLapsComparison = vi.mocked(fetchLapsComparison)
|
||||||
|
|
||||||
|
const results: EnrichedResult[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
position: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: null,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 25,
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 44,
|
||||||
|
position: 2,
|
||||||
|
name_acronym: 'HAM',
|
||||||
|
full_name: 'Lewis Hamilton',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'E8002D',
|
||||||
|
dnf: false,
|
||||||
|
dns: false,
|
||||||
|
dsq: false,
|
||||||
|
duration: null,
|
||||||
|
gap_to_leader: 5.1,
|
||||||
|
number_of_laps: 78,
|
||||||
|
points: 18,
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const drivers: Driver[] = [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
full_name: 'Max Verstappen',
|
||||||
|
first_name: 'Max',
|
||||||
|
last_name: 'Verstappen',
|
||||||
|
team_name: 'Red Bull Racing',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: 'M VERSTAPPEN',
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 44,
|
||||||
|
name_acronym: 'HAM',
|
||||||
|
full_name: 'Lewis Hamilton',
|
||||||
|
first_name: 'Lewis',
|
||||||
|
last_name: 'Hamilton',
|
||||||
|
team_name: 'Ferrari',
|
||||||
|
team_colour: 'E8002D',
|
||||||
|
headshot_url: '',
|
||||||
|
broadcast_name: 'L HAMILTON',
|
||||||
|
session_key: 9472,
|
||||||
|
meeting_key: 1229,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const comparison: LapsComparisonResponse = {
|
||||||
|
session_key: 9472,
|
||||||
|
sc_periods: [],
|
||||||
|
pit_laps: { '44': [19, 40] },
|
||||||
|
drivers: [
|
||||||
|
{
|
||||||
|
driver_number: 1,
|
||||||
|
name_acronym: 'VER',
|
||||||
|
team_colour: '3671C6',
|
||||||
|
laps: [
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 1,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 1,
|
||||||
|
date_start: '2025-05-25T13:00:00.000Z',
|
||||||
|
lap_duration: 92.1,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 1,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 2,
|
||||||
|
date_start: '2025-05-25T13:01:32.100Z',
|
||||||
|
lap_duration: 90.5,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
driver_number: 44,
|
||||||
|
name_acronym: 'HAM',
|
||||||
|
team_colour: 'E8002D',
|
||||||
|
laps: [
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 44,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 1,
|
||||||
|
date_start: '2025-05-25T13:00:01.000Z',
|
||||||
|
lap_duration: 93.0,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 44,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 2,
|
||||||
|
date_start: '2025-05-25T13:01:34.000Z',
|
||||||
|
lap_duration: null,
|
||||||
|
is_pit_out_lap: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
session_key: 9472,
|
||||||
|
driver_number: 44,
|
||||||
|
meeting_key: 1229,
|
||||||
|
lap_number: 3,
|
||||||
|
date_start: '2025-05-25T13:03:10.000Z',
|
||||||
|
lap_duration: 91.2,
|
||||||
|
is_pit_out_lap: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompareView() {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<CompareView sessionKey={9472} results={results} drivers={drivers} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('compare mapping helpers', () => {
|
||||||
|
it('defaults to top two finishers', () => {
|
||||||
|
expect(defaultCompareDriverNumbers(results, drivers)).toEqual([1, 44])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps comparison laps to delta series with null pit laps', () => {
|
||||||
|
const series = comparisonToDeltaSeries(comparison, [1, 44], drivers)
|
||||||
|
expect(series).toHaveLength(2)
|
||||||
|
expect(series[0].label).toBe('VER')
|
||||||
|
expect(series[1].lapTimes[1]).toBeNull()
|
||||||
|
|
||||||
|
const deltas = computeCumulativeDeltas(series, 'VER')
|
||||||
|
expect(deltas[0].deltas[0]).toBeCloseTo(0.9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps car data to trace series', () => {
|
||||||
|
const trace = carDataToTraceSeries(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
speed: 300,
|
||||||
|
throttle: 100,
|
||||||
|
brake: 0,
|
||||||
|
date: '',
|
||||||
|
driver_number: 1,
|
||||||
|
drs: 0,
|
||||||
|
meeting_key: 1,
|
||||||
|
n_gear: 8,
|
||||||
|
rpm: 12000,
|
||||||
|
session_key: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'VER',
|
||||||
|
'#3671C6',
|
||||||
|
)
|
||||||
|
expect(trace.samples[0]).toEqual({ speed: 300, throttle: 100, brake: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters car data to best lap window', () => {
|
||||||
|
const best = findBestLap(comparison.drivers[0].laps)
|
||||||
|
expect(best?.lap_number).toBe(2)
|
||||||
|
|
||||||
|
const samples = [
|
||||||
|
{
|
||||||
|
date: '2025-05-25T13:01:32.100Z',
|
||||||
|
speed: 280,
|
||||||
|
throttle: 90,
|
||||||
|
brake: 0,
|
||||||
|
driver_number: 1,
|
||||||
|
drs: 0,
|
||||||
|
meeting_key: 1229,
|
||||||
|
n_gear: 7,
|
||||||
|
rpm: 11000,
|
||||||
|
session_key: 9472,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: '2025-05-25T13:00:00.000Z',
|
||||||
|
speed: 200,
|
||||||
|
throttle: 50,
|
||||||
|
brake: 10,
|
||||||
|
driver_number: 1,
|
||||||
|
drs: 0,
|
||||||
|
meeting_key: 1229,
|
||||||
|
n_gear: 4,
|
||||||
|
rpm: 9000,
|
||||||
|
session_key: 9472,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const filtered = filterCarDataToLap(samples, best!)
|
||||||
|
expect(filtered).toHaveLength(1)
|
||||||
|
expect(filtered[0].speed).toBe(280)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds best-lap trace series from car data and laps', () => {
|
||||||
|
const series = buildBestLapTraceSeries(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
date: '2025-05-25T13:01:33.000Z',
|
||||||
|
speed: 310,
|
||||||
|
throttle: 100,
|
||||||
|
brake: 0,
|
||||||
|
driver_number: 1,
|
||||||
|
drs: 10,
|
||||||
|
meeting_key: 1229,
|
||||||
|
n_gear: 8,
|
||||||
|
rpm: 12000,
|
||||||
|
session_key: 9472,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
comparison.drivers[0].laps,
|
||||||
|
'VER',
|
||||||
|
'#3671C6',
|
||||||
|
)
|
||||||
|
expect(series?.samples).toHaveLength(1)
|
||||||
|
expect(series?.samples[0].speed).toBe(310)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps laps to lap time arrays with nulls', () => {
|
||||||
|
expect(lapsToLapTimes(comparison.drivers[1].laps)).toEqual([93.0, null, 91.2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('formats pit lap captions', () => {
|
||||||
|
expect(formatPitLapsCaption(comparison.pit_laps, [1, 44], comparison, drivers)).toBe(
|
||||||
|
'Pit stops — HAM: L19, L40',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CompareView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockFetchLapsComparison.mockResolvedValue(comparison)
|
||||||
|
mockFetchTelemetry.mockResolvedValue([
|
||||||
|
{
|
||||||
|
date: '2025-05-25T13:01:33.000Z',
|
||||||
|
speed: 310,
|
||||||
|
throttle: 100,
|
||||||
|
brake: 0,
|
||||||
|
driver_number: 1,
|
||||||
|
drs: 10,
|
||||||
|
meeting_key: 1229,
|
||||||
|
n_gear: 8,
|
||||||
|
rpm: 12000,
|
||||||
|
session_key: 9472,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders pickers defaulting to top two finishers', async () => {
|
||||||
|
renderCompareView()
|
||||||
|
expect(screen.getByTestId('compare-picker-a')).toHaveValue('1')
|
||||||
|
expect(screen.getByTestId('compare-picker-b')).toHaveValue('44')
|
||||||
|
expect(screen.getAllByText('VER').length).toBeGreaterThan(0)
|
||||||
|
expect(screen.getAllByText('HAM').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders telemetry and pace sections with mocked queries', async () => {
|
||||||
|
renderCompareView()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchLapsComparison).toHaveBeenCalledWith(9472, [1, 44])
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('telemetry-trace')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('delta-time-graph')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('compare-pit-caption')).toHaveTextContent('HAM: L19, L40')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows error state when comparison fetch fails', async () => {
|
||||||
|
mockFetchLapsComparison.mockRejectedValue(new Error('comparison unavailable'))
|
||||||
|
renderCompareView()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('comparison unavailable')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when fewer than two drivers', () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={client}>
|
||||||
|
<CompareView sessionKey={9472} results={[]} drivers={[drivers[0]]} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
expect(screen.getByTestId('compare-view-empty')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -158,6 +158,37 @@ export interface Lap {
|
|||||||
is_pit_out_lap: boolean
|
is_pit_out_lap: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CarDataSample {
|
||||||
|
brake: number
|
||||||
|
date: string
|
||||||
|
driver_number: number
|
||||||
|
drs: number
|
||||||
|
meeting_key: number
|
||||||
|
n_gear: number
|
||||||
|
rpm: number
|
||||||
|
session_key: number
|
||||||
|
speed: number
|
||||||
|
throttle: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonLap extends Lap {
|
||||||
|
compound?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComparisonDriver {
|
||||||
|
driver_number: number
|
||||||
|
name_acronym: string
|
||||||
|
team_colour: string
|
||||||
|
laps: ComparisonLap[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LapsComparisonResponse {
|
||||||
|
session_key: number
|
||||||
|
sc_periods: unknown[]
|
||||||
|
pit_laps: Record<string, number[]>
|
||||||
|
drivers: ComparisonDriver[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface WeekendSession {
|
export interface WeekendSession {
|
||||||
session: Session
|
session: Session
|
||||||
source: 'local' | 'partial' | 'none' | 'cancelled'
|
source: 'local' | 'partial' | 'none' | 'cancelled'
|
||||||
|
|||||||
Reference in New Issue
Block a user