diff --git a/backend/main.py b/backend/main.py index 7287b05..448f3e8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -42,6 +42,7 @@ from scrape_jobs import ( list_scrape_jobs, ) from env_config import get_database_url, get_required_env +from weather_forecast import ensure_weather_forecast_table, weather_sync_loop # --- KONFIGURASJON --- DB_URL = get_database_url() @@ -650,7 +651,8 @@ def format_row(row): json_list_fields = [ 'course_statuses', 'courses', 'gallery', 'greenfee', - 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer' + 'faqs', 'shotzoom', 'social_links', 'holes', 'golfpakker', 'cooperating_clubs', 'vtg_datoer', + 'weather_forecast' ] json_dict_fields = [ 'amenities', 'vtg', 'nsg_data', 'golfamore_data', @@ -1494,6 +1496,11 @@ async def lifespan(app: FastAPI): await ensure_articles_table(conn) await ensure_public_user_tables(conn) await ensure_scrape_jobs_table(conn) + await ensure_weather_forecast_table(conn) + app.state.weather_sync_stop_event = asyncio.Event() + app.state.weather_sync_task = asyncio.create_task( + weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event) + ) app.state.contact_submission_tracker = {} print("✅ Database tilkoblet og pool opprettet") except Exception as e: @@ -1501,6 +1508,12 @@ async def lifespan(app: FastAPI): raise e yield # Lukk pool ved avslutning + weather_stop_event = getattr(app.state, "weather_sync_stop_event", None) + weather_sync_task = getattr(app.state, "weather_sync_task", None) + if weather_stop_event is not None: + weather_stop_event.set() + if weather_sync_task is not None: + await weather_sync_task await app.state.pool.close() app = FastAPI(title="TeeOff API v3.8.0", lifespan=lifespan) @@ -2025,7 +2038,26 @@ async def get_facilities(): WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' ORDER BY is_main_course DESC, id ASC ) cs - ) as course_statuses + ) as course_statuses, ( + SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( + SELECT + forecast_date, + day_offset, + dry_all_day, + dry_daylight, + precip_mm, + precip_probability_max, + daylight_precip_mm, + daylight_precip_probability_max, + confidence, + source_updated_at, + source_expires_at, + calculated_at + FROM facility_weather_forecast + WHERE facility_id = f.id + ORDER BY day_offset ASC + ) w_data + ) as weather_forecast FROM facilities f ORDER BY f.name ASC """) @@ -2047,7 +2079,26 @@ async def get_facility(slug: str): AND (c.is_main_course = true OR (c.status NOT IN ('finnes_ingen_bane_to', 'ukjent'))) ORDER BY c.is_main_course DESC, c.id ASC ) c_data - ) as courses + ) as courses, ( + SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( + SELECT + forecast_date, + day_offset, + dry_all_day, + dry_daylight, + precip_mm, + precip_probability_max, + daylight_precip_mm, + daylight_precip_probability_max, + confidence, + source_updated_at, + source_expires_at, + calculated_at + FROM facility_weather_forecast + WHERE facility_id = f.id + ORDER BY day_offset ASC + ) w_data + ) as weather_forecast FROM facilities f WHERE f.slug = $1 """, slug) diff --git a/backend/sync_weather_forecast.py b/backend/sync_weather_forecast.py new file mode 100644 index 0000000..3a06c26 --- /dev/null +++ b/backend/sync_weather_forecast.py @@ -0,0 +1,25 @@ +import argparse +import asyncio + +import asyncpg + +from env_config import get_database_url +from weather_forecast import ensure_weather_forecast_table, sync_all_weather_forecasts + + +async def main(force: bool) -> None: + pool = await asyncpg.create_pool(get_database_url(), min_size=1, max_size=5, command_timeout=60) + try: + async with pool.acquire() as conn: + await ensure_weather_forecast_table(conn) + stats = await sync_all_weather_forecasts(pool, force=force) + print(stats) + finally: + await pool.close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Synk værprognoser fra MET for TeeOff-anlegg.") + parser.add_argument("--force", action="store_true", help="Tving ny nedlasting for alle anlegg.") + args = parser.parse_args() + asyncio.run(main(force=args.force)) diff --git a/backend/weather_forecast.py b/backend/weather_forecast.py new file mode 100644 index 0000000..1960344 --- /dev/null +++ b/backend/weather_forecast.py @@ -0,0 +1,451 @@ +import asyncio +import os +import random +from datetime import date, datetime, time, timedelta, timezone +from email.utils import parsedate_to_datetime +from zoneinfo import ZoneInfo + +import asyncpg +import httpx + +LOCAL_TZ = ZoneInfo("Europe/Oslo") +MET_LOCATIONFORECAST_URL = os.getenv( + "MET_LOCATIONFORECAST_URL", + "https://api.met.no/weatherapi/locationforecast/2.0/compact", +).strip() +MET_API_USER_AGENT = os.getenv( + "MET_API_USER_AGENT", + "TeeOff.no/1.0 contact@teeoff.no https://teeoff.no", +).strip() +WEATHER_SYNC_INTERVAL_SECONDS = max(900, int(os.getenv("WEATHER_SYNC_INTERVAL_SECONDS", "3600"))) +WEATHER_SYNC_INITIAL_DELAY_SECONDS = max(5, int(os.getenv("WEATHER_SYNC_INITIAL_DELAY_SECONDS", "20"))) +WEATHER_SYNC_CONCURRENCY = max(1, min(4, int(os.getenv("WEATHER_SYNC_CONCURRENCY", "3")))) +WEATHER_MAX_DAY_OFFSET = 7 +DAYLIGHT_START_HOUR = 8 +DAYLIGHT_END_HOUR = 20 +DRY_MAX_PRECIP_MM = 0.3 +DRY_MAX_PRECIP_PROBABILITY = 25.0 + + +def _parse_http_datetime(value: str | None) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + parsed = parsedate_to_datetime(raw) + except (TypeError, ValueError, IndexError): + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _parse_iso_datetime(value: str | None) -> datetime | None: + raw = str(value or "").strip() + if not raw: + return None + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + + +def _format_confidence(day_offset: int) -> str: + if day_offset <= 2: + return "high" + if day_offset <= 5: + return "medium" + return "low" + + +def _overlap_hours(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> float: + overlap_start = max(start, window_start) + overlap_end = min(end, window_end) + if overlap_end <= overlap_start: + return 0.0 + return (overlap_end - overlap_start).total_seconds() / 3600 + + +def _select_period_data(data: dict) -> tuple[int, dict] | None: + for key, hours in (("next_1_hours", 1), ("next_6_hours", 6), ("next_12_hours", 12)): + details = ((data.get(key) or {}).get("details") or {}) + if "precipitation_amount" in details or "probability_of_precipitation" in details: + return hours, details + return None + + +def summarize_weather_forecast(payload: dict, today_local: date | None = None) -> list[dict]: + timeseries = (((payload or {}).get("properties") or {}).get("timeseries") or []) + today = today_local or datetime.now(LOCAL_TZ).date() + + buckets: dict[date, dict] = {} + for offset in range(WEATHER_MAX_DAY_OFFSET + 1): + forecast_date = today + timedelta(days=offset) + buckets[forecast_date] = { + "forecast_date": forecast_date, + "day_offset": offset, + "precip_mm": 0.0, + "precip_probability_max": 0.0, + "daylight_precip_mm": 0.0, + "daylight_precip_probability_max": 0.0, + "confidence": _format_confidence(offset), + } + + for entry in timeseries: + period = _select_period_data((entry or {}).get("data") or {}) + if not period: + continue + + period_hours, details = period + base_time = _parse_iso_datetime((entry or {}).get("time")) + if base_time is None or period_hours <= 0: + continue + + precip_amount = float(details.get("precipitation_amount") or 0.0) + precip_probability = float(details.get("probability_of_precipitation") or 0.0) + start_local = base_time.astimezone(LOCAL_TZ) + end_local = (base_time + timedelta(hours=period_hours)).astimezone(LOCAL_TZ) + if end_local <= start_local: + continue + + for forecast_date, bucket in buckets.items(): + day_start = datetime.combine(forecast_date, time.min, tzinfo=LOCAL_TZ) + day_end = day_start + timedelta(days=1) + day_overlap = _overlap_hours(start_local, end_local, day_start, day_end) + if day_overlap > 0: + ratio = day_overlap / period_hours + bucket["precip_mm"] += precip_amount * ratio + bucket["precip_probability_max"] = max(bucket["precip_probability_max"], precip_probability) + + daylight_start = datetime.combine( + forecast_date, + time(hour=DAYLIGHT_START_HOUR), + tzinfo=LOCAL_TZ, + ) + daylight_end = datetime.combine( + forecast_date, + time(hour=DAYLIGHT_END_HOUR), + tzinfo=LOCAL_TZ, + ) + daylight_overlap = _overlap_hours(start_local, end_local, daylight_start, daylight_end) + if daylight_overlap > 0: + ratio = daylight_overlap / period_hours + bucket["daylight_precip_mm"] += precip_amount * ratio + bucket["daylight_precip_probability_max"] = max( + bucket["daylight_precip_probability_max"], + precip_probability, + ) + + rows: list[dict] = [] + for forecast_date in sorted(buckets.keys()): + bucket = buckets[forecast_date] + precip_mm = round(bucket["precip_mm"], 2) + precip_probability_max = round(bucket["precip_probability_max"], 1) + daylight_precip_mm = round(bucket["daylight_precip_mm"], 2) + daylight_precip_probability_max = round(bucket["daylight_precip_probability_max"], 1) + rows.append( + { + "forecast_date": forecast_date, + "day_offset": bucket["day_offset"], + "dry_all_day": precip_mm < DRY_MAX_PRECIP_MM + and precip_probability_max < DRY_MAX_PRECIP_PROBABILITY, + "dry_daylight": daylight_precip_mm < DRY_MAX_PRECIP_MM + and daylight_precip_probability_max < DRY_MAX_PRECIP_PROBABILITY, + "precip_mm": precip_mm, + "precip_probability_max": precip_probability_max, + "daylight_precip_mm": daylight_precip_mm, + "daylight_precip_probability_max": daylight_precip_probability_max, + "confidence": bucket["confidence"], + } + ) + return rows + + +async def ensure_weather_forecast_table(conn) -> None: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS facility_weather_forecast ( + facility_id INTEGER NOT NULL REFERENCES facilities(id) ON DELETE CASCADE, + forecast_date DATE NOT NULL, + day_offset SMALLINT NOT NULL CHECK (day_offset BETWEEN 0 AND 7), + dry_all_day BOOLEAN NOT NULL DEFAULT FALSE, + dry_daylight BOOLEAN NOT NULL DEFAULT FALSE, + precip_mm NUMERIC(6,2), + precip_probability_max NUMERIC(5,2), + daylight_precip_mm NUMERIC(6,2), + daylight_precip_probability_max NUMERIC(5,2), + confidence TEXT NOT NULL DEFAULT 'medium', + source_updated_at TIMESTAMPTZ, + source_expires_at TIMESTAMPTZ, + source_last_modified TEXT, + calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (facility_id, forecast_date) + ) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_facility_weather_forecast_daylight + ON facility_weather_forecast (day_offset, dry_daylight) + """ + ) + await conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_facility_weather_forecast_all_day + ON facility_weather_forecast (day_offset, dry_all_day) + """ + ) + + +async def _read_existing_metadata(conn, facility_id: int) -> dict | None: + return await conn.fetchrow( + """ + SELECT + COUNT(*)::int AS row_count, + MIN(forecast_date) AS min_date, + MAX(forecast_date) AS max_date, + MAX(source_expires_at) AS source_expires_at, + MAX(source_last_modified) AS source_last_modified + FROM facility_weather_forecast + WHERE facility_id = $1 + """, + facility_id, + ) + + +async def _persist_weather_rows( + conn, + facility_id: int, + rows: list[dict], + *, + source_updated_at: datetime | None, + source_expires_at: datetime | None, + source_last_modified: str | None, +) -> None: + async with conn.transaction(): + valid_dates = [row["forecast_date"] for row in rows] + await conn.execute("DELETE FROM facility_weather_forecast WHERE facility_id = $1", facility_id) + for row in rows: + await conn.execute( + """ + INSERT INTO facility_weather_forecast ( + facility_id, + forecast_date, + day_offset, + dry_all_day, + dry_daylight, + precip_mm, + precip_probability_max, + daylight_precip_mm, + daylight_precip_probability_max, + confidence, + source_updated_at, + source_expires_at, + source_last_modified, + calculated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW() + ) + ON CONFLICT (facility_id, forecast_date) DO UPDATE SET + day_offset = EXCLUDED.day_offset, + dry_all_day = EXCLUDED.dry_all_day, + dry_daylight = EXCLUDED.dry_daylight, + precip_mm = EXCLUDED.precip_mm, + precip_probability_max = EXCLUDED.precip_probability_max, + daylight_precip_mm = EXCLUDED.daylight_precip_mm, + daylight_precip_probability_max = EXCLUDED.daylight_precip_probability_max, + confidence = EXCLUDED.confidence, + source_updated_at = EXCLUDED.source_updated_at, + source_expires_at = EXCLUDED.source_expires_at, + source_last_modified = EXCLUDED.source_last_modified, + calculated_at = NOW() + """, + facility_id, + row["forecast_date"], + row["day_offset"], + row["dry_all_day"], + row["dry_daylight"], + row["precip_mm"], + row["precip_probability_max"], + row["daylight_precip_mm"], + row["daylight_precip_probability_max"], + row["confidence"], + source_updated_at, + source_expires_at, + source_last_modified, + ) + + if valid_dates: + await conn.execute( + """ + DELETE FROM facility_weather_forecast + WHERE facility_id = $1 + AND NOT (forecast_date = ANY($2::date[])) + """, + facility_id, + valid_dates, + ) + + +async def sync_facility_weather_forecast( + conn, + client: httpx.AsyncClient, + facility_id: int, + lat: float, + lng: float, + *, + today_local: date | None = None, + force: bool = False, +) -> str: + today = today_local or datetime.now(LOCAL_TZ).date() + metadata = await _read_existing_metadata(conn, facility_id) + row_count = int(metadata["row_count"] or 0) if metadata else 0 + min_date = metadata["min_date"] if metadata else None + max_date = metadata["max_date"] if metadata else None + source_expires_at = metadata["source_expires_at"] if metadata else None + source_last_modified = str(metadata["source_last_modified"] or "").strip() if metadata else "" + + needs_full_window_refresh = ( + row_count != WEATHER_MAX_DAY_OFFSET + 1 + or min_date != today + or max_date != today + timedelta(days=WEATHER_MAX_DAY_OFFSET) + ) + + if not force and not needs_full_window_refresh and isinstance(source_expires_at, datetime): + if source_expires_at.tzinfo is None: + source_expires_at = source_expires_at.replace(tzinfo=timezone.utc) + if source_expires_at > datetime.now(timezone.utc): + return "cached" + + headers: dict[str, str] = {} + if source_last_modified and not needs_full_window_refresh: + headers["If-Modified-Since"] = source_last_modified + + response = await client.get( + MET_LOCATIONFORECAST_URL, + params={ + "lat": round(float(lat), 4), + "lon": round(float(lng), 4), + }, + headers=headers, + ) + + if response.status_code == 304: + refreshed_expires_at = _parse_http_datetime(response.headers.get("Expires")) or source_expires_at + refreshed_last_modified = response.headers.get("Last-Modified") or source_last_modified + await conn.execute( + """ + UPDATE facility_weather_forecast + SET source_expires_at = $2, + source_last_modified = $3, + calculated_at = NOW() + WHERE facility_id = $1 + """, + facility_id, + refreshed_expires_at, + refreshed_last_modified, + ) + return "not_modified" + + response.raise_for_status() + payload = response.json() + rows = summarize_weather_forecast(payload, today_local=today) + updated_at = _parse_iso_datetime((((payload.get("properties") or {}).get("meta") or {}).get("updated_at"))) + expires_at = _parse_http_datetime(response.headers.get("Expires")) + last_modified = response.headers.get("Last-Modified") + await _persist_weather_rows( + conn, + facility_id, + rows, + source_updated_at=updated_at, + source_expires_at=expires_at, + source_last_modified=last_modified, + ) + return "updated" + + +async def sync_all_weather_forecasts(pool, *, force: bool = False) -> dict[str, int]: + today_local = datetime.now(LOCAL_TZ).date() + latest_local_date = today_local + timedelta(days=WEATHER_MAX_DAY_OFFSET) + + async with pool.acquire() as conn: + facilities = await conn.fetch( + """ + SELECT id, name, lat, lng + FROM facilities + WHERE lat IS NOT NULL AND lng IS NOT NULL + ORDER BY id ASC + """ + ) + + if not facilities: + return {"facilities": 0, "updated": 0, "cached": 0, "not_modified": 0, "failed": 0} + + stats = {"facilities": len(facilities), "updated": 0, "cached": 0, "not_modified": 0, "failed": 0} + semaphore = asyncio.Semaphore(WEATHER_SYNC_CONCURRENCY) + timeout = httpx.Timeout(20.0, connect=10.0) + + async with httpx.AsyncClient( + timeout=timeout, + headers={ + "User-Agent": MET_API_USER_AGENT, + "Accept": "application/json", + }, + follow_redirects=True, + ) as client: + async def handle_facility(facility) -> None: + async with semaphore: + async with pool.acquire() as conn: + try: + outcome = await sync_facility_weather_forecast( + conn, + client, + int(facility["id"]), + float(facility["lat"]), + float(facility["lng"]), + force=force, + ) + stats[outcome] = stats.get(outcome, 0) + 1 + except Exception as exc: + stats["failed"] += 1 + print( + f"Vær-sync feilet for {facility['name']} (id={facility['id']}): {exc}" + ) + + await asyncio.gather(*(handle_facility(facility) for facility in facilities)) + + async with pool.acquire() as conn: + await conn.execute( + """ + DELETE FROM facility_weather_forecast + WHERE forecast_date < $1 + OR forecast_date > $2 + """, + today_local, + latest_local_date, + ) + + return stats + + +async def weather_sync_loop(pool, stop_event: asyncio.Event) -> None: + await asyncio.sleep(WEATHER_SYNC_INITIAL_DELAY_SECONDS + random.uniform(0, 30)) + + while not stop_event.is_set(): + try: + stats = await sync_all_weather_forecasts(pool) + print( + "Vær-sync fullført: " + f"{stats['updated']} oppdatert, " + f"{stats['not_modified']} uendret, " + f"{stats['cached']} cachet, " + f"{stats['failed']} feilet" + ) + except Exception as exc: + print(f"Vær-sync feilet på batch-nivå: {exc}") + + sleep_seconds = WEATHER_SYNC_INTERVAL_SECONDS + random.uniform(0, 600) + try: + await asyncio.wait_for(stop_event.wait(), timeout=sleep_seconds) + except asyncio.TimeoutError: + continue diff --git a/frontend/src/app/FacilitySearch.tsx b/frontend/src/app/FacilitySearch.tsx index 7980ee8..559aec1 100755 --- a/frontend/src/app/FacilitySearch.tsx +++ b/frontend/src/app/FacilitySearch.tsx @@ -43,6 +43,18 @@ type Facility = { nsg_data?: unknown; vtg_datoer?: unknown; course_statuses?: unknown; + weather_forecast?: unknown; +}; + +type WeatherForecastDay = { + day_offset?: number; + dry_all_day?: boolean; + dry_daylight?: boolean; + precip_mm?: number; + precip_probability_max?: number; + daylight_precip_mm?: number; + daylight_precip_probability_max?: number; + confidence?: string; }; type FacilitySearchProps = { @@ -346,6 +358,18 @@ const matchesSpecialFilter = (specialFilter: string, flags: SpecialFlags) => { return true; }; +const WEATHER_DAY_OPTIONS = [ + { value: "", label: "Alle dager" }, + { value: "0", label: "I dag" }, + { value: "1", label: "I morgen" }, + { value: "2", label: "Om 2 dager" }, + { value: "3", label: "Om 3 dager" }, + { value: "4", label: "Om 4 dager" }, + { value: "5", label: "Om 5 dager" }, + { value: "6", label: "Om 6 dager" }, + { value: "7", label: "Om en uke" }, +]; + const getSearchShellClasses = (variant: Variant) => variant === "home" ? "rounded-[2rem] bg-[#39443B] px-4 py-5 text-white shadow-2xl sm:px-6 sm:py-7" @@ -375,6 +399,7 @@ export default function FacilitySearch({ const [statusFilter, setStatusFilter] = useState(""); const [holeFilter, setHoleFilter] = useState(""); const [specialFilter, setSpecialFilter] = useState(""); + const [weatherDayFilter, setWeatherDayFilter] = useState(""); const [architectFilter, setArchitectFilter] = useState(""); const [facilityFilter, setFacilityFilter] = useState(""); const [sortMethod, setSortMethod] = useState("updated"); @@ -470,6 +495,7 @@ export default function FacilitySearch({ const golfamoreData = parseJson>(facility.golfamore_data, {}); const nsgData = parseJson>(facility.nsg_data, {}); const rawStatuses = parseJson(facility.course_statuses, []); + const weatherForecast = parseJson(facility.weather_forecast, []); const statuses = Array.isArray(rawStatuses) && rawStatuses.length > 0 ? rawStatuses @@ -533,6 +559,11 @@ export default function FacilitySearch({ hasNSG, hasSimulator, }); + const selectedWeatherDayOffset = Number.parseInt(weatherDayFilter, 10); + const weatherDay = Number.isNaN(selectedWeatherDayOffset) + ? null + : weatherForecast.find((entry) => Number(entry?.day_offset) === selectedWeatherDayOffset); + const matchesWeather = !weatherDayFilter || Boolean(weatherDay?.dry_daylight); const matchesArchitect = !architectFilter || architectKey === architectFilter; const matchesFacility = !facilityFilter || facility.slug === facilityFilter; @@ -549,6 +580,7 @@ export default function FacilitySearch({ matchesStatus, matchesHoles, matchesSpecial, + matchesWeather, matchesArchitect, matchesFacility, }; @@ -560,6 +592,7 @@ export default function FacilitySearch({ facility.matchesStatus && facility.matchesHoles && facility.matchesSpecial && + facility.matchesWeather && facility.matchesArchitect && facility.matchesFacility ) @@ -574,13 +607,26 @@ export default function FacilitySearch({ } return a.name.localeCompare(b.name, "nb"); }); - }, [areaFilter, architectFilter, facilityFilter, holeFilter, initialFacilities, searchQuery, sortMethod, specialFilter, statusFilter, userLocation]); + }, [ + areaFilter, + architectFilter, + facilityFilter, + holeFilter, + initialFacilities, + searchQuery, + sortMethod, + specialFilter, + statusFilter, + userLocation, + weatherDayFilter, + ]); const filtersCount = [ areaFilter, statusFilter, holeFilter, specialFilter, + weatherDayFilter, architectFilter, facilityFilter, searchQuery.trim(), @@ -649,7 +695,7 @@ export default function FacilitySearch({ }`} aria-hidden={isCollapsibleHomeSearch && !searchPanelOpen} > -
+
{!fixedAreaFilter && ( {areaOptions.map((option) => ( @@ -681,8 +727,21 @@ export default function FacilitySearch({ + + {WEATHER_DAY_OPTIONS.map((option) => ( + + ))} + + - + @@ -744,6 +803,7 @@ export default function FacilitySearch({ setStatusFilter(""); setHoleFilter(""); setSpecialFilter(""); + setWeatherDayFilter(""); setArchitectFilter(""); setFacilityFilter(""); setSortMethod(userLocation ? "dist" : "updated"); @@ -756,6 +816,10 @@ export default function FacilitySearch({
+

+ Værfilteret bygger på prognosen fra MET akkurat nå. Lengre fram i tid gir lavere sikkerhet. +

+