Files
HealthLog/src/app/api/analytics/range/route.ts
T
Marc-André Bombeck 03925ddece feat(insights): time-range pills with period-over-period deltas on metric pages
Add a 7d / 30d / 90d / 1y segmented control to every HealthKit metric page,
between the stat strip and the chart, with a sentiment-coloured "vs prior
period" delta caption beside it.

New route GET /api/analytics/range?type=<MeasurementType>&range=<7d|30d|90d|1y>
returns the current-window aggregate, the previous comparable window, and the
composed delta. Single metric type by construction, so the read is one
rollup-tier call covering the trailing 2N days sliced into the two halves —
no per-type fan-out, no Prisma-pool burst. Reuses the WMY readers
(readBestGranularityRollups + aggregateWmyBuckets) and the two-window pattern
the year-ago baseline already uses; count/min/max/mean/sum compose across
buckets, SD/slope/r2 stay out (not composable). The cached summaries slice is
untouched so the hottest dashboard cache key never fragments.

The delta caption reuses the dashboard's metric-aware up-good/up-bad colour
logic — extracted from trend-card into a shared trend-sentiment module — with
the metric's good direction sourced from the metric-status registry
(target-band metrics render neutral, matching deviation framing). When the
prior window has no data the caption shows "no prior-period data" rather than
a misleading 0%. The range choice persists across metrics via a localStorage
seam and defaults to 30d so nothing regresses. Cache keyed per (type, range)
through the query-key factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit eac87a9f8b144f4d8e7a8b950528a19d5dcf8726)
2026-06-02 16:45:14 +02:00

73 lines
2.4 KiB
TypeScript

/**
* v1.9.0 — single-metric period-over-period range read.
*
* `GET /api/analytics/range?type=<MeasurementType>&range=7d|30d|90d|1y`
* returns the current-window aggregate, the previous comparable window, and
* the composed delta. Single metric type by construction — the Insights
* metric page is single-metric — so the read is one
* `readBestGranularityRollups` covering the trailing `2N` days, sliced into
* the two halves. No per-type fan-out, no Prisma-pool burst (the opposite of
* the deleted 15-way live walk).
*
* The cached `/api/analytics?slice=summaries` slice is deliberately left
* untouched: adding a range dimension to its single shared cache key would
* fragment the hottest cache on the dashboard. This dedicated route carries
* its own per-(type, range) cache slot on the client (`queryKeys.analyticsRange`).
*
* iOS-safe: additive new route, no change to the `/api/analytics` envelope.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import {
ANALYTICS_RANGES,
computeRangeDelta,
type AnalyticsRange,
} from "@/lib/analytics/range-delta";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
const rangeQuerySchema = z.object({
type: measurementTypeEnum,
range: z.enum(ANALYTICS_RANGES),
});
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = rangeQuerySchema.safeParse({
type: request.nextUrl.searchParams.get("type"),
range: request.nextUrl.searchParams.get("range"),
});
if (!parsed.success) {
annotate({
action: { name: "analytics.range.invalid-params" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const type = parsed.data.type as MeasurementType;
const range = parsed.data.range as AnalyticsRange;
const result = await computeRangeDelta(user.id, type, range);
annotate({
action: { name: "analytics.range.read" },
meta: {
type,
range,
granularity: result.granularity,
current_count: result.current.count,
previous_count: result.previous.count,
},
});
return apiSuccess(result);
});