MotorSignal-App/website/components/app-cloud-data.tsx

1459 lines
67 KiB
TypeScript

'use client';
import {
collection,
getDocs,
addDoc,
deleteDoc,
doc,
updateDoc,
writeBatch,
} from 'firebase/firestore';
import { onAuthStateChanged, User } from 'firebase/auth';
import Link from 'next/link';
import React, { useEffect, useState, useRef } from 'react';
import { firebaseAuth, firestore } from '../lib/firebase-client';
import { WebTelemetryReplayModal } from './web-telemetry-replay-modal';
import {
generateRideGpx,
generateRideCsv,
downloadFile,
resolveRideTelemetryPoints,
TelemetryPoint,
} from '../lib/telemetry-export';
export type Motorcycle = {
id: string;
nickname: string;
makeName: string;
modelName: string;
variantName: string;
year: number;
description: string;
isDefault: boolean;
imageUrl?: string;
vin?: string;
};
export type Ride = {
id: string;
startedAt: Date | null;
endedAt: Date | null;
distanceMiles: number;
topSpeedMph: number;
averageSpeed: number;
maxLeanLeft: number;
maxLeanRight: number;
maxAccelG: number;
maxBrakingG: number;
movingTime: number; // in seconds
elevationGain: number;
qualityScore: number;
motorcycle: string;
startLat?: number;
startLon?: number;
endLat?: number;
endLon?: number;
points?: TelemetryPoint[];
gpxContent?: string;
csvContent?: string;
telemetryBundlePath?: string;
};
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function number(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
}
function formatDuration(seconds: number): string {
if (!seconds || seconds <= 0) return '0m';
const mins = Math.floor(seconds / 60);
const hrs = Math.floor(mins / 60);
const remainingMins = mins % 60;
if (hrs > 0) {
return `${hrs}h ${remainingMins}m`;
}
return `${mins}m`;
}
// Sleek high-tech stock sportbike vector illustration
function StockMotorcycleGraphic({ className = 'w-full h-40' }: { className?: string }) {
return (
<div className={`relative flex items-center justify-center bg-gradient-to-b from-surface-container-high/40 to-surface-container-low/80 rounded-xl overflow-hidden border border-outline-variant/20 p-4 ${className}`}>
{/* Background technical grid */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,rgba(0,242,254,0.08)_0,transparent_70%)] pointer-events-none"></div>
<svg
className="w-48 sm:w-64 h-auto max-h-36 drop-shadow-[0_4px_16px_rgba(0,242,254,0.25)] transition-transform duration-300 group-hover:scale-105"
viewBox="0 0 200 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
{/* Front Wheel */}
<circle cx="160" cy="85" r="26" stroke="#475569" strokeWidth="6" />
<circle cx="160" cy="85" r="21" stroke="#00f2fe" strokeWidth="1.5" strokeDasharray="3 3" />
<circle cx="160" cy="85" r="9" fill="#0f172a" stroke="#64748b" strokeWidth="2" />
{/* Rear Wheel */}
<circle cx="40" cy="85" r="26" stroke="#475569" strokeWidth="7" />
<circle cx="40" cy="85" r="21" stroke="#00f2fe" strokeWidth="1.5" strokeDasharray="3 3" />
<circle cx="40" cy="85" r="9" fill="#0f172a" stroke="#64748b" strokeWidth="2" />
{/* Swingarm */}
<path d="M40 85 L90 78 L95 85 Z" fill="#334155" stroke="#475569" strokeWidth="1.5" />
{/* Frame & Engine Block */}
<path d="M85 58 L125 58 L115 88 L75 88 Z" fill="#1e293b" stroke="#38bdf8" strokeWidth="1.5" />
{/* Exhaust Pipe */}
<path d="M85 84 Q105 88 120 78 L110 74 Q95 82 85 80 Z" fill="#94a3b8" />
<path d="M115 76 L140 68" stroke="#f59e0b" strokeWidth="3" strokeLinecap="round" />
{/* Front Fork */}
<path d="M135 38 L160 85" stroke="#cbd5e1" strokeWidth="4" strokeLinecap="round" />
<path d="M138 35 L143 28" stroke="#00f2fe" strokeWidth="3" strokeLinecap="round" />
{/* Fuel Tank & Tail */}
<path
d="M50 56 Q65 42 90 44 Q115 45 130 38 Q138 48 125 58 Q95 56 65 64 Z"
fill="url(#sportbikeGrad)"
stroke="#00f2fe"
strokeWidth="1.5"
/>
{/* Aerodynamic Fairing & Windscreen */}
<path d="M130 38 Q145 28 152 35 Q145 52 135 60 Z" fill="#0369a1" fillOpacity="0.7" stroke="#38bdf8" strokeWidth="1" />
<path d="M140 32 Q150 22 156 30" stroke="#00f2fe" strokeWidth="2" strokeLinecap="round" />
{/* Rider Seat */}
<path d="M68 47 Q80 47 88 53 L62 56 Z" fill="#020617" stroke="#1e293b" />
{/* Gradients */}
<defs>
<linearGradient id="sportbikeGrad" x1="50" y1="40" x2="135" y2="65" gradientUnits="userSpaceOnUse">
<stop stopColor="#0284c7" />
<stop offset="0.5" stopColor="#00f2fe" />
<stop offset="1" stopColor="#0369a1" />
</linearGradient>
</defs>
</svg>
<span className="absolute bottom-2 right-3 font-mono text-[10px] text-slate-500 tracking-normal">
Stock Spec Render
</span>
</div>
);
}
export interface AccountCloudMetrics {
totalMiles: number;
rideCount: number;
peakLean: number;
}
export type CloudDataStatus =
| 'idle'
| 'loading'
| 'unauthenticated'
| 'ready'
| 'permission-denied'
| 'unavailable'
| 'error';
export interface AppCloudDataProps {
syncTrigger?: number;
onSyncStateChange?: (syncing: boolean) => void;
onMetricsLoaded?: (metrics: AccountCloudMetrics) => void;
onStatusChange?: (status: CloudDataStatus, error?: unknown) => void;
}
export function AppCloudData({
syncTrigger = 0,
onSyncStateChange,
onMetricsLoaded,
onStatusChange,
}: AppCloudDataProps) {
const [motorcycles, setMotorcycles] = useState<Motorcycle[]>([]);
const [rides, setRides] = useState<Ride[]>([]);
const [garageStatus, setGarageStatus] = useState<CloudDataStatus>('loading');
const [ridesStatus, setRidesStatus] = useState<CloudDataStatus>('loading');
const [garageError, setGarageError] = useState<string | null>(null);
const [ridesError, setRidesError] = useState<string | null>(null);
const [state, setState] = useState('Loading telemetry cloud data...');
const [user, setUser] = useState<User | null>(null);
// Modal / Add Motorcycle Form State
const [showAddModal, setShowAddModal] = useState(false);
const [newYear, setNewYear] = useState(new Date().getFullYear().toString());
const [newMake, setNewMake] = useState('Yamaha');
const [newModel, setNewModel] = useState('');
const [newVariant, setNewVariant] = useState('Standard');
const [newNickname, setNewNickname] = useState('');
const [newVin, setNewVin] = useState('');
const [newIsDefault, setNewIsDefault] = useState(false);
const [newImagePreview, setNewImagePreview] = useState<string | null>(null);
const [formBusy, setFormBusy] = useState(false);
const [formError, setFormError] = useState('');
// Image Upload Target State
const [uploadingMotorcycleId, setUploadingMotorcycleId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// Expandable Ride Detail state
const [expandedRideId, setExpandedRideId] = useState<string | null>(null);
// Pagination for Recent Telemetry Sessions
const [visibleRideCount, setVisibleRideCount] = useState(10);
// Replay Modal & Export State
const [replayRide, setReplayRide] = useState<Ride | null>(null);
const [exportNotice, setExportNotice] = useState<string | null>(null);
useEffect(() => {
if (typeof window !== 'undefined') {
(window as any).openReplayModalForTest = (r?: Partial<Ride>) => {
setReplayRide({
id: r?.id || 'ride_3_20260831_141255',
startedAt: r?.startedAt || new Date('2026-08-28T23:50:03.000Z'),
endedAt: r?.endedAt || new Date('2026-08-29T00:09:05.000Z'),
distanceMiles: r?.distanceMiles || 14.2,
topSpeedMph: r?.topSpeedMph || 68.5,
averageSpeed: r?.averageSpeed || 44.2,
maxLeanLeft: r?.maxLeanLeft || 38.5,
maxLeanRight: r?.maxLeanRight || 41.2,
maxAccelG: r?.maxAccelG || 0.65,
maxBrakingG: r?.maxBrakingG || 0.78,
movingTime: r?.movingTime || 1142,
elevationGain: r?.elevationGain || 420,
qualityScore: r?.qualityScore || 96,
motorcycle: r?.motorcycle || '2024 Yamaha YZF-R7',
startLat: r?.startLat ?? (r?.points?.[0]?.latitude ?? 34.0400856),
startLon: r?.startLon ?? (r?.points?.[0]?.longitude ?? -85.2399786),
endLat: r?.endLat ?? (r?.points?.[r.points.length - 1]?.latitude ?? 34.2435959),
endLon: r?.endLon ?? (r?.points?.[r.points.length - 1]?.longitude ?? -85.1615908),
points: r?.points,
gpxContent: r?.gpxContent,
csvContent: r?.csvContent,
telemetryBundlePath: r?.telemetryBundlePath,
});
};
}
}, []);
async function handleExportRideGpx(e: React.MouseEvent, ride: Ride) {
e.stopPropagation();
try {
setExportNotice('Loading recorded telemetry for export…');
const points = await resolveRideTelemetryPoints(ride);
if (!points.length) {
throw new Error('No recorded telemetry points available.');
}
const gpx = generateRideGpx(ride, points);
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
const filename = `motorsignal_${ride.id}_${dateStr}.gpx`;
downloadFile(gpx, filename, 'application/gpx+xml');
setExportNotice(`Exported GPX: ${filename}`);
setTimeout(() => setExportNotice(null), 3000);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Telemetry export failed.';
setExportNotice(`Export failed: ${msg}`);
setTimeout(() => setExportNotice(null), 4000);
}
}
async function handleExportRideCsv(e: React.MouseEvent, ride: Ride) {
e.stopPropagation();
try {
setExportNotice('Loading recorded telemetry for export…');
const points = await resolveRideTelemetryPoints(ride);
if (!points.length) {
throw new Error('No recorded telemetry points available.');
}
const csv = generateRideCsv(ride, points);
const dateStr = ride.startedAt ? ride.startedAt.toISOString().split('T')[0] : 'session';
const filename = `motorsignal_20hz_${ride.id}_${dateStr}.csv`;
downloadFile(csv, filename, 'text/csv;charset=utf-8;');
setExportNotice(`Exported 20 Hz CSV: ${filename}`);
setTimeout(() => setExportNotice(null), 3000);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Telemetry export failed.';
setExportNotice(`Export failed: ${msg}`);
setTimeout(() => setExportNotice(null), 4000);
}
}
// Handle Delete Ride Session
async function handleDeleteRide(e: React.MouseEvent, ride: Ride) {
e.stopPropagation();
if (!user) return;
const confirmDelete = window.confirm(
`Delete telemetry session recorded on ${ride.startedAt ? ride.startedAt.toLocaleDateString() : 'session'}?`
);
if (!confirmDelete) return;
try {
await deleteDoc(doc(firestore, 'users', user.uid, 'rides', ride.id));
setRides((prev) => {
const updated = prev.filter((r) => r.id !== ride.id);
if (onMetricsLoaded) {
const totalMiles = updated.reduce((acc, r) => acc + r.distanceMiles, 0);
let peakLean = 0;
for (const r of updated) {
if (r.maxLeanLeft > peakLean) peakLean = r.maxLeanLeft;
if (r.maxLeanRight > peakLean) peakLean = r.maxLeanRight;
}
onMetricsLoaded({
totalMiles: Math.round(totalMiles),
rideCount: updated.length,
peakLean: Number(peakLean.toFixed(1)),
});
}
return updated;
});
} catch (err) {
console.error('Error deleting ride:', err);
alert('Unable to delete telemetry session. Please check permissions.');
}
}
// Auto-resolved Stock Photography Cache (from /api/motorcycle-image)
const [stockImages, setStockImages] = useState<
Record<string, { imageUrl: string; attribution?: string; caption?: string } | null>
>({});
// Resolve stock images for bikes that don't have custom uploaded images
useEffect(() => {
motorcycles.forEach((bike) => {
if (bike.imageUrl) return;
const key = `${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase();
if (key in stockImages || bike.id in stockImages) return;
fetch(
`/api/motorcycle-image?make=${encodeURIComponent(bike.makeName)}&model=${encodeURIComponent(
bike.modelName
)}&year=${bike.year || ''}`
)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const val = data?.imageUrl
? {
imageUrl: data.imageUrl as string,
attribution: data.attribution as string,
caption: data.caption as string,
}
: null;
setStockImages((prev) => ({
...prev,
[bike.id]: val,
[key]: val,
}));
})
.catch(() => {
setStockImages((prev) => ({
...prev,
[bike.id]: null,
[key]: null,
}));
});
});
}, [motorcycles, stockImages]);
async function loadData(currentUser: User | null) {
if (!currentUser) {
setMotorcycles([]);
setRides([]);
setGarageStatus('unauthenticated');
setRidesStatus('unauthenticated');
setGarageError(null);
setRidesError(null);
setState('Sign in or connect your Google account to view your live telemetry.');
if (onStatusChange) onStatusChange('unauthenticated');
return;
}
setGarageStatus('loading');
setRidesStatus('loading');
setGarageError(null);
setRidesError(null);
if (onStatusChange) onStatusChange('loading');
if (onSyncStateChange) onSyncStateChange(true);
function classifyError(err: unknown): { status: CloudDataStatus; message: string } {
const msg = err instanceof Error ? err.message : String(err);
const code = (err as { code?: string })?.code;
if (
code === 'permission-denied' ||
msg.includes('permission-denied') ||
msg.includes('Missing or insufficient permissions')
) {
return { status: 'permission-denied', message: 'Permission denied: unable to access cloud telemetry.' };
}
if (
code === 'unavailable' ||
msg.includes('unavailable') ||
msg.includes('offline') ||
msg.includes('network') ||
msg.includes('Failed to fetch')
) {
return { status: 'unavailable', message: 'Network offline: unable to reach MotorSignal cloud.' };
}
return { status: 'error', message: msg || 'An unexpected error occurred while loading cloud telemetry.' };
}
try {
const [motorcycleRes, rideRes] = await Promise.allSettled([
getDocs(collection(firestore, 'users', currentUser.uid, 'motorcycles')),
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
]);
// Process Motorcycles
if (motorcycleRes.status === 'fulfilled') {
const loadedMotorcycles: Motorcycle[] = motorcycleRes.value.docs.map((snapshot) => {
const data = snapshot.data();
const yearVal = number(data.year) || 0;
const makeVal = text(data.makeName) || text(data.make);
const modelVal = text(data.modelName) || text(data.model);
const variantVal = text(data.variantName) || text(data.variant);
const description = [yearVal || '', makeVal, modelVal, variantVal].filter(Boolean).join(' ');
return {
id: snapshot.id,
nickname: text(data.nickname) || description || 'Motorcycle',
makeName: makeVal,
modelName: modelVal,
variantName: variantVal,
year: yearVal,
description,
isDefault: data.isDefault === true,
imageUrl: text(data.imageUrl) || undefined,
vin: text(data.vin) || text(data.vinNumber) || undefined,
};
}).sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
setMotorcycles(loadedMotorcycles);
setGarageStatus('ready');
setGarageError(null);
} else {
const classified = classifyError(motorcycleRes.reason);
setGarageStatus(classified.status);
setGarageError(classified.message);
console.error('Error loading motorcycles:', motorcycleRes.reason);
}
// Process Rides & Metrics
if (rideRes.status === 'fulfilled') {
let totalMiles = 0;
let peakLean = 0;
const loadedRides: Ride[] = rideRes.value.docs.map((snapshot) => {
const data = snapshot.data();
const rawStart = text(data.startTime);
const rawEnd = text(data.endTime);
const startedAt = rawStart ? new Date(rawStart) : null;
const endedAt = rawEnd ? new Date(rawEnd) : null;
const dist = number(data.distanceMiles);
const leanL = number(data.maxLeanLeft);
const leanR = number(data.maxLeanRight);
totalMiles += dist;
if (leanL > peakLean) peakLean = leanL;
if (leanR > peakLean) peakLean = leanR;
const startLat = number(data.startLat) || number(data.startLatitude) || undefined;
const startLon = number(data.startLon) || number(data.startLongitude) || undefined;
const endLat = number(data.endLat) || number(data.endLatitude) || undefined;
const endLon = number(data.endLon) || number(data.endLongitude) || undefined;
const bundlePath = text(data.telemetryBundlePath) || text(data.telemetryBundle?.objectKey) || undefined;
let ridePoints: TelemetryPoint[] | undefined;
if (Array.isArray(data.points) && data.points.length > 0) {
ridePoints = data.points;
} else if (Array.isArray(data.telemetryPoints) && data.telemetryPoints.length > 0) {
ridePoints = data.telemetryPoints;
}
return {
id: snapshot.id,
startedAt: startedAt && !Number.isNaN(startedAt.getTime()) ? startedAt : null,
endedAt: endedAt && !Number.isNaN(endedAt.getTime()) ? endedAt : null,
distanceMiles: dist,
topSpeedMph: number(data.topSpeedMph),
averageSpeed: number(data.averageSpeed),
maxLeanLeft: leanL,
maxLeanRight: leanR,
maxAccelG: number(data.maxAccelG),
maxBrakingG: number(data.maxBrakingG),
movingTime: number(data.movingTime),
elevationGain: number(data.elevationGain),
qualityScore: number(data.qualityScore) || 100,
motorcycle: [number(data.motorcycleYear) || '', text(data.motorcycleMake), text(data.motorcycleModel)].filter(Boolean).join(' '),
startLat,
startLon,
endLat,
endLon,
points: ridePoints,
gpxContent: text(data.gpxContent) || undefined,
csvContent: text(data.csvContent) || undefined,
telemetryBundlePath: bundlePath,
};
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
setRides(loadedRides);
setRidesStatus('ready');
setRidesError(null);
const computedMetrics = {
totalMiles: Math.round(totalMiles),
rideCount: loadedRides.length,
peakLean: Number(peakLean.toFixed(1)),
};
if (onMetricsLoaded) {
onMetricsLoaded(computedMetrics);
}
if (onStatusChange) {
onStatusChange('ready');
}
} else {
const classified = classifyError(rideRes.reason);
setRidesStatus(classified.status);
setRidesError(classified.message);
console.error('Error loading telemetry rides:', rideRes.reason);
if (onStatusChange) {
onStatusChange(classified.status, rideRes.reason);
}
}
if (motorcycleRes.status === 'rejected' && rideRes.status === 'rejected') {
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
} else if (motorcycleRes.status === 'rejected' || rideRes.status === 'rejected') {
setState('Some cloud telemetry could not be loaded.');
} else {
setState('');
}
} catch (error) {
console.error('Error loading MotorSignal cloud data:', error);
setState('App cloud data could not be loaded. Please re-authenticate or click Quick Sync.');
if (onStatusChange) {
onStatusChange('error', error);
}
} finally {
if (onSyncStateChange) onSyncStateChange(false);
}
}
useEffect(() => {
const unsubscribe = onAuthStateChanged(firebaseAuth, (currentUser) => {
setUser(currentUser);
void loadData(currentUser);
});
return () => unsubscribe();
}, []);
// React to parent Quick Sync trigger
useEffect(() => {
if (syncTrigger > 0 && user) {
void loadData(user);
}
}, [syncTrigger]);
// Handle image upload compression
async function handleImageFileSelected(event: React.ChangeEvent<HTMLInputElement>, motorcycleId?: string) {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
const img = new Image();
img.onload = async () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 960;
const MAX_HEIGHT = 640;
let width = img.width;
let height = img.height;
if (width > height) {
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
} else {
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.84);
if (motorcycleId && user) {
// Direct update on existing motorcycle
try {
await updateDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycleId), {
imageUrl: dataUrl,
updatedAt: new Date().toISOString(),
});
setMotorcycles((prev) =>
prev.map((m) => (m.id === motorcycleId ? { ...m, imageUrl: dataUrl } : m))
);
} catch (err) {
console.error('Failed to update motorcycle image:', err);
alert('Failed to save image. Please check permissions.');
}
} else {
// Staged for new motorcycle modal
setNewImagePreview(dataUrl);
}
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
// Reset file input value
event.target.value = '';
}
// Handle Add Motorcycle submission
async function handleAddMotorcycle(event: React.FormEvent) {
event.preventDefault();
if (!user) {
setFormError('Please sign in to add motorcycles.');
return;
}
if (!newMake.trim() || !newModel.trim()) {
setFormError('Make and Model are required.');
return;
}
setFormBusy(true);
setFormError('');
try {
const yearInt = parseInt(newYear, 10) || new Date().getFullYear();
const generatedNickname = newNickname.trim() || `${yearInt} ${newMake} ${newModel}`.trim();
const shouldBeDefault = newIsDefault || motorcycles.length === 0;
if (shouldBeDefault && motorcycles.length > 0) {
const batch = writeBatch(firestore);
motorcycles.forEach((m) => {
batch.update(doc(firestore, 'users', user.uid, 'motorcycles', m.id), {
isDefault: false,
});
});
await batch.commit();
}
const docRef = await addDoc(collection(firestore, 'users', user.uid, 'motorcycles'), {
year: yearInt,
makeName: newMake.trim(),
modelName: newModel.trim(),
variantName: newVariant.trim(),
nickname: generatedNickname,
vin: newVin.trim() || null,
isDefault: shouldBeDefault,
imageUrl: newImagePreview || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
const newBike: Motorcycle = {
id: docRef.id,
year: yearInt,
makeName: newMake.trim(),
modelName: newModel.trim(),
variantName: newVariant.trim(),
nickname: generatedNickname,
description: `${yearInt} ${newMake} ${newModel} ${newVariant}`.trim(),
isDefault: shouldBeDefault,
imageUrl: newImagePreview || undefined,
vin: newVin.trim() || undefined,
};
setMotorcycles((prev) => [
newBike,
...prev.map((m) => (shouldBeDefault ? { ...m, isDefault: false } : m)),
]);
// Reset form
setShowAddModal(false);
setNewModel('');
setNewNickname('');
setNewVin('');
setNewImagePreview(null);
} catch (err) {
console.error('Error adding motorcycle:', err);
setFormError(err instanceof Error ? err.message : 'Unable to add motorcycle. Please retry.');
} finally {
setFormBusy(false);
}
}
// Handle Delete Motorcycle
async function handleDeleteMotorcycle(motorcycle: Motorcycle) {
if (!user) return;
const confirmDelete = window.confirm(`Remove ${motorcycle.nickname} from your garage?`);
if (!confirmDelete) return;
try {
await deleteDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycle.id));
setMotorcycles((prev) => prev.filter((m) => m.id !== motorcycle.id));
} catch (err) {
console.error('Error deleting motorcycle:', err);
alert('Unable to delete motorcycle. Please check connection and permissions.');
}
}
// Handle Make Primary
async function handleMakePrimary(motorcycleId: string) {
if (!user) return;
try {
const batch = writeBatch(firestore);
motorcycles.forEach((m) => {
batch.update(doc(firestore, 'users', user.uid, 'motorcycles', m.id), {
isDefault: m.id === motorcycleId,
});
});
await batch.commit();
setMotorcycles((prev) =>
prev
.map((m) => ({ ...m, isDefault: m.id === motorcycleId }))
.sort((a, b) => Number(b.isDefault) - Number(a.isDefault))
);
} catch (err) {
console.error('Error setting primary motorcycle:', err);
}
}
// Remove custom photo to restore stock graphic
async function handleRemovePhoto(motorcycleId: string) {
if (!user) return;
try {
await updateDoc(doc(firestore, 'users', user.uid, 'motorcycles', motorcycleId), {
imageUrl: null,
updatedAt: new Date().toISOString(),
});
setMotorcycles((prev) =>
prev.map((m) => (m.id === motorcycleId ? { ...m, imageUrl: undefined } : m))
);
} catch (err) {
console.error('Error removing photo:', err);
}
}
return (
<div className="space-y-12">
{/* Hidden file input for photo uploads */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => void handleImageFileSelected(e, uploadingMotorcycleId ?? undefined)}
/>
{/* ========================================================================= */}
{/* SECTION 1: VEHICLE GARAGE (Blue & Purple Areas) */}
{/* ========================================================================= */}
<section className="space-y-6" data-purpose="motorcycle-garage">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="w-2 h-6 bg-brand-cyan rounded"></div>
<div>
<h2 className="text-xl font-bold text-white tracking-wide flex items-center gap-2.5">
<span>Vehicle Garage &amp; Telemetry Hardware</span>
</h2>
<p className="text-xs text-slate-400 font-mono mt-0.5">
Synced bikes, ECU links, and motorcycle profiles
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowAddModal(true)}
className="px-3.5 py-2 rounded-xl bg-brand-cyan/15 hover:bg-brand-cyan/25 border border-brand-cyan/40 text-brand-cyan hover:text-white text-xs font-mono font-semibold transition-all flex items-center space-x-2 cursor-pointer shadow-[0_0_16px_rgba(0,242,254,0.15)]"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path d="M12 4v16m8-8H4" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2"></path>
</svg>
<span>Add Motorcycle</span>
</button>
</div>
</div>
{/* Motorcycle Grid */}
{motorcycles.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{motorcycles.map((bike) => (
<div
key={bike.id}
className={`glass-card rounded-2xl p-6 relative overflow-hidden group transition-all duration-300 ${
bike.isDefault
? 'border-2 border-brand-cyan/40 shadow-[0_0_24px_rgba(0,242,254,0.12)]'
: 'border border-brand-border hover:border-slate-500'
}`}
>
{/* Accent top gradient */}
<div
className={`absolute top-0 left-0 right-0 h-1 ${
bike.isDefault
? 'bg-gradient-to-r from-brand-cyan to-blue-500'
: 'bg-gradient-to-r from-slate-700 to-transparent'
}`}
></div>
{/* Top Badges & Actions */}
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2">
{bike.isDefault ? (
<span className="px-2.5 py-1 rounded bg-brand-cyan/20 text-brand-cyan border border-brand-cyan/40 font-mono text-[11px] font-bold tracking-normal flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald animate-pulse"></span>
Default Primary
</span>
) : (
<button
onClick={() => void handleMakePrimary(bike.id)}
className="px-2.5 py-1 rounded bg-slate-800/80 hover:bg-brand-cyan/20 text-slate-400 hover:text-brand-cyan border border-slate-700 hover:border-brand-cyan/40 font-mono text-[11px] tracking-normal transition-colors cursor-pointer"
>
Set as Primary
</button>
)}
</div>
{/* Delete Motorcycle Action */}
<button
onClick={() => void handleDeleteMotorcycle(bike)}
title="Delete motorcycle"
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 transition-colors cursor-pointer"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</div>
{/* Motorcycle Image Area (Purple Area - Stock Photo or Custom) */}
<div className="relative mb-5 group/img overflow-hidden rounded-xl">
{bike.imageUrl ? (
<div className="relative h-44 w-full bg-slate-900 rounded-xl overflow-hidden border border-outline-variant/30">
<img
src={bike.imageUrl}
alt={bike.nickname}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
<span className="absolute bottom-2 left-3 font-mono text-[10px] text-brand-cyan bg-slate-950/80 px-2 py-0.5 rounded border border-brand-cyan/30 flex items-center gap-1.5 shadow-md">
<span className="w-1.5 h-1.5 rounded-full bg-brand-cyan"></span>
CUSTOM RIDER PHOTO
</span>
</div>
) : (stockImages[bike.id] || stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])?.imageUrl ? (
<div className="relative h-44 w-full bg-slate-900 rounded-xl overflow-hidden border border-brand-cyan/30">
<img
src={
(stockImages[bike.id] ||
stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])!
.imageUrl
}
alt={bike.nickname}
className="w-full h-full object-cover object-center group-hover:scale-105 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-transparent to-black/20"></div>
<span className="absolute bottom-2 left-3 font-mono text-[10px] text-emerald-400 bg-slate-950/85 px-2 py-0.5 rounded border border-emerald-500/40 flex items-center gap-1.5 shadow-md">
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald animate-pulse"></span>
STOCK SPEC PHOTO
</span>
{((stockImages[bike.id] ||
stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])!
.attribution) && (
<span className="absolute bottom-2 right-3 font-mono text-[9px] text-slate-400 bg-slate-950/85 px-1.5 py-0.5 rounded border border-slate-800">
{
(stockImages[bike.id] ||
stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])!
.attribution
}
</span>
)}
</div>
) : (
<StockMotorcycleGraphic className="h-44" />
)}
{/* Image Action Overlay Button */}
<div className="absolute top-2 right-2 flex items-center gap-1.5">
{bike.imageUrl && (
<button
onClick={() => void handleRemovePhoto(bike.id)}
title="Revert to Stock Spec Photo"
className="p-1.5 rounded-lg bg-slate-900/90 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-700 text-xs font-mono transition shadow-lg cursor-pointer backdrop-blur-md"
>
Reset Stock
</button>
)}
<button
onClick={() => {
setUploadingMotorcycleId(bike.id);
fileInputRef.current?.click();
}}
className="px-2.5 py-1.5 rounded-lg bg-slate-900/90 hover:bg-brand-cyan hover:text-black text-slate-200 border border-slate-700 hover:border-brand-cyan text-xs font-mono font-medium transition shadow-lg flex items-center gap-1.5 cursor-pointer backdrop-blur-md"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
></path>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
></path>
</svg>
<span>{bike.imageUrl ? 'Change Photo' : 'Upload Photo'}</span>
</button>
</div>
</div>
{/* Bike Title & Specs */}
<div>
<span className="text-xs font-mono text-slate-400 tracking-normal">
{[bike.year || '', bike.makeName, bike.variantName || 'Standard Spec'].filter(Boolean).join(' • ')}
</span>
<h3 className="text-2xl font-black text-white mt-1 group-hover:text-brand-cyan transition-colors">
{bike.nickname}
</h3>
{bike.vin && (
<p className="text-xs font-mono text-slate-400 mt-0.5">VIN: {bike.vin}</p>
)}
</div>
{/* Telemetry Hardware Link Grid */}
<div className="grid grid-cols-3 gap-2.5 my-4 py-3.5 border-y border-brand-border font-mono text-xs">
<div className="bg-brand-cardAlt/60 p-2.5 rounded-lg border border-brand-border/60">
<span className="text-slate-400 block text-[10px]">Telemetry Link</span>
<span className="text-brand-emerald font-semibold flex items-center space-x-1 mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald inline-block"></span>
<span>CAN Active</span>
</span>
</div>
<div className="bg-brand-cardAlt/60 p-2.5 rounded-lg border border-brand-border/60">
<span className="text-slate-400 block text-[10px]">IMU Status</span>
<span className="text-brand-cyan font-semibold block mt-1">6-Axis Live</span>
</div>
<div className="bg-brand-cardAlt/60 p-2.5 rounded-lg border border-brand-border/60">
<span className="text-slate-400 block text-[10px]">Cloud Sync</span>
<span className="text-white font-semibold block mt-1">100% Synced</span>
</div>
</div>
</div>
))}
</div>
) : garageStatus === 'loading' ? (
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="garage-loading">
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-cyan/40 border-t-brand-cyan animate-spin"></div>
<p className="text-xs text-slate-400 font-mono">Loading motorcycle garage…</p>
</div>
) : garageStatus === 'permission-denied' || garageStatus === 'unavailable' || garageStatus === 'error' ? (
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-garage-error">
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
!
</div>
<h3 className="text-sm font-bold text-white">
{garageStatus === 'permission-denied'
? 'Garage Telemetry Restricted — Permission Denied'
: 'Garage Telemetry Unavailable'}
</h3>
<p className="text-xs text-slate-400 font-mono max-w-md mx-auto">
{garageError || (garageStatus === 'permission-denied'
? 'Your account does not have permission to read motorcycles from Firestore.'
: 'Unable to load motorcycle garage from cloud. Please verify connection and retry.')}
</p>
<button
type="button"
onClick={() => void loadData(user)}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
>
Retry Sync
</button>
</div>
) : (
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-4">
<StockMotorcycleGraphic className="max-w-md mx-auto h-36 opacity-60" />
<div className="space-y-1">
<h3 className="text-base font-bold text-white">No Motorcycles in Garage</h3>
<p className="text-xs text-slate-400 font-mono">
Add your bike to bind CAN-bus telemetry, IMU orientation, and lean angles.
</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 rounded-xl bg-gradient-to-r from-brand-cyan to-blue-600 text-slate-950 font-bold text-xs font-mono shadow-[0_0_16px_rgba(0,242,254,0.3)] transition cursor-pointer"
>
+ Add Your First Motorcycle
</button>
</div>
)}
</section>
{/* ========================================================================= */}
{/* SECTION 2: RECENT RIDES TELEMETRY FEED (White Area) */}
{/* ========================================================================= */}
<section className="space-y-6" data-purpose="recent-rides-telemetry">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-2 h-6 bg-brand-emerald rounded"></div>
<div>
<h2 className="text-xl font-bold text-white tracking-wide">
Recent Telemetry Sessions
</h2>
<p className="text-xs text-slate-400 font-mono mt-0.5">
High-fidelity 20Hz GNSS, lean angles, and speed telemetry logs
</p>
</div>
</div>
{ridesStatus === 'loading' ? (
<span className="text-xs font-mono text-slate-400 bg-slate-800 border border-slate-700 px-2.5 py-1 rounded-full">
Checking sessions…
</span>
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
<span className="text-xs font-mono text-rose-400 bg-rose-500/10 border border-rose-500/30 px-2.5 py-1 rounded-full">
Feed Unavailable
</span>
) : (
<span className="text-xs font-mono text-brand-emerald bg-brand-emerald/10 border border-brand-emerald/30 px-2.5 py-1 rounded-full">
{rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded
</span>
)}
</div>
{rides.length > 0 ? (
<div className="space-y-3.5">
{rides.slice(0, visibleRideCount).map((ride) => {
const isExpanded = expandedRideId === ride.id;
const dateStr = ride.startedAt
? ride.startedAt.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: 'Session Log';
const timeStr = ride.startedAt
? ride.startedAt.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
})
: '';
return (
<div
key={ride.id}
className="glass-card rounded-xl border border-brand-border hover:border-slate-600 p-5 transition-all duration-200"
>
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
{/* Ride Header Info */}
<div className="space-y-1.5 min-w-[220px]">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-white font-mono">{dateStr}</span>
{timeStr && <span className="text-xs text-slate-400 font-mono">• {timeStr}</span>}
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded bg-brand-cardAlt border border-brand-border text-[11px] font-mono text-slate-300">
{ride.motorcycle || 'Motorcycle'}
</span>
<span className="text-[10px] font-mono text-brand-emerald flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-brand-emerald"></span>
{ride.qualityScore}% Sync
</span>
</div>
</div>
{/* Metric Badges Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 flex-1">
{/* Distance */}
<div className="bg-surface-container-low/70 p-2.5 rounded-lg border border-outline-variant/30 text-left">
<span className="text-[10px] font-mono text-slate-400 block">Distance</span>
<span className="text-base font-extrabold text-white font-mono">
{ride.distanceMiles.toFixed(2)}{' '}
<span className="text-xs font-normal text-slate-400">mi</span>
</span>
</div>
{/* Top Speed */}
<div className="bg-surface-container-low/70 p-2.5 rounded-lg border border-outline-variant/30 text-left">
<span className="text-[10px] font-mono text-slate-400 block">Top Speed</span>
<span className="text-base font-extrabold text-brand-cyan font-mono">
{ride.topSpeedMph.toFixed(1)}{' '}
<span className="text-xs font-normal text-slate-400">mph</span>
</span>
</div>
{/* Peak Lean Angle */}
<div className="bg-surface-container-low/70 p-2.5 rounded-lg border border-outline-variant/30 text-left">
<span className="text-[10px] font-mono text-slate-400 block">Max Lean (L / R)</span>
<span className="text-base font-extrabold text-emerald-400 font-mono flex items-center gap-1">
<span>{ride.maxLeanLeft.toFixed(0)}°</span>
<span className="text-slate-500 font-normal">/</span>
<span>{ride.maxLeanRight.toFixed(0)}°</span>
</span>
</div>
{/* Duration */}
<div className="bg-surface-container-low/70 p-2.5 rounded-lg border border-outline-variant/30 text-left">
<span className="text-[10px] font-mono text-slate-400 block">Duration</span>
<span className="text-base font-extrabold text-white font-mono">
{ride.movingTime > 0 ? formatDuration(ride.movingTime) : '< 1m'}
</span>
</div>
</div>
{/* Ride Actions */}
<div className="flex items-center justify-end gap-2 flex-wrap">
<button
onClick={() => setReplayRide(ride)}
data-testid={`replay-ride-${ride.id}`}
className="px-3 py-1.5 rounded-lg bg-surface-container-high hover:bg-brand-cyan hover:text-black text-xs font-mono font-bold text-brand-cyan transition flex items-center gap-1.5 cursor-pointer border border-brand-cyan/30 shadow-sm"
title="Interactive in-browser telemetry replay"
>
<svg className="w-3.5 h-3.5 fill-current" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<span>Replay Ride</span>
</button>
<button
onClick={(e) => handleExportRideGpx(e, ride)}
data-testid={`export-gpx-${ride.id}`}
className="px-2.5 py-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container-high text-xs font-mono text-slate-300 hover:text-white transition flex items-center gap-1 cursor-pointer border border-outline-variant/30"
title="Export GPX (1.1)"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span>GPX</span>
</button>
<button
onClick={(e) => handleExportRideCsv(e, ride)}
data-testid={`export-csv-${ride.id}`}
className="px-2.5 py-1.5 rounded-lg bg-surface-container-low hover:bg-surface-container-high text-xs font-mono text-slate-300 hover:text-white transition flex items-center gap-1 cursor-pointer border border-outline-variant/30"
title="Export 20 Hz CSV"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>20 Hz CSV</span>
</button>
<button
onClick={(e) => void handleDeleteRide(e, ride)}
data-testid={`delete-ride-${ride.id}`}
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 transition-colors cursor-pointer border border-outline-variant/30"
title="Delete session"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
<button
onClick={() => setExpandedRideId(isExpanded ? null : ride.id)}
className="px-2.5 py-1.5 rounded-lg bg-surface-container-high hover:bg-slate-700 text-xs font-mono text-slate-300 hover:text-white transition flex items-center gap-1.5 cursor-pointer"
>
<span>{isExpanded ? 'Less' : 'Details'}</span>
<svg
className={`w-3.5 h-3.5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
</div>
</div>
{/* Expanded Telemetry Drawer */}
{isExpanded && (
<div className="mt-4 pt-4 border-t border-outline-variant/30 grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs font-mono animate-fadeIn">
<div className="p-2 rounded bg-surface-container-low/40">
<span className="text-slate-400 block text-[10px]">AVG SPEED</span>
<span className="text-slate-200 font-semibold mt-0.5 block">
{ride.averageSpeed > 0 ? `${ride.averageSpeed.toFixed(1)} mph` : 'N/A'}
</span>
</div>
<div className="p-2 rounded bg-surface-container-low/40">
<span className="text-slate-400 block text-[10px]">PEAK ACCEL</span>
<span className="text-brand-cyan font-semibold mt-0.5 block">
{ride.maxAccelG > 0 ? `+${ride.maxAccelG.toFixed(2)} G` : '+0.00 G'}
</span>
</div>
<div className="p-2 rounded bg-surface-container-low/40">
<span className="text-slate-400 block text-[10px]">PEAK DECEL</span>
<span className="text-rose-400 font-semibold mt-0.5 block">
{ride.maxBrakingG > 0 ? `-${ride.maxBrakingG.toFixed(2)} G` : '-0.00 G'}
</span>
</div>
<div className="p-2 rounded bg-surface-container-low/40">
<span className="text-slate-400 block text-[10px]">ELEVATION GAIN</span>
<span className="text-slate-200 font-semibold mt-0.5 block">
{ride.elevationGain > 0 ? `${Math.round(ride.elevationGain)} ft` : '0 ft'}
</span>
</div>
</div>
)}
</div>
);
})}
{/* Show more button when more sessions exist */}
{rides.length > visibleRideCount && (
<div className="pt-4 flex flex-col items-center justify-center gap-2">
<button
onClick={() => setVisibleRideCount((prev) => prev + 10)}
data-testid="show-more-rides-btn"
className="px-5 py-2.5 rounded-xl bg-surface-container-high hover:bg-brand-cyan hover:text-black text-xs font-mono font-bold text-brand-cyan border border-brand-cyan/40 transition-all duration-200 shadow-md flex items-center gap-2 cursor-pointer"
>
<span>Show more</span>
<span className="text-[10px] opacity-80">
(+{Math.min(10, rides.length - visibleRideCount)} remaining)
</span>
</button>
<span className="text-[11px] font-mono text-slate-500">
Showing {Math.min(visibleRideCount, rides.length)} of {rides.length} sessions
</span>
</div>
)}
</div>
) : ridesStatus === 'loading' ? (
<div className="glass-card rounded-2xl border border-slate-800 p-8 text-center space-y-3" data-testid="rides-loading">
<div className="w-8 h-8 mx-auto rounded-full border-2 border-brand-emerald/40 border-t-brand-emerald animate-spin"></div>
<p className="text-xs text-slate-400 font-mono">Loading telemetry sessions…</p>
</div>
) : ridesStatus === 'permission-denied' || ridesStatus === 'unavailable' || ridesStatus === 'error' ? (
<div className="glass-card rounded-2xl border border-rose-500/30 bg-rose-500/5 p-8 text-center space-y-3" data-testid="cloud-rides-error">
<div className="w-10 h-10 mx-auto rounded-full bg-rose-500/20 text-rose-400 flex items-center justify-center font-bold">
!
</div>
<h3 className="text-base font-bold text-white">
{ridesStatus === 'permission-denied'
? 'Recent Rides Restricted — Permission Denied'
: 'Recent Rides Cloud Feed Unavailable'}
</h3>
<p className="text-xs text-slate-400 font-mono max-w-sm mx-auto">
{ridesError || (ridesStatus === 'permission-denied'
? 'Firestore rejected read access to telemetry sessions for this rider.'
: 'Unable to retrieve telemetry sessions from MotorSignal cloud. Please check network and retry.')}
</p>
<button
type="button"
onClick={() => void loadData(user)}
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-mono border border-slate-600 transition cursor-pointer"
>
Retry Cloud Sync
</button>
</div>
) : (
<div className="glass-card rounded-2xl border border-dashed border-slate-700 p-8 text-center space-y-3">
<div className="w-12 h-12 mx-auto rounded-full bg-surface-container flex items-center justify-center text-slate-500">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<h3 className="text-base font-bold text-white">No Telemetry Rides Synced Yet</h3>
<p className="text-xs text-slate-400 font-mono max-w-sm mx-auto">
Record a ride using the MotorSignal mobile app. Rides are automatically uploaded to Cloudflare R2 and Firestore upon completion.
</p>
</div>
)}
</section>
{/* ========================================================================= */}
{/* MODAL: ADD MOTORCYCLE FORM (Blue Area) */}
{/* ========================================================================= */}
{showAddModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-fadeIn">
<div className="glass-card rounded-2xl border border-brand-border max-w-lg w-full p-6 sm:p-8 space-y-5 relative shadow-2xl overflow-y-auto max-h-[90vh]">
<div className="flex items-center justify-between border-b border-brand-border pb-4">
<div>
<h3 className="text-xl font-bold text-white">Add Motorcycle</h3>
<p className="text-xs text-slate-400 font-mono mt-0.5">Register a bike in your cloud garage</p>
</div>
<button
onClick={() => setShowAddModal(false)}
className="text-slate-400 hover:text-white p-1 rounded-lg cursor-pointer"
>
✕
</button>
</div>
<form onSubmit={handleAddMotorcycle} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">Year</label>
<input
required
type="number"
min="1970"
max="2030"
value={newYear}
onChange={(e) => setNewYear(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">Make</label>
<input
required
placeholder="e.g. Yamaha, Ducati"
value={newMake}
onChange={(e) => setNewMake(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">Model</label>
<input
required
placeholder="e.g. YZF-R6, Panigale V4"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">Variant / Spec</label>
<input
placeholder="e.g. Standard, SP"
value={newVariant}
onChange={(e) => setNewVariant(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">Nickname (Optional)</label>
<input
placeholder="e.g. Canyon Weapon, Track R6"
value={newNickname}
onChange={(e) => setNewNickname(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-mono text-slate-400">VIN (Optional)</label>
<input
placeholder="17-character VIN"
value={newVin}
onChange={(e) => setNewVin(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-surface-container-low border border-outline-variant/40 text-white font-mono text-sm outline-none focus:border-brand-cyan"
/>
</div>
{/* Photo Upload in Modal */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-mono text-slate-400 block">Motorcycle Photo (Optional)</label>
<span className="text-[10px] font-mono text-brand-cyan">Auto-pulls stock photo if left empty</span>
</div>
{newImagePreview ? (
<div className="relative h-32 rounded-xl overflow-hidden border border-brand-cyan/40">
<img src={newImagePreview} alt="Preview" className="w-full h-full object-cover" />
<button
type="button"
onClick={() => setNewImagePreview(null)}
className="absolute top-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded hover:bg-rose-600 transition"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center p-4 rounded-xl border border-dashed border-outline-variant/40 hover:border-brand-cyan/60 bg-surface-container-low/60 cursor-pointer text-xs font-mono text-slate-400 hover:text-brand-cyan transition">
<span>+ Upload Custom Photo (Optional)</span>
<span className="text-[10px] text-slate-500 font-normal mt-0.5">
OEM stock photo will automatically display for this motorcycle
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(e) => void handleImageFileSelected(e)}
/>
</label>
)}
</div>
<div className="flex items-center gap-2 pt-2">
<input
type="checkbox"
id="makeDefaultCheckbox"
checked={newIsDefault}
onChange={(e) => setNewIsDefault(e.target.checked)}
className="rounded bg-surface-container-low border-outline-variant/40 text-brand-cyan focus:ring-brand-cyan"
/>
<label htmlFor="makeDefaultCheckbox" className="text-xs font-mono text-slate-300 cursor-pointer">
Set as Primary Default Motorcycle
</label>
</div>
{formError && (
<p className="text-xs font-mono text-rose-400 bg-rose-500/10 p-2.5 rounded-lg border border-rose-500/20">
{formError}
</p>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-brand-border">
<button
type="button"
onClick={() => setShowAddModal(false)}
className="px-4 py-2 rounded-xl text-xs font-mono text-slate-400 hover:text-white transition cursor-pointer"
>
Cancel
</button>
<button
disabled={formBusy}
type="submit"
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-brand-cyan to-blue-600 text-slate-950 font-bold text-xs font-mono shadow-[0_0_20px_rgba(0,242,254,0.3)] transition cursor-pointer disabled:opacity-60"
>
{formBusy ? 'Adding Bike...' : 'Save Motorcycle'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Telemetry Replay Modal */}
{replayRide && (
<WebTelemetryReplayModal
ride={replayRide}
onClose={() => setReplayRide(null)}
/>
)}
{/* Floating Export Notice Toast */}
{exportNotice && (
<div className="fixed bottom-6 right-6 z-50 bg-slate-900 border border-brand-cyan/60 text-brand-cyan px-4 py-3 rounded-xl shadow-2xl font-mono text-xs flex items-center gap-2.5 animate-fadeIn">
<span className="w-2 h-2 rounded-full bg-brand-cyan animate-pulse"></span>
<span>{exportNotice}</span>
</div>
)}
</div>
);
}