fifteen/apps/web/app/api/stats/route.ts
2026-03-01 21:59:44 +01:00

79 lines
2.7 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 WHERE city_slug = ${city}
`),
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
WHERE gp.city_slug = ${city}
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);
}