agenda/agenda/busy.py

426 lines
16 KiB
Python

"""Identify busy events and gaps when nothing is scheduled."""
import itertools
import typing
from datetime import date, datetime, timedelta
import flask
import pycountry
from . import events_yaml, get_country, travel
from .event import Event
from .types import StrDict, Trip
def busy_event(e: Event) -> bool:
"""Busy."""
if e.name not in {
"event",
"accommodation",
"conference",
"transport",
"meetup",
"party",
"trip",
"hackathon",
}:
return False
if e.title in ("IA UK board meeting", "Mill Road Winter Fair"):
return False
if e.name == "conference" and not e.going:
return False
if not e.title:
return True
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
return False
lc_title = e.title.lower()
return (
"rebels" not in lc_title
and "south west data social" not in lc_title
and "dorkbot" not in lc_title
)
def get_busy_events(
start: date, config: flask.config.Config, trips: list[Trip]
) -> list[Event]:
"""Find busy events from a year ago to two years in the future."""
last_year = start - timedelta(days=365)
next_year = start + timedelta(days=2 * 365)
my_data = config["PERSONAL_DATA"]
events = events_yaml.read(my_data, last_year, next_year, skip_trips=True)
for trip in trips:
event_type = "trip"
if trip.events and not trip.conferences:
event_type = trip.events[0]["name"]
elif len(trip.conferences) == 1 and trip.conferences[0].get("hackathon"):
event_type = "hackathon"
events.append(
Event(
name=event_type,
title=trip.title + " " + trip.country_flags,
date=trip.start,
end_date=trip.end,
url=flask.url_for("trip_page", start=trip.start.isoformat()),
)
)
busy_events = [
e
for e in sorted(events, key=lambda e: e.as_date)
if (e.as_date >= start or (e.end_date and e.end_as_date >= start))
and e.as_date < next_year
and busy_event(e)
]
return busy_events
def get_location_for_date(
target_date: date,
trips: list[Trip],
bookings: list[dict],
accommodations: list[dict],
airports: dict,
) -> tuple[str, pycountry.db.Country | None]:
"""Get location (city, country) for a specific date using travel history."""
# UK airports that indicate being home
uk_airports = {"LHR", "LGW", "STN", "LTN", "BRS", "BHX", "MAN", "EDI", "GLA"}
# 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
# to determine exact location on the target date
trip_most_recent_date = None
trip_most_recent_location = None
# Check flights within trip period
trip_most_recent_datetime = None
for booking in bookings:
for flight in booking.get("flights", []):
if "arrive" in flight:
arrive_datetime_obj = flight["arrive"]
if hasattr(arrive_datetime_obj, "date"):
arrive_datetime = arrive_datetime_obj
arrive_date = arrive_datetime_obj.date()
elif isinstance(arrive_datetime_obj, str):
arrive_datetime = datetime.fromisoformat(
arrive_datetime_obj.replace("Z", "+00:00")
)
arrive_date = arrive_datetime.date()
# 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
)
)
):
trip_most_recent_date = arrive_date
trip_most_recent_datetime = arrive_datetime
destination_airport = flight["to"]
if destination_airport in uk_airports:
trip_most_recent_location = (
"Bristol",
get_country("gb"),
)
else:
airport_info = airports.get(destination_airport)
if airport_info:
location_name = airport_info.get(
"city",
airport_info.get(
"name", destination_airport
),
)
trip_most_recent_location = (
location_name,
get_country(
airport_info.get("country", "gb")
),
)
# Check accommodations within trip period
for acc in accommodations:
if "from" in acc:
acc_date = acc["from"]
if hasattr(acc_date, "date"):
acc_date = acc_date.date()
elif isinstance(acc_date, str):
acc_date = datetime.fromisoformat(
acc_date.replace("Z", "+00:00")
).date()
# Only consider accommodations within this trip and before/on target date
if trip.start <= acc_date <= target_date:
# Accommodation takes precedence over flights on the same date
# or if it's genuinely more recent
if (
trip_most_recent_date is None
or acc_date > trip_most_recent_date
or acc_date == trip_most_recent_date
):
trip_most_recent_date = acc_date
if acc.get("country") == "gb":
trip_most_recent_location = (
"Bristol",
get_country("gb"),
)
else:
trip_most_recent_location = (
acc.get("location", "Unknown"),
get_country(acc.get("country", "gb")),
)
# Return the most recent location within the trip, or fallback to first trip location
if trip_most_recent_location:
return trip_most_recent_location
# Fallback to first location if no specific location found
locations = trip.locations()
if locations:
city, country = locations[0]
return (city, country)
# Find most recent flight or accommodation before this date
most_recent_location = None
most_recent_date = None
# Check flights
most_recent_datetime = None
for booking in bookings:
for flight in booking.get("flights", []):
if "arrive" in flight:
arrive_datetime_obj = flight["arrive"]
if hasattr(arrive_datetime_obj, "date"):
arrive_datetime = arrive_datetime_obj
arrive_date = arrive_datetime_obj.date()
elif isinstance(arrive_datetime_obj, str):
arrive_datetime = datetime.fromisoformat(
arrive_datetime_obj.replace("Z", "+00:00")
)
arrive_date = arrive_datetime.date()
if arrive_date <= target_date:
# Compare both date and time to handle same-day flights 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_airport = flight["to"]
# If arriving at UK airport, assume back home in Bristol
if destination_airport in uk_airports:
most_recent_location = ("Bristol", get_country("gb"))
else:
# Get destination airport location for non-UK arrivals
airport_info = airports.get(destination_airport)
if airport_info:
location_name = airport_info.get(
"city",
airport_info.get("name", destination_airport),
)
most_recent_location = (
location_name,
get_country(airport_info.get("country", "gb")),
)
# Check accommodation - only override if accommodation is more recent
for acc in accommodations:
if "from" in acc:
acc_date = acc["from"]
if hasattr(acc_date, "date"):
acc_date = acc_date.date()
elif isinstance(acc_date, str):
acc_date = datetime.fromisoformat(
acc_date.replace("Z", "+00:00")
).date()
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
# For UK accommodation, use Bristol as location
if acc.get("country") == "gb":
most_recent_location = ("Bristol", get_country("gb"))
else:
most_recent_location = (
acc.get("location", "Unknown"),
get_country(acc.get("country", "gb")),
)
# 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)
for trip in trips:
if trip.end and trip.end < target_date:
locations = trip.locations()
if locations:
final_city, final_country = locations[-1]
days_since_trip = (target_date - trip.end).days
# If trip ended in UK, you should be home now
if hasattr(final_country, "alpha_2") and final_country.alpha_2 == "GB":
return ("Bristol", 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 hasattr(final_country, "alpha_2")
and (
# European countries (close by rail/ferry)
final_country.alpha_2
in {"BE", "NL", "FR", "DE", "CH", "AT", "IT", "ES"}
# Nearby Balkan countries
or final_country.alpha_2
in {
"GR",
"AL",
"XK",
"HR",
"SI",
"MK",
"BA",
"ME",
"RS",
"BG",
"RO",
}
# International trips (assume return home after trip ends)
or final_country.alpha_2
in {"US", "CA", "IN", "JP", "CN", "AU", "NZ", "BR", "AR", "ZA"}
)
):
return ("Bristol", get_country("gb"))
# Return most recent location or default to Bristol
if most_recent_location:
return most_recent_location
return ("Bristol", get_country("gb"))
def weekends(
start: date, busy_events: list[Event], trips: list[Trip], data_dir: str
) -> typing.Sequence[StrDict]:
"""Next ten weekends."""
weekday = start.weekday()
# Calculate the difference to the next or previous Saturday
if weekday == 6: # Sunday
start_date = start - timedelta(days=1)
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)
weekends_info = []
for i in range(52):
saturday = start_date + timedelta(weeks=i)
sunday = saturday + timedelta(days=1)
saturday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= saturday <= event.end_as_date
]
sunday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= sunday <= event.end_as_date
]
saturday_location = get_location_for_date(
saturday, trips, bookings, accommodations, airports
)
sunday_location = get_location_for_date(
sunday, trips, bookings, accommodations, airports
)
weekends_info.append(
{
"date": saturday,
"saturday": saturday_events,
"sunday": sunday_events,
"saturday_location": saturday_location,
"sunday_location": sunday_location,
}
)
return weekends_info
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
"""Gaps of at least `min_gap_days` between events in a list of events."""
# Sort events by start date
gaps: list[tuple[date, date]] = []
previous_event_end = None
by_start_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
}
by_end_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
}
for event in events:
# Use start date for current event
start_date = event.as_date
# If previous event exists, calculate the gap
if previous_event_end:
gap_days = (start_date - previous_event_end).days
if gap_days >= (min_gap_days + 2):
start_end = (
previous_event_end + timedelta(days=1),
start_date - timedelta(days=1),
)
gaps.append(start_end)
# Update previous event end date
end = event.end_as_date
if not previous_event_end or end > previous_event_end:
previous_event_end = end
return [
{
"start": gap_start,
"end": gap_end,
"after": by_start_date[gap_end + timedelta(days=1)],
"before": by_end_date[gap_start - timedelta(days=1)],
}
for gap_start, gap_end in gaps
]