diff --git a/agenda/types.py b/agenda/types.py
index 4f9d273..789c37c 100644
--- a/agenda/types.py
+++ b/agenda/types.py
@@ -2,6 +2,7 @@
import collections
import datetime
+from datetime import date
import functools
import typing
from collections import defaultdict
@@ -65,6 +66,7 @@ class Trip:
flight_bookings: list[StrDict] = field(default_factory=list)
name: str | None = None
private: bool = False
+ schengen_compliance: typing.Optional["SchengenCalculation"] = None
@property
def title(self) -> str:
@@ -365,3 +367,37 @@ class Holiday:
if self.local_name and self.local_name != self.name
else self.name
)
+
+
+@dataclass
+class SchengenStay:
+ """Represents a stay in the Schengen area."""
+
+ entry_date: date
+ exit_date: typing.Optional[date] # None if currently in Schengen
+ country: str
+ days: int
+
+ def __post_init__(self) -> None:
+ if self.exit_date is None:
+ # Currently in Schengen, calculate days up to today
+ self.days = (date.today() - self.entry_date).days + 1
+ else:
+ self.days = (self.exit_date - self.entry_date).days + 1
+
+
+@dataclass
+class SchengenCalculation:
+ """Result of Schengen time calculation."""
+
+ total_days_used: int
+ days_remaining: int
+ is_compliant: bool
+ current_180_day_period: tuple[date, date] # (start, end)
+ stays_in_period: list["SchengenStay"]
+ next_reset_date: typing.Optional[date] # When the 180-day window resets
+
+ @property
+ def days_over_limit(self) -> int:
+ """Days over the 90-day limit."""
+ return max(0, self.total_days_used - 90)
diff --git a/templates/macros.html b/templates/macros.html
index 9a7b068..d5592a6 100644
--- a/templates/macros.html
+++ b/templates/macros.html
@@ -351,6 +351,18 @@
Total CO₂: {{ "{:,.1f}".format(total_co2_kg) }} kg
{% endif %}
+ {% if trip.schengen_compliance %}
+
+ Schengen:
+ {% if trip.schengen_compliance.is_compliant %}
+ ✅ Compliant
+ {% else %}
+ ❌ Non-compliant
+ {% endif %}
+ ({{ trip.schengen_compliance.total_days_used }}/90 days used)
+
+ {% endif %}
+
{{ conference_list(trip) }}
{% for day, elements in trip.elements_grouped_by_day() %}
diff --git a/templates/navbar.html b/templates/navbar.html
index 52f2a3d..eed9c1f 100644
--- a/templates/navbar.html
+++ b/templates/navbar.html
@@ -14,6 +14,7 @@
{"endpoint": "weekends", "label": "Weekends" },
{"endpoint": "launch_list", "label": "Space launches" },
{"endpoint": "holiday_list", "label": "Holidays" },
+ {"endpoint": "schengen_report", "label": "Schengen" },
] + ([{"endpoint": "birthday_list", "label": "Birthdays" }]
if g.user.is_authenticated else [])
%}
diff --git a/templates/schengen_report.html b/templates/schengen_report.html
new file mode 100644
index 0000000..4311a0b
--- /dev/null
+++ b/templates/schengen_report.html
@@ -0,0 +1,211 @@
+{% extends "base.html" %}
+
+{% set heading = "Schengen Area Compliance Report" %}
+
+{% block title %}{{ heading }} - Edward Betts{% endblock %}
+
+{% block content %}
+
+
Schengen Area Compliance Report
+
+
+
+
+
+
+
+
+
Compliance Status
+ {% if current_compliance.is_compliant %}
+ ✅ COMPLIANT
+ {% else %}
+ ❌ NON-COMPLIANT
+ {% endif %}
+
+
+
Days Used
+
{{ current_compliance.total_days_used }}/90
+
+
+
+
+
+ {% if current_compliance.is_compliant %}
+
Days Remaining
+
{{ current_compliance.days_remaining }}
+ {% else %}
+
Days Over Limit
+
{{ current_compliance.days_over_limit }}
+ {% endif %}
+
+
+ {% if current_compliance.next_reset_date %}
+
Next Reset Date
+
{{ current_compliance.next_reset_date.strftime('%Y-%m-%d') }}
+ {% endif %}
+
+
+
+
+
Current 180-day Period
+
+ {{ current_compliance.current_180_day_period[0].strftime('%Y-%m-%d') }} to
+ {{ current_compliance.current_180_day_period[1].strftime('%Y-%m-%d') }}
+
+
+
+
+
+ {% if current_compliance.stays_in_period %}
+
+
+
+
+
+
+
+ Entry Date |
+ Exit Date |
+ Country |
+ Days |
+
+
+
+ {% for stay in current_compliance.stays_in_period %}
+
+ {{ stay.entry_date.strftime('%Y-%m-%d') }} |
+
+ {% if stay.exit_date %}
+ {{ stay.exit_date.strftime('%Y-%m-%d') }}
+ {% else %}
+ ongoing
+ {% endif %}
+ |
+ {{ stay.country | country_flag }} {{ stay.country.upper() }} |
+ {{ stay.days }} |
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+
+
+ {% if warnings %}
+
+
+
+ {% for warning in warnings %}
+
+ {{ warning.date.strftime('%Y-%m-%d') }}
+ {{ warning.message }}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+
+
+
+ {% if trips_with_compliance %}
+
+
+
+
+
+
+
+ Trip Date |
+ Days Used |
+ Days Remaining |
+ Status |
+
+
+
+ {% for trip_date, calculation in trips_with_compliance.items() %}
+
+ {{ trip_date.strftime('%Y-%m-%d') }} |
+ {{ calculation.total_days_used }}/90 |
+ {{ calculation.days_remaining }} |
+
+ {% if calculation.is_compliant %}
+ Compliant
+ {% else %}
+ Non-compliant
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% endif %}
+
+{% endblock %}
+
+{% block scripts %}
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/trip_page.html b/templates/trip_page.html
index 3f853ca..23d48c3 100644
--- a/templates/trip_page.html
+++ b/templates/trip_page.html
@@ -117,6 +117,25 @@
{% if delta %}
How long until trip: {{ delta }}
{% endif %}
+
+ {% if trip.schengen_compliance %}
+
+
Schengen Compliance:
+ {% if trip.schengen_compliance.is_compliant %}
+
✅ Compliant
+ {% else %}
+
❌ Non-compliant
+ {% endif %}
+
+ {{ trip.schengen_compliance.total_days_used }}/90 days used
+ {% if trip.schengen_compliance.is_compliant %}
+ ({{ trip.schengen_compliance.days_remaining }} remaining)
+ {% else %}
+ ({{ trip.schengen_compliance.days_over_limit }} over limit)
+ {% endif %}
+
+
+ {% endif %}
{% for item in trip.conferences %}
diff --git a/web_view.py b/web_view.py
index 62222be..2042432 100755
--- a/web_view.py
+++ b/web_view.py
@@ -25,6 +25,7 @@ import agenda.holidays
import agenda.stats
import agenda.thespacedevs
import agenda.trip
+import agenda.trip_schengen
import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip
@@ -36,6 +37,13 @@ app.config.from_object("config.default")
agenda.error_mail.setup_error_mail(app)
+@app.template_filter("country_flag")
+def country_flag_filter(country_code: str) -> str:
+ """Convert country code to emoji flag."""
+ from agenda.schengen import get_country_flag
+ return get_country_flag(country_code)
+
+
@app.before_request
def handle_auth() -> None:
"""Handle authentication and set global user."""
@@ -387,11 +395,17 @@ def get_trip_list(
route_distances: agenda.travel.RouteDistances | None = None,
) -> list[Trip]:
"""Get list of trips respecting current authentication status."""
- return [
+ trips = [
trip
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
if flask.g.user.is_authenticated or not trip.private
]
+
+ # Add Schengen compliance information to each trip
+ for trip in trips:
+ agenda.trip_schengen.add_schengen_compliance_to_trip(trip)
+
+ return trips
@app.route("/trip")
@@ -527,6 +541,9 @@ def trip_page(start: str) -> str:
prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list)
if not trip:
flask.abort(404)
+
+ # Add Schengen compliance information
+ trip = agenda.trip_schengen.add_schengen_compliance_to_trip(trip)
coordinates = agenda.trip.collect_trip_coordinates(trip)
routes = agenda.trip.get_trip_routes(trip, app.config["PERSONAL_DATA"])
@@ -603,6 +620,12 @@ def trip_stats() -> str:
)
+@app.route("/schengen")
+def schengen_report() -> str:
+ """Schengen compliance report."""
+ return agenda.trip_schengen.flask_route_schengen_report()
+
+
@app.route("/callback")
def auth_callback() -> tuple[str, int] | werkzeug.Response:
"""Process the authentication callback."""