agenda/agenda/types.py
Edward Betts cd16b857a0 Split trip list into future and past pages
Redo page layout and trip display. Map is now shown on the right.
2024-05-18 12:02:21 +02:00

434 lines
13 KiB
Python

"""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