Før simulatorer

This commit is contained in:
Erol Haagenrud 2026-04-26 12:12:40 +02:00
parent a508db6071
commit 9af374adda
5 changed files with 249 additions and 188 deletions

View file

@ -816,6 +816,7 @@ FACILITY_VIEW_SEARCH_FIELDS = {
'course_statuses', 'weather_forecast', 'course_statuses', 'weather_forecast',
} }
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | { FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
'has_golfpakker',
'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts', 'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts',
'shortest_hole_meters', 'longest_hole_meters', 'shortest_hole_meters', 'longest_hole_meters',
} }
@ -1063,6 +1064,29 @@ async def resolve_cooperating_club_slugs(
return resolved_slugs return resolved_slugs
async def ensure_public_query_indexes(conn) -> None:
await conn.execute("""
CREATE INDEX IF NOT EXISTS facilities_is_published_name_idx
ON facilities (is_published, name)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS courses_facility_id_idx
ON courses (facility_id)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS courses_facility_id_main_idx
ON courses (facility_id, is_main_course DESC, id ASC)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS holes_course_id_idx
ON holes (course_id)
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS facility_weather_forecast_facility_day_idx
ON facility_weather_forecast (facility_id, day_offset)
""")
async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]: async def get_table_columns(conn, table_name: str, schema_name: str = "public") -> set[str]:
rows = await conn.fetch( rows = await conn.fetch(
""" """
@ -1779,7 +1803,7 @@ async def get_published_facility_by_slug(conn, slug: str):
SELECT id, name, slug SELECT id, name, slug
FROM facilities FROM facilities
WHERE slug = $1 WHERE slug = $1
AND COALESCE(is_published, TRUE) = TRUE AND is_published IS DISTINCT FROM FALSE
LIMIT 1 LIMIT 1
""", """,
slug, slug,
@ -2218,6 +2242,7 @@ async def lifespan(app: FastAPI):
await ensure_scrape_jobs_table(conn) await ensure_scrape_jobs_table(conn)
await ensure_course_status_history_table(conn) await ensure_course_status_history_table(conn)
await ensure_weather_forecast_table(conn) await ensure_weather_forecast_table(conn)
await ensure_public_query_indexes(conn)
app.state.weather_sync_stop_event = asyncio.Event() app.state.weather_sync_stop_event = asyncio.Event()
app.state.weather_sync_task = asyncio.create_task( app.state.weather_sync_task = asyncio.create_task(
weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event) weather_sync_loop(app.state.pool, app.state.weather_sync_stop_event)
@ -2897,142 +2922,158 @@ async def upsert_facility_rating(request: Request, payload: FacilityRatingUpsert
def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]: def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | None]:
normalized_view = (view or "").strip().lower() normalized_view = (view or "").strip().lower()
published_facilities_cte = """
course_statuses_sql = """ published_facilities AS (
( SELECT *
SELECT jsonb_agg(cs) FROM ( FROM facilities
SELECT id, name, status FROM courses WHERE is_published IS DISTINCT FROM FALSE
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to' )
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
""" """
weather_compact_sql = """ course_statuses_cte = """
( course_statuses AS (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( SELECT
SELECT c.facility_id,
day_offset, jsonb_agg(
dry_daylight jsonb_build_object(
FROM facility_weather_forecast 'id', c.id,
WHERE facility_id = f.id 'name', c.name,
ORDER BY day_offset ASC 'status', c.status
) w_data )
) as weather_forecast ORDER BY c.is_main_course DESC, c.id ASC
) AS course_statuses
FROM courses c
JOIN published_facilities pf ON pf.id = c.facility_id
WHERE c.status != 'finnes_ingen_bane_to'
GROUP BY c.facility_id
)
""" """
weather_full_sql = """ weather_compact_cte = """
( weather_compact AS (
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM ( SELECT
SELECT facility_id,
forecast_date, jsonb_agg(
day_offset, jsonb_build_object(
dry_all_day, 'day_offset', day_offset,
dry_daylight, 'dry_daylight', dry_daylight
precip_mm, )
precip_probability_max, ORDER BY day_offset ASC
daylight_precip_mm, ) AS weather_forecast
daylight_precip_probability_max, FROM facility_weather_forecast
confidence, WHERE facility_id IN (SELECT id FROM published_facilities)
source_updated_at, GROUP BY facility_id
source_expires_at, )
calculated_at """
FROM facility_weather_forecast weather_full_cte = """
WHERE facility_id = f.id weather_full AS (
ORDER BY day_offset ASC SELECT
) w_data facility_id,
) as weather_forecast jsonb_agg(
jsonb_build_object(
'forecast_date', forecast_date,
'day_offset', day_offset,
'dry_all_day', dry_all_day,
'dry_daylight', dry_daylight,
'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', confidence,
'source_updated_at', source_updated_at,
'source_expires_at', source_expires_at,
'calculated_at', calculated_at
)
ORDER BY day_offset ASC
) AS weather_forecast
FROM facility_weather_forecast
WHERE facility_id IN (SELECT id FROM published_facilities)
GROUP BY facility_id
)
"""
hole_counts_cte = """
hole_counts AS (
SELECT
c.facility_id,
COUNT(h.id) AS total_hole_count,
jsonb_build_object(
'3', COUNT(*) FILTER (WHERE h.par = 3),
'4', COUNT(*) FILTER (WHERE h.par = 4),
'5', COUNT(*) FILTER (WHERE h.par = 5),
'6', COUNT(*) FILTER (WHERE h.par = 6)
) AS hole_par_counts
FROM courses c
JOIN holes h ON h.course_id = c.id
JOIN published_facilities pf ON pf.id = c.facility_id
GROUP BY c.facility_id
)
"""
hole_lengths_cte = """
hole_lengths AS (
SELECT
c.facility_id,
MIN((length_value.value)::int) AS shortest_hole_meters,
MAX((length_value.value)::int) AS longest_hole_meters
FROM courses c
JOIN holes h ON h.course_id = c.id
JOIN published_facilities pf ON pf.id = c.facility_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
WHERE length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
AND length_value.value ~ '^[0-9]+$'
AND (length_value.value)::int BETWEEN 30 AND 900
GROUP BY c.facility_id
)
""" """
has_golfpakker_sql = """ has_golfpakker_sql = """
CASE CASE
WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE WHEN jsonb_typeof(pf.golfpakker) = 'array' AND jsonb_array_length(pf.golfpakker) > 0 THEN TRUE
WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE WHEN NULLIF(BTRIM(COALESCE(pf.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
ELSE FALSE ELSE FALSE
END as has_golfpakker END AS has_golfpakker
"""
total_hole_count_sql = """
(
SELECT COUNT(*)
FROM holes h
JOIN courses c ON c.id = h.course_id
WHERE c.facility_id = f.id
) as total_hole_count
"""
hole_par_counts_sql = """
(
SELECT jsonb_build_object(
'3', COUNT(*) FILTER (WHERE h.par = 3),
'4', COUNT(*) FILTER (WHERE h.par = 4),
'5', COUNT(*) FILTER (WHERE h.par = 5),
'6', COUNT(*) FILTER (WHERE h.par = 6)
)
FROM holes h
JOIN courses c ON c.id = h.course_id
WHERE c.facility_id = f.id
) as hole_par_counts
"""
shortest_hole_sql = """
(
SELECT MIN((length_value.value)::int)
FROM holes h
JOIN courses c ON c.id = h.course_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
WHERE c.facility_id = f.id
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
AND length_value.value ~ '^[0-9]+$'
AND (length_value.value)::int BETWEEN 30 AND 900
) as shortest_hole_meters
"""
longest_hole_sql = """
(
SELECT MAX((length_value.value)::int)
FROM holes h
JOIN courses c ON c.id = h.course_id
CROSS JOIN LATERAL jsonb_each_text(COALESCE(h.lengths, '{}'::jsonb)) AS length_value(key, value)
WHERE c.facility_id = f.id
AND length_value.key IN ('kortest', 'kort', 'mellomkort', 'mellomlang', 'lang', 'lengst')
AND length_value.value ~ '^[0-9]+$'
AND (length_value.value)::int BETWEEN 30 AND 900
) as longest_hole_meters
""" """
if normalized_view in {"search", "home"}: if normalized_view in {"search", "home"}:
return ( return (
f""" f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_compact_cte}
SELECT SELECT
f.id, pf.id,
f.slug, pf.slug,
f.name, pf.name,
f.architect, pf.architect,
f.description, pf.description,
f.city, pf.city,
f.county, pf.county,
f.banetype, pf.banetype,
f.image_url, pf.image_url,
f.phone, pf.phone,
f.website_url, pf.website_url,
f.golfbox_booking_url, pf.golfbox_booking_url,
f.golfbox_tournament_url, pf.golfbox_tournament_url,
f.weather_url, pf.weather_url,
f.lat, pf.lat,
f.lng, pf.lng,
f.golfamore, pf.golfamore,
f.golfamore_url, pf.golfamore_url,
f.nsg_url, pf.nsg_url,
{has_golfpakker_sql}, {has_golfpakker_sql},
f.vtg_pris, pf.vtg_pris,
f.vtg_lenke, pf.vtg_lenke,
f.vtg_beskrivelse, pf.vtg_beskrivelse,
f.amenities, pf.amenities,
f.golfamore_data, pf.golfamore_data,
f.nsg_data, pf.nsg_data,
f.vtg_datoer, pf.vtg_datoer,
f.footnote, pf.footnote,
f.footnote_updated_at, pf.footnote_updated_at,
f.status_updated_at, pf.status_updated_at,
{course_statuses_sql}, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
{weather_compact_sql} COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM facilities f FROM published_facilities pf
WHERE COALESCE(f.is_published, TRUE) = TRUE LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
ORDER BY f.name ASC LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
ORDER BY pf.name ASC
""", """,
FACILITY_VIEW_SEARCH_FIELDS, FACILITY_VIEW_SEARCH_FIELDS,
) )
@ -3040,48 +3081,57 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
if normalized_view == "place": if normalized_view == "place":
return ( return (
f""" f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_compact_cte},
{hole_counts_cte},
{hole_lengths_cte}
SELECT SELECT
f.id, pf.id,
f.slug, pf.slug,
f.name, pf.name,
f.architect, pf.architect,
f.description, pf.description,
f.city, pf.city,
f.county, pf.county,
f.banetype, pf.banetype,
f.image_url, pf.image_url,
f.phone, pf.phone,
f.website_url, pf.website_url,
f.golfbox_booking_url, pf.golfbox_booking_url,
f.golfbox_tournament_url, pf.golfbox_tournament_url,
f.weather_url, pf.weather_url,
f.lat, pf.lat,
f.lng, pf.lng,
f.golfamore, pf.golfamore,
f.golfamore_url, pf.golfamore_url,
f.nsg_url, pf.nsg_url,
{has_golfpakker_sql}, {has_golfpakker_sql},
f.greenfee, pf.greenfee,
f.standard_medlemskap, pf.standard_medlemskap,
f.vtg_pris, pf.vtg_pris,
f.vtg_lenke, pf.vtg_lenke,
f.vtg_beskrivelse, pf.vtg_beskrivelse,
f.amenities, pf.amenities,
f.golfamore_data, pf.golfamore_data,
f.nsg_data, pf.nsg_data,
f.vtg_datoer, pf.vtg_datoer,
f.footnote, pf.footnote,
f.footnote_updated_at, pf.footnote_updated_at,
f.status_updated_at, pf.status_updated_at,
{course_statuses_sql}, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
{total_hole_count_sql}, COALESCE(hc.total_hole_count, 0) AS total_hole_count,
{hole_par_counts_sql}, COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
{shortest_hole_sql}, hl.shortest_hole_meters,
{longest_hole_sql}, hl.longest_hole_meters,
{weather_compact_sql} COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM facilities f FROM published_facilities pf
WHERE COALESCE(f.is_published, TRUE) = TRUE LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
ORDER BY f.name ASC LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
ORDER BY pf.name ASC
""", """,
FACILITY_VIEW_PLACE_FIELDS, FACILITY_VIEW_PLACE_FIELDS,
) )
@ -3103,7 +3153,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.navn_rimeligste_alternativ, f.navn_rimeligste_alternativ,
f.rimeligste_alternativ f.rimeligste_alternativ
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC ORDER BY f.name ASC
""", """,
FACILITY_VIEW_MEMBERSHIP_FIELDS, FACILITY_VIEW_MEMBERSHIP_FIELDS,
@ -3127,7 +3177,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.vtg_datoer, f.vtg_datoer,
f.vtg_updated_at f.vtg_updated_at
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC ORDER BY f.name ASC
""", """,
FACILITY_VIEW_VTG_FIELDS, FACILITY_VIEW_VTG_FIELDS,
@ -3144,7 +3194,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.county, f.county,
f.ngf_number f.ngf_number
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC ORDER BY f.name ASC
""", """,
FACILITY_VIEW_CLUBNUMBERS_FIELDS, FACILITY_VIEW_CLUBNUMBERS_FIELDS,
@ -3158,7 +3208,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.status_updated_at, f.status_updated_at,
f.vtg_updated_at f.vtg_updated_at
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC ORDER BY f.name ASC
""", """,
FACILITY_VIEW_SITEMAP_FIELDS, FACILITY_VIEW_SITEMAP_FIELDS,
@ -3171,7 +3221,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.slug, f.slug,
f.name f.name
FROM facilities f FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC ORDER BY f.name ASC
""", """,
FACILITY_VIEW_ALIASES_FIELDS, FACILITY_VIEW_ALIASES_FIELDS,
@ -3179,17 +3229,26 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
return ( return (
f""" f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_full_cte},
{hole_counts_cte},
{hole_lengths_cte}
SELECT SELECT
f.*, pf.*,
{course_statuses_sql}, COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
{total_hole_count_sql}, COALESCE(hc.total_hole_count, 0) AS total_hole_count,
{hole_par_counts_sql}, COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
{shortest_hole_sql}, hl.shortest_hole_meters,
{longest_hole_sql}, hl.longest_hole_meters,
{weather_full_sql} COALESCE(wf.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM facilities f FROM published_facilities pf
WHERE COALESCE(f.is_published, TRUE) = TRUE LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
ORDER BY f.name ASC LEFT JOIN hole_counts hc ON hc.facility_id = pf.id
LEFT JOIN hole_lengths hl ON hl.facility_id = pf.id
LEFT JOIN weather_full wf ON wf.facility_id = pf.id
ORDER BY pf.name ASC
""", """,
None, None,
) )
@ -3266,7 +3325,7 @@ async def get_facility(slug: str, response: Response):
) as weather_forecast ) as weather_forecast
FROM facilities f FROM facilities f
WHERE f.slug = $1 WHERE f.slug = $1
AND COALESCE(f.is_published, TRUE) = TRUE AND f.is_published IS DISTINCT FROM FALSE
""", slug) """, slug)
if not row: if not row:

View file

@ -1,5 +1,6 @@
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData"; import { getAvailablePlaceConfigs, slugify } from "@/app/facilityData";
import { cache } from "react";
type FacilityAliasSource = { type FacilityAliasSource = {
slug?: string | null; slug?: string | null;
@ -114,9 +115,9 @@ function buildFacilityAliasMap(facilities: FacilityAliasSource[]) {
return Object.fromEntries(aliases); return Object.fromEntries(aliases);
} }
async function getFacilityAliasMap() { const getFacilityAliasMap = cache(async () => {
const response = await fetch(`${API_URL}/facilities?view=aliases`, { const response = await fetch(`${API_URL}/facilities?view=aliases`, {
next: { revalidate: 3600 }, cache: "no-store",
}); });
if (!response.ok) { if (!response.ok) {
@ -126,7 +127,7 @@ async function getFacilityAliasMap() {
const data = await response.json(); const data = await response.json();
const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : []; const facilities = Array.isArray(data) ? (data as FacilityAliasSource[]) : [];
return buildFacilityAliasMap(facilities); return buildFacilityAliasMap(facilities);
} });
export async function resolveFacilityAlias(alias: string) { export async function resolveFacilityAlias(alias: string) {
const normalizedAlias = slugify(alias); const normalizedAlias = slugify(alias);

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { notFound, permanentRedirect } from "next/navigation"; import { notFound, permanentRedirect } from "next/navigation";
import { cache } from "react";
import { API_URL } from "@/config/constants"; import { API_URL } from "@/config/constants";
import { resolveFacilityAlias } from "@/app/facilityAliases"; import { resolveFacilityAlias } from "@/app/facilityAliases";
import type { FacilityRecord } from "@/app/facilityData"; import type { FacilityRecord } from "@/app/facilityData";
@ -23,7 +24,7 @@ type FacilityPageData = FacilityRecord & {
[key: string]: unknown; [key: string]: unknown;
}; };
async function getFacility(slug: string) { const getFacility = cache(async (slug: string) => {
const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: "no-store" }); const res = await fetch(`${API_URL}/facilities/${slug}`, { cache: "no-store" });
if (!res.ok) { if (!res.ok) {
return null; return null;
@ -35,7 +36,7 @@ async function getFacility(slug: string) {
} }
return facility as FacilityPageData; return facility as FacilityPageData;
} });
async function getCanonicalSlug(slug: string) { async function getCanonicalSlug(slug: string) {
const canonicalSlug = await resolveFacilityAlias(slug); const canonicalSlug = await resolveFacilityAlias(slug);

View file

@ -4,7 +4,7 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function fetchPublicFacilities<T>( export async function fetchPublicFacilities<T>(
view: string, view: string,
revalidateSeconds: number, _revalidateSeconds: number,
{ {
allowEmpty = false, allowEmpty = false,
attempts = 3, attempts = 3,
@ -18,7 +18,7 @@ export async function fetchPublicFacilities<T>(
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
try { try {
const res = await fetch(`${API_URL}/facilities?view=${view}`, { const res = await fetch(`${API_URL}/facilities?view=${view}`, {
next: { revalidate: revalidateSeconds }, cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {

View file

@ -131,7 +131,7 @@ export default async function PlacePage({ params }: { params: Promise<{ slug: st
try { try {
const res = await fetch(`${API_URL}/place-pages/${slug}`, { const res = await fetch(`${API_URL}/place-pages/${slug}`, {
next: { revalidate }, cache: "no-store",
}); });
if (!res.ok) { if (!res.ok) {