From b27f704c044e8b6029f6744062bb645423e96f35 Mon Sep 17 00:00:00 2001 From: Megan Schott Date: Fri, 3 Jul 2026 08:20:01 -0500 Subject: [PATCH] feat(web): /waits glance page + phone-widget JSON feed + live weather MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-user "look at my phone, see my waits" surface (successor to the Pi version's widget): - /waits (auth-gated): favorites across all parks on ONE page, grouped by park, DOWN-first-then-longest-wait glance order, with today's ACTIVE plan pinned on top in PLAN order (remaining rides + live waits) and current WDW conditions (temp + clear/rain/storm) in the header. Filter chips (?park=) narrow to one park. 60s meta refresh (poller cadence is 2 min). One shared read model (my-waits.ts) — all keyed reads, no scans. - /api/widget/waits?t=.: the widget JSON feed. Widgets can't carry a NextAuth session, so it uses a per-user capability token — the secret mints on first /waits visit (if_not_exists handles the race), lives on the PROFILE row, revocable by deleting the attribute. Constant-time digest comparison, uniform 401s, no-store. Deliberate documented tradeoff: URL-holder can read ride names + waits (read-only, low sensitivity). - tools/widget/scriptable-waits.js: iOS Scriptable widget rendering the feed (plan if active, else favorites; DOWN flagged; weather in header), taps through to /waits. - Weather via the same Open-Meteo endpoint + WDW coords the poller uses; 5-min fetch cache; page renders fine without it (weather is garnish). Tests: read-model shaping (glance sort, plan order preserved, dormant/ recorded plans excluded), WMO code buckets (incl. the negative-code fallthrough the test caught). Co-Authored-By: Claude Opus 4.8 --- tools/widget/scriptable-waits.js | 106 ++++++++++++++ web/src/app/api/widget/waits/route.ts | 50 +++++++ web/src/app/waits/page.tsx | 196 ++++++++++++++++++++++++++ web/src/lib/dynamodb-writes.ts | 39 +++++ web/src/lib/my-waits.test.ts | 108 ++++++++++++++ web/src/lib/my-waits.ts | 138 ++++++++++++++++++ web/src/lib/weather.test.ts | 26 ++++ web/src/lib/weather.ts | 70 +++++++++ 8 files changed, 733 insertions(+) create mode 100644 tools/widget/scriptable-waits.js create mode 100644 web/src/app/api/widget/waits/route.ts create mode 100644 web/src/app/waits/page.tsx create mode 100644 web/src/lib/my-waits.test.ts create mode 100644 web/src/lib/my-waits.ts create mode 100644 web/src/lib/weather.test.ts create mode 100644 web/src/lib/weather.ts diff --git a/tools/widget/scriptable-waits.js b/tools/widget/scriptable-waits.js new file mode 100644 index 0000000..ea7a19a --- /dev/null +++ b/tools/widget/scriptable-waits.js @@ -0,0 +1,106 @@ +// Magic Monitor — iOS home-screen waits widget (Scriptable). +// +// Setup (one time): +// 1. Install "Scriptable" from the App Store. +// 2. New script → paste this file → name it "MM Waits". +// 3. Sign into magicmonitor.megillini.dev/waits on your phone, expand +// "Phone widget setup", copy your private feed URL, and paste it +// into FEED_URL below. Treat that URL like a password. +// 4. Long-press home screen → add a Scriptable widget (medium works +// best) → choose "MM Waits". +// +// Shows: today's active plan (if any) or your favorites, DOWN rides +// flagged, plus current temp/conditions at WDW. iOS refreshes widgets on +// its own cadence (~every 5–15 min); tap the widget to open /waits for +// live-now numbers. + +const FEED_URL = "PASTE_YOUR_FEED_URL_HERE"; + +const MAX_ROWS = 6; + +async function run() { + const w = new ListWidget(); + w.backgroundColor = new Color("#141210"); + w.url = "https://magicmonitor.megillini.dev/waits"; + w.setPadding(12, 14, 12, 14); + + let data; + try { + data = await new Request(FEED_URL).loadJSON(); + } catch (e) { + w.addText("Magic Monitor").font = Font.boldSystemFont(12); + const err = w.addText("Feed unreachable — tap to open"); + err.font = Font.systemFont(10); + err.textColor = Color.gray(); + return finish(w); + } + + // Header: title + weather. + const head = w.addStack(); + head.centerAlignContent(); + const title = head.addText("Magic Monitor"); + title.font = Font.boldSystemFont(12); + title.textColor = new Color("#d4af37"); + head.addSpacer(); + if (data.weather) { + const wx = head.addText( + `${data.weather.icon} ${data.weather.temp_f}°`, + ); + wx.font = Font.systemFont(12); + wx.textColor = Color.white(); + } + w.addSpacer(6); + + // Prefer the active plan (it's "what's next"); fall back to favorites. + let rows = []; + if (data.plan && data.plan.rides.length > 0) { + rows = data.plan.rides.map((r, i) => ({ ...r, prefix: `${i + 1}. ` })); + } else { + rows = (data.parks ?? []).flatMap((g) => g.rides); + } + + if (rows.length === 0) { + const none = w.addText("No favorites picked yet — tap to set up."); + none.font = Font.systemFont(11); + none.textColor = Color.gray(); + return finish(w); + } + + for (const r of rows.slice(0, MAX_ROWS)) { + const line = w.addStack(); + line.centerAlignContent(); + const name = line.addText(`${r.prefix ?? ""}${r.ride_name}`); + name.font = Font.systemFont(11); + name.textColor = Color.white(); + name.lineLimit = 1; + line.addSpacer(); + let right, color; + if (r.status === "DOWN") { + right = "DOWN"; + color = new Color("#e05d4b"); + } else if (r.status === "OPERATING" && r.wait_mins !== null) { + right = `${r.wait_mins}m`; + color = r.wait_mins <= 20 ? new Color("#7dc47d") : Color.white(); + } else { + right = r.status.toLowerCase(); + color = Color.gray(); + } + const val = line.addText(right); + val.font = Font.boldMonospacedSystemFont(11); + val.textColor = color; + w.addSpacer(3); + } + + return finish(w); +} + +function finish(w) { + if (config.runsInWidget) { + Script.setWidget(w); + } else { + w.presentMedium(); + } + Script.complete(); +} + +await run(); diff --git a/web/src/app/api/widget/waits/route.ts b/web/src/app/api/widget/waits/route.ts new file mode 100644 index 0000000..0618db2 --- /dev/null +++ b/web/src/app/api/widget/waits/route.ts @@ -0,0 +1,50 @@ +/** + * GET /api/widget/waits?t=. — the phone-widget JSON feed. + * + * A widget can't carry a NextAuth session, so this authenticates with the + * per-user capability token minted on /waits (secret lives on the + * PROFILE row — see dynamodb-writes.ts for the tradeoff + revocation). + * Read-only, one user's favorites/plan waits + weather; same read model + * as the page (getMyWaits) so widget and page can't drift. + * + * Comparison is constant-time over digests (timingSafeEqual requires + * equal lengths). Failures are a uniform 401 — don't leak whether the + * sub exists. + */ +import { createHash, timingSafeEqual } from "crypto"; + +import { NextRequest, NextResponse } from "next/server"; + +import { getWidgetSecret } from "@/lib/dynamodb-writes"; +import { getMyWaits } from "@/lib/my-waits"; +import { getCurrentConditions } from "@/lib/weather"; + +export const dynamic = "force-dynamic"; + +function digest(s: string): Buffer { + return createHash("sha256").update(s).digest(); +} + +export async function GET(req: NextRequest) { + const token = req.nextUrl.searchParams.get("t") ?? ""; + const dot = token.indexOf("."); + if (dot <= 0) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + const sub = token.slice(0, dot); + const secret = token.slice(dot + 1); + + const stored = await getWidgetSecret(sub); + if (!stored || !timingSafeEqual(digest(secret), digest(stored))) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const [waits, weather] = await Promise.all([ + getMyWaits(sub), + getCurrentConditions(), + ]); + return NextResponse.json( + { ...waits, weather }, + { headers: { "cache-control": "no-store" } }, + ); +} diff --git a/web/src/app/waits/page.tsx b/web/src/app/waits/page.tsx new file mode 100644 index 0000000..82efc78 --- /dev/null +++ b/web/src/app/waits/page.tsx @@ -0,0 +1,196 @@ +/** + * /waits — the per-user glance page: live waits for YOUR rides. + * + * The phone-widget companion (the successor to the Pi version's widget + * feed): favorites across all parks on one page, grouped by park, with + * today's ACTIVE plan pinned on top and current WDW weather in the + * header. Filter chips narrow to one park (?park=) for anyone who finds + * all-at-once busy. Auto-refreshes every 60s via meta refresh — the + * poller writes every 2 min, so that's always ≤1 poll stale. + * + * The widget feed URL (a per-user capability token — see + * dynamodb-writes.ts) is exposed in a
at the bottom, paired + * with tools/widget/scriptable-waits.js for the actual iOS widget. + */ + +import { redirect } from "next/navigation"; + +import { auth } from "@/auth"; +import { getMyWaits, type MyWaitRide } from "@/lib/my-waits"; +import { getOrCreateWidgetSecret } from "@/lib/dynamodb-writes"; +import { findPark, type ParkKey } from "@/lib/parks"; +import { getCurrentConditions } from "@/lib/weather"; + +export const dynamic = "force-dynamic"; + +export default async function WaitsPage({ + searchParams, +}: { + searchParams: Promise<{ park?: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) { + redirect("/api/auth/signin?callbackUrl=/waits"); + } + const sub = session.user.id; + + const sp = await searchParams; + const parkFilter = (findPark(sp.park ?? "")?.key ?? null) as ParkKey | null; + + const [waits, weather, widgetSecret] = await Promise.all([ + getMyWaits(sub), + getCurrentConditions(), + getOrCreateWidgetSecret(sub), + ]); + + const groups = parkFilter + ? waits.parks.filter((g) => g.park_key === parkFilter) + : waits.parks; + const showPlan = + waits.plan && (!parkFilter || waits.plan.park_key === parkFilter); + + return ( +
+ {/* 60s auto-refresh: a glance page must never show stale-forever data. */} + + +
+
+

My waits

+

Your rides now

+
+ {weather && ( +

+ {weather.icon} {weather.temp_f}° + {weather.condition} +

+ )} +
+ + {waits.parks.length > 1 && ( + + )} + + {showPlan && waits.plan && ( +
+

+ Today’s plan · {waits.plan.park_name} +

+
+ {waits.plan.rides.map((r, i) => ( + + ))} +
+
+ )} + + {groups.length === 0 && !showPlan ? ( +
+

No favorites picked yet.

+

+ Pick rides on the My alerts{" "} + page and they’ll show here with live waits. +

+
+ ) : ( +
+ {groups.map((g) => ( +
+

{g.park_name}

+
+ {g.rides.map((r) => ( + + ))} +
+
+ ))} +
+ )} + +
+ {waits.updated_at && ( +

+ As of {new Date(waits.updated_at).toLocaleTimeString("en-US", { + timeZone: "America/New_York", + hour: "numeric", + minute: "2-digit", + })}{" "} + ET · refreshes every minute +

+ )} +
+ Phone widget setup +
+

+ Your private feed URL (treat it like a password — anyone with + it can see these waits): +

+ + {`https://magicmonitor.megillini.dev/api/widget/waits?t=${sub}.${widgetSecret}`} + +

+ Paste it into the Scriptable script at{" "} + tools/widget/scriptable-waits.js in the repo to get + a home-screen widget. +

+
+
+
+
+ ); +} + +function Chip({ href, active, label }: { href: string; active: boolean; label: string }) { + return ( + + {label} + + ); +} + +function WaitRow({ ride, ordinal }: { ride: MyWaitRide; ordinal?: number }) { + const down = ride.status === "DOWN"; + const operating = ride.status === "OPERATING"; + return ( +
+

+ {ordinal !== undefined && ( + {ordinal}. + )} + {ride.ride_name} +

+ {down ? ( + + Down + + ) : operating && ride.wait_mins !== null ? ( + + {ride.wait_mins} + min + + ) : ( + + {ride.status === "OPERATING" ? "Open" : ride.status.toLowerCase()} + + )} +
+ ); +} diff --git a/web/src/lib/dynamodb-writes.ts b/web/src/lib/dynamodb-writes.ts index eac4b45..05fa29e 100644 --- a/web/src/lib/dynamodb-writes.ts +++ b/web/src/lib/dynamodb-writes.ts @@ -123,6 +123,45 @@ export async function getUserProfile( }; } +// ─── Widget feed secret (2026-07-03) ───────────────────────────────── +// +// The iOS widget can't carry a NextAuth session, so the JSON feed +// (/api/widget/waits) authenticates with a per-user CAPABILITY TOKEN: +// `.`, where the secret lives on the user's PROFILE row. +// Deliberate, documented tradeoff: anyone holding the URL can read that +// user's ride names + waits (low sensitivity, no writes). Revoke by +// deleting the widget_secret attribute (a fresh one mints on next visit +// to /waits). + +/** Get the user's widget secret, creating one on first use. Handles the + * concurrent-first-call race via if_not_exists — both callers converge + * on whichever secret landed. */ +export async function getOrCreateWidgetSecret(sub: string): Promise { + const { randomBytes } = await import("crypto"); + const fresh = randomBytes(16).toString("hex"); + const resp = await client.send( + new UpdateCommand({ + TableName: tableName, + Key: { PK: `USER#${sub}`, SK: "PROFILE" }, + UpdateExpression: "SET widget_secret = if_not_exists(widget_secret, :s)", + ExpressionAttributeValues: { ":s": fresh }, + ReturnValues: "ALL_NEW", + }), + ); + return (resp.Attributes?.widget_secret as string) ?? fresh; +} + +/** The stored secret (null when never provisioned) — for feed verification. */ +export async function getWidgetSecret(sub: string): Promise { + const resp = await client.send( + new GetCommand({ + TableName: tableName, + Key: { PK: `USER#${sub}`, SK: "PROFILE" }, + }), + ); + return (resp.Item?.widget_secret as string | undefined) ?? null; +} + // ─── Park subscriptions ────────────────────────────────────────────── /** diff --git a/web/src/lib/my-waits.test.ts b/web/src/lib/my-waits.test.ts new file mode 100644 index 0000000..7513189 --- /dev/null +++ b/web/src/lib/my-waits.test.ts @@ -0,0 +1,108 @@ +// Tests for the /waits + widget read model: favorites joined with live +// state (glance-sorted), today's active plan in PLAN order, updated_at. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const getParkRides = vi.fn(); +const getUpcomingTrips = vi.fn(); +vi.mock("./dynamodb", () => ({ + getParkRides: (...a: unknown[]) => getParkRides(...a), + getUpcomingTrips: (...a: unknown[]) => getUpcomingTrips(...a), +})); + +const getUserFavoriteRides = vi.fn(); +const getFavoriteRideCountsByPark = vi.fn(); +vi.mock("./dynamodb-writes", () => ({ + getUserFavoriteRides: (...a: unknown[]) => getUserFavoriteRides(...a), + getFavoriteRideCountsByPark: (...a: unknown[]) => + getFavoriteRideCountsByPark(...a), +})); + +function ride(id: string, name: string, wait: number | null, status = "OPERATING") { + return { + ride_id: id, name, status, wait_mins: wait, + park_key: "magic_kingdom", park_name: "Magic Kingdom", + last_seen: `2026-07-03T12:0${id.length}:00Z`, ll: null, + }; +} + +const NO_COUNTS = { + magic_kingdom: 0, epcot: 0, hollywood_studios: 0, animal_kingdom: 0, +}; + +beforeEach(() => { + vi.resetAllMocks(); + getUpcomingTrips.mockResolvedValue([]); +}); + +describe("getMyWaits", () => { + it("groups favorites by park, DOWN first then longest wait", async () => { + getFavoriteRideCountsByPark.mockResolvedValue({ ...NO_COUNTS, magic_kingdom: 3 }); + getUserFavoriteRides.mockResolvedValue(new Set(["a", "b", "c"])); + getParkRides.mockResolvedValue([ + ride("a", "Short Wait", 10), + ride("b", "Broken Ride", null, "DOWN"), + ride("c", "Long Wait", 60), + ride("x", "Not A Favorite", 90), + ]); + const { getMyWaits } = await import("./my-waits"); + const out = await getMyWaits("sub-1"); + + expect(out.parks).toHaveLength(1); + expect(out.parks[0].rides.map((r) => r.ride_name)).toEqual([ + "Broken Ride", "Long Wait", "Short Wait", + ]); + expect(out.plan).toBeNull(); + expect(out.updated_at).not.toBeNull(); + }); + + it("surfaces today's active plan in plan order with joined waits", async () => { + const today = new Date().toLocaleDateString("en-CA", { + timeZone: "America/New_York", + }); + getFavoriteRideCountsByPark.mockResolvedValue({ ...NO_COUNTS }); + getUpcomingTrips.mockResolvedValue([{ + trip_id: "t1", name: "Trip", start_date: today, end_date: today, + days: [{ + date: today, park_key: "epcot", plan_id: "p1", active: true, + ride_count: 2, outcome_recorded: false, alert_subscribers: [], + rides: [ + { ride_name: "Second By Plan", ride_id: "s2" }, + { ride_name: "First By Wait", ride_id: "s1" }, + ], + }], + }]); + getParkRides.mockResolvedValue([ + { ...ride("s1", "First By Wait", 70), park_key: "epcot" }, + { ...ride("s2", "Second By Plan", 5), park_key: "epcot" }, + ]); + const { getMyWaits } = await import("./my-waits"); + const out = await getMyWaits("sub-1"); + + // PLAN order preserved (never re-sorted by wait) + waits joined. + expect(out.plan?.rides.map((r) => [r.ride_name, r.wait_mins])).toEqual([ + ["Second By Plan", 5], + ["First By Wait", 70], + ]); + expect(out.parks).toEqual([]); // no favorites → no park groups + }); + + it("dormant or recorded plans don't appear", async () => { + const today = new Date().toLocaleDateString("en-CA", { + timeZone: "America/New_York", + }); + getFavoriteRideCountsByPark.mockResolvedValue({ ...NO_COUNTS }); + getUpcomingTrips.mockResolvedValue([{ + trip_id: "t1", name: "Trip", start_date: today, end_date: today, + days: [{ + date: today, park_key: "epcot", plan_id: "p1", active: false, + ride_count: 1, outcome_recorded: false, alert_subscribers: [], + rides: [{ ride_name: "X", ride_id: "x" }], + }], + }]); + const { getMyWaits } = await import("./my-waits"); + const out = await getMyWaits("sub-1"); + expect(out.plan).toBeNull(); + }); +}); diff --git a/web/src/lib/my-waits.ts b/web/src/lib/my-waits.ts new file mode 100644 index 0000000..29b0299 --- /dev/null +++ b/web/src/lib/my-waits.ts @@ -0,0 +1,138 @@ +/** + * The per-user "my waits" read model — favorites across all parks joined + * with live waits, plus today's active plan (remaining rides). ONE shape + * shared by the /waits page and the widget JSON feed, so the phone widget + * can never drift from what the page shows. + * + * Read cost: one favorites Query + one STATE GSI Query per park the user + * has favorites in (≤4), plus the trips read when a plan might be active. + * All keyed reads — no scans (DATA-GROWTH-MODEL.md). + */ +import "server-only"; + +import { getParkRides, getUpcomingTrips, type RideState } from "./dynamodb"; +import { getUserFavoriteRides, getFavoriteRideCountsByPark } from "./dynamodb-writes"; +import { PARKS, findPark, type ParkKey } from "./parks"; + +export interface MyWaitRide { + ride_id: string; + ride_name: string; + status: string; + wait_mins: number | null; +} + +export interface MyWaitsParkGroup { + park_key: ParkKey; + park_name: string; + rides: MyWaitRide[]; +} + +export interface MyWaits { + /** Today's ACTIVE plan (remaining rides w/ live waits), or null. */ + plan: { + park_key: ParkKey; + park_name: string; + plan_id: string; + rides: MyWaitRide[]; + } | null; + /** Only parks where the user has favorites, in canonical park order. */ + parks: MyWaitsParkGroup[]; + /** Freshest STATE row seen — "as of" for the glance. */ + updated_at: string | null; +} + +function todayEtIso(): string { + return new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }); +} + +/** DOWN first (that's the news), then longest wait — glance order. */ +function glanceSort(a: MyWaitRide, b: MyWaitRide): number { + const downA = a.status === "DOWN" ? 1 : 0; + const downB = b.status === "DOWN" ? 1 : 0; + if (downA !== downB) return downB - downA; + return (b.wait_mins ?? -1) - (a.wait_mins ?? -1); +} + +function toWaitRide(r: RideState): MyWaitRide { + return { + ride_id: r.ride_id, + ride_name: r.name, + status: r.status, + wait_mins: r.wait_mins, + }; +} + +export async function getMyWaits(sub: string): Promise { + const counts = await getFavoriteRideCountsByPark(sub); + const favParks = PARKS.filter((p) => (counts[p.key] ?? 0) > 0).map( + (p) => p.key, + ); + + // Today's active plan (if any) — cheap check, and it decides whether we + // need a park's STATE rows beyond the favorites set. + const today = todayEtIso(); + const trips = await getUpcomingTrips(); + const planDay = + trips + .flatMap((t) => t.days) + .find( + (d) => d.date === today && d.active && !d.outcome_recorded, + ) ?? null; + + const parksToFetch = new Set(favParks); + if (planDay) parksToFetch.add(planDay.park_key); + + const stateByPark = new Map(); + await Promise.all( + [...parksToFetch].map(async (pk) => { + stateByPark.set(pk, await getParkRides(pk)); + }), + ); + + // Favorites per park, joined with live state. + const groups: MyWaitsParkGroup[] = []; + for (const pk of favParks) { + const favIds = await getUserFavoriteRides(sub, pk); + const rides = (stateByPark.get(pk) ?? []) + .filter((r) => favIds.has(r.ride_id)) + .map(toWaitRide) + .sort(glanceSort); + if (rides.length > 0) { + groups.push({ + park_key: pk, + park_name: findPark(pk)?.name ?? pk, + rides, + }); + } + } + + // Plan section: remaining (un-ridden) sequence in PLAN ORDER — the + // sequence is the plan; re-sorting it would hide what's next. + let plan: MyWaits["plan"] = null; + if (planDay) { + const state = stateByPark.get(planDay.park_key) ?? []; + const byId = new Map(state.map((r) => [r.ride_id, r])); + const byName = new Map(state.map((r) => [r.name.toLowerCase(), r])); + const rides = planDay.rides.map((pr) => { + const match = + (pr.ride_id && byId.get(pr.ride_id)) || + byName.get(pr.ride_name.toLowerCase()); + return match + ? toWaitRide(match) + : { ride_id: pr.ride_id ?? "", ride_name: pr.ride_name, status: "UNKNOWN", wait_mins: null }; + }); + plan = { + park_key: planDay.park_key, + park_name: findPark(planDay.park_key)?.name ?? planDay.park_key, + plan_id: planDay.plan_id, + rides, + }; + } + + const allSeen = [...stateByPark.values()].flat().map((r) => r.last_seen); + return { + plan, + parks: groups, + updated_at: allSeen.length ? allSeen.sort().at(-1)! : null, + }; +} diff --git a/web/src/lib/weather.test.ts b/web/src/lib/weather.test.ts new file mode 100644 index 0000000..f38d608 --- /dev/null +++ b/web/src/lib/weather.test.ts @@ -0,0 +1,26 @@ +// Pin the WMO-code → label/icon buckets (a wrong bucket = a wrong glance). +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { _describe } from "./weather"; + +describe("weather code mapping", () => { + it.each([ + [0, "Clear"], + [2, "Partly cloudy"], + [3, "Overcast"], + [45, "Fog"], + [55, "Drizzle"], + [63, "Rain"], + [81, "Showers"], + [95, "Thunderstorm"], + [99, "Thunderstorm"], + ])("code %i → %s", (code, label) => { + expect(_describe(code as number).condition).toBe(label); + }); + + it("unknown codes degrade to a dash, not a crash", () => { + expect(_describe(-1).condition).toBe("—"); + }); +}); diff --git a/web/src/lib/weather.ts b/web/src/lib/weather.ts new file mode 100644 index 0000000..0e89513 --- /dev/null +++ b/web/src/lib/weather.ts @@ -0,0 +1,70 @@ +/** + * Current conditions at WDW for the /waits page + widget feed. + * + * Same Open-Meteo endpoint + coords the poller's weather module uses + * (infra/lambda/poller/weather.py) — free, no API key. One property-wide + * reading: the four parks sit within ~5 miles, so per-park weather would + * be false precision. Cached for 5 minutes via Next's fetch cache so the + * page/widget can't hammer the API (Open-Meteo updates ~every 15 min). + */ +import "server-only"; + +const WDW_LAT = 28.3852; +const WDW_LON = -81.5639; + +export interface CurrentConditions { + temp_f: number; + /** Human label derived from the WMO weather code ("Clear", "Rain"…). */ + condition: string; + /** Glanceable emoji for the widget/title row. */ + icon: string; + is_raining: boolean; +} + +/** WMO weather-code buckets → label + emoji. Coarse on purpose — this is + * a glance widget, not a forecast product. Thunderstorm codes match the + * poller's _STORM_CODES (95/96/99). */ +function describe(code: number): { condition: string; icon: string } { + if (code < 0 || !Number.isFinite(code)) return { condition: "—", icon: "🌡️" }; + if (code === 0) return { condition: "Clear", icon: "☀️" }; + if (code <= 2) return { condition: "Partly cloudy", icon: "⛅" }; + if (code === 3) return { condition: "Overcast", icon: "☁️" }; + if (code === 45 || code === 48) return { condition: "Fog", icon: "🌫️" }; + if (code <= 57) return { condition: "Drizzle", icon: "🌦️" }; + if (code <= 67) return { condition: "Rain", icon: "🌧️" }; + if (code <= 77) return { condition: "Sleet", icon: "🌨️" }; + if (code <= 82) return { condition: "Showers", icon: "🌧️" }; + if (code >= 95) return { condition: "Thunderstorm", icon: "⛈️" }; + return { condition: "—", icon: "🌡️" }; +} + +/** Exported for tests. */ +export const _describe = describe; + +export async function getCurrentConditions(): Promise { + try { + const url = + "https://api.open-meteo.com/v1/forecast" + + `?latitude=${WDW_LAT}&longitude=${WDW_LON}` + + "¤t=temperature_2m,precipitation,weather_code" + + "&temperature_unit=fahrenheit&timezone=America%2FNew_York"; + const resp = await fetch(url, { next: { revalidate: 300 } }); + if (!resp.ok) return null; + const data = (await resp.json()) as { + current?: { temperature_2m?: number; precipitation?: number; weather_code?: number }; + }; + const cur = data.current; + if (cur?.temperature_2m === undefined) return null; + const code = cur.weather_code ?? -1; + const { condition, icon } = describe(code); + return { + temp_f: Math.round(cur.temperature_2m), + condition, + icon, + is_raining: (cur.precipitation ?? 0) > 0 || (code >= 51 && code <= 99), + }; + } catch { + // Weather is garnish — the waits page must render without it. + return null; + } +}