From 33a0271e05f535eb39900b53864a07c25a86fdb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 29 May 2026 22:21:32 +0000 Subject: [PATCH] Add location-based travel-mode override (always bike to work) Add a config-driven mode_overrides list that forces a travel mode for events whose effective location matches a case-insensitive substring. This lets the planner always show bike time for chosen destinations (e.g. work) while leaving every other event on the default transit mode. - ModeOverride model in models.py and config.py, with Config.mode_overrides - get_effective_mode() helper in planner.py - Folded into plan_event mode precedence: CLI > event > location config > transit - Documented [[mode_overrides]] example in examples/config.toml - Tests for the helper, parsing, and plan_event precedence https://claude.ai/code/session_01QrS4MwWJUUAqoY1J3ZaNnX --- examples/config.toml | 10 ++ src/commutecompass/config.py | 13 +++ src/commutecompass/models.py | 13 +++ src/commutecompass/planner.py | 30 ++++- tests/test_config.py | 58 ++++++++++ tests/test_planner.py | 203 +++++++++++++++++++++++++++++++++- 6 files changed, 321 insertions(+), 6 deletions(-) diff --git a/examples/config.toml b/examples/config.toml index 57ae1f5..186663c 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -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" diff --git a/src/commutecompass/config.py b/src/commutecompass/config.py index 720c693..62f54d9 100644 --- a/src/commutecompass/config.py +++ b/src/commutecompass/config.py @@ -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 @@ -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: diff --git a/src/commutecompass/models.py b/src/commutecompass/models.py index 28258b2..ff206bb 100644 --- a/src/commutecompass/models.py +++ b/src/commutecompass/models.py @@ -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. @@ -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 = "" diff --git a/src/commutecompass/planner.py b/src/commutecompass/planner.py index d3978f6..d7e19af 100644 --- a/src/commutecompass/planner.py +++ b/src/commutecompass/planner.py @@ -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", @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index 075bdf2..79da338 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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.""" diff --git a/tests/test_planner.py b/tests/test_planner.py index 0d5ade3..9dfdbe2 100644 --- a/tests/test_planner.py +++ b/tests/test_planner.py @@ -11,6 +11,7 @@ CalendarSpec, HomeAssistantConfig, LocationOverride, + ModeOverride, MtaConfig, OpencodeGoConfig, Origin, @@ -26,7 +27,12 @@ Route, TransitLeg, ) -from commutecompass.planner import effective_origin, plan_event, get_effective_location +from commutecompass.planner import ( + effective_origin, + get_effective_location, + get_effective_mode, + plan_event, +) from commutecompass.venues import VenueRegistry from commutecompass.llm import OpencodeGoClient from commutecompass.timeutil import NYC_TZ @@ -744,3 +750,198 @@ def test_effective_origin_rejects_low_accuracy_fix(config: Config) -> None: # Bad fix → fall back to config.origin (with station hints) assert result.lat == cfg.origin.lat assert result.subway_station == cfg.origin.subway_station + + +# ── Mode Override tests ───────────────────────────────────────────────────────── + +def test_get_effective_mode_no_overrides(config: Config) -> None: + """With no mode_overrides configured, returns None (caller defaults).""" + assert config.mode_overrides == [] + assert get_effective_mode("200 Example St, New York, NY 10001", config) is None + + +def test_get_effective_mode_substring_match_case_insensitive(config: Config) -> None: + """A case-insensitive substring match returns the configured mode.""" + config.mode_overrides = [ + ModeOverride(location_contains="example st", mode="bicycling"), + ] + assert get_effective_mode("200 Example St, New York, NY 10001", config) == "bicycling" + + +def test_get_effective_mode_no_match(config: Config) -> None: + """A non-matching location returns None.""" + config.mode_overrides = [ + ModeOverride(location_contains="Brooklyn Navy Yard", mode="bicycling"), + ] + assert get_effective_mode("200 Example St, New York, NY 10001", config) is None + + +def test_get_effective_mode_first_match_wins(config: Config) -> None: + """When multiple rules match, the first one in the list applies.""" + config.mode_overrides = [ + ModeOverride(location_contains="Example", mode="bicycling"), + ModeOverride(location_contains="Example St", mode="driving"), + ] + assert get_effective_mode("200 Example St, New York, NY 10001", config) == "bicycling" + + +def test_get_effective_mode_empty_location(config: Config) -> None: + """An empty/None effective location never matches.""" + config.mode_overrides = [ + ModeOverride(location_contains="Example", mode="bicycling"), + ] + assert get_effective_mode("", config) is None + + +def test_plan_event_uses_mode_override_config( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """A matching mode_override forces the mode passed to plan_route.""" + # event.location_raw == "200 Example St, New York, NY 10001" + config.mode_overrides = [ + ModeOverride(location_contains="Example St", mode="bicycling"), + ] + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + mock_plan_route.return_value = mock_route + + plan_event( + event, + config, + MagicMock(spec=VenueRegistry), + MagicMock(), + MagicMock(spec=OpencodeGoClient), + ) + + _, kwargs = mock_plan_route.call_args + assert kwargs["mode"] == "bicycling" + + +def test_plan_event_cli_override_beats_mode_override_config( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """An explicit CLI mode_override takes precedence over a config rule.""" + config.mode_overrides = [ + ModeOverride(location_contains="Example St", mode="bicycling"), + ] + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + mock_plan_route.return_value = mock_route + + plan_event( + event, + config, + MagicMock(spec=VenueRegistry), + MagicMock(), + MagicMock(spec=OpencodeGoClient), + mode_override="driving", + ) + + _, kwargs = mock_plan_route.call_args + assert kwargs["mode"] == "driving" + + +def test_plan_event_event_mode_override_beats_config( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """event.mode_override takes precedence over a matching config rule.""" + event.mode_override = "walking" + config.mode_overrides = [ + ModeOverride(location_contains="Example St", mode="bicycling"), + ] + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + mock_plan_route.return_value = mock_route + + plan_event( + event, + config, + MagicMock(spec=VenueRegistry), + MagicMock(), + MagicMock(spec=OpencodeGoClient), + ) + + _, kwargs = mock_plan_route.call_args + assert kwargs["mode"] == "walking" + + +def test_plan_event_no_mode_match_defaults_transit( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """With no matching rule and no other override, mode defaults to transit.""" + config.mode_overrides = [ + ModeOverride(location_contains="Somewhere Else", mode="bicycling"), + ] + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + mock_plan_route.return_value = mock_route + + plan_event( + event, + config, + MagicMock(spec=VenueRegistry), + MagicMock(), + MagicMock(spec=OpencodeGoClient), + ) + + _, kwargs = mock_plan_route.call_args + assert kwargs["mode"] == "transit" + + +def test_plan_event_mode_override_matches_post_location_override( + event: Event, + config: Config, + resolved_location: ResolvedLocation, + mock_route: Route, + nyc_now: datetime, +) -> None: + """Mode rule matches the effective location (after location_overrides).""" + # The raw event location wouldn't match, but the location_override remaps it + # to an address that does — proving the two features compose. + event.location_raw = "Location available once RSVP'd" + config.location_overrides = [ + LocationOverride(calendar_id="test-cal", location="500 Bike Lane, NY"), + ] + config.mode_overrides = [ + ModeOverride(location_contains="Bike Lane", mode="bicycling"), + ] + with patch("commutecompass.resolver.resolve") as mock_resolve, \ + patch("commutecompass.routing.plan_route") as mock_plan_route, \ + patch("commutecompass.planner.now_nyc", return_value=nyc_now): + mock_resolve.return_value = resolved_location + mock_plan_route.return_value = mock_route + + plan_event( + event, + config, + MagicMock(spec=VenueRegistry), + MagicMock(), + MagicMock(spec=OpencodeGoClient), + ) + + _, kwargs = mock_plan_route.call_args + assert kwargs["mode"] == "bicycling"