1464 lines
67 KiB
TypeScript
1464 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')),
|
|
]);
|
|
|
|
// Guard against race conditions if user logged out or changed while async queries were in-flight
|
|
if (firebaseAuth.currentUser?.uid !== currentUser.uid) {
|
|
return;
|
|
}
|
|
|
|
// 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 & 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>
|
|
);
|
|
}
|
|
|