'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 } 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;
};
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 (
{/* Background technical grid */}
Stock Spec Render
);
}
interface AppCloudDataProps {
syncTrigger?: number;
onSyncStateChange?: (syncing: boolean) => void;
onMetricsLoaded?: (metrics: { totalMiles: number; rideCount: number; peakLean: number }) => void;
}
export function AppCloudData({ syncTrigger = 0, onSyncStateChange, onMetricsLoaded }: AppCloudDataProps) {
const [motorcycles, setMotorcycles] = useState([]);
const [rides, setRides] = useState([]);
const [state, setState] = useState('Loading telemetry cloud data...');
const [user, setUser] = useState(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(null);
const [formBusy, setFormBusy] = useState(false);
const [formError, setFormError] = useState('');
// Image Upload Target State
const [uploadingMotorcycleId, setUploadingMotorcycleId] = useState(null);
const fileInputRef = useRef(null);
// Expandable Ride Detail state
const [expandedRideId, setExpandedRideId] = useState(null);
// Replay Modal & Export State
const [replayRide, setReplayRide] = useState(null);
const [exportNotice, setExportNotice] = useState(null);
function handleExportRideGpx(e: React.MouseEvent, ride: Ride) {
e.stopPropagation();
const gpx = generateRideGpx(ride);
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);
}
function handleExportRideCsv(e: React.MouseEvent, ride: Ride) {
e.stopPropagation();
const csv = generateRideCsv(ride);
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);
}
// Auto-resolved Stock Photography Cache (from /api/motorcycle-image)
const [stockImages, setStockImages] = useState<
Record
>({});
// 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([]);
setState('Sign in or connect your Google account to view your live telemetry.');
if (onMetricsLoaded) onMetricsLoaded({ totalMiles: 0, rideCount: 0, peakLean: 0 });
return;
}
if (onSyncStateChange) onSyncStateChange(true);
try {
const [motorcycleSnapshot, rideSnapshot] = await Promise.all([
getDocs(collection(firestore, 'users', currentUser.uid, 'motorcycles')),
getDocs(collection(firestore, 'users', currentUser.uid, 'rides')),
]);
const loadedMotorcycles: Motorcycle[] = motorcycleSnapshot.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));
let totalMiles = 0;
let peakLean = 0;
const loadedRides: Ride[] = rideSnapshot.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;
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(' '),
};
}).sort((a, b) => (b.startedAt?.getTime() ?? 0) - (a.startedAt?.getTime() ?? 0));
setMotorcycles(loadedMotorcycles);
setRides(loadedRides);
setState('');
if (onMetricsLoaded) {
onMetricsLoaded({
totalMiles: Math.round(totalMiles),
rideCount: loadedRides.length,
peakLean: Number(peakLean.toFixed(1)),
});
}
} 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.');
} 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, 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 (
{/* Hidden file input for photo uploads */}
void handleImageFileSelected(e, uploadingMotorcycleId ?? undefined)}
/>
{/* ========================================================================= */}
{/* SECTION 1: VEHICLE GARAGE (Blue & Purple Areas) */}
{/* ========================================================================= */}
Vehicle Garage & Telemetry Hardware
Synced bikes, ECU links, and motorcycle profiles
{/* Motorcycle Grid */}
{motorcycles.length > 0 ? (
{motorcycles.map((bike) => (
{/* Accent top gradient */}
{/* Top Badges & Actions */}
{bike.isDefault ? (
Default Primary
) : (
)}
{/* Delete Motorcycle Action */}
{/* Motorcycle Image Area (Purple Area - Stock Photo or Custom) */}
{bike.imageUrl ? (
CUSTOM RIDER PHOTO
) : (stockImages[bike.id] || stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])?.imageUrl ? (
STOCK SPEC PHOTO
{((stockImages[bike.id] ||
stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])!
.attribution) && (
{
(stockImages[bike.id] ||
stockImages[`${bike.makeName}_${bike.modelName}_${bike.year || ''}`.toLowerCase()])!
.attribution
}
)}
) : (
)}
{/* Image Action Overlay Button */}
{bike.imageUrl && (
)}
{/* Bike Title & Specs */}
{[bike.year || '', bike.makeName, bike.variantName || 'Standard Spec'].filter(Boolean).join(' • ')}
{bike.nickname}
{bike.vin && (
VIN: {bike.vin}
)}
{/* Telemetry Hardware Link Grid */}
Telemetry Link
CAN Active
IMU Status
6-Axis Live
Cloud Sync
100% Synced
))}
) : (
No Motorcycles in Garage
Add your bike to bind CAN-bus telemetry, IMU orientation, and lean angles.
)}
{/* ========================================================================= */}
{/* SECTION 2: RECENT RIDES TELEMETRY FEED (White Area) */}
{/* ========================================================================= */}
Recent Telemetry Sessions
High-fidelity 20Hz GNSS, lean angles, and speed telemetry logs
{rides.length} {rides.length === 1 ? 'Session' : 'Sessions'} Recorded
{rides.length > 0 ? (
{rides.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 (
{/* Ride Header Info */}
{dateStr}
{timeStr && • {timeStr}}
{ride.motorcycle || 'Motorcycle'}
{ride.qualityScore}% Sync
{/* Metric Badges Grid */}
{/* Distance */}
Distance
{ride.distanceMiles.toFixed(2)}{' '}
mi
{/* Top Speed */}
Top Speed
{ride.topSpeedMph.toFixed(1)}{' '}
mph
{/* Peak Lean Angle */}
Max Lean (L / R)
{ride.maxLeanLeft.toFixed(0)}°
/
{ride.maxLeanRight.toFixed(0)}°
{/* Duration */}
Duration
{ride.movingTime > 0 ? formatDuration(ride.movingTime) : '< 1m'}
{/* Ride Actions */}
{/* Expanded Telemetry Drawer */}
{isExpanded && (
AVG SPEED
{ride.averageSpeed > 0 ? `${ride.averageSpeed.toFixed(1)} mph` : 'N/A'}
PEAK ACCEL
{ride.maxAccelG > 0 ? `+${ride.maxAccelG.toFixed(2)} G` : '+0.00 G'}
PEAK DECEL
{ride.maxBrakingG > 0 ? `-${ride.maxBrakingG.toFixed(2)} G` : '-0.00 G'}
ELEVATION GAIN
{ride.elevationGain > 0 ? `${Math.round(ride.elevationGain)} ft` : '0 ft'}
)}
);
})}
) : (
No Telemetry Rides Synced Yet
Record a ride using the MotorSignal mobile app. Rides are automatically uploaded to Cloudflare R2 and Firestore upon completion.
)}
{/* ========================================================================= */}
{/* MODAL: ADD MOTORCYCLE FORM (Blue Area) */}
{/* ========================================================================= */}
{showAddModal && (
Add Motorcycle
Register a bike in your cloud garage
)}
{/* Telemetry Replay Modal */}
{replayRide && (
setReplayRide(null)}
/>
)}
{/* Floating Export Notice Toast */}
{exportNotice && (
{exportNotice}
)}
);
}