Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions tools/widget/scriptable-waits.js
Original file line number Diff line number Diff line change
@@ -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();
50 changes: 50 additions & 0 deletions web/src/app/api/widget/waits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* GET /api/widget/waits?t=<sub>.<secret> — 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" } },
);
}
196 changes: 196 additions & 0 deletions web/src/app/waits/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <details> 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 (
<div className="mx-auto max-w-2xl px-6 py-10">
{/* 60s auto-refresh: a glance page must never show stale-forever data. */}
<meta httpEquiv="refresh" content="60" />

<header className="mb-6 flex items-baseline justify-between gap-4">
<div>
<p className="label-meta">My waits</p>
<h2 className="display text-3xl font-medium mt-2">Your rides now</h2>
</div>
{weather && (
<p className="shrink-0 text-fg-1 text-lg" title={weather.condition}>
{weather.icon} {weather.temp_f}°
<span className="text-fg-3 text-sm ml-1.5">{weather.condition}</span>
</p>
)}
</header>

{waits.parks.length > 1 && (
<nav className="mb-6 flex flex-wrap gap-2">
<Chip href="/waits" active={!parkFilter} label="All parks" />
{waits.parks.map((g) => (
<Chip
key={g.park_key}
href={`/waits?park=${g.park_key}`}
active={parkFilter === g.park_key}
label={findPark(g.park_key)?.shortName ?? g.park_key}
/>
))}
</nav>
)}

{showPlan && waits.plan && (
<section className="mb-8">
<h3 className="label-meta mb-2">
Today&rsquo;s plan · {waits.plan.park_name}
</h3>
<div className="rounded-lg border border-gold/40 bg-bg-1 shadow-[var(--shadow-card)] divide-y divide-line-soft">
{waits.plan.rides.map((r, i) => (
<WaitRow key={`${r.ride_id}-${i}`} ride={r} ordinal={i + 1} />
))}
</div>
</section>
)}

{groups.length === 0 && !showPlan ? (
<div className="rounded-lg border border-line bg-bg-1 px-5 py-8 text-center shadow-[var(--shadow-card)]">
<p className="text-fg-1 font-medium">No favorites picked yet.</p>
<p className="text-fg-3 text-sm mt-1">
Pick rides on the <a href="/me" className="underline">My alerts</a>{" "}
page and they&rsquo;ll show here with live waits.
</p>
</div>
) : (
<div className="space-y-8">
{groups.map((g) => (
<section key={g.park_key}>
<h3 className="label-meta mb-2">{g.park_name}</h3>
<div className="rounded-lg border border-line bg-bg-1 shadow-[var(--shadow-card)] divide-y divide-line-soft">
{g.rides.map((r) => (
<WaitRow key={r.ride_id} ride={r} />
))}
</div>
</section>
))}
</div>
)}

<footer className="mt-10 space-y-3">
{waits.updated_at && (
<p className="text-fg-3 text-xs">
As of {new Date(waits.updated_at).toLocaleTimeString("en-US", {
timeZone: "America/New_York",
hour: "numeric",
minute: "2-digit",
})}{" "}
ET · refreshes every minute
</p>
)}
<details className="text-xs text-fg-3">
<summary className="cursor-pointer">Phone widget setup</summary>
<div className="mt-2 space-y-2">
<p>
Your private feed URL (treat it like a password — anyone with
it can see these waits):
</p>
<code className="block break-all rounded bg-bg-2 p-2 select-all">
{`https://magicmonitor.megillini.dev/api/widget/waits?t=${sub}.${widgetSecret}`}
</code>
<p>
Paste it into the Scriptable script at{" "}
<code>tools/widget/scriptable-waits.js</code> in the repo to get
a home-screen widget.
</p>
</div>
</details>
</footer>
</div>
);
}

function Chip({ href, active, label }: { href: string; active: boolean; label: string }) {
return (
<a
href={href}
className={
"rounded-full px-3 py-1 text-xs font-medium border " +
(active
? "border-gold/60 bg-gold/15 text-fg-0"
: "border-line bg-bg-1 text-fg-2")
}
>
{label}
</a>
);
}

function WaitRow({ ride, ordinal }: { ride: MyWaitRide; ordinal?: number }) {
const down = ride.status === "DOWN";
const operating = ride.status === "OPERATING";
return (
<div className="flex items-center justify-between gap-3 px-4 py-2.5">
<p className="text-fg-0 text-sm font-medium truncate">
{ordinal !== undefined && (
<span className="text-fg-3 mr-2">{ordinal}.</span>
)}
{ride.ride_name}
</p>
{down ? (
<span className="shrink-0 rounded-full bg-bad/15 px-2.5 py-0.5 text-xs font-medium text-bad">
Down
</span>
) : operating && ride.wait_mins !== null ? (
<span className="shrink-0 text-fg-0 font-semibold tabular-nums">
{ride.wait_mins}
<span className="text-fg-3 text-xs font-normal ml-0.5">min</span>
</span>
) : (
<span className="shrink-0 text-fg-3 text-xs">
{ride.status === "OPERATING" ? "Open" : ride.status.toLowerCase()}
</span>
)}
</div>
);
}
Loading
Loading