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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,15 @@ Wrap the page component with `<AuthGuard>` in `src/App.tsx`.
- `usePacePlanPersistence.ts` — localStorage + Firestore persistence
- Types: `PaceInputs`, `PaceResults`, `DistanceUnit` (`'km' | 'miles'`), `PaceUnit`

**UX features added April 2026 (Runna/Strava-style redesign):**
- `SUGGESTED_TIMES` — contextual goal-time chips per preset distance (e.g. "Sub 3h", "3:30", "4:00" for Marathon). Tapping a chip fills HH:MM:SS and **auto-calculates** without a button press.
- `SLIDER_RANGES` — per-distance min/max/step config; a fine-tune slider appears once a preset distance is selected and a valid time exists. Dragging rewrites the HH:MM:SS fields live.
- **Live VDOT badge** — `⚡ VDOT 45.2 · Intermediate` updates in real time using `calculateVdot` imported from `@/features/vdot-calculator/vdot-math`.
- **Auto-advance focus** HH → MM → SS after 2 digits typed.
- `onPresetClick` now takes `(distance: number, presetName: string)` — the preset name drives which suggested times and slider range are shown.

**Saving is unchanged** — `handleSave` → `SavePlanDialog` → `saveToDashboard` → Firestore path is identical. Guest-redirect flow via sessionStorage also unchanged.

### VDOT Calculator (`src/features/vdot-calculator/`)
Jack Daniels VDOT scoring tool. Refactored March 2026 from 998-line monolith into 12 focused components with dashboard layout.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
* Modern UI similar to fuel planner
*/

import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { Helmet } from "react-helmet-async";
import { Card, CardContent } from "@/components/ui/card";
import { Info, ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "@/hooks/use-toast";
import { usePendingPacePlan } from "@/hooks/usePendingPacePlan";
import ReactGA from "react-ga4";
import { calculateVdot } from "@/features/vdot-calculator/vdot-math";

import type { PaceInputs, PaceResults, FormErrors, PaceUnit } from "../types";
import { usePaceCalculation } from "../hooks/usePaceCalculation";
import { usePacePlanPersistence } from "../hooks/usePacePlanPersistence";
import { timeToSeconds } from "../utils";
import { RaceDetailsForm } from "./RaceDetailsForm";
import { PaceResultsDisplay } from "./PaceResultsDisplay";
import { SavePlanDialog } from "./SavePlanDialog";
Expand Down Expand Up @@ -50,6 +52,12 @@
const [errors, setErrors] = useState<FormErrors>({});
const [isCalculating, setIsCalculating] = useState(false);

// Track which preset button was last clicked so we can show contextual chips
const [selectedPresetName, setSelectedPresetName] = useState<string | null>(null);

// When true the next valid calculation result is auto-committed (e.g. chip click)
const [autoCalc, setAutoCalc] = useState(false);

// UI state
const [showInfo, setShowInfo] = useState(false);
const [showPhilosophy, setShowPhilosophy] = useState(false);
Expand All @@ -62,7 +70,37 @@
const { isSaving, isSaved, saveToDashboard, resetSaveState } =
usePacePlanPersistence();

// Auto-update results when pace type changes
// Live VDOT – updates as user types
const liveVdot = useMemo(() => {
const dist = parseFloat(inputs.distance);
if (!dist || dist <= 0) return null;
const totalSecs = timeToSeconds(inputs.hours, inputs.minutes, inputs.seconds);
if (totalSecs <= 0) return null;
const distMeters = inputs.units === "km" ? dist * 1000 : dist * 1609.34;
const vdot = calculateVdot(distMeters, totalSecs);

Check warning on line 80 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L80

Unsafe assignment of an error typed value.

Check warning on line 80 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L80

Unsafe call of an `error` type typed value.
if (!isFinite(vdot) || vdot < 10 || vdot > 100) return null;

Check warning on line 81 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L81

Unsafe argument of type `any` assigned to a parameter of type `number`.
return Math.round(vdot * 10) / 10;
}, [inputs.distance, inputs.units, inputs.hours, inputs.minutes, inputs.seconds]);
Comment on lines +77 to +83
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

liveVdot is calculated for any nonzero time, even when the time fields are in an invalid state (e.g. minutes/seconds >= 60). This can display a VDOT badge that doesn’t correspond to a valid race time. Consider mirroring validatePaceInputs here (return null when minutes/seconds are >= 60 or when errors.time is present).

Suggested change
const totalSecs = timeToSeconds(inputs.hours, inputs.minutes, inputs.seconds);
if (totalSecs <= 0) return null;
const distMeters = inputs.units === "km" ? dist * 1000 : dist * 1609.34;
const vdot = calculateVdot(distMeters, totalSecs);
if (!isFinite(vdot) || vdot < 10 || vdot > 100) return null;
return Math.round(vdot * 10) / 10;
}, [inputs.distance, inputs.units, inputs.hours, inputs.minutes, inputs.seconds]);
if (errors.time) return null;
const minutes = parseInt(inputs.minutes || "0", 10);
const seconds = parseInt(inputs.seconds || "0", 10);
if (minutes >= 60 || seconds >= 60) return null;
const totalSecs = timeToSeconds(inputs.hours, inputs.minutes, inputs.seconds);
if (totalSecs <= 0) return null;
const distMeters = inputs.units === "km" ? dist * 1000 : dist * 1609.34;
const vdot = calculateVdot(distMeters, totalSecs);
if (!isFinite(vdot) || vdot < 10 || vdot > 100) return null;
return Math.round(vdot * 10) / 10;
}, [inputs.distance, inputs.units, inputs.hours, inputs.minutes, inputs.seconds, errors.time]);

