From 8a2fb25897576ce4ec766222fa6e95023a5a623c Mon Sep 17 00:00:00 2001 From: ben Date: Tue, 15 Sep 2026 22:36:08 +1000 Subject: [PATCH] airtrail_client: pair every date field with its required Time companion --- airtrail_client.py | 38 ++++++++++++++- boarding-pass-time-fields-fix.patch | 73 +++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 boarding-pass-time-fields-fix.patch diff --git a/airtrail_client.py b/airtrail_client.py index 962f989..afc19b8 100644 --- a/airtrail_client.py +++ b/airtrail_client.py @@ -91,17 +91,43 @@ def downsample_track( return new_coords, new_times +def _hhmm(iso_str: str | None) -> str | None: + """Extract 'HH:MM' from one of our seconds-precision ISO datetime + strings (e.g. '2026-09-13T13:57:00+10:00' -> '13:57'). + + AirTrail does NOT read the time-of-day out of departure/arrival/ + departureScheduled/etc. at all - confirmed against its source + (src/lib/server/utils/flight.ts + src/lib/utils/datetime/parse.ts's + mergeTimeWithDate()): it takes only the DATE portion of that field and + merges it with a separate companion '*Time' field (departureTime, + arrivalTime, departureScheduledTime, ...) for the actual clock time, + interpreted in the airport's own timezone. Sending a fully-populated + ISO datetime without its companion Time field means AirTrail silently + drops the time for departure/departureScheduled/etc., and outright + rejects arrival with "Cannot have arrival date without time" - both + confirmed live. Every date field this module sets below needs its + matching *Time companion or the value doesn't actually land.""" + if not iso_str: + return None + return iso_str[11:16] + + def build_save_payload(row: dict, existing_id: int | None) -> dict: flight_number = f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}" flight_date_str = row["flight_date"] + departure_value = row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00" + payload = { "from": row["from_iata"], "to": row["to_iata"], # Falls back to the bare date (day precision, no time) exactly like # before if AeroDataBox never returned a gate-actual time - e.g. no - # AERODATABOX_API_KEY configured, or the lookup came up empty. - "departure": row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00", + # AERODATABOX_API_KEY configured, or the lookup came up empty. No + # departureTime is set in that case, which AirTrail treats the same + # way (silently date-only) rather than erroring - unlike arrival. + "departure": departure_value, + "departureTime": _hhmm(row.get("gate_departure_actual")), "datePrecision": "day", "flightNumber": flight_number or None, "airline": row.get("operating_carrier_icao"), @@ -128,14 +154,22 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict: # manual Search remains the fallback, same as before this change. if row.get("gate_departure_scheduled"): payload["departureScheduled"] = row["gate_departure_scheduled"] + payload["departureScheduledTime"] = _hhmm(row["gate_departure_scheduled"]) if row.get("gate_arrival_scheduled"): payload["arrivalScheduled"] = row["gate_arrival_scheduled"] + payload["arrivalScheduledTime"] = _hhmm(row["gate_arrival_scheduled"]) if row.get("gate_arrival_actual"): + # Unlike departure, AirTrail hard-rejects an arrival date with no + # arrivalTime ("Cannot have arrival date without time") rather than + # silently dropping it - this pairing is not optional. payload["arrival"] = row["gate_arrival_actual"] + payload["arrivalTime"] = _hhmm(row["gate_arrival_actual"]) if row.get("takeoff_actual"): payload["takeoffActual"] = row["takeoff_actual"] + payload["takeoffActualTime"] = _hhmm(row["takeoff_actual"]) if row.get("landing_actual"): payload["landingActual"] = row["landing_actual"] + payload["landingActualTime"] = _hhmm(row["landing_actual"]) coordinates = row.get("track_coordinates") if coordinates: diff --git a/boarding-pass-time-fields-fix.patch b/boarding-pass-time-fields-fix.patch new file mode 100644 index 0000000..a217905 --- /dev/null +++ b/boarding-pass-time-fields-fix.patch @@ -0,0 +1,73 @@ +diff --git a/airtrail_client.py b/airtrail_client.py +index 962f989..afc19b8 100644 +--- a/airtrail_client.py ++++ b/airtrail_client.py +@@ -91,17 +91,43 @@ def downsample_track( + return new_coords, new_times + + ++def _hhmm(iso_str: str | None) -> str | None: ++ """Extract 'HH:MM' from one of our seconds-precision ISO datetime ++ strings (e.g. '2026-09-13T13:57:00+10:00' -> '13:57'). ++ ++ AirTrail does NOT read the time-of-day out of departure/arrival/ ++ departureScheduled/etc. at all - confirmed against its source ++ (src/lib/server/utils/flight.ts + src/lib/utils/datetime/parse.ts's ++ mergeTimeWithDate()): it takes only the DATE portion of that field and ++ merges it with a separate companion '*Time' field (departureTime, ++ arrivalTime, departureScheduledTime, ...) for the actual clock time, ++ interpreted in the airport's own timezone. Sending a fully-populated ++ ISO datetime without its companion Time field means AirTrail silently ++ drops the time for departure/departureScheduled/etc., and outright ++ rejects arrival with "Cannot have arrival date without time" - both ++ confirmed live. Every date field this module sets below needs its ++ matching *Time companion or the value doesn't actually land.""" ++ if not iso_str: ++ return None ++ return iso_str[11:16] ++ ++ + def build_save_payload(row: dict, existing_id: int | None) -> dict: + flight_number = f"{row.get('operating_carrier_iata') or ''}{row.get('flight_number') or ''}" + flight_date_str = row["flight_date"] + ++ departure_value = row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00" ++ + payload = { + "from": row["from_iata"], + "to": row["to_iata"], + # Falls back to the bare date (day precision, no time) exactly like + # before if AeroDataBox never returned a gate-actual time - e.g. no +- # AERODATABOX_API_KEY configured, or the lookup came up empty. +- "departure": row.get("gate_departure_actual") or f"{flight_date_str}T00:00:00.000+00:00", ++ # AERODATABOX_API_KEY configured, or the lookup came up empty. No ++ # departureTime is set in that case, which AirTrail treats the same ++ # way (silently date-only) rather than erroring - unlike arrival. ++ "departure": departure_value, ++ "departureTime": _hhmm(row.get("gate_departure_actual")), + "datePrecision": "day", + "flightNumber": flight_number or None, + "airline": row.get("operating_carrier_icao"), +@@ -128,14 +154,22 @@ def build_save_payload(row: dict, existing_id: int | None) -> dict: + # manual Search remains the fallback, same as before this change. + if row.get("gate_departure_scheduled"): + payload["departureScheduled"] = row["gate_departure_scheduled"] ++ payload["departureScheduledTime"] = _hhmm(row["gate_departure_scheduled"]) + if row.get("gate_arrival_scheduled"): + payload["arrivalScheduled"] = row["gate_arrival_scheduled"] ++ payload["arrivalScheduledTime"] = _hhmm(row["gate_arrival_scheduled"]) + if row.get("gate_arrival_actual"): ++ # Unlike departure, AirTrail hard-rejects an arrival date with no ++ # arrivalTime ("Cannot have arrival date without time") rather than ++ # silently dropping it - this pairing is not optional. + payload["arrival"] = row["gate_arrival_actual"] ++ payload["arrivalTime"] = _hhmm(row["gate_arrival_actual"]) + if row.get("takeoff_actual"): + payload["takeoffActual"] = row["takeoff_actual"] ++ payload["takeoffActualTime"] = _hhmm(row["takeoff_actual"]) + if row.get("landing_actual"): + payload["landingActual"] = row["landing_actual"] ++ payload["landingActualTime"] = _hhmm(row["landing_actual"]) + + coordinates = row.get("track_coordinates") + if coordinates: