From 8ae59fe4c5e461095d38a464374322c54eb23d6d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Jan 2026 13:13:14 +0000 Subject: [PATCH] feat: Add playlist generator with workout AI patterns Adds a playlist generator feature to the playlist view that allows users to generate workout-specific playlists based on their training goals: - Volume: High-volume workout at a consistent grade - Pyramid: Work up to a peak grade and back down - Ladder: Progressive grade increase through steps - Grade Focus: Single grade focused workout Features: - Visual grade progression chart preview - Configurable options (warm-up, target grade, climb counts) - Quality filters (min ascents, min rating, climb bias) - Automatic climb selection from search results - Progress indicator during generation --- .../[playlist_uuid]/playlist-view-actions.tsx | 95 +++-- .../[playlist_uuid]/playlist-view-content.tsx | 11 + .../playlist-generator/generation-utils.ts | 261 +++++++++++++ .../generator-options-form.module.css | 115 ++++++ .../generator-options-form.tsx | 283 ++++++++++++++ .../grade-progression-chart.module.css | 47 +++ .../grade-progression-chart.tsx | 147 +++++++ .../components/playlist-generator/index.ts | 7 + .../playlist-generator-drawer.module.css | 90 +++++ .../playlist-generator-drawer.tsx | 368 ++++++++++++++++++ .../components/playlist-generator/types.ts | 164 ++++++++ .../playlist-generator/workout-icons.tsx | 87 +++++ .../workout-type-selector.module.css | 63 +++ .../workout-type-selector.tsx | 44 +++ 14 files changed, 1754 insertions(+), 28 deletions(-) create mode 100644 packages/web/app/components/playlist-generator/generation-utils.ts create mode 100644 packages/web/app/components/playlist-generator/generator-options-form.module.css create mode 100644 packages/web/app/components/playlist-generator/generator-options-form.tsx create mode 100644 packages/web/app/components/playlist-generator/grade-progression-chart.module.css create mode 100644 packages/web/app/components/playlist-generator/grade-progression-chart.tsx create mode 100644 packages/web/app/components/playlist-generator/index.ts create mode 100644 packages/web/app/components/playlist-generator/playlist-generator-drawer.module.css create mode 100644 packages/web/app/components/playlist-generator/playlist-generator-drawer.tsx create mode 100644 packages/web/app/components/playlist-generator/types.ts create mode 100644 packages/web/app/components/playlist-generator/workout-icons.tsx create mode 100644 packages/web/app/components/playlist-generator/workout-type-selector.module.css create mode 100644 packages/web/app/components/playlist-generator/workout-type-selector.tsx diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx index b3b16dd6..a4c4f609 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-actions.tsx @@ -1,21 +1,32 @@ 'use client'; -import React from 'react'; +import React, { useState } from 'react'; import { Button } from 'antd'; -import { EditOutlined } from '@ant-design/icons'; +import { EditOutlined, ThunderboltOutlined } from '@ant-design/icons'; import { BoardDetails } from '@/app/lib/types'; import { constructClimbListWithSlugs } from '@/app/lib/url-utils'; import BackButton from '@/app/components/back-button'; +import { PlaylistGeneratorDrawer } from '@/app/components/playlist-generator'; import styles from './playlist-view-actions.module.css'; type PlaylistViewActionsProps = { boardDetails: BoardDetails; angle: number; isOwner: boolean; + playlistUuid: string; onEditClick: () => void; + onPlaylistUpdated?: () => void; }; -const PlaylistViewActions = ({ boardDetails, angle, isOwner, onEditClick }: PlaylistViewActionsProps) => { +const PlaylistViewActions = ({ + boardDetails, + angle, + isOwner, + playlistUuid, + onEditClick, + onPlaylistUpdated, +}: PlaylistViewActionsProps) => { + const [generatorOpen, setGeneratorOpen] = useState(false); const getBackToListUrl = () => { const { board_name, layout_name, size_name, size_description, set_names } = boardDetails; @@ -28,36 +39,64 @@ const PlaylistViewActions = ({ boardDetails, angle, isOwner, onEditClick }: Play return `/${board_name}/${boardDetails.layout_id}/${boardDetails.size_id}/${boardDetails.set_ids.join(',')}/${angle}/list`; }; - return ( -
- {/* Mobile view */} -
-
- -
+ const handleGeneratorSuccess = () => { + onPlaylistUpdated?.(); + }; - {isOwner && ( -
- + return ( + <> +
+ {/* Mobile view */} +
+
+
- )} -
- {/* Desktop view */} -
- + {isOwner && ( +
+ + +
+ )} +
- {isOwner && ( -
- -
- )} + {/* Desktop view */} +
+ + + {isOwner && ( +
+ + +
+ )} +
-
+ + {/* Playlist Generator Drawer */} + setGeneratorOpen(false)} + playlistUuid={playlistUuid} + boardDetails={boardDetails} + angle={angle} + onSuccess={handleGeneratorSuccess} + /> + ); }; diff --git a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx index 183b264e..73d57200 100644 --- a/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx +++ b/packages/web/app/[board_name]/[layout_id]/[size_id]/[set_ids]/[angle]/playlist/[playlist_uuid]/playlist-view-content.tsx @@ -49,6 +49,7 @@ export default function PlaylistViewContent({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [editDrawerOpen, setEditDrawerOpen] = useState(false); + const [listRefreshKey, setListRefreshKey] = useState(0); const { token, isLoading: tokenLoading } = useWsAuthToken(); const fetchPlaylist = useCallback(async () => { @@ -86,6 +87,13 @@ export default function PlaylistViewContent({ setPlaylist(updatedPlaylist); }, []); + const handlePlaylistUpdated = useCallback(() => { + // Refresh the climbs list + setListRefreshKey((prev) => prev + 1); + // Refetch playlist to update count + fetchPlaylist(); + }, [fetchPlaylist]); + // Check if current user is the owner const isOwner = playlist?.userRole === 'owner'; @@ -140,7 +148,9 @@ export default function PlaylistViewContent({ boardDetails={boardDetails} angle={angle} isOwner={isOwner} + playlistUuid={playlistUuid} onEditClick={() => setEditDrawerOpen(true)} + onPlaylistUpdated={handlePlaylistUpdated} />
@@ -223,6 +233,7 @@ export default function PlaylistViewContent({ {/* Climbs List */} { + return Math.max(MIN_GRADE, Math.min(MAX_GRADE, grade)); +}; + +// Generate warm-up slots +const generateWarmUp = (targetGrade: number, warmUpType: 'standard' | 'extended' | 'none'): PlannedClimbSlot[] => { + if (warmUpType === 'none') { + return []; + } + + const config = WARM_UP_CONFIG[warmUpType]; + const slots: PlannedClimbSlot[] = []; + + // Start from lower grades and work up + const startGrade = clampGrade(targetGrade - config.grades); + let index = 0; + + for (let grade = startGrade; grade < targetGrade; grade++) { + if (grade < MIN_GRADE) continue; + + for (let i = 0; i < config.climbsPerGrade; i++) { + slots.push({ + grade: clampGrade(grade), + section: 'warmUp', + index: index++, + }); + } + } + + return slots; +}; + +// Generate Volume workout plan +export const generateVolumePlan = (options: VolumeOptions): PlannedClimbSlot[] => { + const slots: PlannedClimbSlot[] = []; + + // Add warm-up + const warmUpSlots = generateWarmUp(options.targetGrade, options.warmUp); + slots.push(...warmUpSlots); + + // Add main set with variability + const mainStartIndex = slots.length; + const minGrade = clampGrade(options.targetGrade - options.mainSetVariability); + const maxGrade = clampGrade(options.targetGrade + options.mainSetVariability); + + for (let i = 0; i < options.mainSetClimbs; i++) { + // Distribute climbs across the grade range + let grade: number; + if (options.mainSetVariability === 0) { + grade = options.targetGrade; + } else { + // Weighted distribution favoring target grade + const offset = Math.round((Math.random() * 2 - 1) * options.mainSetVariability); + grade = clampGrade(options.targetGrade + offset); + } + + slots.push({ + grade, + section: 'main', + index: mainStartIndex + i, + }); + } + + return slots; +}; + +// Generate Pyramid workout plan +export const generatePyramidPlan = (options: PyramidOptions): PlannedClimbSlot[] => { + const slots: PlannedClimbSlot[] = []; + + // Add warm-up + const warmUpSlots = generateWarmUp(options.targetGrade, options.warmUp); + slots.push(...warmUpSlots); + + // Calculate step size + // Start from a lower grade, peak at target, then come back down + const warmUpEndGrade = warmUpSlots.length > 0 + ? warmUpSlots[warmUpSlots.length - 1].grade + : clampGrade(options.targetGrade - options.numberOfSteps); + + const stepsUp = Math.floor(options.numberOfSteps / 2) + 1; + const stepsDown = options.numberOfSteps - stepsUp + 1; + + const gradeIncrement = Math.max(1, Math.floor((options.targetGrade - warmUpEndGrade) / Math.max(1, stepsUp - 1))); + + let currentIndex = slots.length; + + // Increasing phase + for (let step = 0; step < stepsUp; step++) { + const grade = step === stepsUp - 1 + ? options.targetGrade + : clampGrade(warmUpEndGrade + (gradeIncrement * step)); + + for (let i = 0; i < options.climbsPerStep; i++) { + slots.push({ + grade, + section: step === stepsUp - 1 ? 'peak' : 'increasing', + index: currentIndex++, + }); + } + } + + // Decreasing phase + for (let step = 1; step < stepsDown; step++) { + const grade = clampGrade(options.targetGrade - (gradeIncrement * step)); + + for (let i = 0; i < options.climbsPerStep; i++) { + slots.push({ + grade, + section: 'decreasing', + index: currentIndex++, + }); + } + } + + return slots; +}; + +// Generate Ladder workout plan +export const generateLadderPlan = (options: LadderOptions): PlannedClimbSlot[] => { + const slots: PlannedClimbSlot[] = []; + + // Add warm-up + const warmUpSlots = generateWarmUp(options.targetGrade, options.warmUp); + slots.push(...warmUpSlots); + + // Calculate starting grade and step size + const warmUpEndGrade = warmUpSlots.length > 0 + ? warmUpSlots[warmUpSlots.length - 1].grade + : clampGrade(options.targetGrade - options.numberOfSteps); + + const gradeIncrement = Math.max(1, Math.floor((options.targetGrade - warmUpEndGrade) / Math.max(1, options.numberOfSteps - 1))); + + let currentIndex = slots.length; + + // Increasing phase only (ladder goes up) + for (let step = 0; step < options.numberOfSteps; step++) { + const grade = step === options.numberOfSteps - 1 + ? options.targetGrade + : clampGrade(warmUpEndGrade + (gradeIncrement * step)); + + for (let i = 0; i < options.climbsPerStep; i++) { + slots.push({ + grade, + section: step === options.numberOfSteps - 1 ? 'peak' : 'increasing', + index: currentIndex++, + }); + } + } + + return slots; +}; + +// Generate Grade Focus workout plan +export const generateGradeFocusPlan = (options: GradeFocusOptions): PlannedClimbSlot[] => { + const slots: PlannedClimbSlot[] = []; + + // Add warm-up + const warmUpSlots = generateWarmUp(options.targetGrade, options.warmUp); + slots.push(...warmUpSlots); + + // All climbs at target grade + const mainStartIndex = slots.length; + + for (let i = 0; i < options.numberOfClimbs; i++) { + slots.push({ + grade: options.targetGrade, + section: 'main', + index: mainStartIndex + i, + }); + } + + return slots; +}; + +// Main function to generate plan based on options +export const generateWorkoutPlan = (options: GeneratorOptions): PlannedClimbSlot[] => { + switch (options.type) { + case 'volume': + return generateVolumePlan(options); + case 'pyramid': + return generatePyramidPlan(options); + case 'ladder': + return generateLadderPlan(options); + case 'gradeFocus': + return generateGradeFocusPlan(options); + default: + return []; + } +}; + +// Get grade name from difficulty_id +export const getGradeName = (difficultyId: number): string => { + const grade = TENSION_KILTER_GRADES.find((g) => g.difficulty_id === difficultyId); + return grade?.difficulty_name || `Grade ${difficultyId}`; +}; + +// Group slots by section for display +export interface GroupedSlots { + section: PlannedClimbSlot['section']; + label: string; + slots: PlannedClimbSlot[]; +} + +export const groupSlotsBySection = (slots: PlannedClimbSlot[]): GroupedSlots[] => { + const groups: GroupedSlots[] = []; + let currentSection: PlannedClimbSlot['section'] | null = null; + let currentGroup: PlannedClimbSlot[] = []; + + const sectionLabels: Record = { + warmUp: 'Warm Up', + increasing: 'Increasing', + peak: 'Peak', + decreasing: 'Decreasing', + main: 'Main Set', + }; + + for (const slot of slots) { + if (slot.section !== currentSection) { + if (currentSection && currentGroup.length > 0) { + groups.push({ + section: currentSection, + label: sectionLabels[currentSection], + slots: [...currentGroup], + }); + } + currentSection = slot.section; + currentGroup = [slot]; + } else { + currentGroup.push(slot); + } + } + + // Add final group + if (currentSection && currentGroup.length > 0) { + groups.push({ + section: currentSection, + label: sectionLabels[currentSection], + slots: currentGroup, + }); + } + + return groups; +}; diff --git a/packages/web/app/components/playlist-generator/generator-options-form.module.css b/packages/web/app/components/playlist-generator/generator-options-form.module.css new file mode 100644 index 00000000..1c05979d --- /dev/null +++ b/packages/web/app/components/playlist-generator/generator-options-form.module.css @@ -0,0 +1,115 @@ +.container { + display: flex; + flex-direction: column; + gap: 24px; +} + +.form { + display: flex; + flex-direction: column; +} + +.formRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid #E5E7EB; +} + +.formRow:last-child { + border-bottom: none; +} + +.label { + font-size: 15px; + color: #111827; + font-weight: 500; +} + +.select { + min-width: 140px; + text-align: right; +} + +.select :global(.ant-select-selector) { + border: none !important; + background: transparent !important; + padding-right: 0 !important; + box-shadow: none !important; +} + +.select :global(.ant-select-selection-item) { + color: #6B7280; + text-align: right; + padding-right: 24px !important; +} + +.select :global(.ant-select-arrow) { + color: #9CA3AF; +} + +.stepperContainer { + display: flex; + align-items: center; + gap: 12px; +} + +.stepperValue { + font-size: 15px; + color: #6B7280; + min-width: 24px; + text-align: right; +} + +.stepperButtons { + display: flex; + background: #F3F4F6; + border-radius: 8px; + overflow: hidden; +} + +.stepperButton { + border: none !important; + background: transparent !important; + border-radius: 0 !important; + height: 32px; + width: 40px; + display: flex; + align-items: center; + justify-content: center; +} + +.stepperButton:first-child { + border-right: 1px solid #E5E7EB !important; +} + +.stepperButton:hover:not(:disabled) { + background: #E5E7EB !important; +} + +.stepperButton:disabled { + opacity: 0.4; +} + +.inputNumber { + width: 80px; +} + +.inputNumber :global(.ant-input-number-input) { + text-align: right; +} + +.resetContainer { + display: flex; + justify-content: center; + padding-top: 8px; +} + +.resetButton { + color: #06B6D4; +} + +.resetButton:hover { + color: #0891B2; +} diff --git a/packages/web/app/components/playlist-generator/generator-options-form.tsx b/packages/web/app/components/playlist-generator/generator-options-form.tsx new file mode 100644 index 00000000..c0b740fd --- /dev/null +++ b/packages/web/app/components/playlist-generator/generator-options-form.tsx @@ -0,0 +1,283 @@ +'use client'; + +import React from 'react'; +import { Typography, Select, Button, Row, Col, InputNumber } from 'antd'; +import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; +import { TENSION_KILTER_GRADES } from '@/app/lib/board-data'; +import { + WorkoutType, + GeneratorOptions, + WARM_UP_OPTIONS, + CLIMB_BIAS_OPTIONS, + DEFAULT_VOLUME_OPTIONS, + DEFAULT_PYRAMID_OPTIONS, + DEFAULT_LADDER_OPTIONS, + DEFAULT_GRADE_FOCUS_OPTIONS, + VolumeOptions, + PyramidOptions, + LadderOptions, + GradeFocusOptions, +} from './types'; +import styles from './generator-options-form.module.css'; + +const { Text } = Typography; + +interface GeneratorOptionsFormProps { + workoutType: WorkoutType; + options: GeneratorOptions; + onChange: (options: GeneratorOptions) => void; + onReset: () => void; +} + +const GeneratorOptionsForm: React.FC = ({ + workoutType, + options, + onChange, + onReset, +}) => { + const grades = TENSION_KILTER_GRADES; + + // Helper to update options + const updateOption = (key: K, value: GeneratorOptions[K]) => { + onChange({ ...options, [key]: value }); + }; + + // Render number stepper + const renderStepper = ( + label: string, + value: number, + onUpdate: (newValue: number) => void, + min: number = 1, + max: number = 50 + ) => ( +
+ {label} +
+ {value} +
+
+
+
+ ); + + // Render select row + const renderSelect = ( + label: string, + value: T, + optionsList: { value: T; label: string }[], + onUpdate: (newValue: T) => void + ) => ( +
+ {label} + +
+ ); + + // Common options for all workout types + const renderCommonOptions = () => ( + <> + {/* Warm Up */} + {renderSelect('Warm Up', options.warmUp, WARM_UP_OPTIONS, (v) => updateOption('warmUp', v))} + + {/* Target Grade */} +
+ Target Grade + +
+ + ); + + // Quality filters section + const renderQualityFilters = () => ( + <> +
+ Min Ascents + updateOption('minAscents', v || 0)} + className={styles.inputNumber} + /> +
+ +
+ Min Rating + updateOption('minRating', v || 0)} + className={styles.inputNumber} + /> +
+ + {/* Climb Bias */} + {renderSelect('Climb Bias', options.climbBias, CLIMB_BIAS_OPTIONS, (v) => updateOption('climbBias', v))} + + ); + + // Volume-specific options + const renderVolumeOptions = () => { + const volumeOptions = options as VolumeOptions; + return ( + <> + {renderCommonOptions()} + + {renderStepper('Main Set Climbs', volumeOptions.mainSetClimbs, (v) => + onChange({ ...volumeOptions, mainSetClimbs: v }) + )} + + {renderStepper('Main Set Variability', volumeOptions.mainSetVariability, (v) => + onChange({ ...volumeOptions, mainSetVariability: v }), 0, 5 + )} + + {renderQualityFilters()} + + ); + }; + + // Pyramid-specific options + const renderPyramidOptions = () => { + const pyramidOptions = options as PyramidOptions; + return ( + <> + {renderCommonOptions()} + + {renderStepper('Number of Steps', pyramidOptions.numberOfSteps, (v) => + onChange({ ...pyramidOptions, numberOfSteps: v }), 3, 15 + )} + + {renderStepper('Climbs per Step', pyramidOptions.climbsPerStep, (v) => + onChange({ ...pyramidOptions, climbsPerStep: v }), 1, 5 + )} + + {renderQualityFilters()} + + ); + }; + + // Ladder-specific options + const renderLadderOptions = () => { + const ladderOptions = options as LadderOptions; + return ( + <> + {renderCommonOptions()} + + {renderStepper('Number of Steps', ladderOptions.numberOfSteps, (v) => + onChange({ ...ladderOptions, numberOfSteps: v }), 3, 15 + )} + + {renderStepper('Climbs per Step', ladderOptions.climbsPerStep, (v) => + onChange({ ...ladderOptions, climbsPerStep: v }), 1, 5 + )} + + {renderQualityFilters()} + + ); + }; + + // Grade Focus-specific options + const renderGradeFocusOptions = () => { + const focusOptions = options as GradeFocusOptions; + return ( + <> + {renderCommonOptions()} + + {renderStepper('Number of Climbs', focusOptions.numberOfClimbs, (v) => + onChange({ ...focusOptions, numberOfClimbs: v }), 1, 50 + )} + + {renderQualityFilters()} + + ); + }; + + // Render the appropriate options form + const renderOptionsForm = () => { + switch (workoutType) { + case 'volume': + return renderVolumeOptions(); + case 'pyramid': + return renderPyramidOptions(); + case 'ladder': + return renderLadderOptions(); + case 'gradeFocus': + return renderGradeFocusOptions(); + default: + return null; + } + }; + + return ( +
+
+ {renderOptionsForm()} +
+ +
+ +
+
+ ); +}; + +export default GeneratorOptionsForm; + +// Helper to get default options for a workout type +export const getDefaultOptions = (workoutType: WorkoutType, targetGrade: number): GeneratorOptions => { + switch (workoutType) { + case 'volume': + return { ...DEFAULT_VOLUME_OPTIONS, targetGrade }; + case 'pyramid': + return { ...DEFAULT_PYRAMID_OPTIONS, targetGrade }; + case 'ladder': + return { ...DEFAULT_LADDER_OPTIONS, targetGrade }; + case 'gradeFocus': + return { ...DEFAULT_GRADE_FOCUS_OPTIONS, targetGrade }; + } +}; diff --git a/packages/web/app/components/playlist-generator/grade-progression-chart.module.css b/packages/web/app/components/playlist-generator/grade-progression-chart.module.css new file mode 100644 index 00000000..23539b66 --- /dev/null +++ b/packages/web/app/components/playlist-generator/grade-progression-chart.module.css @@ -0,0 +1,47 @@ +.chartContainer { + display: flex; + width: 100%; + padding: 8px 0; +} + +.yAxis { + width: 52px; + position: relative; + flex-shrink: 0; + margin-right: 8px; +} + +.yLabel { + position: absolute; + right: 0; + transform: translateY(-50%); + font-size: 11px; + color: #6B7280; + white-space: nowrap; + line-height: 1; +} + +.chartArea { + flex: 1; + min-width: 0; + position: relative; +} + +.svg { + display: block; + overflow: visible; +} + +.emptyChart { + display: flex; + align-items: center; + justify-content: center; + background-color: #F9FAFB; + border-radius: 8px; + border: 1px dashed #D1D5DB; +} + +.emptyText { + color: #9CA3AF; + font-size: 14px; +} diff --git a/packages/web/app/components/playlist-generator/grade-progression-chart.tsx b/packages/web/app/components/playlist-generator/grade-progression-chart.tsx new file mode 100644 index 00000000..6cf1d762 --- /dev/null +++ b/packages/web/app/components/playlist-generator/grade-progression-chart.tsx @@ -0,0 +1,147 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { TENSION_KILTER_GRADES } from '@/app/lib/board-data'; +import { PlannedClimbSlot } from './types'; +import { themeTokens } from '@/app/theme/theme-config'; +import styles from './grade-progression-chart.module.css'; + +interface GradeProgressionChartProps { + plannedSlots: PlannedClimbSlot[]; + height?: number; +} + +const GradeProgressionChart: React.FC = ({ + plannedSlots, + height = 120, +}) => { + const chartData = useMemo(() => { + if (plannedSlots.length === 0) { + return { points: [], minGrade: 10, maxGrade: 20, gradeLabels: [] }; + } + + const grades = plannedSlots.map((slot) => slot.grade); + const minGrade = Math.min(...grades); + const maxGrade = Math.max(...grades); + + // Add some padding to the range + const paddedMin = Math.max(10, minGrade - 1); + const paddedMax = Math.min(33, maxGrade + 1); + const gradeRange = paddedMax - paddedMin || 1; + + // Get grade labels for Y axis + const gradeLabels = TENSION_KILTER_GRADES.filter( + (g) => g.difficulty_id >= paddedMin && g.difficulty_id <= paddedMax + ); + + // Generate points for the line chart + const padding = { left: 60, right: 20, top: 10, bottom: 10 }; + const chartWidth = 100; // percentage based + + const points = plannedSlots.map((slot, index) => { + const x = padding.left + ((chartWidth - padding.left - padding.right) * index) / Math.max(1, plannedSlots.length - 1); + const y = padding.top + ((height - padding.top - padding.bottom) * (paddedMax - slot.grade)) / gradeRange; + return { x, y, grade: slot.grade, section: slot.section }; + }); + + return { points, minGrade: paddedMin, maxGrade: paddedMax, gradeLabels }; + }, [plannedSlots, height]); + + if (plannedSlots.length === 0) { + return ( +
+ Configure options to preview +
+ ); + } + + const { points, minGrade, maxGrade, gradeLabels } = chartData; + const gradeRange = maxGrade - minGrade || 1; + + // Create SVG path for the line + const linePath = points + .map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`) + .join(' '); + + return ( +
+ {/* Y-axis labels */} +
+ {gradeLabels.filter((_, i, arr) => { + // Show fewer labels on small screens + const step = arr.length > 6 ? 2 : 1; + return i % step === 0 || i === arr.length - 1; + }).reverse().map((grade) => { + const yPercent = ((maxGrade - grade.difficulty_id) / gradeRange) * 100; + return ( +
+ {grade.difficulty_name} +
+ ); + })} +
+ + {/* Chart area */} +
+ + {/* Grid lines */} + {gradeLabels.filter((_, i, arr) => { + const step = arr.length > 6 ? 2 : 1; + return i % step === 0 || i === arr.length - 1; + }).map((grade) => { + const y = 10 + ((height - 20) * (maxGrade - grade.difficulty_id)) / gradeRange; + return ( + + ); + })} + + {/* Line */} + + + {/* Points */} + {points.map((point, index) => ( + + ))} + +
+
+ ); +}; + +export default GradeProgressionChart; diff --git a/packages/web/app/components/playlist-generator/index.ts b/packages/web/app/components/playlist-generator/index.ts new file mode 100644 index 00000000..9836aa1d --- /dev/null +++ b/packages/web/app/components/playlist-generator/index.ts @@ -0,0 +1,7 @@ +export { default as PlaylistGeneratorDrawer } from './playlist-generator-drawer'; +export { default as WorkoutTypeSelector } from './workout-type-selector'; +export { default as GeneratorOptionsForm, getDefaultOptions } from './generator-options-form'; +export { default as GradeProgressionChart } from './grade-progression-chart'; +export * from './types'; +export * from './generation-utils'; +export * from './workout-icons'; diff --git a/packages/web/app/components/playlist-generator/playlist-generator-drawer.module.css b/packages/web/app/components/playlist-generator/playlist-generator-drawer.module.css new file mode 100644 index 00000000..0b50fd8a --- /dev/null +++ b/packages/web/app/components/playlist-generator/playlist-generator-drawer.module.css @@ -0,0 +1,90 @@ +.drawerHeader { + display: flex; + align-items: center; + gap: 8px; +} + +.backButton { + margin-left: -8px; + color: #06B6D4; +} + +.drawerTitle { + flex: 1; + font-weight: 600; +} + +.headerSpacer { + width: 32px; +} + +.configureContainer { + display: flex; + flex-direction: column; + gap: 16px; +} + +.chartSection { + background: #FFFFFF; + border-radius: 12px; + padding: 16px; + border: 1px solid #E5E7EB; +} + +.summarySection { + background: #F9FAFB; + border-radius: 12px; + padding: 12px 16px; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; +} + +.totalRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0 0; + margin-top: 8px; + border-top: 1px solid #E5E7EB; +} + +.optionsSection { + background: #FFFFFF; + border-radius: 12px; + padding: 4px 16px; + border: 1px solid #E5E7EB; +} + +.generatingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + padding: 60px 20px; +} + +.generatingText { + font-size: 16px; + color: #6B7280; +} + +/* Mobile optimizations */ +@media (max-width: 767px) { + .chartSection { + padding: 12px; + } + + .summarySection { + padding: 10px 12px; + } + + .optionsSection { + padding: 0 12px; + } +} diff --git a/packages/web/app/components/playlist-generator/playlist-generator-drawer.tsx b/packages/web/app/components/playlist-generator/playlist-generator-drawer.tsx new file mode 100644 index 00000000..79d97545 --- /dev/null +++ b/packages/web/app/components/playlist-generator/playlist-generator-drawer.tsx @@ -0,0 +1,368 @@ +'use client'; + +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { Drawer, Button, Space, Typography, message, Spin, Alert } from 'antd'; +import { ArrowLeftOutlined, ThunderboltOutlined } from '@ant-design/icons'; +import { BoardDetails, Climb } from '@/app/lib/types'; +import { TENSION_KILTER_GRADES } from '@/app/lib/board-data'; +import { executeGraphQL } from '@/app/lib/graphql/client'; +import { SEARCH_CLIMBS, ClimbSearchInputVariables, ClimbSearchResponse } from '@/app/lib/graphql/operations/climb-search'; +import { + ADD_CLIMB_TO_PLAYLIST, + AddClimbToPlaylistMutationVariables, + AddClimbToPlaylistMutationResponse, +} from '@/app/lib/graphql/operations/playlists'; +import { useWsAuthToken } from '@/app/hooks/use-ws-auth-token'; +import { useBoardProvider } from '@/app/components/board-provider/board-provider-context'; +import { WorkoutType, GeneratorOptions, PlannedClimbSlot, WORKOUT_TYPES } from './types'; +import WorkoutTypeSelector from './workout-type-selector'; +import GeneratorOptionsForm, { getDefaultOptions } from './generator-options-form'; +import GradeProgressionChart from './grade-progression-chart'; +import { generateWorkoutPlan, groupSlotsBySection, getGradeName } from './generation-utils'; +import { themeTokens } from '@/app/theme/theme-config'; +import styles from './playlist-generator-drawer.module.css'; + +const { Title, Text } = Typography; + +interface PlaylistGeneratorDrawerProps { + open: boolean; + onClose: () => void; + playlistUuid: string; + boardDetails: BoardDetails; + angle: number; + onSuccess?: () => void; +} + +type DrawerState = 'select' | 'configure' | 'generating'; + +const PlaylistGeneratorDrawer: React.FC = ({ + open, + onClose, + playlistUuid, + boardDetails, + angle, + onSuccess, +}) => { + const { token } = useWsAuthToken(); + const { user_id } = useBoardProvider(); + + // Default target grade (middle of range) + const defaultTargetGrade = 18; // 6b/V4 + + const [drawerState, setDrawerState] = useState('select'); + const [selectedType, setSelectedType] = useState(null); + const [options, setOptions] = useState(null); + const [generating, setGenerating] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + + // Reset state when drawer opens/closes + useEffect(() => { + if (open) { + setDrawerState('select'); + setSelectedType(null); + setOptions(null); + setGenerating(false); + setProgress({ current: 0, total: 0 }); + } + }, [open]); + + // Generate the workout plan preview + const plannedSlots = useMemo(() => { + if (!options) return []; + return generateWorkoutPlan(options); + }, [options]); + + // Handle workout type selection + const handleTypeSelect = useCallback((type: WorkoutType) => { + setSelectedType(type); + setOptions(getDefaultOptions(type, defaultTargetGrade)); + setDrawerState('configure'); + }, [defaultTargetGrade]); + + // Handle back button + const handleBack = useCallback(() => { + if (drawerState === 'configure') { + setDrawerState('select'); + setSelectedType(null); + setOptions(null); + } + }, [drawerState]); + + // Handle options reset + const handleReset = useCallback(() => { + if (selectedType) { + setOptions(getDefaultOptions(selectedType, defaultTargetGrade)); + } + }, [selectedType, defaultTargetGrade]); + + // Search for climbs at a specific grade + const searchClimbsForGrade = async ( + grade: number, + excludeUuids: Set + ): Promise => { + const input: ClimbSearchInputVariables['input'] = { + boardName: boardDetails.board_name, + layoutId: boardDetails.layout_id, + sizeId: boardDetails.size_id, + setIds: boardDetails.set_ids.join(','), + angle, + minGrade: grade, + maxGrade: grade, + minAscents: options?.minAscents || 5, + sortBy: 'quality', + sortOrder: 'desc', + page: 1, + pageSize: 50, // Get a pool of climbs to choose from + }; + + // Apply climb bias filters if user is authenticated + if (options && user_id) { + switch (options.climbBias) { + case 'unfamiliar': + input.hideAttempted = true; + input.hideCompleted = true; + break; + case 'attempted': + input.showOnlyAttempted = true; + break; + // 'any' - no additional filters + } + } + + const response = await executeGraphQL( + SEARCH_CLIMBS, + { input }, + token + ); + + // Filter out already selected climbs + return response.searchClimbs.climbs.filter((c) => !excludeUuids.has(c.uuid)); + }; + + // Generate the playlist + const handleGenerate = useCallback(async () => { + if (!options || plannedSlots.length === 0) { + message.error('No climbs to generate'); + return; + } + + setGenerating(true); + setDrawerState('generating'); + setProgress({ current: 0, total: plannedSlots.length }); + + const addedUuids = new Set(); + const failedSlots: PlannedClimbSlot[] = []; + + // Group slots by grade to batch search + const gradeGroups = new Map(); + for (const slot of plannedSlots) { + const existing = gradeGroups.get(slot.grade) || []; + existing.push(slot); + gradeGroups.set(slot.grade, existing); + } + + // Cache searched climbs by grade + const climbCache = new Map(); + + let processed = 0; + + // Process slots in order + for (const slot of plannedSlots) { + try { + // Get or search for climbs at this grade + let availableClimbs = climbCache.get(slot.grade); + if (!availableClimbs) { + availableClimbs = await searchClimbsForGrade(slot.grade, addedUuids); + climbCache.set(slot.grade, availableClimbs); + } else { + // Filter out already added + availableClimbs = availableClimbs.filter((c) => !addedUuids.has(c.uuid)); + climbCache.set(slot.grade, availableClimbs); + } + + if (availableClimbs.length === 0) { + failedSlots.push(slot); + processed++; + setProgress({ current: processed, total: plannedSlots.length }); + continue; + } + + // Pick a random climb from top candidates (weighted towards better quality) + const poolSize = Math.min(5, availableClimbs.length); + const selectedIndex = Math.floor(Math.random() * poolSize); + const selectedClimb = availableClimbs[selectedIndex]; + + // Add to playlist + await executeGraphQL( + ADD_CLIMB_TO_PLAYLIST, + { + input: { + playlistId: playlistUuid, + climbUuid: selectedClimb.uuid, + angle, + }, + }, + token + ); + + addedUuids.add(selectedClimb.uuid); + + // Remove from cache + const updatedCache = (climbCache.get(slot.grade) || []).filter( + (c) => c.uuid !== selectedClimb.uuid + ); + climbCache.set(slot.grade, updatedCache); + + } catch (error) { + console.error('Error adding climb:', error); + failedSlots.push(slot); + } + + processed++; + setProgress({ current: processed, total: plannedSlots.length }); + } + + setGenerating(false); + + if (failedSlots.length === 0) { + message.success(`Added ${plannedSlots.length} climbs to playlist`); + } else if (failedSlots.length < plannedSlots.length) { + message.warning( + `Added ${plannedSlots.length - failedSlots.length} climbs. ${failedSlots.length} slots couldn't be filled.` + ); + } else { + message.error('Failed to generate playlist. No suitable climbs found.'); + } + + onSuccess?.(); + onClose(); + }, [options, plannedSlots, playlistUuid, angle, token, user_id, boardDetails, onSuccess, onClose]); + + // Get workout type info + const workoutTypeInfo = selectedType + ? WORKOUT_TYPES.find((t) => t.type === selectedType) + : null; + + // Render title based on state + const renderTitle = () => { + if (drawerState === 'select') { + return 'Generate Playlist'; + } + if (drawerState === 'generating') { + return 'Generating...'; + } + return workoutTypeInfo?.name || 'Options'; + }; + + // Render content based on state + const renderContent = () => { + if (drawerState === 'select') { + return ; + } + + if (drawerState === 'generating') { + return ( +
+ + + Adding climbs... {progress.current} / {progress.total} + +
+ ); + } + + if (drawerState === 'configure' && selectedType && options) { + const groupedSlots = groupSlotsBySection(plannedSlots); + + return ( +
+ {/* Chart Preview */} +
+ +
+ + {/* Summary */} +
+ {groupedSlots.map((group) => ( +
+ {group.label} + + {group.slots.length} climb{group.slots.length !== 1 ? 's' : ''} + {' '}({getGradeName(group.slots[0].grade)} + {group.slots[0].grade !== group.slots[group.slots.length - 1].grade && + ` - ${getGradeName(group.slots[group.slots.length - 1].grade)}`}) + +
+ ))} +
+ Total + {plannedSlots.length} climbs +
+
+ + {/* Options Form */} +
+ +
+
+ ); + } + + return null; + }; + + return ( + + {drawerState === 'configure' && ( + + ) : null + } + > + {renderContent()} + + ); +}; + +export default PlaylistGeneratorDrawer; diff --git a/packages/web/app/components/playlist-generator/types.ts b/packages/web/app/components/playlist-generator/types.ts new file mode 100644 index 00000000..2c3c7de1 --- /dev/null +++ b/packages/web/app/components/playlist-generator/types.ts @@ -0,0 +1,164 @@ +// Playlist Generator Types and Constants + +export type WorkoutType = 'volume' | 'pyramid' | 'ladder' | 'gradeFocus'; + +export type WarmUpType = 'standard' | 'extended' | 'none'; + +export type EffortLevel = 'moderate' | 'challenging' | 'veryDifficult' | 'maxEffort'; + +export type ClimbBias = 'unfamiliar' | 'attempted' | 'any'; + +// Base options shared by all workout types +export interface BaseGeneratorOptions { + warmUp: WarmUpType; + targetGrade: number; // difficulty_id + climbBias: ClimbBias; + minAscents: number; + minRating: number; +} + +// Volume workout - high volume at consistent grade +export interface VolumeOptions extends BaseGeneratorOptions { + type: 'volume'; + mainSetClimbs: number; + mainSetVariability: number; // grades above/below target +} + +// Pyramid workout - ramp up to peak then back down +export interface PyramidOptions extends BaseGeneratorOptions { + type: 'pyramid'; + numberOfSteps: number; + climbsPerStep: number; +} + +// Ladder workout - ramp up through grades +export interface LadderOptions extends BaseGeneratorOptions { + type: 'ladder'; + numberOfSteps: number; + climbsPerStep: number; +} + +// Grade Focus - single grade workout +export interface GradeFocusOptions extends BaseGeneratorOptions { + type: 'gradeFocus'; + numberOfClimbs: number; +} + +export type GeneratorOptions = VolumeOptions | PyramidOptions | LadderOptions | GradeFocusOptions; + +// Workout type metadata for UI +export interface WorkoutTypeInfo { + type: WorkoutType; + name: string; + description: string; + icon: 'volume' | 'pyramid' | 'ladder' | 'focus'; +} + +export const WORKOUT_TYPES: WorkoutTypeInfo[] = [ + { + type: 'volume', + name: 'Volume', + description: 'Generate a high-volume workout.', + icon: 'volume', + }, + { + type: 'pyramid', + name: 'Pyramid', + description: 'Work up to a max grade and back down again.', + icon: 'pyramid', + }, + { + type: 'ladder', + name: 'Ladder', + description: 'Work up through the grades in steps.', + icon: 'ladder', + }, + { + type: 'gradeFocus', + name: 'Grade Focus', + description: 'Pick a grade and go!', + icon: 'focus', + }, +]; + +export const WARM_UP_OPTIONS: { value: WarmUpType; label: string }[] = [ + { value: 'standard', label: 'Standard' }, + { value: 'extended', label: 'Extended' }, + { value: 'none', label: 'None' }, +]; + +export const EFFORT_LEVELS: { value: EffortLevel; label: string }[] = [ + { value: 'moderate', label: 'Moderate' }, + { value: 'challenging', label: 'Challenging' }, + { value: 'veryDifficult', label: 'Very Difficult' }, + { value: 'maxEffort', label: 'Max Effort' }, +]; + +export const CLIMB_BIAS_OPTIONS: { value: ClimbBias; label: string }[] = [ + { value: 'unfamiliar', label: 'Unfamiliar' }, + { value: 'attempted', label: 'Attempted' }, + { value: 'any', label: 'Any' }, +]; + +// Default options for each workout type +export const DEFAULT_VOLUME_OPTIONS: Omit = { + type: 'volume', + warmUp: 'standard', + mainSetClimbs: 20, + mainSetVariability: 0, + climbBias: 'unfamiliar', + minAscents: 5, + minRating: 1.5, +}; + +export const DEFAULT_PYRAMID_OPTIONS: Omit = { + type: 'pyramid', + warmUp: 'standard', + numberOfSteps: 5, + climbsPerStep: 1, + climbBias: 'unfamiliar', + minAscents: 5, + minRating: 1.5, +}; + +export const DEFAULT_LADDER_OPTIONS: Omit = { + type: 'ladder', + warmUp: 'standard', + numberOfSteps: 5, + climbsPerStep: 2, + climbBias: 'unfamiliar', + minAscents: 5, + minRating: 1.5, +}; + +export const DEFAULT_GRADE_FOCUS_OPTIONS: Omit = { + type: 'gradeFocus', + warmUp: 'standard', + numberOfClimbs: 15, + climbBias: 'unfamiliar', + minAscents: 5, + minRating: 1.5, +}; + +// Warm-up configuration +export const WARM_UP_CONFIG = { + standard: { + grades: 4, // Number of grades below target to include + climbsPerGrade: 1, + }, + extended: { + grades: 6, + climbsPerGrade: 2, + }, + none: { + grades: 0, + climbsPerGrade: 0, + }, +}; + +// Represents a planned climb slot in the generated playlist +export interface PlannedClimbSlot { + grade: number; // difficulty_id + section: 'warmUp' | 'increasing' | 'peak' | 'decreasing' | 'main'; + index: number; +} diff --git a/packages/web/app/components/playlist-generator/workout-icons.tsx b/packages/web/app/components/playlist-generator/workout-icons.tsx new file mode 100644 index 00000000..21591ad0 --- /dev/null +++ b/packages/web/app/components/playlist-generator/workout-icons.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; + +// SVG icons for workout types matching the iOS app style + +interface IconProps { + size?: number; + color?: string; +} + +export const VolumeIcon: React.FC = ({ size = 24, color = 'currentColor' }) => ( + + + + + +); + +export const PyramidIcon: React.FC = ({ size = 24, color = 'currentColor' }) => ( + + + + + + + + +); + +export const LadderIcon: React.FC = ({ size = 24, color = 'currentColor' }) => ( + + + + + + + + +); + +export const GradeFocusIcon: React.FC = ({ size = 24, color = 'currentColor' }) => ( + + + + + + +); + +export const getWorkoutIcon = (type: 'volume' | 'pyramid' | 'ladder' | 'focus', props?: IconProps) => { + switch (type) { + case 'volume': + return ; + case 'pyramid': + return ; + case 'ladder': + return ; + case 'focus': + return ; + default: + return ; + } +}; diff --git a/packages/web/app/components/playlist-generator/workout-type-selector.module.css b/packages/web/app/components/playlist-generator/workout-type-selector.module.css new file mode 100644 index 00000000..4d5397e9 --- /dev/null +++ b/packages/web/app/components/playlist-generator/workout-type-selector.module.css @@ -0,0 +1,63 @@ +.container { + padding: 0; +} + +.listItem { + cursor: pointer; + padding: 16px; + border-bottom: 1px solid #E5E7EB; + transition: background-color 150ms ease; + display: flex; + justify-content: space-between; + align-items: center; +} + +.listItem:hover { + background-color: #F9FAFB; +} + +.listItem:active { + background-color: #F3F4F6; +} + +.listItem:last-child { + border-bottom: none; +} + +.itemContent { + display: flex; + align-items: center; + gap: 16px; + flex: 1; +} + +.iconWrapper { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.textContent { + display: flex; + flex-direction: column; + gap: 2px; +} + +.title { + font-size: 16px; + line-height: 1.4; +} + +.description { + font-size: 14px; + line-height: 1.4; +} + +.arrow { + color: #9CA3AF; + font-size: 12px; + flex-shrink: 0; +} diff --git a/packages/web/app/components/playlist-generator/workout-type-selector.tsx b/packages/web/app/components/playlist-generator/workout-type-selector.tsx new file mode 100644 index 00000000..88ac9bee --- /dev/null +++ b/packages/web/app/components/playlist-generator/workout-type-selector.tsx @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; +import { List, Typography } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; +import { WorkoutType, WORKOUT_TYPES } from './types'; +import { getWorkoutIcon } from './workout-icons'; +import { themeTokens } from '@/app/theme/theme-config'; +import styles from './workout-type-selector.module.css'; + +const { Text } = Typography; + +interface WorkoutTypeSelectorProps { + onSelect: (type: WorkoutType) => void; +} + +const WorkoutTypeSelector: React.FC = ({ onSelect }) => { + return ( +
+ ( + onSelect(item.type)} + > +
+
+ {getWorkoutIcon(item.icon, { size: 28, color: themeTokens.colors.primary })} +
+
+ {item.name} + {item.description} +
+
+ +
+ )} + /> +
+ ); +}; + +export default WorkoutTypeSelector;