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 ?? {},
};
}