mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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)
73 lines
2.4 KiB
TypeScript
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);
|
|
});
|