feat: hydration logging and daily goal ring

Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.

Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.

Document GET/PATCH /api/hydration in the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:20:28 +02:00
1 parent bc617adc2c
commit 31c35baec2
20 files changed
+865 -1

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
/**
* v1.25 — hydration daily-goal surface.
*
* GET /api/hydration → today's summed water intake vs the user's goal.
* PATCH /api/hydration → set the per-user daily goal (ml).
*
* Logging itself rides the existing measurements create path (POST
* /api/measurements with `type: "WATER_INTAKE"`, unit "ml") — this route only
* reads the day's total and owns the goal. `userId` is always narrowed from
* `requireAuth()`; the goal is built field-by-field, never mass-assigned.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { getUserTodayBounds } from "@/lib/tz/local-day";
import {
MAX_HYDRATION_GOAL_ML,
MIN_HYDRATION_GOAL_ML,
resolveHydrationGoal,
summariseHydration,
} from "@/lib/hydration/hydration";
export const dynamic = "force-dynamic";
const goalPatchSchema = z.object({
goalMl: z
.number()
.int()
.min(MIN_HYDRATION_GOAL_ML)
.max(MAX_HYDRATION_GOAL_ML),
});
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const rl = await checkAnalyticsReadRateLimit(user.id);
if (!rl.allowed) {
return apiError("Too many requests. Please retry later.", 429);
}
const now = new Date();
const { start, end } = getUserTodayBounds(now, user.timezone);
const rows = await prisma.measurement.findMany({
where: {
userId: user.id,
type: "WATER_INTAKE",
measuredAt: { gte: start, lte: end },
},
select: { id: true, value: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
});
const totalMl = rows.reduce((sum, r) => sum + r.value, 0);
const summary = summariseHydration(
totalMl,
resolveHydrationGoal(user.hydrationGoalMl),
);
annotate({
action: { name: "hydration.today.read" },
meta: { entries: rows.length, met: summary.met },
});
return apiSuccess({
date: start.toISOString(),
...summary,
entries: rows.map((r) => ({
id: r.id,
value: r.value,
measuredAt: r.measuredAt.toISOString(),
})),
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = goalPatchSchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "hydration.goal.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
const goalMl = parsed.data.goalMl;
await prisma.user.update({
where: { id: user.id },
data: { hydrationGoalMl: goalMl },
});
annotate({ action: { name: "hydration.goal.update" }, meta: { goalMl } });
return apiSuccess({ goalMl });
});