From e4ae0908a667bda7dc48aed16d2dafae1379bb57 Mon Sep 17 00:00:00 2001 From: Dawson <71105828+DawsonCodes@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:32:08 +0000 Subject: [PATCH 1/3] Improve prediction engine for v1: refreeze risk, commute trend, snow intensity, sharper confidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New derived signals from data already fetched: wet-evening→freezing-commute refreeze risk, morning trend into school start (improving/steady/worsening), and peak hourly snow rate - Refreeze counts as a real winter hazard in the plausibility gate and feeds the delay profile (black-ice mornings are the textbook 2-hour delay) - Trend factor lowers closure when a storm ends before buses roll and raises it when conditions worsen into the commute (gated by storm presence) - Confidence now drops when precip type is ambiguous near freezing and rises when several independent strong signals agree - Every factor carries a category (snow/ice/cold/wind/visibility/alerts/ timing/school) and results include plain-language top drivers - Fix window bucketing so the 5 AM and 9 AM boundary hours are never counted in two windows at once --- js/engine.js | 123 +++++++++++++++++++++++++++++++++++++++++- js/weather.js | 57 ++++++++++++++++++-- tests/engine.test.js | 82 +++++++++++++++++++++++++++- tests/weather.test.js | 86 +++++++++++++++++++++++++++++ 4 files changed, 341 insertions(+), 7 deletions(-) diff --git a/js/engine.js b/js/engine.js index b667767..486b0d1 100644 --- a/js/engine.js +++ b/js/engine.js @@ -33,6 +33,9 @@ const DELAY_K = 45; * @property {number} windGustMph peak gust (mph) * @property {number|null} visibilityMi min visibility (miles); null = unknown * @property {'overnight'|'morning'|'daytime'} stormTiming + * @property {'improving'|'steady'|'worsening'} morningTrend how conditions move into the commute + * @property {number} peakSnowRateInHr heaviest single-hour snowfall (inches/hour) + * @property {number} refreezeRisk 0..1 wet-evening → below-freezing-commute black-ice risk * @property {boolean} hasWinterAlert * @property {'advisory'|'watch'|'warning'|null} alertSeverity * @property {number} districtSensitivity 0..1 (0.5 = average) @@ -72,6 +75,11 @@ function normalize(input = {}) { stormTiming: ["overnight", "morning", "daytime"].includes(input.stormTiming) ? input.stormTiming : "overnight", + morningTrend: ["improving", "steady", "worsening"].includes(input.morningTrend) + ? input.morningTrend + : "steady", + peakSnowRateInHr: Math.max(0, num(input.peakSnowRateInHr, 0)), + refreezeRisk: clamp(num(input.refreezeRisk, 0), 0, 1), hasWinterAlert: Boolean(input.hasWinterAlert), alertSeverity: ["advisory", "watch", "warning"].includes(input.alertSeverity) ? input.alertSeverity @@ -119,6 +127,7 @@ export function hasMeaningfulWinterHazard(input) { if (x.hasWinterAlert) return true; if (x.snowDepthIn >= HAZARD.snowDepthIn && x.lowTempF <= HAZARD.packTempF) return true; if (x.windChillF <= HAZARD.windChillF) return true; + if (x.refreezeRisk >= 0.4) return true; // wet roads refreezing into the commute return false; } @@ -134,14 +143,23 @@ const stormPresence = (x) => */ function closureFactors(x) { const f = []; - const add = (key, label, points, maxPoints, detail) => { + const add = (key, category, label, points, maxPoints, detail) => { const direction = points > 0.5 ? "positive" : points < -0.5 ? "negative" : "neutral"; - f.push({ key, label, points: Math.round(points * 10) / 10, maxPoints, direction, detail }); + f.push({ + key, + category, + label, + points: Math.round(points * 10) / 10, + maxPoints, + direction, + detail, + }); }; // Ice risk — heaviest single lever (freezing rain closes schools on its own). const icePts = 30 * x.iceRisk; add( + "ice", "ice", "Freezing rain / ice risk", icePts, @@ -153,10 +171,24 @@ function closureFactors(x) { : "Mostly snow, little ice expected" ); + // Refreeze: wet roads from the prior evening turning to black ice by bus time. + const refreezePts = 10 * x.refreezeRisk; + add( + "refreeze", + "ice", + "Roads refreezing overnight", + refreezePts, + 10, + x.refreezeRisk >= 0.5 + ? "Wet roads likely freezing into black ice before the commute" + : "Little refreeze risk on the roads" + ); + // Snow during the morning commute — the decisive operational window. const morningPts = 25 * Math.min(1, x.morningSnowIn / 4); add( "morningCommute", + "snow", "Snow during the morning commute", morningPts, 25, @@ -169,16 +201,33 @@ function closureFactors(x) { const overnightPts = 20 * Math.min(1, x.overnightSnowIn / 8); add( "overnightSnow", + "snow", "Overnight snow accumulation", overnightPts, 20, `~${round1(x.overnightSnowIn)}" expected overnight` ); + // Snowfall intensity — a heavy burst outruns the plows even when totals are modest. + const ratePts = 8 * clamp((x.peakSnowRateInHr - 0.3) / 0.9, 0, 1); + add( + "snowRate", + "snow", + "Snowfall intensity", + ratePts, + 8, + x.peakSnowRateInHr >= 0.8 + ? `Heavy bursts near ${round1(x.peakSnowRateInHr)}"/hr — plows can't keep up` + : x.peakSnowRateInHr >= 0.3 + ? `Steady snow up to ${round1(x.peakSnowRateInHr)}"/hr` + : "Light snowfall rates" + ); + // Storm timing (only counts when a storm is actually present). const timingBase = x.stormTiming === "overnight" ? 12 : x.stormTiming === "morning" ? 9 : 2; const timingPts = timingBase * stormPresence(x); add( + "timing", "timing", "Storm timing", timingPts, @@ -190,10 +239,30 @@ function closureFactors(x) { : "Worst of it lands during the school day or later" ); + // Trend into school start: a storm that ends before buses roll gives crews a + // window to clear; one ramping up into the commute takes that window away. + // Gated by storm presence so a clear day scores nothing either way. + const trendPts = + (x.morningTrend === "improving" ? -6 : x.morningTrend === "worsening" ? 6 : 0) * + stormPresence(x); + add( + "trend", + "timing", + "Trend into school start", + trendPts, + 6, + x.morningTrend === "improving" + ? "Snow winding down before school — crews get a window to clear" + : x.morningTrend === "worsening" + ? "Conditions worsening right into the commute" + : "Conditions roughly steady into the morning" + ); + // Official winter alert. const alertPts = 12 * alertWeight(x.alertSeverity); add( "alert", + "alerts", "Official winter alert", alertPts, 12, @@ -206,6 +275,7 @@ function closureFactors(x) { const chillPts = 8 * clamp((10 - x.windChillF) / 25, 0, 1); add( "windChill", + "cold", "Wind chill", chillPts, 8, @@ -218,6 +288,7 @@ function closureFactors(x) { const gustPts = 6 * clamp((x.windGustMph - 15) / 25, 0, 1); add( "gusts", + "wind", "Wind gusts", gustPts, 6, @@ -230,6 +301,7 @@ function closureFactors(x) { const visPts = x.visibilityMi === null ? 0 : 6 * clamp((2 - x.visibilityMi) / 1.75, 0, 1); add( + "visibility", "visibility", "Low visibility", visPts, @@ -245,6 +317,7 @@ function closureFactors(x) { const depthPts = 5 * Math.min(1, x.snowDepthIn / 12); add( "snowDepth", + "snow", "Existing snow on the ground", depthPts, 5, @@ -255,6 +328,7 @@ function closureFactors(x) { const probPts = 5 * x.precipProbability; add( "precipProbability", + "timing", "Precipitation probability", probPts, 5, @@ -265,6 +339,7 @@ function closureFactors(x) { const tempPts = 5 * clamp((34 - x.lowTempF) / 19, 0, 1); add( "temperature", + "cold", "Overnight low temperature", tempPts, 5, @@ -275,6 +350,7 @@ function closureFactors(x) { const areaPts = x.areaType === "rural" ? 8 : x.areaType === "urban" ? -6 : 0; add( "areaType", + "school", "Area type", areaPts, 8, @@ -295,6 +371,7 @@ function closureFactors(x) { : 0; add( "schoolType", + "school", "School type", schoolPts, 10, @@ -308,6 +385,7 @@ function closureFactors(x) { const sensPts = (x.districtSensitivity - 0.5) * 20; add( "districtSensitivity", + "school", "District snow-day tendency", sensPts, 10, @@ -322,6 +400,7 @@ function closureFactors(x) { const budgetPts = overBudget > 0 ? -Math.min(12, overBudget * 4) : 0; add( "snowDaysUsed", + "school", "Snow days already used", budgetPts, 12, @@ -338,6 +417,11 @@ function rawDelayScore(x) { let s = 0; s += 25 * Math.min(1, x.morningSnowIn / 3); // morning snow dominates delays s += 22 * x.iceRisk; // morning ice → delay to let crews treat roads + s += 14 * x.refreezeRisk; // black ice that melts by mid-morning is the textbook delay + // A storm winding down before school start favors "open two hours late" over a + // closure; one worsening into the commute pushes toward closing outright. + if (x.morningTrend === "improving") s += 6 * stormPresence(x); + s += 3 * clamp((x.peakSnowRateInHr - 0.3) / 0.9, 0, 1); // Timing matters inversely vs closure: a storm that hits/clears in the morning // is the textbook delay; an overnight storm that ends early still needs cleanup. // Gated by storm presence so a clear day scores no timing points. @@ -380,6 +464,21 @@ function computeConfidence(x, closurePct) { // Extreme, unambiguous conditions. if (x.iceRisk >= 0.66 || x.overnightSnowIn >= 10) c += 0.1; + // Rain-vs-snow-vs-ice is genuinely hard to call right at the freezing line: a + // partial ice signal with temps hovering near 32°F could break either way. + if (x.lowTempF >= 28 && x.lowTempF <= 35 && x.iceRisk >= 0.15 && x.iceRisk < 0.7) { + c -= 0.1; + } + + // Several independent strong signals agreeing → a clearer-cut setup. + const strongSignals = + (x.overnightSnowIn >= 6 ? 1 : 0) + + (x.morningSnowIn >= 2 ? 1 : 0) + + (x.iceRisk >= 0.5 ? 1 : 0) + + (x.hasWinterAlert ? 1 : 0) + + (x.windChillF <= -10 ? 1 : 0); + if (strongSignals >= 3) c += 0.1; + // The mushy middle is inherently uncertain. if (closurePct >= 40 && closurePct <= 60) c -= 0.2; @@ -391,6 +490,24 @@ function computeConfidence(x, closurePct) { return { confidence: label, confidenceScore: Math.round(score * 100) / 100 }; } +/** + * Pick the top plain-language drivers: up to three factors that raised the + * estimate the most, plus the single biggest thing holding it down. Gives the + * UI a scannable "why" without dumping every number. + */ +function buildDrivers(factors) { + const raising = factors + .filter((f) => f.points >= 3) + .sort((a, b) => b.points - a.points) + .slice(0, 3) + .map((f) => f.detail); + const reducer = factors + .filter((f) => f.points <= -3) + .sort((a, b) => a.points - b.points)[0]; + if (reducer) raising.push(reducer.detail); + return raising; +} + function buildRecommendation(closurePct, delayPct, confidence) { let base; if (closurePct >= 70) { @@ -456,6 +573,7 @@ export function predictSnowDay(input) { const recommendation = gated ? "No winter-weather hazard in the forecast window, so school should be open and on time." : buildRecommendation(closurePct, delayPct, confidence); + const drivers = gated ? [] : buildDrivers(factors); return { closurePct, @@ -463,6 +581,7 @@ export function predictSnowDay(input) { confidence, confidenceScore, recommendation, + drivers, factors, gated, gateReason, diff --git a/js/weather.js b/js/weather.js index 92ae4f8..6024c3a 100644 --- a/js/weather.js +++ b/js/weather.js @@ -125,14 +125,19 @@ function pickWindows(entries, now) { } const prevDate = shiftDate(targetDate, -1); - const morning = entries.filter((e) => e.date === targetDate && e.hour >= 5 && e.hour <= 9); + // Disjoint buckets so boundary hours are never double-counted: + // overnight = 6 PM prior evening through 4:59 AM, morning commute = 5–8:59 AM, + // daytime = 9 AM–5 PM. (Pre-v1 the 5 AM and 9 AM hours landed in two buckets.) + const morning = entries.filter((e) => e.date === targetDate && e.hour >= 5 && e.hour < 9); const overnight = entries.filter( (e) => - (e.date === prevDate && e.hour >= 18) || (e.date === targetDate && e.hour <= 5) + (e.date === prevDate && e.hour >= 18) || (e.date === targetDate && e.hour < 5) ); const daytime = entries.filter((e) => e.date === targetDate && e.hour >= 9 && e.hour <= 17); + // Prior evening (~3–11 PM), used for the wet-then-freezing refreeze signal. + const evening = entries.filter((e) => e.date === prevDate && e.hour >= 15); - return { morning, overnight, daytime, targetDate }; + return { morning, overnight, daytime, evening, targetDate }; } function iceFromWeatherCode(code) { @@ -159,7 +164,7 @@ export function mapForecastToEngineInput(forecast, { now, schoolContext = {} } = const times = hourly.time || []; const entries = times.map(parseEntry); - const { morning, overnight, daytime } = pickWindows(entries, now ?? new Date()); + const { morning, overnight, daytime, evening } = pickWindows(entries, now ?? new Date()); const get = (arr, i) => (Array.isArray(arr) && Number.isFinite(arr[i]) ? arr[i] : null); @@ -236,6 +241,47 @@ export function mapForecastToEngineInput(forecast, { now, schoolContext = {} } = } } + // Peak hourly snow rate over the event window — a 1"/hr burst is far more + // disruptive than the same total spread thinly, and plows can't keep up. + let peakSnowRateInHr = 0; + for (const e of eventIdxs) { + peakSnowRateInHr = Math.max( + peakSnowRateInHr, + toInchesSnow(get(hourly.snowfall, e.i) ?? 0, units.snowfall) + ); + } + + // Morning trend: is the snow ending before buses roll, or ramping up into the + // commute? Compare average rates (overnight ≈ 11 h, commute = 4 h). + const overnightRate = overnightSnowIn / Math.max(1, overnight.length); + const morningRate = morningSnowIn / Math.max(1, morning.length); + let morningTrend = "steady"; + if (overnightSnowIn >= 0.5 && morningRate < overnightRate * 0.25) { + morningTrend = "improving"; // storm winding down before school start + } else if (morningSnowIn >= 0.3 && morningRate > overnightRate * 1.5) { + morningTrend = "worsening"; // ramping up right into the commute + } + + // Refreeze risk: a wet prior evening followed by a below-freezing commute + // leaves untreated black ice even with little or no new precipitation. + let refreezeRisk = 0; + const eveningWet = evening.reduce((s, e) => { + const precip = get(hourly.precipitation, e.i) ?? 0; + const snow = toInchesSnow(get(hourly.snowfall, e.i) ?? 0, units.snowfall); + const tF = tempToF(get(hourly.temperature_2m, e.i) ?? 32, units.temperature_2m); + // Count liquid-ish precipitation that fell above freezing (wet roads). + return s + (tF > 33 && precip > snow * 0.5 ? precip : 0); + }, 0); + let morningMinF = Infinity; + for (const e of morning) { + const tF = tempToF(get(hourly.temperature_2m, e.i), units.temperature_2m); + if (tF !== null) morningMinF = Math.min(morningMinF, tF); + } + if (Number.isFinite(morningMinF)) { + if (eveningWet >= 0.15 && morningMinF <= 28) refreezeRisk = 0.8; + else if (eveningWet >= 0.05 && morningMinF <= 30) refreezeRisk = 0.6; + } + return { overnightSnowIn, morningSnowIn, @@ -247,6 +293,9 @@ export function mapForecastToEngineInput(forecast, { now, schoolContext = {} } = windGustMph, visibilityMi, // may be null → engine flags lower confidence stormTiming, + morningTrend, + peakSnowRateInHr, + refreezeRisk: clamp(refreezeRisk, 0, 1), // user-controlled context (alerts merged in by the caller): hasWinterAlert: Boolean(schoolContext.hasWinterAlert), alertSeverity: schoolContext.alertSeverity ?? null, diff --git a/tests/engine.test.js b/tests/engine.test.js index 8d41270..2b0ac69 100644 --- a/tests/engine.test.js +++ b/tests/engine.test.js @@ -235,8 +235,9 @@ test("gate: an unusual out-of-season snow/ice event is NOT blocked by the month" assert.ok(freakIce.closurePct > 0); }); -test("factor breakdown exposes proportional bar data", () => { +test("factor breakdown exposes proportional bar data with categories", () => { const r = predictSnowDay(baseInput({ iceRisk: 0.5 })); + const CATEGORIES = ["snow", "ice", "cold", "wind", "visibility", "alerts", "timing", "school"]; for (const f of r.factors) { assert.equal(typeof f.key, "string"); assert.equal(typeof f.label, "string"); @@ -244,5 +245,84 @@ test("factor breakdown exposes proportional bar data", () => { assert.ok(f.maxPoints > 0); assert.ok(["positive", "negative", "neutral"].includes(f.direction)); assert.equal(typeof f.detail, "string"); + assert.ok(CATEGORIES.includes(f.category), `unknown category ${f.category}`); } }); + +// --- v1 engine refinements ------------------------------------------------- + +test("refreeze risk raises closure, feeds delay, and passes the hazard gate", () => { + const dry = predictSnowDay(baseInput({ overnightSnowIn: 0, morningSnowIn: 0, precipProbability: 0.2 })); + const refreeze = predictSnowDay( + baseInput({ overnightSnowIn: 0, morningSnowIn: 0, precipProbability: 0.2, refreezeRisk: 0.8 }) + ); + assert.ok(refreeze.closurePct > dry.closurePct, "refreeze adds closure risk"); + assert.ok(refreeze.delayPct > dry.delayPct, "black ice is a classic delay driver"); + // Wet-then-freezing roads alone are a real winter hazard (not gated to zero). + assert.equal(hasMeaningfulWinterHazard({ lowTempF: 30, refreezeRisk: 0.6 }), true); + assert.equal(hasMeaningfulWinterHazard({ lowTempF: 40, refreezeRisk: 0.2 }), false); +}); + +test("a storm improving before school lowers closure; worsening raises it", () => { + const steady = predictSnowDay(baseInput({ overnightSnowIn: 4, morningTrend: "steady" })); + const improving = predictSnowDay(baseInput({ overnightSnowIn: 4, morningTrend: "improving" })); + const worsening = predictSnowDay(baseInput({ overnightSnowIn: 4, morningTrend: "worsening" })); + assert.ok(improving.closurePct < steady.closurePct, "improving trend reduces closure"); + assert.ok(worsening.closurePct > steady.closurePct, "worsening trend increases closure"); + // Improving before school start is the textbook 2-hour-delay setup. + assert.ok(improving.delayPct >= steady.delayPct, "improving trend favors a delay"); +}); + +test("trend has no effect on a clear day (gated by storm presence)", () => { + const clear = { overnightSnowIn: 0, morningSnowIn: 0, iceRisk: 0, precipProbability: 0.1, lowTempF: 20, windChillF: 12 }; + const a = predictSnowDay(baseInput({ ...clear, morningTrend: "worsening" })); + const b = predictSnowDay(baseInput({ ...clear, morningTrend: "steady" })); + assert.equal(a.closurePct, b.closurePct); +}); + +test("heavy snowfall bursts score higher than the same total spread thin", () => { + const thin = predictSnowDay(baseInput({ overnightSnowIn: 3, peakSnowRateInHr: 0.2 })); + const burst = predictSnowDay(baseInput({ overnightSnowIn: 3, peakSnowRateInHr: 1.2 })); + assert.ok(burst.closurePct > thin.closurePct); +}); + +test("confidence drops when precip type is ambiguous near freezing", () => { + // Same partial ice signal, differing ONLY in temperature: unambiguous cold vs + // hovering right at the freezing line. Inputs are tuned so both land in the + // same closure band (30–40%), isolating the ambiguity term. + const scenario = { overnightSnowIn: 0.55, iceRisk: 0.4, windChillF: 26 }; + const coldSnow = predictSnowDay(baseInput({ ...scenario, lowTempF: 20 })); + const nearFreezing = predictSnowDay(baseInput({ ...scenario, lowTempF: 32 })); + assert.ok(coldSnow.closurePct >= 30 && coldSnow.closurePct < 40, `cold ${coldSnow.closurePct}`); + assert.ok(nearFreezing.closurePct >= 30 && nearFreezing.closurePct < 40, `near ${nearFreezing.closurePct}`); + assert.ok(nearFreezing.confidenceScore < coldSnow.confidenceScore); +}); + +test("several strong agreeing signals raise confidence", () => { + const weak = predictSnowDay(baseInput({ overnightSnowIn: 2 })); + const strong = predictSnowDay( + baseInput({ + overnightSnowIn: 12, + morningSnowIn: 3, + iceRisk: 0.6, + hasWinterAlert: true, + alertSeverity: "warning", + precipProbability: 1, + }) + ); + assert.ok(strong.confidenceScore > weak.confidenceScore); +}); + +test("drivers list the top plain-language reasons, empty when gated", () => { + const storm = predictSnowDay( + baseInput({ overnightSnowIn: 8, morningSnowIn: 3, iceRisk: 0.5, schoolType: "college" }) + ); + assert.ok(Array.isArray(storm.drivers)); + assert.ok(storm.drivers.length >= 2 && storm.drivers.length <= 4); + for (const d of storm.drivers) assert.equal(typeof d, "string"); + // The biggest risk-reducer (college) is surfaced too. + assert.ok(storm.drivers.some((d) => /college/i.test(d)), "includes the top reducer"); + + const gated = predictSnowDay(warmDay()); + assert.deepEqual(gated.drivers, []); +}); diff --git a/tests/weather.test.js b/tests/weather.test.js index 3b322a2..c22e65d 100644 --- a/tests/weather.test.js +++ b/tests/weather.test.js @@ -88,6 +88,92 @@ test("hourly timeline returns capped, well-formed entries", () => { } }); +// --- v1 mapping refinements ------------------------------------------------- + +/** Build a minimal 2-day hourly forecast (inch units) with per-hour overrides. */ +function syntheticForecast(overrides = {}) { + const time = []; + for (const day of ["2026-01-09", "2026-01-10"]) { + for (let h = 0; h < 24; h++) time.push(`${day}T${String(h).padStart(2, "0")}:00`); + } + const zeros = () => time.map(() => 0); + const hourly = { + time, + snowfall: zeros(), + precipitation: zeros(), + precipitation_probability: time.map(() => 50), + temperature_2m: time.map(() => 30), + apparent_temperature: time.map(() => 25), + windspeed_10m: zeros(), + windgusts_10m: zeros(), + visibility: time.map(() => 16000), + snow_depth: zeros(), + weathercode: time.map(() => 3), + }; + for (const [field, byTime] of Object.entries(overrides)) { + for (const [iso, value] of Object.entries(byTime)) { + const i = time.indexOf(iso); + if (i >= 0) hourly[field][i] = value; + } + } + return { + hourly, + hourly_units: { snowfall: "inch", precipitation: "inch", temperature_2m: "°F", visibility: "m", snow_depth: "m" }, + daily: { temperature_2m_min: [28, 28] }, + }; +} + +test("boundary hours are not double-counted across windows", () => { + // Snow ONLY in the 5 AM hour → it belongs to the commute window, not overnight. + const f = syntheticForecast({ snowfall: { "2026-01-10T05:00": 1.0 } }); + const input = mapForecastToEngineInput(f, { now: EVENING }); + assert.equal(input.morningSnowIn, 1.0); + assert.equal(input.overnightSnowIn, 0); +}); + +test("peak snow rate reports the heaviest single hour", () => { + const f = syntheticForecast({ + snowfall: { "2026-01-10T02:00": 0.3, "2026-01-10T03:00": 1.1, "2026-01-10T06:00": 0.4 }, + }); + const input = mapForecastToEngineInput(f, { now: EVENING }); + assert.equal(input.peakSnowRateInHr, 1.1); +}); + +test("morning trend: storm ending overnight reads as improving", () => { + const f = syntheticForecast({ + snowfall: { "2026-01-09T20:00": 1.5, "2026-01-09T22:00": 1.5, "2026-01-10T00:00": 1.0 }, + }); + const input = mapForecastToEngineInput(f, { now: EVENING }); + assert.equal(input.morningTrend, "improving"); +}); + +test("morning trend: snow ramping into the commute reads as worsening", () => { + const f = syntheticForecast({ + snowfall: { "2026-01-10T02:00": 0.1, "2026-01-10T06:00": 0.8, "2026-01-10T07:00": 0.9 }, + }); + const input = mapForecastToEngineInput(f, { now: EVENING }); + assert.equal(input.morningTrend, "worsening"); +}); + +test("refreeze risk: evening rain then a hard-freezing commute", () => { + const f = syntheticForecast({ + precipitation: { "2026-01-09T17:00": 0.15, "2026-01-09T18:00": 0.1 }, + temperature_2m: { + "2026-01-09T17:00": 41, + "2026-01-09T18:00": 39, + "2026-01-10T05:00": 26, + "2026-01-10T06:00": 25, + "2026-01-10T07:00": 26, + "2026-01-10T08:00": 27, + }, + }); + const input = mapForecastToEngineInput(f, { now: EVENING }); + assert.ok(input.refreezeRisk >= 0.6, `refreezeRisk ${input.refreezeRisk}`); + // A dry evening produces no refreeze risk even with a cold morning. + const dry = syntheticForecast({ temperature_2m: { "2026-01-10T06:00": 25 } }); + assert.equal(mapForecastToEngineInput(dry, { now: EVENING }).refreezeRisk, 0); +}); + test("summarizeWinterAlerts picks the most severe winter event, filters non-winter", () => { const summary = summarizeWinterAlerts(nwsAlert); assert.equal(summary.hasWinterAlert, true); From 4151072c4a2fafdc93eaba8955a947358e6bfc5d Mon Sep 17 00:00:00 2001 From: Dawson <71105828+DawsonCodes@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:38:56 +0000 Subject: [PATCH 2/3] Polish UI for v1: grouped factors, drivers, settings depth, a11y and finishing touches - Factor breakdown grouped under 'Raising / Lowering the estimate' headings so direction never relies on color alone; plain-language top drivers under the recommendation; confidence chips explain their label on hover - Settings: more air between groups, check-marked selected pills, a separated can't-be-undone zone for destructive actions, finished About tab with the app mark, tagline, and description - Subtle dial halo and wordmark accent sweep (guarded by @supports) - Hourly timeline is keyboard-focusable; '/' focuses search; noscript notice - Static web-app manifest, theme-color, and social-sharing meta (no new deps) - Remove the orphaned atm-rise keyframe left from beta.4 --- css/base.css | 10 ++++ css/components.css | 115 ++++++++++++++++++++++++++++++++++++++++--- index.html | 42 +++++++++++++--- js/main.js | 11 +++++ js/ui.js | 57 +++++++++++++++++++-- manifest.webmanifest | 18 +++++++ 6 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 manifest.webmanifest diff --git a/css/base.css b/css/base.css index 812afb9..1472829 100644 --- a/css/base.css +++ b/css/base.css @@ -112,6 +112,16 @@ svg { border: 0; } +.noscript-note { + max-width: var(--maxw); + margin: 0 auto 12px; + padding: 12px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface); + font-size: 0.9rem; +} + .skip-link { position: absolute; left: 8px; diff --git a/css/components.css b/css/components.css index 619d054..71bdc95 100644 --- a/css/components.css +++ b/css/components.css @@ -27,6 +27,16 @@ 50% { transform: translateY(-2px) rotate(4deg); } } +/* Subtle accent sweep across the wordmark (only where text-clipping is safe). */ +@supports ((background-clip: text) or (-webkit-background-clip: text)) { + .brand h1 { + background: linear-gradient(100deg, var(--text) 55%, var(--accent-strong)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} + .subtitle { font-size: 0.82rem; color: var(--muted); @@ -381,6 +391,8 @@ input[type="range"]::-moz-range-thumb { stroke-dasharray: 326.7; stroke-dashoffset: 326.7; transition: stroke-dashoffset 0.8s cubic-bezier(0.22, 1, 0.36, 1), stroke 0.4s ease; + /* A faint halo makes the ring read as the primary object on both themes. */ + filter: drop-shadow(0 0 4px var(--accent-ring)); } #dial-delay .dial-fill { @@ -485,6 +497,29 @@ input[type="range"]::-moz-range-thumb { margin-left: auto; } +/* Plain-language top drivers under the recommendation. */ +.drivers { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.84rem; + color: var(--muted); + max-width: 60ch; +} + +.drivers li { + position: relative; + padding-left: 16px; +} + +.drivers li::before { + content: "→"; + position: absolute; + left: 0; + color: var(--accent-strong); +} + /* Plausibility-gate explanation (estimate resolved to 0%). */ .gate-note { font-size: 0.82rem; @@ -670,6 +705,19 @@ input[type="range"]::-moz-range-thumb { gap: 10px; } +/* Group headings: direction is stated in text, never by color alone. */ +.factor-heading { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--muted); + margin-top: 6px; +} + +.factor-heading:first-child { + margin-top: 0; +} + .factor { display: grid; grid-template-columns: 1fr; @@ -1044,10 +1092,15 @@ input[type="range"]::-moz-range-thumb { .settings-group { border: none; - padding: 14px 0 0; + padding: 16px 0 0; margin: 0; } +.settings-group + .settings-group { + margin-top: 6px; + border-top: 1px solid color-mix(in srgb, var(--border) 55%, transparent); +} + .settings-group legend { font-size: 0.74rem; text-transform: uppercase; @@ -1094,6 +1147,13 @@ input[type="range"]::-moz-range-thumb { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, var(--surface)); color: var(--accent-strong); + font-weight: 600; + box-shadow: 0 0 0 1px var(--accent), 0 2px 8px var(--accent-ring); +} + +/* Selection is also marked with a leading check, not color alone. */ +.pill:has(input:checked) span::before { + content: "✓ "; } .pill:has(input:focus-visible) { @@ -1160,6 +1220,52 @@ input[type="range"]::-moz-range-thumb { background: color-mix(in srgb, var(--bad) 14%, var(--surface)); } +/* Destructive actions live in their own clearly separated zone. */ +.settings-danger { + margin-top: 18px; + padding-top: 12px; + border-top: 1px dashed color-mix(in srgb, var(--bad) 45%, transparent); +} + +.settings-danger-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bad); + margin-bottom: 4px; +} + +/* About tab */ +.about-hero { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +.about-icon { + width: 44px; + height: 44px; + flex-shrink: 0; +} + +.about-hero .app-version { + font-size: 1.05rem; +} + +.about-tagline { + font-size: 0.8rem; + color: var(--muted); +} + +.about-blurb { + margin-top: 10px; + font-size: 0.85rem; + line-height: 1.5; + color: var(--muted); + max-width: 60ch; +} + .settings-about { display: flex; flex-direction: column; @@ -1271,13 +1377,6 @@ input[type="range"]::-moz-range-thumb { 100% { transform: translate3d(var(--x, 0), 108vh, 0) rotate(360deg); } } -@keyframes atm-rise { - 0% { transform: translate3d(0, 112vh, 0); opacity: 0; } - 12% { opacity: var(--o, 0.3); } - 88% { opacity: var(--o, 0.3); } - 100% { transform: translate3d(var(--x, 0), -8vh, 0); opacity: 0; } -} - /* Spring: a gentle side-to-side sway while drifting down. */ @keyframes atm-sway-fall { 0% { transform: translate3d(0, -6vh, 0) rotate(0deg); } diff --git a/index.html b/index.html index 60e88c1..0e3b38c 100644 --- a/index.html +++ b/index.html @@ -8,11 +8,22 @@ name="description" content="SnowSignal — a transparent snow-day and school-delay predictor. Estimates school closure and delay chances from live weather data. Free, private, no sign-up." /> + + + + + +