fix: isochrone for poi selection and calculation was wrong, now it is not

This commit is contained in:
Jan-Henrik 2026-03-04 16:32:00 +01:00
parent bfe6146645
commit b891ca79ac
13 changed files with 331 additions and 40 deletions

View file

@ -2,14 +2,11 @@ import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import { compositeScore } from "@/lib/scoring";
import { CATEGORY_IDS, DEFAULT_WEIGHTS } from "@transportationer/shared";
import { CATEGORY_IDS, DEFAULT_WEIGHTS, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared";
import type { GridCell, HeatmapPayload, CategoryId } from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "berlin";
@ -17,7 +14,7 @@ export async function GET(req: NextRequest) {
const threshold = parseInt(p.get("threshold") ?? "15", 10);
const bboxStr = p.get("bbox");
if (!VALID_MODES.includes(mode)) {
if (!(TRAVEL_MODES as readonly string[]).includes(mode)) {
return NextResponse.json(
{ error: "Invalid mode", code: "INVALID_MODE" },
{ status: 400 },

View file

@ -52,7 +52,9 @@ export async function POST(req: NextRequest) {
`);
if (cached.length > 0) {
return NextResponse.json({ ...cached[0].result, cached: true });
const raw = cached[0].result;
const result = typeof raw === "string" ? JSON.parse(raw as string) : raw;
return NextResponse.json({ ...result, cached: true });
}
// Refuse to call valhalla_service while tiles are being rebuilt —
@ -86,14 +88,17 @@ export async function POST(req: NextRequest) {
);
}
// Store in PostGIS cache
// Store in PostGIS cache.
// Use an explicit ::jsonb cast so PostgreSQL receives a text parameter and
// parses it as JSON itself. Without the cast, postgres.js infers the JSONB
// column type and re-encodes the string as a JSONB string literal.
await Promise.resolve(sql`
INSERT INTO isochrone_cache (origin_geom, travel_mode, contours_min, result)
VALUES (
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326),
${mode},
${contours},
${JSON.stringify(geojson)}
${JSON.stringify(geojson)}::jsonb
)
`);

View file

@ -4,13 +4,12 @@ import type { CategoryId, ProfileId } from "@transportationer/shared";
import {
CATEGORY_IDS,
PROFILE_IDS,
TRAVEL_MODES,
VALID_THRESHOLDS,
} from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving", "fifteen"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const lat = parseFloat(p.get("lat") ?? "");
@ -26,7 +25,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 });
}
if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 });
if (!VALID_MODES.includes(mode)) {
if (!(TRAVEL_MODES as readonly string[]).includes(mode)) {
return NextResponse.json({ error: "Invalid mode" }, { status: 400 });
}
@ -123,6 +122,11 @@ export async function GET(req: NextRequest) {
recreation: r.time_recreation ?? undefined,
};
// "fifteen" is synthetic — grid_poi_details only stores the raw modes
// (walking, cycling, transit, driving). For fifteen we query all three
// contributing modes and pick the best (minimum travel_time_s) per subcategory.
const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode];
// Fetch nearest POI per subcategory for this grid point and mode.
const detailRows = await Promise.resolve(sql<{
category: string;
@ -131,11 +135,12 @@ export async function GET(req: NextRequest) {
distance_m: number | null;
travel_time_s: number | null;
}[]>`
SELECT category, subcategory, nearest_poi_name, distance_m, travel_time_s
SELECT DISTINCT ON (category, subcategory)
category, subcategory, nearest_poi_name, distance_m, travel_time_s
FROM grid_poi_details
WHERE grid_point_id = ${r.grid_point_id}::bigint
AND travel_mode = ${mode}
ORDER BY category, distance_m
AND travel_mode = ANY(${detailModes})
ORDER BY category, subcategory, travel_time_s ASC NULLS LAST
`);
type SubcategoryDetail = {

View file

@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { isochroneContours } from "@/lib/isochrone";
export const runtime = "nodejs";
const CACHE_TOLERANCE_M = 50;
type CacheFeature = { geometry: object; properties: { contour: number } };
export async function GET(req: NextRequest) {
console.log("[reachable-pois] GET called:", req.nextUrl.search);
const { searchParams } = req.nextUrl;
const city = searchParams.get("city");
const lat = parseFloat(searchParams.get("lat") ?? "");
const lng = parseFloat(searchParams.get("lng") ?? "");
const mode = searchParams.get("mode") ?? "walking";
const threshold = parseInt(searchParams.get("threshold") ?? "15", 10);
if (!city || isNaN(lat) || isNaN(lng)) {
return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 });
}
// Map modes the same way the display isochrone route does.
// "fifteen" uses cycling as the representative isochrone mode.
// "transit" uses its own multimodal isochrone.
const isoMode = mode === "fifteen" ? "cycling" : mode;
// Use the identical contours array that the display isochrone route caches with,
// so the exact-match lookup is guaranteed to find the entry once isochroneData
// is set on the client (which is our fetch trigger).
const contours = isochroneContours(threshold);
const cached = await Promise.resolve(sql<{ result: { features: CacheFeature[] } }[]>`
SELECT result
FROM isochrone_cache
WHERE travel_mode = ${isoMode}
AND contours_min = ${contours}
AND ST_DWithin(
origin_geom::geography,
ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography,
${CACHE_TOLERANCE_M}
)
ORDER BY created_at DESC
LIMIT 1
`);
if (cached.length === 0) {
// Should not happen if client fires this only after isochroneData is set.
console.warn(`[reachable-pois] cache miss for ${city} ${lat},${lng} mode=${isoMode} contours=${JSON.stringify(contours)}`);
return NextResponse.json({ pois: [], _debug: "cache_miss" }, { headers: { "Cache-Control": "no-store" } });
}
// Existing cache entries may have been stored with JSON.stringify() (double-encoded)
// resulting in a JSONB string instead of a JSONB object. Parse those on read.
const rawResult = cached[0].result;
const result: { features?: CacheFeature[] } =
typeof rawResult === "string" ? JSON.parse(rawResult as string) : rawResult;
const features = result?.features;
if (!Array.isArray(features) || features.length === 0) {
console.warn(`[reachable-pois] bad features: ${JSON.stringify(rawResult).slice(0, 200)}`);
return NextResponse.json({ pois: [], _debug: "bad_features" }, { headers: { "Cache-Control": "no-store" } });
}
// Pick the feature matching the exact threshold contour, or fall back to the largest.
const contourValues = features.map((f) => f.properties?.contour);
const match = features.find((f) => f.properties?.contour === threshold);
const polygon = (match ?? features[features.length - 1])?.geometry;
console.log(`[reachable-pois] features=${features.length} contours=${JSON.stringify(contourValues)} match=${!!match} threshold=${threshold}`);
if (!polygon) {
console.warn(`[reachable-pois] no polygon geometry`);
return NextResponse.json({ pois: [], _debug: "no_polygon" }, { headers: { "Cache-Control": "no-store" } });
}
const pois = await Promise.resolve(sql<{ category: string; subcategory: string; name: string | null; lat: number; lng: number }[]>`
SELECT category, subcategory, name,
ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng
FROM raw_pois
WHERE city_slug = ${city}
AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326))
ORDER BY category, subcategory, name
`);
if (pois.length === 0) {
// Log to help distinguish "no POIs in polygon" from a query error.
console.warn(`[reachable-pois] 0 pois for ${city} (cache hit, polygon type=${(polygon as { type?: string }).type})`);
return NextResponse.json({ pois: [], _debug: "empty_spatial" }, { headers: { "Cache-Control": "no-store" } });
}
return NextResponse.json(
{ pois },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}

View file

@ -1,12 +1,9 @@
import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { PROFILE_IDS } from "@transportationer/shared";
import { PROFILE_IDS, TRAVEL_MODES, VALID_THRESHOLDS } from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving", "transit", "fifteen"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ tile: string[] }> },
@ -30,7 +27,7 @@ export async function GET(
const profile = p.get("profile") ?? "universal";
if (!city) return new NextResponse("Missing city", { status: 400 });
if (!VALID_MODES.includes(mode)) return new NextResponse("Invalid mode", { status: 400 });
if (!(TRAVEL_MODES as readonly string[]).includes(mode)) return new NextResponse("Invalid mode", { status: 400 });
const validProfile = (PROFILE_IDS as readonly string[]).includes(profile) ? profile : "universal";
const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) =>

View file

@ -19,21 +19,13 @@ import {
type OverlayMode,
} from "@/components/location-score-panel";
import { MapLegend } from "@/components/map-legend";
import { isochroneContours } from "@/lib/isochrone";
const MapView = dynamic(
() => import("@/components/map-view").then((m) => m.MapView),
{ ssr: false, loading: () => <div className="flex-1 bg-gray-200 animate-pulse" /> },
);
/** Compute 3 evenly-spaced contour values up to the threshold (deduped, min 1). */
function isochroneContours(threshold: number): number[] {
const raw = [
Math.max(1, Math.round(threshold / 3)),
Math.max(2, Math.round((threshold * 2) / 3)),
threshold,
];
return [...new Set(raw)];
}
export default function HomePage() {
const [cities, setCities] = useState<City[]>([]);
@ -53,6 +45,11 @@ export default function HomePage() {
const [pinEstatePercentile, setPinEstatePercentile] = useState<number | null>(null);
const [pinEstateScorePercentile, setPinEstateScorePercentile] = useState<number | null>(null);
// Reachable POI pins
type ReachablePoi = { category: string; subcategory: string; name: string | null; lat: number; lng: number };
const [reachablePois, setReachablePois] = useState<ReachablePoi[]>([]);
const [showPois, setShowPois] = useState(true);
// Overlay mode: isochrone (new default) or relative heatmap
const [overlayMode, setOverlayMode] = useState<OverlayMode>("isochrone");
const [isochroneData, setIsochroneData] = useState<object | null>(null);
@ -181,6 +178,22 @@ export default function HomePage() {
};
}, [pinLocation, estateValueAvailable, selectedCity, mode, threshold, profile]);
// Fetch reachable POIs once isochroneData is set — guarantees the isochrone
// cache is warm so the GET route finds the entry with an exact contours match.
useEffect(() => {
if (!pinLocation || !selectedCity || !isochroneData) { setReachablePois([]); return; }
let cancelled = false;
const params = new URLSearchParams({
city: selectedCity, lat: String(pinLocation.lat), lng: String(pinLocation.lng),
mode, threshold: String(threshold),
});
fetch(`/api/reachable-pois?${params}`)
.then((r) => r.json())
.then((data) => { if (!cancelled) setReachablePois(data.pois ?? []); })
.catch(() => { if (!cancelled) setReachablePois([]); });
return () => { cancelled = true; };
}, [pinLocation, selectedCity, mode, threshold, isochroneData]);
// Pre-fetch the isochrone whenever a pin is placed (in accessibility mode).
// overlayMode is intentionally NOT a dep — fetching is decoupled from display.
// This means:
@ -204,9 +217,10 @@ export default function HomePage() {
body: JSON.stringify({
lng: pinLocation.lng,
lat: pinLocation.lat,
// "fifteen" and "transit" have no direct Valhalla isochrone costing —
// use walking as the representative display mode for both.
travelMode: (mode === "fifteen" || mode === "transit") ? "walking" : mode,
// "fifteen" uses cycling as the representative display mode (largest
// reliable non-time-dependent coverage). "transit" uses the real
// multimodal costing supported by fetchIsochrone.
travelMode: mode === "fifteen" ? "cycling" : mode,
contourMinutes: isochroneContours(threshold),
}),
})
@ -259,6 +273,7 @@ export default function HomePage() {
setPinEstatePercentile(null);
setPinEstateScorePercentile(null);
setIsochroneData(null);
setReachablePois([]);
}
@ -319,6 +334,7 @@ export default function HomePage() {
}
isochrones={overlayMode === "isochrone" ? isochroneData : null}
baseOverlay={baseOverlay}
reachablePois={showPois ? reachablePois : []}
onLocationClick={handleLocationClick}
/>
)}
@ -371,6 +387,9 @@ export default function HomePage() {
estateScorePercentile={pinEstateScorePercentile}
overlayMode={overlayMode}
isochroneLoading={isochroneLoading}
showPois={showPois}
onTogglePois={() => setShowPois((v) => !v)}
poiCount={reachablePois.length}
onOverlayModeChange={setOverlayMode}
onClose={handlePinClose}
/>

View file

@ -1,6 +1,6 @@
"use client";
import { CATEGORIES, PROFILES, PROFILE_IDS } from "@transportationer/shared";
import { CATEGORIES, PROFILES, PROFILE_IDS, VALID_THRESHOLDS } from "@transportationer/shared";
import type { CategoryId, TravelMode, ProfileId } from "@transportationer/shared";
const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> =
@ -12,7 +12,6 @@ const TRAVEL_MODES: Array<{ value: TravelMode; label: string; icon: string }> =
{ value: "driving", label: "Driving", icon: "🚗" },
];
const THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
@ -116,7 +115,7 @@ export function ControlPanel({
Target Threshold
</p>
<div className="grid grid-cols-4 gap-1">
{THRESHOLDS.map((t) => (
{VALID_THRESHOLDS.map((t) => (
<button
key={t}
onClick={() => onThresholdChange(t)}

View file

@ -117,6 +117,9 @@ export function LocationScorePanel({
estateScorePercentile,
overlayMode,
isochroneLoading,
showPois,
onTogglePois,
poiCount,
onOverlayModeChange,
onClose,
}: {
@ -130,6 +133,9 @@ export function LocationScorePanel({
estateScorePercentile?: number | null;
overlayMode: OverlayMode;
isochroneLoading: boolean;
showPois: boolean;
onTogglePois: () => void;
poiCount: number;
onOverlayModeChange: (mode: OverlayMode) => void;
onClose: () => void;
}) {
@ -205,6 +211,22 @@ export function LocationScorePanel({
</button>
</div>
{/* POI pin toggle */}
{poiCount > 0 && (
<div className="mb-3">
<button
onClick={onTogglePois}
className={`w-full py-1.5 rounded-lg text-xs font-medium transition-colors ${
showPois
? "bg-blue-100 text-blue-700 hover:bg-blue-200"
: "bg-gray-100 text-gray-500 hover:bg-gray-200"
}`}
>
{showPois ? `Hide ${poiCount} POIs` : `Show ${poiCount} POIs`}
</button>
</div>
)}
{/* Per-category score bars */}
<div className="space-y-2.5">
{CATEGORIES.map((cat) => {

View file

@ -10,6 +10,22 @@ type Weights = Record<CategoryId, number>;
export type BaseOverlay = "accessibility" | "estate-value" | "hidden-gem";
export type ReachablePoi = {
category: string;
subcategory: string;
name: string | null;
lat: number;
lng: number;
};
const CATEGORY_COLORS: Record<string, string> = {
service_trade: "#f59e0b",
transport: "#0ea5e9",
work_school: "#8b5cf6",
culture_community: "#ec4899",
recreation: "#10b981",
};
export interface MapViewProps {
citySlug: string;
cityBbox: [number, number, number, number];
@ -25,6 +41,8 @@ export interface MapViewProps {
isochrones?: object | null;
/** Which base overlay to show (accessibility grid, estate value, or hidden gem). */
baseOverlay?: BaseOverlay;
/** Reachable POI pins to show on the map. */
reachablePois?: ReachablePoi[] | null;
onLocationClick?: (lat: number, lng: number, estateValue: number | null) => void;
}
@ -98,6 +116,11 @@ function removeEstateValueLayers(map: maplibregl.Map) {
if (map.getSource("estate-value-zones")) map.removeSource("estate-value-zones");
}
function removePoiLayers(map: maplibregl.Map) {
if (map.getLayer("poi-circles")) map.removeLayer("poi-circles");
if (map.getSource("reachable-pois")) map.removeSource("reachable-pois");
}
function removeHiddenGemLayers(map: maplibregl.Map) {
if (map.getLayer("hidden-gems-fill")) map.removeLayer("hidden-gems-fill");
if (map.getSource("hidden-gems-tiles")) map.removeSource("hidden-gems-tiles");
@ -142,6 +165,7 @@ export function MapView({
pinCategoryScores,
isochrones,
baseOverlay = "accessibility",
reachablePois,
onLocationClick,
}: MapViewProps) {
const containerRef = useRef<HTMLDivElement>(null);
@ -159,6 +183,15 @@ export function MapView({
onLeave: () => void;
} | null>(null);
// Ref for POI layer event handlers
const poiHandlersRef = useRef<{
onEnter: () => void;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onMove: (e: any) => void;
onLeave: () => void;
} | null>(null);
const poiPopupRef = useRef<maplibregl.Popup | null>(null);
// Tracked as state so effects re-run once the map style finishes loading.
// Without this, effects that check isStyleLoaded() return early on first render
// and never reapply state after the async on("load") fires.
@ -264,7 +297,7 @@ export function MapView({
maxContour * 0.5, "#fee08b",
maxContour, "#d73027",
] as any,
"fill-opacity": 0.5,
"fill-opacity": 0.3,
"fill-outline-color": "rgba(0,0,0,0.1)",
},
});
@ -440,6 +473,108 @@ export function MapView({
};
}, [mapLoaded, showEstateValue, citySlug]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Reachable POI pins ────────────────────────────────────────────────────
useEffect(() => {
if (!mapLoaded) return;
const map = mapRef.current!;
// Clean up previous handlers
if (poiHandlersRef.current) {
const { onEnter, onMove, onLeave } = poiHandlersRef.current;
map.off("mouseenter", "poi-circles", onEnter);
map.off("mousemove", "poi-circles", onMove);
map.off("mouseleave", "poi-circles", onLeave);
poiHandlersRef.current = null;
}
poiPopupRef.current?.remove();
poiPopupRef.current = null;
if (!reachablePois || reachablePois.length === 0) {
removePoiLayers(map);
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const geojson: any = {
type: "FeatureCollection",
features: reachablePois.map((poi) => ({
type: "Feature",
geometry: { type: "Point", coordinates: [poi.lng, poi.lat] },
properties: { category: poi.category, subcategory: poi.subcategory, name: poi.name },
})),
};
const existingSource = map.getSource("reachable-pois") as maplibregl.GeoJSONSource | undefined;
if (existingSource) {
existingSource.setData(geojson);
} else {
map.addSource("reachable-pois", { type: "geojson", data: geojson });
// Build match expression for category colors
const colorMatch: unknown[] = ["match", ["get", "category"]];
for (const [cat, color] of Object.entries(CATEGORY_COLORS)) {
colorMatch.push(cat, color);
}
colorMatch.push("#888888"); // fallback
map.addLayer({
id: "poi-circles",
type: "circle",
source: "reachable-pois",
paint: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
"circle-color": colorMatch as any,
"circle-radius": 6,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
"circle-opacity": 0.85,
},
});
}
const popup = new maplibregl.Popup({ closeButton: false, closeOnClick: false, maxWidth: "200px" });
poiPopupRef.current = popup;
const onEnter = () => { map.getCanvas().style.cursor = "pointer"; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onMove = (e: any) => {
const feature = e.features?.[0];
if (!feature) return;
const name = feature.properties?.name as string | null;
const subcategory = feature.properties?.subcategory as string;
popup.setLngLat(e.lngLat).setHTML(
`<div style="font:13px/1.5 system-ui,sans-serif;min-width:100px">
<div style="font-weight:600;color:#111">${name ?? subcategory}</div>
${name ? `<div style="color:#777;font-size:11px">${subcategory}</div>` : ""}
</div>`
).addTo(map);
};
const onLeave = () => { map.getCanvas().style.cursor = "crosshair"; popup.remove(); };
map.on("mouseenter", "poi-circles", onEnter);
map.on("mousemove", "poi-circles", onMove);
map.on("mouseleave", "poi-circles", onLeave);
poiHandlersRef.current = { onEnter, onMove, onLeave };
return () => {
if (poiHandlersRef.current) {
const { onEnter: e, onMove: m, onLeave: l } = poiHandlersRef.current;
map.off("mouseenter", "poi-circles", e);
map.off("mousemove", "poi-circles", m);
map.off("mouseleave", "poi-circles", l);
poiHandlersRef.current = null;
}
try {
if (map.isStyleLoaded()) {
removePoiLayers(map);
map.getCanvas().style.cursor = "crosshair";
}
} catch { /* map removed */ }
poiPopupRef.current?.remove();
poiPopupRef.current = null;
};
}, [mapLoaded, reachablePois]);
// ── Initialize map (runs once on mount) ───────────────────────────────────
useEffect(() => {
if (mountedRef.current || !containerRef.current) return;

View file

@ -0,0 +1,9 @@
/** Compute up to 3 evenly-spaced contour values up to threshold (deduped, min 1). */
export function isochroneContours(threshold: number): number[] {
const raw = [
Math.max(1, Math.round(threshold / 3)),
Math.max(2, Math.round((threshold * 2) / 3)),
threshold,
];
return [...new Set(raw)];
}

View file

@ -17,14 +17,19 @@ export interface IsochroneOpts {
}
export async function fetchIsochrone(opts: IsochroneOpts): Promise<object> {
const costing = COSTING_MAP[opts.travelMode] ?? "pedestrian";
const body = {
const isTransit = opts.travelMode === "transit";
const costing = isTransit ? "multimodal" : (COSTING_MAP[opts.travelMode] ?? "pedestrian");
const body: Record<string, unknown> = {
locations: [{ lon: opts.lng, lat: opts.lat }],
costing,
contours: opts.contourMinutes.map((time) => ({ time })),
polygons: opts.polygons ?? true,
show_locations: false,
};
if (isTransit) {
body.costing_options = { transit: { use_bus: 1.0, use_rail: 1.0, use_transfers: 1.0 } };
body.date_time = { type: 0 }; // current time
}
const res = await fetch(`${VALHALLA_BASE}/isochrone`, {
method: "POST",

View file

@ -10,6 +10,9 @@ export type RoutingMode = "walking" | "cycling" | "driving" | "transit";
/** All display modes, including the synthetic "fifteen" (best-of walking+cycling+transit). */
export type TravelMode = RoutingMode | "fifteen";
export const TRAVEL_MODES = ["walking", "cycling", "driving", "transit", "fifteen"] as const satisfies TravelMode[];
export const VALID_THRESHOLDS = [5, 10, 15, 20, 30] as const;
export interface TagFilter {
key: string;
values: string[];

View file

@ -2,7 +2,7 @@ import type { Job } from "bullmq";
import { FlowProducer } from "bullmq";
import { createBullMQConnection } from "../redis.js";
import { getSql } from "../db.js";
import { JOB_OPTIONS } from "@transportationer/shared";
import { JOB_OPTIONS, VALID_THRESHOLDS } from "@transportationer/shared";
import type { JobProgress } from "@transportationer/shared";
export type RefreshCityData = {
@ -111,7 +111,7 @@ export async function handleRefreshCity(
type: "compute-scores" as const,
citySlug,
modes: ["walking", "cycling", "driving", "transit"] as const,
thresholds: [5, 10, 15, 20, 30],
thresholds: [...VALID_THRESHOLDS],
ingestBorisNi: niApplicable,
},
opts: JOB_OPTIONS["compute-scores"],