diff --git a/web/src/app/replan/HeldLlInput.tsx b/web/src/app/replan/HeldLlInput.tsx new file mode 100644 index 0000000..a8422ca --- /dev/null +++ b/web/src/app/replan/HeldLlInput.tsx @@ -0,0 +1,103 @@ +"use client"; + +/** + * "I have an LL at ⏱" control per ride on the schedule. Records a held + * Lightning Lane return time (self-serve, no Claude needed) so the poller + * stops alerting about earlier LLs that are still later than what you + * hold. Shows the current hold with a clear/edit affordance. + */ + +import { useState, useTransition } from "react"; + +import { applyHeldLl } from "./actions"; + +/** ISO ("…T15:30:00-04:00") β†’ "3:30 PM" for display. */ +function fmt(iso: string): string { + try { + const t = iso.slice(11, 16); // HH:MM + const [h, m] = t.split(":").map(Number); + const ap = h >= 12 ? "PM" : "AM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}:${String(m).padStart(2, "0")} ${ap}`; + } catch { + return iso; + } +} + +export default function HeldLlInput({ + planId, + rideId, + dateIso, + heldIso, +}: { + planId: string; + rideId: string; + dateIso: string; + heldIso: string | null; +}) { + const [pending, start] = useTransition(); + const [held, setHeld] = useState(heldIso); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + + const save = (time: string) => { + setError(null); + start(async () => { + const res = await applyHeldLl(planId, rideId, dateIso, time); + if (res.ok) { + setHeld(time ? `${dateIso}T${time.padStart(5, "0")}:00` : null); + setEditing(false); + } else setError(res.error ?? "Couldn't save."); + }); + }; + + if (held && !editing) { + return ( + + 🎟 LL {fmt(held)} + + + + ); + } + + if (editing || !held) { + return ( + + + e.target.value && save(e.target.value)} + className="rounded border border-line bg-bg-0 px-1 py-0.5 text-fg-0" + /> + {editing && ( + + )} + {error && {error}} + + ); + } + return null; +} diff --git a/web/src/app/replan/actions.ts b/web/src/app/replan/actions.ts index 6facb34..816056f 100644 --- a/web/src/app/replan/actions.ts +++ b/web/src/app/replan/actions.ts @@ -16,6 +16,7 @@ import { auth } from "@/auth"; import { getParkRides, getReplanContext } from "@/lib/dynamodb"; import { bumpReplanLlmCount, + setHeldLl, setPlanNextUp, setPlanOrder, setRideDone, @@ -170,6 +171,53 @@ export async function applyReplanOrder( return { ok: true }; } +/** America/New_York UTC offset (e.g. "-04:00") for a given ISO date. */ +function etOffset(dateIso: string): string { + try { + const d = new Date(`${dateIso}T12:00:00Z`); + const tz = new Intl.DateTimeFormat("en-US", { + timeZone: "America/New_York", + timeZoneName: "longOffset", + }) + .formatToParts(d) + .find((p) => p.type === "timeZoneName")?.value; + const off = (tz ?? "GMT-04:00").replace("GMT", ""); + return /^[+-]\d{2}:\d{2}$/.test(off) ? off : "-04:00"; + } catch { + return "-04:00"; + } +} + +/** + * Record (or clear) a held Lightning Lane for a ride from /replan β€” the + * self-serve version of set_held_ll. `time` is "HH:MM" (24h, from a time + * input) or "" to clear; combined with the plan's date in ET. + */ +export async function applyHeldLl( + planId: string, + rideId: string, + dateIso: string, + time: string, +): Promise { + const bad = await gate(planId, rideId); + if (bad) return bad; + let iso: string | null = null; + if (time) { + const m = /^(\d{1,2}):(\d{2})$/.exec(time); + if (!m) return { ok: false, error: "Enter a time like 3:00 PM." }; + const hh = String(Number(m[1])).padStart(2, "0"); + iso = `${dateIso}T${hh}:${m[2]}:00${etOffset(dateIso)}`; + } + try { + await setHeldLl(planId, rideId, iso); + } catch { + return { ok: false, error: "Couldn't save β€” try again." }; + } + revalidatePath("/replan"); + revalidatePath("/trips"); + return { ok: true }; +} + /** Mark a ride done (done=true) or un-done (false) from /replan. */ export async function applyDone( planId: string, diff --git a/web/src/app/replan/page.tsx b/web/src/app/replan/page.tsx index 71278b9..694996b 100644 --- a/web/src/app/replan/page.tsx +++ b/web/src/app/replan/page.tsx @@ -12,12 +12,13 @@ import { redirect } from "next/navigation"; import { auth } from "@/auth"; -import { getReplanContext } from "@/lib/dynamodb"; +import { getParkRides, getReplanContext } from "@/lib/dynamodb"; import { isTripsAllowed } from "@/lib/trips-access"; import { FamilyOnly } from "@/components/auth/FamilyOnly"; import ReplanControls from "./ReplanControls"; import AskClaude from "./AskClaude"; +import HeldLlInput from "./HeldLlInput"; export const dynamic = "force-dynamic"; @@ -41,6 +42,9 @@ export default async function ReplanPage({ // wait / earlier LL / back-up) β†’ Do next; "storm"/absent β†’ neutral. const kind = sp.type ?? ""; const ctx = planId ? await getReplanContext(planId) : null; + // Live waits for the plan's park, to show current wait per ride. + const liveWaits = ctx ? await getParkRides(ctx.park_key) : []; + const waitById = new Map(liveWaits.map((r) => [r.ride_id, r])); if (!ctx) { return ( @@ -105,12 +109,21 @@ export default async function ReplanPage({
{i + 1}. {r.ride_name} + {isAffected && ( alert )}
+
+ +

+ Waits are live (poller refreshes every ~2 min) Β·{" "} All trips & days β†’

); } + +/** Compact current-wait chip for a ride, from the live STATE row. */ +function CurrentWait({ + live, +}: { + live?: { status: string; wait_mins: number | null }; +}) { + if (!live) return null; + if (live.status === "DOWN") + return ( + + Down + + ); + if (live.status === "OPERATING" && live.wait_mins !== null) + return ( + + {live.wait_mins} + min + + ); + return ( + + {live.status === "OPERATING" ? "open" : live.status.toLowerCase()} + + ); +} diff --git a/web/src/app/trips/page.tsx b/web/src/app/trips/page.tsx index 1ee986a..4874d1d 100644 --- a/web/src/app/trips/page.tsx +++ b/web/src/app/trips/page.tsx @@ -226,9 +226,9 @@ function DayCard({ day }: { day: TripDay }) { {day.active && !day.outcome_recorded && day.rides.length > 0 && ( - Adjust / drop rides β†’ + Today’s schedule β€” waits, mark done, LLs β†’ )} diff --git a/web/src/lib/dynamodb-writes.test.ts b/web/src/lib/dynamodb-writes.test.ts index 482d0a4..96ebca6 100644 --- a/web/src/lib/dynamodb-writes.test.ts +++ b/web/src/lib/dynamodb-writes.test.ts @@ -214,3 +214,27 @@ describe("setPlanOrder + bumpReplanLlmCount", () => { expect(input.UpdateExpression).toContain("ADD #c :one"); }); }); + +describe("setHeldLl", () => { + it("ensures the map then sets the ride's held-LL key", async () => { + sendMock.mockResolvedValue({}); + const { setHeldLl } = await import("./dynamodb-writes"); + await setHeldLl("p1", "tron", "2026-07-03T15:00:00-04:00"); + expect(sendMock).toHaveBeenCalledTimes(2); + expect(sendMock.mock.calls[0][0].input.UpdateExpression).toContain( + "if_not_exists(ll_holds", + ); + const set = sendMock.mock.calls[1][0].input; + expect(set.UpdateExpression).toBe("SET ll_holds.#r = :t"); + expect(set.ExpressionAttributeNames["#r"]).toBe("tron"); + expect(set.ExpressionAttributeValues[":t"]).toBe("2026-07-03T15:00:00-04:00"); + }); + + it("REMOVEs the key when cleared (null)", async () => { + sendMock.mockResolvedValue({}); + const { setHeldLl } = await import("./dynamodb-writes"); + await setHeldLl("p1", "tron", null); + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock.mock.calls[0][0].input.UpdateExpression).toBe("REMOVE ll_holds.#r"); + }); +}); diff --git a/web/src/lib/dynamodb-writes.ts b/web/src/lib/dynamodb-writes.ts index d559672..9cd554e 100644 --- a/web/src/lib/dynamodb-writes.ts +++ b/web/src/lib/dynamodb-writes.ts @@ -355,6 +355,50 @@ export async function setPlanNextUp( ); } +/** + * Set (isoOrNull) or clear a held Lightning Lane return time for a ride + * on a plan, in the ll_holds map. Atomic per-key map update β€” ll_holds is + * ensured first so the nested SET can't fail on a missing map; never + * touches ride_sequence. Mirrors the MCP apply_held_ll so web + Claude + * agree. isoOrNull=null clears the hold. + */ +export async function setHeldLl( + planId: string, + rideId: string, + isoOrNull: string | null, +): Promise { + const Key = { PK: `USER#${SHARED_TRIP_USER}`, SK: `PLAN#${planId}` }; + if (isoOrNull) { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "SET ll_holds = if_not_exists(ll_holds, :empty)", + ExpressionAttributeValues: { ":empty": {} }, + ConditionExpression: "attribute_exists(PK)", + }), + ); + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "SET ll_holds.#r = :t", + ExpressionAttributeNames: { "#r": rideId }, + ExpressionAttributeValues: { ":t": isoOrNull }, + }), + ); + } else { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "REMOVE ll_holds.#r", + ExpressionAttributeNames: { "#r": rideId }, + }), + ); + } +} + // ─── "Ask Claude" daily rate limit (2026-07-03) ────────────────────── // // The /replan "Ask Claude" server action costs real Anthropic tokens, so