diff --git a/vite-project/src/components/ui/accordion.tsx b/vite-project/src/components/ui/accordion.tsx index e1797c9..7880c60 100644 --- a/vite-project/src/components/ui/accordion.tsx +++ b/vite-project/src/components/ui/accordion.tsx @@ -12,7 +12,7 @@ const AccordionItem = React.forwardRef< >(({ className, ...props }, ref) => ( )) diff --git a/vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx b/vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx new file mode 100644 index 0000000..fe84c24 --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx @@ -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 = { + "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 ( +
+ + +
+ {DISTANCE_GROUPS.map((group) => ( +
+

+ {group.label} +

+
+ {group.distances.map((d) => { + const isSelected = selectedMeters === d.meters; + return ( + + ); + })} +
+
+ ))} +
+ + {error && ( +

{error}

+ )} +
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/RacePredictionsTable.tsx b/vite-project/src/features/vdot-calculator/components/RacePredictionsTable.tsx new file mode 100644 index 0000000..442f4b6 --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/RacePredictionsTable.tsx @@ -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 ( +
+

+ Race Equivalency +

+

+ Predicted finish times across all distances +

+ +
+ + + + + + + + + + {predictions.map((prediction) => { + const isInput = prediction.name === inputDistanceName; + const barWidth = (prediction.timeSeconds / maxTime) * 100; + + return ( + + + + + + ); + })} + +
DistanceTimePace
+
+ {isInput && ( + + )} + + {prediction.name} + + {isInput && !compact && ( + + YOUR RACE + + )} +
+
+
+ + {prediction.time} + + {!compact && ( +
+
+
+ )} +
+
+ {prediction.pace} +
+
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/SampleWorkouts.tsx b/vite-project/src/features/vdot-calculator/components/SampleWorkouts.tsx new file mode 100644 index 0000000..20929f5 --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/SampleWorkouts.tsx @@ -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 ( +
+

+ Sample Workouts +

+

+ Key sessions at your personalized paces +

+ +
+ {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 ( +
+
+ {!compact && ( +
+ +
+ )} +
+
+ {compact && } +

+ {workout.title} +

+
+ {!compact && ( +

{workout.description}

+ )} +

+ {pace} {unit} +

+
+
+
+ ); + })} +
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/TimeInput.tsx b/vite-project/src/features/vdot-calculator/components/TimeInput.tsx new file mode 100644 index 0000000..2108a9d --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/TimeInput.tsx @@ -0,0 +1,150 @@ +/** + * TimeInput — Smart time input with auto-advance, placeholder hints, and VDOT preview + */ + +import { useRef, useCallback } from "react"; +import { Zap } from "lucide-react"; +import type { VdotInputs } from "../types"; + +// Typical finish times by distance (for placeholder hints) +const TYPICAL_TIMES: Record = { + "800m": { hours: "", minutes: "3", seconds: "00" }, + "1500m": { hours: "", minutes: "6", seconds: "30" }, + Mile: { hours: "", minutes: "7", seconds: "00" }, + "3K": { hours: "", minutes: "14", seconds: "00" }, + "5K": { hours: "", minutes: "25", seconds: "00" }, + "10K": { hours: "", minutes: "55", seconds: "00" }, + "15K": { hours: "1", minutes: "25", seconds: "00" }, + "Half Marathon": { hours: "2", minutes: "00", seconds: "00" }, + Marathon: { hours: "4", minutes: "15", seconds: "00" }, +}; + +interface TimeInputProps { + inputs: VdotInputs; + onTimeChange: (field: "hours" | "minutes" | "seconds", value: string) => void; + onCalculate: () => void; + vdotPreview: number | null; + error?: string; +} + +export function TimeInput({ + inputs, + onTimeChange, + onCalculate, + vdotPreview, + error, +}: TimeInputProps) { + const minutesRef = useRef(null); + const secondsRef = useRef(null); + + const typicalTime = inputs.distanceName ? TYPICAL_TIMES[inputs.distanceName] : null; + + const handleFieldChange = useCallback( + (field: "hours" | "minutes" | "seconds", value: string) => { + onTimeChange(field, value); + + // Auto-advance: when 2 digits typed, move to next field + const cleaned = value.replace(/\D/g, "").slice(0, 2); + if (cleaned.length === 2) { + if (field === "hours") { + minutesRef.current?.focus(); + minutesRef.current?.select(); + } else if (field === "minutes") { + secondsRef.current?.focus(); + secondsRef.current?.select(); + } + } + }, + [onTimeChange] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onCalculate(); + } + }; + + return ( +
+ + +
+ {/* Hours */} +
+ handleFieldChange("hours", e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-2 sm:px-3 py-3.5 text-center text-xl sm:text-2xl font-mono font-bold border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all bg-gray-50 focus:bg-white" + aria-label="Hours" + /> +

Hours

+
+ + : + + {/* Minutes */} +
+ handleFieldChange("minutes", e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-2 sm:px-3 py-3.5 text-center text-xl sm:text-2xl font-mono font-bold border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all bg-gray-50 focus:bg-white" + aria-label="Minutes" + /> +

Min

+
+ + : + + {/* Seconds */} +
+ handleFieldChange("seconds", e.target.value)} + onKeyDown={handleKeyDown} + className="w-full px-2 sm:px-3 py-3.5 text-center text-xl sm:text-2xl font-mono font-bold border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all bg-gray-50 focus:bg-white" + aria-label="Seconds" + /> +

Sec

+
+
+ + {error && ( +

{error}

+ )} + + {/* Live VDOT Preview */} + {vdotPreview !== null && !error && ( +
+
+ + Est. VDOT: + {vdotPreview} +
+
+ )} + + {/* Calculate Button */} + +
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/TrainingZonesDisplay.tsx b/vite-project/src/features/vdot-calculator/components/TrainingZonesDisplay.tsx new file mode 100644 index 0000000..12e7e29 --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/TrainingZonesDisplay.tsx @@ -0,0 +1,128 @@ +/** + * TrainingZonesDisplay — Color-coded zone cards showing pace, description, and sample workouts. + * Supports compact mode for dashboard grid layout. + */ + +import { Info } from "lucide-react"; +import type { TrainingZone, PaceDisplayUnit } from "../types"; +import { getZoneColorClasses } from "../hooks/useVdotCalculator"; + +const ZONE_WORKOUTS: Record = { + E: [ + { workout: "Easy Run", detail: "30–60 min at conversational pace" }, + { workout: "Long Run", detail: "60–150 min for aerobic endurance" }, + ], + M: [ + { workout: "Marathon Tempo", detail: "8–15 miles at marathon pace" }, + { workout: "MP Intervals", detail: "3 \u00d7 3 miles with 1 min rest" }, + ], + T: [ + { workout: "Tempo Run", detail: "20 min continuous at threshold" }, + { workout: "Cruise Intervals", detail: "4 \u00d7 1 mile with 1 min jog" }, + ], + I: [ + { workout: "VO\u2082max Intervals", detail: "5 \u00d7 1000m with 3 min jog" }, + { workout: "3–5 min Repeats", detail: "4 \u00d7 4 min hard, 3 min easy" }, + ], + R: [ + { workout: "Speed Reps", detail: "8 \u00d7 200m with full recovery" }, + { workout: "Fast Strides", detail: "10 \u00d7 100m at max speed" }, + ], +}; + +interface TrainingZonesDisplayProps { + zones: TrainingZone[]; + paceUnit: PaceDisplayUnit; + vdot: number; + onTogglePaceUnit: () => void; + compact?: boolean; +} + +export function TrainingZonesDisplay({ + zones, + paceUnit, + vdot, + onTogglePaceUnit, + compact, +}: TrainingZonesDisplayProps) { + return ( +
+ {/* Header */} +
+
+

+ Training Paces +

+

+ Daniels' 5 zones · VDOT {vdot} +

+
+ {!compact && ( +
+
+
+
min/km
+
min/mi
+
+
+ )} +
+ + {/* Zone cards grid */} +
+ {zones.map((zone) => { + const colors = getZoneColorClasses(zone.color); + const workouts = ZONE_WORKOUTS[zone.shortName] || []; + const pace = paceUnit === "km" ? zone.pacePerKm : zone.pacePerMile; + const unit = paceUnit === "km" ? "/km" : "/mi"; + + return ( +
+
+ {/* Badge + name inline */} +
+
+ {zone.shortName} +
+

+ {zone.name} +

+
+ + {/* Pace */} +

+ {pace} +

+

{unit}

+ + {/* Workouts — compact list */} + {!compact && workouts.length > 0 && ( +
+ {workouts.map((w, i) => ( +
+ +

{w.workout}

+
+ ))} +
+ )} +
+
+ ); + })} +
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx b/vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx index a27ee61..f60b9c0 100644 --- a/vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx +++ b/vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx @@ -1,946 +1,238 @@ /** - * VDOT Calculator - Main Component - * Comprehensive VDOT calculator based on Jack Daniels' Running Formula + * VdotCalculator — Main orchestrator component + * Dashboard-style grid layout: everything visible at a glance, minimal scrolling. */ -import { useState, useMemo } from "react"; +import { useRef } from "react"; import { Link } from "react-router-dom"; -import { Helmet } from "react-helmet-async"; +import { Clock, ArrowLeft } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; -import { ChevronDown, ChevronUp } from "lucide-react"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import ReactGA from "react-ga4"; -import type { - VdotInputs, - VdotFormErrors, - VdotResult, - PaceDisplayUnit, -} from "../types"; -import { INPUT_DISTANCES, RACE_DISTANCES } from "../types"; -import { - calculateVdot, - predictRaceTime, - calculateTrainingZones, - formatTime, - formatPace, -} from "../vdot-math"; - -const initialFormState: VdotInputs = { - distanceMeters: 0, - distanceName: "", - hours: "", - minutes: "", - seconds: "", -}; - -function validateInputs(inputs: VdotInputs): { - isValid: boolean; - errors: VdotFormErrors; -} { - const errors: VdotFormErrors = {}; - - if (!inputs.distanceMeters || inputs.distanceMeters <= 0) { - errors.distance = "Please select a race distance"; - } - - const h = parseInt(inputs.hours || "0"); - const m = parseInt(inputs.minutes || "0"); - const s = parseInt(inputs.seconds || "0"); - const totalSeconds = h * 3600 + m * 60 + s; - - if (totalSeconds <= 0) { - errors.time = "Please enter a valid finish time"; - } - - if (m >= 60 || s >= 60) { - errors.time = "Invalid time format"; - } - - return { isValid: Object.keys(errors).length === 0, errors }; -} +import { useVdotCalculator } from "../hooks/useVdotCalculator"; +import { VdotSeoHead } from "./VdotSeoHead"; +import { VdotHero } from "./VdotHero"; +import { DistanceSelector } from "./DistanceSelector"; +import { TimeInput } from "./TimeInput"; +import { VdotScoreDisplay } from "./VdotScoreDisplay"; +import { TrainingZonesDisplay } from "./TrainingZonesDisplay"; +import { RacePredictionsTable } from "./RacePredictionsTable"; +import { VdotComparison } from "./VdotComparison"; +import { SampleWorkouts } from "./SampleWorkouts"; +import { VdotFaq } from "./VdotFaq"; export function VdotCalculator() { - const [inputs, setInputs] = useState(initialFormState); - const [result, setResult] = useState(null); - const [errors, setErrors] = useState({}); - const [paceUnit, setPaceUnit] = useState("km"); - const [showScience, setShowScience] = useState(false); - - // Memoized input state - const totalSeconds = useMemo(() => { - const h = parseInt(inputs.hours || "0"); - const m = parseInt(inputs.minutes || "0"); - const s = parseInt(inputs.seconds || "0"); - return h * 3600 + m * 60 + s; - }, [inputs.hours, inputs.minutes, inputs.seconds]); - - const handleDistanceSelect = (meters: number, name: string) => { - setInputs((prev) => ({ ...prev, distanceMeters: meters, distanceName: name })); - setErrors((prev) => ({ ...prev, distance: undefined })); - }; - - const handleTimeChange = (field: "hours" | "minutes" | "seconds", value: string) => { - const numValue = value.replace(/\D/g, "").slice(0, 2); - setInputs((prev) => ({ ...prev, [field]: numValue })); - setErrors((prev) => ({ ...prev, time: undefined })); - }; - - const handleCalculate = () => { - const validation = validateInputs(inputs); - if (!validation.isValid) { - setErrors(validation.errors); - return; - } - - const vdot = calculateVdot(inputs.distanceMeters, totalSeconds); - - // Calculate race predictions - const racePredictions = RACE_DISTANCES.map((race) => { - const timeSeconds = predictRaceTime(vdot, race.meters); - const pacePerKm = (timeSeconds / race.meters) * 1000; - const pacePerMile = (timeSeconds / race.meters) * 1609.34; - return { - name: race.name, - distance: race.meters, - time: formatTime(timeSeconds), - timeSeconds, - pace: - paceUnit === "km" - ? `${formatPace(pacePerKm)}/km` - : `${formatPace(pacePerMile)}/mi`, - }; - }); - - // Calculate training zones - const trainingZones = calculateTrainingZones(vdot).map((zone) => ({ - name: zone.name, - shortName: zone.shortName, - description: zone.description, - pacePerKm: `${formatPace(zone.pacePerKmSeconds[0])} – ${formatPace(zone.pacePerKmSeconds[1])}`, - pacePerMile: `${formatPace(zone.pacePerMileSeconds[0])} – ${formatPace(zone.pacePerMileSeconds[1])}`, - intensityRange: `${Math.round(zone.intensityRange[0] * 100)}–${Math.round(zone.intensityRange[1] * 100)}%`, - color: zone.color, - })); - - setResult({ - vdot: Math.round(vdot * 10) / 10, - trainingZones, - racePredictions, - vo2max: Math.round(vdot * 10) / 10, - }); - - ReactGA.event({ - category: "VDOT Calculator", - action: "Calculated VDOT", - label: `${inputs.distanceName} - VDOT ${Math.round(vdot * 10) / 10}`, - }); - }; - - const handleReset = () => { - setInputs(initialFormState); - setResult(null); - setErrors({}); - }; - - const handlePaceUnitToggle = () => { - const newUnit = paceUnit === "km" ? "mi" : "km"; - setPaceUnit(newUnit); - - // Recalculate race prediction pace display - if (result) { - const updatedPredictions = RACE_DISTANCES.map((race) => { - const timeSeconds = predictRaceTime(result.vdot, race.meters); - const pacePerKm = (timeSeconds / race.meters) * 1000; - const pacePerMile = (timeSeconds / race.meters) * 1609.34; - return { - name: race.name, - distance: race.meters, - time: formatTime(timeSeconds), - timeSeconds, - pace: - newUnit === "km" - ? `${formatPace(pacePerKm)}/km` - : `${formatPace(pacePerMile)}/mi`, - }; - }); - setResult((prev) => (prev ? { ...prev, racePredictions: updatedPredictions } : prev)); - } - }; - - // Get VDOT level description - const getVdotLevel = (vdot: number): { label: string; color: string } => { - if (vdot >= 80) return { label: "Elite World Class", color: "text-purple-700" }; - if (vdot >= 70) return { label: "Elite", color: "text-red-600" }; - if (vdot >= 60) return { label: "Advanced", color: "text-orange-600" }; - if (vdot >= 50) return { label: "Competitive", color: "text-yellow-600" }; - if (vdot >= 40) return { label: "Intermediate", color: "text-blue-600" }; - if (vdot >= 30) return { label: "Recreational", color: "text-emerald-600" }; - return { label: "Beginner", color: "text-gray-600" }; - }; - - const getZoneColorClasses = (color: string) => { - const colors: Record = { - emerald: { - bg: "bg-emerald-50", - border: "border-emerald-200", - text: "text-emerald-700", - badge: "bg-emerald-100 text-emerald-800", - }, - blue: { - bg: "bg-blue-50", - border: "border-blue-200", - text: "text-blue-700", - badge: "bg-blue-100 text-blue-800", - }, - yellow: { - bg: "bg-yellow-50", - border: "border-yellow-200", - text: "text-yellow-700", - badge: "bg-yellow-100 text-yellow-800", - }, - orange: { - bg: "bg-orange-50", - border: "border-orange-200", - text: "text-orange-700", - badge: "bg-orange-100 text-orange-800", - }, - red: { - bg: "bg-red-50", - border: "border-red-200", - text: "text-red-700", - badge: "bg-red-100 text-red-800", - }, - }; - return colors[color] || colors.blue; + const { + inputs, + result, + errors, + paceUnit, + totalSeconds, + vdotPreview, + history, + handleDistanceSelect, + handleTimeChange, + handleCalculate, + handleReset, + handlePaceUnitToggle, + loadFromHistory, + clearHistory, + } = useVdotCalculator(); + + const resultsRef = useRef(null); + + const onCalculate = () => { + handleCalculate(); + setTimeout(() => { + resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, 100); }; return ( <> - - VDOT Running Calculator – Jack Daniels Formula | TrainPace - - - {/* Open Graph */} - - - - - - - {/* Twitter */} - - - - - {/* Structured Data */} - - - -
-
- {/* Header */} -
- -

- VDOT Running Calculator -

-

- Calculate your VDOT score from any race result using Jack Daniels' proven formula. Get personalized training paces and race predictions across all distances. -

-
- - {/* Always-visible intro — crawlable by search engines */} - - -

- What is VDOT? -

-

- VDOT is a measure of your current running fitness developed by legendary coach - Jack Daniels. It represents your effective VO₂max — how much oxygen your body - can use while running. A single race result is enough to calculate it. -

-
    -
  • - Enter any recent race result to get your VDOT score -
  • -
  • - Get equivalent race time predictions from 800m to the marathon -
  • -
  • - Receive science-based paces for 5 Daniels training zones -
  • -
  • - Easy, Marathon, Threshold, Interval, and Repetition paces -
  • -
-
-
- -
- {/* Input Form */} - {!result ? ( - - -

- Enter a Recent Race Result -

- - {/* Distance Selection */} -
- -
- {INPUT_DISTANCES.map((d) => ( - - ))} -
- {errors.distance && ( -

- {errors.distance} -

- )} + + +
+ {/* Wider container for dashboard layout */} +
+ {/* Hero — compact when showing results */} + {!result ? ( + + ) : ( + /* Compact header bar in results mode */ +
+
+ +
+

VDOT Dashboard

+

+ {inputs.distanceName} · {(() => { const h = parseInt(inputs.hours || "0"); const m = parseInt(inputs.minutes || "0"); const s = parseInt(inputs.seconds || "0"); const parts = []; if (h > 0) parts.push(`${h}h`); if (m > 0) parts.push(`${m}m`); if (s > 0) parts.push(`${s}s`); return parts.join(" "); })()} +

+
+
+
+
+
+
+ min/km
- - {/* Time Input */} -
- -
-
- - handleTimeChange("hours", e.target.value) - } - className="w-full px-3 sm:px-4 py-3 text-center text-lg font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - aria-label="Hours" - /> -

- Hours -

-
- - : - -
- - handleTimeChange("minutes", e.target.value) - } - className="w-full px-3 sm:px-4 py-3 text-center text-lg font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - aria-label="Minutes" - /> -

- Minutes -

-
- - : - -
- - handleTimeChange("seconds", e.target.value) - } - className="w-full px-3 sm:px-4 py-3 text-center text-lg font-mono border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" - aria-label="Seconds" - /> -

- Seconds -

-
-
- {errors.time && ( -

{errors.time}

- )} +
+ min/mi +
+
+
+
+ )} + + {/* Main content */} + {!result ? ( + /* ─── INPUT STATE ─── */ +
+ + +
+

+ Enter a Recent Race Result +

+

+ Select your race distance and finish time below +

- {/* Calculate Button */} - + + +
- ) : ( - <> - {/* VDOT Score Display */} - -
-
-
-

- Your VDOT Score -

-
- - {result.vdot} - - - {getVdotLevel(result.vdot).label} - -
-

- Based on {inputs.distanceName} in{" "} - {formatTime(totalSeconds)} -

-
-
- -
-
-
- {/* VDOT Scale */} - -
-
-
-
- Beginner (20) - Recreational (35) - Competitive (50) - Advanced (65) - Elite (80+) -
- - - - {/* Pace Unit Toggle */} -
-
-
-
-
- min/km -
-
0 && ( +
+
+

+ Recent Calculations +

+ +
+
+ {history.map((entry, idx) => ( +
-
+ + {entry.distanceName} + {entry.timeFormatted} + {entry.vdot} + + ))}
- - {/* Training Paces */} - - -

- 🎯 Training Paces -

-

- Daniels' 5 training zones based on your VDOT of{" "} - {result.vdot} -

- -
- {result.trainingZones.map((zone) => { - const colors = getZoneColorClasses(zone.color); - return ( -
-
-
-
- - {zone.shortName} - -

- {zone.name} -

- - - - - ⓘ - - - -

{zone.description}

-
-
-
-
-

- {zone.description} -

-
-
-

- {paceUnit === "km" - ? zone.pacePerKm - : zone.pacePerMile} -

-

- {paceUnit === "km" ? "min/km" : "min/mi"} •{" "} - {zone.intensityRange} VO₂max -

-
-
-
- ); - })} -
-
-
- - {/* Race Equivalency Table */} - - -

- 🏅 Race Equivalency -

-

- Predicted race times based on your VDOT of {result.vdot} -

- -
- - - - - - - - - - {result.racePredictions.map((prediction, idx) => { - const isInputRace = - prediction.name === inputs.distanceName; - return ( - - - - - - ); - })} - -
- Distance - - Time - - Pace -
- {prediction.name} - {isInputRace && ( - - (your race) - - )} - - {prediction.time} - - {prediction.pace} -
-
-
-
- - {/* Workout Suggestions */} - - -

- 📋 Sample Workouts -

-

- Key workouts for your VDOT level -

- -
- {/* Easy Run */} -
-

- 🟢 Easy Run -

-

- 30–60 min at Easy pace -

-

- {paceUnit === "km" - ? result.trainingZones[0].pacePerKm - : result.trainingZones[0].pacePerMile}{" "} - {paceUnit === "km" ? "/km" : "/mi"} -

-
- - {/* Tempo Run */} -
-

- 🟡 Tempo Run -

-

- 20 min continuous at Threshold pace -

-

- {paceUnit === "km" - ? result.trainingZones[2].pacePerKm - : result.trainingZones[2].pacePerMile}{" "} - {paceUnit === "km" ? "/km" : "/mi"} -

-
- - {/* Intervals */} -
-

- 🟠 VO₂max Intervals -

-

- 5 × 1000m at Interval pace, 3 min jog rest -

-

- {paceUnit === "km" - ? result.trainingZones[3].pacePerKm - : result.trainingZones[3].pacePerMile}{" "} - {paceUnit === "km" ? "/km" : "/mi"} -

-
- - {/* Repetitions */} -
-

- 🔴 Speed Reps -

-

- 8 × 200m at Repetition pace, full recovery -

-

- {paceUnit === "km" - ? result.trainingZones[4].pacePerKm - : result.trainingZones[4].pacePerMile}{" "} - {paceUnit === "km" ? "/km" : "/mi"} -

-
-
-
-
- - )} -
- - {/* FAQ Section — always visible for SEO */} -
-

- Frequently Asked Questions -

- -
-
- - What is VDOT in running? - - -

- VDOT is a performance-based fitness metric developed by exercise physiologist and coach - Jack Daniels. It stands for “V-dot-O₂max” and represents the rate of oxygen - consumption your body can sustain. Unlike a lab VO₂max test, VDOT is estimated from - your race results, making it practical for every runner. -

-
- -
- - How do I calculate my VDOT? - - -

- Select a race distance (800m to Marathon), enter your finish time, and press - “Calculate VDOT.” The calculator uses the Daniels & Gilbert oxygen-cost and - time-limit equations to compute your VDOT score and all corresponding training paces. -

-
- -
- - What are the 5 VDOT training zones? - - -
-

Jack Daniels defines five training intensities:

-
    -
  • Easy (E): 59–74% VO₂max — Builds aerobic base and promotes recovery
  • -
  • Marathon (M): 75–84% VO₂max — Marathon-specific endurance
  • -
  • Threshold (T): 83–88% VO₂max — Improves lactate clearance at tempo pace
  • -
  • Interval (I): 95–100% VO₂max — Maximizes aerobic capacity
  • -
  • Repetition (R): 105%+ VO₂max — Develops speed and running economy
  • -
+ )} +
+ ) : ( + /* ─── RESULTS DASHBOARD ─── */ +
+ {/* + Dashboard grid: + ┌──────────────┬───────────────────┐ + │ VDOT Score │ Training Zones │ + │ (gauge) │ (5 zones) │ + ├──────────────┤ │ + │ Workouts │ │ + │ (4 cards) │ │ + ├──────────────┴───────────────────┤ + │ Race Predictions │ What If? │ + └──────────────┴───────────────────┘ + */} + + {/* Row 1: Score + Zones (side by side on lg) */} +
+ {/* Left: Score + Workouts stacked */} +
+ +
-
- -
- - Can I predict race times from my VDOT? - - -

- Yes. Once your VDOT is calculated from one race distance, the calculator predicts - equivalent finish times for all standard distances from 800m to the marathon. - These predictions assume equal training across all energy systems. -

-
-
- - What is a good VDOT score? - - -

- VDOT scores typically range from 20 to 85+. Beginner runners often score 20–30, - recreational runners 30–40, competitive club runners 45–55, advanced runners 60–70, - and elite athletes 70–85+. A 20:00 5K corresponds to roughly VDOT 50. -

-
- -
- - How is VDOT different from VO₂max? - - -

- VO₂max is measured in a lab and reflects your maximum oxygen uptake. VDOT is a - “pseudo VO₂max” estimated from race performance — it captures not just your - aerobic ceiling but also your running economy and lactate tolerance. Two runners - with the same lab VO₂max can have different VDOT scores. -

-
+ {/* Right: Training Zones */} +
+ +
+
+ + {/* Row 2: Race Predictions + What If (side by side on lg) */} +
+
+ +
+
+ +
+
+ )} - {/* The Science Behind VDOT — collapsible for interested readers */} -
- - - {showScience && ( - - -

- VDOT was developed by exercise physiologist and running coach{" "} - Jack Daniels. It stands for “V-dot-O₂max” — - the rate of oxygen consumption — and represents your current - running fitness level. -

-

- How It Works -

-
    -
  • - VO₂ cost equation: Calculates the oxygen - cost of running at a given velocity using the formula: - VO₂ = -4.6 + 0.182258v + 0.000104v² -
  • -
  • - %VO₂max equation: Estimates what fraction - of your VO₂max you can sustain for a given race duration -
  • -
  • - VDOT = VO₂ / %VO₂max: Dividing the - oxygen cost by the sustainable fraction gives your - effective VO₂max -
  • -
-

- Based on the Daniels & Gilbert oxygen cost and time-limit - equations from “Daniels’ Running Formula” (4th Edition). -

-
-
- )} -
+ {/* FAQ — always visible for SEO */} +
+ - {/* Internal links for SEO */} - + {/* Related Tools */} +

Related Tools @@ -948,9 +240,9 @@ export function VdotCalculator() {
- ⏱️ + ⏱️

Pace Calculator

Training paces from any race time

@@ -958,9 +250,9 @@ export function VdotCalculator() { - +

Fuel Planner

Race-day nutrition strategy

@@ -968,9 +260,9 @@ export function VdotCalculator() { - ⛰️ + ⛰️

Elevation Finder

Course elevation analysis

@@ -978,9 +270,9 @@ export function VdotCalculator() { - 🏅 + 🏅

Race Guides

Marathon course previews & tips

diff --git a/vite-project/src/features/vdot-calculator/components/VdotComparison.tsx b/vite-project/src/features/vdot-calculator/components/VdotComparison.tsx new file mode 100644 index 0000000..db136be --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/VdotComparison.tsx @@ -0,0 +1,160 @@ +/** + * VdotComparison — "What If" explorer: adjust time to see how VDOT changes + * Supports compact mode for dashboard grid layout — always open when compact. + */ + +import { useState, useMemo } from "react"; +import { Sliders, TrendingUp, TrendingDown, Minus } from "lucide-react"; +import { calculateVdot, formatTime } from "../vdot-math"; +import { getVdotLevel } from "../hooks/useVdotCalculator"; + +interface VdotComparisonProps { + currentVdot: number; + distanceMeters: number; + distanceName: string; + totalSeconds: number; + compact?: boolean; +} + +export function VdotComparison({ + currentVdot, + distanceMeters, + distanceName, + totalSeconds, + compact, +}: VdotComparisonProps) { + const [isOpen, setIsOpen] = useState(!!compact); + const [offsetSeconds, setOffsetSeconds] = useState(0); + + const maxOffset = useMemo(() => { + return Math.min(Math.floor(totalSeconds * 0.2), 600); + }, [totalSeconds]); + + const targetSeconds = totalSeconds + offsetSeconds; + const targetVdot = useMemo(() => { + if (targetSeconds <= 0) return currentVdot; + const v = calculateVdot(distanceMeters, targetSeconds); + return Math.round(v * 10) / 10; + }, [distanceMeters, targetSeconds, currentVdot]); + + const vdotDelta = Math.round((targetVdot - currentVdot) * 10) / 10; + const targetLevel = getVdotLevel(targetVdot); + + const formatOffset = (seconds: number): string => { + const abs = Math.abs(seconds); + const m = Math.floor(abs / 60); + const s = abs % 60; + const sign = seconds < 0 ? "-" : "+"; + if (m === 0) return `${sign}${s}s`; + if (s === 0) return `${sign}${m}:00`; + return `${sign}${m}:${s.toString().padStart(2, "0")}`; + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+
+

What If?

+

+ Adjust your {distanceName} time +

+
+ {!compact && ( + + )} +
+ + {/* Slider */} +
+
+ Faster + + {offsetSeconds === 0 ? "Your time" : formatOffset(offsetSeconds)} + + Slower +
+ 600 ? 10 : 5} + value={offsetSeconds} + onChange={(e) => setOffsetSeconds(parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-full appearance-none cursor-pointer accent-indigo-600 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-indigo-600 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:shadow-md" + /> +
+ + {/* Comparison cards */} +
+
+

Current

+

{currentVdot}

+

{formatTime(totalSeconds)}

+

{getVdotLevel(currentVdot).label}

+
+ +
0 + ? "bg-emerald-50 border-emerald-200" + : vdotDelta < 0 + ? "bg-red-50 border-red-200" + : "bg-blue-50 border-blue-200" + }`} + > +

Target

+

0 ? "text-emerald-700" : vdotDelta < 0 ? "text-red-700" : "text-blue-700" + }`} + > + {targetVdot} +

+

{formatTime(targetSeconds)}

+

{targetLevel.label}

+
+
+ + {/* Delta summary */} + {offsetSeconds !== 0 && ( +
0 + ? "bg-emerald-50 text-emerald-700" + : vdotDelta < 0 + ? "bg-red-50 text-red-700" + : "bg-gray-50 text-gray-700" + }`} + > + {vdotDelta > 0 ? ( + + ) : vdotDelta < 0 ? ( + + ) : ( + + )} + + {vdotDelta > 0 ? "+" : ""}{vdotDelta} VDOT + +
+ )} +
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/VdotFaq.tsx b/vite-project/src/features/vdot-calculator/components/VdotFaq.tsx new file mode 100644 index 0000000..b1c130a --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/VdotFaq.tsx @@ -0,0 +1,138 @@ +/** + * VdotFaq — Clean text-based FAQ with links to blog posts for deeper reading + */ + +import { Link } from "react-router-dom"; +import { ArrowRight } from "lucide-react"; + +export function VdotFaq() { + return ( +
+
+

+ Understanding VDOT +

+

+ Everything you need to know about your score and training zones +

+
+ + {/* What is VDOT */} +
+

+ What is VDOT? +

+

+ VDOT is a performance-based fitness metric created by exercise + physiologist Jack Daniels. It stands for + “V-dot-O₂max” — the rate of oxygen your body + can consume — and represents your current running fitness. + Unlike a lab VO₂max test, VDOT is estimated from race results, + making it practical for every runner. Two athletes with the same lab + VO₂max can have different VDOT scores because VDOT also + captures running economy and lactate tolerance. +

+
+ + {/* The 5 Training Zones */} +
+

+ The 5 Training Zones +

+

+ Daniels defines five intensity levels, each targeting a different + physiological system: +

+
+ {[ + { letter: "E", name: "Easy", range: "59–74%", color: "bg-emerald-50 text-emerald-700 border-emerald-200" }, + { letter: "M", name: "Marathon", range: "75–84%", color: "bg-blue-50 text-blue-700 border-blue-200" }, + { letter: "T", name: "Threshold", range: "83–88%", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, + { letter: "I", name: "Interval", range: "95–100%", color: "bg-orange-50 text-orange-700 border-orange-200" }, + { letter: "R", name: "Repetition", range: "105%+", color: "bg-red-50 text-red-700 border-red-200" }, + ].map((z) => ( +
+ {z.letter} +

{z.name}

+

{z.range} VO₂max

+
+ ))} +
+
+ + {/* VDOT Scores */} +
+

+ What is a good VDOT score? +

+

+ Scores typically range from 20 to 85+. Beginners often land at + 20–30, recreational runners 30–40, competitive club + runners 45–55, advanced runners 60–70, and elite athletes + 70–85+. For reference, a 20:00 5K corresponds to roughly VDOT + 50. +

+
+ + {/* How it works */} +
+

+ How the math works +

+

+ The calculator uses two equations from Daniels & Gilbert: one + estimates the oxygen cost of running at a given + speed, and the other estimates the fraction of VO₂max you + can sustain for a given duration. Dividing cost by sustainable + fraction gives your effective VO₂max — your VDOT. From + that single number, equivalent race times and training paces for all + five zones are derived. +

+

+ Based on “Daniels’ Running Formula” (4th Edition). +

+
+ + {/* Blog links */} +
+

+ Learn more +

+
+ {[ + { + title: "Understanding VDOT and Training Paces", + slug: "understanding-vdot-training-paces", + }, + { + title: "Tempo Runs: The Workout That Made Me Faster", + slug: "tempo-runs-explained", + }, + { + title: "How to Pace a 5K Without Blowing Up", + slug: "how-to-pace-a-5k-without-blowing-up", + }, + { + title: "Why Easy Runs Feel Too Slow Until They Click", + slug: "why-easy-runs-feel-too-slow", + }, + ].map((post) => ( + + + {post.title} + + + + ))} +
+
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/VdotHero.tsx b/vite-project/src/features/vdot-calculator/components/VdotHero.tsx new file mode 100644 index 0000000..30ab9ea --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/VdotHero.tsx @@ -0,0 +1,94 @@ +/** + * VdotHero — Premium gradient header with breadcrumbs and VDOT explainer + */ + +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { ChevronDown, Activity } from "lucide-react"; + +export function VdotHero() { + const [showInfo, setShowInfo] = useState(false); + + return ( +
+ {/* Background decoration */} +
+
+ +
+ {/* Breadcrumb */} + + + {/* Title */} +
+
+ +
+

+ VDOT Running Calculator +

+
+ +

+ Calculate your VDOT score from any race result using Jack Daniels' proven formula. + Get personalized training paces and race predictions across all distances. +

+ + {/* What is VDOT expandable */} + + + {showInfo && ( +
+

+ VDOT is a measure of your current running fitness developed by legendary coach + Jack Daniels. It represents your effective VO₂max — how much oxygen your body + can use while running. +

+
    +
  • + + Enter any recent race result to get your VDOT score +
  • +
  • + + Get equivalent race time predictions from 800m to the marathon +
  • +
  • + + Receive science-based paces for 5 Daniels training zones +
  • +
  • + + Easy, Marathon, Threshold, Interval, and Repetition paces +
  • +
+
+ )} +
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/VdotScoreDisplay.tsx b/vite-project/src/features/vdot-calculator/components/VdotScoreDisplay.tsx new file mode 100644 index 0000000..dcd509b --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/VdotScoreDisplay.tsx @@ -0,0 +1,174 @@ +/** + * VdotScoreDisplay — Animated VDOT gauge with score reveal, percentile, and stats + * Supports compact mode for dashboard grid layout. + */ + +import { useState, useEffect, useRef } from "react"; +import { TrendingUp, Heart, Target } from "lucide-react"; +import type { VdotResult, VdotInputs } from "../types"; +import { formatTime } from "../vdot-math"; +import { getVdotLevel, getVdotPercentile } from "../hooks/useVdotCalculator"; + +interface VdotScoreDisplayProps { + result: VdotResult; + inputs: VdotInputs; + totalSeconds: number; + onReset?: () => void; + compact?: boolean; +} + +function useCountUp(target: number, duration = 1000) { + const [value, setValue] = useState(0); + const rafRef = useRef(0); + + useEffect(() => { + const start = performance.now(); + const animate = (now: number) => { + const elapsed = now - start; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setValue(Math.round(eased * target * 10) / 10); + if (progress < 1) { + rafRef.current = requestAnimationFrame(animate); + } + }; + rafRef.current = requestAnimationFrame(animate); + return () => cancelAnimationFrame(rafRef.current); + }, [target, duration]); + + return value; +} + +/** SVG semicircular gauge */ +function VdotGauge({ vdot, size = "normal" }: { vdot: number; size?: "normal" | "small" }) { + const minVdot = 15; + const maxVdot = 85; + const clamped = Math.max(minVdot, Math.min(maxVdot, vdot)); + const fraction = (clamped - minVdot) / (maxVdot - minVdot); + + const cx = 150; + const cy = 130; + const r = 110; + const startAngle = Math.PI; + const totalAngle = Math.PI; + + const arcPath = (startFrac: number, endFrac: number) => { + const a1 = startAngle - startFrac * totalAngle; + const a2 = startAngle - endFrac * totalAngle; + const x1 = cx + r * Math.cos(a1); + const y1 = cy - r * Math.sin(a1); + const x2 = cx + r * Math.cos(a2); + const y2 = cy - r * Math.sin(a2); + const largeArc = endFrac - startFrac > 0.5 ? 1 : 0; + return `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`; + }; + + const needleAngle = startAngle - fraction * totalAngle; + const needleX = cx + r * Math.cos(needleAngle); + const needleY = cy - r * Math.sin(needleAngle); + + const zones = [ + { start: 0, end: 0.21, color: "#6ee7b7" }, + { start: 0.21, end: 0.36, color: "#60a5fa" }, + { start: 0.36, end: 0.5, color: "#34d399" }, + { start: 0.5, end: 0.64, color: "#fbbf24" }, + { start: 0.64, end: 0.79, color: "#fb923c" }, + { start: 0.79, end: 0.93, color: "#f87171" }, + { start: 0.93, end: 1, color: "#a855f7" }, + ]; + + return ( + + + {zones.map((zone, i) => ( + + ))} + + + + + + + + + + + 20 + 50 + 80+ + + ); +} + +export function VdotScoreDisplay({ result, inputs, totalSeconds, compact }: VdotScoreDisplayProps) { + const animatedVdot = useCountUp(result.vdot, 1200); + const level = getVdotLevel(result.vdot); + const percentile = getVdotPercentile(result.vdot); + + return ( +
+ {/* Score header */} +
+
+ + +
+

Your VDOT Score

+ + {animatedVdot.toFixed(1)} + +
+ +
+ + {level.label} + + + {percentile} + +
+ +

+ {inputs.distanceName} in {formatTime(totalSeconds)} +

+
+
+ + {/* Quick stats */} +
+
+
+ +
+

{result.vo2max}

+

VO₂max

+
+
+
+ +
+

5

+

Zones

+
+
+
+ +
+

9

+

Predictions

+
+
+
+ ); +} diff --git a/vite-project/src/features/vdot-calculator/components/VdotSeoHead.tsx b/vite-project/src/features/vdot-calculator/components/VdotSeoHead.tsx new file mode 100644 index 0000000..79eff10 --- /dev/null +++ b/vite-project/src/features/vdot-calculator/components/VdotSeoHead.tsx @@ -0,0 +1,101 @@ +/** + * VdotSeoHead — SEO meta tags and structured data for the VDOT calculator page + */ + +import { Helmet } from "react-helmet-async"; + +export function VdotSeoHead() { + return ( + + VDOT Running Calculator – Jack Daniels Formula | TrainPace + + + {/* Open Graph */} + + + + + + + {/* Twitter */} + + + + + {/* Structured Data */} + + + ); +} diff --git a/vite-project/src/features/vdot-calculator/hooks/useVdotCalculator.ts b/vite-project/src/features/vdot-calculator/hooks/useVdotCalculator.ts new file mode 100644 index 0000000..d92890b --- /dev/null +++ b/vite-project/src/features/vdot-calculator/hooks/useVdotCalculator.ts @@ -0,0 +1,318 @@ +/** + * useVdotCalculator — Custom hook for VDOT calculator state & logic + */ + +import { useState, useMemo, useCallback } from "react"; +import type { + VdotInputs, + VdotFormErrors, + VdotResult, + PaceDisplayUnit, + CalculationHistoryEntry, +} from "../types"; +import { RACE_DISTANCES } from "../types"; +import { + calculateVdot, + predictRaceTime, + calculateTrainingZones, + formatTime, + formatPace, +} from "../vdot-math"; +import ReactGA from "react-ga4"; + +const HISTORY_KEY = "trainpace_vdot_history"; +const MAX_HISTORY = 5; + +const initialFormState: VdotInputs = { + distanceMeters: 0, + distanceName: "", + hours: "", + minutes: "", + seconds: "", +}; + +function validateInputs(inputs: VdotInputs): { + isValid: boolean; + errors: VdotFormErrors; +} { + const errors: VdotFormErrors = {}; + + if (!inputs.distanceMeters || inputs.distanceMeters <= 0) { + errors.distance = "Please select a race distance"; + } + + const h = parseInt(inputs.hours || "0"); + const m = parseInt(inputs.minutes || "0"); + const s = parseInt(inputs.seconds || "0"); + const totalSeconds = h * 3600 + m * 60 + s; + + if (totalSeconds <= 0) { + errors.time = "Please enter a valid finish time"; + } + + if (m >= 60 || s >= 60) { + errors.time = "Invalid time format"; + } + + return { isValid: Object.keys(errors).length === 0, errors }; +} + +function buildRacePredictions(vdot: number, unit: PaceDisplayUnit) { + return RACE_DISTANCES.map((race) => { + const timeSeconds = predictRaceTime(vdot, race.meters); + const pacePerKm = (timeSeconds / race.meters) * 1000; + const pacePerMile = (timeSeconds / race.meters) * 1609.34; + return { + name: race.name, + distance: race.meters, + time: formatTime(timeSeconds), + timeSeconds, + pace: + unit === "km" + ? `${formatPace(pacePerKm)}/km` + : `${formatPace(pacePerMile)}/mi`, + }; + }); +} + +function buildTrainingZones(vdot: number) { + return calculateTrainingZones(vdot).map((zone) => ({ + name: zone.name, + shortName: zone.shortName, + description: zone.description, + pacePerKm: `${formatPace(zone.pacePerKmSeconds[0])} – ${formatPace(zone.pacePerKmSeconds[1])}`, + pacePerMile: `${formatPace(zone.pacePerMileSeconds[0])} – ${formatPace(zone.pacePerMileSeconds[1])}`, + intensityRange: `${Math.round(zone.intensityRange[0] * 100)}–${Math.round(zone.intensityRange[1] * 100)}%`, + color: zone.color, + })); +} + +function loadHistory(): CalculationHistoryEntry[] { + try { + const raw = localStorage.getItem(HISTORY_KEY); + if (!raw) return []; + return JSON.parse(raw) as CalculationHistoryEntry[]; + } catch { + return []; + } +} + +function saveHistory(history: CalculationHistoryEntry[]) { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY))); + } catch { + // localStorage may be unavailable + } +} + +export function getVdotLevel(vdot: number): { label: string; color: string; bgColor: string } { + if (vdot >= 80) return { label: "Elite World Class", color: "text-purple-700", bgColor: "bg-purple-100" }; + if (vdot >= 70) return { label: "Elite", color: "text-red-600", bgColor: "bg-red-100" }; + if (vdot >= 60) return { label: "Advanced", color: "text-orange-600", bgColor: "bg-orange-100" }; + if (vdot >= 50) return { label: "Competitive", color: "text-yellow-600", bgColor: "bg-yellow-100" }; + if (vdot >= 40) return { label: "Intermediate", color: "text-blue-600", bgColor: "bg-blue-100" }; + if (vdot >= 30) return { label: "Recreational", color: "text-emerald-600", bgColor: "bg-emerald-100" }; + return { label: "Beginner", color: "text-gray-600", bgColor: "bg-gray-100" }; +} + +export function getVdotPercentile(vdot: number): string { + if (vdot >= 80) return "Top 0.01%"; + if (vdot >= 70) return "Top 0.5%"; + if (vdot >= 65) return "Top 2%"; + if (vdot >= 60) return "Top 5%"; + if (vdot >= 55) return "Top 10%"; + if (vdot >= 50) return "Top 20%"; + if (vdot >= 45) return "Top 30%"; + if (vdot >= 40) return "Top 45%"; + if (vdot >= 35) return "Top 60%"; + if (vdot >= 30) return "Top 75%"; + return "Getting started"; +} + +export type ZoneColorClasses = { + bg: string; + border: string; + text: string; + badge: string; + gradient: string; +}; + +export function getZoneColorClasses(color: string): ZoneColorClasses { + const colors: Record = { + emerald: { + bg: "bg-emerald-50", + border: "border-emerald-200", + text: "text-emerald-700", + badge: "bg-emerald-100 text-emerald-800", + gradient: "from-emerald-500 to-emerald-600", + }, + blue: { + bg: "bg-blue-50", + border: "border-blue-200", + text: "text-blue-700", + badge: "bg-blue-100 text-blue-800", + gradient: "from-blue-500 to-blue-600", + }, + yellow: { + bg: "bg-yellow-50", + border: "border-yellow-200", + text: "text-yellow-700", + badge: "bg-yellow-100 text-yellow-800", + gradient: "from-yellow-500 to-yellow-600", + }, + orange: { + bg: "bg-orange-50", + border: "border-orange-200", + text: "text-orange-700", + badge: "bg-orange-100 text-orange-800", + gradient: "from-orange-500 to-orange-600", + }, + red: { + bg: "bg-red-50", + border: "border-red-200", + text: "text-red-700", + badge: "bg-red-100 text-red-800", + gradient: "from-red-500 to-red-600", + }, + }; + return colors[color] || colors.blue; +} + +export function useVdotCalculator() { + const [inputs, setInputs] = useState(initialFormState); + const [result, setResult] = useState(null); + const [errors, setErrors] = useState({}); + const [paceUnit, setPaceUnit] = useState("km"); + const [history, setHistory] = useState(loadHistory); + + const totalSeconds = useMemo(() => { + const h = parseInt(inputs.hours || "0"); + const m = parseInt(inputs.minutes || "0"); + const s = parseInt(inputs.seconds || "0"); + return h * 3600 + m * 60 + s; + }, [inputs.hours, inputs.minutes, inputs.seconds]); + + // Live VDOT preview (computed before clicking Calculate) + const vdotPreview = useMemo(() => { + if (!inputs.distanceMeters || inputs.distanceMeters <= 0 || totalSeconds <= 0) { + return null; + } + const m = parseInt(inputs.minutes || "0"); + const s = parseInt(inputs.seconds || "0"); + if (m >= 60 || s >= 60) return null; + + const vdot = calculateVdot(inputs.distanceMeters, totalSeconds); + if (!isFinite(vdot) || vdot < 1 || vdot > 120) return null; + return Math.round(vdot * 10) / 10; + }, [inputs.distanceMeters, totalSeconds, inputs.minutes, inputs.seconds]); + + const handleDistanceSelect = useCallback((meters: number, name: string) => { + setInputs((prev) => ({ ...prev, distanceMeters: meters, distanceName: name })); + setErrors((prev) => ({ ...prev, distance: undefined })); + }, []); + + const handleTimeChange = useCallback((field: "hours" | "minutes" | "seconds", value: string) => { + const numValue = value.replace(/\D/g, "").slice(0, 2); + setInputs((prev) => ({ ...prev, [field]: numValue })); + setErrors((prev) => ({ ...prev, time: undefined })); + }, []); + + const handleCalculate = useCallback(() => { + const validation = validateInputs(inputs); + if (!validation.isValid) { + setErrors(validation.errors); + return; + } + + const vdot = calculateVdot(inputs.distanceMeters, totalSeconds); + const roundedVdot = Math.round(vdot * 10) / 10; + + const newResult: VdotResult = { + vdot: roundedVdot, + trainingZones: buildTrainingZones(vdot), + racePredictions: buildRacePredictions(vdot, paceUnit), + vo2max: roundedVdot, + }; + + setResult(newResult); + + // Save to history + const entry: CalculationHistoryEntry = { + distanceName: inputs.distanceName, + distanceMeters: inputs.distanceMeters, + timeFormatted: formatTime(totalSeconds), + totalSeconds, + vdot: roundedVdot, + date: new Date().toISOString(), + hours: inputs.hours, + minutes: inputs.minutes, + seconds: inputs.seconds, + }; + setHistory((prev) => { + const updated = [entry, ...prev.filter( + (h) => !(h.distanceName === entry.distanceName && h.totalSeconds === entry.totalSeconds) + )].slice(0, MAX_HISTORY); + saveHistory(updated); + return updated; + }); + + ReactGA.event({ + category: "VDOT Calculator", + action: "Calculated VDOT", + label: `${inputs.distanceName} - VDOT ${roundedVdot}`, + }); + }, [inputs, totalSeconds, paceUnit]); + + const handleReset = useCallback(() => { + setInputs(initialFormState); + setResult(null); + setErrors({}); + }, []); + + const handlePaceUnitToggle = useCallback(() => { + const newUnit = paceUnit === "km" ? "mi" : "km"; + setPaceUnit(newUnit); + + if (result) { + setResult((prev) => + prev + ? { ...prev, racePredictions: buildRacePredictions(prev.vdot, newUnit) } + : prev + ); + } + }, [paceUnit, result]); + + const loadFromHistory = useCallback((entry: CalculationHistoryEntry) => { + setInputs({ + distanceMeters: entry.distanceMeters, + distanceName: entry.distanceName, + hours: entry.hours, + minutes: entry.minutes, + seconds: entry.seconds, + }); + setErrors({}); + setResult(null); + }, []); + + const clearHistory = useCallback(() => { + setHistory([]); + saveHistory([]); + }, []); + + return { + inputs, + result, + errors, + paceUnit, + totalSeconds, + vdotPreview, + history, + handleDistanceSelect, + handleTimeChange, + handleCalculate, + handleReset, + handlePaceUnitToggle, + loadFromHistory, + clearHistory, + }; +} diff --git a/vite-project/src/features/vdot-calculator/index.ts b/vite-project/src/features/vdot-calculator/index.ts index 81ada9a..1fb3115 100644 --- a/vite-project/src/features/vdot-calculator/index.ts +++ b/vite-project/src/features/vdot-calculator/index.ts @@ -5,6 +5,7 @@ export type { TrainingZone, RacePrediction, PaceDisplayUnit, + CalculationHistoryEntry, } from "./types"; export { calculateVdot, diff --git a/vite-project/src/features/vdot-calculator/types.ts b/vite-project/src/features/vdot-calculator/types.ts index b588e36..5476206 100644 --- a/vite-project/src/features/vdot-calculator/types.ts +++ b/vite-project/src/features/vdot-calculator/types.ts @@ -73,3 +73,16 @@ export interface VdotFormErrors { distance?: string; time?: string; } + +/** Calculation history entry for localStorage persistence */ +export interface CalculationHistoryEntry { + distanceName: string; + distanceMeters: number; + timeFormatted: string; + totalSeconds: number; + vdot: number; + date: string; + hours: string; + minutes: string; + seconds: string; +}