Skip to content

Refactor VdotCalculator into modular component architecture#44

Open
aleexwong wants to merge 8 commits intomainfrom
claude/enhance-vdot-calculator-ef3nf
Open

Refactor VdotCalculator into modular component architecture#44
aleexwong wants to merge 8 commits intomainfrom
claude/enhance-vdot-calculator-ef3nf

Conversation

@aleexwong
Copy link
Copy Markdown
Owner

Summary

Refactored the monolithic VdotCalculator component (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 hook
    • Manages inputs, results, errors, pace unit toggle, and calculation history
    • Provides memoized calculations and callbacks for distance/time changes
    • Implements localStorage-based calculation history (max 5 entries)
    • Exports utility functions: getVdotLevel(), 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 explainer
  • DistanceSelector — Visual card-based distance picker grouped by category (Track/Road/Endurance)
  • TimeInput — Smart time input with auto-advance between fields, placeholder hints, and VDOT preview
  • VdotScoreDisplay — Animated VDOT gauge (SVG semicircle) with score reveal, percentile, and stats
  • TrainingZonesDisplay — Visual training zone cards with spectrum bar and expandable workout details
  • RacePredictionsTable — Enhanced race predictions with visual bars and mobile-responsive cards
  • VdotComparison — "What If" explorer to adjust time and see VDOT changes in real-time
  • SampleWorkouts — Zone-integrated workout cards with personalized paces
  • VdotFaq — Accordion FAQ section using shadcn/ui Accordion component

Main Component Refactor

  • VdotCalculator — Now acts as a lightweight orchestrator that composes all sub-components
    • Reduced from 986 to 210 lines
    • Delegates state to useVdotCalculator hook
    • Handles scroll-to-results behavior on calculation
    • Maintains clean separation of concerns

Type Additions

  • Added CalculationHistoryEntry interface to support history persistence

Notable Implementation Details

  • Auto-advance time input: Typing 2 digits in hours/minutes automatically focuses the next field
  • Animated VDOT gauge: SVG-based semicircular gauge with color zones and smooth needle animation
  • Calculation history: Persists up to 5 recent calculations in localStorage with load/clear functionality
  • VDOT preview: Real-time VDOT calculation as user types time values
  • Responsive design: Mobile-first approach with card-based layouts for smaller screens
  • Accessibility: Proper ARIA labels, semantic HTML, and keyboard navigation support
  • SEO optimization: Structured data (JSON-LD) for FAQPage, WebApplication, and BreadcrumbList

Benefits

  • Maintainability: Each component has a single responsibility
  • Testability: Easier to unit test individual components and the custom hook
  • Reusability: Sub-components can be imported and used elsewhere
  • Performance: Memoized calculations and optimized re-renders
  • Developer experience: Clear file structure and self-documenting component names

https://claude.ai/code/session_01XpNzAQmfgdyx5T6hDX8c8Z

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
Copilot AI review requested due to automatic review settings March 19, 2026 06:55
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
trainpace Ready Ready Preview, Comment Mar 19, 2026 7:35am

@netlify
Copy link
Copy Markdown

netlify bot commented Mar 19, 2026

Deploy Preview for trainpace ready!

Name Link
🔨 Latest commit 94f1bec
🔍 Latest deploy log https://app.netlify.com/projects/trainpace/deploys/69bba726a873410008b4de7a
😎 Deploy Preview https://deploy-preview-44--trainpace.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
Lighthouse
Lighthouse
1 paths audited
Performance: 72
Accessibility: 89
Best Practices: 100
SEO: 100
PWA: 60
View the detailed breakdown and full score reports

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useVdotCalculator to 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 CalculationHistoryEntry type 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.

Comment on lines +227 to +236
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,
};

Comment on lines +277 to +281
setResult((prev) =>
prev
? { ...prev, racePredictions: buildRacePredictions(prev.vdot, newUnit) }
: prev
);
Comment on lines +54 to +66
<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">
Comment on lines +43 to +48
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}
Comment on lines +61 to +65
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
onCalculate();
}
};
Comment on lines +15 to +21
<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/" },
Comment on lines +75 to +79
<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
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 useVdotCalculator hook 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.

Comment on lines +72 to +75
<h1 className="text-lg font-bold text-gray-900">VDOT Dashboard</h1>
<p className="text-xs text-gray-500">
{inputs.distanceName} &middot; {(() => { 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;
Comment on lines +93 to +101
<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"
/>
Comment on lines +58 to +66
<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"
}`}
>
Comment on lines +277 to +281
setResult((prev) =>
prev
? { ...prev, racePredictions: buildRacePredictions(prev.vdot, newUnit) }
: prev
);
Comment on lines +10 to +22
<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" />
Comment on lines +78 to +96
<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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants