Add timeline car import and CO2 trip stats

This commit is contained in:
Edward Betts 2026-08-03 13:52:45 +01:00
parent 38dbb2b4e7
commit 3747e83ade
14 changed files with 1038 additions and 29 deletions

View file

@ -0,0 +1,152 @@
"""Tests for importing car journeys from a Google Timeline export."""
from __future__ import annotations
import json
from datetime import date, datetime, timezone
from pathlib import Path
import typing
from agenda import car_journey_timeline, car_journey_yaml
def test_journey_key_orders_split_same_day_rows() -> None:
"""Datetime rows should sort chronologically within a single day."""
earlier = {
"trip": date(2025, 7, 4),
"depart": datetime(2025, 7, 4, 8, 0, tzinfo=timezone.utc),
"arrive": datetime(2025, 7, 4, 8, 30, tzinfo=timezone.utc),
"route": "a",
}
later = {
"trip": date(2025, 7, 4),
"depart": datetime(2025, 7, 4, 9, 0, tzinfo=timezone.utc),
"arrive": datetime(2025, 7, 4, 9, 30, tzinfo=timezone.utc),
"route": "b",
}
assert car_journey_yaml.journey_key(earlier) < car_journey_yaml.journey_key(later)
def write_json(path: Path, data: dict[str, typing.Any]) -> None:
"""Write JSON test data."""
path.write_text(json.dumps(data))
def test_import_from_timeline_splits_stopovers(tmp_path: Path) -> None:
"""Past car journeys should be split into multiple timeline segments."""
data_dir = tmp_path
route_dir = data_dir / "car_routes"
route_dir.mkdir()
(data_dir / "car_journeys.yaml").write_text("""---
- trip: 2025-07-04
depart: 2025-07-04
arrive: 2025-07-04
route: PCH_to_Far
- trip: 2026-07-16
depart: 2026-07-16
arrive: 2026-07-16
route: Future_to_Elsewhere
""")
write_json(
route_dir / "PCH_to_Far.geojson",
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "LineString",
"coordinates": [[-2.6, 51.4], [-4.1, 50.3]],
},
},
)
write_json(
route_dir / "Future_to_Elsewhere.geojson",
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "LineString",
"coordinates": [[0.0, 0.0], [1.0, 1.0]],
},
},
)
timeline_path = tmp_path / "Timeline.json"
write_json(
timeline_path,
{
"semanticSegments": [
{
"startTime": "2025-07-04T08:00:00+00:00",
"endTime": "2025-07-04T10:00:00+00:00",
"timelinePath": [
{
"point": "51.4000°, -2.6000°",
"time": "2025-07-04T08:00:00+00:00",
},
{
"point": "51.0000°, -3.1000°",
"time": "2025-07-04T08:30:00+00:00",
},
],
},
{
"startTime": "2025-07-04T08:00:00+00:00",
"endTime": "2025-07-04T08:30:00+00:00",
"activity": {
"start": {"latLng": "51.4000°, -2.6000°"},
"end": {"latLng": "51.0000°, -3.1000°"},
"topCandidate": {"type": "IN_PASSENGER_VEHICLE"},
},
},
{
"startTime": "2025-07-04T10:00:00+00:00",
"endTime": "2025-07-04T12:00:00+00:00",
"timelinePath": [
{
"point": "51.0000°, -3.1000°",
"time": "2025-07-04T10:00:00+00:00",
},
{
"point": "50.3000°, -4.1000°",
"time": "2025-07-04T11:00:00+00:00",
},
],
},
{
"startTime": "2025-07-04T10:00:00+00:00",
"endTime": "2025-07-04T11:00:00+00:00",
"activity": {
"start": {"latLng": "51.0000°, -3.1000°"},
"end": {"latLng": "50.3000°, -4.1000°"},
"topCandidate": {"type": "IN_PASSENGER_VEHICLE"},
},
},
]
},
)
routes_written, rows_replaced, rows_written = (
car_journey_timeline.import_from_timeline(
timeline_path, data_dir=data_dir, today=date(2026, 7, 9)
)
)
assert routes_written == 2
assert rows_replaced == 1
assert rows_written == 2
updated = car_journey_yaml.load_yaml_list(data_dir / "car_journeys.yaml")
assert len(updated) == 3
assert updated[0]["from"] == "PCH"
assert updated[0]["to"] == "Drive stop 1"
assert updated[1]["from"] == "Drive stop 1"
assert updated[1]["to"] == "Far"
assert updated[2]["route"] == "Future_to_Elsewhere"
generated_paths = sorted(route_dir.glob("timeline_*.geojson"))
assert len(generated_paths) == 2
generated_geojson = json.loads(generated_paths[0].read_text())
assert generated_geojson["geometry"]["type"] == "LineString"

View file

