Refactor VdotCalculator into modular component architecture#44
Refactor VdotCalculator into modular component architecture#44
Conversation
Break the monolithic 998-line VdotCalculator into 12 focused components: - VdotHero: gradient header with breadcrumbs and expandable VDOT explainer - DistanceSelector: card-based distance picker grouped by Track/Road/Endurance - TimeInput: smart fields with auto-advance, placeholder hints, live VDOT preview - VdotScoreDisplay: animated SVG gauge with count-up, percentile context, stats row - TrainingZonesDisplay: visual spectrum bar, expandable zone cards with workouts - RacePredictionsTable: desktop table + mobile cards with relative time bars - VdotComparison: "What If" slider to explore faster/slower scenarios - SampleWorkouts: zone-integrated workout cards with personalized paces - VdotFaq: shadcn Accordion replacing manual details/summary elements - VdotSeoHead: extracted SEO meta tags and structured data New useVdotCalculator hook extracts all state/logic with calculation history (localStorage, last 5 results as clickable chips). Extended types.ts with CalculationHistoryEntry. Math engine (vdot-math.ts) unchanged. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for trainpace ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
Refactors the VDOT calculator feature from a large monolithic component into a modular architecture built around a dedicated state/logic hook and focused UI sub-components, while adding localStorage-backed calculation history and richer UI sections.
Changes:
- Introduces
useVdotCalculatorto centralize state, validation, calculations, analytics eventing, and calculation history persistence. - Splits the UI into composable components (SEO head, hero, distance/time inputs, results displays, comparison, workouts, FAQ).
- Adds a
CalculationHistoryEntrytype and wires history into the main calculator experience.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| vite-project/src/features/vdot-calculator/types.ts | Adds CalculationHistoryEntry for persisted history entries. |
| vite-project/src/features/vdot-calculator/index.ts | Re-exports CalculationHistoryEntry from the feature module. |
| vite-project/src/features/vdot-calculator/hooks/useVdotCalculator.ts | New hook: validation, VDOT calculation, predictions/zones building, GA event, localStorage history. |
| vite-project/src/features/vdot-calculator/components/VdotSeoHead.tsx | Extracts page meta tags + JSON-LD schemas into a dedicated component. |
| vite-project/src/features/vdot-calculator/components/VdotScoreDisplay.tsx | New results “score” section with animated gauge + summary stats. |
| vite-project/src/features/vdot-calculator/components/VdotHero.tsx | New hero header with breadcrumbs and collapsible explainer. |
| vite-project/src/features/vdot-calculator/components/VdotFaq.tsx | New FAQ accordion section using shadcn/ui Accordion. |
| vite-project/src/features/vdot-calculator/components/VdotComparison.tsx | Adds “What If” time slider to see VDOT changes. |
| vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx | Refactors main component into an orchestrator composing the new pieces + history UI + scrolling behavior. |
| vite-project/src/features/vdot-calculator/components/TrainingZonesDisplay.tsx | New training zones UI with toggle + expandable zone details. |
| vite-project/src/features/vdot-calculator/components/TimeInput.tsx | New time input with auto-advance, Enter-to-calc, and live preview. |
| vite-project/src/features/vdot-calculator/components/SampleWorkouts.tsx | Adds workout cards that display personalized paces from zones. |
| vite-project/src/features/vdot-calculator/components/RacePredictionsTable.tsx | New predictions table with bars and a mobile card layout. |
| vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx | New grouped distance selection UI (Track/Road/Endurance). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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((prev) => | ||
| prev | ||
| ? { ...prev, racePredictions: buildRacePredictions(prev.vdot, newUnit) } | ||
| : prev | ||
| ); |
| <button | ||
| onClick={() => setShowInfo(!showInfo)} | ||
| className="inline-flex items-center gap-2 text-sm font-medium text-white/90 hover:text-white bg-white/10 hover:bg-white/20 rounded-lg px-4 py-2 transition-all" | ||
| > | ||
| What is VDOT? | ||
| <ChevronDown | ||
| className={`w-4 h-4 transition-transform duration-200 ${showInfo ? "rotate-180" : ""}`} | ||
| /> | ||
| </button> | ||
|
|
||
| {showInfo && ( | ||
| <div className="mt-4 bg-white/10 backdrop-blur-sm rounded-xl p-5 text-white/90 text-sm leading-relaxed max-w-2xl"> | ||
| <p className="mb-3"> |
| const onCalculate = () => { | ||
| handleCalculate(); | ||
| // Scroll to results after a short delay for state to update | ||
| setTimeout(() => { | ||
| resultsRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); | ||
| }, 100); |
| <div className="flex flex-wrap gap-2"> | ||
| {history.map((entry, idx) => ( | ||
| <button | ||
| key={idx} |
| const handleKeyDown = (e: React.KeyboardEvent) => { | ||
| if (e.key === "Enter") { | ||
| onCalculate(); | ||
| } | ||
| }; |
| <link rel="canonical" href="https://trainpace.com/vdot" /> | ||
| {/* Open Graph */} | ||
| <meta property="og:title" content="VDOT Running Calculator – Jack Daniels Formula | TrainPace" /> | ||
| <meta property="og:description" content="Free VDOT running calculator based on Jack Daniels' formula. Get your VDOT score, race predictions, and science-based training paces." /> | ||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:url" content="https://trainpace.com/vdot" /> | ||
| <meta property="og:image" content="https://trainpace.com/landing-page-2025.png" /> |
| { | ||
| "@type": "BreadcrumbList", | ||
| itemListElement: [ | ||
| { "@type": "ListItem", position: 1, name: "Home", item: "https://trainpace.com/" }, |
| <div | ||
| className="relative w-36 h-9 bg-indigo-100 rounded-full cursor-pointer overflow-hidden select-none" | ||
| onClick={onToggle} | ||
| title="Toggle pace display unit" | ||
| > |
…roll Replace the long vertical scroll with a 2-column dashboard grid on desktop: - Left column: VDOT score gauge + sample workouts (stacked) - Right column: training zones (full height) - Bottom row: race predictions + What If explorer (side by side) All components now accept a `compact` prop for denser dashboard display: - Smaller padding, font sizes, and spacing in compact mode - Pace unit toggle moved to a persistent top bar in results view - Compact header replaces full hero when viewing results - What If explorer opens by default in dashboard mode - Race predictions table uses compact rows without visual bars On mobile (< lg), falls back to single-column stacked layout. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
- FAQ: Override shadcn AccordionItem's default `border-b` (black) with `!border-b-gray-200` so card borders stay consistent gray - Training zones: Replace flexbox layout with CSS grid using fixed column widths (badge | name | pace | chevron) so pace values align perfectly across all 5 zone rows. Added `tabular-nums` for monospace digit alignment. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
- Remove default border-b from AccordionItem base class that inherited dark color from --border CSS variable, causing ugly black borders on FAQ cards - Widen training pace grid columns (7rem→8rem compact, 8.5rem→9rem normal) to prevent misalignment with longer pace range strings https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
There was a problem hiding this comment.
Pull request overview
Refactors the VDOT calculator feature from a large, monolithic component into a hook-driven, composable component architecture to improve maintainability and reuse while keeping the same user-facing capabilities (VDOT calculation, training zones, race predictions, SEO).
Changes:
- Added
useVdotCalculatorhook to centralize calculator state, validation, calculations, and localStorage-backed history. - Split UI into focused sub-components (SEO head, hero, distance/time inputs, results dashboard widgets, FAQ).
- Updated feature exports/types to support the new hook + history model.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
vite-project/src/features/vdot-calculator/types.ts |
Adds CalculationHistoryEntry to support persisted history. |
vite-project/src/features/vdot-calculator/index.ts |
Re-exports the new history type. |
vite-project/src/features/vdot-calculator/hooks/useVdotCalculator.ts |
New hook encapsulating calculator logic, derived values, handlers, and history persistence. |
vite-project/src/features/vdot-calculator/components/VdotSeoHead.tsx |
New SEO/structured-data head component for the VDOT page. |
vite-project/src/features/vdot-calculator/components/VdotHero.tsx |
New hero/header section with breadcrumbs and expandable explainer. |
vite-project/src/features/vdot-calculator/components/DistanceSelector.tsx |
New grouped, card-based distance picker. |
vite-project/src/features/vdot-calculator/components/TimeInput.tsx |
New time input with auto-advance + live VDOT preview. |
vite-project/src/features/vdot-calculator/components/VdotScoreDisplay.tsx |
New animated gauge + summary stats display. |
vite-project/src/features/vdot-calculator/components/TrainingZonesDisplay.tsx |
New training zones UI with expand/collapse details and aligned pace columns. |
vite-project/src/features/vdot-calculator/components/RacePredictionsTable.tsx |
New race equivalency table with visual bars and compact mode. |
vite-project/src/features/vdot-calculator/components/VdotComparison.tsx |
New “What If?” slider to explore time/VDOT deltas. |
vite-project/src/features/vdot-calculator/components/SampleWorkouts.tsx |
New workout cards bound to calculated training zones. |
vite-project/src/features/vdot-calculator/components/VdotFaq.tsx |
New shadcn/ui Accordion-based FAQ + science section. |
vite-project/src/features/vdot-calculator/components/VdotCalculator.tsx |
Refactored orchestrator composing new hook + sub-components into input/results states. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| <h1 className="text-lg font-bold text-gray-900">VDOT Dashboard</h1> | ||
| <p className="text-xs text-gray-500"> | ||
| {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(" "); })()} | ||
| </p> |
| result: VdotResult; | ||
| inputs: VdotInputs; | ||
| totalSeconds: number; | ||
| onReset?: () => void; |
| <input | ||
| type="range" | ||
| min={-maxOffset} | ||
| max={maxOffset} | ||
| step={totalSeconds > 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" | ||
| /> |
| <button | ||
| key={d.name} | ||
| onClick={() => onSelect(d.meters, d.name)} | ||
| 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" | ||
| }`} | ||
| > |
| setResult((prev) => | ||
| prev | ||
| ? { ...prev, racePredictions: buildRacePredictions(prev.vdot, newUnit) } | ||
| : prev | ||
| ); |
| <title>VDOT Running Calculator – Jack Daniels Formula | TrainPace</title> | ||
| <meta | ||
| name="description" | ||
| content="Free VDOT running calculator based on Jack Daniels' formula. Enter any race time to get your VDOT score, equivalent race predictions for 800m to marathon, and training paces for Easy, Marathon, Threshold, Interval, and Repetition zones." | ||
| /> | ||
| <link rel="canonical" href="https://trainpace.com/vdot" /> | ||
| {/* Open Graph */} | ||
| <meta property="og:title" content="VDOT Running Calculator – Jack Daniels Formula | TrainPace" /> | ||
| <meta property="og:description" content="Free VDOT running calculator based on Jack Daniels' formula. Get your VDOT score, race predictions, and science-based training paces." /> | ||
| <meta property="og:type" content="website" /> | ||
| <meta property="og:url" content="https://trainpace.com/vdot" /> | ||
| <meta property="og:image" content="https://trainpace.com/landing-page-2025.png" /> | ||
| <meta property="og:site_name" content="TrainPace" /> |
| <div | ||
| className="relative w-32 h-8 bg-indigo-100 rounded-full cursor-pointer overflow-hidden select-none" | ||
| onClick={handlePaceUnitToggle} | ||
| title="Toggle pace display unit" | ||
| > | ||
| <div | ||
| className={`absolute top-0.5 left-0.5 w-[calc(50%-0.25rem)] h-7 bg-indigo-600 rounded-full shadow-md transform transition-transform duration-300 ease-in-out ${ | ||
| paceUnit === "mi" ? "translate-x-full" : "translate-x-0" | ||
| }`} | ||
| /> | ||
| <div className="absolute inset-0 flex items-center"> | ||
| <div className={`w-1/2 text-center text-xs font-semibold transition-colors ${paceUnit === "km" ? "text-white" : "text-indigo-700"}`}> | ||
| min/km | ||
| </div> | ||
|
|
||
| {/* Time Input */} | ||
| <div className="mb-8"> | ||
| <label className="block text-sm font-medium text-gray-700 mb-3"> | ||
| Finish Time | ||
| </label> | ||
| <div className="flex items-center gap-2 sm:gap-3"> | ||
| <div className="flex-1"> | ||
| <input | ||
| type="text" | ||
| inputMode="numeric" | ||
| placeholder="HH" | ||
| value={inputs.hours} | ||
| onChange={(e) => | ||
| 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" | ||
| /> | ||
| <p className="text-xs text-gray-500 text-center mt-1"> | ||
| Hours | ||
| </p> | ||
| </div> | ||
| <span className="text-2xl font-bold text-gray-400"> | ||
| : | ||
| </span> | ||
| <div className="flex-1"> | ||
| <input | ||
| type="text" | ||
| inputMode="numeric" | ||
| placeholder="MM" | ||
| value={inputs.minutes} | ||
| onChange={(e) => | ||
| 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" | ||
| /> | ||
| <p className="text-xs text-gray-500 text-center mt-1"> | ||
| Minutes | ||
| </p> | ||
| </div> | ||
| <span className="text-2xl font-bold text-gray-400"> | ||
| : | ||
| </span> | ||
| <div className="flex-1"> | ||
| <input | ||
| type="text" | ||
| inputMode="numeric" | ||
| placeholder="SS" | ||
| value={inputs.seconds} | ||
| onChange={(e) => | ||
| 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" | ||
| /> | ||
| <p className="text-xs text-gray-500 text-center mt-1"> | ||
| Seconds | ||
| </p> | ||
| </div> | ||
| </div> | ||
| {errors.time && ( | ||
| <p className="mt-2 text-sm text-red-600">{errors.time}</p> | ||
| )} | ||
| <div className={`w-1/2 text-center text-xs font-semibold transition-colors ${paceUnit === "mi" ? "text-white" : "text-indigo-700"}`}> | ||
| min/mi | ||
| </div> | ||
| </div> | ||
| </div> |
Removes the accordion UI in favor of readable text blocks covering VDOT explanation, training zones (visual chips), score ranges, and the underlying math. Adds a "Learn more" section linking to 4 relevant blog posts for deeper reading. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
Replace the grid-row/accordion layout with individual color-coded cards per zone. Each card shows the zone badge, pace prominently, description, and sample workouts. Responsive grid: 1 col on mobile, up to 5 cols on wide screens so all zones sit side-by-side. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
Shrink zone cards: 6px badge, xs text, 2px padding in compact mode, all 5 zones in a single row. Removed top accent bar, description text, and workout details in compact mode. Non-compact shows workouts as a minimal one-line list. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z
Add items-start to the dashboard grid so cards align to top instead of stretching to equal height. https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z

Summary
Refactored the monolithic
VdotCalculatorcomponent (986 lines) into a modular, composable architecture with dedicated sub-components and a custom hook. This improves maintainability, testability, and code reusability while preserving all existing functionality.Key Changes
New Custom Hook
useVdotCalculator— Extracted all state management, validation, and calculation logic into a reusable hookgetVdotLevel(),getVdotPercentile(),getZoneColorClasses()New Sub-Components
VdotSeoHead— Extracted SEO meta tags, Open Graph, Twitter cards, and structured data (JSON-LD)VdotHero— Premium gradient header with breadcrumbs and collapsible VDOT explainerDistanceSelector— Visual card-based distance picker grouped by category (Track/Road/Endurance)TimeInput— Smart time input with auto-advance between fields, placeholder hints, and VDOT previewVdotScoreDisplay— Animated VDOT gauge (SVG semicircle) with score reveal, percentile, and statsTrainingZonesDisplay— Visual training zone cards with spectrum bar and expandable workout detailsRacePredictionsTable— Enhanced race predictions with visual bars and mobile-responsive cardsVdotComparison— "What If" explorer to adjust time and see VDOT changes in real-timeSampleWorkouts— Zone-integrated workout cards with personalized pacesVdotFaq— Accordion FAQ section using shadcn/ui Accordion componentMain Component Refactor
VdotCalculator— Now acts as a lightweight orchestrator that composes all sub-componentsuseVdotCalculatorhookType Additions
CalculationHistoryEntryinterface to support history persistenceNotable Implementation Details
Benefits
https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z