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

@@ -0,0 +1,21 @@
-- v1.25 — hydration logging.
--
-- Two additive changes, no backfill, no existing row touched:
--
-- 1. `WATER_INTAKE` — a new MeasurementType: a manual water-intake counter in
-- millilitres (ml). Cumulative within a day like ACTIVITY_STEPS — each
-- entry adds to the day's running total, summed for the goal ring.
-- DISTINCT from TOTAL_BODY_WATER (a scale body-composition reading in kg).
-- Forward-only: Postgres cannot remove an enum value; idempotent via
-- `IF NOT EXISTS`, so reruns are safe.
--
-- 2. `users.hydration_goal_ml` — a nullable per-user daily hydration goal in
-- millilitres. Null means the in-code default (2000 ml). Edited inline on
-- the hydration card.
--
-- The enum value is only added, never used in this same statement batch, so the
-- Postgres "unsafe use of new enum value" restriction does not apply.
ALTER TYPE "measurement_type" ADD VALUE IF NOT EXISTS 'WATER_INTAKE';
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "hydration_goal_ml" INTEGER;
+12
View File
@@ -233,6 +233,10 @@ model User {
// MDY pins month-day-year, YMD pins ISO yyyy-MM-dd. Display-time only —
// canonical storage stays UTC instants.
dateFormat DateFormatPreference @default(AUTO) @map("date_format")
// v1.25 — daily water-intake goal in millilitres for the hydration ring.
// Null = the in-code default (DEFAULT_HYDRATION_GOAL_ML, 2000 ml). Surfaced
// and edited inline on the hydration card; canonical storage stays ml.
hydrationGoalMl Int? @map("hydration_goal_ml")
// AI provider selection for insights. Null = OPENAI fallback.
aiProvider String? @map("ai_provider") // "OPENAI" | "ANTHROPIC" | "LOCAL" | "CHATGPT_OAUTH"
aiModel String? @map("ai_model")
@@ -1107,6 +1111,14 @@ enum MeasurementType {
// `src/lib/oura/client.ts` (RESILIENCE_LEVELS). Stored as `source = OURA`.
RESILIENCE // level (ordinal 1–5) — Oura `daily_resilience.level` (limited=1 … exceptional=5).
// ── v1.25 — hydration logging (additive) ──
// Manual water-intake counter in millilitres (ml). Cumulative within a day
// like ACTIVITY_STEPS / ACTIVE_ENERGY_BURNED — each entry adds to the day's
// running total; the daily total is summed for the goal ring. DISTINCT from
// TOTAL_BODY_WATER (a scale body-composition reading in kg). Manual entry
// only today (no device source maps to it), unit "ml", higher = more intake.
WATER_INTAKE // ml — manual hydration intake counter; daily total vs a user goal.
@@map("measurement_type")
}
+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 });
});
+288
View File
@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, GlassWater, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
queryKeys,
invalidateKeys,
measurementDependentKeys,
} from "@/lib/query-keys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SectionHeading } from "@/components/insights/section-heading";
import { HydrationRing } from "@/components/insights/hydration-ring";
import {
HYDRATION_QUICK_ADD_ML,
MAX_HYDRATION_ENTRY_ML,
MAX_HYDRATION_GOAL_ML,
MIN_HYDRATION_ENTRY_ML,
MIN_HYDRATION_GOAL_ML,
} from "@/lib/hydration/hydration";
/**
* v1.25 — hydration daily-goal card.
*
* A calm goal tracker for the Insights overview: a goal ring (today's summed
* WATER_INTAKE vs the user's goal), quick-add buttons (+250 / +500 ml and a
* custom amount), and an inline goal editor. Logging rides the existing
* measurements create path (POST /api/measurements, type WATER_INTAKE), so a
* new entry busts `measurementDependentKeys`, which carries the hydration key —
* the ring refreshes in lockstep. The goal write goes to PATCH /api/hydration.
*
* Always present (a goal ring at 0 / goal is still useful), unlike the
* insufficient-history read cards which un-mount.
*/
interface HydrationTodayResponse {
date: string;
totalMl: number;
goalMl: number;
percent: number;
rawPercent: number;
met: boolean;
remainingMl: number;
entries: { id: string; value: number; measuredAt: string }[];
}
interface HydrationCardProps {
enabled?: boolean;
className?: string;
}
export function HydrationCard({
enabled = true,
className,
}: HydrationCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [customMl, setCustomMl] = useState("");
const [editingGoal, setEditingGoal] = useState(false);
const [goalDraft, setGoalDraft] = useState("");
const gated = enabled && isAuthenticated;
const { data } = useQuery({
queryKey: queryKeys.hydrationToday(),
queryFn: () => apiGet<HydrationTodayResponse>("/api/hydration"),
enabled: gated,
staleTime: 60_000,
});
async function logAmount(ml: number) {
if (busy || ml <= 0) return;
setBusy(true);
try {
await apiPost("/api/measurements", {
type: "WATER_INTAKE",
value: ml,
measuredAt: new Date().toISOString(),
});
await invalidateKeys(queryClient, measurementDependentKeys);
toast.success(t("hydration.added", { amount: ml }));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
async function logCustom() {
const ml = Math.round(Number(customMl));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_ENTRY_ML ||
ml > MAX_HYDRATION_ENTRY_ML
) {
toast.error(t("hydration.invalidAmount"));
return;
}
await logAmount(ml);
setCustomMl("");
}
async function saveGoal() {
const ml = Math.round(Number(goalDraft));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_GOAL_ML ||
ml > MAX_HYDRATION_GOAL_ML
) {
toast.error(t("hydration.invalidGoal"));
return;
}
setBusy(true);
try {
await apiPatch("/api/hydration", { goalMl: ml });
await invalidateKeys(queryClient, [queryKeys.hydration()]);
setEditingGoal(false);
toast.success(t("hydration.goalSaved"));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
const totalMl = data?.totalMl ?? 0;
const goalMl = data?.goalMl ?? 0;
const percent = data?.percent ?? 0;
const met = data?.met ?? false;
const remainingMl = data?.remainingMl ?? 0;
return (
<section
data-slot="hydration-section"
aria-label={t("hydration.cardTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={GlassWater}
title={t("hydration.cardTitle")}
action={
editingGoal ? null : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
onClick={() => {
setGoalDraft(String(goalMl || ""));
setEditingGoal(true);
}}
data-slot="hydration-edit-goal"
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span className="text-xs">{t("hydration.editGoal")}</span>
</Button>
)
}
/>
<div
data-slot="hydration-card"
className="bg-card flex w-full min-w-0 flex-col items-center gap-4 rounded-xl border p-4 md:flex-row md:items-center md:gap-6 md:p-6"
>
<HydrationRing
percent={percent}
totalMl={totalMl}
goalMl={goalMl}
met={met}
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<p
className="text-muted-foreground text-sm"
data-slot="hydration-status"
role="status"
>
{met
? t("hydration.metMessage")
: t("hydration.remainingMessage", { amount: remainingMl })}
</p>
{editingGoal ? (
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_GOAL_ML}
max={MAX_HYDRATION_GOAL_ML}
value={goalDraft}
onChange={(e) => setGoalDraft(e.target.value)}
aria-label={t("hydration.goalLabel")}
className="h-9 w-28"
data-slot="hydration-goal-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9"
disabled={busy}
onClick={saveGoal}
>
{t("common.save")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9"
onClick={() => setEditingGoal(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<>
<div className="flex flex-wrap gap-2">
{HYDRATION_QUICK_ADD_ML.map((ml) => (
<Button
key={ml}
type="button"
variant="outline"
size="sm"
className="h-9 gap-1"
disabled={busy}
onClick={() => logAmount(ml)}
data-slot="hydration-quick-add"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{`${ml} ml`}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_ENTRY_ML}
max={MAX_HYDRATION_ENTRY_ML}
placeholder={t("hydration.customPlaceholder")}
value={customMl}
onChange={(e) => setCustomMl(e.target.value)}
aria-label={t("hydration.customLabel")}
className="h-9 w-28"
data-slot="hydration-custom-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9 gap-1"
disabled={busy || customMl.trim() === ""}
onClick={logCustom}
>
{met ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
)}
{t("hydration.add")}
</Button>
</div>
</>
)}
</div>
</div>
</section>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
/**
* v1.25 — hydration daily-goal ring.
*
* A hand-rolled SVG arc gauge in the same idiom as `score-ring.tsx` (fixed
* `0 0 100 100` viewBox, ~9% round-cap stroke, a faint same-hue track, a
* subtle first-paint sweep gated on `prefers-reduced-motion`). It is purpose-
* built for a goal counter rather than a 0–100 band score: the arc fills to the
* percent of goal (capped at 100), the centre shows the day's total in ml, and
* the hue is a single calm water tone — never the green/yellow/red band
* semantics a low score would otherwise paint, so "not much yet" never reads as
* an alarm. The `<linearGradient>` is real SVG JSX, not innerHTML.
*/
const VIEW = 100;
const STROKE = 9;
const R = (VIEW - STROKE) / 2;
const C = 2 * Math.PI * R;
const CX = VIEW / 2;
export interface HydrationRingProps {
/** Progress toward the goal, 0..100 (already capped by the caller). */
percent: number;
/** Today's total in ml — the centred number. */
totalMl: number;
/** The goal in ml — rendered under the total. */
goalMl: number;
/** Whether the goal has been met (adds a met affordance). */
met?: boolean;
/** Render size in px. */
size?: number;
className?: string;
}
export function HydrationRing({
percent,
totalMl,
goalMl,
met = false,
size = 168,
className,
}: HydrationRingProps) {
const gid = useId();
const clamped = Math.min(
100,
Math.max(0, Number.isFinite(percent) ? percent : 0),
);
const dash = (clamped / 100) * C;
const reduced = prefersReducedMotion();
const [armed, setArmed] = useState(false);
useEffect(() => {
if (reduced) return;
const raf = requestAnimationFrame(() => setArmed(true));
return () => cancelAnimationFrame(raf);
}, [reduced]);
const filled = reduced || armed;
return (
<div
data-slot="hydration-ring"
data-met={met ? "true" : undefined}
role="img"
aria-label={`${totalMl} of ${goalMl} ml (${Math.round(clamped)}%)`}
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
aria-hidden="true"
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--chart-2)" />
<stop offset="100%" stopColor="var(--chart-2)" />
</linearGradient>
</defs>
{/* Faint full-circle track. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.16}
strokeWidth={STROKE}
/>
{/* Progress arc — round cap, subtle first-paint sweep. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={{
strokeDashoffset: filled ? C - dash : C,
transition: reduced
? undefined
: "stroke-dashoffset 900ms var(--ring-ease, ease-out)",
}}
/>
</svg>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-foreground text-3xl font-semibold tracking-tight tabular-nums">
{totalMl}
</span>
<span className="text-muted-foreground text-[11px]">{`/ ${goalMl} ml`}</span>
</div>
</div>
);
}
@@ -35,6 +35,7 @@ import {
Sun,
PersonStanding,
Waves,
GlassWater,
type LucideIcon,
} from "lucide-react";
@@ -54,6 +55,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
WATER_INTAKE: "measurements.typeWaterIntake",
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
@@ -140,6 +142,7 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
HEART_RATE_VARIABILITY: HeartPulse,
RESTING_HEART_RATE: Heart,
ACTIVE_ENERGY_BURNED: Flame,
WATER_INTAKE: GlassWater,
FLIGHTS_CLIMBED: TrendingUp,
WALKING_RUNNING_DISTANCE: Footprints,
VO2_MAX: Gauge,
@@ -263,6 +266,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
WATER_INTAKE: "bg-chart-2/20 text-chart-2",
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",
@@ -98,10 +98,12 @@ const EXPECTED_TYPES = [
"BODY_TEMPERATURE_DEVIATION",
// ── v1.19.0 — Oura resilience (ordinal-encoded level) ──
"RESILIENCE",
// ── v1.25 — in-app water-intake logging (hydration goal ring) ──
"WATER_INTAKE",
] as const;
describe("measurementTypeEnum coverage", () => {
it("exposes the 66 canonical measurement types", () => {
it("exposes the 67 canonical measurement types", () => {
expect([...measurementTypeEnum.options].sort()).toEqual(
[...EXPECTED_TYPES].sort(),
);
@@ -244,6 +246,11 @@ describe("measurementTypeEnum coverage", () => {
// Insights / measurement-list surfaces and never belongs in the clinical
// vitals PDF. See doctor-report-pdf-core.ts for the matching rationale.
"RESILIENCE",
// v1.25 — water intake is an in-app lifestyle log (the hydration goal
// ring), not a measured clinical vital; it surfaces on the Insights
// overview and never belongs in the clinical vitals PDF. See
// doctor-report-pdf-core.ts for the matching exclusion rationale.
"WATER_INTAKE",
]);
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
@@ -0,0 +1,72 @@
import { describe, it, expect } from "vitest";
import {
DEFAULT_HYDRATION_GOAL_ML,
MAX_HYDRATION_GOAL_ML,
MIN_HYDRATION_GOAL_ML,
resolveHydrationGoal,
summariseHydration,
} from "@/lib/hydration/hydration";
describe("resolveHydrationGoal", () => {
it("falls back to the default when unset", () => {
expect(resolveHydrationGoal(null)).toBe(DEFAULT_HYDRATION_GOAL_ML);
expect(resolveHydrationGoal(undefined)).toBe(DEFAULT_HYDRATION_GOAL_ML);
expect(resolveHydrationGoal(Number.NaN)).toBe(DEFAULT_HYDRATION_GOAL_ML);
});
it("clamps below the minimum", () => {
expect(resolveHydrationGoal(10)).toBe(MIN_HYDRATION_GOAL_ML);
});
it("clamps above the maximum", () => {
expect(resolveHydrationGoal(99999)).toBe(MAX_HYDRATION_GOAL_ML);
});
it("keeps an in-band value (rounded)", () => {
expect(resolveHydrationGoal(2500.4)).toBe(2500);
});
});
describe("summariseHydration", () => {
it("empty day", () => {
const s = summariseHydration(0, 2000);
expect(s).toMatchObject({
totalMl: 0,
goalMl: 2000,
percent: 0,
rawPercent: 0,
met: false,
remainingMl: 2000,
});
});
it("partial day", () => {
const s = summariseHydration(1000, 2000);
expect(s.percent).toBe(50);
expect(s.rawPercent).toBe(50);
expect(s.met).toBe(false);
expect(s.remainingMl).toBe(1000);
});
it("goal met exactly", () => {
const s = summariseHydration(2000, 2000);
expect(s.percent).toBe(100);
expect(s.met).toBe(true);
expect(s.remainingMl).toBe(0);
});
it("exceeded — percent caps at 100, rawPercent does not", () => {
const s = summariseHydration(2500, 2000);
expect(s.percent).toBe(100);
expect(s.rawPercent).toBe(125);
expect(s.met).toBe(true);
expect(s.remainingMl).toBe(0);
});
it("floors a negative total at zero", () => {
const s = summariseHydration(-500, 2000);
expect(s.totalMl).toBe(0);
expect(s.met).toBe(false);
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* Hydration logging — pure helpers for the daily water-intake goal ring.
*
* Water intake is stored as `WATER_INTAKE` Measurement rows in millilitres
* (ml), one row per quick-add entry. The day's running total is the SUM of
* those rows (see `CUMULATIVE_HK_TYPES`); the goal ring compares that total
* against a per-user goal (`User.hydrationGoalMl`, null = the default).
*
* The math lives here, free of Prisma + React, so the ring states (empty /
* partial / met / exceeded) are unit-testable in isolation.
*/
/** Default daily hydration goal when the user has not set their own (ml). */
export const DEFAULT_HYDRATION_GOAL_ML = 2000;
/** Bounds for a user-set goal — a plausible adult range, not a clinical limit. */
export const MIN_HYDRATION_GOAL_ML = 250;
export const MAX_HYDRATION_GOAL_ML = 8000;
/** The fixed quick-add amounts the card offers alongside a custom entry (ml). */
export const HYDRATION_QUICK_ADD_ML = [250, 500] as const;
/** Bounds for a single logged entry (ml) — mirrors the measurement clamp. */
export const MIN_HYDRATION_ENTRY_ML = 1;
export const MAX_HYDRATION_ENTRY_ML = 5000;
/**
* Resolve the effective goal from the (nullable) stored value: fall back to
* the default when unset, and clamp a stored value into the supported band so
* a stale out-of-range row never produces a broken ring.
*/
export function resolveHydrationGoal(
stored: number | null | undefined,
): number {
if (stored == null || !Number.isFinite(stored)) {
return DEFAULT_HYDRATION_GOAL_ML;
}
return Math.min(
MAX_HYDRATION_GOAL_ML,
Math.max(MIN_HYDRATION_GOAL_ML, Math.round(stored)),
);
}
export interface HydrationSummary {
/** Today's summed intake in ml. */
totalMl: number;
/** The effective daily goal in ml. */
goalMl: number;
/** Progress toward the goal, capped at 100 for the ring fill. */
percent: number;
/** Uncapped progress (can exceed 100 once the goal is met). */
rawPercent: number;
/** Whether the day's total has reached the goal. */
met: boolean;
/** Remaining ml to the goal (0 once met). */
remainingMl: number;
}
/**
* Summarise a day's intake against a goal. `totalMl` is the already-summed
* day total; `goalMl` is the effective goal (run it through
* `resolveHydrationGoal` first). Negative totals are floored at 0.
*/
export function summariseHydration(
totalMl: number,
goalMl: number,
): HydrationSummary {
const total = Number.isFinite(totalMl) ? Math.max(0, Math.round(totalMl)) : 0;
const goal = resolveHydrationGoal(goalMl);
const rawPercent = goal > 0 ? Math.round((total / goal) * 100) : 0;
const percent = Math.min(100, rawPercent);
return {
totalMl: total,
goalMl: goal,
percent,
rawPercent,
met: total >= goal,
remainingMl: Math.max(0, goal - total),
};
}
+3
View File
@@ -38,6 +38,9 @@ export const ALLOWED_CHART_TOKENS = [
// components ship in v1.5 alongside the iOS app surface.
"metric:HEART_RATE_VARIABILITY",
"metric:RESTING_HEART_RATE",
// v1.25 — daily water intake (cumulative ml), charted alongside the
// hydration goal ring on the overview.
"metric:WATER_INTAKE",
"metric:ACTIVE_ENERGY_BURNED",
"metric:FLIGHTS_CLIMBED",
"metric:WALKING_RUNNING_DISTANCE",
@@ -77,6 +77,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// the Oura API (source = OURA), never from HealthKit: Apple ships no
// resilience metric. No HK mapping by design.
"RESILIENCE",
// v1.25 — water intake is logged in-app (the hydration goal ring writes
// WATER_INTAKE rows directly via POST /api/measurements). Apple's
// `HKQuantityTypeIdentifierDietaryWater` stays on the nutrition deferred
// hold (HK_QUANTITY_TYPE_DEFERRED), so the type carries no HK import mapping
// by design.
"WATER_INTAKE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
@@ -706,6 +706,11 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> =
// daily total is the meaningful reduction (SUM), matching the other
// cumulative HK counts.
"FALL_COUNT",
// v1.25 — manual hydration intake. Not a HealthKit type, but its daily
// reduction is a SUM (running total of the day's entries), so it rides the
// same cumulative daily-sum path as the counts above rather than averaging
// individual sips.
"WATER_INTAKE",
]);
/**
+2
View File
@@ -89,6 +89,8 @@ export const MEASUREMENT_CATEGORIES: ReadonlyMap<
// ── Activity (cumulative + fitness + mobility) ──
["ACTIVITY_STEPS", "activity"],
["ACTIVE_ENERGY_BURNED", "activity"],
// v1.25 — hydration intake; cumulative daily counter, groups with activity.
["WATER_INTAKE", "activity"],
["FLIGHTS_CLIMBED", "activity"],
["WALKING_RUNNING_DISTANCE", "activity"],
["VO2_MAX", "activity"],
@@ -105,6 +105,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
// grain for these day-cumulative counts.
"ACTIVE_ENERGY_BURNED",
"TIME_IN_DAYLIGHT",
// ml of water — whole millilitres are the meaningful display grain.
"WATER_INTAKE",
]);
/**
+1
View File
@@ -77,6 +77,7 @@ const openApiBase: Pick<
{ name: "Illness" },
{ name: "Admin" },
{ name: "Meta" },
{ name: "Hydration" },
],
};
+102
View File
@@ -0,0 +1,102 @@
/**
* OpenAPI route module — hydration daily-goal surface (v1.25).
*
* `GET /api/hydration` reads today's summed water intake vs the user's goal;
* `PATCH /api/hydration` sets the per-user daily goal. Logging rides the
* measurements create path (POST /api/measurements, type WATER_INTAKE), so it
* is documented there, not here.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, stdResponses } from "./shared";
const hydrationEntry = z.object({
id: z.string(),
value: z.number().describe("Logged amount in millilitres."),
measuredAt: z.string().describe("ISO-8601 instant the entry was logged."),
});
const hydrationTodayResponse = z
.object({
date: z.string().describe("ISO-8601 start of the user's local day."),
totalMl: z.number().describe("Today's summed intake in ml."),
goalMl: z.number().describe("Effective daily goal in ml."),
percent: z.number().describe("Progress toward the goal, capped at 100."),
rawPercent: z.number().describe("Uncapped progress; can exceed 100."),
met: z.boolean().describe("Whether the day's total reached the goal."),
remainingMl: z.number().describe("Remaining ml to the goal (0 once met)."),
entries: z.array(hydrationEntry),
})
.meta({
id: "HydrationToday",
description: "Today's water-intake total vs the user's daily goal.",
});
const hydrationGoalRequest = z
.object({
goalMl: z
.number()
.int()
.min(250)
.max(8000)
.describe("Daily hydration goal in millilitres."),
})
.meta({
id: "HydrationGoalRequest",
description: "Set the per-user daily hydration goal (ml).",
});
const hydrationGoalResponse = z
.object({ goalMl: z.number() })
.meta({ id: "HydrationGoalResponse" });
export const hydrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/hydration": {
get: {
tags: ["Hydration"],
summary: "Today's hydration total vs goal",
description:
"Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the per-user goal (default 2000 ml).",
responses: {
"200": {
description: "Today's hydration summary.",
content: {
"application/json": {
schema: dataEnvelope(
hydrationTodayResponse,
"HydrationTodayEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Hydration"],
summary: "Set the daily hydration goal",
description: "Updates the per-user daily hydration goal in millilitres.",
requestBody: {
required: true,
content: { "application/json": { schema: hydrationGoalRequest } },
},
responses: {
"200": {
description: "Goal updated.",
content: {
"application/json": {
schema: dataEnvelope(
hydrationGoalResponse,
"HydrationGoalEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
+5
View File
@@ -194,6 +194,11 @@ export function getPRDirection(
// inputs — a derived composite with no clean goal axis, so it stays null
// like the other device-derived scores.
case "RESILIENCE":
// v1.25 — water intake is a cumulative daily counter surfaced as a
// goal ring + logging streak, not a single-reading achievement. A
// "biggest sip" record would be meaningless and the daily-total record
// is better served by the streak rail, so it stays null.
case "WATER_INTAKE":
return null;
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Query keys — hydration tree: today's water-intake total vs the daily goal
* (`/api/hydration`). Part of the centralized factory; aggregated in
* `./index.ts`. A `WATER_INTAKE` measurement write busts this key through the
* `measurementDependentKeys` bundle so the goal ring refreshes in lockstep.
*/
export const hydrationKeys = {
hydration: () => ["hydration"] as const,
hydrationToday: () => ["hydration", "today"] as const,
};
+4
View File
@@ -19,6 +19,7 @@ import { authKeys } from "./auth";
import { coachKeys } from "./coach";
import { cycleKeys } from "./cycle";
import { dashboardKeys } from "./dashboard";
import { hydrationKeys } from "./hydration";
import { illnessKeys } from "./illness";
import { insightsKeys } from "./insights";
import { integrationKeys } from "./integrations";
@@ -46,6 +47,7 @@ export const queryKeys = {
...measurementReminderKeys,
...labKeys,
...illnessKeys,
...hydrationKeys,
};
/**
@@ -86,6 +88,8 @@ export const measurementDependentKeys = [
["sleep-night"] as const,
// v1.17.0 — refresh the sleep-debt + chronotype read when sleep rows change.
["sleep-rhythm"] as const,
// v1.25 — refresh the hydration goal ring when a WATER_INTAKE row is added.
queryKeys.hydration(),
];
/**
+8
View File
@@ -122,6 +122,10 @@ export const measurementTypeEnum = z.enum([
// into the numeric value (limited=1 … exceptional=5), unit `level`. Ingests
// server-side as `source = OURA`. See `RESILIENCE_LEVELS` in `src/lib/oura/client`.
"RESILIENCE",
// ── v1.25 — hydration logging (additive) ──
// Manual water-intake counter in millilitres. Cumulative within a day like
// ACTIVITY_STEPS; distinct from TOTAL_BODY_WATER (a scale reading in kg).
"WATER_INTAKE",
]);
/**
@@ -239,6 +243,7 @@ const unitMap: Record<string, string> = {
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
WATER_INTAKE: "ml",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
@@ -394,6 +399,9 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Active energy kcal/sample — a daily-rollup sample tops out around
// 6–8000 kcal even for ultra-endurance days.
ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 },
// Daily cumulative intake in ml; a single quick-add entry is small but the
// running daily total can reach several litres on an active day.
WATER_INTAKE: { min: 0, max: 20000 },
// Flights — vertical-feet/3 ≈ flights; cap generous.
FLIGHTS_CLIMBED: { min: 0, max: 1000 },
// Walking/running distance per sample (metres). 200 km covers an