Skip to content
2 changes: 1 addition & 1 deletion vite-project/src/components/ui/accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const AccordionItem = React.forwardRef<
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
className={cn(className)}
{...props}
/>
))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* DistanceSelector — Visual card-based distance picker grouped by category
*/

import { Check } from "lucide-react";
import { INPUT_DISTANCES } from "../types";

const DISTANCE_GROUPS = [
{
label: "Track",
distances: INPUT_DISTANCES.filter((d) => d.meters <= 1609.34),
},
{
label: "Road",
distances: INPUT_DISTANCES.filter((d) => d.meters > 1609.34 && d.meters <= 15000),
},
{
label: "Endurance",
distances: INPUT_DISTANCES.filter((d) => d.meters > 15000),
},
];

const DISTANCE_CONTEXT: Record<string, string> = {
"800m": "Half mile",
"1500m": "Metric mile",
Mile: "Classic mile",
"3K": "Cross country",
"5K": "Park run",
"10K": "Road classic",
"15K": "Long road race",
"Half Marathon": "13.1 miles",
Marathon: "26.2 miles",
};

interface DistanceSelectorProps {
selectedMeters: number;
onSelect: (meters: number, name: string) => void;
error?: string;
}

export function DistanceSelector({ selectedMeters, onSelect, error }: DistanceSelectorProps) {
return (
<div>
<label className="block text-sm font-semibold text-gray-700 mb-3">
Race Distance
</label>

<div className="space-y-4">
{DISTANCE_GROUPS.map((group) => (
<div key={group.label}>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">
{group.label}
</p>
<div className="grid grid-cols-3 gap-2">
{group.distances.map((d) => {
const isSelected = selectedMeters === d.meters;
return (
<button
key={d.name}
onClick={() => onSelect(d.meters, d.name)}

Check warning on line 60 in vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx#L60

Returning a void expression from an arrow function shorthand is forbidden. Please add braces to the arrow function.
className={`relative p-3 rounded-xl text-left transition-all duration-200 border-2 ${
isSelected
? "border-blue-500 bg-blue-50 shadow-md"
: "border-gray-100 bg-white hover:border-gray-200 hover:shadow-sm"
}`}
>
Comment on lines +58 to +66
{isSelected && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
</div>
)}
<span
className={`block text-sm font-bold ${
isSelected ? "text-blue-700" : "text-gray-900"
}`}
>
{d.label}
</span>
<span
className={`block text-xs mt-0.5 ${
isSelected ? "text-blue-500" : "text-gray-400"
}`}
>
{DISTANCE_CONTEXT[d.name] || ""}
</span>
</button>
);
})}
</div>
</div>
))}
</div>

{error && (
<p className="mt-2 text-sm text-red-600">{error}</p>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* RacePredictionsTable — Enhanced race predictions with visual bars
* Supports compact mode for dashboard grid layout.
*/

import type { RacePrediction } from "../types";

interface RacePredictionsTableProps {
predictions: RacePrediction[];
inputDistanceName: string;
compact?: boolean;
}

export function RacePredictionsTable({ predictions, inputDistanceName, compact }: RacePredictionsTableProps) {
const maxTime = Math.max(...predictions.map((p) => p.timeSeconds));

return (
<div className={`bg-white rounded-2xl shadow-lg h-full ${compact ? "p-4" : "p-5 sm:p-8"}`}>
<h2 className={`font-bold text-gray-900 ${compact ? "text-lg mb-0.5" : "text-xl sm:text-2xl mb-1"}`}>
Race Equivalency
</h2>
<p className={`text-gray-500 ${compact ? "text-xs mb-3" : "text-sm mb-6"}`}>
Predicted finish times across all distances
</p>

<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b-2 border-gray-200">
<th className={`text-left font-semibold text-gray-700 ${compact ? "py-2 px-2 text-xs" : "py-3 px-4"}`}>Distance</th>
<th className={`text-left font-semibold text-gray-700 ${compact ? "py-2 px-2 text-xs" : "py-3 px-4"}`}>Time</th>
<th className={`text-right font-semibold text-gray-700 ${compact ? "py-2 px-2 text-xs" : "py-3 px-4"}`}>Pace</th>
</tr>
</thead>
<tbody>
{predictions.map((prediction) => {
const isInput = prediction.name === inputDistanceName;
const barWidth = (prediction.timeSeconds / maxTime) * 100;

return (
<tr
key={prediction.name}
className={`border-b border-gray-50 transition-colors hover:bg-gray-50 ${
isInput ? "bg-blue-50/70" : ""
}`}
>
<td className={compact ? "py-1.5 px-2" : "py-3 px-4"}>
<div className="flex items-center gap-1.5">
{isInput && (
<span className="w-1 h-5 bg-blue-500 rounded-full shrink-0" />
)}
<span className={`font-medium ${compact ? "text-xs" : "text-sm"} ${isInput ? "text-blue-700" : "text-gray-900"}`}>
{prediction.name}
</span>
{isInput && !compact && (
<span className="text-[10px] font-semibold text-blue-500 bg-blue-100 px-1.5 py-0.5 rounded">
YOUR RACE
</span>
)}
</div>
</td>
<td className={compact ? "py-1.5 px-2" : "py-3 px-4"}>
<div className="flex items-center gap-2">
<span className={`font-mono font-semibold ${compact ? "text-xs min-w-[60px]" : "text-sm min-w-[80px]"} ${isInput ? "text-blue-700" : "text-gray-900"}`}>
{prediction.time}
</span>
{!compact && (
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${
isInput
? "bg-gradient-to-r from-blue-400 to-blue-500"
: "bg-gradient-to-r from-gray-200 to-gray-300"
}`}
style={{ width: `${barWidth}%` }}
/>
</div>
)}
</div>
</td>
<td className={`text-right text-gray-500 font-mono ${compact ? "py-1.5 px-2 text-[11px]" : "py-3 px-4 text-xs"}`}>
{prediction.pace}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* SampleWorkouts — Zone-integrated workout cards with personalized paces
* Supports compact mode for dashboard grid layout.
*/

import { Clock, Repeat, Zap, Wind } from "lucide-react";
import type { TrainingZone, PaceDisplayUnit } from "../types";

interface SampleWorkoutsProps {
zones: TrainingZone[];
paceUnit: PaceDisplayUnit;
compact?: boolean;
}

const WORKOUTS = [
{
title: "Easy Run",
description: "30–60 min conversational",
zoneIndex: 0,
icon: Wind,
colors: {
bg: "bg-emerald-50",
border: "border-emerald-200",
icon: "text-emerald-600",
pace: "text-emerald-700",
},
},
{
title: "Tempo Run",
description: "20 min at threshold",
zoneIndex: 2,
icon: Clock,
colors: {
bg: "bg-yellow-50",
border: "border-yellow-200",
icon: "text-yellow-600",
pace: "text-yellow-700",
},
},
{
title: "VO\u2082max Intervals",
description: "5 \u00d7 1000m, 3 min jog",
zoneIndex: 3,
icon: Repeat,
colors: {
bg: "bg-orange-50",
border: "border-orange-200",
icon: "text-orange-600",
pace: "text-orange-700",
},
},
{
title: "Speed Reps",
description: "8 \u00d7 200m, full recovery",
zoneIndex: 4,
icon: Zap,
colors: {
bg: "bg-red-50",
border: "border-red-200",
icon: "text-red-600",
pace: "text-red-700",
},
},
];

export function SampleWorkouts({ zones, paceUnit, compact }: SampleWorkoutsProps) {
return (
<div className={`bg-white rounded-2xl shadow-lg h-full ${compact ? "p-4" : "p-5 sm:p-8"}`}>
<h2 className={`font-bold text-gray-900 ${compact ? "text-lg mb-0.5" : "text-xl sm:text-2xl mb-1"}`}>
Sample Workouts
</h2>
<p className={`text-gray-500 ${compact ? "text-xs mb-3" : "text-sm mb-6"}`}>
Key sessions at your personalized paces
</p>

<div className={`grid gap-2 ${compact ? "grid-cols-2" : "grid-cols-1 sm:grid-cols-2 gap-3"}`}>
{WORKOUTS.map((workout) => {
const zone = zones[workout.zoneIndex];
if (!zone) return null;
const Icon = workout.icon;
const pace = paceUnit === "km" ? zone.pacePerKm : zone.pacePerMile;
const unit = paceUnit === "km" ? "/km" : "/mi";

return (
<div
key={workout.title}
className={`rounded-xl border ${workout.colors.bg} ${workout.colors.border} hover:shadow-sm transition-shadow ${compact ? "p-2.5" : "p-4"}`}
>
<div className={compact ? "" : "flex items-start gap-3"}>
{!compact && (
<div className={`w-9 h-9 rounded-lg flex items-center justify-center ${workout.colors.bg} ${workout.colors.icon}`}>
<Icon className="w-5 h-5" />
</div>
)}
<div className="flex-1 min-w-0">
<div className={compact ? "flex items-center gap-1.5 mb-0.5" : ""}>
{compact && <Icon className={`w-3.5 h-3.5 ${workout.colors.icon}`} />}
<h3 className={`font-semibold text-gray-900 ${compact ? "text-xs" : "text-sm"}`}>
{workout.title}
</h3>
</div>
{!compact && (
<p className="text-xs text-gray-500 mt-0.5">{workout.description}</p>
)}
<p className={`font-bold font-mono ${workout.colors.pace} ${compact ? "text-sm mt-0.5" : "text-base mt-2"}`}>
{pace} <span className={`font-normal text-gray-400 ${compact ? "text-[10px]" : "text-xs"}`}>{unit}</span>
</p>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
Loading
Loading