Nye-TeeOff/frontend/src/app/facilityData.ts

313 lines
11 KiB
TypeScript
Executable file

import { STATUS_MAP } from "@/config/constants";
export type CourseStatus = {
status?: string;
name?: string;
};
export type FacilityRecord = {
id: number;
slug: string;
name: string;
description?: string | null;
city?: string | null;
county?: string | null;
banetype?: string | null;
image_url?: string | null;
phone?: string | null;
email?: string | null;
website_url?: string | null;
ngf_number?: string | number | null;
golfbox_club_id?: string | number | null;
golfbox_booking_url?: string | null;
golfbox_tournament_url?: string | null;
weather_url?: string | null;
lat?: number | null;
lng?: number | null;
golfamore?: boolean | null;
nsg_url?: string | null;
vtg_pris?: number | null;
vtg_lenke?: string | null;
vtg_beskrivelse?: string | null;
amenities?: unknown;
golfamore_data?: unknown;
nsg_data?: unknown;
vtg_datoer?: unknown;
vtg_updated_at?: string | null;
social_links?: unknown;
course_statuses?: unknown;
footnote?: string | null;
footnote_updated_at?: string | null;
status_updated_at?: string | null;
};
export type EnrichedFacility = FacilityRecord & {
holeValue: string;
primaryStatus: string;
hasGolfamore: boolean;
hasNSG: boolean;
hasSimulator: boolean;
hasDrivingRange: boolean;
hasVtg: boolean;
countySlug: string;
regions: string[];
statuses: CourseStatus[];
distance: number;
lastUpdatedTs: number;
};
export type PlaceConfig = {
slug: string;
areaFilter: string;
label: string;
title: string;
intro: string;
};
export const AREA_GROUPS: Record<string, string[]> = {
"nord-norge": ["finnmark", "troms", "nordland"],
"midt-norge": ["trondelag", "nord-trondelag", "sor-trondelag"],
vestlandet: ["more-og-romsdal", "sogn-og-fjordane", "hordaland", "rogaland", "vestland"],
sorlandet: ["vest-agder", "aust-agder", "agder"],
ostlandet: ["telemark", "vestfold", "ostfold", "buskerud", "hedmark", "oppland", "innlandet", "viken", "akershus", "oslo"],
"oslo-og-akershus": ["akershus", "oslo", "viken"],
};
const COUNTY_FILTER_ALIASES: Record<string, string[]> = {
trondelag: ["trondelag", "nord-trondelag", "sor-trondelag"],
};
export const HIERARCHICAL_AREA_OPTIONS = [
{ value: "", label: "Hele Norge", slug: "norge" },
{ value: "region:nord-norge", label: "Nord-Norge", slug: "nord-norge" },
{ value: "county:finnmark", label: "Finnmark", slug: "finnmark" },
{ value: "county:troms", label: "Troms", slug: "troms" },
{ value: "county:nordland", label: "Nordland", slug: "nordland" },
{ value: "county:nord-trondelag", label: "Nord-Trøndelag", slug: "nord-trondelag" },
{ value: "county:sor-trondelag", label: "Sør-Trøndelag", slug: "sor-trondelag" },
{ value: "county:trondelag", label: "Trøndelag", slug: "trondelag" },
{ value: "region:vestlandet", label: "Vestlandet", slug: "vestlandet" },
{ value: "county:more-og-romsdal", label: "Møre og Romsdal", slug: "more-og-romsdal" },
{ value: "county:sogn-og-fjordane", label: "Sogn og Fjordane", slug: "sogn-og-fjordane" },
{ value: "county:hordaland", label: "Hordaland", slug: "hordaland" },
{ value: "county:rogaland", label: "Rogaland", slug: "rogaland" },
{ value: "region:sorlandet", label: "Sørlandet", slug: "sorlandet" },
{ value: "county:vest-agder", label: "Vest-Agder", slug: "vest-agder" },
{ value: "county:aust-agder", label: "Aust-Agder", slug: "aust-agder" },
{ value: "county:agder", label: "Agder", slug: "agder" },
{ value: "region:ostlandet", label: "Østlandet", slug: "ostlandet" },
{ value: "county:telemark", label: "Telemark", slug: "telemark" },
{ value: "county:vestfold", label: "Vestfold", slug: "vestfold" },
{ value: "county:ostfold", label: "Østfold", slug: "ostfold" },
{ value: "county:buskerud", label: "Buskerud", slug: "buskerud" },
{ value: "county:hedmark", label: "Hedmark", slug: "hedmark" },
{ value: "county:oppland", label: "Oppland", slug: "oppland" },
{ value: "region:oslo-og-akershus", label: "Oslo og Akershus", slug: "oslo-og-akershus" },
{ value: "county:akershus", label: "Akershus", slug: "akershus" },
{ value: "county:oslo", label: "Oslo", slug: "oslo" },
{ value: "county:innlandet", label: "Innlandet", slug: "innlandet" },
{ value: "county:viken", label: "Viken", slug: "viken" },
];
export const STATUS_ICON_PATHS: Record<string, string> = {
aapen: "/icons/open.png",
aapen_med_vintergreener: "/icons/open-winter.png",
stengt: "/icons/closed.png",
aapner_snart: "/icons/open-soon.png",
stenger_snart: "/icons/close-soon.png",
under_utvikling: "/icons/under-development.png",
nedlagt: "/icons/discontinued.png",
ukjent: "/icons/unknown.png",
};
export const normalizeText = (value: unknown) =>
String(value ?? "")
.replace(/[æøå]/gi, (char) => {
const normalized = char.toLowerCase();
if (normalized === "æ") return "ae";
if (normalized === "ø") return "o";
if (normalized === "å") return "a";
return normalized;
})
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
export const normalizeStatus = (value: unknown) =>
String(value ?? "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\s+/g, "_")
.replace(/[^a-z0-9_]+/g, "")
.trim();
export const slugify = (value: unknown) =>
normalizeText(value)
.replace(/\s+/g, "-")
.replace(/^-+|-+$/g, "");
export const parseJson = <T,>(value: unknown, fallback: T): T => {
if (!value) return fallback;
if (typeof value === "object") return value as T;
try {
return JSON.parse(String(value)) as T;
} catch {
return fallback;
}
};
const hasTruthyAmenity = (value: unknown) => {
const normalized = normalizeText(value);
return Boolean(normalized) && !["nei", "no", "false", "0", "ingen"].includes(normalized);
};
export const getFacilityRegions = (county: string) => {
const countySlug = slugify(county);
return Object.entries(AREA_GROUPS)
.filter(([, counties]) => counties.includes(countySlug))
.map(([region]) => region);
};
const STATUS_ORDER = [
"aapen",
"aapen_med_vintergreener",
"stenger_snart",
"aapner_snart",
"ukjent",
"stengt",
"under_utvikling",
"nedlagt",
];
export const getPrimaryStatus = (statuses: Array<{ status?: string }>) => {
for (const candidate of STATUS_ORDER) {
if (statuses.some((status) => normalizeStatus(status.status) === candidate)) {
return candidate;
}
}
return "ukjent";
};
export const getStatusLabel = (status: string) => STATUS_MAP[status] || "Ukjent status";
export const formatUpdatedDate = (value: string | null | undefined) => {
if (!value) return "Ukjent";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Ukjent";
return date.toLocaleDateString("nb-NO", {
day: "2-digit",
month: "short",
year: "numeric",
});
};
export const buildMapUrl = (lat?: number | null, lng?: number | null) => {
if (typeof lat !== "number" || typeof lng !== "number") return null;
return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}`;
};
export const getDistance = (lat1: number, lon1: number, lat2: number, lon2: number) => {
const r = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
return r * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
export const enrichFacilities = (
facilities: FacilityRecord[],
userLocation?: { lat: number; lng: number } | null
): EnrichedFacility[] =>
(Array.isArray(facilities) ? facilities : []).map((facility) => {
const amenities = parseJson<Record<string, unknown>>(facility.amenities, {});
const golfamoreData = parseJson<Record<string, unknown>>(facility.golfamore_data, {});
const nsgData = parseJson<Record<string, unknown>>(facility.nsg_data, {});
const vtgDates = parseJson<unknown[]>(facility.vtg_datoer, []);
const rawStatuses = parseJson<CourseStatus[]>(facility.course_statuses, []);
const statuses =
Array.isArray(rawStatuses) && rawStatuses.length > 0
? rawStatuses
: [{ status: "ukjent", name: "Hovedbane" }];
const holeValue = String(amenities.antall_hull || "").trim();
const countySlug = slugify(facility.county || "");
const regions = getFacilityRegions(facility.county || "");
const updatedTsRaw = facility.status_updated_at ? new Date(facility.status_updated_at).getTime() : 0;
return {
...facility,
holeValue,
countySlug,
regions,
statuses,
primaryStatus: getPrimaryStatus(statuses),
hasGolfamore: facility.golfamore === true || Object.keys(golfamoreData).length > 0,
hasNSG: Boolean(facility.nsg_url) || Object.keys(nsgData).length > 0,
hasSimulator: hasTruthyAmenity(amenities.simulator),
hasDrivingRange: hasTruthyAmenity(amenities.drivingrange),
hasVtg:
Boolean(facility.vtg_pris) ||
Boolean(facility.vtg_lenke) ||
Boolean(facility.vtg_beskrivelse) ||
(Array.isArray(vtgDates) && vtgDates.length > 0),
distance:
userLocation && typeof facility.lat === "number" && typeof facility.lng === "number"
? getDistance(userLocation.lat, userLocation.lng, facility.lat, facility.lng)
: Number.POSITIVE_INFINITY,
lastUpdatedTs: Number.isFinite(updatedTsRaw) ? updatedTsRaw : 0,
};
});
export const matchesAreaFilter = (facility: EnrichedFacility, areaFilter: string) => {
if (!areaFilter) return true;
const selectedArea = areaFilter.replace(/^(region:|county:)/, "");
if (areaFilter.startsWith("region:")) {
return (
facility.regions.includes(selectedArea) ||
(AREA_GROUPS[selectedArea] ? AREA_GROUPS[selectedArea].includes(facility.countySlug) : false)
);
}
if (areaFilter.startsWith("county:")) {
const aliases = COUNTY_FILTER_ALIASES[selectedArea];
return aliases ? aliases.includes(facility.countySlug) : facility.countySlug === selectedArea;
}
return true;
};
export const filterFacilitiesByArea = (facilities: EnrichedFacility[], areaFilter: string) =>
facilities.filter((facility) => matchesAreaFilter(facility, areaFilter));
export const getPlaceConfigFromSlug = (slug: string): PlaceConfig | null => {
const option = HIERARCHICAL_AREA_OPTIONS.find((entry) => entry.slug === slug);
if (!option) return null;
if (!option.value) {
return {
slug,
areaFilter: "",
label: option.label,
title: "Golfbaner i Norge",
intro: "Se alle norske golfbaner samlet med banestatus, kart og lenker videre til hver baneprofil.",
};
}
const isRegion = option.value.startsWith("region:");
return {
slug,
areaFilter: option.value,
label: option.label,
title: `Golfbaner i ${option.label}`,
intro: isRegion
? `Utforsk golfbaner i ${option.label} med oppdatert banestatus, kart og direkte lenker til baneprofilene.`
: `Utforsk golfbaner i ${option.label} og sammenlign banestatus, plassering og banedetaljer i listen under.`,
};
};
export const getAvailablePlaceConfigs = () => HIERARCHICAL_AREA_OPTIONS.map(({ slug }) => slug);