From 8866b7820287bf98d36271c6a83ca82c371ace7a Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 3 Aug 2026 14:57:06 +0100 Subject: [PATCH] Refine conference exploration day labels --- agenda/trip.py | 67 +++++++++++++++++++- tests/test_trip.py | 150 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 1 deletion(-) diff --git a/agenda/trip.py b/agenda/trip.py index 9c538e1..6de8e4d 100644 --- a/agenda/trip.py +++ b/agenda/trip.py @@ -6,7 +6,7 @@ import json import os import typing import unicodedata -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, time, timedelta, timezone import flask import pycountry @@ -22,6 +22,7 @@ COACH_CO2_KG_PER_KM = 0.027 FERRY_CO2_KG_PER_KM = 0.02254 BUS_CO2_KG_PER_KM = 0.1 CAR_CO2_KG_PER_KM = 0.218 +NOON = time(12) class Airline(typing.TypedDict, total=False): @@ -786,6 +787,58 @@ def add_coordinates_for_eurostar_home_feeder( add_coordinate_if_missing(coordinates, coordinate_dict(from_station, "station")) +def timed_departures(item: StrDict) -> list[datetime]: + """Return timed train/flight departure datetimes from a travel item.""" + depart_values: list[typing.Any] = [] + + if item.get("type") in ("flight", "train"): + depart_values.append(item.get("depart")) + + if item.get("type") == "train": + legs = item.get("legs", []) + if isinstance(legs, list): + depart_values.extend( + leg.get("depart") for leg in legs if isinstance(leg, dict) + ) + + return [value for value in depart_values if isinstance(value, datetime)] + + +def timed_arrivals(item: StrDict) -> list[datetime]: + """Return timed train/flight arrival datetimes from a travel item.""" + arrive_values: list[typing.Any] = [] + + if item.get("type") in ("flight", "train"): + arrive_values.append(item.get("arrive")) + + if item.get("type") == "train": + legs = item.get("legs", []) + if isinstance(legs, list): + arrive_values.extend( + leg.get("arrive") for leg in legs if isinstance(leg, dict) + ) + + return [value for value in arrive_values if isinstance(value, datetime)] + + +def has_morning_train_or_flight_departure(trip: Trip, target_date: date) -> bool: + """Return true if a train or flight leaves before noon on target_date.""" + return any( + depart.date() == target_date and depart.time() < NOON + for item in trip.travel + for depart in timed_departures(item) + ) + + +def has_afternoon_train_or_flight_arrival(trip: Trip, target_date: date) -> bool: + """Return true if a train or flight arrives after noon on target_date.""" + return any( + arrive.date() == target_date and arrive.time() > NOON + for item in trip.travel + for arrive in timed_arrivals(item) + ) + + def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]: """Return (days_before, days_after) exploration days for each conference. @@ -814,6 +867,18 @@ def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]: ) days_before = (conf_attend_start(conf) - before_boundary).days days_after = (after_boundary - conf_attend_end(conf)).days + if ( + i == 0 + and days_before > 0 + and has_afternoon_train_or_flight_arrival(trip, before_boundary) + ): + days_before -= 1 + if ( + i == len(sorted_confs) - 1 + and days_after > 0 + and has_morning_train_or_flight_departure(trip, after_boundary) + ): + days_after -= 1 result[str(conf["start"])] = (days_before, days_after) return result diff --git a/tests/test_trip.py b/tests/test_trip.py index c3657c1..880579f 100644 --- a/tests/test_trip.py +++ b/tests/test_trip.py @@ -467,3 +467,153 @@ def test_trip_title_ignores_generated_drive_stop_labels() -> None: ) assert trip.title == "St Ives" + + +def test_conference_free_days_ignores_morning_train_departure_after() -> None: + """A morning train on the day after a conference leaves no explore day.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc), + "arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc), + "legs": [ + { + "depart": datetime(2027, 2, 1, 8, 57, tzinfo=timezone.utc), + "arrive": datetime(2027, 2, 1, 9, 57, tzinfo=timezone.utc), + } + ], + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0) + + +def test_conference_free_days_ignores_afternoon_train_arrival_before() -> None: + """An afternoon train on the day before a conference leaves no explore day.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc), + "arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc), + "legs": [ + { + "depart": datetime(2027, 1, 29, 13, 4, tzinfo=timezone.utc), + "arrive": datetime(2027, 1, 29, 16, 5, tzinfo=timezone.utc), + } + ], + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (0, 0) + + +def test_conference_free_days_keeps_morning_train_arrival_before() -> None: + """A morning train arrival still leaves the afternoon to explore.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": datetime(2027, 1, 29, 8, 0, tzinfo=timezone.utc), + "arrive": datetime(2027, 1, 29, 11, 30, tzinfo=timezone.utc), + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0) + + +def test_conference_free_days_keeps_date_only_arrival_before() -> None: + """Date-only travel should not be treated as a known afternoon arrival.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": date(2027, 1, 29), + "arrive": date(2027, 1, 29), + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 0) + + +def test_conference_free_days_keeps_afternoon_train_departure_after() -> None: + """An afternoon train still leaves the morning to explore.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": datetime(2027, 2, 1, 13, 4, tzinfo=timezone.utc), + "arrive": datetime(2027, 2, 1, 16, 5, tzinfo=timezone.utc), + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1) + + +def test_conference_free_days_keeps_date_only_departure_after() -> None: + """Date-only travel should not be treated as a known morning departure.""" + trip = Trip( + start=date(2027, 1, 29), + conferences=[ + { + "name": "FOSDEM", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + } + ], + travel=[ + { + "type": "train", + "depart": date(2027, 2, 1), + "arrive": date(2027, 2, 1), + } + ], + ) + + assert agenda.trip.conference_free_days(trip)["2027-01-30"] == (1, 1)