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
3 changes: 3 additions & 0 deletions mcp/server_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", []),
Expand Down
59 changes: 58 additions & 1 deletion web/src/app/replan/ReplanControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +24,7 @@ export default function ReplanControls({
initiallyDropped,
initiallyNext,
initiallyDone,
initialActual,
emphasize,
}: {
planId: string;
Expand All @@ -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";
}) {
Expand Down Expand Up @@ -69,6 +77,11 @@ export default function ReplanControls({
<span className="rounded-full bg-ok/15 px-3 py-1 text-xs font-medium text-ok">
Done ✓
</span>
<ActualWait
planId={planId}
rideId={rideId}
initial={initialActual}
/>
<TextBtn onClick={unDone} disabled={pending} label="Undo" />
<Err error={error} />
</Row>
Expand Down Expand Up @@ -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<string | null>(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 (
<span className="inline-flex items-center gap-1 text-xs text-fg-3">
actual
<input
type="number"
min={0}
max={600}
inputMode="numeric"
defaultValue={initial ?? ""}
placeholder="—"
disabled={pending}
onBlur={(e) => 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 && <span className="text-ok">✓</span>}
{err && <span className="text-warn">{err}</span>}
</span>
);
}

function Row({ children }: { children: React.ReactNode }) {
return <div className="flex flex-wrap items-center gap-2">{children}</div>;
}
Expand Down
30 changes: 30 additions & 0 deletions web/src/app/replan/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
setHeldLl,
setPlanNextUp,
setPlanOrder,
setRideActualWait,
setRideDone,
setRideDropped,
} from "@/lib/dynamodb-writes";
Expand Down Expand Up @@ -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<ReplanResult> {
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,
Expand Down
1 change: 1 addition & 0 deletions web/src/app/replan/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
/>
</div>
Expand Down
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 @@ -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");
});
});
43 changes: 43 additions & 0 deletions web/src/lib/dynamodb-writes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/dynamodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,8 @@ export interface ReplanContext {
held_lls: Record<string, string>;
/** ride_ids marked done from /replan. */
completed_ride_ids: string[];
/** {ride_id: actual wait minutes} captured on Mark done (optional). */
actual_waits: Record<string, number>;
}

/**
Expand All @@ -370,6 +372,7 @@ export async function getReplanContext(
| (Omit<PlanRow, "ride_sequence"> & {
dropped_ride_ids?: Set<string> | string[];
completed_ride_ids?: Set<string> | string[];
actual_waits?: Record<string, number>;
next_up?: string;
ll_holds?: Record<string, string>;
plan_order?: string[];
Expand Down Expand Up @@ -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 ?? {},
};
}
Loading