Copilot uses AI. Check for mistakes.

// Auto-calculate when a suggested-time chip is tapped
useEffect(() => {
if (autoCalc && calculation.isValid && calculation.result) {
setResults(calculation.result);
setAutoCalc(false);
toast({
title: "Calculation Complete! ✨",
description: "Your training paces have been calculated.",
duration: 3000,
});
ReactGA.event({
category: "Pace Calculator",
action: "Calculated Paces",
label: `${inputs.distance}${inputs.units} (suggested time)`,
});
}
}, [autoCalc, calculation.isValid, calculation.result, inputs.distance, inputs.units]);

// Auto-update results when pace type changes (user is already on results screen)
useEffect(() => {
if (results && calculation.isValid && calculation.result) {
setResults(calculation.result);
Expand All @@ -76,47 +114,59 @@
label: "User opened the Pace Calculator",
});

// Handlers
const handleInputChange = (e: {
target: { name: string; value: string };
}) => {
// ── Handlers ────────────────────────────────────────────────────────────────

const handleInputChange = (e: { target: { name: string; value: string } }) => {
const { name, value } = e.target;

// Time input validation
if (["hours", "minutes", "seconds"].includes(name)) {
const numValue = value.replace(/\D/g, "");
setInputs((prev) => ({
...prev,
[name]: numValue.slice(0, 2),
}));
setInputs((prev) => ({ ...prev, [name]: numValue.slice(0, 2) }));
return;
}

setInputs((prev) => ({
...prev,
[name]: value,
}));
setInputs((prev) => ({ ...prev, [name]: value }));

// Clear errors for this field
if (errors[name as keyof FormErrors]) {
setErrors((prev) => ({
...prev,
[name]: undefined,
}));
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
};
Comment on lines +119 to 133
Copy link

Copilot AI Apr 12, 2026

Choose a reason for hiding this comment

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

selectedPresetName is only set when a preset button is clicked, but it is never cleared when the user manually edits the distance field. This can leave suggested-time chips/slider showing for a preset even after switching to a custom distance, which conflicts with “contextually shown only for known race distances.” Clear selectedPresetName when name === "distance" changes via typing/paste (and possibly when units toggle changes distance away from the preset).

Copilot uses AI. Check for mistakes.

const handlePreset = (distance: number) => {
/** Called by distance preset buttons – now also tracks the preset name */
const handlePreset = (distance: number, presetName: string) => {
setInputs((prev) => ({ ...prev, distance: distance.toString() }));
setSelectedPresetName(presetName);
setErrors({});
};

/** Tapping a suggested-time chip fills HH/MM/SS and auto-calculates */
const handleSuggestedTimeClick = (h: string, m: string, s: string) => {
setInputs((prev) => ({
...prev,
distance: distance.toString(),
hours: h === "0" ? "" : h,
minutes: m,
seconds: s,
}));
setErrors({});
setAutoCalc(true);
};

/** Slider drag – decompose total seconds into HH/MM/SS fields */
const handleSliderChange = (totalSeconds: number) => {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
setInputs((prev) => ({
...prev,
hours: h > 0 ? h.toString() : "",
minutes: m.toString(),
seconds: s.toString(),
}));
};

const handleCalculate = () => {
if (!calculation.isValid) {
setErrors(calculation.errors);
setErrors(calculation.errors as FormErrors);
toast({
title: "Validation Error",
description: "Please check the form for errors.",
Expand Down Expand Up @@ -159,12 +209,7 @@
};

const handlePaceTypeChange = (newPaceType: PaceUnit) => {
// Update the input state
setInputs((prev) => ({
...prev,
paceType: newPaceType,
}));

setInputs((prev) => ({ ...prev, paceType: newPaceType }));
ReactGA.event({
category: "Pace Calculator",
action: "Changed Pace Type",
Expand All @@ -180,19 +225,13 @@

Object.entries(results).forEach(([key, value]) => {
const displayName = key === "xlong" ? "Long Run" : key;
text += `${
displayName.charAt(0).toUpperCase() + displayName.slice(1)
}: ${value}\n`;
text += `${displayName.charAt(0).toUpperCase() + displayName.slice(1)}: ${value}\n`;
});

try {
await navigator.clipboard.writeText(text);
toast({ title: "Copied to clipboard! 📋" });

ReactGA.event({
category: "Pace Calculator",
action: "Copied Plan",
});
ReactGA.event({ category: "Pace Calculator", action: "Copied Plan" });

Check warning on line 234 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L234

Unsafe call of an `error` type typed value.

Check warning on line 234 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L234

Unsafe member access .event on an `error` typed value.
} catch {
toast({ title: "Failed to copy", variant: "destructive" });
}
Expand All @@ -206,9 +245,7 @@

Object.entries(results).forEach(([key, value]) => {
const displayName = key === "xlong" ? "Long Run" : key;
text += `${
displayName.charAt(0).toUpperCase() + displayName.slice(1)
}: ${value}\n`;
text += `${displayName.charAt(0).toUpperCase() + displayName.slice(1)}: ${value}\n`;
});

const blob = new Blob([text], { type: "text/plain" });
Expand All @@ -220,11 +257,7 @@
URL.revokeObjectURL(url);

toast({ title: "Download started! 💾" });

ReactGA.event({
category: "Pace Calculator",
action: "Downloaded Plan",
});
ReactGA.event({ category: "Pace Calculator", action: "Downloaded Plan" });

Check warning on line 260 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L260

Unsafe call of an `error` type typed value.

Check warning on line 260 in vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

vite-project/src/features/pace-calculator/components/PaceCalculatorV2.tsx#L260

Unsafe member access .event on an `error` typed value.
};

const handleSave = async () => {
Expand All @@ -238,26 +271,20 @@
raceDate?: string
) => {
if (!results) return;

await saveToDashboard({
inputs,
results,
planName,
notes,
raceDate,
});

await saveToDashboard({ inputs, results, planName, notes, raceDate });
setShowSaveDialog(false);
};

const getRaceTime = () => {
const parts = [];
if (inputs.hours) parts.push(`${inputs.hours}h`);
if (inputs.hours) parts.push(`${inputs.hours}h`);
if (inputs.minutes) parts.push(`${inputs.minutes}m`);
if (inputs.seconds) parts.push(`${inputs.seconds}s`);
return parts.join(" ");
};

// ── Render ──────────────────────────────────────────────────────────────────

return (
<>
{seoMode !== "none" && (
Expand All @@ -276,10 +303,7 @@
description:
"Use your recent race time to calculate personalized training paces for Easy, Tempo, Threshold, and Interval runs using VDOT methodology.",
totalTime: "PT1M",
tool: {
"@type": "HowToTool",
name: "TrainPace Calculator",
},
tool: { "@type": "HowToTool", name: "TrainPace Calculator" },
step: [
{
"@type": "HowToStep",
Expand Down Expand Up @@ -315,9 +339,7 @@
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<h1 className="text-4xl font-bold text-gray-900">
⏱️ Pace Calculator
</h1>
<h1 className="text-4xl font-bold text-gray-900">⏱️ Pace Calculator</h1>
<button
onClick={() => setShowInfo(!showInfo)}
className="p-3 rounded-full bg-white shadow-md hover:shadow-lg transition-all"
Expand Down Expand Up @@ -347,7 +369,6 @@

{/* Main Content */}
<div className="max-w-4xl mx-auto space-y-8">
{/* Race Details OR Results */}
{!results ? (
<RaceDetailsForm
inputs={inputs}
Expand All @@ -356,6 +377,10 @@
onPresetClick={handlePreset}
onCalculate={handleCalculate}
isCalculating={isCalculating}
selectedPresetName={selectedPresetName}
liveVdot={liveVdot}
onSuggestedTimeClick={handleSuggestedTimeClick}
onSliderChange={handleSliderChange}
/>
) : (
<PaceResultsDisplay
Expand All @@ -380,9 +405,7 @@
onClick={() => setShowPhilosophy(!showPhilosophy)}
className="w-full flex items-center justify-between p-4 bg-white rounded-xl shadow-md hover:shadow-lg transition-all"
>
<span className="text-lg font-semibold text-gray-900">
Training Philosophy
</span>
<span className="text-lg font-semibold text-gray-900">Training Philosophy</span>
{showPhilosophy ? (
<ChevronUp className="h-5 w-5 text-gray-600" />
) : (
Expand Down
Loading
Loading