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 '