From 7ec36a5e807c19dd8f1fd7be60e2f4d8a0dd8a5b Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 22 Jun 2026 09:06:09 +0100 Subject: [PATCH 1/3] Document personal data YAML formats --- docs/personal-data-yaml.md | 720 +++++++++++++++++++++++++++++++++++++ validate_yaml.py | 62 +++- 2 files changed, 776 insertions(+), 6 deletions(-) create mode 100644 docs/personal-data-yaml.md diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md new file mode 100644 index 0000000..572ece6 --- /dev/null +++ b/docs/personal-data-yaml.md @@ -0,0 +1,720 @@ +# Personal Data YAML Formats + +This document describes the YAML files read from `../personal-data/`. It is intended for humans and LLMs generating new entries. + +## General Rules + +- Use YAML lists for most files. `airports.yaml` is a mapping keyed by IATA code. +- Use ISO-like YAML dates and datetimes: + - Date: `2026-03-14` + - Datetime with timezone: `2026-03-14 09:30:00+01:00` +- Use lowercase ISO 3166-1 alpha-2 country codes, for example `gb`, `be`, `us`. +- Use quoted strings for prices and identifiers that might otherwise be parsed as numbers: `'154.34'`, `'06525269'`, `'0042'`. +- Currencies must be in `config.CURRENCIES` or `GBP`. +- Travel and trip-related entries are grouped by the `trip` date. That date should match an entry in `trips.yaml` when a named trip is needed, but trip groups can also be created from travel/accommodation/conference entries. +- Keep chronological files sorted by their natural start field. `validate_yaml.py` checks ordering for trips, flights, trains, ferries, conferences, and accommodation. +- Coordinates are `latitude` then `longitude`, both numeric. + +## Cross-File References + +- `flights.yaml` flight `airline` values reference `airlines.yaml` `iata`. +- `flights.yaml` flight `from` and `to` values reference `airports.yaml` keys. +- `trains.yaml` journey and leg `from` and `to` values reference `stations.yaml` `name`. +- `ferries.yaml` `from` and `to` values reference `ferry_terminals.yaml` `name`. +- `buses.yaml` `from` and `to` values reference `bus_stops.yaml` `name`. +- `coaches.yaml` `from` and `to` values reference `coach_stations.yaml` `name`. +- Station, stop, and terminal `routes` values name GeoJSON files without the `.geojson` extension. + +## `accommodation.yaml` + +Top-level shape: list of accommodation stays. + +Used by: agenda events, trip pages, trip maps, busy/location logic. + +Required fields: + +- `type`: accommodation category such as `hotel`, `apartment`, `airbnb`. +- `name`: property name. +- `country`: lowercase country code. +- `location`: city or place name. +- `trip`: trip start date. +- `from`: check-in datetime. +- `to`: check-out datetime. + +Common optional fields: + +- Booking: `operator`, `booking_reference`, `confirmation_code`, `booking_url`, `url`, `email`, `phone`. +- Money: `price`, `currency`, `room_rate`, `estimated_taxes`, `estimated_additional_fees`. +- Room/stay: `address`, `room_type`, `room_name`, `room_number`, `number_of_adults`, `breakfast_included`, `breakfast`, `cancellation_policy`, `free_cancellation`, `refundable`. +- Coordinates/IDs: `latitude`, `longitude`, `timezone`, `osm_node`, `wikidata`. +- Loyalty: `rewards`, `radisson_rewards_number`. + +Example: + +```yaml +- type: hotel + operator: Example Hotels + name: Example Central Hotel + location: Brussels + country: be + trip: 2026-02-06 + from: 2026-02-06 15:00:00+01:00 + to: 2026-02-09 11:00:00+01:00 + address: 1 Example Street, Brussels + confirmation_code: ABC123 + price: '312.50' + currency: EUR + number_of_adults: 1 + room_type: Standard double + breakfast_included: true + latitude: 50.8466 + longitude: 4.3528 +``` + +## `airlines.yaml` + +Top-level shape: list of airlines. + +Used by: flight loading and display. + +Required fields: + +- `iata`: two-character IATA airline code. +- `icao`: three-character ICAO airline code. +- `name`: airline name. + +Optional fields: + +- `flight_number_prefer_icao`: boolean. When true, display flight numbers with the ICAO code instead of the IATA code. + +Example: + +```yaml +- iata: BA + icao: BAW + name: British Airways +- iata: U2 + icao: EZY + name: easyJet + flight_number_prefer_icao: true +``` + +## `airports.yaml` + +Top-level shape: mapping keyed by IATA airport code. + +Used by: flight loading, distance calculation, maps, unbooked route hints. + +Required fields for each airport: + +- `iata`: IATA code. Should match the mapping key. +- `name`: airport name. +- `city`: city or main served place. +- `country`: lowercase country code. +- `latitude`, `longitude`: numeric coordinates. +- `qid`: Wikidata QID. + +Optional fields: + +- `alt_name`: display name override used in labels. +- `elevation`: metres. +- `website`, `url`. + +Example: + +```yaml +BRU: + iata: BRU + name: Brussels Airport + city: Brussels + country: be + qid: Q220613 + latitude: 50.9014 + longitude: 4.4844 + elevation: 56 + website: https://www.brusselsairport.be/ +``` + +## `bus_stops.yaml` + +Top-level shape: list of bus stops. + +Used by: bus trip loading, maps, route rendering. + +Required fields: + +- `name`: stop name referenced by `buses.yaml`. +- `city`: city or place. +- `country`: lowercase country code. +- `latitude`, `longitude`: numeric coordinates. +- `routes`: mapping from destination stop name to GeoJSON filename without `.geojson`. + +Optional fields: + +- `Atco`: UK ATCO stop code. +- `osm_node`. + +Example: + +```yaml +- name: West Street + city: Bristol + country: gb + Atco: '0100BRA10073' + osm_node: 485403178 + latitude: 51.4393854 + longitude: -2.6017977 + routes: + Bristol Airport: West_Street_to_Bristol_Airport +``` + +## `buses.yaml` + +Top-level shape: list of bus journeys. + +Used by: trip loading, maps, trip timeline. Bus journeys are not counted for Schengen tracking. + +Required fields: + +- `trip`: trip start date. +- `depart`: departure datetime. +- `arrive`: arrival datetime. `validate_yaml.py` requires arrival after departure and duration no more than 12 hours. +- `from`, `to`: names from `bus_stops.yaml`. + +Optional fields: + +- `operator`, `price`, `currency`. + +Example: + +```yaml +- trip: 2026-03-14 + depart: 2026-03-14 08:20:00+00:00 + arrive: 2026-03-14 08:55:00+00:00 + from: West Street + to: Bristol Airport + operator: First Bus + price: '2.00' + currency: GBP +``` + +## `coach_stations.yaml` + +Top-level shape: list of coach stations. + +Used by: coach trip loading, maps, route rendering. + +Fields are the same pattern as `bus_stops.yaml`, except entries describe coach stations. + +Example: + +```yaml +- name: Example Coach Station + city: Example City + country: gb + latitude: 51.4500 + longitude: -2.5800 + routes: + Other Coach Station: example_city_to_other_city +``` + +## `coaches.yaml` + +Top-level shape: list of coach journeys. + +Used by: trip loading, maps, trip timeline. Coach journeys are not counted for Schengen tracking. + +Required fields: + +- `trip`, `depart`, `arrive`, `from`, `to`. +- `from` and `to` must be names from `coach_stations.yaml`. + +Optional fields: + +- `operator`, `class`, `booking_reference`, `price`, `currency`, `price_details`. + +Example: + +```yaml +- booking_reference: ABC123 + trip: 2026-05-25 + price: '55.00' + currency: GBP + depart: 2026-05-26 14:45:00+01:00 + arrive: 2026-05-26 18:30:00+01:00 + from: Example Coach Station + to: Other Coach Station + operator: Example Coaches + class: Standard + price_details: + base_fare: '55.00' +``` + +## `conferences.yaml` + +Top-level shape: list of conferences and conference-like events. + +Used by: agenda events, trip pages, trip maps, conference list, CFP reminders. + +Required fields: + +- `name`: event name. +- `topic`: topic/category. +- `location`: city or location label. +- `start`: date or datetime. +- `end`: date or datetime. Must be no earlier than `start`, and duration must be under 20 days. + +Common optional fields: + +- Trip/location: `trip`, `country`, `venue`, `address`, `latitude`, `longitude`. +- Attendance: `going`, `registered`, `speaking`, `online`, `accommodation_booked`, `transport_booked`. +- Partial attendance: `attend_start`, `attend_end`. These may be dates or timezone-aware datetimes and are used on trip pages instead of official dates. +- Web/CFP: `url`, `cfp_end`, `cfp_url`, `hashtag`, `description`. +- Money/tickets: `free`, `price`, `currency`, `ticket_type`. +- Other flags: `hackathon`, `attendees`. + +Example: + +```yaml +- name: FOSDEM + topic: FOSDEM + location: Brussels + country: be + trip: 2026-02-06 + start: 2026-02-07 + end: 2026-02-08 + attend_start: 2026-02-07 14:00:00+01:00 + attend_end: 2026-02-08 + going: true + registered: true + accommodation_booked: true + transport_booked: true + url: https://fosdem.org/2026/ + venue: Universite Libre de Bruxelles + address: Av. Franklin Roosevelt 50, 1050 Bruxelles, Belgium + latitude: 50.8132 + longitude: 4.3822 +``` + +## `entities.yaml` + +Top-level shape: list of people/entities. + +Used by: birthday events. + +Required fields for birthday support: + +- `name`: full name. +- `label`: display name. +- `type`: for example `human`. +- `birthday`: mapping with `day`, `month`, and optionally `year`. + +Optional fields: + +- `relation`, `email`. + +If `birthday.year` is omitted, age is shown as unknown. + +Example: + +```yaml +- name: Ada Example + label: Ada + type: human + relation: friend + birthday: + day: 10 + month: 12 + year: 1990 +``` + +## `events.yaml` + +Top-level shape: list of general events. + +Used by: agenda events and trip pages. + +Required fields: + +- `name`: event type. +- One date source: + - `date`: single event date/datetime, or + - `start_date`: used for events with a separate `end_date`, or + - `rrule`: recurrence rule string. + +Optional fields: + +- `title`: display title. +- `end_date`: explicit end date/datetime. +- `duration`: ISO 8601 duration such as `PT2H`, `P1D`. +- `url`. +- Trip/map fields: `trip`, `location`, `country`, `venue`, `address`, `latitude`, `longitude`. + +Special cases: + +- For `name: travel_insurance`, the event date field is `end_date`; no `end_date` is attached to the generated event. +- For recurring events, if the `rrule` has no `BYHOUR`, `BYMINUTE`, or `BYSECOND`, generated events are all-day dates. Otherwise generated datetimes are localized to UK time. +- `skip_trips=True` consumers ignore entries with `trip`. + +Examples: + +```yaml +- name: travel_insurance + start_date: 2026-05-04 + end_date: 2027-05-03 + +- name: meetup + title: Example Geo Meetup + date: 2026-06-18 18:30:00+01:00 + duration: PT2H + url: https://example.org/meetup + location: Bristol + country: gb + latitude: 51.4545 + longitude: -2.5879 + +- name: market + title: Monthly Example Market + rrule: FREQ=MONTHLY;BYDAY=1SA +``` + +## `ferries.yaml` + +Top-level shape: list of ferry journeys. + +Used by: trip loading, maps, trip timeline, Schengen tracking. + +Required fields: + +- `trip`: trip start date. +- `depart`, `arrive`: datetimes. Ferry `arrive` is required. +- `from`, `to`: names from `ferry_terminals.yaml`. + +Common optional fields: + +- `operator`, `ferry`, `direction`, `class`, `booking_reference`, `price`, `currency`. +- `price_details`: free-form mapping of fare components. +- `vehicle`: mapping with fields such as `type`, `registration`, `height`, `length`, `extras`. + +Example: + +```yaml +- booking_reference: ABC123 + trip: 2026-05-04 + price: '302.00' + currency: GBP + depart: 2026-05-04 23:00:00+01:00 + arrive: 2026-05-05 08:00:00+02:00 + from: Portsmouth + to: Cherbourg + operator: Brittany Ferries + class: Commodore cabin + price_details: + base_fare: '153.00' + cabin: '149.00' + vehicle: + type: Example car + registration: AB12CDE + height: 1.63m + length: 4.15m +``` + +## `ferry_terminals.yaml` + +Top-level shape: list of ferry terminals. + +Used by: ferry loading and route rendering. + +Required fields: + +- `name`: terminal name referenced by `ferries.yaml`. +- `city`, `country`. +- `latitude`, `longitude`. +- `routes`: mapping from destination terminal name to GeoJSON filename without `.geojson`. Ferry route rendering expects a GeoJSON route. + +Optional fields: + +- `osm_node`, `osm_way`. + +Example: + +```yaml +- name: Portsmouth + city: Portsmouth + country: gb + osm_way: 123456 + latitude: 50.8120 + longitude: -1.0880 + routes: + Cherbourg: portsmouth_cherbourg +``` + +## `flight_destinations.yaml` + +Top-level shape: list of origin rules for unbooked conference flight route hints. + +Used by: trip maps when a trip has conferences but no booked travel. + +Required fields: + +- `origin`: origin airport IATA code. +- `airline`: airline IATA code. Currently loaded for validation/description but not used in origin selection. +- `destinations`: list of destination airport IATA codes. + +Example: + +```yaml +- origin: BRS + airline: U2 + destinations: + - AMS + - BCN + - CDG +``` + +## `flights.yaml` + +Top-level shape: list of flight bookings. Each booking contains one or more flight legs. + +Used by: agenda transport events, trip loading, maps, distance calculation. + +Required booking fields: + +- `trip`: trip start date. +- `flights`: list of flight leg mappings. + +Common optional booking fields: + +- `booking_reference`, `price`, `currency`. + +Required flight leg fields: + +- `depart`: departure datetime. +- `from`, `to`: airport IATA codes from `airports.yaml`. +- `flight_number`: numeric/string flight number without airline prefix. +- `airline`: airline IATA code from `airlines.yaml`. + +Common optional flight leg fields: + +- Time/location: `arrive`, `from_terminal`, `to_terminal`, `duration`. +- Seat/cabin: `seat`, `seat_type`, `class`, `cabin`. +- Aircraft: `plane`, `registration`. +- Tracking: `distance`, `co2_kg`, `openflights_trip`, `reason`. +- Ticket/passenger: `e_ticket_number`, `ticket_number`, `frequent_flyer_number`, `passenger_name`, `passengers`, `baggage`, `payment_details`. + +`validate_yaml.py` checks that every booking has `trip`, all flight airlines exist in `airlines.yaml`, bookings are sorted by first departure, and currencies are configured. It reports flights missing `co2_kg`. + +Example: + +```yaml +- booking_reference: ABC123 + trip: 2026-04-22 + price: '62.50' + currency: GBP + flights: + - depart: 2026-04-22 17:20:00+01:00 + arrive: 2026-04-22 20:20:00+02:00 + from: LHR + to: BRU + flight_number: '1234' + airline: BA + duration: 01:00 + seat: 5F + seat_type: W + class: C + cabin: business + plane: Airbus A320 + registration: G-ABCD + co2_kg: 154 +``` + +## `follow_launches.yaml` + +Top-level shape: list of SpaceDevs launch slugs. + +Used by: no current in-repo reader was found, but the file appears intended as a watch list for launch update tooling. + +Example: + +```yaml +- starship-integrated-flight-test-5 +- artemis-ii +``` + +## `stations.yaml` + +Top-level shape: list of railway stations. + +Used by: train loading, maps, route rendering. + +Required fields: + +- `name`: station name referenced by `trains.yaml`. +- `country`: lowercase country code. +- `latitude`, `longitude`. +- `routes`: mapping from destination station name to GeoJSON filename without `.geojson`. + +Common optional fields: + +- `uic`, `alpha3`, `wikidata`, `osm_node`. + +Note: the code reads `routes`, not `rotues`; `rotues` appears to be a typo in existing data and should not be used for new entries. + +Example: + +```yaml +- name: London St Pancras + uic: 7015400 + alpha3: STP + wikidata: Q720102 + latitude: 51.531921 + longitude: -0.126361 + country: gb + routes: + Brussels Midi: london_brussels_eurostar +``` + +## `subscriptions.yaml` + +Top-level shape: list of subscriptions. + +Used by: subscription renewal agenda events when `renewal_date` is present. + +Required fields: + +- `name`: subscription name. + +Common optional fields: + +- Dates: `start`, `start_date`, `renewal_date`. +- `price`: mapping with `amount` and `currency`. +- `term`: mapping with `duration` and `unit` or `term_unit`. +- Account: `email`, `account_url`, `account_number`. + +Only items with `renewal_date` create agenda events. + +Example: + +```yaml +- name: Example Magazine + start_date: 2026-01-01 + renewal_date: 2027-01-01 + price: + amount: 99 + currency: GBP + term: + duration: 1 + unit: year + email: me@example.com + account_url: https://example.com/account + account_number: '001234' +``` + +## `trains.yaml` + +Top-level shape: list of train journeys. Each journey contains one or more legs. + +Used by: agenda transport events, trip loading, trip timeline, maps, stats. + +Required journey fields: + +- `operator`: booking/operator label. +- `from`, `to`: station names from `stations.yaml`. +- `trip`: trip start date. +- `depart`, `arrive`: journey datetimes or dates. +- `legs`: list of leg mappings. + +Common optional journey fields: + +- `class`, `number`, `tickets`, `ticket_code`, `total_price`, `co2_kg`. + +Required leg fields: + +- `from`, `to`: station names from `stations.yaml`. +- `depart`, `arrive`. +- `operator`. + +Common optional leg fields: + +- `train`, `number`, `service`, `service_number`, `service_numbers`, `reporting_number`, `mode`. +- Seat/reservation: `coach`, `seat`, `seat_type`, `seat_features`, `reservation_number`, `platform`. +- `class`, `trip`, `url`. + +Ticket fields are free-form but commonly include `booking_reference`, `url`, `price`, `currency`, `booking_date`, `ticket`, `ticket_code`, `ticket_type`, `from`, `to`, `class`, `validity`, `route`, `fare`, `quantity`, `seat_reservation`. + +Example: + +```yaml +- operator: eurostar + from: London St Pancras + to: Brussels Midi + trip: 2026-02-06 + depart: 2026-02-06 15:04:00+00:00 + arrive: 2026-02-06 18:12:00+01:00 + class: Standard Premier + tickets: + - booking_reference: ABCDEF + url: https://example.com/booking/ABCDEF + price: '89.00' + currency: GBP + legs: + - from: London St Pancras + to: Brussels Midi + depart: 2026-02-06 15:04:00+00:00 + arrive: 2026-02-06 18:12:00+01:00 + coach: 1 + seat: 41 + operator: Eurostar +``` + +## `travel_rewards.yaml` + +Top-level shape: list of travel loyalty accounts. + +Used by: no current in-repo reader was found. The file is structured as account metadata. + +Common fields: + +- `name`: programme name. +- `type`: category such as `hotel`, `airline`, `rail`. +- `member_number`: membership identifier. +- `balance`: current points/miles balance. +- `expiry`: expiry date or null. +- `url`: account URL. +- `person`: account holder key/name. +- `email`, `note`. + +Example: + +```yaml +- name: Example Rewards + type: hotel + member_number: '123456789' + balance: 3665 + expiry: 2027-08-15 + url: https://example.com/rewards + person: edward +``` + +## `trips.yaml` + +Top-level shape: list of trip metadata. + +Used by: trip grouping and trip titles. + +Required fields: + +- `trip`: trip start date. This is the grouping key used by travel, accommodation, conferences, and trip events. + +Optional fields: + +- `name`: explicit trip title. +- `private`: boolean. Private trips are hidden from unauthenticated users. + +Example: + +```yaml +- trip: 2026-02-06 + name: Brussels for FOSDEM + private: false +``` diff --git a/validate_yaml.py b/validate_yaml.py index 0fc82ab..1e5b29e 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -36,6 +36,23 @@ def check_currency(item: agenda.types.StrDict) -> None: sys.exit(-1) +def check_country_code( + item: agenda.types.StrDict, source: str, required: bool = True +) -> None: + """Throw error if country code is missing or invalid.""" + country = item.get("country") + if country is None: + if not required: + return + pprint(item) + print(f"{source} missing country") + sys.exit(-1) + if not isinstance(country, str) or not agenda.get_country(country): + pprint(item) + print(f"{source} has invalid country {country!r}") + sys.exit(-1) + + def get_coords(item: agenda.types.StrDict) -> LatLon | None: """Return latitude/longitude tuple when present.""" if "latitude" in item and "longitude" in item: @@ -267,6 +284,8 @@ def check_conferences() -> None: print(f"currency {conf.currency!r} not in {currencies!r}") sys.exit(-1) + check_country_code(conf_data, "conference", required=False) + current_start = normalize_datetime(conf_data["start"]) if prev_start and current_start < prev_start: assert prev_conf_data is not None @@ -290,6 +309,11 @@ def check_events() -> None: last_year = today - timedelta(days=365) next_year = today + timedelta(days=2 * 365) + filepath = os.path.join(data_dir, "events.yaml") + events_data = yaml.safe_load(open(filepath, "r")) + for event in events_data: + check_country_code(event, "event", required=False) + events = agenda.events_yaml.read(data_dir, last_year, next_year) print(len(events), "events") @@ -314,6 +338,7 @@ def check_accommodation() -> None: for stay in accommodation_list: try: assert all(field in stay for field in required_fields) + check_country_code(stay, "accommodation") check_coordinates(stay) except AssertionError: pprint(stay) @@ -347,8 +372,7 @@ def check_airports() -> None: ) print(len(airports), "airports") for airport in airports.values(): - assert "country" in airport - assert agenda.get_country(airport["country"]) + check_country_code(airport, "airport") def check_stations() -> None: @@ -356,8 +380,31 @@ def check_stations() -> None: stations = agenda.travel.parse_yaml("stations", data_dir) print(len(stations), "stations") for station in stations: - assert "country" in station - assert agenda.get_country(station["country"]) + check_country_code(station, "station") + + +def check_ferry_terminals() -> None: + """Check ferry terminals.""" + terminals = agenda.travel.parse_yaml("ferry_terminals", data_dir) + print(len(terminals), "ferry terminals") + for terminal in terminals: + check_country_code(terminal, "ferry terminal") + + +def check_bus_stops() -> None: + """Check bus stops.""" + stops = agenda.travel.parse_yaml("bus_stops", data_dir) + print(len(stops), "bus stops") + for stop in stops: + check_country_code(stop, "bus stop") + + +def check_coach_stations() -> None: + """Check coach stations.""" + stations = agenda.travel.parse_yaml("coach_stations", data_dir) + print(len(stations), "coach stations") + for station in stations: + check_country_code(station, "coach station") def check_ferries() -> None: @@ -415,8 +462,8 @@ def check_buses() -> None: def check_airlines() -> list[agenda.types.StrDict]: """Check airlines.""" - airlines = typing.cast( - list[agenda.types.StrDict], agenda.travel.parse_yaml("airlines", data_dir) + airlines: list[agenda.types.StrDict] = agenda.travel.parse_yaml( + "airlines", data_dir ) print(len(airlines), "airlines") for airline in airlines: @@ -450,6 +497,9 @@ def check() -> None: check_accommodation() check_airports() check_stations() + check_ferry_terminals() + check_bus_stops() + check_coach_stations() if __name__ == "__main__": From 14f5baf77cc662349f9dbed999549a6eb19dc90e Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 22 Jun 2026 09:08:33 +0100 Subject: [PATCH 2/3] Validate conference date ranges --- validate_yaml.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/validate_yaml.py b/validate_yaml.py index 1e5b29e..c8d4022 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -17,6 +17,7 @@ import agenda.data import agenda.travel import agenda.trip import agenda.types +import agenda.utils config = __import__("config.default", fromlist=[""]) data_dir = config.PERSONAL_DATA @@ -267,6 +268,23 @@ def check_trains() -> None: print(len(trains), "trains") +def check_conference_dates(conf: agenda.conference.Conference) -> None: + """Check conference start/end range.""" + if normalize_datetime(conf.start) > normalize_datetime(conf.end): + pprint(conf) + print(f"conference {conf.name!r} ends before it starts") + sys.exit(-1) + + duration = (agenda.utils.as_date(conf.end) - agenda.utils.as_date(conf.start)).days + if duration >= agenda.conference.MAX_CONF_DAYS: + pprint(conf) + print( + f"conference {conf.name!r} is {duration} days; " + + f"maximum is {agenda.conference.MAX_CONF_DAYS - 1}" + ) + sys.exit(-1) + + def check_conferences() -> None: """Check conferences and ensure they are in chronological order.""" filepath = os.path.join(data_dir, "conferences.yaml") @@ -285,6 +303,7 @@ def check_conferences() -> None: sys.exit(-1) check_country_code(conf_data, "conference", required=False) + check_conference_dates(conf) current_start = normalize_datetime(conf_data["start"]) if prev_start and current_start < prev_start: From 098c7e4447f03107c0183d5db8e15507c0c75eb6 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Mon, 22 Jun 2026 09:25:51 +0100 Subject: [PATCH 3/3] Support inexact conference dates Closes: #188 --- agenda/add_new_conference.py | 39 ++++++-- agenda/conference.py | 148 +++++++++++++++++++++++++++---- docs/personal-data-yaml.md | 69 ++++++++++++-- templates/conference_list.html | 17 ++-- tests/test_add_new_conference.py | 42 +++++++++ tests/test_conference.py | 100 ++++++++++++++++++++- tests/test_conference_list.py | 55 ++++++++++++ validate_yaml.py | 24 ++--- web_view.py | 36 +++++--- 9 files changed, 464 insertions(+), 66 deletions(-) create mode 100644 tests/test_conference_list.py diff --git a/agenda/add_new_conference.py b/agenda/add_new_conference.py index e46df3c..71c12ce 100644 --- a/agenda/add_new_conference.py +++ b/agenda/add_new_conference.py @@ -16,6 +16,8 @@ import pycountry import requests import yaml +from agenda.conference import conference_date_fields + USER_AGENT = "add-new-conference/0.1" COORDINATE_PATTERNS = ( re.compile(r"@(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)"), @@ -160,7 +162,9 @@ def webpage_to_text(root: lxml.html.HtmlElement) -> str: text_maker = html2text.HTML2Text() text_maker.ignore_links = True text_maker.ignore_images = True - return text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode")) + return typing.cast( + str, text_maker.handle(lxml.html.tostring(root_copy, encoding="unicode")) + ) def parse_osm_url(url: str) -> tuple[float, float] | None: @@ -267,13 +271,13 @@ def insert_sorted( ) -> list[dict[str, typing.Any]]: """Insert a conference sorted by start date and skip duplicate URLs.""" new_url = new_conf.get("url") - new_start = parse_date(str(new_conf["start"])) + new_start = conference_sort_datetime(new_conf) new_year = new_start.year if new_url: for conf in conferences: if conf.get("url") == new_url: - existing_start = parse_date(str(conf["start"])) + existing_start = conference_sort_datetime(conf) existing_year = existing_start.year if url_has_year_component(new_url): @@ -287,7 +291,7 @@ def insert_sorted( return conferences for idx, conf in enumerate(conferences): - existing_start = parse_date(str(conf["start"])) + existing_start = conference_sort_datetime(conf) if new_start < existing_start: conferences.insert(idx, new_conf) return conferences @@ -295,6 +299,14 @@ def insert_sorted( return conferences +def conference_sort_datetime(conf: dict[str, typing.Any]) -> datetime: + """Return conference sort date as a datetime.""" + sort_date = conference_date_fields(conf)["sort_date"] + if isinstance(sort_date, datetime): + return sort_date + return datetime.combine(sort_date, time()) + + def validate_country(conf: dict[str, typing.Any]) -> None: """Ensure country is a valid ISO 3166-1 alpha-2 code, normalise if possible.""" country = conf.get("country") @@ -377,7 +389,11 @@ def maybe_extract_explicit_end_time(source_text: str) -> int | None: def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> None: """Ensure an end value exists, with a Geomob-specific fallback.""" - start_value = new_conf.get("start") + dates = new_conf.get("dates") + nested_dates = dates if isinstance(dates, dict) else None + start_value = ( + nested_dates.get("start") if nested_dates is not None else new_conf.get("start") + ) if start_value is None: return @@ -395,9 +411,16 @@ def normalise_end_field(new_conf: dict[str, typing.Any], source_text: str) -> No end_hour = 22 geomob_end = start_dt.replace(hour=end_hour, minute=0, second=0, microsecond=0) - new_conf["end"] = same_type_as_start( - start_value, geomob_end, prefer_datetime=True - ) + end_value = same_type_as_start(start_value, geomob_end, prefer_datetime=True) + if nested_dates is not None: + nested_dates["end"] = end_value + else: + new_conf["end"] = end_value + return + + if nested_dates is not None: + if "end" not in nested_dates: + nested_dates["end"] = same_type_as_start(start_value, start_dt) return if "end" not in new_conf: diff --git a/agenda/conference.py b/agenda/conference.py index 642f792..f9e15b4 100644 --- a/agenda/conference.py +++ b/agenda/conference.py @@ -2,14 +2,18 @@ import dataclasses import decimal +import typing from datetime import date, datetime import yaml from . import utils from .event import Event +from .types import DateOrDateTime, StrDict MAX_CONF_DAYS = 20 +DATE_STATUSES = {"exact", "approximate", "tentative"} +DATED_STATUSES = {"exact", "tentative"} @dataclasses.dataclass @@ -19,8 +23,8 @@ class Conference: name: str topic: str location: str - start: date | datetime - end: date | datetime + start: date | datetime | None = None + end: date | datetime | None = None trip: date | None = None country: str | None = None venue: str | None = None @@ -46,6 +50,7 @@ class Conference: attendees: int | None = None hashtag: str | None = None description: str | None = None + dates: StrDict | None = None @property def display_name(self) -> str: @@ -57,22 +62,135 @@ class Conference: ) +def _date_range_label(start: date, end: date) -> str: + """Format conference date range for display.""" + if start == end: + return start.strftime("%a %-d %b %Y") + if start.year == end.year and start.month == end.month: + return f"{start.strftime('%a %-d')}-{end.strftime('%-d %b %Y')}" + return f"{start.strftime('%a %-d %b')}-{end.strftime('%-d %b %Y')}" + + +def _require_date_value(value: typing.Any, field_name: str) -> DateOrDateTime: + """Return date-like field value or raise ValueError.""" + if isinstance(value, (date, datetime)): + return value + raise ValueError(f"conference dates field {field_name!r} must be a date/datetime") + + +def _require_date_only(value: typing.Any, field_name: str) -> date: + """Return field value as a date or raise ValueError.""" + return typing.cast(date, utils.as_date(_require_date_value(value, field_name))) + + +def conference_date_fields(item: StrDict) -> StrDict: + """Return derived date fields for a conference YAML item.""" + raw_dates = item.get("dates") + if raw_dates is None: + status = typing.cast(str, item.get("date_status", "exact")) + if status not in DATE_STATUSES: + raise ValueError(f"unknown conference date status {status!r}") + start = _require_date_value(item.get("start"), "start") + end = _require_date_value(item.get("end", start), "end") + start_date = utils.as_date(start) + end_date = utils.as_date(end) + return { + "date_status": status, + "start": start, + "end": end, + "start_date": start_date, + "end_date": end_date, + "sort_date": start_date, + "latest_date": end_date, + "display_date": _date_range_label(start_date, end_date), + "has_exact_dates": status == "exact", + } + + if not isinstance(raw_dates, dict): + raise ValueError("conference dates must be a mapping") + + status_value = raw_dates.get("status", "exact") + if not isinstance(status_value, str) or status_value not in DATE_STATUSES: + raise ValueError(f"unknown conference date status {status_value!r}") + + if status_value in DATED_STATUSES: + start = _require_date_value(raw_dates.get("start", item.get("start")), "start") + end = _require_date_value(raw_dates.get("end", item.get("end", start)), "end") + start_date = utils.as_date(start) + end_date = utils.as_date(end) + label = raw_dates.get("label") + display_date = ( + label if isinstance(label, str) else _date_range_label(start_date, end_date) + ) + return { + "date_status": status_value, + "start": start, + "end": end, + "start_date": start_date, + "end_date": end_date, + "sort_date": start_date, + "latest_date": end_date, + "display_date": display_date, + "has_exact_dates": status_value == "exact", + } + + earliest = _require_date_only(raw_dates.get("earliest"), "earliest") + latest = _require_date_only(raw_dates.get("latest"), "latest") + label = raw_dates.get("label") + display_date = ( + label if isinstance(label, str) else _date_range_label(earliest, latest) + ) + return { + "date_status": status_value, + "start_date": earliest, + "end_date": latest, + "sort_date": earliest, + "latest_date": latest, + "display_date": display_date, + "has_exact_dates": False, + } + + +def validate_conference_date_fields(item: StrDict) -> StrDict: + """Validate conference date fields and return derived values.""" + fields = conference_date_fields(item) + if fields["start_date"] > fields["end_date"]: + raise ValueError("conference ends before it starts") + + if fields["date_status"] in DATED_STATUSES: + duration = (fields["end_date"] - fields["start_date"]).days + if duration >= MAX_CONF_DAYS: + raise ValueError( + f"conference is {duration} days; maximum is {MAX_CONF_DAYS - 1}" + ) + return fields + + def get_list(filepath: str) -> list[Event]: """Read conferences from a YAML file and return a list of Event objects.""" events: list[Event] = [] - for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))): - assert conf.start <= conf.end - duration = (utils.as_date(conf.end) - utils.as_date(conf.start)).days - assert duration < MAX_CONF_DAYS - event = Event( - name="conference", - date=conf.start, - end_date=conf.end, - title=conf.display_name, - url=conf.url, - going=conf.going, - ) - events.append(event) + for item in yaml.safe_load(open(filepath, "r")): + try: + fields = validate_conference_date_fields(item) + except ValueError as exc: + raise AssertionError(str(exc)) from exc + normalized_item = dict(item) + if "start" in fields: + normalized_item["start"] = fields["start"] + normalized_item["end"] = fields["end"] + conf = Conference(**normalized_item) + + if fields["has_exact_dates"]: + assert conf.start is not None and conf.end is not None + event = Event( + name="conference", + date=conf.start, + end_date=conf.end, + title=conf.display_name, + url=conf.url, + going=conf.going, + ) + events.append(event) if not conf.cfp_end: continue cfp_end_event = Event( diff --git a/docs/personal-data-yaml.md b/docs/personal-data-yaml.md index 572ece6..a538fff 100644 --- a/docs/personal-data-yaml.md +++ b/docs/personal-data-yaml.md @@ -261,8 +261,25 @@ Required fields: - `name`: event name. - `topic`: topic/category. - `location`: city or location label. -- `start`: date or datetime. -- `end`: date or datetime. Must be no earlier than `start`, and duration must be under 20 days. +- Date information, either as legacy top-level `start` and `end`, or preferred nested `dates`. + +Preferred `dates` fields: + +- `status`: one of `exact`, `tentative`, or `approximate`. +- For `exact` and `tentative`: `start` and `end` dates/datetimes. `end` must be no earlier than `start`, and duration must be under 20 days. +- For `approximate`: `earliest` and `latest` dates for sorting/past-future filtering. +- `label`: optional human-readable date text. Recommended for `tentative` and `approximate`, for example `likely first weekend of February 2027` or `March 2027`. +- `basis`: optional explanation of why a tentative date is expected. + +Date status behavior: + +- `exact`: confirmed dates. These create agenda events, iCalendar entries, and timeline bars. +- `tentative`: guessed or unconfirmed exact dates. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars. +- `approximate`: only a broad date range is known. These appear on the conference list with a status badge, but do not create agenda/iCalendar events or timeline bars. + +Legacy fields: + +- Existing top-level `start` and `end` are still supported and are treated as `exact` unless `date_status` says otherwise. Common optional fields: @@ -273,7 +290,7 @@ Common optional fields: - Money/tickets: `free`, `price`, `currency`, `ticket_type`. - Other flags: `hackathon`, `attendees`. -Example: +Exact example: ```yaml - name: FOSDEM @@ -281,8 +298,10 @@ Example: location: Brussels country: be trip: 2026-02-06 - start: 2026-02-07 - end: 2026-02-08 + dates: + status: exact + start: 2026-02-07 + end: 2026-02-08 attend_start: 2026-02-07 14:00:00+01:00 attend_end: 2026-02-08 going: true @@ -296,6 +315,46 @@ Example: longitude: 4.3822 ``` +Tentative example: + +```yaml +- name: FOSDEM + topic: FOSDEM + location: Brussels + country: be + dates: + status: tentative + start: 2027-01-30 + end: 2027-01-31 + label: likely first weekend of February 2027 + basis: FOSDEM is usually on the weekend where Sunday is the first Sunday in February + url: https://fosdem.org/2027/ +``` + +Approximate examples: + +```yaml +- name: Wikimedia Hackathon 2027 + topic: Wikimedia + location: Albania + country: al + dates: + status: approximate + label: mid-April 2027 + earliest: 2027-04-11 + latest: 2027-04-20 + hackathon: true + +- name: PyCascades 2027 + topic: Python + location: TBC + dates: + status: approximate + label: March 2027 + earliest: 2027-03-01 + latest: 2027-03-31 +``` + ## `entities.yaml` Top-level shape: list of people/entities. diff --git a/templates/conference_list.html b/templates/conference_list.html index 4e49074..6529b22 100644 --- a/templates/conference_list.html +++ b/templates/conference_list.html @@ -138,22 +138,21 @@ tr.conf-hl > td { {% set ns = namespace(prev_month="") %} {% for item in item_list %} - {% set month_label = item.start_date.strftime("%B %Y") %} + {% set month_label = item.sort_date.strftime("%B %Y") %} {% if month_label != ns.prev_month %} {% set ns.prev_month = month_label %} {{ month_label }} {% endif %} - + - {%- if item.start_date == item.end_date -%} - {{ item.start_date.strftime("%a %-d %b %Y") }} - {%- elif item.start_date.year == item.end_date.year and item.start_date.month == item.end_date.month -%} - {{ item.start_date.strftime("%a %-d") }}–{{ item.end_date.strftime("%-d %b %Y") }} - {%- else -%} - {{ item.start_date.strftime("%a %-d %b") }}–{{ item.end_date.strftime("%-d %b %Y") }} - {%- endif -%} + {{ item.display_date }} + {% if item.date_status == "tentative" %} + tentative + {% elif item.date_status == "approximate" %} + approximate + {% endif %} {% if item.url %}{{ item.name }} diff --git a/tests/test_add_new_conference.py b/tests/test_add_new_conference.py index 6a7117b..21338d9 100644 --- a/tests/test_add_new_conference.py +++ b/tests/test_add_new_conference.py @@ -47,6 +47,33 @@ def test_insert_sorted_allows_same_url_different_year_without_year_component() - assert updated[1]["name"] == "NewConf" +def test_insert_sorted_supports_nested_dates() -> None: + """Nested dates should be used for sorting.""" + conferences: list[dict[str, typing.Any]] = [ + { + "name": "PyCascades", + "dates": { + "status": "approximate", + "label": "March 2027", + "earliest": date(2027, 3, 1), + "latest": date(2027, 3, 31), + }, + } + ] + new_conf: dict[str, typing.Any] = { + "name": "FOSDEM", + "dates": { + "status": "tentative", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + }, + } + + updated = add_new_conference.insert_sorted(conferences, new_conf) + + assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"] + + def test_validate_country_normalises_name() -> None: """Country names should be normalised to alpha-2 codes.""" conf: dict[str, typing.Any] = {"country": "United Kingdom"} @@ -68,6 +95,21 @@ def test_normalise_end_field_defaults_single_day_date() -> None: assert conf["end"] == date(2026, 4, 10) +def test_normalise_end_field_defaults_nested_exact_date() -> None: + """Nested exact dates should get a default end date.""" + conf: dict[str, typing.Any] = { + "name": "PyCon", + "dates": { + "status": "exact", + "start": date(2026, 4, 10), + }, + } + + add_new_conference.normalise_end_field(conf, "plain text") + + assert conf["dates"]["end"] == date(2026, 4, 10) + + def test_normalise_end_field_sets_geomob_end_time() -> None: """Geomob conferences should default to a 22:00 end time.""" conf: dict[str, typing.Any] = { diff --git a/tests/test_conference.py b/tests/test_conference.py index 811bdb9..d056527 100644 --- a/tests/test_conference.py +++ b/tests/test_conference.py @@ -8,7 +8,7 @@ from typing import Any import pytest import yaml -from agenda.conference import Conference, get_list +from agenda.conference import Conference, conference_date_fields, get_list from agenda.event import Event @@ -298,6 +298,104 @@ class TestGetList: assert event.date == datetime(2024, 5, 15, 9, 0) assert event.end_date == datetime(2024, 5, 17, 17, 0) + def test_get_list_nested_exact_dates(self) -> None: + """Test reading conference with nested exact dates.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "dates": { + "status": "exact", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + }, + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + assert events[0].date == date(2024, 5, 15) + assert events[0].end_date == date(2024, 5, 17) + + def test_get_list_tentative_dates_do_not_create_conference_event(self) -> None: + """Test tentative conference dates are not emitted as calendar events.""" + yaml_data = [ + { + "name": "FOSDEM", + "topic": "FOSDEM", + "location": "Brussels", + "dates": { + "status": "tentative", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + "label": "likely first weekend of February 2027", + }, + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert events == [] + + def test_get_list_approximate_dates_keep_cfp_event(self) -> None: + """Test approximate dates do not block CFP reminders.""" + yaml_data = [ + { + "name": "PyCascades", + "topic": "Python", + "location": "TBC", + "dates": { + "status": "approximate", + "label": "March 2027", + "earliest": date(2027, 3, 1), + "latest": date(2027, 3, 31), + }, + "cfp_end": date(2026, 11, 1), + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + assert events[0].name == "cfp_end" + assert events[0].date == date(2026, 11, 1) + + def test_conference_date_fields_approximate(self) -> None: + """Test derived fields for approximate conference dates.""" + fields = conference_date_fields( + { + "name": "PyCascades", + "topic": "Python", + "location": "TBC", + "dates": { + "status": "approximate", + "label": "March 2027", + "earliest": date(2027, 3, 1), + "latest": date(2027, 3, 31), + }, + } + ) + + assert fields["date_status"] == "approximate" + assert fields["sort_date"] == date(2027, 3, 1) + assert fields["latest_date"] == date(2027, 3, 31) + assert fields["display_date"] == "March 2027" + assert fields["has_exact_dates"] is False + def test_get_list_invalid_date_order(self) -> None: """Test that conferences with end before start raise assertion error.""" yaml_data = [ diff --git a/tests/test_conference_list.py b/tests/test_conference_list.py new file mode 100644 index 0000000..7a09802 --- /dev/null +++ b/tests/test_conference_list.py @@ -0,0 +1,55 @@ +"""Tests for conference list date handling.""" + +from datetime import date +import typing + +import yaml + +import agenda.trip +import web_view + + +def test_build_conference_list_supports_inexact_dates( + tmp_path: typing.Any, monkeypatch: typing.Any +) -> None: + """Conference list should include tentative and approximate dates.""" + conferences = [ + { + "name": "PyCascades 2027", + "topic": "Python", + "location": "TBC", + "dates": { + "status": "approximate", + "label": "March 2027", + "earliest": date(2027, 3, 1), + "latest": date(2027, 3, 31), + }, + }, + { + "name": "FOSDEM 2027", + "topic": "FOSDEM", + "location": "Brussels", + "dates": { + "status": "tentative", + "start": date(2027, 1, 30), + "end": date(2027, 1, 31), + "label": "likely first weekend of February 2027", + }, + }, + ] + (tmp_path / "conferences.yaml").write_text( + yaml.safe_dump(conferences), encoding="utf-8" + ) + + monkeypatch.setitem(web_view.app.config, "PERSONAL_DATA", str(tmp_path)) + monkeypatch.setattr(agenda.trip, "build_trip_list", lambda: []) + + items = web_view.build_conference_list() + + assert [item["name"] for item in items] == ["FOSDEM 2027", "PyCascades 2027"] + assert items[0]["date_status"] == "tentative" + assert items[0]["display_date"] == "likely first weekend of February 2027" + assert items[0]["sort_date"] == date(2027, 1, 30) + assert items[1]["date_status"] == "approximate" + assert items[1]["display_date"] == "March 2027" + assert items[1]["latest_date"] == date(2027, 3, 31) diff --git a/validate_yaml.py b/validate_yaml.py index c8d4022..8baa314 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -268,20 +268,13 @@ def check_trains() -> None: print(len(trains), "trains") -def check_conference_dates(conf: agenda.conference.Conference) -> None: +def check_conference_dates(conf_data: agenda.types.StrDict) -> None: """Check conference start/end range.""" - if normalize_datetime(conf.start) > normalize_datetime(conf.end): - pprint(conf) - print(f"conference {conf.name!r} ends before it starts") - sys.exit(-1) - - duration = (agenda.utils.as_date(conf.end) - agenda.utils.as_date(conf.start)).days - if duration >= agenda.conference.MAX_CONF_DAYS: - pprint(conf) - print( - f"conference {conf.name!r} is {duration} days; " - + f"maximum is {agenda.conference.MAX_CONF_DAYS - 1}" - ) + try: + agenda.conference.validate_conference_date_fields(conf_data) + except ValueError as exc: + pprint(conf_data) + print(f"conference {conf_data.get('name', '[unknown]')!r}: {exc}") sys.exit(-1) @@ -303,9 +296,10 @@ def check_conferences() -> None: sys.exit(-1) check_country_code(conf_data, "conference", required=False) - check_conference_dates(conf) + check_conference_dates(conf_data) - current_start = normalize_datetime(conf_data["start"]) + date_fields = agenda.conference.conference_date_fields(conf_data) + current_start = normalize_datetime(date_fields["sort_date"]) if prev_start and current_start < prev_start: assert prev_conf_data is not None print(f"Out of order conference found:") diff --git a/web_view.py b/web_view.py index 41a4f32..e4981fa 100755 --- a/web_view.py +++ b/web_view.py @@ -7,7 +7,6 @@ import hashlib import importlib import inspect import json -import operator import os.path import sys import time @@ -26,6 +25,7 @@ from authlib.integrations.flask_client import OAuth from werkzeug.middleware.proxy_fix import ProxyFix import agenda.data +import agenda.conference import agenda.error_mail import agenda.fx import agenda.holidays @@ -380,18 +380,18 @@ def build_conference_list() -> list[StrDict]: conference_trip_lookup[key] = trip for conf in items: - conf["start_date"] = agenda.utils.as_date(conf["start"]) - conf["end_date"] = agenda.utils.as_date(conf["end"]) + conf.update(agenda.conference.validate_conference_date_fields(conf)) price = conf.get("price") if price: conf["price"] = decimal.Decimal(price) - key = (conf["start"], conf["name"]) - if this_trip := conference_trip_lookup.get(key): - conf["linked_trip"] = this_trip + if "start" in conf: + key = (conf["start"], conf["name"]) + if this_trip := conference_trip_lookup.get(key): + conf["linked_trip"] = this_trip - items.sort(key=operator.itemgetter("start_date")) + items.sort(key=lambda item: item["sort_date"]) return items @@ -442,7 +442,7 @@ def _conference_description(conf: StrDict) -> str: def build_conference_timeline( current: list[StrDict], future: list[StrDict], today: date, days: int = 90 -) -> dict | None: +) -> dict[str, typing.Any] | None: """Build data for a Gantt-style timeline of upcoming conferences.""" timeline_start = today timeline_end = today + timedelta(days=days) @@ -450,7 +450,9 @@ def build_conference_timeline( visible = [ c for c in (current + future) - if c["start_date"] <= timeline_end and c["end_date"] >= today + if c["has_exact_dates"] + and c["start_date"] <= timeline_end + and c["end_date"] >= today ] if not visible: return None @@ -500,7 +502,9 @@ def build_conference_timeline( d = today.replace(day=1) while d <= timeline_end: off = max((d - timeline_start).days, 0) - months.append({"label": d.strftime("%b %Y"), "left_pct": round(off / days * 100, 2)}) + months.append( + {"label": d.strftime("%b %Y"), "left_pct": round(off / days * 100, 2)} + ) # advance to next month d = (d.replace(day=28) + timedelta(days=4)).replace(day=1) @@ -525,6 +529,8 @@ def build_conference_ical(items: list[StrDict]) -> bytes: generated = datetime.now(tz=timezone.utc) for conf in items: + if not conf["has_exact_dates"]: + continue start_date = agenda.utils.as_date(conf["start"]) end_date = agenda.utils.as_date(conf["end"]) end_exclusive = end_date + timedelta(days=1) @@ -556,9 +562,13 @@ def conference_list() -> str: current = [ conf for conf in items - if conf["start_date"] <= today and conf["end_date"] >= today + if conf["has_exact_dates"] + and conf["start_date"] <= today + and conf["end_date"] >= today + ] + future = [ + conf for conf in items if conf not in current and conf["latest_date"] >= today ] - future = [conf for conf in items if conf["start_date"] > today] timeline = build_conference_timeline(current, future, today) @@ -579,7 +589,7 @@ def past_conference_list() -> str: today = date.today() return flask.render_template( "conference_list.html", - past=[conf for conf in build_conference_list() if conf["end_date"] < today], + past=[conf for conf in build_conference_list() if conf["latest_date"] < today], today=today, get_country=agenda.get_country, fx_rate=agenda.fx.get_rates(app.config),