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
103 changes: 103 additions & 0 deletions web/src/app/replan/HeldLlInput.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(heldIso);
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(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 (
<span className="inline-flex items-center gap-1.5 text-xs text-gold">
🎟 LL {fmt(held)}
<button
type="button"
onClick={() => setEditing(true)}
disabled={pending}
className="text-fg-3 underline hover:text-fg-1"
>
edit
</button>
<button
type="button"
onClick={() => save("")}
disabled={pending}
className="text-fg-3 underline hover:text-fg-1"
>
clear
</button>
</span>
);
}

if (editing || !held) {
return (
<span className="inline-flex items-center gap-1.5 text-xs">
<label className="text-fg-3">🎟 LL at</label>
<input
type="time"
defaultValue={held ? held.slice(11, 16) : ""}
disabled={pending}
onBlur={(e) => e.target.value && save(e.target.value)}
className="rounded border border-line bg-bg-0 px-1 py-0.5 text-fg-0"
/>
{editing && (
<button
type="button"
onClick={() => setEditing(false)}
className="text-fg-3 underline"
>
cancel
</button>
)}
{error && <span className="text-warn">{error}</span>}
</span>
);
}
return null;
}
48 changes: 48 additions & 0 deletions web/src/app/replan/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { auth } from "@/auth";
import { getParkRides, getReplanContext } from "@/lib/dynamodb";
import {
bumpReplanLlmCount,
setHeldLl,
setPlanNextUp,
setPlanOrder,
setRideDone,
Expand Down Expand Up @@ -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<ReplanResult> {
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,
Expand Down
43 changes: 42 additions & 1 deletion web/src/app/replan/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 (
Expand Down Expand Up @@ -105,12 +109,21 @@ export default async function ReplanPage({
<div className="flex items-center gap-3">
<span className="text-fg-3 text-xs w-5">{i + 1}.</span>
<span className="text-fg-0 text-sm flex-1">{r.ride_name}</span>
<CurrentWait live={waitById.get(r.ride_id)} />
{isAffected && (
<span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">
alert
</span>
)}
</div>
<div className="mt-2 pl-8">
<HeldLlInput
planId={ctx.plan_id}
rideId={r.ride_id}
dateIso={ctx.date}
heldIso={ctx.held_lls[r.ride_id] ?? null}
/>
</div>
<div className="mt-2 pl-8">
<ReplanControls
planId={ctx.plan_id}
Expand All @@ -133,8 +146,36 @@ export default async function ReplanPage({
</div>

<p className="mt-4 text-fg-3 text-xs">
Waits are live (poller refreshes every ~2 min) Β·{" "}
<a href="/trips" className="underline">All trips &amp; days β†’</a>
</p>
</div>
);
}

/** 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 (
<span className="shrink-0 rounded-full bg-bad/15 px-2 py-0.5 text-xs font-medium text-bad">
Down
</span>
);
if (live.status === "OPERATING" && live.wait_mins !== null)
return (
<span className="shrink-0 text-fg-0 text-sm font-semibold tabular-nums">
{live.wait_mins}
<span className="text-fg-3 text-xs font-normal ml-0.5">min</span>
</span>
);
return (
<span className="shrink-0 text-fg-3 text-xs">
{live.status === "OPERATING" ? "open" : live.status.toLowerCase()}
</span>
);
}
4 changes: 2 additions & 2 deletions web/src/app/trips/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,9 +226,9 @@ function DayCard({ day }: { day: TripDay }) {
{day.active && !day.outcome_recorded && day.rides.length > 0 && (
<a
href={`/replan?plan=${encodeURIComponent(day.plan_id)}`}
className="mt-3 inline-block text-xs text-gold hover:underline"
className="mt-3 inline-flex items-center gap-1 rounded-md bg-gold/15 border border-gold/40 px-3 py-1.5 text-xs font-medium text-gold hover:bg-gold/25"
>
Adjust / drop rides β†’
Today’s schedule β€” waits, mark done, LLs β†’
</a>
)}
</div>
Expand Down
24 changes: 24 additions & 0 deletions web/src/lib/dynamodb-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
44 changes: 44 additions & 0 deletions web/src/lib/dynamodb-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
Loading