From efa7775e3c2c44c71619e11daf2cea72af5238dc Mon Sep 17 00:00:00 2001 From: Maksim Milykh <34083943+aidmax@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:44:36 +0000 Subject: [PATCH 1/5] Add Cycling/Rest/Other entry types Introduces an entry-type selector so the app supports rest-day logging (wellness metrics + free-form journal) and non-cycling activities (MFR, yoga, strength, mobility) alongside the existing cycling form. Cycling output is unchanged; rest and other entries get dedicated markdown formats. Old drafts without entryType are migrated to cycling on restore. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 28 +- client/src/hooks/use-form-persistence.ts | 4 + client/src/hooks/use-section-state.ts | 6 +- client/src/pages/home.tsx | 366 ++++++++++++++++++----- client/src/test/schema.test.ts | 115 ++++++- client/src/test/utils.test.ts | 216 +++++++++++-- shared/schema-static.ts | 47 ++- 7 files changed, 669 insertions(+), 113 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 40d1f85..fbb9156 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,10 +86,22 @@ scripts/ # AWS S3 deployment automation - localStorage key: `"pedalnotes-sections"`; stored as `{ version: 1, data: Record }` - On mount: reads stored state, merges defaults for any missing keys, drops unknown keys - Version mismatch or malformed data falls back to defaults and overwrites stored value -- Default expansion: Core Metrics=open, Reflection=open; Fueling/Performance/Recovery=collapsed +- Default expansion: Core Metrics=open, Reflection=open, Rest Day=open, Activity=open; Fueling/Performance/Recovery=collapsed - `toggleSection(id)`, `setSection(id, open)`, `resetSections()` — writes persist immediately (no debounce) - `QuotaExceededError` on write is caught; section states still work in memory - Section states are independent from form draft; clearing the form does not reset section states +- Section IDs: `core-metrics`, `fueling`, `performance-metrics`, `recovery-metrics`, `reflection`, `rest-day`, `activity` +- Visible sections depend on `entryType` — `expandAllOrCollapseAll` only affects currently visible sections + +### Entry Types +Three entry types supported via `entryType` field (default: `cycling`): +- **`cycling`** — full cycling workout form (Core Metrics, Fueling, Performance, Recovery, Reflection) +- **`rest`** — rest day logging (Recovery Metrics + Rest Day sections only); auto-expands Recovery Metrics on switch +- **`other`** — non-cycling activities like MFR/yoga/strength (Activity section only) + +`goal`, `rpe`, `feel` are only required when `entryType === "cycling"` (enforced by `superRefine`). Switching entry types does not reset other fields — hidden fields retain their values but don't appear in markdown output. + +Old drafts (pre-entryType) are migrated to `cycling` on restore in `use-form-persistence.ts`. ### Data Flow 1. User fills form → React Hook Form manages state @@ -104,11 +116,19 @@ scripts/ # AWS S3 deployment automation - `@shared/` → `shared/` ### Workout Schema Fields -Required: `workoutDate`, `goal`, `rpe` (1-10), `feel` (W/P/N/G/S) -Optional: `choIntakePre`, `choIntake`, `choIntakePost`, `normalizedPower`, `tss`, `avgHeartRate`, `hrv`, `rMSSD`, `rhr`, `trainerRoadRpe`, `trainerRoadLgt`, `whatWentWell`, `whatCouldBeImproved`, `description` +Always required: `entryType` (`cycling`/`rest`/`other`), `workoutDate` +Cycling-required (via `superRefine`): `goal`, `rpe` (1-10), `feel` (W/P/N/G/S) +Cycling-optional: `choIntakePre`, `choIntake`, `choIntakePost`, `normalizedPower`, `tss`, `avgHeartRate`, `hrv`, `rMSSD`, `rhr`, `trainerRoadRpe`, `trainerRoadLgt`, `whatWentWell`, `whatCouldBeImproved`, `description` +Rest-only: `weight` (positive number, kg), `restNotes` (free-form, rendered as bullets). Rest entries also reuse `hrv`/`rMSSD`/`rhr`/`trainerRoadLgt`. +Other-only: `activityGoal` (e.g. "MFR", "Yoga"), `activityNotes` (free-form, rendered as bullets). ### Markdown Abbreviations -G=Goal, R=RPE, F=Feel, Ci-Pre=Carbohydrate Intake Pre-Workout, Ci=Carbohydrate Intake During Ride, Ci-Post=Carbohydrate Intake Post-Workout, NP=Normalized Power, TSS=Training Stress Score, Hr=Heart Rate, HRV=Heart Rate Variability, RHR=Resting Heart Rate, TR-RPE=TrainerRoad RPE, TR-LGT=TrainerRoad Light +G=Goal (cycling) or Activity (other), R=RPE, F=Feel, Ci-Pre=Carbohydrate Intake Pre-Workout, Ci=Carbohydrate Intake During Ride, Ci-Post=Carbohydrate Intake Post-Workout, NP=Normalized Power, TSS=Training Stress Score, Hr=Heart Rate, HRV=Heart Rate Variability, rMSSD=HRV Recovery Metric, RHR=Resting Heart Rate, TR-RPE=TrainerRoad RPE, TR-LGT=TrainerRoad Light, W=Weight (rest) + +### Markdown Output Per Entry Type +- **cycling**: `G` / `R` / `F` + optional metrics + WWW/WCBI/Planned blocks (current format, unchanged) +- **rest**: `Rest Day` marker + present-only recovery metrics and `W` + bulleted `restNotes` +- **other**: optional `G: ` + bulleted `activityNotes`; no metrics ## Environment Variables (Deployment Only) ``` diff --git a/client/src/hooks/use-form-persistence.ts b/client/src/hooks/use-form-persistence.ts index 62b7617..f13a3fa 100644 --- a/client/src/hooks/use-form-persistence.ts +++ b/client/src/hooks/use-form-persistence.ts @@ -46,6 +46,10 @@ export function useFormPersistence( localStorage.removeItem(key); return; } + const data = draft.data as Record; + if (data && typeof data === "object" && !data.entryType) { + data.entryType = "cycling"; + } form.reset(draft.data); setWasRestored(true); } diff --git a/client/src/hooks/use-section-state.ts b/client/src/hooks/use-section-state.ts index 1113d87..5ea0c7a 100644 --- a/client/src/hooks/use-section-state.ts +++ b/client/src/hooks/use-section-state.ts @@ -5,7 +5,9 @@ export type SectionId = | "fueling" | "performance-metrics" | "recovery-metrics" - | "reflection"; + | "reflection" + | "rest-day" + | "activity"; const ALL_SECTION_IDS: SectionId[] = [ "core-metrics", @@ -13,6 +15,8 @@ const ALL_SECTION_IDS: SectionId[] = [ "performance-metrics", "recovery-metrics", "reflection", + "rest-day", + "activity", ]; interface PersistedSectionState { diff --git a/client/src/pages/home.tsx b/client/src/pages/home.tsx index 5ce2a6b..bc78b80 100644 --- a/client/src/pages/home.tsx +++ b/client/src/pages/home.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { insertWorkoutSchema, type InsertWorkout } from "@shared/schema-static"; +import { insertWorkoutSchema, type InsertWorkout, type EntryType } from "@shared/schema-static"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -32,7 +32,13 @@ import { Info, Sun, Moon, - RotateCcw + RotateCcw, + Bike, + BedDouble, + Dumbbell, + ClipboardList, + Scale, + NotebookPen } from "lucide-react"; const SECTION_DEFAULTS: Record = { @@ -41,6 +47,8 @@ const SECTION_DEFAULTS: Record = { "performance-metrics": false, "recovery-metrics": false, "reflection": true, + "rest-day": true, + "activity": true, }; const FIELD_TO_SECTION: Partial> = { @@ -61,6 +69,28 @@ const FIELD_TO_SECTION: Partial> = { whatWentWell: "reflection", whatCouldBeImproved: "reflection", description: "reflection", + weight: "rest-day", + restNotes: "rest-day", + activityGoal: "activity", + activityNotes: "activity", +}; + +const entryTypeOptions = [ + { value: "cycling" as const, label: "Cycling", icon: Bike }, + { value: "rest" as const, label: "Rest", icon: BedDouble }, + { value: "other" as const, label: "Other", icon: Dumbbell }, +]; + +const entryTypeSubtitles: Record = { + cycling: "Fill in your cycling workout information to generate a structured markdown report.", + rest: "Log your rest day — wellness metrics and how you're feeling.", + other: "Log your activity — stretching, yoga, strength, mobility, or anything else.", +}; + +const VISIBLE_SECTIONS_BY_TYPE: Record = { + cycling: ["core-metrics", "fueling", "performance-metrics", "recovery-metrics", "reflection"], + rest: ["recovery-metrics", "rest-day"], + other: ["activity"], }; const rpeOptions = [ @@ -100,6 +130,7 @@ const lgtOptions = [ function getDefaultWorkoutValues(): InsertWorkout { return { + entryType: "cycling", workoutDate: new Date().toISOString().split('T')[0], goal: "", rpe: 5, @@ -117,7 +148,11 @@ function getDefaultWorkoutValues(): InsertWorkout { trainerRoadLgt: undefined, whatWentWell: "", whatCouldBeImproved: "", - description: "" + description: "", + weight: undefined, + restNotes: "", + activityGoal: "", + activityNotes: "", }; } @@ -147,81 +182,101 @@ function formatBulletPoints(text: string): string { .join("\n"); } -function generateMarkdown(data: InsertWorkout): string { - const formatDate = (dateStr: string) => { - const date = new Date(dateStr); - const day = String(date.getDate()).padStart(2, '0'); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const year = date.getFullYear(); - return `${day}.${month}.${year}`; - }; +function formatWorkoutDate(dateStr: string): string { + const date = new Date(dateStr); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}.${month}.${year}`; +} - let markdown = `---\n## ${formatDate(data.workoutDate)}\n\n`; +function generateCyclingMarkdown(data: InsertWorkout): string { + let markdown = ""; if (data.goal) markdown += `G: ${data.goal}\n`; markdown += `R: ${data.rpe}\n`; markdown += `F: ${data.feel}\n`; - - if (data.choIntakePre) { - markdown += `Ci-Pre: ${data.choIntakePre}\n`; - } - - if (data.choIntake) { - markdown += `Ci: ${data.choIntake}\n`; - } - - if (data.choIntakePost) { - markdown += `Ci-Post: ${data.choIntakePost}\n`; - } - - if (data.normalizedPower) { - markdown += `NP: ${data.normalizedPower}\n`; - } - - if (data.tss) { - markdown += `TSS: ${data.tss}\n`; } - - if (data.avgHeartRate) { - markdown += `Hr: ${data.avgHeartRate}\n`; - } - - if (data.trainerRoadRpe) { - markdown += `TR-RPE: ${data.trainerRoadRpe}\n`; - } - - if (data.hrv) { - markdown += `HRV: ${data.hrv}\n`; - } - - if (data.rMSSD) { - markdown += `rMSSD: ${data.rMSSD}\n`; - } - - if (data.rhr) { - markdown += `RHR: ${data.rhr}\n`; - } + if (data.choIntakePre) markdown += `Ci-Pre: ${data.choIntakePre}\n`; + if (data.choIntake) markdown += `Ci: ${data.choIntake}\n`; + if (data.choIntakePost) markdown += `Ci-Post: ${data.choIntakePost}\n`; + if (data.normalizedPower) markdown += `NP: ${data.normalizedPower}\n`; + if (data.tss) markdown += `TSS: ${data.tss}\n`; + if (data.avgHeartRate) markdown += `Hr: ${data.avgHeartRate}\n`; + if (data.trainerRoadRpe) markdown += `TR-RPE: ${data.trainerRoadRpe}\n`; + if (data.hrv) markdown += `HRV: ${data.hrv}\n`; + if (data.rMSSD) markdown += `rMSSD: ${data.rMSSD}\n`; + if (data.rhr) markdown += `RHR: ${data.rhr}\n`; if (data.trainerRoadLgt && data.trainerRoadLgt !== 'G') { markdown += `TR-LGT: ${data.trainerRoadLgt}\n`; } - + markdown += '\n'; - + if (data.whatWentWell) { markdown += 'WWW\n'; markdown += formatBulletPoints(data.whatWentWell) + '\n\n'; } - + if (data.whatCouldBeImproved) { markdown += 'WCBI\n'; markdown += formatBulletPoints(data.whatCouldBeImproved) + '\n'; } - + if (data.description) { markdown += '\nPlanned\n'; markdown += data.description + '\n'; } - + + return markdown; +} + +function generateRestMarkdown(data: InsertWorkout): string { + let markdown = "Rest Day\n\n"; + + if (data.hrv) markdown += `HRV: ${data.hrv}\n`; + if (data.rMSSD) markdown += `rMSSD: ${data.rMSSD}\n`; + if (data.rhr) markdown += `RHR: ${data.rhr}\n`; + if (data.trainerRoadLgt && data.trainerRoadLgt !== 'G') { + markdown += `TR-LGT: ${data.trainerRoadLgt}\n`; + } + if (data.weight) markdown += `W: ${data.weight}\n`; + + if (data.restNotes) { + markdown += '\n' + formatBulletPoints(data.restNotes) + '\n'; + } + + return markdown; +} + +function generateOtherMarkdown(data: InsertWorkout): string { + let markdown = ""; + + if (data.activityGoal) markdown += `G: ${data.activityGoal}\n`; + + if (data.activityNotes) { + markdown += '\n' + formatBulletPoints(data.activityNotes) + '\n'; + } + + return markdown; +} + +function generateMarkdown(data: InsertWorkout): string { + let markdown = `---\n## ${formatWorkoutDate(data.workoutDate)}\n\n`; + + switch (data.entryType) { + case "rest": + markdown += generateRestMarkdown(data); + break; + case "other": + markdown += generateOtherMarkdown(data); + break; + case "cycling": + default: + markdown += generateCyclingMarkdown(data); + break; + } + return markdown; } @@ -250,18 +305,28 @@ export default function Home() { ); const watchedValues = form.watch(); + const entryType: EntryType = watchedValues.entryType ?? "cycling"; const { isDirty } = form.formState; - const allExpanded = Object.values(sectionStates).every(Boolean); + const visibleSectionIds = VISIBLE_SECTIONS_BY_TYPE[entryType]; + const allExpanded = visibleSectionIds.every((id) => sectionStates[id]); function expandAllOrCollapseAll() { const target = !allExpanded; - (["core-metrics", "fueling", "performance-metrics", "recovery-metrics", "reflection"] as SectionId[]).forEach( - (id) => setSection(id, target) - ); + visibleSectionIds.forEach((id) => setSection(id, target)); } + const prevEntryTypeRef = useRef(entryType); + useEffect(() => { + const prev = prevEntryTypeRef.current; + if (prev !== entryType && entryType === "rest") { + setSection("recovery-metrics", true); + } + prevEntryTypeRef.current = entryType; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [entryType]); + function autoExpandErrorSections() { const errors = form.formState.errors; for (const [field, sectionId] of Object.entries(FIELD_TO_SECTION)) { @@ -433,18 +498,18 @@ export default function Home() { - R (RPE): {field.value}/10 + R (RPE): {field.value ?? 5}/10
field.onChange(value[0])} max={10} min={1} step={1} className="w-full" />
1 - Nothing at all - {field.value >= 2 && field.value <= 9 && {rpeDescriptions[field.value]}} + {field.value !== undefined && field.value >= 2 && field.value <= 9 && {rpeDescriptions[field.value]}} 10 - Max effort
@@ -608,7 +673,7 @@ export default function Home() { {allExpanded ? "Collapse all" : "Expand all"} -

Fill in your cycling workout information to generate a structured markdown report.

+

{entryTypeSubtitles[entryType]}

@@ -633,7 +698,34 @@ export default function Home() { /> - {/* Core Metrics Section */} + {/* Entry Type Selector */} +
+ +
+ {entryTypeOptions.map((option) => { + const Icon = option.icon; + const isActive = entryType === option.value; + return ( + + ); + })} +
+
+ + {/* Core Metrics Section — Cycling only */} + {entryType === "cycling" && ( - R (RPE - Rate of Perceived Exertion): {field.value}/10 + R (RPE - Rate of Perceived Exertion): {field.value ?? 5}/10
field.onChange(value[0])} max={10} min={1} @@ -698,7 +790,7 @@ export default function Home() { />
1 - Nothing at all - {field.value >= 2 && field.value <= 9 && ( + {field.value !== undefined && field.value >= 2 && field.value <= 9 && ( {rpeDescriptions[field.value]} @@ -828,8 +920,10 @@ export default function Home() { /> + )} - {/* Fueling Section */} + {/* Fueling Section — Cycling only */} + {entryType === "cycling" && ( + )} - {/* Performance Metrics Section */} + {/* Performance Metrics Section — Cycling only */} + {entryType === "cycling" && (
+ )} - {/* Recovery Metrics Section */} + {/* Recovery Metrics Section — Cycling + Rest */} + {(entryType === "cycling" || entryType === "rest") && (
+ )} - {/* Reflection Section */} + {/* Reflection Section — Cycling only */} + {entryType === "cycling" && ( + )} + + {/* Rest Day Section — Rest only */} + {entryType === "rest" && ( + setSection("rest-day", open)} + hasData={ + watchedValues.weight !== undefined || !!watchedValues.restNotes + } + > + ( + + + + W (Weight) + + + field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)} + value={field.value ?? ""} + /> + + + + )} + /> + + ( + + + + Notes + + +