Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions apps/frontend/app/components/runs/main-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,30 +169,28 @@ export function MainContent({
jsonData={jsonData}
onOpenComments={(r) => { setCommentsRun(r); setCommentsOpen(true); }}
/>
<SectionDivider />

<DisplacementSection
<SummarySection
selected={runs}
jsonData={jsonData}
isCompareMode={isCompareMode}
/>

<SectionDivider />

{/* Always visible: Histogram */}
<HistogramSection
<DisplacementSection
selected={runs}
jsonData={jsonData}
isCompareMode={isCompareMode}
/>

<SectionDivider />

<SummarySection
{/* Always visible: Histogram */}
<HistogramSection
selected={runs}
jsonData={jsonData}
isCompareMode={isCompareMode}
/>

</>
)}
</div>
Expand Down
130 changes: 115 additions & 15 deletions apps/frontend/app/components/runs/summary-section.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,44 @@
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[];
jsonData: Record<number, RunJson>;
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';
Expand Down Expand Up @@ -100,11 +128,10 @@ function TravelZoneCell({ value, seconds }: TravelZoneCellProps) {

interface RunSummaryRowProps {
run: Run;
jsonData: Record<number, RunJson>;
metrics: RunMetrics;
}

function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) {
const metrics = useRunMetrics(run, jsonData);
function RunSummaryRow({ run, metrics }: RunSummaryRowProps) {

return (
<tr className="border-t border-border">
Expand Down Expand Up @@ -137,10 +164,10 @@ function RunSummaryRow({ run, jsonData }: RunSummaryRowProps) {

interface SummaryTableProps {
selected: Run[];
jsonData: Record<number, RunJson>;
metricsMap: Map<number, RunMetrics>;
}

function SummaryTable({ selected, jsonData }: SummaryTableProps) {
function SummaryTable({ selected, metricsMap }: SummaryTableProps) {
if (!selected || selected.length === 0) {
return (
<div className="text-sm text-muted-foreground">No runs selected.</div>
Expand Down Expand Up @@ -200,7 +227,7 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) {
</thead>
<tbody>
{selected.map((run) => (
<RunSummaryRow key={run.id} run={run} jsonData={jsonData} />
<RunSummaryRow key={run.id} run={run} metrics={metricsMap.get(run.id)!} />
))}
</tbody>
</table>
Expand All @@ -210,12 +237,11 @@ function SummaryTable({ selected, jsonData }: SummaryTableProps) {

interface MobileRunSummaryRowProps {
run: Run;
jsonData: Record<number, RunJson>;
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';

Expand Down Expand Up @@ -246,10 +272,10 @@ function MobileRunSummaryRow({ run, jsonData, type }: MobileRunSummaryRowProps)

interface MobileSummaryTableProps {
selected: Run[];
jsonData: Record<number, RunJson>;
metricsMap: Map<number, RunMetrics>;
}

function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) {
function MobileSummaryTable({ selected, metricsMap }: MobileSummaryTableProps) {
if (!selected || selected.length === 0) {
return <div className="text-sm text-muted-foreground">No runs selected.</div>;
}
Expand All @@ -269,7 +295,7 @@ function MobileSummaryTable({ selected, jsonData }: MobileSummaryTableProps) {
</thead>
<tbody>
{selected.map((run) => (
<MobileRunSummaryRow key={run.id} run={run} jsonData={jsonData} type={type} />
<MobileRunSummaryRow key={run.id} run={run} metrics={metricsMap.get(run.id)!} type={type} />
))}
</tbody>
</table>
Expand All @@ -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 (
<ul className="space-y-0.5">
{display.map((item) => (
<li key={item} className="text-sm text-muted-foreground flex gap-2">
<span className="text-muted-foreground select-none">•</span>
{item}
</li>
))}
</ul>
);
}

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 (
<div className="rounded-md border border-border bg-white p-3">
<p className="text-sm font-semibold text-foreground mb-3">{title}</p>
<div className="flex flex-col gap-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Fork</p>
<ComponentRecommendationList items={forkItems} />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-1">Shock</p>
<ComponentRecommendationList items={shockItems} />
</div>
</div>
</div>
);
}

interface RecommendationsSummaryProps {
selected: Run[];
metricsMap: Map<number, RunMetrics>;
}

function RecommendationsSummary({ selected, metricsMap }: RecommendationsSummaryProps) {
if (selected.length === 0) return null;

return (
<div className="mt-4">
<p className="text-sm font-semibold text-foreground mb-2">Recommendations</p>
<div className="flex flex-col gap-2">
{selected.map((run) => (
<RunRecommendationsBlock key={run.id} run={run} metrics={metricsMap.get(run.id)!} />
))}
</div>
</div>
);
}

export function SummarySection({
selected,
jsonData,
isCompareMode,
}: SummarySectionProps) {
const metricsMap = useMemo(
() => new Map(selected.map((run) => [run.id, computeRunMetrics(run, jsonData)])),
[selected, jsonData],
);

return (
<section className="w-full">
<SectionHeader>{isCompareMode ? "Comparison Summary" : "Summary"}</SectionHeader>
{/* Mobile: tabbed Fork / Shock view */}
<div className="md:hidden">
<MobileSummaryTable selected={selected} jsonData={jsonData} />
<MobileSummaryTable selected={selected} metricsMap={metricsMap} />
</div>
{/* Desktop: full table */}
<div className="hidden md:block">
<SummaryTable selected={selected} jsonData={jsonData} />
<SummaryTable selected={selected} metricsMap={metricsMap} />
</div>
<RecommendationsSummary selected={selected} metricsMap={metricsMap} />
</section>
);
}
70 changes: 37 additions & 33 deletions apps/frontend/app/hooks/useRunMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,37 +87,41 @@ function calculateRebound(_run: Run, _jsonData: Record<number, RunJson>, _type:
return 0;
}

export function useRunMetrics(run: Run, jsonData: Record<number, RunJson>) {
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<number, RunJson>) {
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<typeof computeRunMetrics>;

export function useRunMetrics(run: Run, jsonData: Record<number, RunJson>): RunMetrics {
return useMemo(() => computeRunMetrics(run, jsonData), [run, jsonData]);
}
Loading