From 1cc48c5f0296b905294b41cec2d3bd3f9513acca Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 16:14:44 +0000 Subject: [PATCH 1/3] feat(schedule): expandable stops with map-from-my-location waypoints Restore in-park walking navigation: tap a schedule stop to expand it, then open Google Maps walking directions from the device location to that exhibit/ride. Keep previous-stop walking links, qualify map destinations with venue context, and expose map_destination on schedule items for reliable geocoding. Co-authored-by: jssturm --- app/engine/planner.py | 55 ++++++++++++-- app/schemas/itinerary.py | 5 ++ static/css/app.css | 75 +++++++++++++++++- static/js/app.js | 160 ++++++++++++++++++++++++++++++++++++--- static/js/i18n.js | 16 ++++ test_waypoint_maps.py | 28 +++++++ 6 files changed, 318 insertions(+), 21 deletions(-) create mode 100644 test_waypoint_maps.py diff --git a/app/engine/planner.py b/app/engine/planner.py index a13f5b9..aadd6c8 100644 --- a/app/engine/planner.py +++ b/app/engine/planner.py @@ -200,6 +200,7 @@ def build_travel_plan( plan = { "venue_type": venue.get("venue_type", "general"), + "venue_name": intent.get("venue", ""), "departure_time": final_departure, "route": route, "schedule": schedule, @@ -944,10 +945,50 @@ def _looks_international(origin: str, destination: str) -> bool: """ combined = f"{origin} {destination}".lower() return any(signal in combined for signal in _INTERNATIONAL_SIGNALS) + + +# --------------------------------------------------------------------------- # Schedule construction # --------------------------------------------------------------------------- +def _venue_label(intent: dict[str, str]) -> str: + """Human-readable venue + location for Maps queries.""" + venue = (intent.get("venue") or "").strip() + loc = (intent.get("location") or "").strip() + if loc and loc.lower() not in venue.lower(): + return f"{venue} {loc}".strip() + return venue + + +def _attraction_short_name(attraction: str) -> str: + """Strip tip suffixes like 'Ride — tip text' down to the place name.""" + return attraction.split(" — ")[0].strip() + + +def _map_destination(place: str, venue: str) -> str: + """Qualify a place name with venue context for reliable geocoding.""" + place = (place or "").strip() + venue = (venue or "").strip() + if not place: + return venue + if venue and venue.lower() not in place.lower(): + return f"{place}, {venue}" + return place + + +def _walking_directions_url(origin_place: str, dest_place: str, venue: str = "") -> str: + """Build a Google Maps walking-directions URL between two in-venue places.""" + origin = _map_destination(origin_place, venue) + dest = _map_destination(dest_place, venue) + return ( + "https://www.google.com/maps/dir/?api=1" + f"&origin={urllib.parse.quote(origin, safe='')}" + f"&destination={urllib.parse.quote(dest, safe='')}" + "&travelmode=walking" + ) + + def _build_schedule( intent: dict[str, str], venue: dict, @@ -1012,6 +1053,7 @@ def _build_schedule( "meal_timing_note": None, "reminder_min": None, "walking_map_url": None, + "map_destination": _venue_label(intent) or None, "backup_plan": None, }) @@ -1026,6 +1068,7 @@ def _build_schedule( "meal_timing_note": None, "reminder_min": None, "walking_map_url": None, + "map_destination": _map_destination("entrance", _venue_label(intent)) or None, "backup_plan": None, }) @@ -1071,15 +1114,16 @@ def _build_schedule( priority = "low" is_lunch_slot = current_h in (11, 12, 13) + attr_short = _attraction_short_name(attraction) + venue_label = _venue_label(intent) action_text = f"Visit {attraction}" if tips and i == 0: action_text += f" — tip: {tips[0][:80]}" maps_url = None - if i > 0 and i - 1 < len(attractions): - prev = urllib.parse.quote(attractions[i - 1].split(" — ")[0], safe="") - curr = urllib.parse.quote(attraction.split(" — ")[0], safe="") - maps_url = f"https://www.google.com/maps/dir/?api=1&origin={prev}&destination={curr}&travelmode=walking" + if i > 0: + prev_short = _attraction_short_name(attractions[i - 1]) + maps_url = _walking_directions_url(prev_short, attr_short, venue_label) schedule.append({ "time": _fmt_time(current_h, current_m), @@ -1091,7 +1135,8 @@ def _build_schedule( "meal_timing_note": "Beat the lunch crowd — dine early or after 1 PM" if is_lunch_slot else None, "reminder_min": None, "walking_map_url": maps_url, - "backup_plan": f"If {attraction.split(' — ')[0]} is closed or too crowded, explore nearby exhibits instead", + "map_destination": _map_destination(attr_short, venue_label), + "backup_plan": f"If {attr_short} is closed or too crowded, explore nearby exhibits instead", }) # ========================================================================= diff --git a/app/schemas/itinerary.py b/app/schemas/itinerary.py index 72fb628..0ed698e 100644 --- a/app/schemas/itinerary.py +++ b/app/schemas/itinerary.py @@ -86,6 +86,11 @@ class ScheduleItem(BaseModel): description="Google Maps walking directions URL from the previous schedule stop " "to this one within the venue (e.g. 'https://www.google.com/maps/dir/?api=1&origin=Congo+River+Rapids&destination=Kumba&travelmode=walking')", ) + map_destination: Optional[str] = Field( + default=None, + description="Venue-qualified place name for this stop (e.g. 'Kumba, Busch Gardens Tampa'). " + "Used by the UI to open walking directions from the user's current location.", + ) backup_plan: Optional[str] = Field( default=None, description="Fallback if this activity is closed, too crowded, or weather-impacted", diff --git a/static/css/app.css b/static/css/app.css index 6f5f447..12e1640 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -825,6 +825,74 @@ ul, ol { padding-bottom: 0; } +.timeline-item-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); + width: 100%; + padding: var(--space-2) var(--space-2) var(--space-2) 0; + margin: 0; + border: none; + background: transparent; + text-align: left; + cursor: pointer; + color: inherit; + border-radius: var(--radius-md, 8px); + transition: background var(--transition-fast); +} + +.timeline-item-header:hover, +.timeline-item-header:focus-visible { + background: var(--color-bg-secondary, rgba(0, 0, 0, 0.04)); + outline: none; +} + +.timeline-item-header-main { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.timeline-expand-icon { + flex-shrink: 0; + font-size: 0.7rem; + opacity: 0.55; + transition: transform var(--transition-fast); + margin-top: 0.35rem; +} + +.timeline-item.is-expanded .timeline-expand-icon { + transform: rotate(180deg); +} + +.timeline-item-body { + padding: var(--space-2) 0 var(--space-1); + animation: schedule-expand 160ms ease-out; +} + +.timeline-item-body[hidden] { + display: none; +} + +@keyframes schedule-expand { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.schedule-map-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin: var(--space-3) 0; +} + +.schedule-map-actions .btn { + text-decoration: none; +} + .timeline-dot { position: absolute; left: calc(-1 * var(--space-8) + 7px); @@ -1598,15 +1666,14 @@ ul, ol { border-color: var(--color-text-muted); } -/* Editable schedule item */ +/* Editable schedule item — visible inside expanded stop body */ .schedule-item-actions { display: flex; gap: var(--space-1); - opacity: 0; - transition: opacity var(--transition-fast); + opacity: 1; } -.timeline-item:hover .schedule-item-actions { +.timeline-item.is-expanded .schedule-item-actions { opacity: 1; } diff --git a/static/js/app.js b/static/js/app.js index ded9d22..09d745a 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -739,6 +739,7 @@ return '
' + t("schedule.noItems") + '
'; } + const venueName = plan.venue_name || ""; const items = plan.schedule .map((item, idx) => { const prioClass = item.priority || "medium"; @@ -758,11 +759,6 @@ '' + t("badge.reminder", { min: item.reminder_min }) + '' ); } - if (item.walking_map_url) { - metaBadges.push( - '' + t("schedule.walkToHere") + '' - ); - } if (item.restaurant) { metaBadges.push( `🍴 ${escapeHtml(item.restaurant)}` @@ -792,21 +788,49 @@ var reminderSelect = ''; + var mapDest = resolveMapDestination(item, venueName); + var mapActions = ""; + if (mapDest || item.walking_map_url) { + mapActions = '
'; + if (mapDest) { + mapActions += + '"; + } + if (item.walking_map_url) { + mapActions += + '' + + t("schedule.walkFromPrev") + + ""; + } + mapActions += "
"; + } + return `
-
- ${escapeHtml(item.time || "—")} + + -
${escapeHtml(item.action)}
- ${item.meal_timing_note ? `
💡 ${escapeHtml(item.meal_timing_note)}
` : ""} -
${reminderSelect}${metaBadges.join("")}
`; }) .join(""); @@ -824,6 +848,7 @@
${t("section.schedule")} + ${t("schedule.tapToExpand")}
${items}
@@ -833,6 +858,110 @@ `; } + /** Prefer explicit map_destination; else Maps URL dest; else "Visit X" / venue. */ + function resolveMapDestination(item, venueName) { + if (item && item.map_destination) return String(item.map_destination).trim(); + if (item && item.walking_map_url) { + try { + var u = new URL(item.walking_map_url); + var dest = u.searchParams.get("destination"); + if (dest) return decodeURIComponent(dest.replace(/\+/g, " ")); + } catch (e) { /* ignore */ } + } + var action = (item && item.action) ? String(item.action) : ""; + var visit = action.match(/^Visit\s+(.+?)(?:\s+—|$)/i); + if (visit) { + var place = visit[1].split(" — ")[0].trim(); + if (venueName && place.toLowerCase().indexOf(venueName.toLowerCase()) === -1) { + return place + ", " + venueName; + } + return place; + } + if (venueName && /arrive at/i.test(action)) return venueName; + return ""; + } + + function buildWalkingMapsUrl(origin, destination) { + var url = "https://www.google.com/maps/dir/?api=1&destination=" + + encodeURIComponent(destination) + "&travelmode=walking"; + if (origin) { + url += "&origin=" + encodeURIComponent(origin); + } + return url; + } + + function openWalkingMapFromHere(destination) { + if (!destination) { + showToast(t("schedule.mapNoDestination"), "error"); + return; + } + function openWithOrigin(origin) { + window.open(buildWalkingMapsUrl(origin, destination), "_blank", "noopener,noreferrer"); + } + if (!navigator.geolocation) { + openWithOrigin(null); + showToast(t("schedule.mapOpenedNoGeo"), "info"); + return; + } + showToast(t("schedule.locating"), "info"); + navigator.geolocation.getCurrentPosition( + function (pos) { + openWithOrigin(pos.coords.latitude + "," + pos.coords.longitude); + showToast(t("schedule.mapOpened"), "success"); + }, + function () { + openWithOrigin(null); + showToast(t("schedule.mapOpenedNoGeo"), "info"); + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 } + ); + } + + /** Expand/collapse stops and map-from-here — shared by plan detail + inline result. */ + function handleScheduleUiClick(e) { + var mapBtn = e.target.closest(".map-from-here-btn"); + if (mapBtn) { + e.preventDefault(); + e.stopPropagation(); + openWalkingMapFromHere(mapBtn.getAttribute("data-destination") || ""); + return; + } + + var header = e.target.closest(".timeline-item-header"); + if (!header) return; + // Ignore clicks that bubbled from controls inside the expanded body + if (e.target.closest(".timeline-item-body")) return; + + var item = header.closest(".timeline-item"); + if (!item) return; + var body = item.querySelector(".timeline-item-body"); + if (!body) return; + + var willOpen = body.hasAttribute("hidden"); + // Accordion: close other open stops in the same timeline + var timeline = item.closest(".timeline"); + if (timeline && willOpen) { + timeline.querySelectorAll(".timeline-item.is-expanded").forEach(function (el) { + if (el === item) return; + el.classList.remove("is-expanded"); + var h = el.querySelector(".timeline-item-header"); + var b = el.querySelector(".timeline-item-body"); + if (h) h.setAttribute("aria-expanded", "false"); + if (b) b.setAttribute("hidden", ""); + }); + } + + if (willOpen) { + body.removeAttribute("hidden"); + item.classList.add("is-expanded"); + header.setAttribute("aria-expanded", "true"); + } else { + body.setAttribute("hidden", ""); + item.classList.remove("is-expanded"); + header.setAttribute("aria-expanded", "false"); + } + } + function renderRoute(plan) { if (!Array.isArray(plan.route) || plan.route.length === 0) { return ""; @@ -1044,6 +1173,7 @@ { key: "restaurant", label: "Restaurant" }, { key: "reminder_min", label: "Reminder Before (min)", type: "select", options: ["", "5", "10", "15", "20", "25", "30", "35", "40", "45", "50", "55", "60"] }, { key: "walking_map_url", label: "Walking Map URL" }, + { key: "map_destination", label: "Map Destination (for walk-from-here)" }, { key: "meal_timing_note", label: "Meal Timing Note" }, { key: "backup_plan", label: "Backup Plan" }, ]; @@ -1413,10 +1543,11 @@ // Plan detail content delegation for schedule editing & delete $planDetailContent.addEventListener("click", (e) => { + handleScheduleUiClick(e); + const editBtn = e.target.closest(".edit-schedule-btn"); const removeBtn = e.target.closest(".remove-schedule-btn"); const addBtn = e.target.closest("#btn-add-schedule-item"); - const deleteBtn = e.target.closest("#btn-delete-plan"); if (editBtn && currentPlanId) { const idx = parseInt(editBtn.dataset.index, 10); @@ -1436,6 +1567,11 @@ } }); + // Inline new-trip result: expand + map actions + $resultNewTrip.addEventListener("click", function (e) { + handleScheduleUiClick(e); + }); + // Reminder dropdown change (plan detail) $planDetailContent.addEventListener("change", function (e) { var sel = e.target.closest(".reminder-inline"); diff --git a/static/js/i18n.js b/static/js/i18n.js index efc4a0e..f2c010c 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -110,6 +110,14 @@ "schedule.reminderNone": "\uD83D\uDD14 None", "schedule.reminderMin": "\uD83D\uDD14 {min} min", "schedule.walkToHere": "\uD83D\uDEE3 Walk to here", + "schedule.walkFromPrev": "\uD83D\uDEB6 From previous stop", + "schedule.mapFromHere": "\uD83D\uDCCD Map from my location", + "schedule.mapFromHereHint": "Open walking directions from where you are now", + "schedule.tapToExpand": "Tap a stop for walking directions", + "schedule.locating": "Getting your location\u2026", + "schedule.mapOpened": "Opening walking directions", + "schedule.mapOpenedNoGeo": "Opening map (location unavailable \u2014 Maps may still use your position)", + "schedule.mapNoDestination": "This stop has no map destination yet.", "schedule.noItems": "No schedule items.", // Edit/Add Modal @@ -311,6 +319,14 @@ "schedule.reminderNone": "\uD83D\uDD14 Ninguno", "schedule.reminderMin": "\uD83D\uDD14 {min} min", "schedule.walkToHere": "\uD83D\uDEE3 Caminar hasta aquí", + "schedule.walkFromPrev": "\uD83D\uDEB6 Desde la parada anterior", + "schedule.mapFromHere": "\uD83D\uDCCD Mapa desde mi ubicación", + "schedule.mapFromHereHint": "Abrir indicaciones a pie desde donde estás", + "schedule.tapToExpand": "Toca una parada para ver indicaciones a pie", + "schedule.locating": "Obteniendo tu ubicación\u2026", + "schedule.mapOpened": "Abriendo indicaciones a pie", + "schedule.mapOpenedNoGeo": "Abriendo mapa (ubicación no disponible \u2014 Maps puede usar tu posición)", + "schedule.mapNoDestination": "Esta parada aún no tiene destino en el mapa.", "schedule.noItems": "Sin elementos en el horario.", // Edit/Add Modal diff --git a/test_waypoint_maps.py b/test_waypoint_maps.py new file mode 100644 index 0000000..30da8b6 --- /dev/null +++ b/test_waypoint_maps.py @@ -0,0 +1,28 @@ +"""Tests for in-venue walking map destination helpers.""" + +from app.engine.planner import ( + _attraction_short_name, + _map_destination, + _venue_label, + _walking_directions_url, +) + + +def test_venue_label_includes_location_when_distinct(): + assert _venue_label({"venue": "Busch Gardens", "location": "Tampa"}) == "Busch Gardens Tampa" + assert _venue_label({"venue": "Busch Gardens Tampa", "location": "Tampa"}) == "Busch Gardens Tampa" + + +def test_map_destination_qualifies_attraction(): + assert _map_destination("Kumba", "Busch Gardens Tampa") == "Kumba, Busch Gardens Tampa" + + +def test_walking_directions_url_includes_venue_and_walking_mode(): + url = _walking_directions_url("Congo River Rapids", "Kumba", "Busch Gardens Tampa") + assert "travelmode=walking" in url + assert "origin=" in url and "destination=" in url + assert "Busch" in url or "Busch%20" in url or "Busch+" in url + + +def test_attraction_short_name_strips_tip_suffix(): + assert _attraction_short_name("Kumba — tip: ride early") == "Kumba" From 25553a1e1783dc95d7f3c05e3e3fb00d37c01106 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 16:14:58 +0000 Subject: [PATCH 2/3] fix(reminders): open map_destination when walking_map_url is missing Co-authored-by: jssturm --- static/js/app.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/js/app.js b/static/js/app.js index 09d745a..814919c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -161,6 +161,9 @@ saveFiredReminders(); var mapUrl = item.walking_map_url; + if (!mapUrl && item.map_destination) { + mapUrl = buildWalkingMapsUrl(null, item.map_destination); + } if (!mapUrl) { // Fall back to the route maps_url if available var routeIdx = findRouteIndexForSchedule(plan, idx); From 06bc7e5025dc85df6424fb11e07362cb3b146ad7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 16:24:23 +0000 Subject: [PATCH 3/3] fix(ci): install requirements-dev so pytest is available in Test workflow The Test job only installed requirements.txt, which does not include pytest, so CI failed with "No module named pytest" (including on main). Install requirements-dev.txt and run the full pytest suite. Co-authored-by: jssturm --- .github/workflows/test.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45aaf76..4cd7457 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,7 +20,9 @@ jobs: python-version: "3.12" - name: Install dependencies - run: pip install -r requirements.txt + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt - name: Run tests - run: python -m pytest test_travel.py -v + run: python -m pytest -q