diff --git a/apps/frontend/app/components/runs/main-content.tsx b/apps/frontend/app/components/runs/main-content.tsx index 44d9af3..03bea2f 100644 --- a/apps/frontend/app/components/runs/main-content.tsx +++ b/apps/frontend/app/components/runs/main-content.tsx @@ -169,17 +169,15 @@ export function MainContent({ jsonData={jsonData} onOpenComments={(r) => { setCommentsRun(r); setCommentsOpen(true); }} /> + - - - - {/* Always visible: Histogram */} - - - )} diff --git a/apps/frontend/app/components/runs/summary-section.tsx b/apps/frontend/app/components/runs/summary-section.tsx index a47c23f..9192ff1 100644 --- a/apps/frontend/app/components/runs/summary-section.tsx +++ b/apps/frontend/app/components/runs/summary-section.tsx @@ -1,9 +1,10 @@ +import { useMemo } from "react"; import { Run } from "@repo/database"; import { RunJson } from "app/types/runs"; import { SectionHeader } from "app/components/ui/run-elements"; import { cn } from "app/lib/utils"; import { ArrowUp, ArrowDown } from "lucide-react"; -import { useRunMetrics, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, BOTTOM_OUT_COUNT_THRESHOLD, BOTTOM_OUT_TRAVEL_MIN } from "app/hooks/useRunMetrics"; +import { computeRunMetrics, type RunMetrics, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, BOTTOM_OUT_COUNT_THRESHOLD, BOTTOM_OUT_TRAVEL_MIN } from "app/hooks/useRunMetrics"; interface SummarySectionProps { selected: Run[]; @@ -11,6 +12,33 @@ interface SummarySectionProps { isCompareMode: boolean; } +function getComponentRecommendations( + sag: number | null, + sagMin: number, + sagMax: number, + sagInRange: boolean | null, + bottomOutCount: number | null, + maxTravel: number | null, +): string[] { + const items: string[] = []; + + if (sag !== null) { + if (sag < sagMin) items.push(`Reduce pressure (sag too low: ${sag.toFixed(1)}%)`); + else if (sag > sagMax) items.push(`Increase pressure (sag too high: ${sag.toFixed(1)}%)`); + } + + if (bottomOutCount !== null) { + const sagWarning = sagInRange === false ? 'Sag not in range — suggestions may be inaccurate. ' : ''; + if (bottomOutCount > BOTTOM_OUT_COUNT_THRESHOLD) { + items.push(`${sagWarning}Add a volume spacer (${bottomOutCount} bottom-outs)`); + } else if (bottomOutCount === 0 && maxTravel !== null && maxTravel < BOTTOM_OUT_TRAVEL_MIN) { + items.push(`${sagWarning}Remove volume spacer (never reached travel)`); + } + } + + return items; +} + interface SagCellProps { value: number | null; type: 'front' | 'rear'; @@ -100,11 +128,10 @@ function TravelZoneCell({ value, seconds }: TravelZoneCellProps) { interface RunSummaryRowProps { run: Run; - jsonData: Record; + metrics: RunMetrics; } -function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) { - const metrics = useRunMetrics(run, jsonData); +function RunSummaryRow({ run, metrics }: RunSummaryRowProps) { return ( @@ -137,10 +164,10 @@ function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) { interface SummaryTableProps { selected: Run[]; - jsonData: Record; + metricsMap: Map; } -function SummaryTable({ selected, jsonData }: SummaryTableProps) { +function SummaryTable({ selected, metricsMap }: SummaryTableProps) { if (!selected || selected.length === 0) { return (
No runs selected.
@@ -200,7 +227,7 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) { {selected.map((run) => ( - + ))} @@ -210,12 +237,11 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) { interface MobileRunSummaryRowProps { run: Run; - jsonData: Record; + metrics: RunMetrics; type: 'fork' | 'shock'; } -function MobileRunSummaryRow({ run, jsonData, type }: MobileRunSummaryRowProps) { - const metrics = useRunMetrics(run, jsonData); +function MobileRunSummaryRow({ run, metrics, type }: MobileRunSummaryRowProps) { const isFork = type === 'fork'; @@ -246,10 +272,10 @@ function MobileRunSummaryRow({ run, jsonData, type }: MobileRunSummaryRowProps) interface MobileSummaryTableProps { selected: Run[]; - jsonData: Record; + metricsMap: Map; } -function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { +function MobileSummaryTable({ selected, metricsMap }: MobileSummaryTableProps) { if (!selected || selected.length === 0) { return
No runs selected.
; } @@ -269,7 +295,7 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { {selected.map((run) => ( - + ))} @@ -290,22 +316,96 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) { ); } +interface RunRecommendationsBlockProps { + run: Run; + metrics: RunMetrics; +} + +function ComponentRecommendationList({ items }: { items: string[] }) { + const display = items.length > 0 ? items : ['No issues detected.']; + return ( +
    + {display.map((item) => ( +
  • + + {item} +
  • + ))} +
+ ); +} + +function RunRecommendationsBlock({ run, metrics }: RunRecommendationsBlockProps) { + const title = run.title || `Run ${run.id}`; + + const forkItems = getComponentRecommendations( + metrics.frontSag, DYNAMIC_SAG_IDEAL_MIN_FRONT, DYNAMIC_SAG_IDEAL_MAX_FRONT, + metrics.frontSagInRange, metrics.frontBottomOutCount, metrics.frontMaxTravel, + ); + const shockItems = getComponentRecommendations( + metrics.rearSag, DYNAMIC_SAG_IDEAL_MIN_REAR, DYNAMIC_SAG_IDEAL_MAX_REAR, + metrics.rearSagInRange, metrics.rearBottomOutCount, metrics.rearMaxTravel, + ); + + return ( +
+

{title}

+
+
+

Fork

+ +
+
+

Shock

+ +
+
+
+ ); +} + +interface RecommendationsSummaryProps { + selected: Run[]; + metricsMap: Map; +} + +function RecommendationsSummary({ selected, metricsMap }: RecommendationsSummaryProps) { + if (selected.length === 0) return null; + + return ( +
+

Recommendations

+
+ {selected.map((run) => ( + + ))} +
+
+ ); +} + export function SummarySection({ selected, jsonData, isCompareMode, }: SummarySectionProps) { + const metricsMap = useMemo( + () => new Map(selected.map((run) => [run.id, computeRunMetrics(run, jsonData)])), + [selected, jsonData], + ); + return (
{isCompareMode ? "Comparison Summary" : "Summary"} {/* Mobile: tabbed Fork / Shock view */}
- +
{/* Desktop: full table */}
- +
+
); } diff --git a/apps/frontend/app/hooks/useRunMetrics.ts b/apps/frontend/app/hooks/useRunMetrics.ts index fcc3ab8..8aa4f69 100644 --- a/apps/frontend/app/hooks/useRunMetrics.ts +++ b/apps/frontend/app/hooks/useRunMetrics.ts @@ -87,37 +87,41 @@ function calculateRebound(_run: Run, _jsonData: Record, _type: return 0; } -export function useRunMetrics(run: Run, jsonData: Record) { - return useMemo(() => { - const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); - const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); - const frontFreq = run.front_freq ?? null; - const rearFreq = run.rear_freq ?? null; - - const frontSag = dynamicSag(frontNorm); - const rearSag = dynamicSag(rearNorm); - - return { - frontSag, - rearSag, - frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, - rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, - frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - frontMaxTravel: maxTravel(frontNorm), - frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), - frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), - rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), - rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), - rearMaxTravel: maxTravel(rearNorm), - rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), - rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), - frontCompression: calculateCompression(run, jsonData, 'front'), - rearCompression: calculateCompression(run, jsonData, 'rear'), - frontRebound: calculateRebound(run, jsonData, 'front'), - rearRebound: calculateRebound(run, jsonData, 'rear'), - }; - }, [run, jsonData]); +export function computeRunMetrics(run: Run, jsonData: Record) { + const frontNorm = getNormalizedSuspensionData(run, jsonData, 'front'); + const rearNorm = getNormalizedSuspensionData(run, jsonData, 'rear'); + const frontFreq = run.front_freq ?? null; + const rearFreq = run.rear_freq ?? null; + + const frontSag = dynamicSag(frontNorm); + const rearSag = dynamicSag(rearNorm); + + return { + frontSag, + rearSag, + frontSagInRange: frontSag === null ? null : frontSag >= DYNAMIC_SAG_IDEAL_MIN_FRONT && frontSag <= DYNAMIC_SAG_IDEAL_MAX_FRONT, + rearSagInRange: rearSag === null ? null : rearSag >= DYNAMIC_SAG_IDEAL_MIN_REAR && rearSag <= DYNAMIC_SAG_IDEAL_MAX_REAR, + frontBottomOutPct: zonePercent(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutSec: zoneSeconds(frontNorm, frontFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + frontBottomOutCount: countZoneEntries(frontNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + frontMaxTravel: maxTravel(frontNorm), + frontOffGroundPct: zonePercent(frontNorm, 0, OFF_GROUND_TRAVEL_MAX), + frontOffGroundSec: zoneSeconds(frontNorm, frontFreq, 0, OFF_GROUND_TRAVEL_MAX), + rearBottomOutPct: zonePercent(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutSec: zoneSeconds(rearNorm, rearFreq, BOTTOM_OUT_TRAVEL_MIN, 100), + rearBottomOutCount: countZoneEntries(rearNorm, BOTTOM_OUT_TRAVEL_MIN, 100), + rearMaxTravel: maxTravel(rearNorm), + rearOffGroundPct: zonePercent(rearNorm, 0, OFF_GROUND_TRAVEL_MAX), + rearOffGroundSec: zoneSeconds(rearNorm, rearFreq, 0, OFF_GROUND_TRAVEL_MAX), + frontCompression: calculateCompression(run, jsonData, 'front'), + rearCompression: calculateCompression(run, jsonData, 'rear'), + frontRebound: calculateRebound(run, jsonData, 'front'), + rearRebound: calculateRebound(run, jsonData, 'rear'), + }; +} + +export type RunMetrics = ReturnType; + +export function useRunMetrics(run: Run, jsonData: Record): RunMetrics { + return useMemo(() => computeRunMetrics(run, jsonData), [run, jsonData]); }