@@ -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"
>
-
+
onSelectResort(resort)}
- className="text-left transition-colors hover:text-cyan-400"
+ title={resort.name}
+ className="block w-24 text-left transition-colors hover:text-cyan-400 sm:w-auto"
>
- {resort.name}
-
+
{resort.name}
+
{distances?.[resort.id] !== undefined
? `${formatDistance(distances[resort.id], units)} away`
: resort.region}
-
+
@@ -167,9 +178,9 @@ function DayCell({
// a real one, and "Fair" is a worse answer than "don't know".
if (!day.hasData) {
return (
-
+
β
@@ -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 (
+
+ onSelectResort(resort)}
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-white/5"
+ >
+
+ {weekday(day.timestamp, resort.timezone)}
+
+ {dayNumber(day.timestamp, resort.timezone)}
+
+
+
+ {resort.name}
+
+ {day.snowfallIn >= 0.5 && (
+
+ {formatSnow(day.snowfallIn, units)}
+
+ )}
+
+ {day.score}
+
+
+
+ );
+ })}
+
+
+ );
+}
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');