Skip to content

Commit dd152ca

Browse files
theredmooseclaude
andcommitted
Add: alpine ski waist width recommendation with terrain selector
- Add AlpineTerrain type ('groomed' | 'all-mountain' | 'powder') and ALPINE_TERRAIN_LABELS to types/index.ts - Add calculateAlpineWaistWidth(terrain) to sizing.ts with documented ranges: 65–80 mm (groomed), 80–96 mm (all-mountain), 96–120 mm (powder) - Add terrain selector pill row to SportSizing (alpine tab only), following the same pattern as the Nordic model selector - Display 'Ski Waist' mm range in Equipment Sizing section below ski length - Add 4 tests for calculateAlpineWaistWidth in sizing.test.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 297d6b4 commit dd152ca

4 files changed

Lines changed: 96 additions & 2 deletions

File tree

src/components/SportSizing.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import { useState, useRef } from 'react';
22
import { ChevronLeft } from 'lucide-react';
3-
import type { FamilyMember, GearItem, SkillLevel, Sport, GearType, SizingModel, SizingDisplay } from '../types';
4-
import { SIZING_MODEL_LABELS } from '../types';
3+
import type { FamilyMember, GearItem, SkillLevel, Sport, GearType, SizingModel, SizingDisplay, AlpineTerrain } from '../types';
4+
import { SIZING_MODEL_LABELS, ALPINE_TERRAIN_LABELS } from '../types';
55
import {
66
calculateNordicSkiSizingByModel,
77
calculateNordicBootSizing,
88
calculateAlpineSkiSizing,
99
calculateAlpineBootSizing,
10+
calculateAlpineWaistWidth,
1011
calculateSnowboardSizing,
1112
calculateSnowboardBootSizing,
1213
calculateHockeySkateSize,
@@ -52,6 +53,7 @@ export function SportSizing({
5253
const [currentIndex, setCurrentIndex] = useState(0);
5354
const [sizingModel, setSizingModel] = useState<SizingModel>(defaultSizingModel);
5455
const [sizingDisplay, setSizingDisplay] = useState<SizingDisplay>(defaultSizingDisplay);
56+
const [alpineTerrain, setAlpineTerrain] = useState<AlpineTerrain>('all-mountain');
5557
const [skillLevels, setSkillLevels] = useState<Record<Sport, SkillLevel>>(() => ({
5658
'nordic-classic': member.skillLevels?.['nordic-classic'] ?? 'intermediate',
5759
'nordic-skate': member.skillLevels?.['nordic-skate'] ?? 'intermediate',
@@ -191,6 +193,7 @@ export function SportSizing({
191193
const renderAlpineContent = () => {
192194
const skiSizing = calculateAlpineSkiSizing(member.measurements, skillLevel, member.gender);
193195
const bootSizing = calculateAlpineBootSizing(member.measurements, skillLevel, member.gender);
196+
const waistWidth = calculateAlpineWaistWidth(alpineTerrain);
194197
const showRange = sizingDisplay === 'range';
195198

196199
return (
@@ -217,6 +220,13 @@ export function SportSizing({
217220
)}
218221
</div>
219222
</div>
223+
<div className="sizing-row">
224+
<span className="sizing-label">Ski Waist</span>
225+
<div className="sizing-value-group">
226+
<span className="sizing-value">{waistWidth.min}{waistWidth.max}</span>
227+
<span className="sizing-unit">mm</span>
228+
</div>
229+
</div>
220230
<div className="sizing-row">
221231
<span className="sizing-label">Boots</span>
222232
<div className="sizing-value-group">
@@ -465,6 +475,28 @@ export function SportSizing({
465475
</div>
466476
)}
467477

478+
{/* Terrain selector (Alpine only) */}
479+
{currentSport.id === 'alpine' && (
480+
<div className="px-6 py-2 bg-slate-50 border-b border-slate-100 flex items-center gap-2">
481+
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">Terrain</span>
482+
{(['groomed', 'all-mountain', 'powder'] as AlpineTerrain[]).map((t) => (
483+
<button
484+
key={t}
485+
onClick={() => setAlpineTerrain(t)}
486+
className={`px-3 py-1 rounded-lg text-[10px] font-bold border transition-colors ${
487+
alpineTerrain === t
488+
? 'text-white border-transparent'
489+
: 'bg-white text-slate-500 border-slate-200 hover:border-slate-300'
490+
}`}
491+
style={alpineTerrain === t ? { backgroundColor: currentSport.color, borderColor: currentSport.color } : undefined}
492+
aria-pressed={alpineTerrain === t}
493+
>
494+
{ALPINE_TERRAIN_LABELS[t]}
495+
</button>
496+
))}
497+
</div>
498+
)}
499+
468500
{/* Loadout + member info */}
469501
<div className="sizing-sections">
470502
<GearLoadoutPanel

src/services/__tests__/sizing.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
calculateNordicBootSizing,
55
calculateAlpineSkiSizing,
66
calculateAlpineBootSizing,
7+
calculateAlpineWaistWidth,
78
calculateSnowboardSizing,
89
calculateSnowboardBootSizing,
910
calculateHockeySkateSize,
@@ -205,6 +206,37 @@ describe('sizing service', () => {
205206
});
206207
});
207208

209+
// ============================================
210+
// ALPINE WAIST WIDTH
211+
// ============================================
212+
describe('calculateAlpineWaistWidth', () => {
213+
it('returns 65–80 mm for groomed terrain', () => {
214+
const result = calculateAlpineWaistWidth('groomed');
215+
expect(result.min).toBe(65);
216+
expect(result.max).toBe(80);
217+
});
218+
219+
it('returns 80–96 mm for all-mountain terrain', () => {
220+
const result = calculateAlpineWaistWidth('all-mountain');
221+
expect(result.min).toBe(80);
222+
expect(result.max).toBe(96);
223+
});
224+
225+
it('returns 96–120 mm for powder terrain', () => {
226+
const result = calculateAlpineWaistWidth('powder');
227+
expect(result.min).toBe(96);
228+
expect(result.max).toBe(120);
229+
});
230+
231+
it('ranges are in ascending order (groomed < all-mountain < powder)', () => {
232+
const groomed = calculateAlpineWaistWidth('groomed');
233+
const allMountain = calculateAlpineWaistWidth('all-mountain');
234+
const powder = calculateAlpineWaistWidth('powder');
235+
expect(groomed.max).toBeLessThanOrEqual(allMountain.min);
236+
expect(allMountain.max).toBeLessThanOrEqual(powder.min);
237+
});
238+
});
239+
208240
// ============================================
209241
// SNOWBOARD SIZING
210242
// ============================================

src/services/sizing.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
Measurements,
33
SkillLevel,
44
SizingModel,
5+
AlpineTerrain,
56
NordicSkiSizing,
67
NordicBootSizing,
78
AlpineSkiSizing,
@@ -349,6 +350,26 @@ export function calculateAlpineSkiSizing(
349350
};
350351
}
351352

353+
/**
354+
* Recommend alpine ski waist width range based on terrain preference.
355+
*
356+
* Waist width (the narrowest point of the ski) is the primary driver of
357+
* how a ski performs in different snow/terrain conditions:
358+
* groomed 65–80 mm — hardpack, carving, race
359+
* all-mountain 80–96 mm — versatile, mixed conditions
360+
* powder 96–120 mm — off-piste, soft snow, freeride
361+
*/
362+
export function calculateAlpineWaistWidth(
363+
terrain: AlpineTerrain
364+
): { min: number; max: number } {
365+
const ranges: Record<AlpineTerrain, { min: number; max: number }> = {
366+
'groomed': { min: 65, max: 80 },
367+
'all-mountain': { min: 80, max: 96 },
368+
'powder': { min: 96, max: 120 },
369+
};
370+
return ranges[terrain];
371+
}
372+
352373
/**
353374
* Calculate DIN release setting
354375
* This is a simplified calculation - actual DIN should be set by a professional

src/types/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ export const SIZING_MODEL_LABELS: Record<SizingModel, string> = {
6666
evosports: 'Evosports',
6767
};
6868

69+
// Alpine terrain preference — drives waist width recommendation
70+
export type AlpineTerrain = 'groomed' | 'all-mountain' | 'powder';
71+
72+
export const ALPINE_TERRAIN_LABELS: Record<AlpineTerrain, string> = {
73+
'groomed': 'Groomed',
74+
'all-mountain': 'All-Mountain',
75+
'powder': 'Powder',
76+
};
77+
6978
// Nordic Skiing
7079
export interface NordicSkiSizing {
7180
sport: 'nordic-classic' | 'nordic-skate' | 'nordic-combi';

0 commit comments

Comments
 (0)