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',
}
FACILITY_VIEW_PLACE_FIELDS = FACILITY_VIEW_SEARCH_FIELDS | {
'has_golfpakker',
'greenfee', 'standard_medlemskap', 'total_hole_count', 'hole_par_counts',
'shortest_hole_meters', 'longest_hole_meters',
}
@ -1063,6 +1064,29 @@ async def resolve_cooperating_club_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]:
rows = await conn.fetch(
"""
@ -1779,7 +1803,7 @@ async def get_published_facility_by_slug(conn, slug: str):
SELECT id, name, slug
FROM facilities
WHERE slug = $1
AND COALESCE(is_published, TRUE) = TRUE
AND is_published IS DISTINCT FROM FALSE
LIMIT 1
""",
slug,
@ -2218,6 +2242,7 @@ async def lifespan(app: FastAPI):
await ensure_scrape_jobs_table(conn)
await ensure_course_status_history_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_task = asyncio.create_task(
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]:
normalized_view = (view or "").strip().lower()
course_statuses_sql = """
(
SELECT jsonb_agg(cs) FROM (
SELECT id, name, status FROM courses
WHERE facility_id = f.id AND status != 'finnes_ingen_bane_to'
ORDER BY is_main_course DESC, id ASC
) cs
) as course_statuses
published_facilities_cte = """
published_facilities AS (
SELECT *
FROM facilities
WHERE is_published IS DISTINCT FROM FALSE
)
"""
weather_compact_sql = """
(
SELECT jsonb_agg(w_data ORDER BY w_data.day_offset ASC) FROM (
SELECT
day_offset,
dry_daylight
FROM facility_weather_forecast
WHERE facility_id = f.id
ORDER BY day_offset ASC
) w_data
) as weather_forecast
course_statuses_cte = """
course_statuses AS (
SELECT
c.facility_id,
jsonb_agg(
jsonb_build_object(
'id', c.id,
'name', c.name,
'status', c.status
)
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 = """
(
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
weather_compact_cte = """
weather_compact AS (
SELECT
facility_id,
jsonb_agg(
jsonb_build_object(
'day_offset', day_offset,
'dry_daylight', dry_daylight
)
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
)
"""
weather_full_cte = """
weather_full AS (
SELECT
facility_id,
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 = """
CASE
WHEN jsonb_typeof(f.golfpakker) = 'array' AND jsonb_array_length(f.golfpakker) > 0 THEN TRUE
WHEN NULLIF(BTRIM(COALESCE(f.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
WHEN jsonb_typeof(pf.golfpakker) = 'array' AND jsonb_array_length(pf.golfpakker) > 0 THEN TRUE
WHEN NULLIF(BTRIM(COALESCE(pf.golfpakker_url, '')), '') IS NOT NULL THEN TRUE
ELSE FALSE
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
END AS has_golfpakker
"""
if normalized_view in {"search", "home"}:
return (
f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_compact_cte}
SELECT
f.id,
f.slug,
f.name,
f.architect,
f.description,
f.city,
f.county,
f.banetype,
f.image_url,
f.phone,
f.website_url,
f.golfbox_booking_url,
f.golfbox_tournament_url,
f.weather_url,
f.lat,
f.lng,
f.golfamore,
f.golfamore_url,
f.nsg_url,
pf.id,
pf.slug,
pf.name,
pf.architect,
pf.description,
pf.city,
pf.county,
pf.banetype,
pf.image_url,
pf.phone,
pf.website_url,
pf.golfbox_booking_url,
pf.golfbox_tournament_url,
pf.weather_url,
pf.lat,
pf.lng,
pf.golfamore,
pf.golfamore_url,
pf.nsg_url,
{has_golfpakker_sql},
f.vtg_pris,
f.vtg_lenke,
f.vtg_beskrivelse,
f.amenities,
f.golfamore_data,
f.nsg_data,
f.vtg_datoer,
f.footnote,
f.footnote_updated_at,
f.status_updated_at,
{course_statuses_sql},
{weather_compact_sql}
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
pf.vtg_pris,
pf.vtg_lenke,
pf.vtg_beskrivelse,
pf.amenities,
pf.golfamore_data,
pf.nsg_data,
pf.vtg_datoer,
pf.footnote,
pf.footnote_updated_at,
pf.status_updated_at,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM published_facilities pf
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
LEFT JOIN weather_compact wc ON wc.facility_id = pf.id
ORDER BY pf.name ASC
""",
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":
return (
f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_compact_cte},
{hole_counts_cte},
{hole_lengths_cte}
SELECT
f.id,
f.slug,
f.name,
f.architect,
f.description,
f.city,
f.county,
f.banetype,
f.image_url,
f.phone,
f.website_url,
f.golfbox_booking_url,
f.golfbox_tournament_url,
f.weather_url,
f.lat,
f.lng,
f.golfamore,
f.golfamore_url,
f.nsg_url,
pf.id,
pf.slug,
pf.name,
pf.architect,
pf.description,
pf.city,
pf.county,
pf.banetype,
pf.image_url,
pf.phone,
pf.website_url,
pf.golfbox_booking_url,
pf.golfbox_tournament_url,
pf.weather_url,
pf.lat,
pf.lng,
pf.golfamore,
pf.golfamore_url,
pf.nsg_url,
{has_golfpakker_sql},
f.greenfee,
f.standard_medlemskap,
f.vtg_pris,
f.vtg_lenke,
f.vtg_beskrivelse,
f.amenities,
f.golfamore_data,
f.nsg_data,
f.vtg_datoer,
f.footnote,
f.footnote_updated_at,
f.status_updated_at,
{course_statuses_sql},
{total_hole_count_sql},
{hole_par_counts_sql},
{shortest_hole_sql},
{longest_hole_sql},
{weather_compact_sql}
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
pf.greenfee,
pf.standard_medlemskap,
pf.vtg_pris,
pf.vtg_lenke,
pf.vtg_beskrivelse,
pf.amenities,
pf.golfamore_data,
pf.nsg_data,
pf.vtg_datoer,
pf.footnote,
pf.footnote_updated_at,
pf.status_updated_at,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
hl.shortest_hole_meters,
hl.longest_hole_meters,
COALESCE(wc.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM published_facilities pf
LEFT JOIN course_statuses cs ON cs.facility_id = pf.id
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,
)
@ -3103,7 +3153,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.navn_rimeligste_alternativ,
f.rimeligste_alternativ
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC
""",
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_updated_at
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC
""",
FACILITY_VIEW_VTG_FIELDS,
@ -3144,7 +3194,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.county,
f.ngf_number
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC
""",
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.vtg_updated_at
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC
""",
FACILITY_VIEW_SITEMAP_FIELDS,
@ -3171,7 +3221,7 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
f.slug,
f.name
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
WHERE f.is_published IS DISTINCT FROM FALSE
ORDER BY f.name ASC
""",
FACILITY_VIEW_ALIASES_FIELDS,
@ -3179,17 +3229,26 @@ def build_public_facilities_query(view: str | None) -> tuple[str, set[str] | Non
return (
f"""
WITH
{published_facilities_cte},
{course_statuses_cte},
{weather_full_cte},
{hole_counts_cte},
{hole_lengths_cte}
SELECT
f.*,
{course_statuses_sql},
{total_hole_count_sql},
{hole_par_counts_sql},
{shortest_hole_sql},
{longest_hole_sql},
{weather_full_sql}
FROM facilities f
WHERE COALESCE(f.is_published, TRUE) = TRUE
ORDER BY f.name ASC
pf.*,
COALESCE(cs.course_statuses, '[]'::jsonb) AS course_statuses,
COALESCE(hc.total_hole_count, 0) AS total_hole_count,
COALESCE(hc.hole_par_counts, jsonb_build_object('3', 0, '4', 0, '5', 0, '6', 0)) AS hole_par_counts,
hl.shortest_hole_meters,
hl.longest_hole_meters,
COALESCE(wf.weather_forecast, '[]'::jsonb) AS weather_forecast
FROM published_facilities pf
LEFT JOIN course_statuses cs ON cs.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
LEFT JOIN weather_full wf ON wf.facility_id = pf.id
ORDER BY pf.name ASC
""",
None,
)
@ -3266,7 +3325,7 @@ async def get_facility(slug: str, response: Response):
) as weather_forecast
FROM facilities f
WHERE f.slug = $1
AND COALESCE(f.is_published, TRUE) = TRUE
AND f.is_published IS DISTINCT FROM FALSE
""", slug)
if not row:

View file

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

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound, permanentRedirect } from "next/navigation";
import { cache } from "react";
import { API_URL } from "@/config/constants";
import { resolveFacilityAlias } from "@/app/facilityAliases";
import type { FacilityRecord } from "@/app/facilityData";
@ -23,7 +24,7 @@ type FacilityPageData = FacilityRecord & {
[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" });
if (!res.ok) {
return null;
@ -35,7 +36,7 @@ async function getFacility(slug: string) {
}
return facility as FacilityPageData;
}
});
async function getCanonicalSlug(slug: string) {
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>(
view: string,
revalidateSeconds: number,
_revalidateSeconds: number,
{
allowEmpty = false,
attempts = 3,
@ -18,7 +18,7 @@ export async function fetchPublicFacilities<T>(
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const res = await fetch(`${API_URL}/facilities?view=${view}`, {
next: { revalidate: revalidateSeconds },
cache: "no-store",
});
if (!res.ok) {

View file

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