From 31c35baec2b6bfe17dbb57984ce692c93dc23a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:20:28 +0200 Subject: [PATCH] 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. --- .../0209_v125_water_intake/migration.sql | 21 ++ prisma/schema.prisma | 12 + src/app/api/hydration/route.ts | 111 +++++++ src/components/insights/hydration-card.tsx | 288 ++++++++++++++++++ src/components/insights/hydration-ring.tsx | 121 ++++++++ .../measurements/measurement-list-meta.ts | 4 + .../measurement-type-enum-coverage.test.ts | 9 +- src/lib/hydration/__tests__/hydration.test.ts | 72 +++++ src/lib/hydration/hydration.ts | 80 +++++ src/lib/insights/chart-tokens.ts | 3 + .../__tests__/apple-health-mapping.test.ts | 6 + src/lib/measurements/apple-health-mapping.ts | 5 + src/lib/measurements/categories.ts | 2 + src/lib/measurements/display-transform.ts | 2 + src/lib/openapi/registry.ts | 1 + src/lib/openapi/routes/hydration.ts | 102 +++++++ src/lib/personal-records/pr-direction.ts | 5 + src/lib/query-keys/hydration.ts | 10 + src/lib/query-keys/index.ts | 4 + src/lib/validations/measurement.ts | 8 + 20 files changed, 865 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/0209_v125_water_intake/migration.sql create mode 100644 src/app/api/hydration/route.ts create mode 100644 src/components/insights/hydration-card.tsx create mode 100644 src/components/insights/hydration-ring.tsx create mode 100644 src/lib/hydration/__tests__/hydration.test.ts create mode 100644 src/lib/hydration/hydration.ts create mode 100644 src/lib/openapi/routes/hydration.ts create mode 100644 src/lib/query-keys/hydration.ts diff --git a/prisma/migrations/0209_v125_water_intake/migration.sql b/prisma/migrations/0209_v125_water_intake/migration.sql new file mode 100644 index 000000000..a3212ada1 --- /dev/null +++ b/prisma/migrations/0209_v125_water_intake/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5bbd4cb75..85f64b7ff 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app/api/hydration/route.ts b/src/app/api/hydration/route.ts new file mode 100644 index 000000000..0b056c59d --- /dev/null +++ b/src/app/api/hydration/route.ts @@ -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 }); +}); diff --git a/src/components/insights/hydration-card.tsx b/src/components/insights/hydration-card.tsx new file mode 100644 index 000000000..45e5c3919 --- /dev/null +++ b/src/components/insights/hydration-card.tsx @@ -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("/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 ( +
+ { + setGoalDraft(String(goalMl || "")); + setEditingGoal(true); + }} + data-slot="hydration-edit-goal" + > +
+ ); +} diff --git a/src/components/insights/hydration-ring.tsx b/src/components/insights/hydration-ring.tsx new file mode 100644 index 000000000..6c73c7c24 --- /dev/null +++ b/src/components/insights/hydration-ring.tsx @@ -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 `` 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 ( +
+ +
+ + {totalMl} + + {`/ ${goalMl} ml`} +
+
+ ); +} diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index f27dd5ef2..56255d6b0 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -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 = { 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 = { 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 = { 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", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 17b9a1fdf..ceaf09f2c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/hydration/__tests__/hydration.test.ts b/src/lib/hydration/__tests__/hydration.test.ts new file mode 100644 index 000000000..8939919be --- /dev/null +++ b/src/lib/hydration/__tests__/hydration.test.ts @@ -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); + }); +}); diff --git a/src/lib/hydration/hydration.ts b/src/lib/hydration/hydration.ts new file mode 100644 index 000000000..a16bce868 --- /dev/null +++ b/src/lib/hydration/hydration.ts @@ -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), + }; +} diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index fca9edb1f..06c669fb0 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -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", diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 0c0a7ef6b..ab5dbb6fd 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -77,6 +77,12 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set([ // 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", () => { diff --git a/src/lib/measurements/apple-health-mapping.ts b/src/lib/measurements/apple-health-mapping.ts index d6be7265a..12a6d34aa 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -706,6 +706,11 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet = // 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", ]); /** diff --git a/src/lib/measurements/categories.ts b/src/lib/measurements/categories.ts index f9457adf2..aaf1038f6 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -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"], diff --git a/src/lib/measurements/display-transform.ts b/src/lib/measurements/display-transform.ts index 1bc059952..39b058505 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -105,6 +105,8 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet = 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", ]); /** diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts index 147d50b24..afe62485e 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -77,6 +77,7 @@ const openApiBase: Pick< { name: "Illness" }, { name: "Admin" }, { name: "Meta" }, + { name: "Hydration" }, ], }; diff --git a/src/lib/openapi/routes/hydration.ts b/src/lib/openapi/routes/hydration.ts new file mode 100644 index 000000000..58dccb2f2 --- /dev/null +++ b/src/lib/openapi/routes/hydration.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 0058a5131..9671186b8 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -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; } } diff --git a/src/lib/query-keys/hydration.ts b/src/lib/query-keys/hydration.ts new file mode 100644 index 000000000..867dce37a --- /dev/null +++ b/src/lib/query-keys/hydration.ts @@ -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, +}; diff --git a/src/lib/query-keys/index.ts b/src/lib/query-keys/index.ts index d84854923..762f42536 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -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(), ]; /** diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 2c11ba6f0..97a4876e0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -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 = { 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 = { // 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