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 plugin.
—
—
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
"""Lodging-leg source — the getting-there legs of a flight-less trip — pure.
The engine's third leg source, beside the airport chain (`chain.py`) and the
meeting scan (`meeting_source.py`). Those two between them left a whole trip
shape unplanned: lodging booked, no flight, the operator drives. The intra-trip
drives worked (a hotel→event drive is an ordinary meeting leg anchored at the
hotel), but nothing planned the drive that GETS the operator there, because a
hotel check-in is not a flight and so anchors no airport leg. Neither did
anything warn — for a genuine drive trip a missing flight is normal, so the
booking-gap check counted it complete. The trip fell through both nets in
silence (#231).
This module adds the outbound `home → lodging` leg and its symmetric
`lodging → home` return, and decides which flight-less trips get them.
**The outer legs skip the lodging when the operator would not stop there.**
Anchoring the outbound to land as the first local drive departs the hotel is
zero dwell — an arrival and a departure at the same instant, a stop made only
to leave again. When that first local drive LEAVES the lodging the outbound
goes straight to its venue and absorbs it; the operator reaches the hotel on
that event's own return leg, checking in when they actually do. The return
mirrors it: a last local drive back to a lodging already checked out of is
absorbed, and the drive home departs the venue. Both absorptions are reported
on `TripPlan.subsumed` for the caller to drop — planning both halves would put
two drives on the calendar for one journey. A failed direct route degrades to
the via-the-lodging shape rather than dropping the leg.
**The origin is home by construction, never `position_at`.** Every other leg
resolves its non-fixed endpoint through the planned-position ladder; this one
must not. At the outbound leg's own anchor the operator is already checked in
by that ladder's reckoning, so `position_at` answers "the hotel" and the leg
collapses to a zero-length drive; anchor earlier and it answers the TRIP's
location (also the destination city), because the pre-departure "still at home"
guard keys on the first FLIGHT and a flight-less trip has none. Getting there
is the one leg whose origin is definitionally home.
**Drive-or-fly is decided by the computed drive time**, in three bands (see
`classify_drive`). The middle band is the interesting one: the engine cannot
tell a long drive from an unbooked flight, so it asks the operator once and
persists the answer (`drive_decision.py`). An answer outranks the band.
Pure: the caller supplies `route`, the home address, the current verdicts, and
the per-trip context derived from the already-planned local drives. Nothing
here reads a clock, a file, or the network — `now` arrives as an argument.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
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))
_TRAVEL_CORE = Path("/home/node/.claude/skills/tessl__travel-core")
if not _TRAVEL_CORE.is_dir():
_TRAVEL_CORE = _BUNDLE_DIR.parent / "travel-core"
if str(_TRAVEL_CORE) not in sys.path:
sys.path.insert(0, str(_TRAVEL_CORE))
from drive_decision import ( # noqa: E402
VERDICT_DRIVE,
VERDICT_FLY,
VERDICT_UNKNOWN,
TripVerdict,
)
from lodging import CHECK_IN, CHECK_OUT, hotel_name, lodging_role # noqa: E402
from reconcile import DesiredBlock # noqa: E402
from trip_key import trip_key # noqa: E402
from trip_origin import parse_schedule_time # noqa: E402
RouteFn = Callable[[str, str], "timedelta | None"]
# The drive-or-fly bands, read off the routed home→lodging drive.
#
# NOT to be merged with `meeting_source.DEFAULT_MAX_REASONABLE_DRIVE`, which is
# also three hours and means the opposite thing: there, a drive that long is
# evidence the operator is somewhere else entirely and the leg is suppressed as
# implausible. Here it is the ceiling under which a drive is unremarkable. Same
# number, opposite conclusion — they drift apart independently.
DRIVE_CERTAIN_MAX = timedelta(hours=3)
DRIVE_IMPLAUSIBLE_MIN = timedelta(hours=7)
# How far from the lodging an event can be and still be part of THAT trip.
#
# A third constant at three hours, and a third meaning — kept apart from the
# two above for the reason their own comment gives. `DRIVE_CERTAIN_MAX` is "a
# drive short enough that the operator is certainly driving to this trip";
# `meeting_source.DEFAULT_MAX_REASONABLE_DRIVE` is "a drive so long the
# operator cannot be positioned to make it"; this is "an event near enough the
# hotel to be part of this stay". Generous on purpose: excluding a genuine trip
# event costs a suppressed drive, and the alternative reading (every meeting on
# these dates belongs to the trip) is what #243 got wrong.
LOCAL_TO_LODGING_MAX = timedelta(hours=3)
# How long a trip's verdict outlives the trip itself, so a verdict recorded for
# a trip still under way is not pruned out from under the return leg.
VERDICT_GRACE = timedelta(days=2)
# Record types that mean the journey to the destination is already booked, so
# it is not a drive. Mirrors `check-travel-bookings.classify_trip`'s
# has_transport (Flight or Rail); Car Rental is deliberately absent — renting
# a car is compatible with driving there, not evidence against it.
_TRANSPORT_TYPES = frozenset({"Flight", "Rail"})
KIND_OUTBOUND = "lodging_outbound"
KIND_RETURN = "lodging_return"
@dataclass(frozen=True)
class DriveTrip:
"""A trip with lodging and no flight — a drive-to-lodging candidate.
key — `travel-core`'s `trip_key`, the join with the verdict store and the
booking-gap check.
address — the lodging's street address, the drive's far endpoint.
check_in / check_out — the stay's own instants, the fallback anchors.
`check_out` is None for an orphan check-in, which TripIt does write.
span_start / span_end — the Trip wrapper's own dates, the bounds on "away
at the destination". Independent of where the stay's stamps sit, so the
plan does not move when the operator restamps a check-in (#242).
expires — when a verdict about this trip stops applying.
"""
key: str
summary: str
hotel: str
address: str
check_in: datetime
check_out: datetime | None
span_start: datetime
span_end: datetime
expires: datetime
@dataclass(frozen=True)
class LocalLeg:
"""One local drive between the lodging and a venue, as the outer legs see it.
`venue` is the endpoint that is NOT the lodging, `label` the event name the
drive was planned for. `identity` / `kind` key the block so a caller can
drop it when an outer leg subsumes it.
"""
identity: str
kind: str
label: str
venue: str
start: datetime
end: datetime
@property
def key(self) -> tuple[str, str]:
return (self.identity, self.kind)
@dataclass(frozen=True)
class TripContext:
"""What a trip's already-planned local drives imply for the outer legs.
onward_start — when the first drive that STARTS at the lodging leaves it.
The outbound leg must land by then, not merely by check-in: check-in is
a nominal mid-afternoon stamp TripIt supplies whether or not anyone
agreed to it, while the onward drive is anchored on a real commitment.
trailing_end — when the last local drive finishes, so a return home is not
planned across an event the operator is still at.
timezone — the IANA zone those local drives render in, reused so the outer
legs display in destination-local time too.
first_out — the first local drive that LEAVES the lodging, when there is
one. The outbound goes straight to its venue instead of through the
hotel: landing at the lodging exactly as that drive departs it is zero
dwell, a stop the operator makes only to leave it again.
last_back — the last local drive that RETURNS to the lodging. The return
home departs from its venue instead when the operator has already
checked out by then, for the mirror reason.
"""
onward_start: datetime | None = None
trailing_end: datetime | None = None
timezone: str | None = None
first_out: LocalLeg | None = None
last_back: LocalLeg | None = None
@dataclass(frozen=True)
class TripPlan:
"""One trip's resolved drive-or-fly outcome, for the caller to persist.
`verdict` is what the band says, `effective` what applies once a recorded
operator answer is honoured, and `ask` the question owed the operator (None
when none is). `blocks` is empty for every outcome but a drive.
`subsumed` carries the `(identity, kind)` of every local drive an outer leg
absorbed — a hotel→venue drive the operator never makes because the
outbound went straight to the venue. The caller drops those blocks before
reconciling; leaving them in would plan both halves of a journey made once.
"""
trip: DriveTrip
drive_seconds: int
verdict: str
effective: str
ask: str | None
blocks: tuple[DesiredBlock, ...]
subsumed: tuple[tuple[str, str], ...] = ()
def _trip_span(record: dict) -> tuple[datetime, datetime] | None:
start = parse_schedule_time(record.get("start"))
end = parse_schedule_time(record.get("end"))
if start is None or end is None or end < start:
return None
return start, end
def _in_span(when: datetime, span: tuple[datetime, datetime]) -> bool:
"""Whether a timed item belongs to a trip's date span.
The Trip wrapper is date-only, so its `end` parses to that day's midnight
while items on the final day carry real times past it. Compare on dates,
inclusive of both endpoints — the same reading `trip_origin._active_trip`
takes.
"""
start, end = span
return start.date() <= when.date() <= end.date()
def _has_booked_transport(schedule: list[dict], span: tuple[datetime, datetime]) -> bool:
"""Whether any TIMED transport segment falls inside the trip's span.
Rail counts alongside Flight: a train to the destination is not a drive
either, and planning a home→hotel drive around one would double up on a
journey already booked. `check-travel-bookings` draws the same Flight-or-
Rail line for its transport gap.
Date-only records are ignored for the reason `trip_origin._timed_within`
ignores them: a bare `YYYY-MM-DD` cannot say when the operator actually
leaves, and treating one as booked transport would silently suppress the
getting-there legs of a trip whose segment the feed never timed.
"""
for record in schedule:
if not isinstance(record, dict) or record.get("type") not in _TRANSPORT_TYPES:
continue
raw = record.get("start")
if not (isinstance(raw, str) and "T" in raw):
continue
when = parse_schedule_time(raw)
if when is not None and _in_span(when, span):
return True
return False
def _stay_in_span(
schedule: list[dict], span: tuple[datetime, datetime]
) -> tuple[datetime, datetime | None, str, str] | None:
"""The trip's stay as `(check_in, check_out, hotel, address)`, else None.
Takes the EARLIEST check-in with a usable address as the trip's arrival
point and the LATEST check-out as its departure point, so a trip that hops
between two hotels still yields one outer pair — the engine plans getting
there and getting home, and the hops between are ordinary local drives.
"""
check_in: datetime | None = None
check_out: datetime | None = None
hotel = ""
address = ""
for record in schedule:
if not isinstance(record, dict) or record.get("type") != "Lodging":
continue
when = parse_schedule_time(record.get("start"))
if when is None or not _in_span(when, span):
continue
role = lodging_role(record.get("summary"))
if role == CHECK_OUT:
if check_out is None or when > check_out:
check_out = when
continue
if role != CHECK_IN:
continue
location = record.get("location")
if not isinstance(location, str) or not location.strip():
continue
if check_in is None or when < check_in:
check_in = when
address = location.strip()
hotel = hotel_name(record.get("summary")) or "lodging"
if check_in is None:
return None
return check_in, check_out, hotel, address
def find_drive_trips(
schedule: list[dict] | None,
*,
now: datetime,
window: timedelta,
) -> list[DriveTrip]:
"""Every flight-less trip with lodging that starts within `window` of `now`.
A trip qualifies when it has at least one check-in carrying a usable
address and no timed flight segment inside its span. Trips already over are
dropped; a trip under way is kept, since its return leg is still ahead.
Ordered by check-in.
"""
if not schedule:
return []
horizon = now + window
trips: list[DriveTrip] = []
for record in schedule:
if not isinstance(record, dict) or record.get("type") != "Trip":
continue
span = _trip_span(record)
if span is None:
continue
start, end = span
if end.date() < now.date() or start > horizon:
continue
if _has_booked_transport(schedule, span):
continue
stay = _stay_in_span(schedule, span)
if stay is None:
continue
check_in, check_out, hotel, address = stay
summary = record.get("summary")
if not isinstance(summary, str) or not summary.strip():
continue
trips.append(
DriveTrip(
key=trip_key(summary, start),
summary=summary.strip(),
hotel=hotel,
address=address,
check_in=check_in,
check_out=check_out,
span_start=start,
span_end=end,
expires=(check_out or end) + VERDICT_GRACE,
)
)
trips.sort(key=lambda trip: trip.check_in)
return trips
def effective_verdict(trip: DriveTrip, drive: timedelta, verdicts: dict[str, TripVerdict]) -> str:
"""The verdict in force for a trip — the operator's answer, else the band."""
recorded = verdicts.get(trip.key)
if recorded is not None and recorded.is_operator_answer:
return recorded.verdict
return classify_drive(drive)
def driving_trips(
trips: list[DriveTrip],
*,
route: RouteFn,
home_address: str | None,
verdicts: dict[str, TripVerdict],
) -> list[DriveTrip]:
"""The trips the operator is known to DRIVE to.
The caller needs this before planning the meeting side, to spare those
trips' own events the away-suppression (`meeting_source`, #242). A trip
whose home→lodging route fails is not claimed as a drive — the same
conservative reading `lodging_desired_blocks` takes when it skips one.
"""
if home_address is None:
return []
driving: list[DriveTrip] = []
for trip in trips:
drive = route(home_address, trip.address)
if drive is None:
continue
if effective_verdict(trip, drive, verdicts) == VERDICT_DRIVE:
driving.append(trip)
return driving
def meetings_on_trip(trip: DriveTrip, meetings: list, *, route: RouteFn) -> dict[str, timedelta]:
"""`meetings` belonging to `trip`, as id → drive from its lodging.
Membership is dates AND reachability. The drive itself is returned, not
just the id, so a caller holding overlapping trips can settle which one a
meeting belongs to instead of letting iteration order decide (#245).
Dates alone are not evidence of belonging. Every meeting occurring while a
trip is under way would qualify, including a home appointment the operator
never cancelled and a meeting belonging to an overlapping trip. Those then
bypass the implausible-drive suppression and have their endpoints rewritten
to this trip's lodging — reintroducing the invented cross-country drive
that suppression exists to prevent.
So a meeting belongs when it is also REACHABLE from the trip's lodging: a
local drive, under `LOCAL_TO_LODGING_MAX`. The venue is where the operator
would actually go from that hotel, which is the question `TripPresence`
needs answered. A meeting with no parsed start, no id, no location, or an
unroutable one belongs to no trip — the conservative reading, since the
only thing membership grants is an exemption.
Date comparison for the reason `_in_span` gives: the Trip wrapper is
date-only, so an instant comparison drops the final day's evening events.
The upper bound matches `context_from_blocks` — a stay recorded past the
wrapper's end extends it.
"""
last_day = max(trip.span_end, trip.check_out) if trip.check_out else trip.span_end
reachable: dict[str, timedelta] = {}
for meeting in meetings:
start = getattr(meeting, "start", None)
meeting_id = getattr(meeting, "meeting_id", None)
location = getattr(meeting, "location", None)
if start is None or not meeting_id or not location:
continue
if not (trip.span_start.date() <= start.date() <= last_day.date()):
continue
local = route(trip.address, location)
if local is not None and local <= LOCAL_TO_LODGING_MAX:
reachable[meeting_id] = local
return reachable
def classify_drive(drive: timedelta) -> str:
"""The drive-or-fly verdict the routed home→lodging drive alone supports.
At or under `DRIVE_CERTAIN_MAX` it is a drive; at or over
`DRIVE_IMPLAUSIBLE_MIN` it is not; between them the drive time is not
evidence either way and only the operator can settle it.
"""
if drive <= DRIVE_CERTAIN_MAX:
return VERDICT_DRIVE
if drive >= DRIVE_IMPLAUSIBLE_MIN:
return VERDICT_FLY
return VERDICT_UNKNOWN
def _hours(drive: timedelta) -> str:
"""A drive length as a short operator-facing string (`3h40m`, `45m`)."""
minutes = int(round(drive.total_seconds() / 60))
hours, mins = divmod(minutes, 60)
return f"{hours}h{mins:02d}m" if hours else f"{mins}m"
def build_question(trip: DriveTrip, drive: timedelta) -> str:
"""The one-time drive-or-fly question for an ambiguous trip.
Emitted verbatim by the sweep's notice, so it names the trip the way the
operator's reply will and states the drive that made it ambiguous.
"""
return (
f"{trip.summary}: no flight booked, and it's a {_hours(drive)} drive to "
f"{trip.hotel}. Reply 'drive' and I'll plan the drive, or 'fly' and I'll "
"flag the missing flight."
)
def _local_leg(block: DesiredBlock, *, venue: str) -> LocalLeg:
"""A planned local drive as the outer legs need to see it."""
label = block.summary.removeprefix("Drive: ").strip() or venue
return LocalLeg(
identity=block.identity,
kind=block.kind,
label=label,
venue=venue,
start=block.start,
end=block.end,
)
def context_from_blocks(
trips: list[DriveTrip], blocks: list[DesiredBlock]
) -> dict[str, TripContext]:
"""Derive each trip's `TripContext` from the local drives already planned.
A block counts toward a trip when its anchor falls between that trip's
check-in and the end of the trip — the window during which the operator is
at the destination, so any drive anchored in it is a local one this trip's
outer legs must not overlap.
The window closes at the end of the trip's last DAY, never at check-out.
Check-out releases the room, not the operator: an evening event on the
check-out day is the ordinary shape of a weekend trip, and clamping at
check-out hid every drive after it. The return leg then departed at
check-out and landed home hours before a game the calendar still had the
operator driving to from a hotel they had left.
The upper bound compares DATES, the reading `_in_span` takes for the same
wrapper: date-only `end` parses to that day's midnight while items on the
final day carry real times past it. An instant comparison drops the last
evening's drives — the ones the return leg most needs to depart after.
An orphan check-in (no check-out record, which TripIt does write) needs no
special case under that bound. Collapsing the window to the check-in instant
made every local drive invisible: the outbound ignored the onward drive it
must land before, and the return leg was dropped as having nothing to depart
after even with trailing drives on the calendar.
Both bounds are the trip's, never the stay's. Keying the lower bound on
check-in made the plan depend on where the operator stamped it: a check-in
after the trip's first event hid that event's drives, and the outbound
re-anchored on the next day's (#242). A check-in time is a fact about a
reservation, not an instruction about which drives count.
The trip's VENUES are the endpoints its lodging-touching drives reach, and
`first_out` is the first drive INTO one of them — from the hotel or from
home, whichever the position ladder produced. Keying on "leaves the
lodging" assumed the operator checks in before doing anything, which is the
same check-in dependence by another name. Deriving venues from the
lodging-touching drives is what keeps a home→dentist errand on the trip's
first morning from reading as the trip's first commitment.
"""
contexts: dict[str, TripContext] = {}
for trip in trips:
# `max` rather than `span_end` alone: a stay TripIt records past the
# wrapper's end still bounds the window at the later of the two.
last_day = max(trip.span_end, trip.check_out) if trip.check_out else trip.span_end
in_window = [
block
for block in blocks
if trip.span_start.date() <= block.anchor.date() <= last_day.date()
]
venues = {
block.destination if block.origin == trip.address else block.origin
for block in in_window
if trip.address in (block.origin, block.destination)
} - {trip.address}
onward: datetime | None = None
trailing: datetime | None = None
zone: str | None = None
first_out: LocalLeg | None = None
last_back: LocalLeg | None = None
for block in in_window:
if not venues & {block.origin, block.destination}:
continue
if onward is None or block.start < onward:
onward = block.start
zone = block.timezone
if trailing is None or block.end > trailing:
trailing = block.end
if block.destination in venues and (first_out is None or block.start < first_out.start):
first_out = _local_leg(block, venue=block.destination)
if block.destination == trip.address and (
last_back is None or block.end > last_back.end
):
last_back = _local_leg(block, venue=block.origin)
contexts[trip.key] = TripContext(
onward_start=onward,
trailing_end=trailing,
timezone=zone,
first_out=first_out,
last_back=last_back,
)
return contexts
def _outbound_block(
trip: DriveTrip,
ctx: TripContext,
drive: timedelta,
home_address: str,
*,
destination: str | None = None,
label: str | None = None,
arrive_by: datetime | None = None,
) -> DesiredBlock:
"""The leg that gets the operator to the trip.
Defaults to home→lodging landing by the first local drive. The caller
overrides the far end when that first drive leaves the lodging, so the
outbound goes straight to the venue rather than through a hotel the
operator would arrive at and depart from in the same instant.
"""
arrive_by = arrive_by or ctx.onward_start or trip.check_in
return DesiredBlock(
identity=trip.key,
kind=KIND_OUTBOUND,
summary=f"Drive: home → {label or trip.hotel}",
start=arrive_by - drive,
end=arrive_by,
origin=home_address,
destination=destination or trip.address,
baseline_seconds=int(drive.total_seconds()),
anchor=arrive_by,
timezone=ctx.timezone,
)
def _return_block(
trip: DriveTrip,
ctx: TripContext,
drive: timedelta,
home_address: str,
*,
origin: str | None = None,
label: str | None = None,
depart_after: datetime | None = None,
) -> DesiredBlock | None:
"""The leg that gets the operator home.
Defaults to lodging→home departing after the later of check-out and the
last local drive. The caller overrides the near end when that last drive
returns to a lodging already checked out of, so the drive home starts at
the venue rather than doubling back to a room the operator has released.
"""
if depart_after is None:
candidates = [when for when in (trip.check_out, ctx.trailing_end) if when is not None]
if not candidates:
return None
depart_after = max(candidates)
return DesiredBlock(
identity=trip.key,
kind=KIND_RETURN,
summary=f"Drive: {label or trip.hotel} → home",
start=depart_after,
end=depart_after + drive,
origin=origin or trip.address,
destination=home_address,
baseline_seconds=int(drive.total_seconds()),
anchor=depart_after,
timezone=ctx.timezone,
)
def lodging_desired_blocks(
trips: list[DriveTrip],
*,
route: RouteFn,
home_address: str | None,
verdicts: dict[str, TripVerdict],
contexts: dict[str, TripContext] | None = None,
now: datetime,
) -> tuple[list[DesiredBlock], list[str], list[TripPlan]]:
"""Plan the getting-there legs for each flight-less trip.
Returns `(blocks, skipped_diagnostics, plans)`. `plans` carries every trip's
resolved verdict and the question owed on it, for the caller to persist and
send — this module writes no state and sends nothing.
A trip yields blocks only when the effective verdict is a drive. A route
failure or an unconfigured home skips the trip with a diagnostic rather than
guessing; a leg whose anchor has already passed is dropped the way
`engine._leg_past` drops a stale airport leg.
"""
blocks: list[DesiredBlock] = []
skipped: list[str] = []
plans: list[TripPlan] = []
contexts = contexts or {}
if home_address is None:
if trips:
skipped.append(
f"lodging legs: no home address configured — {len(trips)} drive trip(s) unplanned"
)
return blocks, skipped, plans
for trip in trips:
outbound_drive = route(home_address, trip.address)
if outbound_drive is None:
skipped.append(f"lodging {trip.key}: home→lodging route failed")
continue
band = classify_drive(outbound_drive)
effective = effective_verdict(trip, outbound_drive, verdicts)
recorded = verdicts.get(trip.key)
ask = None
if effective == VERDICT_UNKNOWN and (recorded is None or recorded.needs_question):
ask = build_question(trip, outbound_drive)
trip_blocks: list[DesiredBlock] = []
subsumed: list[tuple[str, str]] = []
if effective == VERDICT_DRIVE:
ctx = contexts.get(trip.key, TripContext())
# Outbound. When the trip's first local drive LEAVES the lodging,
# going through the hotel means arriving exactly as that drive
# departs — a stop made only to leave it. Drive to the venue
# instead and absorb that local leg. A route failure to the venue
# falls back to the hotel rather than dropping the leg.
out = ctx.first_out
direct_out = route(home_address, out.venue) if out is not None else None
if out is not None and direct_out is None:
skipped.append(
f"lodging {trip.key} outbound: home→{out.label} route failed — "
"routing via the lodging"
)
if out is not None and direct_out is not None:
outbound = _outbound_block(
trip,
ctx,
direct_out,
home_address,
destination=out.venue,
label=out.label,
arrive_by=out.end,
)
absorbs = out.key
else:
outbound = _outbound_block(trip, ctx, outbound_drive, home_address)
absorbs = None
if outbound.anchor >= now:
trip_blocks.append(outbound)
if absorbs is not None:
subsumed.append(absorbs)
else:
# The outbound is not planned, so a local drive it would have
# absorbed has to stand on its own.
skipped.append(f"lodging {trip.key} outbound: past, skipped")
# Return. Mirror case: the last local drive comes BACK to a lodging
# the operator has already checked out of, so leave from the venue.
back = ctx.last_back
returning: DesiredBlock | None = None
absorbs_back: tuple[str, str] | None = None
if back is not None and trip.check_out is not None and back.start >= trip.check_out:
direct_home = route(back.venue, home_address)
if direct_home is None:
skipped.append(
f"lodging {trip.key} return: {back.label}→home route failed — "
"routing via the lodging"
)
else:
returning = _return_block(
trip,
ctx,
direct_home,
home_address,
origin=back.venue,
label=back.label,
depart_after=back.start,
)
absorbs_back = back.key
if returning is None:
return_drive = route(trip.address, home_address)
if return_drive is None:
skipped.append(f"lodging {trip.key}: lodging→home route failed")
else:
returning = _return_block(trip, ctx, return_drive, home_address)
if returning is None:
skipped.append(f"lodging {trip.key} return: no check-out to depart after")
if returning is not None:
if returning.anchor < now:
skipped.append(f"lodging {trip.key} return: past, skipped")
else:
trip_blocks.append(returning)
if absorbs_back is not None:
subsumed.append(absorbs_back)
elif effective == VERDICT_FLY:
skipped.append(f"lodging {trip.key}: flying — no drive planned")
else:
skipped.append(f"lodging {trip.key}: drive-or-fly unresolved — no drive planned")
blocks.extend(trip_blocks)
plans.append(
TripPlan(
trip=trip,
drive_seconds=int(outbound_drive.total_seconds()),
verdict=band,
effective=effective,
ask=ask,
blocks=tuple(trip_blocks),
subsumed=tuple(subsumed),
)
)
return blocks, skipped, plans.tessl-plugin
skills
check-travel-bookings
drive-engine
expertflyer
flight-assist
references
nightly-travel-sync
sync-tripit