feat: align frontend with rusty-telemetry v0.5.0 API changes

- Update ShiftPointAnalysis types: gear_ranges → gear_curves (with
  acceleration data points), shift_events → crossover_points, add
  runs_analyzed field
- Add UseCaseInfo type for GET /api/use-cases (now returns objects
  with id, name, guidance instead of plain strings)
- Add guidance and recording_id fields to UseCaseSession
- Rewrite ShiftPointsView: scatter chart for gear acceleration curves,
  dual y-axis bar chart for crossover RPM/speed, 4-column summary
- Update SessionDetailView: show session guidance, crossover points
  and runs analyzed in analysis stats
- Extract shared type guard functions into src/utils/analysis.ts
- Type getAnalysis/reAnalyze as AnalysisResult instead of
  Record<string, unknown>
- Type getGames return as string[] to match backend
This commit is contained in:
Jan-Peter von Hunnius 2026-06-05 21:28:27 +02:00
parent aa6293d346
commit 87e5bcae5b
6 changed files with 156 additions and 114 deletions

View file

@ -7,8 +7,8 @@ import type {
CreateSessionRequest, CreateSessionRequest,
StartRunRequest, StartRunRequest,
SessionRun, SessionRun,
UseCase, UseCaseInfo,
Game, AnalysisResult,
} from '@/types' } from '@/types'
const api = axios.create({ const api = axios.create({
@ -93,25 +93,25 @@ export async function completeSession(id: string): Promise<UseCaseSession> {
return res.data return res.data
} }
export async function getAnalysis(id: string): Promise<Record<string, unknown>> { export async function getAnalysis(id: string): Promise<AnalysisResult> {
const res = await api.get<Record<string, unknown>>(`/sessions/${id}/analysis`) const res = await api.get<AnalysisResult>(`/sessions/${id}/analysis`)
return res.data return res.data
} }
export async function reAnalyze(id: string): Promise<Record<string, unknown>> { export async function reAnalyze(id: string): Promise<AnalysisResult> {
const res = await api.post<Record<string, unknown>>(`/sessions/${id}/analyze`) const res = await api.post<AnalysisResult>(`/sessions/${id}/analyze`)
return res.data return res.data
} }
// ── Metadata ── // ── Metadata ──
export async function getUseCases(): Promise<UseCase[]> { export async function getUseCases(): Promise<UseCaseInfo[]> {
const res = await api.get<UseCase[]>('/use-cases') const res = await api.get<UseCaseInfo[]>('/use-cases')
return res.data return res.data
} }
export async function getGames(): Promise<Game[]> { export async function getGames(): Promise<string[]> {
const res = await api.get<Game[]>('/games') const res = await api.get<string[]>('/games')
return res.data return res.data
} }

View file

@ -1,6 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun } from '@/types' import type { UseCaseSession, CreateSessionRequest, StartRunRequest, SessionRun, AnalysisResult } from '@/types'
import * as api from '@/api/client' import * as api from '@/api/client'
export const useSessionsStore = defineStore('sessions', () => { export const useSessionsStore = defineStore('sessions', () => {
@ -110,7 +110,7 @@ export const useSessionsStore = defineStore('sessions', () => {
} }
} }
async function reAnalyze(id: string): Promise<Record<string, unknown> | null> { async function reAnalyze(id: string): Promise<AnalysisResult | null> {
try { try {
return await api.reAnalyze(id) return await api.reAnalyze(id)
} catch (e: unknown) { } catch (e: unknown) {

View file

@ -100,6 +100,8 @@ export interface UseCaseSession {
completed_at: string | null completed_at: string | null
runs: SessionRun[] runs: SessionRun[]
analysis: AnalysisResult | null analysis: AnalysisResult | null
guidance: string | null
recording_id: string | null
} }
export interface CreateSessionRequest { export interface CreateSessionRequest {
@ -112,31 +114,49 @@ export interface StartRunRequest {
label?: string | null label?: string | null
} }
// ── Use Cases ──
export interface UseCaseInfo {
id: string
name: string
guidance: string
}
// ── Analysis Results ── // ── Analysis Results ──
// Shift Point Analysis (use_case = "shift_points") // Shift Point Analysis (use_case = "shift_points")
export interface GearRpmRange { export interface GearDataPoint {
speed_kmh: number
rpm: number
acceleration_g: number
}
export interface GearCurve {
gear: number gear: number
gear_display: string gear_display: string
run_label: string
data_points: GearDataPoint[]
min_rpm: number min_rpm: number
max_rpm: number max_rpm: number
avg_rpm: number min_speed_kmh: number
max_speed_kmh: number
sample_count: number sample_count: number
} }
export interface ShiftEvent { export interface CrossoverPoint {
from_gear: number from_gear: number
to_gear: number to_gear: number
rpm_before_shift: number from_gear_display: string
rpm_after_shift: number to_gear_display: string
rpm_drop: number crossover_speed_kmh: number
speed_kmh: number crossover_rpm: number
} }
export interface ShiftPointAnalysis { export interface ShiftPointAnalysis {
gear_ranges: GearRpmRange[] gear_curves: GearCurve[]
shift_events: ShiftEvent[] crossover_points: CrossoverPoint[]
recommended_shift_rpm: number | null recommended_shift_rpm: number | null
runs_analyzed: number
total_frames_analyzed: number total_frames_analyzed: number
} }

24
src/utils/analysis.ts Normal file
View file

@ -0,0 +1,24 @@
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
/**
* Runtime type guard: checks if an unknown value is a ShiftPointAnalysis.
* Uses the 'gear_curves' discriminator field that the backend includes.
*/
export function isShiftPointAnalysis(data: unknown): data is ShiftPointAnalysis {
return typeof data === 'object' && data !== null && 'gear_curves' in data
}
/**
* Runtime type guard: checks if an unknown value is a TrackMapAnalysis.
* Uses the 'boundaries' discriminator field that the backend includes.
*/
export function isTrackMapAnalysis(data: unknown): data is TrackMapAnalysis {
return typeof data === 'object' && data !== null && 'boundaries' in data
}
/**
* Runtime type guard: checks if an unknown value is a NotImplementedResponse.
*/
export function isNotImplemented(data: unknown): data is { status: 'not_implemented'; use_case: string } {
return typeof data === 'object' && data !== null && 'status' in data && (data as { status: string }).status === 'not_implemented'
}

View file

@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useSessionsStore } from '@/stores/sessions' import { useSessionsStore } from '@/stores/sessions'
import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types' import type { ShiftPointAnalysis, TrackMapAnalysis } from '@/types'
import { isShiftPointAnalysis, isTrackMapAnalysis } from '@/utils/analysis'
const props = defineProps<{ const props = defineProps<{
id: string id: string
@ -34,19 +35,16 @@ const analysisData = computed(() => {
const shiftAnalysis = computed((): ShiftPointAnalysis | null => { const shiftAnalysis = computed((): ShiftPointAnalysis | null => {
if (!analysisData.value) return null if (!analysisData.value) return null
// The analysis field is the full analysis object if (isShiftPointAnalysis(analysisData.value)) {
const data = analysisData.value return analysisData.value
if (data && typeof data === 'object' && 'gear_ranges' in data) {
return data as unknown as ShiftPointAnalysis
} }
return null return null
}) })
const trackAnalysis = computed((): TrackMapAnalysis | null => { const trackAnalysis = computed((): TrackMapAnalysis | null => {
if (!analysisData.value) return null if (!analysisData.value) return null
const data = analysisData.value if (isTrackMapAnalysis(analysisData.value)) {
if (data && typeof data === 'object' && 'boundaries' in data) { return analysisData.value
return data as unknown as TrackMapAnalysis
} }
return null return null
}) })
@ -147,6 +145,10 @@ async function handleDelete() {
<span class="info-label">Completed</span> <span class="info-label">Completed</span>
<span>{{ formatTime(session.completed_at) }}</span> <span>{{ formatTime(session.completed_at) }}</span>
</div> </div>
<div v-if="session.guidance" class="info-item" style="grid-column: 1 / -1">
<span class="info-label">Guidance</span>
<span class="text-secondary" style="font-size: 0.85rem">{{ session.guidance }}</span>
</div>
</div> </div>
</section> </section>
@ -243,14 +245,18 @@ async function handleDelete() {
</div> </div>
<!-- Quick stats for shift points --> <!-- Quick stats for shift points -->
<div v-if="shiftAnalysis" class="analysis-stats grid grid-2 mb-2"> <div v-if="shiftAnalysis" class="analysis-stats grid grid-3 mb-2">
<div class="stat-card"> <div class="stat-card">
<span class="stat-label">Recommended Shift RPM</span> <span class="stat-label">Recommended Shift RPM</span>
<span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span> <span class="stat-value mono">{{ Math.round(shiftAnalysis.recommended_shift_rpm ?? 0) }}</span>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<span class="stat-label">Shift Events Detected</span> <span class="stat-label">Crossover Points</span>
<span class="stat-value mono">{{ shiftAnalysis.shift_events.length }}</span> <span class="stat-value mono">{{ shiftAnalysis.crossover_points.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">Runs Analyzed</span>
<span class="stat-value mono">{{ shiftAnalysis.runs_analyzed }}</span>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Bar, Line } from 'vue-chartjs' import { Bar, Scatter } from 'vue-chartjs'
import { import {
Chart as ChartJS, Chart as ChartJS,
CategoryScale, CategoryScale,
@ -15,6 +15,7 @@ import {
Filler, Filler,
} from 'chart.js' } from 'chart.js'
import type { ShiftPointAnalysis } from '@/types' import type { ShiftPointAnalysis } from '@/types'
import { isShiftPointAnalysis } from '@/utils/analysis'
import * as api from '@/api/client' import * as api from '@/api/client'
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler) ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler)
@ -30,8 +31,8 @@ const error = ref<string | null>(null)
onMounted(async () => { onMounted(async () => {
try { try {
const data = await api.getAnalysis(sessionId.value) const data = await api.getAnalysis(sessionId.value)
if (data && 'gear_ranges' in data) { if (isShiftPointAnalysis(data)) {
analysis.value = data as unknown as ShiftPointAnalysis analysis.value = data
} else { } else {
error.value = 'Analysis data not available or wrong type' error.value = 'Analysis data not available or wrong type'
} }
@ -42,96 +43,87 @@ onMounted(async () => {
} }
}) })
// Gear RPM range chart data const gearCurvesChartData = computed(() => {
const gearRangeChartData = computed(() => {
if (!analysis.value) return null if (!analysis.value) return null
const ranges = analysis.value.gear_ranges.filter((g) => g.gear >= 1) const curves = analysis.value.gear_curves.filter((g) => g.gear >= 2)
const colors = ['#4f8ff7', '#34d399', '#fbbf24', '#f87171', '#a78bfa', '#60a5fa', '#f472b6']
return { return {
labels: ranges.map((g) => g.gear_display), datasets: curves.map((curve, i) => ({
datasets: [ label: `${curve.gear_display} (${curve.run_label})`,
{ data: curve.data_points.map((p) => ({ x: p.speed_kmh, y: p.acceleration_g })),
label: 'Min RPM', borderColor: colors[i % colors.length],
data: ranges.map((g) => g.min_rpm), backgroundColor: colors[i % colors.length] + '20',
backgroundColor: 'rgba(79, 143, 247, 0.3)', showLine: true,
borderColor: 'rgba(79, 143, 247, 0.8)', fill: false,
borderWidth: 1, tension: 0.3,
}, pointRadius: 0,
{ borderWidth: 2,
label: 'Avg RPM', })),
data: ranges.map((g) => g.avg_rpm),
backgroundColor: 'rgba(52, 211, 153, 0.3)',
borderColor: 'rgba(52, 211, 153, 0.8)',
borderWidth: 1,
},
{
label: 'Max RPM',
data: ranges.map((g) => g.max_rpm),
backgroundColor: 'rgba(248, 113, 113, 0.3)',
borderColor: 'rgba(248, 113, 113, 0.8)',
borderWidth: 1,
},
],
} }
}) })
const gearRangeOptions = { const gearCurvesOptions = {
responsive: true, responsive: true,
plugins: { plugins: {
legend: { labels: { color: '#8b8fa3' } }, legend: { labels: { color: '#8b8fa3' } },
title: { display: true, text: 'Gear RPM Ranges', color: '#e2e4ea' }, title: { display: true, text: 'Gear Acceleration Curves', color: '#e2e4ea' },
}, },
scales: { scales: {
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } }, x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'Speed (km/h)', color: '#8b8fa3' } },
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } }, y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'Acceleration (g)', color: '#8b8fa3' } },
}, },
} }
// Shift events timeline data const crossoverChartData = computed(() => {
const shiftEventsChartData = computed(() => { if (!analysis.value || analysis.value.crossover_points.length === 0) return null
if (!analysis.value || analysis.value.shift_events.length === 0) return null const points = analysis.value.crossover_points
const events = analysis.value.shift_events
return { return {
labels: events.map((_, i) => `#${i + 1}`), labels: points.map((p) => `${p.from_gear_display} → ${p.to_gear_display}`),
datasets: [ datasets: [
{ {
label: 'RPM Before Shift', label: 'Crossover RPM',
data: events.map((e) => e.rpm_before_shift), data: points.map((p) => p.crossover_rpm),
borderColor: 'rgba(248, 113, 113, 1)', backgroundColor: 'rgba(79, 143, 247, 0.5)',
backgroundColor: 'rgba(248, 113, 113, 0.1)', borderColor: 'rgba(79, 143, 247, 1)',
fill: false, borderWidth: 1,
tension: 0.2, yAxisID: 'y',
pointRadius: 4,
}, },
{ {
label: 'RPM After Shift', label: 'Crossover Speed (km/h)',
data: events.map((e) => e.rpm_after_shift), data: points.map((p) => p.crossover_speed_kmh),
backgroundColor: 'rgba(52, 211, 153, 0.5)',
borderColor: 'rgba(52, 211, 153, 1)', borderColor: 'rgba(52, 211, 153, 1)',
backgroundColor: 'rgba(52, 211, 153, 0.1)', borderWidth: 1,
fill: false, yAxisID: 'y1',
tension: 0.2,
pointRadius: 4,
}, },
], ],
} }
}) })
const shiftEventsOptions = { const crossoverChartOptions = {
responsive: true, responsive: true,
plugins: { plugins: {
legend: { labels: { color: '#8b8fa3' } }, legend: { labels: { color: '#8b8fa3' } },
title: { display: true, text: 'Shift Events — RPM Before/After', color: '#e2e4ea' }, title: { display: true, text: 'Crossover Points', color: '#e2e4ea' },
}, },
scales: { scales: {
x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } }, x: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' } },
y: { ticks: { color: '#8b8fa3' }, grid: { color: '#2a2e3f' }, title: { display: true, text: 'RPM', color: '#8b8fa3' } }, y: {
type: 'linear' as const,
position: 'left' as const,
ticks: { color: '#4f8ff7' },
grid: { color: '#2a2e3f' },
title: { display: true, text: 'RPM', color: '#4f8ff7' },
},
y1: {
type: 'linear' as const,
position: 'right' as const,
ticks: { color: '#34d399' },
grid: { drawOnChartArea: false },
title: { display: true, text: 'Speed (km/h)', color: '#34d399' },
},
}, },
} }
function gearLabel(gear: number): string {
if (gear === -1) return 'R'
if (gear === 0) return 'N'
return gear.toString()
}
</script> </script>
<template> <template>
@ -146,14 +138,18 @@ function gearLabel(gear: number): string {
<template v-else-if="analysis"> <template v-else-if="analysis">
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-3 mb-3"> <div class="grid grid-4 mb-3">
<div class="card stat-card"> <div class="card stat-card">
<span class="stat-label">Recommended Shift RPM</span> <span class="stat-label">Recommended Shift RPM</span>
<span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span> <span class="stat-value mono">{{ Math.round(analysis.recommended_shift_rpm ?? 0) }}</span>
</div> </div>
<div class="card stat-card"> <div class="card stat-card">
<span class="stat-label">Shift Events</span> <span class="stat-label">Crossover Points</span>
<span class="stat-value mono">{{ analysis.shift_events.length }}</span> <span class="stat-value mono">{{ analysis.crossover_points.length }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Runs Analyzed</span>
<span class="stat-value mono">{{ analysis.runs_analyzed }}</span>
</div> </div>
<div class="card stat-card"> <div class="card stat-card">
<span class="stat-label">Frames Analyzed</span> <span class="stat-label">Frames Analyzed</span>
@ -161,20 +157,20 @@ function gearLabel(gear: number): string {
</div> </div>
</div> </div>
<!-- Gear RPM Ranges Chart --> <!-- Gear Acceleration Curves Chart -->
<div v-if="gearRangeChartData" class="card mb-3"> <div v-if="gearCurvesChartData" class="card mb-3">
<Bar :data="gearRangeChartData" :options="gearRangeOptions" /> <Scatter :data="gearCurvesChartData" :options="gearCurvesOptions" />
</div> </div>
<!-- Shift Events Chart --> <!-- Crossover Points Chart -->
<div v-if="shiftEventsChartData" class="card mb-3"> <div v-if="crossoverChartData" class="card mb-3">
<Line :data="shiftEventsChartData" :options="shiftEventsOptions" /> <Bar :data="crossoverChartData" :options="crossoverChartOptions" />
</div> </div>
<!-- Shift Events Table --> <!-- Crossover Points Table -->
<div v-if="analysis.shift_events.length > 0" class="card"> <div v-if="analysis.crossover_points.length > 0" class="card">
<div class="card-header"> <div class="card-header">
<h2>Shift Events Detail</h2> <h2>Crossover Points</h2>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@ -182,20 +178,16 @@ function gearLabel(gear: number): string {
<tr> <tr>
<th>#</th> <th>#</th>
<th>From → To</th> <th>From → To</th>
<th>RPM Before</th> <th>Crossover RPM</th>
<th>RPM After</th> <th>Crossover Speed (km/h)</th>
<th>RPM Drop</th>
<th>Speed (km/h)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(event, i) in analysis.shift_events" :key="i"> <tr v-for="(point, i) in analysis.crossover_points" :key="i">
<td class="mono">{{ i + 1 }}</td> <td class="mono">{{ i + 1 }}</td>
<td>{{ gearLabel(event.from_gear) }} → {{ gearLabel(event.to_gear) }}</td> <td>{{ point.from_gear_display }} → {{ point.to_gear_display }}</td>
<td class="mono">{{ Math.round(event.rpm_before_shift) }}</td> <td class="mono">{{ Math.round(point.crossover_rpm) }}</td>
<td class="mono">{{ Math.round(event.rpm_after_shift) }}</td> <td class="mono">{{ point.crossover_speed_kmh.toFixed(1) }}</td>
<td class="mono">{{ Math.round(event.rpm_drop) }}</td>
<td class="mono">{{ Math.round(event.speed_kmh) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>