From fd480c6c2ff2950206d4b5266b42f2ff17c3bcc1 Mon Sep 17 00:00:00 2001 From: Megan Schott Date: Fri, 3 Jul 2026 15:31:03 -0500 Subject: [PATCH] feat(web): /replan "Ask Claude" holistic re-plan + Mark done per ride MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Claude-app "reevaluate my plan" experience onto /replan, plus a one-tap Mark done — so the whole loop works without the Claude app. - Ask Claude: a tap-only, family-gated, daily-capped (20/user) server action calls Sonnet with the remaining rides' current + predicted waits, held LLs (told to IGNORE standby for LL'd rides), and weather. Returns a re-SEQUENCED plan (order + drops + short reasons) or "no changes." Apply writes an atomic plan_order list (never touches ride_sequence) + drops; web + MCP reads present rides in that order. - Mark done: atomic completed_ride_ids set (mirrors dropped) — poller stops watching it, page shows "Done ✓", MCP drops it from the remaining sequence. (Claude's mark_ride_complete stays the calibration path.) - Infra: Anthropic key as an SSM SecureString (name in env, value never in repo) + a tight one-parameter GetParameter grant on the Amplify compute role. Rate-limit counter is a TTL'd USER# row. Cost posture: tap-only + family gate + 20/day cap → pennies/day at Sonnet. Tests: plan_order honored (MCP), setPlanOrder + rate-counter write shapes. web 46 / MCP 222 / poller 116 green. Co-Authored-By: Claude Opus 4.8 --- infra/lambda/poller/db.py | 8 +- infra/lib/disney-stack.ts | 20 +++ mcp/_tool_impls.py | 18 ++- mcp/tests/test_trip_planner.py | 26 ++++ web/package.json | 1 + web/pnpm-lock.yaml | 99 ++++++++------ web/src/app/replan/AskClaude.tsx | 125 ++++++++++++++++++ web/src/app/replan/ReplanControls.tsx | 37 +++++- web/src/app/replan/actions.ts | 137 +++++++++++++++++++- web/src/app/replan/page.tsx | 13 +- web/src/lib/claude-replan.ts | 177 ++++++++++++++++++++++++++ web/src/lib/dynamodb-writes.test.ts | 22 ++++ web/src/lib/dynamodb-writes.ts | 79 ++++++++++++ web/src/lib/dynamodb.ts | 41 +++++- 14 files changed, 746 insertions(+), 57 deletions(-) create mode 100644 web/src/app/replan/AskClaude.tsx create mode 100644 web/src/lib/claude-replan.ts diff --git a/infra/lambda/poller/db.py b/infra/lambda/poller/db.py index 7d58b70..c32eacc 100644 --- a/infra/lambda/poller/db.py +++ b/infra/lambda/poller/db.py @@ -594,10 +594,12 @@ def build_active_plan_ride_index( recipients = [user_id] + sorted( s for s in subscribers if s and s != user_id ) - # Rides dropped via the /replan approve flow leave the watch - # set (atomic dropped_ride_ids set — never mutates + # Rides dropped OR marked done via /replan leave the watch set + # (atomic dropped_ride_ids / completed_ride_ids — never mutate # ride_sequence, so the MCP planner's view is intact). - dropped = item.get("dropped_ride_ids") or set() + dropped = (item.get("dropped_ride_ids") or set()) | ( + item.get("completed_ride_ids") or set() + ) # Remaining planned rides with their at-plan-time predictions, # for the plan-drift check (current waits vs what the plan # assumed). Only rides carrying a numeric predicted_wait_min diff --git a/infra/lib/disney-stack.ts b/infra/lib/disney-stack.ts index c00ad86..94c75e8 100644 --- a/infra/lib/disney-stack.ts +++ b/infra/lib/disney-stack.ts @@ -23,6 +23,10 @@ import * as fs from "fs"; * (one-time `aws ssm put-parameter`) so secrets never live in CDK, * CloudFormation, or git. Same hygiene pattern as the earlier project's TMDB key. */ const PUSHOVER_APP_TOKEN_PARAM = "/disney/pushover/app_token"; +// Anthropic API key for the /replan "Ask Claude" suggestion (server-side +// Sonnet call). SecureString in SSM; only the NAME is in env, the value +// never leaves SSM. Populate the value out-of-band (not in the repo). +const ANTHROPIC_API_KEY_PARAM = "/magicmonitor/anthropic-api-key"; const PUSHOVER_USER_KEY_PARAM = "/disney/pushover/megan_user_key"; /** Park entity IDs from themeparks.wiki — duplicated here from the Lambda @@ -592,6 +596,9 @@ export class DisneyStack extends cdk.Stack { // PARAMETER NAME is in env — the secret value never leaves // SSM. Rotates without a redeploy. PUSHOVER_APP_TOKEN_PARAM, + // Anthropic key param name for the /replan "Ask Claude" call + // (value read from SSM at runtime via the grant below). + ANTHROPIC_API_KEY_PARAM, AMPLIFY_MONOREPO_APP_ROOT: "web", AMPLIFY_DIFF_DEPLOY: "false", // Avoid the "do you accept telemetry?" interactive prompt @@ -651,6 +658,7 @@ export class DisneyStack extends cdk.Stack { // NEXTAUTH_* are kept as v4-compat fallbacks. "echo \"DISNEY_TABLE_NAME=$DISNEY_TABLE_NAME\" >> .env.production", "echo \"PUSHOVER_APP_TOKEN_PARAM=$PUSHOVER_APP_TOKEN_PARAM\" >> .env.production", + "echo \"ANTHROPIC_API_KEY_PARAM=$ANTHROPIC_API_KEY_PARAM\" >> .env.production", "echo \"AUTH_URL=$NEXTAUTH_URL\" >> .env.production", "echo \"AUTH_SECRET=$NEXTAUTH_SECRET\" >> .env.production", "echo \"NEXTAUTH_URL=$NEXTAUTH_URL\" >> .env.production", @@ -807,6 +815,18 @@ export class DisneyStack extends cdk.Stack { }), ); + // SSM read for the Anthropic API key — used by the /replan + // "Ask Claude" server action (server-side Sonnet call). Same tight + // one-parameter scoping as the Pushover grant. + webApp.computeRole.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ["ssm:GetParameter"], + resources: [ + `arn:aws:ssm:${this.region}:${this.account}:parameter${ANTHROPIC_API_KEY_PARAM}`, + ], + }), + ); + // CloudWatch Logs permissions for the SSR compute role. Newer // alpha versions of @aws-cdk/aws-amplify-alpha don't auto-attach // these (the earlier project's deploy at v2.251.0 picked up an diff --git a/mcp/_tool_impls.py b/mcp/_tool_impls.py index d71f393..9355202 100644 --- a/mcp/_tool_impls.py +++ b/mcp/_tool_impls.py @@ -2706,11 +2706,23 @@ def split_dropped_rides( sequence — not a stale one that still lists a ride the family dropped. """ dropped_ids = set(plan.get("dropped_ride_ids") or []) + # Rides marked done from /replan also leave the remaining sequence + # (the plan's completed_rides list is the richer, calibration path). + done_ids = set(plan.get("completed_ride_ids") or []) seq = plan.get("ride_sequence") or [] - if not dropped_ids: - return list(seq), [] - still = [r for r in seq if r.get("ride_id") not in dropped_ids] + still = [ + r for r in seq + if r.get("ride_id") not in dropped_ids and r.get("ride_id") not in done_ids + ] dropped = [r for r in seq if r.get("ride_id") in dropped_ids] + # Honor a Claude-applied re-order (plan_order, set by the /replan + # "Ask Claude" apply): listed rides first, in that order; unlisted + # rides keep their original position after. Keeps the planner's view + # consistent with what the family sees on the page. + order = plan.get("plan_order") or [] + if order: + rank = {rid: i for i, rid in enumerate(order)} + still.sort(key=lambda r: rank.get(r.get("ride_id"), len(order) + 1)) return still, dropped diff --git a/mcp/tests/test_trip_planner.py b/mcp/tests/test_trip_planner.py index f2ab355..d01e226 100644 --- a/mcp/tests/test_trip_planner.py +++ b/mcp/tests/test_trip_planner.py @@ -785,3 +785,29 @@ def test_garbage_returns_none(self): import _tool_impls assert _tool_impls.parse_ll_time("later", "2026-07-03") is None assert _tool_impls.parse_ll_time("", "2026-07-03") is None + + +class TestPlanOrderHonored: + """split_dropped_rides applies a Claude-set plan_order so the MCP view + matches what the family reordered on /replan.""" + + def test_reorders_still_by_plan_order(self): + import _tool_impls + plan = { + "ride_sequence": [{"ride_id": "a"}, {"ride_id": "b"}, {"ride_id": "c"}], + "plan_order": ["c", "a"], + } + still, _ = _tool_impls.split_dropped_rides(plan) + # c, a first (in order), then b (unlisted) keeps trailing. + assert [r["ride_id"] for r in still] == ["c", "a", "b"] + + def test_order_plus_drop(self): + import _tool_impls + plan = { + "ride_sequence": [{"ride_id": "a"}, {"ride_id": "b"}, {"ride_id": "c"}], + "plan_order": ["c", "b", "a"], + "dropped_ride_ids": ["b"], + } + still, dropped = _tool_impls.split_dropped_rides(plan) + assert [r["ride_id"] for r in still] == ["c", "a"] # b dropped + assert [r["ride_id"] for r in dropped] == ["b"] diff --git a/web/package.json b/web/package.json index 81fadc0..540396a 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "@anthropic-ai/sdk": "^0.110.0", "@aws-sdk/client-dynamodb": "^3.700.0", "@aws-sdk/client-ssm": "^3.1041.0", "@aws-sdk/lib-dynamodb": "^3.700.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 20ba11b..05fadf2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@anthropic-ai/sdk': + specifier: ^0.110.0 + version: 0.110.0(zod@4.4.2) '@aws-sdk/client-dynamodb': specifier: ^3.700.0 version: 3.1041.0 @@ -70,6 +73,15 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@anthropic-ai/sdk@0.110.0': + resolution: {integrity: sha512-hOP4bNYXDFHDxxiEgzlILXrxZIYCDnhe8sry0RDRKD/QnsEpvZcQpablCdm9X/WuD/YgOiSIkkqsL1mLLlTqJw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@auth/core@0.41.2': resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} peerDependencies: @@ -292,6 +304,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -401,105 +417,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -572,28 +572,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@16.2.4': resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@16.2.4': resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@16.2.4': resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@16.2.4': resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} @@ -667,42 +663,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.2': resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} @@ -905,6 +895,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -949,28 +942,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -1130,49 +1119,41 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1635,6 +1616,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-xml-builder@1.1.5: resolution: {integrity: sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==} @@ -1930,6 +1914,10 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1998,28 +1986,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2388,6 +2372,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} @@ -2476,6 +2463,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -2673,6 +2663,13 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@anthropic-ai/sdk@0.110.0(zod@4.4.2)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 4.4.2 + '@auth/core@0.41.2': dependencies: '@panva/hkdf': 1.2.1 @@ -3202,6 +3199,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3811,6 +3810,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -4683,6 +4684,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} + fast-xml-builder@1.1.5: dependencies: path-expression-matcher: 1.5.0 @@ -4979,6 +4982,11 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -5467,6 +5475,11 @@ snapshots: stackback@0.0.2: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + std-env@4.1.0: {} stop-iteration-iterator@1.1.0: @@ -5562,6 +5575,8 @@ snapshots: dependencies: is-number: 7.0.0 + ts-algebra@2.0.0: {} + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 diff --git a/web/src/app/replan/AskClaude.tsx b/web/src/app/replan/AskClaude.tsx new file mode 100644 index 0000000..8a72656 --- /dev/null +++ b/web/src/app/replan/AskClaude.tsx @@ -0,0 +1,125 @@ +"use client"; + +/** + * "Ask Claude" on /replan — a holistic re-plan suggestion (or "no changes + * needed"), the same thing you'd get chatting with Claude, brought onto + * the page. Tap → server-side Sonnet call → render its take, with an + * Approve button per proposed change that applies it via the existing + * atomic actions. Nothing changes until you approve. + */ + +import { useState, useTransition } from "react"; + +import { applyReplanOrder, askClaudeReplan, type AskClaudeResult } from "./actions"; +import type { ReplanSuggestion } from "@/lib/claude-replan"; + +export default function AskClaude({ + planId, + trigger, + rideNames, +}: { + planId: string; + trigger?: string | null; + /** ride_id → name, so the suggestion can label rides. */ + rideNames: Record; +}) { + const [pending, start] = useTransition(); + const [result, setResult] = useState(null); + + const ask = () => { + setResult(null); + start(async () => setResult(await askClaudeReplan(planId, trigger))); + }; + + return ( +
+
+
+

Not sure what to do?

+

+ Ask Claude for a take on the whole day. +

+
+ +
+ + {result && !result.ok && ( +

{result.error}

+ )} + {result && result.ok && ( + + )} +
+ ); +} + +function Suggestion({ + planId, + suggestion, + rideNames, +}: { + planId: string; + suggestion: ReplanSuggestion; + rideNames: Record; +}) { + const [pending, start] = useTransition(); + const [applied, setApplied] = useState(false); + const [error, setError] = useState(null); + const name = (id: string) => rideNames[id] ?? "(ride)"; + + const apply = () => { + setError(null); + start(async () => { + const res = await applyReplanOrder(planId, suggestion.order, suggestion.drop); + if (res.ok) setApplied(true); + else setError(res.error ?? "Couldn't apply."); + }); + }; + + return ( +
+

{suggestion.summary}

+ +
    + {suggestion.order.map((id, i) => ( +
  1. + {i + 1}. + {name(id)} + {suggestion.reasons[id] && ( + — {suggestion.reasons[id]} + )} +
  2. + ))} +
+ + {suggestion.drop.length > 0 && ( +

+ Drop: {suggestion.drop.map(name).join(", ")} +

+ )} + +
+ {applied ? ( + Applied ✓ — this is your order now. + ) : ( + + )} + {error && {error}} +
+
+ ); +} diff --git a/web/src/app/replan/ReplanControls.tsx b/web/src/app/replan/ReplanControls.tsx index 937d937..2bd73f0 100644 --- a/web/src/app/replan/ReplanControls.tsx +++ b/web/src/app/replan/ReplanControls.tsx @@ -9,7 +9,7 @@ import { useState, useTransition } from "react"; -import { applyDrop, applyNextUp, type ReplanResult } from "./actions"; +import { applyDone, applyDrop, applyNextUp, type ReplanResult } from "./actions"; export default function ReplanControls({ planId, @@ -17,6 +17,7 @@ export default function ReplanControls({ rideName, initiallyDropped, initiallyNext, + initiallyDone, emphasize, }: { planId: string; @@ -24,12 +25,14 @@ export default function ReplanControls({ rideName: string; initiallyDropped: boolean; initiallyNext: boolean; + initiallyDone: boolean; /** Which action to lead with for this ride. */ emphasize: "drop" | "next"; }) { const [pending, startTransition] = useTransition(); const [dropped, setDropped] = useState(initiallyDropped); const [isNext, setIsNext] = useState(initiallyNext); + const [done, setDone] = useState(initiallyDone); const [error, setError] = useState(null); const act = (fn: () => Promise, onOk: () => void) => { @@ -53,6 +56,24 @@ export default function ReplanControls({ setDropped(false); }); const clearNext = () => act(() => applyNextUp(planId, rideId, false), () => setIsNext(false)); + const markDone = () => + act(() => applyDone(planId, rideId, true), () => { + setDone(true); + setIsNext(false); + }); + const unDone = () => act(() => applyDone(planId, rideId, false), () => setDone(false)); + + if (done) { + return ( + + + Done ✓ + + + + + ); + } if (dropped) { return ( @@ -105,7 +126,18 @@ export default function ReplanControls({ ); - // Lead with the emphasized action. + const doneBtn = ( + + ); + + // Lead with the emphasized action; Done is always available. return ( {emphasize === "next" ? ( @@ -119,6 +151,7 @@ export default function ReplanControls({ {nextBtn} )} + {doneBtn} ); diff --git a/web/src/app/replan/actions.ts b/web/src/app/replan/actions.ts index 6e77f3c..6facb34 100644 --- a/web/src/app/replan/actions.ts +++ b/web/src/app/replan/actions.ts @@ -13,9 +13,20 @@ import { revalidatePath } from "next/cache"; import { auth } from "@/auth"; -import { setPlanNextUp, setRideDropped } from "@/lib/dynamodb-writes"; +import { getParkRides, getReplanContext } from "@/lib/dynamodb"; +import { + bumpReplanLlmCount, + setPlanNextUp, + setPlanOrder, + setRideDone, + setRideDropped, +} from "@/lib/dynamodb-writes"; +import { proposeReplan, type ReplanSuggestion } from "@/lib/claude-replan"; +import { getCurrentConditions } from "@/lib/weather"; import { isTripsAllowed } from "@/lib/trips-access"; +const ASK_CLAUDE_DAILY_CAP = 20; + export interface ReplanResult { ok: boolean; error?: string; @@ -53,6 +64,130 @@ export async function applyDrop( return { ok: true }; } +export type AskClaudeResult = + | { ok: true; suggestion: ReplanSuggestion } + | { ok: false; error: string }; + +/** + * "Ask Claude" — a server-side Sonnet call that returns a holistic + * re-plan suggestion (or "no changes needed") for the day's plan. Costs + * real tokens, so: family-gated + a per-user daily cap. Tap-only (there's + * no automatic caller). + */ +export async function askClaudeReplan( + planId: string, + trigger?: string | null, +): Promise { + const session = await auth(); + const sub = session?.user?.id; + if (!sub) return { ok: false, error: "Not signed in." }; + if (!isTripsAllowed(session.user?.email)) { + return { ok: false, error: "Family accounts only." }; + } + + const today = new Date().toLocaleDateString("en-CA", { + timeZone: "America/New_York", + }); + try { + const count = await bumpReplanLlmCount(sub, today); + if (count > ASK_CLAUDE_DAILY_CAP) { + return { + ok: false, + error: `Daily limit reached (${ASK_CLAUDE_DAILY_CAP} Ask-Claude checks). Try again tomorrow.`, + }; + } + + const ctx = await getReplanContext(planId); + if (!ctx) return { ok: false, error: "Plan not found." }; + + const [state, weather] = await Promise.all([ + getParkRides(ctx.park_key), + getCurrentConditions(), + ]); + const byId = new Map(state.map((r) => [r.ride_id, r])); + const dropped = new Set(ctx.dropped_ride_ids); + const rides = ctx.rides + .filter((r) => !dropped.has(r.ride_id)) + .map((r) => { + const live = byId.get(r.ride_id); + return { + ride_id: r.ride_id, + ride_name: r.ride_name, + predicted_wait_min: r.predicted_wait_min, + current_wait: live?.wait_mins ?? null, + status: live?.status ?? "UNKNOWN", + held_ll: ctx.held_lls[r.ride_id] ?? null, + }; + }); + + const suggestion = await proposeReplan({ + park_name: ctx.park_name, + date: ctx.date, + weather: weather ? `${weather.condition}, ${weather.temp_f}°` : null, + trigger: trigger ?? null, + rides, + }); + return { ok: true, suggestion }; + } catch (err) { + console.warn("[replan/ask] failed:", err); + return { ok: false, error: "Couldn't reach Claude — try again." }; + } +} + +/** + * Apply a Claude-suggested re-plan: set the new ride order + drop the + * rides it flagged. Both are atomic (plan_order SET, dropped_ride_ids + * ADD). Family-gated; the suggestion itself was already produced behind + * the daily cap, so this apply is free. + */ +export async function applyReplanOrder( + planId: string, + order: string[], + drop: string[], +): Promise { + 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); + if (!planId || clean.length === 0) { + return { ok: false, error: "Nothing to apply." }; + } + try { + await setPlanOrder(planId, clean); + await Promise.all( + (drop ?? []) + .filter((s) => typeof s === "string") + .slice(0, 50) + .map((id) => setRideDropped(planId, id, true)), + ); + } catch { + return { ok: false, error: "Couldn't apply — 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, + rideId: string, + done: boolean, +): Promise { + const bad = await gate(planId, rideId); + if (bad) return bad; + try { + await setRideDone(planId, rideId, done); + } catch { + return { ok: false, error: "Couldn't update — try again." }; + } + revalidatePath("/replan"); + revalidatePath("/trips"); + return { ok: true }; +} + /** Mark a ride "do next" (on=true) or clear the plan's next_up (on=false). */ export async function applyNextUp( planId: string, diff --git a/web/src/app/replan/page.tsx b/web/src/app/replan/page.tsx index 4ef54d4..71278b9 100644 --- a/web/src/app/replan/page.tsx +++ b/web/src/app/replan/page.tsx @@ -17,6 +17,7 @@ import { isTripsAllowed } from "@/lib/trips-access"; import { FamilyOnly } from "@/components/auth/FamilyOnly"; import ReplanControls from "./ReplanControls"; +import AskClaude from "./AskClaude"; export const dynamic = "force-dynamic"; @@ -55,6 +56,7 @@ export default async function ReplanPage({ const affected = ctx.rides.find((r) => r.ride_id === rideId); const droppedSet = new Set(ctx.dropped_ride_ids); + const doneSet = new Set(ctx.completed_ride_ids); // "down" alerts lead with Drop; everything else (short wait, earlier // LL, back-up) leads with Do next. The affected ride follows the alert // kind; other rides default to Drop-lead. @@ -81,7 +83,15 @@ export default async function ReplanPage({

{heading}

{lede}

-
+
+ [r.ride_id, r.ride_name]))} + /> +
+ +
{ctx.rides.map((r, i) => { const isAffected = r.ride_id === rideId; const isNext = ctx.next_up === r.ride_id; @@ -108,6 +118,7 @@ export default async function ReplanPage({ rideName={r.ride_name} initiallyDropped={droppedSet.has(r.ride_id)} initiallyNext={isNext} + initiallyDone={doneSet.has(r.ride_id)} emphasize={isAffected ? affectedEmphasis : "drop"} />
diff --git a/web/src/lib/claude-replan.ts b/web/src/lib/claude-replan.ts new file mode 100644 index 0000000..66b3954 --- /dev/null +++ b/web/src/lib/claude-replan.ts @@ -0,0 +1,177 @@ +/** + * Server-only Claude client for the /replan "Ask Claude" suggestion. + * + * Calls Sonnet with the live plan context and forces a structured + * response (a single tool) so we get clean JSON — either "no changes" or + * a short list of proposed changes. The API key is loaded from SSM at + * runtime (same pattern as pushover.ts); only the param NAME is in env. + * + * Cost/abuse posture: this is only ever reached from a TAP on /replan, + * behind the family gate + a per-user daily cap (see the server action). + * A single call is a few cents at Sonnet. + */ +import "server-only"; + +import Anthropic from "@anthropic-ai/sdk"; +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; + +const region = process.env.DISNEY_REGION ?? "us-east-2"; +const MODEL = "claude-sonnet-4-6"; + +declare global { + // eslint-disable-next-line no-var + var __ssmClientReplan: SSMClient | undefined; + // eslint-disable-next-line no-var + var __anthropicKey: string | undefined; +} + +const ssm = globalThis.__ssmClientReplan ?? new SSMClient({ region }); +if (process.env.NODE_ENV !== "production") globalThis.__ssmClientReplan = ssm; + +async function getKey(): Promise { + if (globalThis.__anthropicKey) return globalThis.__anthropicKey; + const name = process.env.ANTHROPIC_API_KEY_PARAM; + if (!name) throw new Error("ANTHROPIC_API_KEY_PARAM unset."); + const resp = await ssm.send( + new GetParameterCommand({ Name: name, WithDecryption: true }), + ); + const key = resp.Parameter?.Value; + if (!key) throw new Error(`SSM ${name} returned no value.`); + globalThis.__anthropicKey = key; + return key; +} + +export interface ReplanRideInput { + ride_id: string; + ride_name: string; + predicted_wait_min: number | null; + current_wait: number | null; + status: string; + held_ll: string | null; // ISO, if the party holds an LL for it +} + +export interface ReplanSuggestion { + /** True when the current order is already good — order/drop echo it. */ + no_change: boolean; + summary: string; + /** Remaining rides in the suggested order (ride_ids), best next first. */ + order: string[]; + /** ride_ids to drop entirely (down / not worth it). */ + drop: string[]; + /** Optional short note per ride_id explaining a move/drop. */ + reasons: Record; +} + +const TOOL = { + name: "propose_replan", + description: + "Re-evaluate the remaining plan: return the suggested ORDER of the " + + "remaining rides (best next first) and any to drop. Reorder only — do " + + "not invent rides that aren't in the list.", + input_schema: { + type: "object" as const, + properties: { + no_change: { + type: "boolean", + description: + "True if the current order is already good (still return it in `order`).", + }, + summary: { + type: "string", + description: + "One or two plain sentences the family reads at a glance — the gist of the re-plan.", + }, + order: { + type: "array", + items: { type: "string" }, + description: + "ALL remaining (non-dropped) ride_ids, in the suggested order, best next first.", + }, + drop: { + type: "array", + items: { type: "string" }, + description: "ride_ids to drop (down or not worth the time).", + }, + reasons: { + type: "object", + description: + "Optional map of ride_id → short reason for a move or drop.", + additionalProperties: { type: "string" }, + }, + }, + required: ["no_change", "summary", "order", "drop"], + }, +}; + +export async function proposeReplan(input: { + park_name: string; + date: string; + weather: string | null; + trigger: string | null; + rides: ReplanRideInput[]; +}): Promise { + const client = new Anthropic({ apiKey: await getKey() }); + + const rideLines = input.rides + .map((r) => { + const bits = [ + `${r.ride_name}`, + `now ${r.status === "DOWN" ? "DOWN" : r.current_wait ?? "?"}`, + r.predicted_wait_min != null ? `planned ~${r.predicted_wait_min}m` : null, + r.held_ll ? `HELD LL (ignore standby)` : null, + ].filter(Boolean); + return `- ${bits.join(", ")}`; + }) + .join("\n"); + + const msg = await client.messages.create({ + model: MODEL, + max_tokens: 1024, + tools: [TOOL], + tool_choice: { type: "tool", name: "propose_replan" }, + system: + "You re-evaluate a family's Walt Disney World ride plan in real time, " + + "the way you would if they pasted the alert into a chat. Re-sequence the " + + "REMAINING rides so the best next ride is first — favor rides that are " + + "unusually short now or that a coming storm threatens (do indoor first). " + + "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.", + 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` + + `Remaining planned rides (current wait vs planned):\n${rideLines}\n\n` + + `Propose changes or confirm no changes are needed.`, + }, + ], + }); + + 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: {} }; + } + const out = block.input as ReplanSuggestion; + const ids = new Set(input.rides.map((r) => r.ride_id)); + 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. + 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); + } + return { + no_change: Boolean(out.no_change), + summary: out.summary ?? "", + order, + drop, + reasons: out.reasons ?? {}, + }; +} diff --git a/web/src/lib/dynamodb-writes.test.ts b/web/src/lib/dynamodb-writes.test.ts index 1e3530a..482d0a4 100644 --- a/web/src/lib/dynamodb-writes.test.ts +++ b/web/src/lib/dynamodb-writes.test.ts @@ -192,3 +192,25 @@ describe("setPlanNextUp", () => { expect(input.ExpressionAttributeValues).toBeUndefined(); }); }); + +describe("setPlanOrder + bumpReplanLlmCount", () => { + it("setPlanOrder atomically SETs the plan_order list", async () => { + sendMock.mockResolvedValue({}); + const { setPlanOrder } = await import("./dynamodb-writes"); + await setPlanOrder("p1", ["c", "a", "b"]); + const input = sendMock.mock.calls[0][0].input; + expect(input.UpdateExpression).toBe("SET plan_order = :o"); + expect(input.ExpressionAttributeValues[":o"]).toEqual(["c", "a", "b"]); + expect(input.ConditionExpression).toBe("attribute_exists(PK)"); + }); + + it("bumpReplanLlmCount ADDs to a dated counter and returns it", async () => { + sendMock.mockResolvedValue({ Attributes: { count: 3 } }); + const { bumpReplanLlmCount } = await import("./dynamodb-writes"); + const n = await bumpReplanLlmCount("sub-1", "2026-07-03"); + expect(n).toBe(3); + const input = sendMock.mock.calls[0][0].input; + expect(input.Key).toEqual({ PK: "USER#sub-1", SK: "REPLAN_LLM#2026-07-03" }); + expect(input.UpdateExpression).toContain("ADD #c :one"); + }); +}); diff --git a/web/src/lib/dynamodb-writes.ts b/web/src/lib/dynamodb-writes.ts index d75c2de..d559672 100644 --- a/web/src/lib/dynamodb-writes.ts +++ b/web/src/lib/dynamodb-writes.ts @@ -307,6 +307,32 @@ export async function setRideDropped( ); } +/** + * Mark a ride done (done=true) or un-done (false) for a shared plan, via + * atomic ADD/DELETE on completed_ride_ids. Like dropped_ride_ids: no + * ride_sequence surgery, so it can't race a plan edit. The poller stops + * watching it and the page shows it done. (Claude's mark_ride_complete is + * the richer path — it also captures actual wait for calibration — but a + * one-tap "I rode it" from the phone doesn't need that.) + */ +export async function setRideDone( + planId: string, + rideId: string, + done: boolean, +): Promise { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key: { PK: `USER#${SHARED_TRIP_USER}`, SK: `PLAN#${planId}` }, + UpdateExpression: done + ? "ADD completed_ride_ids :r" + : "DELETE completed_ride_ids :r", + ExpressionAttributeValues: { ":r": new Set([rideId]) }, + ConditionExpression: "attribute_exists(PK)", + }), + ); +} + /** * Mark a ride as "do next" for a shared plan (rideId), or clear it * (null). A single scalar next_up set atomically (SET/REMOVE) — no @@ -329,6 +355,59 @@ export async function setPlanNextUp( ); } +// ─── "Ask Claude" daily rate limit (2026-07-03) ────────────────────── +// +// The /replan "Ask Claude" server action costs real Anthropic tokens, so +// it's capped per user per day. Atomic ADD on a dated counter row (TTL'd +// to auto-expire) — returns the post-increment count so the caller can +// reject once over the cap. Row: USER#/REPLAN_LLM#. + +/** Increment + return today's Ask-Claude call count for a user. */ +export async function bumpReplanLlmCount( + sub: string, + dayIso: string, +): Promise { + const resp = await client.send( + new UpdateCommand({ + TableName: tableName, + Key: { PK: `USER#${sub}`, SK: `REPLAN_LLM#${dayIso}` }, + UpdateExpression: + "SET #ttl = if_not_exists(#ttl, :ttl) ADD #c :one", + ExpressionAttributeNames: { "#ttl": "ttl", "#c": "count" }, + ExpressionAttributeValues: { + ":one": 1, + // ~2 days out; the row only needs to survive the current day. + ":ttl": Math.floor(Date.now() / 1000) + 172800, + }, + ReturnValues: "UPDATED_NEW", + }), + ); + return Number(resp.Attributes?.count ?? 1); +} + +/** + * 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 + * (plan_order) — overwrites wholesale, never touches ride_sequence, so it + * can't corrupt the planner's list or race a concurrent edit. Reads + * present rides in plan_order when present; a dropped ride just won't + * appear even if still listed. + */ +export async function setPlanOrder( + planId: string, + order: string[], +): Promise { + await client.send( + new UpdateCommand({ + TableName: tableName, + Key: { PK: `USER#${SHARED_TRIP_USER}`, SK: `PLAN#${planId}` }, + UpdateExpression: "SET plan_order = :o", + ExpressionAttributeValues: { ":o": order }, + ConditionExpression: "attribute_exists(PK)", + }), + ); +} + // ─── Favorite rides (M3 Phase 2) ───────────────────────────────────── // // Schema: USER# / FAV_RIDE# with denormalized park_key. diff --git a/web/src/lib/dynamodb.ts b/web/src/lib/dynamodb.ts index 48af09e..2bd7670 100644 --- a/web/src/lib/dynamodb.ts +++ b/web/src/lib/dynamodb.ts @@ -340,11 +340,15 @@ export interface ReplanContext { active: boolean; outcome_recorded: boolean; /** Rides still in the sequence (not dropped, not completed). */ - rides: { ride_name: string; ride_id: string }[]; + rides: { ride_name: string; ride_id: string; predicted_wait_min: number | null }[]; /** ride_ids already dropped via the /replan approve flow. */ dropped_ride_ids: string[]; /** ride_id the family marked "do next" (or null). */ next_up: string | null; + /** {ride_id: LL return ISO} the party holds a Lightning Lane on. */ + held_lls: Record; + /** ride_ids marked done from /replan. */ + completed_ride_ids: string[]; } /** @@ -363,12 +367,39 @@ export async function getReplanContext( }), ); const r = resp.Item as - | (PlanRow & { + | (Omit & { dropped_ride_ids?: Set | string[]; + completed_ride_ids?: Set | string[]; next_up?: string; + ll_holds?: Record; + plan_order?: string[]; + ride_sequence?: { + ride_name?: string; + ride_id?: string; + predicted_wait_min?: number; + }[]; }) | undefined; if (!r) return null; + const rides = (r.ride_sequence ?? []) + .filter((rd) => rd.ride_id) + .map((rd) => ({ + ride_name: rd.ride_name ?? "(unnamed)", + ride_id: rd.ride_id!, + predicted_wait_min: + typeof rd.predicted_wait_min === "number" ? rd.predicted_wait_min : null, + })); + // Honor a Claude-applied order (plan_order): rides listed there first, + // in that order; anything not listed keeps its original position after. + const order = r.plan_order ?? []; + if (order.length > 0) { + const rank = new Map(order.map((id, i) => [id, i])); + rides.sort( + (a, b) => + (rank.get(a.ride_id) ?? Number.MAX_SAFE_INTEGER) - + (rank.get(b.ride_id) ?? Number.MAX_SAFE_INTEGER), + ); + } return { plan_id: planId, date: r.planned_for_date ?? "", @@ -376,10 +407,10 @@ export async function getReplanContext( park_name: findPark(r.park_key ?? "magic_kingdom")?.name ?? (r.park_key ?? ""), active: Boolean(r.active), outcome_recorded: Boolean(r.outcome_recorded), - rides: (r.ride_sequence ?? []) - .filter((rd) => rd.ride_id) - .map((rd) => ({ ride_name: rd.ride_name ?? "(unnamed)", ride_id: rd.ride_id! })), + rides, dropped_ride_ids: [...(r.dropped_ride_ids ?? [])], next_up: r.next_up ?? null, + held_lls: r.ll_holds ?? {}, + completed_ride_ids: [...(r.completed_ride_ids ?? [])], }; }