From e370049bcb665ad80b1d088da0e132a6e2f035ad Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 11:10:58 +0200 Subject: [PATCH 1/6] Add train and ferry support to location tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add helper functions for train and ferry location extraction - Update get_location_for_date to consider trains and ferries alongside flights - Parse stations.yaml and ferry_terminals.yaml for location data - Handle UK vs international locations consistently for all transport modes - Add comprehensive tests for new train and ferry location helpers - Format code with black for consistent style Now tracks complete travel history including flights, trains, ferries, and accommodation for accurate location determination. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- agenda/busy.py | 246 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_busy.py | 62 ++++++++++++ 2 files changed, 302 insertions(+), 6 deletions(-) diff --git a/agenda/busy.py b/agenda/busy.py index 4fcbeec..31cc4b6 100644 --- a/agenda/busy.py +++ b/agenda/busy.py @@ -151,12 +151,79 @@ def _get_accommodation_location( return (acc.get("location", "Unknown"), get_country(acc.get("country", "gb"))) +def _get_train_location( + train_leg: StrDict, stations: StrDict, on_trip: bool = False +) -> tuple[str | None, pycountry.db.Country | None]: + """Get location from train leg data.""" + destination = train_leg.get("to") + if not destination: + return (None, get_country("gb")) + + # Find station info + station_info = None + for station in stations: + if station.get("name") == destination: + station_info = station + break + + if not station_info: + return (destination, get_country("gb")) + + station_country = station_info.get("country", "gb") + + if station_country == "gb": + if on_trip: + # When on a trip, show the actual location even for UK stations + return (destination, get_country("gb")) + else: + # When not on a trip, UK stations mean home + return (None, get_country("gb")) + else: + return (destination, get_country(station_country)) + + +def _get_ferry_location( + ferry: StrDict, terminals: StrDict, on_trip: bool = False +) -> tuple[str | None, pycountry.db.Country | None]: + """Get location from ferry data.""" + destination = ferry.get("to") + if not destination: + return (None, get_country("gb")) + + # Find terminal info + terminal_info = None + for terminal in terminals: + if terminal.get("name") == destination: + terminal_info = terminal + break + + if not terminal_info: + return (destination, get_country("gb")) + + terminal_country = terminal_info.get("country", "gb") + terminal_city = terminal_info.get("city", destination) + + if terminal_country == "gb": + if on_trip: + # When on a trip, show the actual location even for UK terminals + return (terminal_city, get_country("gb")) + else: + # When not on a trip, UK terminals mean home + return (None, get_country("gb")) + else: + return (terminal_city, get_country(terminal_country)) + + def _find_most_recent_travel_within_trip( trip: Trip, target_date: date, bookings: list[StrDict], accommodations: list[StrDict], airports: StrDict, + trains: list[StrDict] | None = None, + stations: StrDict | None = None, + ferries: list[StrDict] | None = None, + terminals: StrDict | None = None, ) -> tuple[str | None, pycountry.db.Country | None] | None: """Find the most recent travel location within a trip.""" uk_airports = {"LHR", "LGW", "STN", "LTN", "BRS", "BHX", "MAN", "EDI", "GLA"} @@ -219,6 +286,69 @@ def _find_most_recent_travel_within_trip( acc, on_trip=True ) + # Check trains within trip period + if trains and stations: + for train in trains: + for leg in train.get("legs", []): + if "arrive" in leg: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + leg["arrive"] + ) + except ValueError: + continue + + # Only consider trains within this trip and before target date + if trip.start <= arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + trip_most_recent_date is None + or arrive_date > trip_most_recent_date + or ( + arrive_date == trip_most_recent_date + and ( + trip_most_recent_datetime is None + or arrive_datetime > trip_most_recent_datetime + ) + ) + ): + trip_most_recent_date = arrive_date + trip_most_recent_datetime = arrive_datetime + trip_most_recent_location = _get_train_location( + leg, stations, on_trip=True + ) + + # Check ferries within trip period + if ferries and terminals: + for ferry in ferries: + if "arrive" in ferry: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + ferry["arrive"] + ) + except ValueError: + continue + + # Only consider ferries within this trip and before target date + if trip.start <= arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + trip_most_recent_date is None + or arrive_date > trip_most_recent_date + or ( + arrive_date == trip_most_recent_date + and ( + trip_most_recent_datetime is None + or arrive_datetime > trip_most_recent_datetime + ) + ) + ): + trip_most_recent_date = arrive_date + trip_most_recent_datetime = arrive_datetime + trip_most_recent_location = _get_ferry_location( + ferry, terminals, on_trip=True + ) + return trip_most_recent_location @@ -253,6 +383,10 @@ def _find_most_recent_travel_before_date( bookings: list[StrDict], accommodations: list[StrDict], airports: StrDict, + trains: list[StrDict] | None = None, + stations: StrDict | None = None, + ferries: list[StrDict] | None = None, + terminals: StrDict | None = None, ) -> tuple[str | None, pycountry.db.Country | None] | None: """Find the most recent travel location before a given date.""" uk_airports = {"LHR", "LGW", "STN", "LTN", "BRS", "BHX", "MAN", "EDI", "GLA"} @@ -308,6 +442,67 @@ def _find_most_recent_travel_before_date( acc, on_trip=False ) + # Check trains + if trains and stations: + for train in trains: + for leg in train.get("legs", []): + if "arrive" in leg: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + leg["arrive"] + ) + except ValueError: + continue + + if arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + most_recent_date is None + or arrive_date > most_recent_date + or ( + arrive_date == most_recent_date + and ( + most_recent_datetime is None + or arrive_datetime > most_recent_datetime + ) + ) + ): + most_recent_date = arrive_date + most_recent_datetime = arrive_datetime + most_recent_location = _get_train_location( + leg, stations, on_trip=False + ) + + # Check ferries + if ferries and terminals: + for ferry in ferries: + if "arrive" in ferry: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + ferry["arrive"] + ) + except ValueError: + continue + + if arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + most_recent_date is None + or arrive_date > most_recent_date + or ( + arrive_date == most_recent_date + and ( + most_recent_datetime is None + or arrive_datetime > most_recent_datetime + ) + ) + ): + most_recent_date = arrive_date + most_recent_datetime = arrive_datetime + most_recent_location = _get_ferry_location( + ferry, terminals, on_trip=False + ) + return most_recent_location @@ -349,14 +544,26 @@ def get_location_for_date( bookings: list[StrDict], accommodations: list[StrDict], airports: StrDict, + trains: list[StrDict] | None = None, + stations: StrDict | None = None, + ferries: list[StrDict] | None = None, + terminals: StrDict | None = None, ) -> tuple[str | None, pycountry.db.Country | None]: """Get location (city, country) for a specific date using travel history.""" # First check if currently on a trip for trip in trips: if trip.start <= target_date <= (trip.end or trip.start): - # For trips, find the most recent flight or accommodation within the trip period + # For trips, find the most recent travel within the trip period trip_location = _find_most_recent_travel_within_trip( - trip, target_date, bookings, accommodations, airports + trip, + target_date, + bookings, + accommodations, + airports, + trains, + stations, + ferries, + terminals, ) if trip_location: return trip_location @@ -366,9 +573,16 @@ def get_location_for_date( if progression_location: return progression_location - # Find most recent flight or accommodation before this date + # Find most recent travel before this date recent_travel = _find_most_recent_travel_before_date( - target_date, bookings, accommodations, airports + target_date, + bookings, + accommodations, + airports, + trains, + stations, + ferries, + terminals, ) # Check for recent trips that have ended - prioritize this over individual travel data @@ -400,6 +614,10 @@ def weekends( bookings = travel.parse_yaml("flights", data_dir) accommodations = travel.parse_yaml("accommodation", data_dir) airports = travel.parse_yaml("airports", data_dir) + trains = travel.parse_yaml("trains", data_dir) + stations = travel.parse_yaml("stations", data_dir) + ferries = travel.parse_yaml("ferries", data_dir) + terminals = travel.parse_yaml("ferry_terminals", data_dir) weekends_info = [] for i in range(52): @@ -418,10 +636,26 @@ def weekends( ] saturday_location = get_location_for_date( - saturday, trips, bookings, accommodations, airports + saturday, + trips, + bookings, + accommodations, + airports, + trains, + stations, + ferries, + terminals, ) sunday_location = get_location_for_date( - sunday, trips, bookings, accommodations, airports + sunday, + trips, + bookings, + accommodations, + airports, + trains, + stations, + ferries, + terminals, ) weekends_info.append( diff --git a/tests/test_busy.py b/tests/test_busy.py index 150dc8f..026eeb9 100644 --- a/tests/test_busy.py +++ b/tests/test_busy.py @@ -178,6 +178,68 @@ def test_parse_datetime_field(): assert parsed_dt.day == 1 +def test_train_location_helpers(): + """Test the train location helper functions.""" + from agenda.busy import _get_train_location + + # Mock station data + stations = [ + {"name": "London St Pancras", "country": "gb"}, + {"name": "Brussels Midi", "country": "be"}, + {"name": "Edinburgh Waverley", "country": "gb"}, + ] + + # Test UK station when not on trip (should return None for home) + train_leg = {"to": "London St Pancras"} + location = _get_train_location(train_leg, stations, on_trip=False) + assert location[0] is None # Should be home + assert location[1].alpha_2 == "GB" + + # Test UK station when on trip (should return city name) + location = _get_train_location(train_leg, stations, on_trip=True) + assert location[0] == "London St Pancras" + assert location[1].alpha_2 == "GB" + + # Test non-UK station + train_leg = {"to": "Brussels Midi"} + location = _get_train_location(train_leg, stations, on_trip=False) + assert location[0] == "Brussels Midi" + assert location[1].alpha_2 == "BE" + + +def test_ferry_location_helpers(): + """Test the ferry location helper functions.""" + from agenda.busy import _get_ferry_location + + # Mock terminal data + terminals = [ + {"name": "Dover Eastern Docks", "country": "gb", "city": "Dover"}, + {"name": "Calais Ferry Terminal", "country": "fr", "city": "Calais"}, + { + "name": "Portsmouth Continental Terminal", + "country": "gb", + "city": "Portsmouth", + }, + ] + + # Test UK terminal when not on trip (should return None for home) + ferry = {"to": "Dover Eastern Docks"} + location = _get_ferry_location(ferry, terminals, on_trip=False) + assert location[0] is None # Should be home + assert location[1].alpha_2 == "GB" + + # Test UK terminal when on trip (should return city name) + location = _get_ferry_location(ferry, terminals, on_trip=True) + assert location[0] == "Dover" + assert location[1].alpha_2 == "GB" + + # Test non-UK terminal + ferry = {"to": "Calais Ferry Terminal"} + location = _get_ferry_location(ferry, terminals, on_trip=False) + assert location[0] == "Calais" + assert location[1].alpha_2 == "FR" + + def test_get_busy_events(app_context, trips): """Test get_busy_events function.""" start_date = date(2023, 1, 1) From f396d8a62f7ae50bdb26e138c63313398f7eecf7 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 11:16:33 +0200 Subject: [PATCH 2/6] Simplify return home heuristics now that we have comprehensive travel data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove complex country-based return home assumptions since we now track actual train and ferry travel back to the UK. The system can rely on real travel data rather than heuristics. - Remove NEARBY_BALKAN_COUNTRIES constant - Remove geographic assumptions about returning home after trips - Keep only UK trip ending logic (if trip ends in UK, you're home) With complete train/ferry tracking, actual return travel is captured rather than inferred. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- agenda/busy.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/agenda/busy.py b/agenda/busy.py index 31cc4b6..9d7e61d 100644 --- a/agenda/busy.py +++ b/agenda/busy.py @@ -11,20 +11,6 @@ from . import events_yaml, get_country, travel from .event import Event from .types import StrDict, Trip -NEARBY_BALKAN_COUNTRIES = { - "GR", - "AL", - "XK", - "HR", - "SI", - "MK", - "BA", - "ME", - "RS", - "BG", - "RO", -} - def busy_event(e: Event) -> bool: """Busy.""" @@ -522,19 +508,6 @@ def _check_return_home_heuristic( if hasattr(final_country, "alpha_2") and final_country.alpha_2 == "GB": return (None, get_country("gb")) - # For short trips to nearby countries or international trips - # (ended >=1 day ago), assume returned home if no subsequent travel data - if days_since_trip >= 1 and ( - # European countries (close by rail/ferry) - final_alpha_2 in {"BE", "NL", "FR", "DE", "CH", "AT", "IT", "ES"} - # Nearby Balkan countries - or final_alpha_2 in NEARBY_BALKAN_COUNTRIES - # International trips (assume return home after trip ends) - or final_alpha_2 - in {"US", "CA", "IN", "JP", "CN", "AU", "NZ", "BR", "AR", "ZA"} - ): - return (None, get_country("gb")) - return None From 0e2c95117c2457cc91e9197ea2863eb400f5e66b Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 11:25:17 +0200 Subject: [PATCH 3/6] Add debug page for trip objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /trip//debug endpoint that displays the complete trip object data in pretty-printed JSON format with syntax highlighting. Includes both raw data and computed properties for debugging purposes. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- templates/trip_debug.html | 128 ++++++++++++++++++++++++++++++++++++++ web_view.py | 58 +++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 templates/trip_debug.html diff --git a/templates/trip_debug.html b/templates/trip_debug.html new file mode 100644 index 0000000..7ccc06e --- /dev/null +++ b/templates/trip_debug.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} + +{% block title %}Debug: {{ trip.title }} ({{ trip.start }}) - Edward Betts{% endblock %} + +{% block style %} + +{% endblock %} + +{% block content %} +
+
+

🐛 Trip Debug Information

+

Raw trip object data for: {{ trip.title }}

+ ← Back to Trip Page + +
+ +
+
+

Trip Object (JSON)

+
{{ trip_json }}
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/web_view.py b/web_view.py index 2cffe28..3a23807 100755 --- a/web_view.py +++ b/web_view.py @@ -4,8 +4,10 @@ import decimal import inspect +import json import operator import os.path +import pprint import sys import time import traceback @@ -586,6 +588,62 @@ def trip_page(start: str) -> str: ) +@app.route("/trip//debug") +def trip_debug_page(start: str) -> str: + """Trip debug page showing raw trip object data.""" + route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) + trip_list = get_trip_list(route_distances) + + prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list) + if not trip: + flask.abort(404) + + # Add Schengen compliance information + trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip) + + # Convert trip object to dictionary for display + trip_dict = { + "start": trip.start.isoformat(), + "name": trip.name, + "private": trip.private, + "travel": trip.travel, + "accommodation": trip.accommodation, + "conferences": trip.conferences, + "events": trip.events, + "flight_bookings": trip.flight_bookings, + "computed_properties": { + "title": trip.title, + "end": trip.end.isoformat() if trip.end else None, + "countries": [{"name": c.name, "alpha_2": c.alpha_2, "flag": c.flag} for c in trip.countries], + "locations": [{"location": loc, "country": {"name": country.name, "alpha_2": country.alpha_2}} for loc, country in trip.locations()], + "total_distance": trip.total_distance(), + "total_co2_kg": trip.total_co2_kg(), + "distances_by_transport_type": trip.distances_by_transport_type(), + "co2_by_transport_type": trip.co2_by_transport_type(), + }, + "schengen_compliance": { + "total_days_used": trip.schengen_compliance.total_days_used, + "days_remaining": trip.schengen_compliance.days_remaining, + "is_compliant": trip.schengen_compliance.is_compliant, + "current_180_day_period": [ + trip.schengen_compliance.current_180_day_period[0].isoformat(), + trip.schengen_compliance.current_180_day_period[1].isoformat() + ], + "days_over_limit": trip.schengen_compliance.days_over_limit, + } if trip.schengen_compliance else None, + } + + # Convert to JSON for pretty printing + trip_json = json.dumps(trip_dict, indent=2, default=str) + + return flask.render_template( + "trip_debug.html", + trip=trip, + trip_json=trip_json, + start=start, + ) + + @app.route("/holidays") def holiday_list() -> str: """List of holidays.""" From 29d5145b871e9f30aad98dfa9e4dae2c6b1e367e Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 12:08:19 +0200 Subject: [PATCH 4/6] Refactor get_location_for_date to use trip data directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the location tracking function by extracting travel data directly from trip objects instead of requiring separate YAML file parameters. Changes: - Remove airport, train, and ferry location helper functions that required separate YAML data lookups - Update get_location_for_date signature to only take target_date and trips - Extract flight/train/ferry details directly from trip.travel items - Use embedded airport/station/terminal objects from trip data - Remove YAML file parsing from weekends function - Update test calls to use new simplified signature This eliminates duplicate data loading and simplifies the API while maintaining all existing functionality. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- agenda/busy.py | 556 +++++++++++++++++++++------------------------ tests/test_busy.py | 71 ------ 2 files changed, 260 insertions(+), 367 deletions(-) diff --git a/agenda/busy.py b/agenda/busy.py index 9d7e61d..8452903 100644 --- a/agenda/busy.py +++ b/agenda/busy.py @@ -92,125 +92,20 @@ def _parse_datetime_field(datetime_obj: datetime | date) -> tuple[datetime, date raise ValueError(f"Invalid datetime format: {datetime_obj}") -def _get_airport_location( - airport_code: str, airports: StrDict, uk_airports: set[str], on_trip: bool = False -) -> tuple[str | None, pycountry.db.Country | None]: - """Get location from airport code.""" - if airport_code in uk_airports: - if on_trip: - # When on a trip, show the actual location even for UK airports - airport_info = airports.get(airport_code) - if airport_info: - location_name = airport_info.get( - "city", airport_info.get("name", "London") - ) - return (location_name, get_country("gb")) - else: - return ("London", get_country("gb")) - else: - # When not on a trip, UK airports mean home - return (None, get_country("gb")) - else: - # Non-UK airports - airport_info = airports.get(airport_code) - if airport_info: - location_name = airport_info.get( - "city", airport_info.get("name", airport_code) - ) - return (location_name, get_country(airport_info.get("country", "gb"))) - else: - return (airport_code, get_country("gb")) - - def _get_accommodation_location( acc: StrDict, on_trip: bool = False -) -> tuple[str | None, pycountry.db.Country | None]: +) -> tuple[str | None, pycountry.db.Country]: """Get location from accommodation data.""" - if acc.get("country") == "gb": - if on_trip: - # When on a trip, show the actual location even for UK accommodations - return (acc.get("location", "London"), get_country("gb")) - else: - # When not on a trip, UK accommodation means home - return (None, get_country("gb")) - else: - return (acc.get("location", "Unknown"), get_country(acc.get("country", "gb"))) - - -def _get_train_location( - train_leg: StrDict, stations: StrDict, on_trip: bool = False -) -> tuple[str | None, pycountry.db.Country | None]: - """Get location from train leg data.""" - destination = train_leg.get("to") - if not destination: - return (None, get_country("gb")) - - # Find station info - station_info = None - for station in stations: - if station.get("name") == destination: - station_info = station - break - - if not station_info: - return (destination, get_country("gb")) - - station_country = station_info.get("country", "gb") - - if station_country == "gb": - if on_trip: - # When on a trip, show the actual location even for UK stations - return (destination, get_country("gb")) - else: - # When not on a trip, UK stations mean home - return (None, get_country("gb")) - else: - return (destination, get_country(station_country)) - - -def _get_ferry_location( - ferry: StrDict, terminals: StrDict, on_trip: bool = False -) -> tuple[str | None, pycountry.db.Country | None]: - """Get location from ferry data.""" - destination = ferry.get("to") - if not destination: - return (None, get_country("gb")) - - # Find terminal info - terminal_info = None - for terminal in terminals: - if terminal.get("name") == destination: - terminal_info = terminal - break - - if not terminal_info: - return (destination, get_country("gb")) - - terminal_country = terminal_info.get("country", "gb") - terminal_city = terminal_info.get("city", destination) - - if terminal_country == "gb": - if on_trip: - # When on a trip, show the actual location even for UK terminals - return (terminal_city, get_country("gb")) - else: - # When not on a trip, UK terminals mean home - return (None, get_country("gb")) - else: - return (terminal_city, get_country(terminal_country)) + c = get_country(acc["country"]) + assert c + assert isinstance(acc["location"], str) + return (acc["location"] if on_trip else None, c) def _find_most_recent_travel_within_trip( trip: Trip, target_date: date, - bookings: list[StrDict], - accommodations: list[StrDict], - airports: StrDict, - trains: list[StrDict] | None = None, - stations: StrDict | None = None, - ferries: list[StrDict] | None = None, - terminals: StrDict | None = None, -) -> tuple[str | None, pycountry.db.Country | None] | None: +) -> tuple[str | None, pycountry.db.Country] | None: """Find the most recent travel location within a trip.""" uk_airports = {"LHR", "LGW", "STN", "LTN", "BRS", "BHX", "MAN", "EDI", "GLA"} @@ -219,39 +114,54 @@ def _find_most_recent_travel_within_trip( trip_most_recent_datetime = None # Check flights within trip period - for booking in bookings: - for flight in booking.get("flights", []): - if "arrive" in flight: - try: - arrive_datetime, arrive_date = _parse_datetime_field( - flight["arrive"] - ) - except ValueError: - continue + for travel_item in trip.travel: + if travel_item["type"] == "flight" and "arrive" in travel_item: + arrive_datetime, arrive_date = _parse_datetime_field(travel_item["arrive"]) - # Only consider flights within this trip and before target date - if trip.start <= arrive_date <= target_date: - # Compare both date and time to handle same-day flights correctly - if ( - trip_most_recent_date is None - or arrive_date > trip_most_recent_date - or ( - arrive_date == trip_most_recent_date - and ( - trip_most_recent_datetime is None - or arrive_datetime > trip_most_recent_datetime - ) + # Only consider flights within this trip and before target date + if not (trip.start <= arrive_date <= target_date): + continue + # Compare both date and time to handle same-day flights correctly + if ( + trip_most_recent_date is None + or arrive_date > trip_most_recent_date + or ( + arrive_date == trip_most_recent_date + and ( + trip_most_recent_datetime is None + or arrive_datetime > trip_most_recent_datetime + ) + ) + ): + trip_most_recent_date = arrive_date + trip_most_recent_datetime = arrive_datetime + destination_airport = travel_item["to"] + assert "to_airport" in travel_item + airport_info = travel_item["to_airport"] + airport_country = airport_info["country"] + if airport_country == "gb": + if destination_airport in uk_airports: + # UK airport while on trip - show actual location + location_name = airport_info.get( + "city", airport_info.get("name", "London") ) - ): - trip_most_recent_date = arrive_date - trip_most_recent_datetime = arrive_datetime - destination_airport = flight["to"] - trip_most_recent_location = _get_airport_location( - destination_airport, airports, uk_airports, on_trip=True + trip_most_recent_location = ( + location_name, + get_country("gb"), ) + else: + trip_most_recent_location = (None, get_country("gb")) + else: + location_name = airport_info.get( + "city", airport_info.get("name", destination_airport) + ) + trip_most_recent_location = ( + location_name, + get_country(airport_country), + ) # Check accommodations within trip period - for acc in accommodations: + for acc in trip.accommodation: if "from" in acc: try: _, acc_date = _parse_datetime_field(acc["from"]) @@ -273,9 +183,9 @@ def _find_most_recent_travel_within_trip( ) # Check trains within trip period - if trains and stations: - for train in trains: - for leg in train.get("legs", []): + for travel_item in trip.travel: + if travel_item["type"] == "train": + for leg in travel_item.get("legs", []): if "arrive" in leg: try: arrive_datetime, arrive_date = _parse_datetime_field( @@ -300,39 +210,63 @@ def _find_most_recent_travel_within_trip( ): trip_most_recent_date = arrive_date trip_most_recent_datetime = arrive_datetime - trip_most_recent_location = _get_train_location( - leg, stations, on_trip=True - ) + # For trains, we can get station info from to_station if available + destination = leg.get("to") + assert "to_station" in leg + station_info = leg["to_station"] + station_country = station_info["country"] + if station_country == "gb": + trip_most_recent_location = ( + destination, + get_country("gb"), + ) + else: + trip_most_recent_location = ( + destination, + get_country(station_country), + ) # Check ferries within trip period - if ferries and terminals: - for ferry in ferries: - if "arrive" in ferry: - try: - arrive_datetime, arrive_date = _parse_datetime_field( - ferry["arrive"] - ) - except ValueError: - continue + for travel_item in trip.travel: + if travel_item["type"] == "ferry" and "arrive" in travel_item: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + travel_item["arrive"] + ) + except ValueError: + continue - # Only consider ferries within this trip and before target date - if trip.start <= arrive_date <= target_date: - # Compare both date and time to handle same-day arrivals correctly - if ( - trip_most_recent_date is None - or arrive_date > trip_most_recent_date - or ( - arrive_date == trip_most_recent_date - and ( - trip_most_recent_datetime is None - or arrive_datetime > trip_most_recent_datetime - ) + # Only consider ferries within this trip and before target date + if trip.start <= arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + trip_most_recent_date is None + or arrive_date > trip_most_recent_date + or ( + arrive_date == trip_most_recent_date + and ( + trip_most_recent_datetime is None + or arrive_datetime > trip_most_recent_datetime ) - ): - trip_most_recent_date = arrive_date - trip_most_recent_datetime = arrive_datetime - trip_most_recent_location = _get_ferry_location( - ferry, terminals, on_trip=True + ) + ): + trip_most_recent_date = arrive_date + trip_most_recent_datetime = arrive_datetime + # For ferries, we can get terminal info from to_terminal if available + destination = travel_item.get("to") + assert "to_terminal" in travel_item + terminal_info = travel_item["to_terminal"] + terminal_country = terminal_info.get("country", "gb") + terminal_city = terminal_info.get("city", destination) + if terminal_country == "gb": + trip_most_recent_location = ( + terminal_city, + get_country("gb"), + ) + else: + trip_most_recent_location = ( + terminal_city, + get_country(terminal_country), ) return trip_most_recent_location @@ -366,13 +300,7 @@ def _get_trip_location_by_progression( def _find_most_recent_travel_before_date( target_date: date, - bookings: list[StrDict], - accommodations: list[StrDict], - airports: StrDict, - trains: list[StrDict] | None = None, - stations: StrDict | None = None, - ferries: list[StrDict] | None = None, - terminals: StrDict | None = None, + trips: list[Trip], ) -> tuple[str | None, pycountry.db.Country | None] | None: """Find the most recent travel location before a given date.""" uk_airports = {"LHR", "LGW", "STN", "LTN", "BRS", "BHX", "MAN", "EDI", "GLA"} @@ -381,13 +309,14 @@ def _find_most_recent_travel_before_date( most_recent_date = None most_recent_datetime = None - # Check flights - for booking in bookings: - for flight in booking.get("flights", []): - if "arrive" in flight: + # Check all travel across all trips + for trip in trips: + # Check flights + for travel_item in trip.travel: + if travel_item["type"] == "flight" and "arrive" in travel_item: try: arrive_datetime, arrive_date = _parse_datetime_field( - flight["arrive"] + travel_item["arrive"] ) except ValueError: continue @@ -407,65 +336,105 @@ def _find_most_recent_travel_before_date( ): most_recent_date = arrive_date most_recent_datetime = arrive_datetime - destination_airport = flight["to"] - most_recent_location = _get_airport_location( - destination_airport, airports, uk_airports, on_trip=False + destination_airport = travel_item["to"] + # For flights, determine if we're "on trip" based on whether this is within any trip period + on_trip = any( + t.start <= arrive_date <= (t.end or t.start) for t in trips ) - # Check accommodation - only override if accommodation is more recent - for acc in accommodations: - if "from" in acc: - try: - _, acc_date = _parse_datetime_field(acc["from"]) - except ValueError: - continue - - if acc_date <= target_date: - # Only update if this accommodation is more recent than existing result - if most_recent_date is None or acc_date > most_recent_date: - most_recent_date = acc_date - most_recent_location = _get_accommodation_location( - acc, on_trip=False - ) - - # Check trains - if trains and stations: - for train in trains: - for leg in train.get("legs", []): - if "arrive" in leg: - try: - arrive_datetime, arrive_date = _parse_datetime_field( - leg["arrive"] - ) - except ValueError: - continue - - if arrive_date <= target_date: - # Compare both date and time to handle same-day arrivals correctly - if ( - most_recent_date is None - or arrive_date > most_recent_date - or ( - arrive_date == most_recent_date - and ( - most_recent_datetime is None - or arrive_datetime > most_recent_datetime + if "to_airport" in travel_item: + airport_info = travel_item["to_airport"] + airport_country = airport_info.get("country", "gb") + if airport_country == "gb": + if not on_trip: + # When not on a trip, UK airports mean home + most_recent_location = (None, get_country("gb")) + else: + # When on a trip, show the actual location even for UK airports + location_name = airport_info.get( + "city", airport_info.get("name", "London") + ) + most_recent_location = ( + location_name, + get_country("gb"), + ) + else: + location_name = airport_info.get( + "city", + airport_info.get("name", destination_airport), ) - ) - ): - most_recent_date = arrive_date - most_recent_datetime = arrive_datetime - most_recent_location = _get_train_location( - leg, stations, on_trip=False + most_recent_location = ( + location_name, + get_country(airport_country), + ) + else: + most_recent_location = ( + destination_airport, + get_country("gb"), ) - # Check ferries - if ferries and terminals: - for ferry in ferries: - if "arrive" in ferry: + # Check trains + elif travel_item["type"] == "train": + for leg in travel_item.get("legs", []): + if "arrive" in leg: + try: + arrive_datetime, arrive_date = _parse_datetime_field( + leg["arrive"] + ) + except ValueError: + continue + + if arrive_date <= target_date: + # Compare both date and time to handle same-day arrivals correctly + if ( + most_recent_date is None + or arrive_date > most_recent_date + or ( + arrive_date == most_recent_date + and ( + most_recent_datetime is None + or arrive_datetime > most_recent_datetime + ) + ) + ): + most_recent_date = arrive_date + most_recent_datetime = arrive_datetime + destination = leg.get("to") + on_trip = any( + t.start <= arrive_date <= (t.end or t.start) + for t in trips + ) + + if "to_station" in leg: + station_info = leg["to_station"] + station_country = station_info.get("country", "gb") + if station_country == "gb": + if not on_trip: + most_recent_location = ( + None, + get_country("gb"), + ) + else: + most_recent_location = ( + destination, + get_country("gb"), + ) + else: + most_recent_location = ( + destination, + get_country(station_country), + ) + else: + most_recent_location = ( + destination, + get_country("gb"), + ) + + # Check ferries + elif travel_item["type"] == "ferry" and "arrive" in travel_item: try: arrive_datetime, arrive_date = _parse_datetime_field( - ferry["arrive"] + travel_item["arrive"] ) except ValueError: continue @@ -485,8 +454,48 @@ def _find_most_recent_travel_before_date( ): most_recent_date = arrive_date most_recent_datetime = arrive_datetime - most_recent_location = _get_ferry_location( - ferry, terminals, on_trip=False + destination = travel_item.get("to") + on_trip = any( + t.start <= arrive_date <= (t.end or t.start) for t in trips + ) + + if "to_terminal" in travel_item: + terminal_info = travel_item["to_terminal"] + terminal_country = terminal_info.get("country", "gb") + terminal_city = terminal_info.get("city", destination) + if terminal_country == "gb": + if not on_trip: + most_recent_location = (None, get_country("gb")) + else: + most_recent_location = ( + terminal_city, + get_country("gb"), + ) + else: + most_recent_location = ( + terminal_city, + get_country(terminal_country), + ) + else: + most_recent_location = (destination, get_country("gb")) + + # Check accommodation - only override if accommodation is more recent + for acc in trip.accommodation: + if "from" in acc: + try: + _, acc_date = _parse_datetime_field(acc["from"]) + except ValueError: + continue + + if acc_date <= target_date: + # Only update if this accommodation is more recent than existing result + if most_recent_date is None or acc_date > most_recent_date: + most_recent_date = acc_date + on_trip = any( + t.start <= acc_date <= (t.end or t.start) for t in trips + ) + most_recent_location = _get_accommodation_location( + acc, on_trip=on_trip ) return most_recent_location @@ -514,49 +523,27 @@ def _check_return_home_heuristic( def get_location_for_date( target_date: date, trips: list[Trip], - bookings: list[StrDict], - accommodations: list[StrDict], - airports: StrDict, - trains: list[StrDict] | None = None, - stations: StrDict | None = None, - ferries: list[StrDict] | None = None, - terminals: StrDict | None = None, ) -> tuple[str | None, pycountry.db.Country | None]: """Get location (city, country) for a specific date using travel history.""" # First check if currently on a trip for trip in trips: - if trip.start <= target_date <= (trip.end or trip.start): - # For trips, find the most recent travel within the trip period - trip_location = _find_most_recent_travel_within_trip( - trip, - target_date, - bookings, - accommodations, - airports, - trains, - stations, - ferries, - terminals, - ) - if trip_location: - return trip_location + if not (trip.start <= target_date <= (trip.end or trip.start)): + continue + # For trips, find the most recent travel within the trip period + trip_location = _find_most_recent_travel_within_trip( + trip, + target_date, + ) + if trip_location: + return trip_location - # Fallback: determine location based on trip progression and date - progression_location = _get_trip_location_by_progression(trip, target_date) - if progression_location: - return progression_location + # Fallback: determine location based on trip progression and date + progression_location = _get_trip_location_by_progression(trip, target_date) + if progression_location: + return progression_location # Find most recent travel before this date - recent_travel = _find_most_recent_travel_before_date( - target_date, - bookings, - accommodations, - airports, - trains, - stations, - ferries, - terminals, - ) + recent_travel = _find_most_recent_travel_before_date(target_date, trips) # Check for recent trips that have ended - prioritize this over individual travel data # This handles cases where you're traveling home after a trip (e.g. stopovers, connections) @@ -583,15 +570,6 @@ def weekends( else: start_date = start + timedelta(days=(5 - weekday)) - # Parse YAML files once for all location lookups - bookings = travel.parse_yaml("flights", data_dir) - accommodations = travel.parse_yaml("accommodation", data_dir) - airports = travel.parse_yaml("airports", data_dir) - trains = travel.parse_yaml("trains", data_dir) - stations = travel.parse_yaml("stations", data_dir) - ferries = travel.parse_yaml("ferries", data_dir) - terminals = travel.parse_yaml("ferry_terminals", data_dir) - weekends_info = [] for i in range(52): saturday = start_date + timedelta(weeks=i) @@ -611,24 +589,10 @@ def weekends( saturday_location = get_location_for_date( saturday, trips, - bookings, - accommodations, - airports, - trains, - stations, - ferries, - terminals, ) sunday_location = get_location_for_date( sunday, trips, - bookings, - accommodations, - airports, - trains, - stations, - ferries, - terminals, ) weekends_info.append( diff --git a/tests/test_busy.py b/tests/test_busy.py index 026eeb9..8a12d8c 100644 --- a/tests/test_busy.py +++ b/tests/test_busy.py @@ -73,9 +73,6 @@ def test_specific_home_dates(travel_data): location = agenda.busy.get_location_for_date( test_date, trips, - travel_data["bookings"], - travel_data["accommodations"], - travel_data["airports"], ) assert not location[ 0 @@ -94,9 +91,6 @@ def test_specific_away_dates(travel_data): location = agenda.busy.get_location_for_date( test_date, trips, - travel_data["bookings"], - travel_data["accommodations"], - travel_data["airports"], ) assert ( location[0] == expected_city @@ -111,9 +105,6 @@ def test_get_location_for_date_basic(travel_data): location = agenda.busy.get_location_for_date( test_date, trips, - travel_data["bookings"], - travel_data["accommodations"], - travel_data["airports"], ) # Should return a tuple with (city|None, country) @@ -178,68 +169,6 @@ def test_parse_datetime_field(): assert parsed_dt.day == 1 -def test_train_location_helpers(): - """Test the train location helper functions.""" - from agenda.busy import _get_train_location - - # Mock station data - stations = [ - {"name": "London St Pancras", "country": "gb"}, - {"name": "Brussels Midi", "country": "be"}, - {"name": "Edinburgh Waverley", "country": "gb"}, - ] - - # Test UK station when not on trip (should return None for home) - train_leg = {"to": "London St Pancras"} - location = _get_train_location(train_leg, stations, on_trip=False) - assert location[0] is None # Should be home - assert location[1].alpha_2 == "GB" - - # Test UK station when on trip (should return city name) - location = _get_train_location(train_leg, stations, on_trip=True) - assert location[0] == "London St Pancras" - assert location[1].alpha_2 == "GB" - - # Test non-UK station - train_leg = {"to": "Brussels Midi"} - location = _get_train_location(train_leg, stations, on_trip=False) - assert location[0] == "Brussels Midi" - assert location[1].alpha_2 == "BE" - - -def test_ferry_location_helpers(): - """Test the ferry location helper functions.""" - from agenda.busy import _get_ferry_location - - # Mock terminal data - terminals = [ - {"name": "Dover Eastern Docks", "country": "gb", "city": "Dover"}, - {"name": "Calais Ferry Terminal", "country": "fr", "city": "Calais"}, - { - "name": "Portsmouth Continental Terminal", - "country": "gb", - "city": "Portsmouth", - }, - ] - - # Test UK terminal when not on trip (should return None for home) - ferry = {"to": "Dover Eastern Docks"} - location = _get_ferry_location(ferry, terminals, on_trip=False) - assert location[0] is None # Should be home - assert location[1].alpha_2 == "GB" - - # Test UK terminal when on trip (should return city name) - location = _get_ferry_location(ferry, terminals, on_trip=True) - assert location[0] == "Dover" - assert location[1].alpha_2 == "GB" - - # Test non-UK terminal - ferry = {"to": "Calais Ferry Terminal"} - location = _get_ferry_location(ferry, terminals, on_trip=False) - assert location[0] == "Calais" - assert location[1].alpha_2 == "FR" - - def test_get_busy_events(app_context, trips): """Test get_busy_events function.""" start_date = date(2023, 1, 1) From 46091779f01e8177bcd6d966e778bb9a3d84780a Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 12:12:27 +0200 Subject: [PATCH 5/6] Format code with black, remove unused import. --- web_view.py | 42 ++++++++++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/web_view.py b/web_view.py index 3a23807..c670732 100755 --- a/web_view.py +++ b/web_view.py @@ -7,7 +7,6 @@ import inspect import json import operator import os.path -import pprint import sys import time import traceback @@ -260,7 +259,9 @@ async def weekends() -> str: trip_list = agenda.trip.build_trip_list() busy_events = agenda.busy.get_busy_events(start, app.config, trip_list) - weekends = agenda.busy.weekends(start, busy_events, trip_list, app.config["PERSONAL_DATA"]) + weekends = agenda.busy.weekends( + start, busy_events, trip_list, app.config["PERSONAL_DATA"] + ) return flask.render_template( "weekends.html", items=weekends, @@ -614,23 +615,36 @@ def trip_debug_page(start: str) -> str: "computed_properties": { "title": trip.title, "end": trip.end.isoformat() if trip.end else None, - "countries": [{"name": c.name, "alpha_2": c.alpha_2, "flag": c.flag} for c in trip.countries], - "locations": [{"location": loc, "country": {"name": country.name, "alpha_2": country.alpha_2}} for loc, country in trip.locations()], + "countries": [ + {"name": c.name, "alpha_2": c.alpha_2, "flag": c.flag} + for c in trip.countries + ], + "locations": [ + { + "location": loc, + "country": {"name": country.name, "alpha_2": country.alpha_2}, + } + for loc, country in trip.locations() + ], "total_distance": trip.total_distance(), "total_co2_kg": trip.total_co2_kg(), "distances_by_transport_type": trip.distances_by_transport_type(), "co2_by_transport_type": trip.co2_by_transport_type(), }, - "schengen_compliance": { - "total_days_used": trip.schengen_compliance.total_days_used, - "days_remaining": trip.schengen_compliance.days_remaining, - "is_compliant": trip.schengen_compliance.is_compliant, - "current_180_day_period": [ - trip.schengen_compliance.current_180_day_period[0].isoformat(), - trip.schengen_compliance.current_180_day_period[1].isoformat() - ], - "days_over_limit": trip.schengen_compliance.days_over_limit, - } if trip.schengen_compliance else None, + "schengen_compliance": ( + { + "total_days_used": trip.schengen_compliance.total_days_used, + "days_remaining": trip.schengen_compliance.days_remaining, + "is_compliant": trip.schengen_compliance.is_compliant, + "current_180_day_period": [ + trip.schengen_compliance.current_180_day_period[0].isoformat(), + trip.schengen_compliance.current_180_day_period[1].isoformat(), + ], + "days_over_limit": trip.schengen_compliance.days_over_limit, + } + if trip.schengen_compliance + else None + ), } # Convert to JSON for pretty printing From 22036771463c65d21dac680494ce29eb3e2edb9f Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Wed, 16 Jul 2025 12:12:47 +0200 Subject: [PATCH 6/6] Trip debug page needs user to be authenticated. --- web_view.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web_view.py b/web_view.py index c670732..0f137b8 100755 --- a/web_view.py +++ b/web_view.py @@ -592,6 +592,10 @@ def trip_page(start: str) -> str: @app.route("/trip//debug") def trip_debug_page(start: str) -> str: """Trip debug page showing raw trip object data.""" + + if not flask.g.user.is_authenticated: + flask.abort(401) + route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"]) trip_list = get_trip_list(route_distances)