"""Types.""" import collections import datetime import typing from collections import defaultdict from dataclasses import dataclass, field import emoji from pycountry.db import Country import agenda from agenda import format_list_with_ampersand from . import utils StrDict = dict[str, typing.Any] DateOrDateTime = datetime.datetime | datetime.date @dataclass class TripElement: """Trip element.""" start_time: DateOrDateTime title: str element_type: str detail: StrDict end_time: DateOrDateTime | None = None start_loc: str | None = None end_loc: str | None = None start_country: Country | None = None end_country: Country | None = None def get_emoji(self) -> str | None: """Emoji for trip element.""" emoji_map = { "check-in": ":hotel:", "check-out": ":hotel:", "train": ":train:", "flight": ":airplane:", "ferry": ":ferry:", } alias = emoji_map.get(self.element_type) return emoji.emojize(alias, language="alias") if alias else None def airport_label(airport: StrDict) -> str: """Airport label: name and iata.""" name = airport.get("alt_name") or airport["city"] return f"{name} ({airport['iata']})" @dataclass class Trip: """Trip.""" start: datetime.date travel: list[StrDict] = field(default_factory=list) accommodation: list[StrDict] = field(default_factory=list) conferences: list[StrDict] = field(default_factory=list) events: list[StrDict] = field(default_factory=list) flight_bookings: list[StrDict] = field(default_factory=list) name: str | None = None private: bool = False @property def title(self) -> str: """Trip title.""" if self.name: return self.name titles: list[str] = [conf["name"] for conf in self.conferences] + [ event["title"] for event in self.events ] if not titles: for travel in self.travel: if travel["depart"] and travel["depart"].date() != self.start: place = travel["from"] if place not in titles: titles.append(place) if travel["depart"] and travel["depart"].date() != self.end: place = travel["to"] if place not in titles: titles.append(place) return format_list_with_ampersand(titles) or "[unnamed trip]" @property def end(self) -> datetime.date | None: """End date for trip.""" max_conference_end = ( max(utils.as_date(item["end"]) for item in self.conferences) if self.conferences else datetime.date.min ) assert isinstance(max_conference_end, datetime.date) arrive = [ utils.as_date(item["arrive"]) for item in self.travel if "arrive" in item ] travel_end = max(arrive) if arrive else datetime.date.min assert isinstance(travel_end, datetime.date) accommodation_end = ( max(utils.as_date(item["to"]) for item in self.accommodation) if self.accommodation else datetime.date.min ) assert isinstance(accommodation_end, datetime.date) max_date = max(max_conference_end, travel_end, accommodation_end) return max_date if max_date != datetime.date.min else None def locations(self) -> list[tuple[str, Country]]: """Locations for trip.""" seen: set[tuple[str, str]] = set() items = [] for item in self.conferences + self.accommodation + self.events: if "country" not in item or "location" not in item: continue key = (item["location"], item["country"]) if key in seen: continue seen.add(key) country = agenda.get_country(item["country"]) assert country items.append((item["location"], country)) return items @property def countries(self) -> list[Country]: """Countries visited as part of trip, in order.""" seen: set[str] = set() items: list[Country] = [] for item in self.conferences + self.accommodation + self.events: if "country" not in item: continue if item["country"] in seen: continue seen.add(item["country"]) country = agenda.get_country(item["country"]) assert country items.append(country) for item in self.travel: travel_countries = set() if item["type"] == "flight": for key in "from_airport", "to_airport": c = item[key]["country"] travel_countries.add(c) if item["type"] == "train": for leg in item["legs"]: for key in "from_station", "to_station": c = leg[key]["country"] travel_countries.add(c) for c in travel_countries - seen: seen.add(c) country = agenda.get_country(c) assert country items.append(country) # Don't include GB in countries visited unless entire trip was GB based return [c for c in items if c.alpha_2 != "GB"] or items @property def countries_str(self) -> str: """List of countries visited on this trip.""" return format_list_with_ampersand( [f"{c.name} {c.flag}" for c in self.countries] ) @property def locations_str(self) -> str: """List of countries visited on this trip.""" return format_list_with_ampersand( [f"{location} ({c.name}) {c.flag}" for location, c in self.locations()] ) @property def country_flags(self) -> str: """Countries flags for trip.""" return "".join(c.flag for c in self.countries) def total_distance(self) -> float | None: """Total distance for trip.""" return ( sum(t["distance"] for t in self.travel) if all(t.get("distance") for t in self.travel) else None ) @property def flights(self) -> list[StrDict]: """Flights.""" return [item for item in self.travel if item["type"] == "flight"] def distances_by_transport_type(self) -> list[tuple[str, float]]: """Calculate the total distance travelled for each type of transport. Any travel item with a missing or None 'distance' field is ignored. """ transport_distances: defaultdict[str, float] = defaultdict(float) for item in self.travel: distance = item.get("distance") if distance: transport_type: str = item.get("type", "unknown") transport_distances[transport_type] += distance return list(transport_distances.items()) def elements(self) -> list[TripElement]: """Trip elements ordered by time.""" elements: list[TripElement] = [] for item in self.accommodation: title = "Airbnb" if item.get("operator") == "airbnb" else item["name"] start = TripElement( start_time=item["from"], title=title, detail=item, element_type="check-in", ) elements.append(start) end = TripElement( start_time=item["to"], title=title, detail=item, element_type="check-out", ) elements.append(end) for item in self.travel: if item["type"] == "flight": name = ( f"{airport_label(item['from_airport'])} β†’ " + f"{airport_label(item['to_airport'])}" ) from_country = agenda.get_country(item["from_airport"]["country"]) to_country = agenda.get_country(item["to_airport"]["country"]) elements.append( TripElement( start_time=item["depart"], end_time=item.get("arrive"), title=name, detail=item, element_type="flight", start_loc=airport_label(item["from_airport"]), end_loc=airport_label(item["to_airport"]), start_country=from_country, end_country=to_country, ) ) if item["type"] == "train": for leg in item["legs"]: from_country = agenda.get_country(leg["from_station"]["country"]) to_country = agenda.get_country(leg["to_station"]["country"]) assert from_country and to_country name = f"{leg['from']} β†’ {leg['to']}" elements.append( TripElement( start_time=leg["depart"], end_time=leg["arrive"], title=name, detail=leg, element_type="train", start_loc=leg["from"], end_loc=leg["to"], start_country=from_country, end_country=to_country, ) ) if item["type"] == "ferry": from_country = agenda.get_country(item["from_terminal"]["country"]) to_country = agenda.get_country(item["to_terminal"]["country"]) name = f"{item['from']} β†’ {item['to']}" elements.append( TripElement( start_time=item["depart"], end_time=item["arrive"], title=name, detail=item, element_type="ferry", start_loc=item["from"], end_loc=item["to"], start_country=from_country, end_country=to_country, ) ) return sorted(elements, key=lambda e: utils.as_datetime(e.start_time)) def elements_grouped_by_day(self) -> list[tuple[datetime.date, list[TripElement]]]: """Group trip elements by day.""" # Create a dictionary to hold lists of TripElements grouped by their date grouped_elements: collections.defaultdict[datetime.date, list[TripElement]] = ( collections.defaultdict(list) ) for element in self.elements(): # Extract the date part of the 'when' attribute day = utils.as_date(element.start_time) grouped_elements[day].append(element) # Sort elements within each day for day in grouped_elements: grouped_elements[day].sort( key=lambda e: ( e.element_type == "check-in", # check-out elements last e.element_type != "check-out", # check-in elements first utils.as_datetime(e.start_time), # then sort by time ) ) # Convert the dictionary to a sorted list of tuples grouped_elements_list = sorted(grouped_elements.items()) return grouped_elements_list # Example usage: # You would call the function with your travel list here to get the results. @dataclass class Holiday: """Holiay.""" name: str country: str date: datetime.date local_name: str | None = None @property def display_name(self) -> str: """Format name for display.""" return ( f"{self.name} ({self.local_name})" if self.local_name and self.local_name != self.name else self.name ) emojis = { "market": "🧺", "us_presidential_election": "πŸ—³οΈπŸ‡ΊπŸ‡Έ", "bus_route_closure": "🚌❌", "meetup": "πŸ‘₯", "dinner": "🍷", "party": "🍷", "ba_voucher": "✈️", "accommodation": "🏨", # alternative: 🧳 "flight": "✈️", "conference": "🎀", "rocket": "πŸš€", "birthday": "🎈", "waste_schedule": "πŸ—‘οΈ", "economist": "πŸ“°", "running": "πŸƒ", "critical_mass": "🚴", "trip": "🧳", "hackathon": "πŸ’»", } @dataclass class Event: """Event.""" name: str date: DateOrDateTime end_date: DateOrDateTime | None = None title: str | None = None url: str | None = None going: bool | None = None @property def as_datetime(self) -> datetime.datetime: """Date/time of event.""" return utils.as_datetime(self.date) @property def has_time(self) -> bool: """Event has a time associated with it.""" return isinstance(self.date, datetime.datetime) @property def as_date(self) -> datetime.date: """Date of event.""" return ( self.date.date() if isinstance(self.date, datetime.datetime) else self.date ) @property def end_as_date(self) -> datetime.date: """Date of event.""" return ( ( self.end_date.date() if isinstance(self.end_date, datetime.datetime) else self.end_date ) if self.end_date else self.as_date ) @property def display_time(self) -> str | None: """Time for display on web page.""" return ( self.date.strftime("%H:%M") if isinstance(self.date, datetime.datetime) else None ) @property def display_timezone(self) -> str | None: """Timezone for display on web page.""" return ( self.date.strftime("%z") if isinstance(self.date, datetime.datetime) else None ) def display_duration(self) -> str | None: """Duration for display.""" if self.end_as_date != self.as_date or not self.has_time: return None assert isinstance(self.date, datetime.datetime) assert isinstance(self.end_date, datetime.datetime) secs: int = int((self.end_date - self.date).total_seconds()) hours: int = secs // 3600 mins: int = (secs % 3600) // 60 if mins == 0: return f"{hours:d}h" if hours == 0: return f"{mins:d} mins" return f"{hours:d}h {mins:02d} mins" def delta_days(self, today: datetime.date) -> str: """Return number of days from today as a string.""" delta = (self.as_date - today).days match delta: case 0: return "today" case 1: return "1 day" case _: return f"{delta:,d} days" @property def display_date(self) -> str: """Date for display on web page.""" if isinstance(self.date, datetime.datetime): return self.date.strftime("%a, %d, %b %Y %H:%M %z") else: return self.date.strftime("%a, %d, %b %Y") @property def display_title(self) -> str: """Name for display.""" return self.title or self.name @property def emoji(self) -> str | None: """Emoji.""" if self.title == "LHG Run Club": return "πŸƒπŸ»" return emojis.get(self.name) @property def title_with_emoji(self) -> str | None: """Title with optional emoji at the start.""" title = self.title or self.name if title is None: return None emoji = self.emoji return f"{emoji} {title}" if emoji else title