Travel assistant for NanoClaw: byAir flight notifications (delay, gate, connection risk, inbound aircraft delay, time-to-leave, arrival logistics), traffic-aware drive planning for in-person meetings (auto drive blocks + leave-by traffic rechecks), travel-booking gap checks, and nightly TripIt sync. Per-chat overlay tile.
77
96%
Does it follow best practices?
Impact
—
No eval scenarios have been run
Advisory
Suggest reviewing before use
"""Pure planner for the airport drive blocks — create / shift / skip decisions.
Piece 4a of #90. Given a flight's already-computed drive inputs (the I/O —
byAir airport context, Maps routing, the resolved origin — happens upstream in
the precheck, mirroring how `calendar_plan.py` receives a resolved
`boarding_lead_minutes`), this module decides whether to create a new airport
drive block, shift an existing one, or leave it as-is, and emits the ops.
It is a PURE function: no network, no clock reads, no I/O. The caller executes
the returned ops (create / update via the `airport_block` CREATE/PATCH
contract) and the calendar carries the result.
Calendar-as-state, no local ledger (Epic #59 §4, the drive-planner model):
the planner finds an existing block by scanning the fetched calendar events for
its own `[flight-assist:flight=<id>:dir=<dir>]` marker (via
`airport_block.parse_block`), NOT by reading the per-flight `calendar_events`
ledger. So airport drive blocks add no entry to that ledger — the event itself
is the record, matching how `state-schema.md` documents them. This is distinct
from the boarding/flight events, which `calendar_plan.py` DOES track in the
ledger.
Why not `calendar_plan.py`: that reconcile planner emits `{summary, start,
end, private_props}` bodies encoded via `calendar_tags` for byAir-calendar
events. The airport drive blocks use the self-contained `airport_block` codec
(full `build_block_args`, `<!--fadrive:-->` state) and live on the PRIMARY
calendar, create-first and re-anchored. So the op body here IS the
`build_block_args` dict.
Two block kinds, one per direction:
- `airport_drive_dep` — drive TO the departure airport (to_airport).
- `airport_drive_arr` — drive home from the arrival airport (from_airport).
stdlib-only (`datetime`) per `coding-policy: dependency-management`.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
_BUNDLE_DIR = Path(__file__).resolve().parent
if str(_BUNDLE_DIR) not in sys.path:
sys.path.insert(0, str(_BUNDLE_DIR))
from airport_block import build_block_args, parse_block # noqa: E402
KIND_AIRPORT_DRIVE_DEP = "airport_drive_dep"
KIND_AIRPORT_DRIVE_ARR = "airport_drive_arr"
_DIRECTION_KIND = {
"to_airport": KIND_AIRPORT_DRIVE_DEP,
"from_airport": KIND_AIRPORT_DRIVE_ARR,
}
class AirportDrivePlanError(ValueError):
"""Raised when a desired-block input is missing a field the planner needs.
A ValueError subclass: the caller's recovery is "pass a well-formed input",
not "retry". The precheck resolves the routing/airport inputs before
calling the planner, so this signals a caller bug, not bad calendar data.
"""
@dataclass(frozen=True)
class DesiredDriveBlock:
"""The block the caller wants on the calendar, with inputs already resolved.
The precheck computes these from the flight snapshot + byAir airport context
+ Maps routing + the resolved origin (see module docstring). The planner
turns this into create/shift/skip ops without any I/O.
Fields:
direction: "to_airport" or "from_airport".
summary: human title (e.g. "Drive: → BNA (DL123)").
leg_start: block start. to_airport: leave-by (`anchor − drive`);
from_airport: the anchor itself.
anchor: the deadline (to_airport: `dep − clearance`) or the earliest
drive-home start (from_airport: `actual_arr + post_arrival_delay`).
baseline_seconds: routed drive seconds.
origin / destination: routed leg endpoints.
leg_end: block end; defaults to `anchor` (to_airport). from_airport
passes `anchor + drive`.
timezone: the airport's IANA tz (for the CREATE), or None.
"""
direction: str
summary: str
leg_start: datetime
anchor: datetime
baseline_seconds: int
origin: str
destination: str
leg_end: datetime | None = None
timezone: str | None = None
@property
def kind(self) -> str:
kind = _DIRECTION_KIND.get(self.direction)
if kind is None:
raise AirportDrivePlanError(
f"DesiredDriveBlock: unknown direction {self.direction!r} "
f"(want one of {tuple(_DIRECTION_KIND)})"
)
return kind
@property
def _end(self) -> datetime:
return self.leg_end if self.leg_end is not None else self.anchor
def signature(self) -> str:
"""The `<start>/<end>` window pair the planner compares to decide no-op."""
return f"{self.leg_start.isoformat()}/{self._end.isoformat()}"
def _make_op(
*, op, kind, flight_id, calendar_id, reason, event_id=None, create_args=None, signature=None
):
return {
"op": op,
"kind": kind,
"flight_id": flight_id,
"calendar_id": calendar_id,
"event_id": event_id,
"create_args": create_args,
"signature": signature,
"reason": reason,
}
def _find_existing_block(events: list[dict], flight_id, direction: str) -> dict | None:
"""Find this flight+direction's block among fetched events, by its marker.
Parses each event with `airport_block.parse_block` (which recognizes the
`[flight-assist:flight=<id>:dir=<dir>]` marker + `<!--fadrive:-->` state)
and returns the first event whose block serves this `flight_id` in this
`direction`, or None. The calendar — not a ledger — is the source of block
identity. A non-block or malformed event yields None from `parse_block` and
is skipped, so one bad event can't break the scan.
"""
target = str(flight_id)
for event in events:
state = parse_block(event)
if state is not None and state.flight_id == target and state.direction == direction:
return event
return None
def plan_drive_block(
*,
flight_id,
flight_code: str,
desired: DesiredDriveBlock,
events: list[dict],
calendar_id: str,
) -> list[dict]:
"""Reconcile one airport drive block against the calendar. Returns 0–1 ops.
`events` is the fetched calendar events for the drive-block calendar, each a
dict carrying `id`, `description`, a `signature` of the live `<start>/<end>`
window, and `calendar_id`. The planner finds this flight+direction's block
by its marker (no ledger), then:
- no existing block → create;
- existing block whose live window matches desired → no-op;
- existing block with a different window (re-anchor / re-route) → update.
The op `create_args` is the `airport_block` `build_block_args` dict; the
executor passes it to CREATE, or to PATCH on update (targeting the existing
event's id). `create_args["calendar_id"]` always equals the op's
`calendar_id` — airport blocks live on one calendar, passed in here — so the
PATCH target and the body's calendar never diverge.
"""
kind = desired.kind
create_args = build_block_args(
calendar_id=calendar_id,
flight_id=str(flight_id),
direction=desired.direction,
summary=desired.summary,
leg_start=desired.leg_start,
anchor=desired.anchor,
baseline_seconds=desired.baseline_seconds,
origin=desired.origin,
destination=desired.destination,
leg_end=desired.leg_end,
timezone=desired.timezone,
)
desired_sig = desired.signature()
start_iso = desired.leg_start.isoformat()
existing = _find_existing_block(events, flight_id, desired.direction)
if existing is None:
return [
_make_op(
op="create",
kind=kind,
flight_id=flight_id,
calendar_id=calendar_id,
create_args=create_args,
signature=desired_sig,
reason=f"no {kind} block on the calendar for {flight_code}; create at {start_iso}",
)
]
if existing.get("signature") == desired_sig:
return [] # live window already matches; nothing to write
return [
_make_op(
op="update",
kind=kind,
flight_id=flight_id,
calendar_id=calendar_id,
event_id=existing.get("id"),
create_args=create_args,
signature=desired_sig,
reason=f"shift {kind} block for {flight_code} to {start_iso}",
)
]skills
check-travel-bookings
drive-planner
drive-planner-recheck
flight-assist
references
nightly-travel-sync
sync-tripit