85 lines
3 KiB
TypeScript
85 lines
3 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { sql } from "@/lib/db";
|
|
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
|
|
import type { CityStats } from "@transportationer/shared";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const city = req.nextUrl.searchParams.get("city") ?? "berlin";
|
|
const mode = req.nextUrl.searchParams.get("mode") ?? "walking";
|
|
const threshold = parseInt(
|
|
req.nextUrl.searchParams.get("threshold") ?? "15",
|
|
10,
|
|
);
|
|
|
|
const profile = req.nextUrl.searchParams.get("profile") ?? "universal";
|
|
|
|
const cacheKey = `api:stats:${hashParams({ city, mode, threshold, profile })}`;
|
|
const cached = await cacheGet<CityStats>(cacheKey);
|
|
if (cached) {
|
|
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
|
|
}
|
|
|
|
const [poiCounts, gridCount, percentiles] = await Promise.all([
|
|
Promise.resolve(sql<{ category: string; count: number }[]>`
|
|
SELECT category, COUNT(*)::int AS count
|
|
FROM raw_pois
|
|
WHERE city_slug = ${city}
|
|
GROUP BY category
|
|
`),
|
|
Promise.resolve(sql<{ count: number }[]>`
|
|
SELECT COUNT(*)::int AS count
|
|
FROM grid_points gp
|
|
JOIN cities c ON c.slug = ${city}
|
|
WHERE gp.city_slug = ${city}
|
|
AND (c.boundary IS NULL OR ST_Within(gp.geom, c.boundary))
|
|
`),
|
|
Promise.resolve(sql<{
|
|
category: string;
|
|
p10: number; p25: number; p50: number; p75: number; p90: number;
|
|
coverage_pct: number;
|
|
}[]>`
|
|
SELECT
|
|
gs.category,
|
|
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY gs.score)::float AS p10,
|
|
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY gs.score)::float AS p25,
|
|
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY gs.score)::float AS p50,
|
|
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY gs.score)::float AS p75,
|
|
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY gs.score)::float AS p90,
|
|
(COUNT(*) FILTER (WHERE gs.score >= 0.5) * 100.0 / COUNT(*))::float AS coverage_pct
|
|
FROM grid_scores gs
|
|
JOIN grid_points gp ON gp.id = gs.grid_point_id
|
|
JOIN cities c ON c.slug = ${city}
|
|
WHERE gp.city_slug = ${city}
|
|
AND (c.boundary IS NULL OR ST_Within(gp.geom, c.boundary))
|
|
AND gs.travel_mode = ${mode}
|
|
AND gs.threshold_min = ${threshold}
|
|
AND gs.profile = ${profile}
|
|
GROUP BY gs.category
|
|
`),
|
|
]);
|
|
|
|
const poisByCategory = Object.fromEntries(
|
|
poiCounts.map((r) => [r.category, r.count]),
|
|
);
|
|
|
|
const stats: CityStats = {
|
|
citySlug: city,
|
|
totalPois: Object.values(poisByCategory).reduce((a, b) => a + b, 0),
|
|
gridPointCount: gridCount[0]?.count ?? 0,
|
|
categoryStats: percentiles.map((r) => ({
|
|
category: r.category as any,
|
|
poiCount: poisByCategory[r.category] ?? 0,
|
|
p10: r.p10,
|
|
p25: r.p25,
|
|
p50: r.p50,
|
|
p75: r.p75,
|
|
p90: r.p90,
|
|
coveragePct: r.coverage_pct,
|
|
})),
|
|
};
|
|
|
|
await cacheSet(cacheKey, stats, "API_STATS");
|
|
return NextResponse.json(stats);
|
|
}
|