313 lines
11 KiB
TypeScript
Executable file
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);
|