"""Types.""" import collections import datetime import typing from collections import Counter from dataclasses import dataclass, field from pycountry.db import Country import agenda from agenda import format_list_with_ampersand StrDict = dict[str, typing.Any] DateOrDateTime = datetime.datetime | datetime.date def as_date(d: DateOrDateTime) -> datetime.date: """Convert datetime to date.""" if isinstance(d, datetime.datetime): return d.date() assert isinstance(d, datetime.date) return d def as_datetime(d: DateOrDateTime) -> datetime.datetime: """Date/time of event.""" t0 = datetime.datetime.min.time() return ( d if isinstance(d, datetime.datetime) else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc) ) @dataclass class TripElement: """Trip element.""" when: DateOrDateTime title: str element_type: str detail: StrDict @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) 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(as_date(item["end"]) for item in self.conferences) if self.conferences else datetime.date.min ) assert isinstance(max_conference_end, datetime.date) arrive = [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(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) return 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 ) 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: Counter[float] = Counter() for item in self.travel: distance = item.get("distance") if distance: transport_type = 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( when=item["from"], title=title, detail=item, element_type="check-in", ) elements.append(start) end = TripElement( when=item["to"], title=title, detail=item, element_type="check-out", ) elements.append(end) for item in self.travel: if item["type"] == "flight": flight_from = item["from_airport"] flight_to = item["to_airport"] name = ( "βοΈ " + f"{flight_from['name']} ({flight_from['iata']}) -> " + f"{flight_to['name']} ({flight_to['iata']})" ) elements.append( TripElement( when=item["depart"], title=name, detail=item, element_type="flight", ) ) if item["type"] == "train": name = f"{item['from']} -> {item['to']}" elements.append( TripElement( when=item["depart"], title=name, detail=item, element_type="train", ) ) if item["type"] == "ferry": name = f"{item['from']} -> {item['to']}" elements.append( TripElement( when=item["depart"], title=name, detail=item, element_type="ferry", ) ) return sorted(elements, key=lambda e: as_datetime(e.when)) 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 = as_date(element.when) grouped_elements[day].append(element) # 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.""" d = self.date t0 = datetime.datetime.min.time() return ( d if isinstance(d, datetime.datetime) else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc) ) @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