Refine conference exploration day labels

This commit is contained in:
Edward Betts 2026-08-03 14:57:06 +01:00
parent 6a601f5c14
commit 8866b78202
2 changed files with 216 additions and 1 deletions

View file

@ -6,7 +6,7 @@ import json
import os import os
import typing import typing
import unicodedata import unicodedata
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, time, timedelta, timezone
import flask import flask
import pycountry import pycountry
@ -22,6 +22,7 @@ COACH_CO2_KG_PER_KM = 0.027
FERRY_CO2_KG_PER_KM = 0.02254 FERRY_CO2_KG_PER_KM = 0.02254
BUS_CO2_KG_PER_KM = 0.1 BUS_CO2_KG_PER_KM = 0.1
CAR_CO2_KG_PER_KM = 0.218 CAR_CO2_KG_PER_KM = 0.218
NOON = time(12)
class Airline(typing.TypedDict, total=False): 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")) 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]]: def conference_free_days(trip: Trip) -> dict[str, tuple[int, int]]:
"""Return (days_before, days_after) exploration days for each conference. """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_before = (conf_attend_start(conf) - before_boundary).days
days_after = (after_boundary - conf_attend_end(conf)).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) result[str(conf["start"])] = (days_before, days_after)
return result return result

View file

@ -467,3 +467,153 @@ def test_trip_title_ignores_generated_drive_stop_labels() -> None:
) )
assert trip.title == "St Ives" 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)