Add trip linking and names to Schengen compliance report

- Add trip_date and trip_name fields to SchengenStay dataclass
- Implement extract_schengen_stays_with_trip_info() to associate stays with trips
- Update schengen_report.html to show trip names with clickable links
- Add Trip column to stays table and trip name column to compliance history
- Links navigate to individual trip pages using existing URL structure

Closes #197

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Edward Betts 2025-07-15 15:06:09 +02:00
parent 1d37f92cfb
commit 8363a581c7
3 changed files with 87 additions and 11 deletions

View file

@ -1,17 +1,14 @@
"""Integration of Schengen calculator with the existing trip system."""
import logging
import typing
from datetime import date, timedelta
import flask
from . import trip
from .schengen import (
SchengenCalculation,
calculate_schengen_time,
extract_schengen_stays_from_travel,
)
from .types import StrDict, Trip
from .schengen import calculate_schengen_time, extract_schengen_stays_from_travel
from .types import SchengenCalculation, SchengenStay, StrDict, Trip
def add_schengen_compliance_to_trip(trip_obj: Trip) -> Trip:
@ -24,8 +21,9 @@ def add_schengen_compliance_to_trip(trip_obj: Trip) -> Trip:
trip_obj.schengen_compliance = calculation
except Exception as e:
# Log the error but don't fail the trip loading
import logging
logging.warning(f"Failed to calculate Schengen compliance for trip {trip_obj.start}: {e}")
logging.warning(
f"Failed to calculate Schengen compliance for trip {trip_obj.start}: {e}"
)
trip_obj.schengen_compliance = None
return trip_obj
@ -82,6 +80,47 @@ def generate_schengen_warnings(trip_list: list[Trip]) -> list[dict[str, typing.A
return warnings
def extract_schengen_stays_with_trip_info(
trip_list: list[Trip],
) -> list[SchengenStay]:
"""Extract Schengen stays from travel items with trip information."""
# Create a mapping of travel items to their trips
travel_to_trip_map = {}
for trip_obj in trip_list:
for travel_item in trip_obj.travel:
travel_to_trip_map[id(travel_item)] = trip_obj
# Collect all travel items
all_travel_items = []
for trip_obj in trip_list:
all_travel_items.extend(trip_obj.travel)
# Get stays with trip information
stays = extract_schengen_stays_from_travel(all_travel_items)
# Try to associate stays with trips based on entry dates
for stay in stays:
# Find the trip that contains this stay's entry date
for trip_obj in trip_list:
if trip_obj.start <= stay.entry_date <= (trip_obj.end or stay.entry_date):
stay.trip_date = trip_obj.start
stay.trip_name = trip_obj.title
break
# If no exact match, find the closest trip by start date
if stay.trip_date is None:
closest_trip = min(
trip_list,
key=lambda t: abs((t.start - stay.entry_date).days),
default=None,
)
if closest_trip:
stay.trip_date = closest_trip.start
stay.trip_name = closest_trip.title
return stays
def schengen_dashboard_data(data_dir: str | None = None) -> dict[str, typing.Any]:
"""Generate dashboard data for Schengen compliance."""
if data_dir is None:
@ -90,13 +129,24 @@ def schengen_dashboard_data(data_dir: str | None = None) -> dict[str, typing.Any
# Load all trips
trip_list = trip.build_trip_list(data_dir)
# Calculate current compliance
# Calculate current compliance with trip information
all_travel_items = []
for trip_obj in trip_list:
all_travel_items.extend(trip_obj.travel)
current_calculation = calculate_schengen_time(all_travel_items)
# Get stays with trip information
stays_with_trip_info = extract_schengen_stays_with_trip_info(trip_list)
# Update current calculation with trip information
current_calculation.stays_in_period = [
stay
for stay in stays_with_trip_info
if stay.entry_date >= current_calculation.current_180_day_period[0]
and stay.entry_date <= current_calculation.current_180_day_period[1]
]
# Generate warnings
warnings = generate_schengen_warnings(trip_list)
@ -122,11 +172,17 @@ def schengen_dashboard_data(data_dir: str | None = None) -> dict[str, typing.Any
}
def flask_route_schengen_report():
def flask_route_schengen_report() -> str:
"""Flask route for Schengen compliance report."""
data_dir = flask.current_app.config["PERSONAL_DATA"]
dashboard_data = schengen_dashboard_data(data_dir)
return flask.render_template("schengen_report.html", **dashboard_data)
# Load trips for the template
trip_list = trip.build_trip_list(data_dir)
return flask.render_template(
"schengen_report.html", trip_list=trip_list, **dashboard_data
)
def export_schengen_data_for_external_calculator(

View file

@ -377,6 +377,8 @@ class SchengenStay:
exit_date: typing.Optional[date] # None if currently in Schengen
country: str
days: int
trip_date: typing.Optional[date] = None # Trip start date for linking
trip_name: typing.Optional[str] = None # Trip name for display
def __post_init__(self) -> None:
if self.exit_date is None:

View file

@ -72,6 +72,7 @@
<th>Exit Date</th>
<th>Country</th>
<th>Days</th>
<th>Trip</th>
</tr>
</thead>
<tbody>
@ -87,6 +88,15 @@
</td>
<td>{{ stay.country | country_flag }} {{ stay.country.upper() }}</td>
<td>{{ stay.days }}</td>
<td>
{% if stay.trip_date and stay.trip_name %}
<a href="{{ url_for('trip_page', start=stay.trip_date.strftime('%Y-%m-%d')) }}" class="text-decoration-none">
{{ stay.trip_name }}
</a>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@ -139,6 +149,7 @@
<thead>
<tr>
<th>Trip Date</th>
<th>Trip Name</th>
<th>Days Used</th>
<th>Days Remaining</th>
<th>Status</th>
@ -148,6 +159,13 @@
{% for trip_date, calculation in trips_with_compliance.items() %}
<tr>
<td>{{ trip_date.strftime('%Y-%m-%d') }}</td>
<td>
<a href="{{ url_for('trip_page', start=trip_date.strftime('%Y-%m-%d')) }}" class="text-decoration-none">
{% for trip_obj in trip_list if trip_obj.start == trip_date %}
{{ trip_obj.title }}
{% endfor %}
</a>
</td>
<td>{{ calculation.total_days_used }}/90</td>
<td>{{ calculation.days_remaining }}</td>
<td>