Før filtre og kart på steder
This commit is contained in:
parent
89e2670c11
commit
d53383b6fd
5 changed files with 598 additions and 6 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
25
backend/sync_weather_forecast.py
Normal file
25
backend/sync_weather_forecast.py
Normal file
|
|
@ -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))
|
||||
451
backend/weather_forecast.py
Normal file
451
backend/weather_forecast.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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<SortMethod>("updated");
|
||||
|
|
@ -470,6 +495,7 @@ export default function FacilitySearch({
|
|||
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
|
||||
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
|
||||
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
|
||||
const weatherForecast = parseJson<WeatherForecastDay[]>(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}
|
||||
>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||
{!fixedAreaFilter && (
|
||||
<FieldSelect label="Område" value={areaFilter} onChange={setAreaFilter} labelClassName={labelClassName}>
|
||||
{areaOptions.map((option) => (
|
||||
|
|
@ -681,8 +727,21 @@ export default function FacilitySearch({
|
|||
<option value="under-utvikling">Under utvikling</option>
|
||||
</FieldSelect>
|
||||
|
||||
<FieldSelect
|
||||
label="Ikke regn meldt"
|
||||
value={weatherDayFilter}
|
||||
onChange={setWeatherDayFilter}
|
||||
labelClassName={labelClassName}
|
||||
>
|
||||
{WEATHER_DAY_OPTIONS.map((option) => (
|
||||
<option key={option.value || "all"} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
|
||||
<FieldSelect label="NSG / GOLFAMORE" value={specialFilter} onChange={setSpecialFilter} labelClassName={labelClassName}>
|
||||
<option value="">Ingen tillegg</option>
|
||||
<option value="">Ikke hensyntatt</option>
|
||||
<option value="golfamore">Golfamore</option>
|
||||
<option value="nsg">Seniorgolf / NSG</option>
|
||||
</FieldSelect>
|
||||
|
|
@ -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({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<p className={`mt-3 text-xs leading-5 ${variant === "home" ? "text-white/70" : "text-[#617063]"}`}>
|
||||
Værfilteret bygger på prognosen fra MET akkurat nå. Lengre fram i tid gir lavere sikkerhet.
|
||||
</p>
|
||||
|
||||
<div
|
||||
className={`mt-4 rounded-[1.2rem] px-4 py-3 text-sm font-bold ${
|
||||
variant === "home" ? "bg-white/10 text-white/90" : "bg-[#F3F6EE] text-[#617063]"
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export type FacilityRecord = {
|
|||
vtg_updated_at?: string | null;
|
||||
social_links?: unknown;
|
||||
course_statuses?: unknown;
|
||||
weather_forecast?: unknown;
|
||||
footnote?: string | null;
|
||||
footnote_updated_at?: string | null;
|
||||
status_updated_at?: string | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue