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
43 changes: 32 additions & 11 deletions web/src/app/replan/AskClaude.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,26 +25,35 @@ export default function AskClaude({
}) {
const [pending, start] = useTransition();
const [result, setResult] = useState<AskClaudeResult | null>(null);
const [note, setNote] = useState("");

const ask = () => {
setResult(null);
start(async () => setResult(await askClaudeReplan(planId, trigger)));
start(async () => setResult(await askClaudeReplan(planId, trigger, note)));
};

return (
<div className="mb-6 rounded-lg border border-gold/40 bg-bg-1 p-4 shadow-[var(--shadow-card)]">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-fg-1 text-sm font-medium">Not sure what to do?</p>
<p className="text-fg-3 text-xs">
Ask Claude for a take on the whole day.
</p>
</div>
<div>
<p className="text-fg-1 text-sm font-medium">Not sure what to do?</p>
<p className="text-fg-3 text-xs">
Add anything Claude should know, then ask for a take on the day.
</p>
</div>
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="e.g. leaving by 5, skip water rides, add a couple more headliners…"
className="mt-2 w-full rounded-md border border-line bg-bg-0 px-3 py-2 text-sm text-fg-0 placeholder:text-fg-3 focus:border-accent focus:outline-none"
/>
<div className="mt-2 flex justify-end">
<button
type="button"
onClick={ask}
disabled={pending}
className="shrink-0 rounded-md bg-gold px-3 py-1.5 text-sm font-medium text-gold-ink hover:opacity-90 disabled:opacity-60"
className="rounded-md bg-gold px-3 py-1.5 text-sm font-medium text-gold-ink hover:opacity-90 disabled:opacity-60"
>
{pending ? "Thinking…" : "Ask Claude"}
</button>
Expand Down Expand Up @@ -72,12 +81,19 @@ function Suggestion({
const [pending, start] = useTransition();
const [applied, setApplied] = useState(false);
const [error, setError] = useState<string | null>(null);
const name = (id: string) => rideNames[id] ?? "(ride)";
// Names for planned rides plus any Claude proposed adding.
const addNames = Object.fromEntries(suggestion.add.map((a) => [a.ride_id, a.ride_name]));
const name = (id: string) => rideNames[id] ?? addNames[id] ?? "(ride)";

const apply = () => {
setError(null);
start(async () => {
const res = await applyReplanOrder(planId, suggestion.order, suggestion.drop);
const res = await applyReplanOrder(
planId,
suggestion.order,
suggestion.drop,
suggestion.add,
);
if (res.ok) setApplied(true);
else setError(res.error ?? "Couldn't apply.");
});
Expand All @@ -99,6 +115,11 @@ function Suggestion({
))}
</ol>

{suggestion.add.length > 0 && (
<p className="mt-2 text-xs text-ok">
+ Add: {suggestion.add.map((a) => a.ride_name).join(", ")}
</p>
)}
{suggestion.drop.length > 0 && (
<p className="mt-2 text-xs text-bad">
Drop: {suggestion.drop.map(name).join(", ")}
Expand Down
25 changes: 24 additions & 1 deletion web/src/app/replan/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { getParkRides, getReplanContext } from "@/lib/dynamodb";
import {
addRidesToSequence,
bumpReplanLlmCount,
setHeldLl,
setPlanNextUp,
Expand Down Expand Up @@ -79,6 +80,7 @@ export type AskClaudeResult =
export async function askClaudeReplan(
planId: string,
trigger?: string | null,
note?: string | null,
): Promise<AskClaudeResult> {
const session = await auth();
const sub = session?.user?.id;
Expand Down Expand Up @@ -122,12 +124,26 @@ export async function askClaudeReplan(
};
});

// Catalog of rides in the park NOT already in the plan, so Claude can
// suggest adds (from real ride_ids only).
const planned = new Set(ctx.rides.map((r) => r.ride_id));
const catalog = state
.filter((r) => !planned.has(r.ride_id) && r.status !== "CLOSED")
.map((r) => ({
ride_id: r.ride_id,
ride_name: r.name,
current_wait: r.wait_mins,
status: r.status,
}));

const suggestion = await proposeReplan({
park_name: ctx.park_name,
date: ctx.date,
weather: weather ? `${weather.condition}, ${weather.temp_f}°` : null,
trigger: trigger ?? null,
note: (note ?? "").trim().slice(0, 500) || null,
rides,
catalog,
});
return { ok: true, suggestion };
} catch (err) {
Expand All @@ -146,17 +162,24 @@ export async function applyReplanOrder(
planId: string,
order: string[],
drop: string[],
add: { ride_id: string; ride_name: string }[] = [],
): Promise<ReplanResult> {
const session = await auth();
if (!session?.user?.id) return { ok: false, error: "Not signed in." };
if (!isTripsAllowed(session.user?.email)) {
return { ok: false, error: "Family accounts only." };
}
const clean = (order ?? []).filter((s) => typeof s === "string").slice(0, 50);
const clean = (order ?? []).filter((s) => typeof s === "string").slice(0, 60);
if (!planId || clean.length === 0) {
return { ok: false, error: "Nothing to apply." };
}
try {
// Add new rides FIRST (so they exist in ride_sequence before the
// order + poller reference them), then order, then drops.
const cleanAdds = (add ?? [])
.filter((a) => a && typeof a.ride_id === "string" && typeof a.ride_name === "string")
.slice(0, 20);
if (cleanAdds.length) await addRidesToSequence(planId, cleanAdds);
await setPlanOrder(planId, clean);
await Promise.all(
(drop ?? [])
Expand Down
59 changes: 48 additions & 11 deletions web/src/lib/claude-replan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ export interface ReplanSuggestion {
order: string[];
/** ride_ids to drop entirely (down / not worth it). */
drop: string[];
/** Optional short note per ride_id explaining a move/drop. */
/** New rides to ADD (from the park catalog): ride_id + name. */
add: { ride_id: string; ride_name: string }[];
/** Optional short note per ride_id explaining a move/drop/add. */
reasons: Record<string, string>;
}

Expand Down Expand Up @@ -92,6 +94,19 @@ const TOOL = {
items: { type: "string" },
description: "ride_ids to drop (down or not worth the time).",
},
add: {
type: "array",
description:
"New rides to add — ONLY ride_ids from the provided catalog. Empty unless the family asked or it clearly helps.",
items: {
type: "object",
properties: {
ride_id: { type: "string" },
ride_name: { type: "string" },
},
required: ["ride_id", "ride_name"],
},
},
reasons: {
type: "object",
description:
Expand All @@ -108,7 +123,11 @@ export async function proposeReplan(input: {
date: string;
weather: string | null;
trigger: string | null;
/** Free-text context the family typed (e.g. "leaving by 5, skip water rides"). */
note: string | null;
rides: ReplanRideInput[];
/** Other rides in the park (not in the plan) Claude may add from. */
catalog: { ride_id: string; ride_name: string; current_wait: number | null; status: string }[];
}): Promise<ReplanSuggestion> {
const client = new Anthropic({ apiKey: await getKey() });

Expand Down Expand Up @@ -137,41 +156,59 @@ export async function proposeReplan(input: {
"A ride marked HELD LL means they hold a Lightning Lane for it — IGNORE " +
"its standby wait; keep it where it fits their LL time, don't reorder " +
"around the standby. Drop rides that are DOWN or clearly not worth the " +
"time. Reorder ONLY the ride_ids given — never invent rides. Put every " +
"non-dropped ride_id in `order`. If the current order is already good, " +
"set no_change=true but still return the order. Keep summary + reasons short.",
"time. You may ADD rides — but ONLY ride_ids from the provided catalog, " +
"and only when the family asked or it clearly helps (e.g. time to spare). " +
"Never invent a ride_id. Put every non-dropped ride_id (planned + added) " +
"in `order`. If nothing needs changing, set no_change=true but still " +
"return the order. Keep summary + reasons short.",
messages: [
{
role: "user",
content:
`Park: ${input.park_name} (${input.date}). ` +
`Weather: ${input.weather ?? "n/a"}. ` +
`Alert that prompted this: ${input.trigger ?? "manual check"}.\n\n` +
(input.note ? `From the family: "${input.note}". Weigh this heavily.\n\n` : "") +
`Remaining planned rides (current wait vs planned):\n${rideLines}\n\n` +
`Propose changes or confirm no changes are needed.`,
(input.catalog.length
? `Other rides in the park you may ADD (use these exact ride_ids only):\n` +
input.catalog
.map((c) => `- ${c.ride_name} [${c.ride_id}] now ${c.status === "DOWN" ? "DOWN" : c.current_wait ?? "?"}`)
.join("\n") + "\n\n"
: "") +
`Re-sequence, drop, and/or add as needed. Only add when the family asked or it clearly helps.`,
},
],
});

const block = msg.content.find((b) => b.type === "tool_use");
if (!block || block.type !== "tool_use") {
return { no_change: true, summary: "No suggestion available right now.", order: [], drop: [], reasons: {} };
return { no_change: true, summary: "No suggestion available right now.", order: [], drop: [], add: [], reasons: {} };
}
const out = block.input as ReplanSuggestion;
const ids = new Set(input.rides.map((r) => r.ride_id));
const catalogById = new Map(input.catalog.map((c) => [c.ride_id, c.ride_name]));
// Adds must be real catalog ride_ids not already planned.
const planned = new Set(input.rides.map((r) => r.ride_id));
const add = (out.add ?? [])
.filter((a) => catalogById.has(a.ride_id) && !planned.has(a.ride_id))
.map((a) => ({ ride_id: a.ride_id, ride_name: catalogById.get(a.ride_id) ?? a.ride_name }));
const addSet = new Set(add.map((a) => a.ride_id));
// Valid ride universe = planned + adds.
const ids = new Set([...planned, ...addSet]);
const drop = (out.drop ?? []).filter((id) => ids.has(id));
const dropSet = new Set(drop);
// Only real, non-dropped ride_ids, and append any the model forgot so
// the order always covers every remaining ride.
// Only real, non-dropped ride_ids; append any the model forgot so the
// order always covers every remaining (and added) ride.
const order = (out.order ?? []).filter((id) => ids.has(id) && !dropSet.has(id));
for (const r of input.rides) {
if (!dropSet.has(r.ride_id) && !order.includes(r.ride_id)) order.push(r.ride_id);
for (const id of ids) {
if (!dropSet.has(id) && !order.includes(id)) order.push(id);
}
return {
no_change: Boolean(out.no_change),
summary: out.summary ?? "",
order,
drop,
add,
reasons: out.reasons ?? {},
};
}
21 changes: 21 additions & 0 deletions web/src/lib/dynamodb-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,24 @@ describe("setRideActualWait", () => {
expect(sendMock.mock.calls[0][0].input.UpdateExpression).toBe("REMOVE actual_waits.#r");
});
});

describe("addRidesToSequence", () => {
it("atomically list_appends new ride entries", async () => {
sendMock.mockResolvedValue({});
const { addRidesToSequence } = await import("./dynamodb-writes");
await addRidesToSequence("p1", [{ ride_id: "sm", ride_name: "Space Mountain" }]);
const input = sendMock.mock.calls[0][0].input;
expect(input.UpdateExpression).toContain("list_append(if_not_exists(ride_sequence");
expect(input.ExpressionAttributeValues[":new"]).toEqual([
{ ride_id: "sm", ride_name: "Space Mountain", added_via: "replan" },
]);
expect(input.ConditionExpression).toBe("attribute_exists(PK)");
});

it("no-ops on an empty add list", async () => {
sendMock.mockClear();
const { addRidesToSequence } = await import("./dynamodb-writes");
await addRidesToSequence("p1", []);
expect(sendMock).not.toHaveBeenCalled();
});
});
31 changes: 31 additions & 0 deletions web/src/lib/dynamodb-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,37 @@ export async function bumpReplanLlmCount(
return Number(resp.Attributes?.count ?? 1);
}

/**
* Append new rides to a plan's ride_sequence (from an Ask-Claude add).
* Uses DDB's atomic `list_append` in the UpdateExpression — a single
* server-side append, so no read-modify-write and no race with a
* concurrent plan edit. Idempotency (not re-adding an existing ride) is
* the caller's job via the catalog filter.
*/
export async function addRidesToSequence(
planId: string,
rides: { ride_id: string; ride_name: string }[],
): Promise<void> {
if (rides.length === 0) return;
await client.send(
new UpdateCommand({
TableName: tableName,
Key: { PK: `USER#${SHARED_TRIP_USER}`, SK: `PLAN#${planId}` },
UpdateExpression:
"SET ride_sequence = list_append(if_not_exists(ride_sequence, :empty), :new)",
ExpressionAttributeValues: {
":empty": [],
":new": rides.map((r) => ({
ride_id: r.ride_id,
ride_name: r.ride_name,
added_via: "replan",
})),
},
ConditionExpression: "attribute_exists(PK)",
}),
);
}

/**
* Set the suggested ride ORDER for a plan (a list of ride_ids), from the
* "Ask Claude" re-plan. A single atomic SET of one list attribute
Expand Down
Loading