From e9c709c6b8ab368faaa25d8155f55153af13b256 Mon Sep 17 00:00:00 2001 From: Megan Schott Date: Fri, 3 Jul 2026 17:03:13 -0500 Subject: [PATCH] feat(web): optional actual-wait capture on Mark done (calibration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When you mark a ride done on the schedule, an OPTIONAL "actual __ min" field appears — never required (Mark done works standalone), saves on blur. Captures the predicted-vs-actual calibration signal from the phone, without needing Claude's mark_ride_complete. - setRideActualWait: atomic per-key map update on actual_waits (ll_holds- style; ensured-then-set; no ride_sequence surgery). - applyActualWait action validates 0–600 min; blank clears. - get_plan_for_day surfaces actual_waits so Claude sees phone-captured actuals alongside completed_rides. Tests: actual-wait SET/REMOVE shapes. web 50 / MCP 222 green. Co-Authored-By: Claude Opus 4.8 --- mcp/server_http.py | 3 ++ web/src/app/replan/ReplanControls.tsx | 59 ++++++++++++++++++++++++++- web/src/app/replan/actions.ts | 30 ++++++++++++++ web/src/app/replan/page.tsx | 1 + web/src/lib/dynamodb-writes.test.ts | 21 ++++++++++ web/src/lib/dynamodb-writes.ts | 43 +++++++++++++++++++ web/src/lib/dynamodb.ts | 4 ++ 7 files changed, 160 insertions(+), 1 deletion(-) diff --git a/mcp/server_http.py b/mcp/server_http.py index abbc7a9..96f7d1b 100644 --- a/mcp/server_http.py +++ b/mcp/server_http.py @@ -1878,6 +1878,9 @@ def get_plan_for_day(date: str | None = None) -> dict[str, Any]: # Lane on (set via set_held_ll). Those rides' predicted waits are # LL returns, not standby — don't treat their standby as a signal. "held_lls": chosen.get("ll_holds", {}), + # {ride_id: actual wait min} captured from the phone on Mark done — + # calibration signal (predicted vs actual) for done-via-web rides. + "actual_waits": chosen.get("actual_waits", {}), "completed_rides": chosen.get("completed_rides", []), "dropped_rides": chosen.get("dropped_rides", []), "show_selections": chosen.get("show_selections", []), diff --git a/web/src/app/replan/ReplanControls.tsx b/web/src/app/replan/ReplanControls.tsx index 2bd73f0..335793d 100644 --- a/web/src/app/replan/ReplanControls.tsx +++ b/web/src/app/replan/ReplanControls.tsx @@ -9,7 +9,13 @@ import { useState, useTransition } from "react"; -import { applyDone, applyDrop, applyNextUp, type ReplanResult } from "./actions"; +import { + applyActualWait, + applyDone, + applyDrop, + applyNextUp, + type ReplanResult, +} from "./actions"; export default function ReplanControls({ planId, @@ -18,6 +24,7 @@ export default function ReplanControls({ initiallyDropped, initiallyNext, initiallyDone, + initialActual, emphasize, }: { planId: string; @@ -26,6 +33,7 @@ export default function ReplanControls({ initiallyDropped: boolean; initiallyNext: boolean; initiallyDone: boolean; + initialActual: number | null; /** Which action to lead with for this ride. */ emphasize: "drop" | "next"; }) { @@ -69,6 +77,11 @@ export default function ReplanControls({ Done ✓ + @@ -157,6 +170,50 @@ export default function ReplanControls({ ); } +/** Optional "actual wait" capture on a done ride — calibration data. + * Blank is fine; saves on blur. */ +function ActualWait({ + planId, + rideId, + initial, +}: { + planId: string; + rideId: string; + initial: number | null; +}) { + const [pending, start] = useTransition(); + const [saved, setSaved] = useState(initial !== null); + const [err, setErr] = useState(null); + + const save = (v: string) => { + setErr(null); + start(async () => { + const res = await applyActualWait(planId, rideId, v); + if (res.ok) setSaved(v.trim() !== ""); + else setErr(res.error ?? ""); + }); + }; + return ( + + actual + save(e.target.value)} + className="w-12 rounded border border-line bg-bg-0 px-1 py-0.5 text-center text-fg-0" + /> + min + {saved && } + {err && {err}} + + ); +} + function Row({ children }: { children: React.ReactNode }) { return
{children}
; } diff --git a/web/src/app/replan/actions.ts b/web/src/app/replan/actions.ts index 816056f..f76b006 100644 --- a/web/src/app/replan/actions.ts +++ b/web/src/app/replan/actions.ts @@ -19,6 +19,7 @@ import { setHeldLl, setPlanNextUp, setPlanOrder, + setRideActualWait, setRideDone, setRideDropped, } from "@/lib/dynamodb-writes"; @@ -218,6 +219,35 @@ export async function applyHeldLl( return { ok: true }; } +/** + * Record an OPTIONAL actual wait (minutes) for a ride, or clear it + * (empty). Never required — Mark done works without it; this just + * captures calibration data (predicted vs actual) when the user offers it. + */ +export async function applyActualWait( + planId: string, + rideId: string, + minutes: string, +): Promise { + const bad = await gate(planId, rideId); + if (bad) return bad; + let val: number | null = null; + if (minutes.trim() !== "") { + const n = Number(minutes); + if (!Number.isFinite(n) || n < 0 || n > 600) { + return { ok: false, error: "Enter minutes (0–600)." }; + } + val = Math.round(n); + } + try { + await setRideActualWait(planId, rideId, val); + } catch { + return { ok: false, error: "Couldn't save — try again." }; + } + revalidatePath("/replan"); + 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 694996b..9d1d62f 100644 --- a/web/src/app/replan/page.tsx +++ b/web/src/app/replan/page.tsx @@ -132,6 +132,7 @@ export default async function ReplanPage({ initiallyDropped={droppedSet.has(r.ride_id)} initiallyNext={isNext} initiallyDone={doneSet.has(r.ride_id)} + initialActual={ctx.actual_waits[r.ride_id] ?? null} emphasize={isAffected ? affectedEmphasis : "drop"} /> diff --git a/web/src/lib/dynamodb-writes.test.ts b/web/src/lib/dynamodb-writes.test.ts index 96ebca6..6f63b1f 100644 --- a/web/src/lib/dynamodb-writes.test.ts +++ b/web/src/lib/dynamodb-writes.test.ts @@ -238,3 +238,24 @@ describe("setHeldLl", () => { expect(sendMock.mock.calls[0][0].input.UpdateExpression).toBe("REMOVE ll_holds.#r"); }); }); + +describe("setRideActualWait", () => { + it("ensures the map then sets the ride's actual minutes", async () => { + sendMock.mockResolvedValue({}); + const { setRideActualWait } = await import("./dynamodb-writes"); + await setRideActualWait("p1", "tron", 45); + expect(sendMock).toHaveBeenCalledTimes(2); + const set = sendMock.mock.calls[1][0].input; + expect(set.UpdateExpression).toBe("SET actual_waits.#r = :m"); + expect(set.ExpressionAttributeNames["#r"]).toBe("tron"); + expect(set.ExpressionAttributeValues[":m"]).toBe(45); + }); + + it("REMOVEs when cleared (null)", async () => { + sendMock.mockResolvedValue({}); + const { setRideActualWait } = await import("./dynamodb-writes"); + await setRideActualWait("p1", "tron", null); + expect(sendMock).toHaveBeenCalledOnce(); + expect(sendMock.mock.calls[0][0].input.UpdateExpression).toBe("REMOVE actual_waits.#r"); + }); +}); diff --git a/web/src/lib/dynamodb-writes.ts b/web/src/lib/dynamodb-writes.ts index 9cd554e..9ac1a71 100644 --- a/web/src/lib/dynamodb-writes.ts +++ b/web/src/lib/dynamodb-writes.ts @@ -399,6 +399,49 @@ export async function setHeldLl( } } +/** + * Record (minutes) or clear (null) the ACTUAL wait for a ride, in the + * actual_waits map — the calibration signal (predicted vs actual). Atomic + * per-key map update, ll_holds-style; ensured first so the nested SET + * can't fail on a missing map. Paired with Mark done on the schedule. + */ +export async function setRideActualWait( + planId: string, + rideId: string, + minutes: number | null, +): Promise { + const Key = { PK: `USER#${SHARED_TRIP_USER}`, SK: `PLAN#${planId}` }; + if (minutes !== null) { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "SET actual_waits = if_not_exists(actual_waits, :empty)", + ExpressionAttributeValues: { ":empty": {} }, + ConditionExpression: "attribute_exists(PK)", + }), + ); + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "SET actual_waits.#r = :m", + ExpressionAttributeNames: { "#r": rideId }, + ExpressionAttributeValues: { ":m": minutes }, + }), + ); + } else { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key, + UpdateExpression: "REMOVE actual_waits.#r", + ExpressionAttributeNames: { "#r": rideId }, + }), + ); + } +} + // ─── "Ask Claude" daily rate limit (2026-07-03) ────────────────────── // // The /replan "Ask Claude" server action costs real Anthropic tokens, so diff --git a/web/src/lib/dynamodb.ts b/web/src/lib/dynamodb.ts index 2bd7670..5d807a0 100644 --- a/web/src/lib/dynamodb.ts +++ b/web/src/lib/dynamodb.ts @@ -349,6 +349,8 @@ export interface ReplanContext { held_lls: Record; /** ride_ids marked done from /replan. */ completed_ride_ids: string[]; + /** {ride_id: actual wait minutes} captured on Mark done (optional). */ + actual_waits: Record; } /** @@ -370,6 +372,7 @@ export async function getReplanContext( | (Omit & { dropped_ride_ids?: Set | string[]; completed_ride_ids?: Set | string[]; + actual_waits?: Record; next_up?: string; ll_holds?: Record; plan_order?: string[]; @@ -412,5 +415,6 @@ export async function getReplanContext( next_up: r.next_up ?? null, held_lls: r.ll_holds ?? {}, completed_ride_ids: [...(r.completed_ride_ids ?? [])], + actual_waits: r.actual_waits ?? {}, }; }