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 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..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); @@ -739,6 +742,7 @@ return '
' + t("schedule.noItems") + '
'; } + const venueName = plan.venue_name || ""; const items = plan.schedule .map((item, idx) => { const prioClass = item.priority || "medium"; @@ -758,11 +762,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 +791,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 +851,7 @@
${t("section.schedule")} + ${t("schedule.tapToExpand")}
${items}
@@ -833,6 +861,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 +1176,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 +1546,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 +1570,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"