diff --git a/app/page.tsx b/app/page.tsx index 9bc9cc8..a29e51f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -95,14 +95,15 @@ function HomeContent() { ? 'Near you' : REGION_LABELS[comparisonRegion]; + // A single swipeable row on a phone, wrapped and centred from `sm` up. const regionChips = ( -
+
{[{ code: 'Nearby' as const, emoji: 'πŸ“', label: 'Nearby' }, ...regions].map((region) => (
@@ -135,7 +136,8 @@ export default function ComparisonDashboard({ : 'text-gray-400 hover:text-white' }`} > - πŸ† Ride Score + πŸ† Ride Score + Score
{/* Refresh Button */} @@ -188,8 +193,16 @@ export default function ComparisonDashboard({ )} - {/* Resorts Grid */} -
+ + + {/* Resorts Grid β€” from `sm` up. A phone gets the compact list above. */} +
{sortedResorts.map(({ resort, weather, score, scoreBreakdown, error }) => { const isResortLoading = loading && !weather && !error; @@ -389,3 +402,108 @@ export default function ComparisonDashboard({
); } + +interface CompactRow { + resort: Resort; + weather: RiderConditions | null; + score: number; + error: string | null | undefined; +} + +/** + * One line per resort, for phones. + * + * The cards are a screen tall each on a phone, so a region of twelve took + * twelve screens and the ranking (the point of the view) was invisible. Here + * the whole ranking fits on about one screen; a tap opens the full forecast. + */ +function CompactList({ + rows, + loading, + onSelectResort, + distances, + units, +}: { + rows: CompactRow[]; + loading: boolean; + onSelectResort: (resort: Resort) => void; + distances?: Record; + units: 'metric' | 'imperial'; +}) { + return ( +
    + {rows.map(({ resort, weather, score, error }, index) => { + const subtitle = + distances?.[resort.id] !== undefined + ? `${formatDistance(distances[resort.id], units)} away` + : resort.region; + + if (!weather) { + return ( +
  1. + +
  2. + ); + } + + const label = getRideScoreLabel(score); + const flags = [ + weather.powderAlert && '❄️', + weather.bluebirdDay && 'β˜€οΈ', + weather.windHoldRisk && 'πŸ’¨', + ].filter(Boolean); + + return ( +
  3. + +
  4. + ); + })} +
+ ); +} diff --git a/components/PassFilter.tsx b/components/PassFilter.tsx index 8edbaac..ea5b090 100644 --- a/components/PassFilter.tsx +++ b/components/PassFilter.tsx @@ -27,8 +27,10 @@ export default function PassFilter({ if (available.length === 0) return null; return ( -
- + // One swipeable row on a phone: wrapped, the passes and the region chips + // below them took a third of the screen before the first result. +
+ My pass @@ -41,7 +43,7 @@ export default function PassFilter({ key={pass} onClick={() => onToggle(pass)} aria-pressed={isOn} - className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all ${ + className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-bold transition-all ${ isOn ? `${meta.className} ring-1 ring-inset ring-white/20` : 'border-white/10 bg-white/5 text-gray-400 hover:bg-white/10 hover:text-white' @@ -58,7 +60,7 @@ export default function PassFilter({ {selected.length > 0 && ( diff --git a/components/PlannerGrid.tsx b/components/PlannerGrid.tsx index 08ac9f8..82e87ff 100644 --- a/components/PlannerGrid.tsx +++ b/components/PlannerGrid.tsx @@ -2,7 +2,7 @@ import type { Resort } from '@/lib/types'; import { usePlanner } from '@/hooks/usePlanner'; -import { scoreTone, type DayOutlook } from '@/lib/planner'; +import { bestPerDay, scoreTone, type DayOutlook, type ResortOutlook } from '@/lib/planner'; import { useUnits } from '@/hooks/useUnits'; import { formatDistance, formatSnow, formatWind } from '@/lib/units'; import { PassBadgeList } from '@/components/PassBadge'; @@ -52,7 +52,7 @@ export default function PlannerGrid({ resorts, title, onSelectResort, distances πŸ—“οΈ 7-Day Planner {title} -

+

Every resort, every day, ranked by conditions. Best day per mountain is outlined.

@@ -75,18 +75,24 @@ export default function PlannerGrid({ resorts, title, onSelectResort, distances
)} + {outlooks.length > 0 && ( + + )} + + {/* On a phone the resort column is capped and the badges dropped, so + five or six days fit beside it instead of one. */} {outlooks.length > 0 && (
- +
- {columnDays.map((day) => ( @@ -167,9 +178,9 @@ function DayCell({ // a real one, and "Fair" is a worse answer than "don't know". if (!day.hasData) { return ( -
+ Resort
{weekday(day.timestamp, timezone)}
@@ -103,18 +109,23 @@ export default function PlannerGrid({ resorts, title, onSelectResort, distances key={resort.id} className="border-b border-white/5 transition-colors last:border-b-0 hover:bg-white/5" > -
+ +
–
@@ -181,9 +192,9 @@ function DayCell({ const tone = scoreTone(day.score); return ( -
+
); } + +/** + * The grid read by column: where to go on each day. + * + * A phone shows the grid a few days at a time, so the question the planner + * exists for, "which day, and where", would otherwise take a sideways scroll + * per resort. Desktop sees every column at once and doesn't need it. + */ +function BestPerDay({ + outlooks, + onSelectResort, + units, +}: { + outlooks: ResortOutlook[]; + onSelectResort: (resort: Resort) => void; + units: 'metric' | 'imperial'; +}) { + const picks = bestPerDay(outlooks); + if (picks.length === 0) return null; + + return ( +
+

+ Best pick each day +

+
    + {picks.map(({ day, resort }) => { + const tone = scoreTone(day.score); + return ( +
  1. + +
  2. + ); + })} +
+
+ ); +} diff --git a/lib/planner.ts b/lib/planner.ts index 578d1ea..890cfd1 100644 --- a/lib/planner.ts +++ b/lib/planner.ts @@ -147,6 +147,40 @@ export function rankOutlooks(outlooks: ResortOutlook[]): ResortOutlook[] { }); } +export interface DayPick { + /** The day as it appears in the winning resort's outlook. */ + day: DayOutlook; + resort: Resort; +} + +/** + * The best mountain on each day of the window: the grid read by column. + * + * On a phone the grid shows a day or two at a time, so "which day, and where" + * needs its own answer. Columns follow the first outlook, as the grid does. + * Ties go to the snowier day. A day nobody has data for is left out rather + * than awarded to whichever resort happens to come first. + */ +export function bestPerDay(outlooks: ResortOutlook[]): DayPick[] { + const columns = outlooks[0]?.days.map((d) => d.dayKey) ?? []; + + return columns.flatMap((dayKey) => { + let pick: DayPick | null = null; + for (const { resort, days } of outlooks) { + const day = days.find((d) => d.dayKey === dayKey); + if (!day?.hasData) continue; + if ( + !pick || + day.score > pick.day.score || + (day.score === pick.day.score && day.snowfallIn > pick.day.snowfallIn) + ) { + pick = { day, resort }; + } + } + return pick ? [pick] : []; + }); +} + /** Kept for the planner grid's cell styling; delegates to the shared labels. */ export function scoreTone(score: number): { bg: string; text: string; label: string } { const label = scoreLabel(score); diff --git a/tests/units-and-planner.test.ts b/tests/units-and-planner.test.ts index 1035f18..944e2a6 100644 --- a/tests/units-and-planner.test.ts +++ b/tests/units-and-planner.test.ts @@ -16,7 +16,7 @@ import { tempValue, } from '../lib/units'; import { snowLabel, detectFirnWindow } from '../lib/snowVocabulary'; -import { buildOutlook, rankOutlooks, scoreTone } from '../lib/planner'; +import { bestPerDay, buildOutlook, rankOutlooks, scoreTone } from '../lib/planner'; import { degreesToCompass, windLoadedAspects, haversineMeters } from '../lib/resortGeo'; import type { NormalizedForecast, Resort } from '../lib/types'; import { emptyHourlySeries } from '../lib/types'; @@ -357,6 +357,52 @@ test('ranking puts the best outlook first', () => { assert.equal(rankOutlooks([bad, good])[0].resort.id, 'good'); }); +test('the best pick per day goes to the best mountain that day', () => { + // "early" gets its snow on day 1, "late" on day 3. + const snowOn = (id: string, from: number) => + buildOutlook( + { ...testResort, id }, + syntheticForecast((i) => ({ + snowMm: i >= from && i < from + 24 ? 20 : 0, + tempC: -12, + gustKmh: 10, + cloud: 60, + })), + START, + 7 + ); + const early = snowOn('early', 0); + const late = snowOn('late', 48); + + const picks = bestPerDay([early, late]); + assert.equal(picks.length, early.days.length); + assert.equal(picks[0].resort.id, 'early'); + assert.equal(picks[2].resort.id, 'late'); + assert.deepEqual( + picks.map((p) => p.day.dayKey), + early.days.map((d) => d.dayKey), + 'one pick per column, in order' + ); +}); + +test('a day with no data anywhere gets no pick', () => { + const outlook = buildOutlook( + testResort, + syntheticForecast(() => ({ snowMm: 0, tempC: -5, gustKmh: 10, cloud: 50 })), + START, + 7 + ); + const blanked = { + ...outlook, + days: outlook.days.map((d, i) => (i === 1 ? { ...d, hasData: false } : d)), + }; + + const picks = bestPerDay([blanked]); + assert.equal(picks.length, outlook.days.length - 1); + assert.ok(!picks.some((p) => p.day.dayKey === outlook.days[1].dayKey)); + assert.deepEqual(bestPerDay([]), []); +}); + test('score tones are ordered and complete', () => { assert.equal(scoreTone(95).label, 'Epic'); assert.equal(scoreTone(10).label, 'Poor');