@ -2,7 +2,7 @@
import json
import pathlib
from datetime import date
from datetime import date, datetime, timezone
import agenda.trip
import pytest
@ -203,6 +203,9 @@ def test_load_cars_infers_route_labels_and_home_marker(
assert cars[0]["to"] == "EMF"
assert cars[0]["geojson_filename"] == "PCH_to_EMF"
assert cars[0]["distance"] > 0
assert cars[0]["co2_kg"] == pytest.approx(
cars[0]["distance"] * agenda.trip.CAR_CO2_KG_PER_KM
)
assert cars[0]["from_location"] == {
"name": "PCH",
"type": "home",
@ -274,3 +277,49 @@ def test_get_trip_routes_includes_car_geojson() -> None:
"geojson_filename": "car_routes/PCH_to_EMF",
}
]
def test_trip_title_ignores_generated_drive_stop_labels() -> None:
"""Fallback trip titles should ignore synthetic split-car stop labels."""
trip = Trip(
start=date(2025, 12, 29),
travel=[
{
"type": "car",
"depart": datetime(2025, 12, 29, 11, 4, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 11, 10, tzinfo=timezone.utc),
"from": "PCH",
"to": "Drive stop 1",
},
{
"type": "car",
"depart": datetime(2025, 12, 29, 11, 27, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 11, 40, tzinfo=timezone.utc),
"from": "Drive stop 1",
"to": "Drive stop 2",
},
{
"type": "car",
"depart": datetime(2025, 12, 29, 14, 6, tzinfo=timezone.utc),
"arrive": datetime(2025, 12, 29, 15, 22, tzinfo=timezone.utc),
"from": "Drive stop 2",
"to": "St Ives",
},
{
"type": "car",
"depart": datetime(2026, 1, 5, 12, 46, tzinfo=timezone.utc),
"arrive": datetime(2026, 1, 5, 13, 0, tzinfo=timezone.utc),
"from": "St Ives",
"to": "Drive stop 1",
},
{
"type": "car",
"depart": datetime(2026, 1, 5, 16, 58, tzinfo=timezone.utc),
"arrive": datetime(2026, 1, 5, 17, 29, tzinfo=timezone.utc),
"from": "Drive stop 3",
"to": "PCH",
},
],
)
assert trip.title == "St Ives"

View file

@ -1,6 +1,6 @@
"""Regression tests for trip page route wiring and rendering."""
from datetime import date, datetime
from datetime import date, datetime, timedelta, timezone
import typing
from unittest import mock
@ -307,3 +307,57 @@ def test_trip_page_uses_bed_icon_for_overnight_train_coach() -> None:
assert response.status_code == 200
page = response.data.decode()
assert "🛏️ Coach S, Seat 4" in page
def test_trip_page_renders_car_times_in_local_route_timezone() -> None:
"""Car journeys should display in the route's local timezone."""
trip = Trip(
start=date(2023, 8, 23),
travel=[
{
"type": "car",
"depart": datetime(
2023, 8, 24, 5, 22, tzinfo=timezone(timedelta(hours=1))
),
"arrive": datetime(
2023, 8, 24, 6, 26, tzinfo=timezone(timedelta(hours=1))
),
"from": "JFK",
"to": "Rockaway",
"distance": 102.0,
"from_location": {
"name": "JFK",
"country": "us",
"latitude": 40.64,
"longitude": -73.78,
},
"to_location": {
"name": "Rockaway",
"country": "us",
"latitude": 40.907,
"longitude": -74.554,
},
}
],
)
with web_view.app.app_context():
with (
mock.patch.object(web_view, "get_trip_list", return_value=[trip]),
mock.patch.object(agenda.weather, "get_trip_weather", return_value=[]),
mock.patch.object(
web_view,
"_timezone_from_coordinates",
return_value="America/New_York",
),
):
web_view.app.config["TESTING"] = True
with web_view.app.test_client() as client:
response = client.get("/trip/2023-08-23")
assert response.status_code == 200
page = response.data.decode()
assert "JFK → Rockaway" in page
assert "00:22 → 01:26" in page
assert "05:22 → 06:26" not in page

View file

@ -3,6 +3,7 @@
from datetime import date
import agenda
import pytest
from agenda.stats import calculate_yearly_stats
from agenda.types import Trip
@ -35,3 +36,22 @@ def test_new_country_respects_previously_visited() -> None:
yearly_stats = calculate_yearly_stats(trips, {"CZ"})
assert "new_countries" not in yearly_stats[2024]
def test_yearly_stats_tracks_co2_by_transport_type() -> None:
"""CO₂ stats should include a per-transport-type breakdown."""
trip = Trip(
start=date(2024, 5, 1),
travel=[
{"type": "car", "distance": 100.0, "co2_kg": 21.8},
{"type": "bus", "distance": 50.0, "co2_kg": 5.0},
],
)
yearly_stats = calculate_yearly_stats([trip])
assert yearly_stats[2024]["co2_kg"] == pytest.approx(26.8)
assert yearly_stats[2024]["co2_by_transport_type"] == {
"car": 21.8,
"bus": 5.0,
}