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 && (
-
-
} onClick={onEditClick}>
- Edit
-
+ return (
+ <>
+
+ {/* Mobile view */}
+
- {/* Desktop view */}
-
-
+ {isOwner && (
+
+ }
+ onClick={() => setGeneratorOpen(true)}
+ >
+ Generate
+
+ } onClick={onEditClick}>
+ Edit
+
+
+ )}
+
- {isOwner && (
-
- } onClick={onEditClick}>
- Edit Playlist
-
-
- )}
+ {/* Desktop view */}
+
+
+
+ {isOwner && (
+
+ }
+ onClick={() => setGeneratorOpen(true)}
+ >
+ Generate Playlist
+
+ } onClick={onEditClick}>
+ Edit Playlist
+
+
+ )}
+
-
+
+ {/* 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}
+
+ }
+ onClick={() => onUpdate(Math.max(min, value - 1))}
+ disabled={value <= min}
+ className={styles.stepperButton}
+ />
+ }
+ onClick={() => onUpdate(Math.min(max, value + 1))}
+ disabled={value >= max}
+ className={styles.stepperButton}
+ />
+
+
+
+ );
+
+ // 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()}
+
+
+
+ }
+ onClick={onReset}
+ className={styles.resetButton}
+ >
+ Reset
+
+
+
+ );
+};
+
+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 */}
+
+
+
+
+ );
+};
+
+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' && (
+ }
+ onClick={handleBack}
+ className={styles.backButton}
+ />
+ )}
+ {renderTitle()}
+ {drawerState === 'configure' && }
+
+ }
+ open={open}
+ onClose={generating ? undefined : onClose}
+ placement="bottom"
+ height="85vh"
+ closable={!generating}
+ maskClosable={!generating}
+ styles={{
+ header: {
+ borderBottom: `1px solid ${themeTokens.neutral[200]}`,
+ },
+ body: {
+ padding: drawerState === 'select' ? 0 : 16,
+ overflow: 'auto',
+ },
+ }}
+ extra={
+ drawerState === 'configure' && !generating ? (
+ }
+ onClick={handleGenerate}
+ disabled={plannedSlots.length === 0}
+ >
+ Generate
+
+ ) : 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;