Skip to content
Draft
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
6 changes: 4 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 50 additions & 5 deletions app/engine/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
})

Expand All @@ -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,
})

Expand Down Expand Up @@ -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),
Expand All @@ -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",
})

# =========================================================================
Expand Down
5 changes: 5 additions & 0 deletions app/schemas/itinerary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
75 changes: 71 additions & 4 deletions static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading