Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions examples/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,13 @@ calendar_id = "job-calendar@example.com"
location = "200 Example St, New York, NY 10001"
# # title_contains is optional — omit to override ALL events in that calendar.
# # title_contains = "Office Hours"

# ── Travel-Mode Overrides ───────────────────────────────────────────────────
# Force a travel mode for any event whose (effective) location contains the
# given text, case-insensitive. First match wins. Handy for "always bike to
# work" — the digest then shows the bike duration instead of a transit route.
# Valid modes: "transit", "driving", "walking", "bicycling".
#
[[mode_overrides]]
location_contains = "200 Example St"
mode = "bicycling"
13 changes: 13 additions & 0 deletions src/commutecompass/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ class LocationOverride(BaseModel):
location: str


class ModeOverride(BaseModel):
"""Force a travel mode for events whose (effective) location matches.

``location_contains`` is matched case-insensitively as a substring against
the event's effective location (after ``location_overrides`` are applied).
First matching rule wins. Handy for "always bike to work".
"""

location_contains: str
mode: Literal["transit", "driving", "walking", "bicycling"]


class ZoneOrigin(BaseModel):
zone: str
address: str
Expand Down Expand Up @@ -131,6 +143,7 @@ class Config(BaseModel):
opencode_go: OpencodeGoConfig
mta: MtaConfig
location_overrides: list[LocationOverride] = []
mode_overrides: list[ModeOverride] = []
home_assistant: HomeAssistantConfig = HomeAssistantConfig()
notify: NotifyConfig = NotifyConfig()
# Loaded from env, not TOML:
Expand Down
13 changes: 13 additions & 0 deletions src/commutecompass/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,18 @@ class LocationOverride(BaseModel):
location: str


class ModeOverride(BaseModel):
"""Force a travel mode for events whose (effective) location matches.

``location_contains`` is matched case-insensitively as a substring against
the event's effective location (after ``location_overrides`` are applied).
First matching rule wins. Handy for "always bike to work".
"""

location_contains: str
mode: Literal["transit", "driving", "walking", "bicycling"]


class ZoneOrigin(BaseModel):
"""Per-zone origin override with subway/LIRR hints.

Expand Down Expand Up @@ -143,6 +155,7 @@ class Config(BaseModel):
opencode_go: OpencodeGoConfig
mta: MtaConfig
location_overrides: list[LocationOverride] = []
mode_overrides: list[ModeOverride] = []
home_assistant: HomeAssistantConfig = HomeAssistantConfig()
notify: NotifyConfig = NotifyConfig()
google_maps_api_key: str = ""
Expand Down
30 changes: 25 additions & 5 deletions src/commutecompass/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ def get_effective_location(
return event.location_raw or ""


def get_effective_mode(
effective_location: str,
config: Config,
) -> Optional[Literal["transit", "driving", "walking", "bicycling"]]:
"""Return the forced travel mode if the location matches a mode_override.

``location_contains`` is matched case-insensitively as a substring against
the (effective) location. First matching rule wins. Returns None when no
rule matches so callers can fall back to their default.
"""
hay = (effective_location or "").lower()
for ov in config.mode_overrides:
if ov.location_contains.lower() in hay:
return ov.mode
return None


def effective_origin(
config: Config,
store: "Store",
Expand Down Expand Up @@ -116,14 +133,17 @@ def plan_event(
from commutecompass.routing import plan_route
from commutecompass.geocode import geocode

# Determine travel mode
mode: Literal["transit", "driving", "walking", "bicycling"] = (
mode_override or event.mode_override or "transit"
)

# Step 1: resolve location (override applied first)
raw_location = get_effective_location(event, config)

# Determine travel mode (CLI > event > location-based config > default)
mode: Literal["transit", "driving", "walking", "bicycling"] = (
mode_override
or event.mode_override
or get_effective_mode(raw_location, config)
or "transit"
)

def geocoder(addr: str) -> Optional[GeocodeResult]:
return geocode(addr, config.google_maps_api_key)

Expand Down
58 changes: 58 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,64 @@ def test_multiple_overrides(self, tmp_path: Path, required_env: dict[str, str])
assert cfg.location_overrides[1].calendar_id == "cal-b"


class TestModeOverrides:
def test_backward_compat_no_mode_overrides_section(
self, minimal_toml: Path, required_env: dict[str, str]
) -> None:
"""Config without a mode_overrides section yields an empty list."""
_apply_env(required_env)
cfg = load_config(minimal_toml)
assert cfg.mode_overrides == []

def test_mode_override_parses(self, tmp_path: Path, required_env: dict[str, str]) -> None:
"""A [[mode_overrides]] block parses into a ModeOverride."""
_apply_env(required_env)
toml = """
[origin]
address = "123 Example Ave, Brooklyn, NY 11201"
lat = 40.6950
lon = -73.9890
subway_station = "Jay St-MetroTech"
lirr_station = "Atlantic Terminal"

[prep]
prep_minutes = 20

[scheduling]
morning_run_time = "06:00"
poll_interval_seconds = 60

[paths]
venues_file = "/etc/commutecompass/known_venues.yaml"
db_path = "/var/lib/commutecompass/state.db"
oauth_token_path = "/var/lib/commutecompass/google_token.json"

[opencode_go]
endpoint = "https://opencode-go.example/v1/chat/completions"

[mta]
subway_alerts_url = "https://example.com/subway"
lirr_alerts_url = "https://example.com/lirr"
bus_alerts_url = "https://example.com/bus"

[[calendars]]
id = "job-cal"
name = "Job"

[[mode_overrides]]
location_contains = "200 Example St"
mode = "bicycling"
"""
p = tmp_path / "config.toml"
p.write_text(toml)
cfg = load_config(p)

assert len(cfg.mode_overrides) == 1
ov = cfg.mode_overrides[0]
assert ov.location_contains == "200 Example St"
assert ov.mode == "bicycling"


class TestHomeAssistant:
"""HOME_ASSISTANT_TOKEN is required only when [home_assistant].enabled = true."""

Expand Down
Loading