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 1/3] 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 From ec3fcea84fc2d696d93fe31540206ec80f03a7f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 03:20:49 +0200 Subject: [PATCH 2/3] feat: baseline-drift, breathing-screening and lab-change insight cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three read-only awareness cards to the Insights overview, each drawn off an existing engine and each honest about empty state — the section un-mounts rather than surfacing a husk. - Health status: surfaces what is drifting from the user's own normal by folding the personal-band deviations (coincident-deviation engine) with the dated, sustained level shifts (changepoint detector) at GET /api/insights/health-status. Awareness only, never a diagnosis. - Breathing screening: summarises the last 30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events at GET /api/insights/breathing-screening. Mirrors the device's own classification and states plainly that it is a screening signal, not a diagnosis — discuss with a clinician. - Lab changes: pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta and reference-band standing at GET /api/insights/labs-changes. Neutral framing only. Each reshaping helper is pure and unit-tested for its present/absent states. Register the three sections in the insights-layout universe so they merge in as default-visible, wire them into the overview and the edit-mode title map, add the query-factory keys, document the routes in the OpenAPI contract, and translate every new string across all six locales. --- docs/api/openapi.yaml | 453 ++++++++++++++++++ messages/de.json | 51 +- messages/en.json | 51 +- messages/es.json | 51 +- messages/fr.json | 51 +- messages/it.json | 51 +- messages/pl.json | 51 +- .../coach-route-gate-inventory.test.ts | 8 + .../api/insights/breathing-screening/route.ts | 73 +++ src/app/api/insights/health-status/route.ts | 67 +++ src/app/api/insights/labs-changes/route.ts | 67 +++ src/app/insights/page.tsx | 40 ++ .../insights/breathing-screening-card.tsx | 137 ++++++ .../insights/health-status-card.tsx | 152 ++++++ .../insights/insights-edit-mode.tsx | 4 + src/components/insights/labs-changes-card.tsx | 114 +++++ src/lib/__tests__/insights-layout.test.ts | 6 +- src/lib/insights-layout.ts | 4 + .../__tests__/breathing-screening.test.ts | 58 +++ .../insights/__tests__/health-status.test.ts | 62 +++ .../insights/__tests__/labs-changes.test.ts | 67 +++ src/lib/insights/breathing-screening.ts | 122 +++++ src/lib/insights/health-status.ts | 84 ++++ src/lib/insights/labs-changes.ts | 131 +++++ src/lib/openapi/routes/index.ts | 4 + src/lib/openapi/routes/insights-signals.ts | 177 +++++++ src/lib/query-keys/insights.ts | 25 + 27 files changed, 2148 insertions(+), 13 deletions(-) create mode 100644 src/app/api/insights/breathing-screening/route.ts create mode 100644 src/app/api/insights/health-status/route.ts create mode 100644 src/app/api/insights/labs-changes/route.ts create mode 100644 src/components/insights/breathing-screening-card.tsx create mode 100644 src/components/insights/health-status-card.tsx create mode 100644 src/components/insights/labs-changes-card.tsx create mode 100644 src/lib/insights/__tests__/breathing-screening.test.ts create mode 100644 src/lib/insights/__tests__/health-status.test.ts create mode 100644 src/lib/insights/__tests__/labs-changes.test.ts create mode 100644 src/lib/insights/breathing-screening.ts create mode 100644 src/lib/insights/health-status.ts create mode 100644 src/lib/insights/labs-changes.ts create mode 100644 src/lib/openapi/routes/insights-signals.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index afefa5a85..f7be19a22 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44,6 +44,7 @@ tags: - name: Illness - name: Admin - name: Meta + - name: Hydration paths: /api/cycle/day-logs: get: @@ -2087,6 +2088,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE - in: query name: from schema: @@ -2276,6 +2278,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number measuredAt: @@ -5770,6 +5773,7 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -6178,6 +6182,60 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /api/insights/health-status: + get: + tags: + - Insights + summary: Baseline-drift health status + description: Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the + personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the + rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth. + responses: + "200": + description: The drift summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsHealthStatusEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/breathing-screening: + get: + tags: + - Insights + summary: Sleep-breathing screening signal + description: Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged + events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights + module. Cookie or Bearer auth. + responses: + "200": + description: The breathing-screening summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + /api/insights/labs-changes: + get: + tags: + - Insights + summary: What changed since your last lab panel + description: Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its + reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, + never a diagnosis. Cookie or Bearer auth. + responses: + "200": + description: The lab-change summary. + content: + application/json: + schema: + $ref: "#/components/schemas/InsightsLabsChangesEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 /api/mood/tags: get: tags: @@ -7945,6 +8003,44 @@ paths: "401": *a1 "422": *a3 "429": *a4 + /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: + $ref: "#/components/schemas/HydrationTodayEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 + 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: + $ref: "#/components/schemas/HydrationGoalRequest" + responses: + "200": + description: Goal updated. + content: + application/json: + schema: + $ref: "#/components/schemas/HydrationGoalEnvelope" + "401": *a1 + "422": *a3 + "429": *a4 components: schemas: EncryptedExportRequest: @@ -9245,6 +9341,10 @@ components: - cycle-summary - signals - rhythm-events + - hydration + - health-status + - breathing + - labs-changes visible: type: boolean order: @@ -9654,6 +9754,17 @@ components: - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. + HydrationGoalRequest: + type: object + properties: + goalMl: + type: integer + minimum: 250 + maximum: 8000 + description: Daily hydration goal in millilitres. + required: + - goalMl + description: Set the per-user daily hydration goal (ml). MedicationTreatmentClass: type: string enum: @@ -12255,6 +12366,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number unit: @@ -12571,6 +12683,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE additionalProperties: type: array items: @@ -12648,6 +12761,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -12746,6 +12860,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -12979,6 +13094,7 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE + - WATER_INTAKE value: type: number unit: @@ -20663,6 +20779,244 @@ components: - windowDays - interpretation additionalProperties: false + InsightsHealthStatusEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsHealthStatus" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsHealthStatus: + type: object + properties: + present: + type: boolean + description: True when at least one deviation or shift is surfaced. + deviations: + type: array + items: + type: object + properties: + type: + type: string + description: MeasurementType token. + value: + type: number + description: Today's value. + center: + type: number + description: Personal band center (median). + low: + type: number + high: + type: number + direction: + type: string + enum: + - above + - below + required: + - type + - value + - center + - low + - high + - direction + additionalProperties: false + shifts: + type: array + items: + type: object + properties: + metric: + type: string + description: MeasurementType token. + breakDate: + type: string + description: YYYY-MM-DD of the new level's first day. + beforeMean: + type: number + afterMean: + type: number + direction: + type: string + enum: + - up + - down + required: + - metric + - breakDate + - beforeMean + - afterMean + - direction + additionalProperties: false + generatedAt: + type: string + description: ISO-8601 instant the read was computed. + required: + - present + - deviations + - shifts + - generatedAt + additionalProperties: false + description: Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. + Awareness only, never a diagnosis. + InsightsBreathingScreeningEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsBreathingScreening" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsBreathingScreening: + type: object + properties: + present: + type: boolean + nights: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Nights with a per-night index reading. + recentMeanIndex: + anyOf: + - type: number + - type: "null" + description: Mean of the index readings (lower-better); null when none. + trend: + anyOf: + - type: string + enum: + - up + - down + - stable + - type: "null" + description: Recent index vs the prior window; null when too few nights. + eventCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Device-flagged breathing-disturbance / apnea events. + classification: + anyOf: + - type: string + enum: + - not-elevated + - elevated + - type: "null" + description: The device's own classification; null when no data. + generatedAt: + type: string + required: + - present + - nights + - recentMeanIndex + - trend + - eventCount + - classification + - generatedAt + additionalProperties: false + description: Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis. + InsightsLabsChangesEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/InsightsLabsChanges" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + InsightsLabsChanges: + type: object + properties: + present: + type: boolean + latestDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the most-recent panel. + previousDate: + anyOf: + - type: string + - type: "null" + description: YYYY-MM-DD of the prior panel. + changes: + type: array + items: + type: object + properties: + analyte: + type: string + unit: + type: string + latest: + type: number + previous: + type: number + delta: + type: number + description: Signed latest − previous. + direction: + type: string + enum: + - up + - down + - flat + status: + type: string + enum: + - in-range + - below + - above + - unknown + required: + - analyte + - unit + - latest + - previous + - delta + - direction + - status + additionalProperties: false + generatedAt: + type: string + required: + - present + - latestDate + - previousDate + - changes + - generatedAt + additionalProperties: false + description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis. MoodTagTreeEnvelope: type: object properties: @@ -23024,6 +23378,101 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. + HydrationTodayEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/HydrationToday" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HydrationToday: + type: object + properties: + date: + type: string + description: ISO-8601 start of the user's local day. + totalMl: + type: number + description: Today's summed intake in ml. + goalMl: + type: number + description: Effective daily goal in ml. + percent: + type: number + description: Progress toward the goal, capped at 100. + rawPercent: + type: number + description: Uncapped progress; can exceed 100. + met: + type: boolean + description: Whether the day's total reached the goal. + remainingMl: + type: number + description: Remaining ml to the goal (0 once met). + entries: + type: array + items: + type: object + properties: + id: + type: string + value: + type: number + description: Logged amount in millilitres. + measuredAt: + type: string + description: ISO-8601 instant the entry was logged. + required: + - id + - value + - measuredAt + additionalProperties: false + required: + - date + - totalMl + - goalMl + - percent + - rawPercent + - met + - remainingMl + - entries + additionalProperties: false + description: Today's water-intake total vs the user's daily goal. + HydrationGoalEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/HydrationGoalResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + HydrationGoalResponse: + type: object + properties: + goalMl: + type: number + required: + - goalMl + additionalProperties: false MedicationScheduleOutput: type: object properties: @@ -23201,6 +23650,10 @@ components: - cycle-summary - signals - rhythm-events + - hydration + - health-status + - breathing + - labs-changes visible: type: boolean order: diff --git a/messages/de.json b/messages/de.json index ce28bb09c..52333ac05 100644 --- a/messages/de.json +++ b/messages/de.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} Messungen gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messungen löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.", + "typeWaterIntake": "Wasseraufnahme" }, "mood": { "title": "Stimmung", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Heute aktualisiert, {time}", "updatedYesterday": "Gestern aktualisiert", - "updatedOn": "Aktualisiert am {date}" + "updatedOn": "Aktualisiert am {date}", + "healthStatus": { + "sectionTitle": "Was sich verändert hat", + "subtitle": "Werte, die von deinem üblichen Bereich abweichen.", + "deviationAbove": "{vital} liegt über deinem üblichen Bereich.", + "deviationBelow": "{vital} liegt unter deinem üblichen Bereich.", + "shiftUp": "{vital} hat sich gegenüber deiner jüngsten Norm nach oben verschoben.", + "shiftDown": "{vital} hat sich gegenüber deiner jüngsten Norm nach unten verschoben.", + "rangeContext": "Aktuell {value}, sonst {low}–{high}.", + "shiftContext": "Etwa {before} davor, {after} seitdem." + }, + "breathingScreening": { + "sectionTitle": "Atmung im Schlaf", + "subtitle": "Ein Screening-Signal aus deinen Schlafdaten.", + "classificationElevated": "Dein Gerät hat erhöhte Atemstörungen gemeldet.", + "classificationNotElevated": "Die Einstufung deines Geräts ist nicht erhöht.", + "trendUp": "Höher als an deinen früheren Nächten.", + "trendDown": "Niedriger als an deinen früheren Nächten.", + "trendStable": "Stabil gegenüber deinen früheren Nächten.", + "nights": "{count} Nächte erfasst", + "indexLine": "jüngster Durchschnittsindex {value}", + "eventsFlagged": "{count} vom Gerät gemeldete Ereignisse", + "disclaimer": "Dies ist ein Screening-Signal, keine Diagnose — bitte mit einer Ärztin oder einem Arzt besprechen." + }, + "labsChanges": { + "sectionTitle": "Seit deinem letzten Laborbefund", + "subtitle": "Wie deine neuesten Werte im Vergleich zum vorherigen Befund stehen.", + "statusInRange": "im Normbereich", + "statusBelow": "unter dem Bereich", + "statusAbove": "über dem Bereich", + "directionFlat": "keine Änderung" + } }, "thresholds": { "defaultLabel": "Default", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } + }, + "hydration": { + "cardTitle": "Flüssigkeitszufuhr", + "editGoal": "Ziel bearbeiten", + "goalLabel": "Tagesziel (ml)", + "metMessage": "Du hast dein heutiges Ziel erreicht.", + "remainingMessage": "Noch {amount} ml heute.", + "add": "Hinzufügen", + "added": "{amount} ml hinzugefügt.", + "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "invalidAmount": "Gib eine Menge zwischen 1 und 5000 ml ein.", + "invalidGoal": "Gib ein Ziel zwischen 250 und 8000 ml ein.", + "goalSaved": "Ziel aktualisiert.", + "customPlaceholder": "Menge", + "customLabel": "Eigene Menge (ml)" } } diff --git a/messages/en.json b/messages/en.json index 14fa8b085..c47b3d0b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", - "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.", + "typeWaterIntake": "Water intake" }, "mood": { "title": "Mood", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Updated today, {time}", "updatedYesterday": "Updated yesterday", - "updatedOn": "Updated on {date}" + "updatedOn": "Updated on {date}", + "healthStatus": { + "sectionTitle": "What's changed", + "subtitle": "Signals drifting from your usual range.", + "deviationAbove": "{vital} is above your typical range.", + "deviationBelow": "{vital} is below your typical range.", + "shiftUp": "{vital} has shifted up from your recent normal.", + "shiftDown": "{vital} has shifted down from your recent normal.", + "rangeContext": "Now {value}, usually {low}–{high}.", + "shiftContext": "Around {before} before, {after} since." + }, + "breathingScreening": { + "sectionTitle": "Breathing during sleep", + "subtitle": "A screening signal from your sleep data.", + "classificationElevated": "Your device flagged elevated breathing disturbances.", + "classificationNotElevated": "Your device's classification is not elevated.", + "trendUp": "Trending higher than your earlier nights.", + "trendDown": "Trending lower than your earlier nights.", + "trendStable": "Holding steady against your earlier nights.", + "nights": "{count} nights tracked", + "indexLine": "recent average index {value}", + "eventsFlagged": "{count} events flagged by your device", + "disclaimer": "This is a screening signal, not a diagnosis — discuss with a clinician." + }, + "labsChanges": { + "sectionTitle": "Since your last panel", + "subtitle": "How your latest results compare with the panel before.", + "statusInRange": "in range", + "statusBelow": "below range", + "statusAbove": "above range", + "directionFlat": "no change" + } }, "thresholds": { "defaultLabel": "Default", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } + }, + "hydration": { + "cardTitle": "Hydration", + "editGoal": "Edit goal", + "goalLabel": "Daily goal (ml)", + "metMessage": "You've reached today's goal.", + "remainingMessage": "{amount} ml to go today.", + "add": "Add", + "added": "Added {amount} ml.", + "saveError": "Could not save. Please try again.", + "invalidAmount": "Enter an amount between 1 and 5000 ml.", + "invalidGoal": "Enter a goal between 250 and 8000 ml.", + "goalSaved": "Goal updated.", + "customPlaceholder": "Amount", + "customLabel": "Custom amount (ml)" } } diff --git a/messages/es.json b/messages/es.json index ce2ee6de9..6e03917c8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", - "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.", + "typeWaterIntake": "Ingesta de agua" }, "mood": { "title": "Estado de ánimo", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Actualizado hoy, {time}", "updatedYesterday": "Actualizado ayer", - "updatedOn": "Actualizado el {date}" + "updatedOn": "Actualizado el {date}", + "healthStatus": { + "sectionTitle": "Qué ha cambiado", + "subtitle": "Señales que se desvían de tu rango habitual.", + "deviationAbove": "{vital} está por encima de tu rango habitual.", + "deviationBelow": "{vital} está por debajo de tu rango habitual.", + "shiftUp": "{vital} ha subido respecto a tu normal reciente.", + "shiftDown": "{vital} ha bajado respecto a tu normal reciente.", + "rangeContext": "Ahora {value}, normalmente {low}–{high}.", + "shiftContext": "Cerca de {before} antes, {after} desde entonces." + }, + "breathingScreening": { + "sectionTitle": "Respiración durante el sueño", + "subtitle": "Una señal de cribado de tus datos de sueño.", + "classificationElevated": "Tu dispositivo detectó alteraciones respiratorias elevadas.", + "classificationNotElevated": "La clasificación de tu dispositivo no es elevada.", + "trendUp": "Con tendencia al alza respecto a noches anteriores.", + "trendDown": "Con tendencia a la baja respecto a noches anteriores.", + "trendStable": "Estable respecto a noches anteriores.", + "nights": "{count} noches registradas", + "indexLine": "índice medio reciente {value}", + "eventsFlagged": "{count} eventos detectados por tu dispositivo", + "disclaimer": "Es una señal de cribado, no un diagnóstico: consultalo con un profesional médico." + }, + "labsChanges": { + "sectionTitle": "Desde tu último análisis", + "subtitle": "Cómo se comparan tus últimos resultados con el análisis anterior.", + "statusInRange": "dentro del rango", + "statusBelow": "por debajo del rango", + "statusAbove": "por encima del rango", + "directionFlat": "sin cambios" + } }, "thresholds": { "defaultLabel": "Por defecto", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } + }, + "hydration": { + "cardTitle": "Hidratación", + "editGoal": "Editar objetivo", + "goalLabel": "Objetivo diario (ml)", + "metMessage": "Has alcanzado el objetivo de hoy.", + "remainingMessage": "Faltan {amount} ml hoy.", + "add": "Añadir", + "added": "Añadidos {amount} ml.", + "saveError": "No se pudo guardar. Inténtalo de nuevo.", + "invalidAmount": "Introduce una cantidad entre 1 y 5000 ml.", + "invalidGoal": "Introduce un objetivo entre 250 y 8000 ml.", + "goalSaved": "Objetivo actualizado.", + "customPlaceholder": "Cantidad", + "customLabel": "Cantidad personalizada (ml)" } } diff --git a/messages/fr.json b/messages/fr.json index 69f085b74..1e40e6df6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", - "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.", + "typeWaterIntake": "Apport en eau" }, "mood": { "title": "Humeur", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Mis à jour aujourd'hui, {time}", "updatedYesterday": "Mis à jour hier", - "updatedOn": "Mis à jour le {date}" + "updatedOn": "Mis à jour le {date}", + "healthStatus": { + "sectionTitle": "Ce qui a changé", + "subtitle": "Signaux s'écartant de votre plage habituelle.", + "deviationAbove": "{vital} est au-dessus de votre plage habituelle.", + "deviationBelow": "{vital} est en dessous de votre plage habituelle.", + "shiftUp": "{vital} a augmenté par rapport à votre normale récente.", + "shiftDown": "{vital} a diminué par rapport à votre normale récente.", + "rangeContext": "Actuellement {value}, habituellement {low}–{high}.", + "shiftContext": "Environ {before} avant, {after} depuis." + }, + "breathingScreening": { + "sectionTitle": "Respiration pendant le sommeil", + "subtitle": "Un signal de dépistage issu de vos données de sommeil.", + "classificationElevated": "Votre appareil a signalé des perturbations respiratoires élevées.", + "classificationNotElevated": "La classification de votre appareil n'est pas élevée.", + "trendUp": "En hausse par rapport à vos nuits précédentes.", + "trendDown": "En baisse par rapport à vos nuits précédentes.", + "trendStable": "Stable par rapport à vos nuits précédentes.", + "nights": "{count} nuits suivies", + "indexLine": "indice moyen récent {value}", + "eventsFlagged": "{count} événements signalés par votre appareil", + "disclaimer": "Il s'agit d'un signal de dépistage, pas d'un diagnostic — parlez-en à un professionnel de santé." + }, + "labsChanges": { + "sectionTitle": "Depuis votre dernier bilan", + "subtitle": "Comparaison de vos derniers résultats avec le bilan précédent.", + "statusInRange": "dans la plage", + "statusBelow": "sous la plage", + "statusAbove": "au-dessus de la plage", + "directionFlat": "aucun changement" + } }, "thresholds": { "defaultLabel": "Par défaut", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } + }, + "hydration": { + "cardTitle": "Hydratation", + "editGoal": "Modifier l'objectif", + "goalLabel": "Objectif quotidien (ml)", + "metMessage": "Vous avez atteint l'objectif du jour.", + "remainingMessage": "Encore {amount} ml aujourd'hui.", + "add": "Ajouter", + "added": "{amount} ml ajoutés.", + "saveError": "Échec de l'enregistrement. Réessayez.", + "invalidAmount": "Saisissez une quantité entre 1 et 5000 ml.", + "invalidGoal": "Saisissez un objectif entre 250 et 8000 ml.", + "goalSaved": "Objectif mis à jour.", + "customPlaceholder": "Quantité", + "customLabel": "Quantité personnalisée (ml)" } } diff --git a/messages/it.json b/messages/it.json index 60e5288d4..c8b3d58a2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", - "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.", + "typeWaterIntake": "Assunzione di acqua" }, "mood": { "title": "Umore", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Aggiornato oggi, {time}", "updatedYesterday": "Aggiornato ieri", - "updatedOn": "Aggiornato il {date}" + "updatedOn": "Aggiornato il {date}", + "healthStatus": { + "sectionTitle": "Cosa è cambiato", + "subtitle": "Segnali che si discostano dal tuo intervallo abituale.", + "deviationAbove": "{vital} è sopra il tuo intervallo abituale.", + "deviationBelow": "{vital} è sotto il tuo intervallo abituale.", + "shiftUp": "{vital} si è spostato verso l'alto rispetto alla tua norma recente.", + "shiftDown": "{vital} si è spostato verso il basso rispetto alla tua norma recente.", + "rangeContext": "Ora {value}, di solito {low}–{high}.", + "shiftContext": "Circa {before} prima, {after} da allora." + }, + "breathingScreening": { + "sectionTitle": "Respirazione durante il sonno", + "subtitle": "Un segnale di screening dai tuoi dati del sonno.", + "classificationElevated": "Il tuo dispositivo ha segnalato disturbi respiratori elevati.", + "classificationNotElevated": "La classificazione del tuo dispositivo non è elevata.", + "trendUp": "In aumento rispetto alle notti precedenti.", + "trendDown": "In calo rispetto alle notti precedenti.", + "trendStable": "Stabile rispetto alle notti precedenti.", + "nights": "{count} notti registrate", + "indexLine": "indice medio recente {value}", + "eventsFlagged": "{count} eventi segnalati dal tuo dispositivo", + "disclaimer": "È un segnale di screening, non una diagnosi: parlane con un medico." + }, + "labsChanges": { + "sectionTitle": "Dal tuo ultimo pannello", + "subtitle": "Come i tuoi ultimi risultati si confrontano con il pannello precedente.", + "statusInRange": "nell'intervallo", + "statusBelow": "sotto l'intervallo", + "statusAbove": "sopra l'intervallo", + "directionFlat": "nessuna variazione" + } }, "thresholds": { "defaultLabel": "Predefinito", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } + }, + "hydration": { + "cardTitle": "Idratazione", + "editGoal": "Modifica obiettivo", + "goalLabel": "Obiettivo giornaliero (ml)", + "metMessage": "Hai raggiunto l'obiettivo di oggi.", + "remainingMessage": "Ancora {amount} ml oggi.", + "add": "Aggiungi", + "added": "Aggiunti {amount} ml.", + "saveError": "Salvataggio non riuscito. Riprova.", + "invalidAmount": "Inserisci una quantità tra 1 e 5000 ml.", + "invalidGoal": "Inserisci un obiettivo tra 250 e 8000 ml.", + "goalSaved": "Obiettivo aggiornato.", + "customPlaceholder": "Quantità", + "customLabel": "Quantità personalizzata (ml)" } } diff --git a/messages/pl.json b/messages/pl.json index 5631b6f90..603de4300 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,7 +574,8 @@ "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", - "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.", + "typeWaterIntake": "Spożycie wody" }, "mood": { "title": "Nastrój", @@ -3666,7 +3667,38 @@ }, "updatedTodayAt": "Zaktualizowano dzisiaj, {time}", "updatedYesterday": "Zaktualizowano wczoraj", - "updatedOn": "Zaktualizowano {date}" + "updatedOn": "Zaktualizowano {date}", + "healthStatus": { + "sectionTitle": "Co się zmieniło", + "subtitle": "Sygnały odbiegające od Twojego zwykłego zakresu.", + "deviationAbove": "{vital} jest powyżej Twojego zwykłego zakresu.", + "deviationBelow": "{vital} jest poniżej Twojego zwykłego zakresu.", + "shiftUp": "{vital} przesunął się w górę względem Twojej ostatniej normy.", + "shiftDown": "{vital} przesunął się w dół względem Twojej ostatniej normy.", + "rangeContext": "Teraz {value}, zwykle {low}–{high}.", + "shiftContext": "Około {before} wcześniej, {after} od tego czasu." + }, + "breathingScreening": { + "sectionTitle": "Oddychanie podczas snu", + "subtitle": "Sygnał przesiewowy z Twoich danych o śnie.", + "classificationElevated": "Twoje urządzenie wykryło podwyższone zaburzenia oddychania.", + "classificationNotElevated": "Klasyfikacja Twojego urządzenia nie jest podwyższona.", + "trendUp": "Wzrost względem wcześniejszych nocy.", + "trendDown": "Spadek względem wcześniejszych nocy.", + "trendStable": "Stabilnie względem wcześniejszych nocy.", + "nights": "Śledzono {count} nocy", + "indexLine": "ostatni średni indeks {value}", + "eventsFlagged": "{count} zdarzeń oznaczonych przez urządzenie", + "disclaimer": "To sygnał przesiewowy, a nie diagnoza — skonsultuj się z lekarzem." + }, + "labsChanges": { + "sectionTitle": "Od ostatnich badań", + "subtitle": "Jak najnowsze wyniki wypadają na tle poprzednich badań.", + "statusInRange": "w normie", + "statusBelow": "poniżej zakresu", + "statusAbove": "powyżej zakresu", + "directionFlat": "bez zmian" + } }, "thresholds": { "defaultLabel": "Domyślne", @@ -7372,5 +7404,20 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } + }, + "hydration": { + "cardTitle": "Nawodnienie", + "editGoal": "Edytuj cel", + "goalLabel": "Cel dzienny (ml)", + "metMessage": "Osiągnięto dzisiejszy cel.", + "remainingMessage": "Jeszcze {amount} ml dzisiaj.", + "add": "Dodaj", + "added": "Dodano {amount} ml.", + "saveError": "Nie udało się zapisać. Spróbuj ponownie.", + "invalidAmount": "Podaj ilość od 1 do 5000 ml.", + "invalidGoal": "Podaj cel od 250 do 8000 ml.", + "goalSaved": "Cel zaktualizowany.", + "customPlaceholder": "Ilość", + "customLabel": "Własna ilość (ml)" } } diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index 939ff22f8..5e94255c4 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -92,6 +92,14 @@ const NOT_COACH_OWNED_ROUTES: ReadonlyArray = [ // wedge the user's ability to reorder the insights tile strip. "src/app/api/insights/layout/route.ts", "src/app/api/insights/targets/route.ts", + // v1.25 — read-only awareness cards for the overview. Each is pure compute + // over the rollup / lab tier (baseline-drift, sleep-breathing screening, + // last-panel lab deltas) and carries no assistant prose; they gate on the + // `insights` module, not on the Coach surface. Disabling Coach must not + // wedge these factual reads. + "src/app/api/insights/health-status/route.ts", + "src/app/api/insights/breathing-screening/route.ts", + "src/app/api/insights/labs-changes/route.ts", ]; const COACH_GATE_NEEDLE = 'requireAssistantSurface("coach")'; diff --git a/src/app/api/insights/breathing-screening/route.ts b/src/app/api/insights/breathing-screening/route.ts new file mode 100644 index 000000000..ee8cdf25b --- /dev/null +++ b/src/app/api/insights/breathing-screening/route.ts @@ -0,0 +1,73 @@ +/** + * v1.25 — breathing-disturbance screening read. + * + * GET /api/insights/breathing-screening + * + * Reads the last ~30 days of per-night sleep-breathing-disturbance index + * (`BREATHING_DISTURBANCES`) and device-flagged breathing-disturbance / apnea + * events (`BREATHING_DISTURBANCE_EVENT`) and summarises them into a calm + * awareness read (nights, recent mean, trend, event count, the device's own + * classification). SCREENING SIGNAL ONLY — never a HealthLog diagnosis; the + * card states this explicitly. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseBreathing } from "@/lib/insights/breathing-screening"; + +export const dynamic = "force-dynamic"; + +const WINDOW_DAYS = 30; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const since = new Date(now.getTime() - WINDOW_DAYS * 24 * 60 * 60 * 1000); + + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["BREATHING_DISTURBANCES", "BREATHING_DISTURBANCE_EVENT"] }, + measuredAt: { gte: since }, + }, + select: { type: true, value: true, measuredAt: true }, + orderBy: { measuredAt: "asc" }, + }); + + const indexRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCES") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + const eventRows = rows + .filter((r) => r.type === "BREATHING_DISTURBANCE_EVENT") + .map((r) => ({ value: r.value, measuredAt: r.measuredAt })); + + const summary = summariseBreathing(indexRows, eventRows); + + annotate({ + action: { name: "insights.breathing-screening.read" }, + meta: { + present: summary.present, + nights: summary.nights, + events: summary.eventCount, + classification: summary.classification ?? "none", + }, + }); + + return apiSuccess({ ...summary, generatedAt: now.toISOString() }); +}); diff --git a/src/app/api/insights/health-status/route.ts b/src/app/api/insights/health-status/route.ts new file mode 100644 index 000000000..0b179058a --- /dev/null +++ b/src/app/api/insights/health-status/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — health-status / baseline-drift read. + * + * GET /api/insights/health-status + * + * Surfaces what is drifting from the user's own normal, drawn off two existing + * read-only engines — the personal-band deviations from the coincident- + * deviation flag (`coincident-deviation.ts`) and the dated, sustained level + * shifts from the changepoint detector (`changepoint.ts`). Pure compute over + * the rollup tier: no provider call, no fabricated value, never a diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler` wrapper, `requireAuth`, the + * `insights` module gate, the shared analytics-read budget. `userId` is always + * narrowed from the session. The labels are mapped client-side from the + * MeasurementType, so the wire carries the type tokens only. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { loadBaselineProfile } from "@/lib/insights/derived"; +import { computeCoincidentDeviation } from "@/lib/insights/derived/coincident-deviation"; +import { buildChangepointSignals } from "@/lib/insights/derived/changepoint"; +import { summariseHealthStatus } from "@/lib/insights/health-status"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const now = new Date(); + const profile = await loadBaselineProfile(prisma, user.id); + + const coincident = await computeCoincidentDeviation(user.id, profile, { + tz: user.timezone, + now, + }); + const shifts = await buildChangepointSignals(user.id, now); + + const vitals = coincident.status === "ok" ? coincident.value.vitals : []; + const summary = summariseHealthStatus(vitals, shifts); + + annotate({ + action: { name: "insights.health-status.read" }, + meta: { + deviations: summary.deviations.length, + shifts: summary.shifts.length, + }, + }); + + return apiSuccess({ + present: summary.present, + deviations: summary.deviations, + shifts: summary.shifts, + generatedAt: now.toISOString(), + }); +}); diff --git a/src/app/api/insights/labs-changes/route.ts b/src/app/api/insights/labs-changes/route.ts new file mode 100644 index 000000000..518fc417a --- /dev/null +++ b/src/app/api/insights/labs-changes/route.ts @@ -0,0 +1,67 @@ +/** + * v1.25 — "what changed since your last panel" read. + * + * GET /api/insights/labs-changes + * + * Groups the user's live numeric lab results by panel date, pairs the two + * most-recent panels, and reports the per-analyte delta + reference-band + * standing (`summariseLabChanges`). Absent when there are fewer than two + * panels or no analyte is shared. Neutral framing only — a delta is not a + * diagnosis. + * + * Mirrors `/api/insights/derived`: `apiHandler`, `requireAuth`, the `insights` + * module gate, the shared analytics-read budget. `userId` is narrowed from the + * session; soft-deleted rows (`deletedAt`) are filtered out. + */ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit"; +import { requireModuleEnabled } from "@/lib/modules/gate"; +import { prisma } from "@/lib/db"; +import { summariseLabChanges } from "@/lib/insights/labs-changes"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + + const m = await requireModuleEnabled(user.id, "insights"); + if (!m.enabled) return m.response; + + const rl = await checkAnalyticsReadRateLimit(user.id); + if (!rl.allowed) { + return apiError("Too many analytics requests. Please retry later.", 429); + } + + const rows = await prisma.labResult.findMany({ + where: { userId: user.id, deletedAt: null, value: { not: null } }, + select: { + analyte: true, + unit: true, + value: true, + referenceLow: true, + referenceHigh: true, + takenAt: true, + }, + orderBy: { takenAt: "desc" }, + }); + + const summary = summariseLabChanges( + rows.map((r) => ({ + analyte: r.analyte, + unit: r.unit, + value: r.value as number, + referenceLow: r.referenceLow, + referenceHigh: r.referenceHigh, + takenAt: r.takenAt, + })), + ); + + annotate({ + action: { name: "insights.labs-changes.read" }, + meta: { present: summary.present, changes: summary.changes.length }, + }); + + return apiSuccess({ ...summary, generatedAt: new Date().toISOString() }); +}); diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 000d31c73..846068a61 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -127,6 +127,42 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); +// v1.25 — hydration daily-goal ring. Always present (a goal ring at 0 / goal is +// still useful); it owns its own today-total query and the goal editor. +const HydrationCard = dynamic( + () => + import("@/components/insights/hydration-card").then((mod) => ({ + default: mod.HydrationCard, + })), + { ssr: false }, +); +// v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own +// read-only `/api/insights/health-status` query. +const HealthStatusCard = dynamic( + () => + import("@/components/insights/health-status-card").then((mod) => ({ + default: mod.HealthStatusCard, + })), + { ssr: false }, +); +// v1.25 — sleep-breathing screening card. Un-mounts on no data; screening +// signal only, never a diagnosis. +const BreathingScreeningCard = dynamic( + () => + import("@/components/insights/breathing-screening-card").then((mod) => ({ + default: mod.BreathingScreeningCard, + })), + { ssr: false }, +); +// v1.25 — "what changed since your last lab panel" card. Un-mounts with fewer +// than two panels or no shared analyte. +const LabsChangesCard = dynamic( + () => + import("@/components/insights/labs-changes-card").then((mod) => ({ + default: mod.LabsChangesCard, + })), + { ssr: false }, +); // v1.10.3 — "Today's signal" headline card. Promotes COINCIDENT_DEVIATION from // a buried below-the-fold tile to the top-of-overview daily read (the // always-present Apple/WHOOP/Oura pattern). Deferred behind `next/dynamic`; it @@ -472,6 +508,10 @@ export default function InsightsPage() { ) : null, signals: , "rhythm-events": , + hydration: , + "health-status": , + breathing: , + "labs-changes": , }; const orderedSectionIds = orderedVisibleSectionIds(layout); diff --git a/src/components/insights/breathing-screening-card.tsx b/src/components/insights/breathing-screening-card.tsx new file mode 100644 index 000000000..0da2d22fc --- /dev/null +++ b/src/components/insights/breathing-screening-card.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Wind } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import type { + BreathingClassification, + BreathingTrend, +} from "@/lib/insights/breathing-screening"; + +/** + * v1.25 — sleep-breathing screening card. + * + * A calm read off the read-only `/api/insights/breathing-screening` route: the + * device's own classification, the recent index trend, and the device-flagged + * event count. It un-mounts when there is no data (`present === false`). Every + * render carries the mandatory screening-only disclaimer — this is a SCREENING + * SIGNAL, not a diagnosis. Neutral tone, no chart, no red. + */ + +interface BreathingScreeningResponse { + present: boolean; + nights: number; + recentMeanIndex: number | null; + trend: BreathingTrend; + eventCount: number; + classification: BreathingClassification; + generatedAt: string; +} + +interface BreathingScreeningCardProps { + enabled?: boolean; + className?: string; +} + +export function BreathingScreeningCard({ + enabled = true, + className, +}: BreathingScreeningCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsBreathingScreening(), + queryFn: () => + apiGet("/api/insights/breathing-screening"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + const classificationLabel = + data.classification === "elevated" + ? t("insights.breathingScreening.classificationElevated") + : data.classification === "not-elevated" + ? t("insights.breathingScreening.classificationNotElevated") + : null; + + const trendLabel = + data.trend === "up" + ? t("insights.breathingScreening.trendUp") + : data.trend === "down" + ? t("insights.breathingScreening.trendDown") + : data.trend === "stable" + ? t("insights.breathingScreening.trendStable") + : null; + + return ( +
+ +
+ {classificationLabel ? ( +

+ {classificationLabel} +

+ ) : null} + +

+ {t("insights.breathingScreening.nights", { count: data.nights })} + {data.recentMeanIndex != null + ? ` · ${t("insights.breathingScreening.indexLine", { + value: data.recentMeanIndex, + })}` + : ""} +

+ + {trendLabel ? ( +

+ {trendLabel} +

+ ) : null} + + {data.eventCount > 0 ? ( +

+ {t("insights.breathingScreening.eventsFlagged", { + count: data.eventCount, + })} +

+ ) : null} + +

+ {t("insights.breathingScreening.disclaimer")} +

+
+
+ ); +} diff --git a/src/components/insights/health-status-card.tsx b/src/components/insights/health-status-card.tsx new file mode 100644 index 000000000..4318ec413 --- /dev/null +++ b/src/components/insights/health-status-card.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import type { + HealthStatusDeviation, + HealthStatusShift, +} from "@/lib/insights/health-status"; + +/** + * v1.25 — baseline-drift card. + * + * A calm read of what is drifting from the user's own normal, drawn off the + * read-only `/api/insights/health-status` route (which folds the personal-band + * deviations from the coincident engine with the dated changepoint shifts). The + * card un-mounts entirely when nothing is drifting (`present === false`) rather + * than surfacing an empty header. Awareness framing only — at most a neutral + * tone, never a red alert, never a diagnosis. + */ + +interface HealthStatusResponse { + present: boolean; + deviations: HealthStatusDeviation[]; + shifts: HealthStatusShift[]; + generatedAt: string; +} + +interface HealthStatusCardProps { + enabled?: boolean; + className?: string; +} + +export function HealthStatusCard({ + enabled = true, + className, +}: HealthStatusCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsHealthStatus(), + queryFn: () => apiGet("/api/insights/health-status"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + const vitalName = (type: string): string => { + const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; + return key ? t(key) : type; + }; + + return ( +
+ +
+ {data.deviations.map((d) => ( +
+ {d.direction === "above" ? ( +
+ ))} + + {data.shifts.map((s) => ( +
+ {s.direction === "up" ? ( +
+ ))} +
+
+ ); +} diff --git a/src/components/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index 7ac41f6b1..bf64ceea6 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -63,6 +63,10 @@ const SECTION_TITLE_KEYS: Record = { "cycle-summary": "cycle.insightsSummary.title", signals: "insights.derived.coincident.cardTitle", "rhythm-events": "insights.rhythmEvents.sectionTitle", + hydration: "hydration.cardTitle", + "health-status": "insights.healthStatus.sectionTitle", + breathing: "insights.breathingScreening.sectionTitle", + "labs-changes": "insights.labsChanges.sectionTitle", }; interface InsightsEditModeProps { diff --git a/src/components/insights/labs-changes-card.tsx b/src/components/insights/labs-changes-card.tsx new file mode 100644 index 000000000..386924758 --- /dev/null +++ b/src/components/insights/labs-changes-card.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { FlaskConical } from "lucide-react"; + +import { useTranslations } from "@/lib/i18n/context"; +import { useAuth } from "@/hooks/use-auth"; +import { cn } from "@/lib/utils"; +import { apiGet } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; +import { SectionHeading } from "@/components/insights/section-heading"; +import type { LabChange } from "@/lib/insights/labs-changes"; + +/** + * v1.25 — "what changed since your last panel" card. + * + * A neutral read off the read-only `/api/insights/labs-changes` route: for each + * analyte present in both the latest and the previous panel, the latest value, + * the signed delta, and where the latest value sits against its reference band. + * It un-mounts when there are fewer than two panels or no shared analyte + * (`present === false`). Never a diagnosis — neutral framing only. + */ + +interface LabsChangesResponse { + present: boolean; + latestDate: string | null; + previousDate: string | null; + changes: LabChange[]; + generatedAt: string; +} + +interface LabsChangesCardProps { + enabled?: boolean; + className?: string; +} + +function statusLabelKey(status: LabChange["status"]): string | null { + switch (status) { + case "in-range": + return "insights.labsChanges.statusInRange"; + case "below": + return "insights.labsChanges.statusBelow"; + case "above": + return "insights.labsChanges.statusAbove"; + default: + return null; + } +} + +export function LabsChangesCard({ + enabled = true, + className, +}: LabsChangesCardProps) { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + + const { data } = useQuery({ + queryKey: queryKeys.insightsLabsChanges(), + queryFn: () => apiGet("/api/insights/labs-changes"), + enabled: enabled && isAuthenticated, + staleTime: 60_000, + }); + + if (!data || data.present === false) return null; + + return ( +
+ +
+ {data.changes.map((c) => { + const labelKey = statusLabelKey(c.status); + const signedDelta = + c.direction === "flat" + ? t("insights.labsChanges.directionFlat") + : `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim(); + return ( +
+
+

+ {c.analyte} +

+

+ {`${c.latest} ${c.unit}`.trim()} + {labelKey ? ` · ${t(labelKey)}` : ""} +

+
+

+ {signedDelta} +

+
+ ); + })} +
+
+ ); +} diff --git a/src/lib/__tests__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index b637a385f..0d216bed3 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -20,7 +20,7 @@ import { } from "@/lib/insights-layout"; describe("insights-layout v2 — section id universe", () => { - it("carries exactly the eight default sections in render order", () => { + it("carries exactly the default sections in render order", () => { expect([...INSIGHTS_SECTION_IDS]).toEqual([ "wellness-scores", "daily-briefing", @@ -30,6 +30,10 @@ describe("insights-layout v2 — section id universe", () => { "cycle-summary", "signals", "rhythm-events", + "hydration", + "health-status", + "breathing", + "labs-changes", ]); }); diff --git a/src/lib/insights-layout.ts b/src/lib/insights-layout.ts index 7b2d3367a..f3bff31be 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -113,6 +113,10 @@ export const INSIGHTS_SECTION_IDS = [ "cycle-summary", "signals", "rhythm-events", + "hydration", + "health-status", + "breathing", + "labs-changes", ] as const; export type InsightsSectionId = (typeof INSIGHTS_SECTION_IDS)[number]; diff --git a/src/lib/insights/__tests__/breathing-screening.test.ts b/src/lib/insights/__tests__/breathing-screening.test.ts new file mode 100644 index 000000000..952ae8fbd --- /dev/null +++ b/src/lib/insights/__tests__/breathing-screening.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "vitest"; + +import { + summariseBreathing, + type BreathingRow, +} from "@/lib/insights/breathing-screening"; + +function night(daysAgo: number, value: number): BreathingRow { + return { + value, + measuredAt: new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000), + }; +} + +describe("summariseBreathing", () => { + it("is absent with no rows at all", () => { + const s = summariseBreathing([], []); + expect(s.present).toBe(false); + expect(s.nights).toBe(0); + expect(s.classification).toBeNull(); + expect(s.trend).toBeNull(); + }); + + it("classifies not-elevated with index nights and no events", () => { + const s = summariseBreathing([night(2, 3), night(1, 4)], []); + expect(s.present).toBe(true); + expect(s.nights).toBe(2); + expect(s.recentMeanIndex).toBe(3.5); + expect(s.classification).toBe("not-elevated"); + }); + + it("classifies elevated when the device flagged events", () => { + const s = summariseBreathing([night(1, 5)], [night(1, 1)]); + expect(s.present).toBe(true); + expect(s.eventCount).toBe(1); + expect(s.classification).toBe("elevated"); + }); + + it("computes an upward trend over enough nights", () => { + const rows = [ + night(8, 2), + night(7, 2), + night(6, 2), + night(5, 8), + night(4, 8), + night(3, 8), + night(2, 8), + night(1, 8), + ]; + const s = summariseBreathing(rows, []); + expect(s.trend).toBe("up"); + }); + + it("leaves the trend null with too few nights", () => { + const s = summariseBreathing([night(2, 4), night(1, 5)], []); + expect(s.trend).toBeNull(); + }); +}); diff --git a/src/lib/insights/__tests__/health-status.test.ts b/src/lib/insights/__tests__/health-status.test.ts new file mode 100644 index 000000000..f0750a2bc --- /dev/null +++ b/src/lib/insights/__tests__/health-status.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; + +import { summariseHealthStatus } from "@/lib/insights/health-status"; +import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; +import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; + +function vital( + outside: boolean, + direction: VitalDeviation["direction"], +): VitalDeviation { + return { + type: "RESTING_HEART_RATE", + value: 70, + center: 60, + low: 55, + high: 65, + outside, + direction, + }; +} + +function shift(): ChangepointSignal { + return { + metric: "WEIGHT", + breakDate: "2026-06-10", + beforeMean: 80, + afterMean: 82, + direction: "up", + magnitude: 1.6, + }; +} + +describe("summariseHealthStatus", () => { + it("is absent when nothing is outside and no shift fired", () => { + const s = summariseHealthStatus([vital(false, "in")], []); + expect(s.present).toBe(false); + expect(s.deviations).toHaveLength(0); + expect(s.shifts).toHaveLength(0); + }); + + it("surfaces an out-of-band deviation", () => { + const s = summariseHealthStatus([vital(true, "above")], []); + expect(s.present).toBe(true); + expect(s.deviations).toHaveLength(1); + expect(s.deviations[0]).toMatchObject({ + type: "RESTING_HEART_RATE", + direction: "above", + }); + }); + + it("surfaces a changepoint shift even with no deviation", () => { + const s = summariseHealthStatus([vital(false, "in")], [shift()]); + expect(s.present).toBe(true); + expect(s.shifts).toHaveLength(1); + expect(s.shifts[0]).toMatchObject({ metric: "WEIGHT", direction: "up" }); + }); + + it("drops a vital flagged outside but with an 'in' direction", () => { + const s = summariseHealthStatus([vital(true, "in")], []); + expect(s.present).toBe(false); + }); +}); diff --git a/src/lib/insights/__tests__/labs-changes.test.ts b/src/lib/insights/__tests__/labs-changes.test.ts new file mode 100644 index 000000000..a6d020465 --- /dev/null +++ b/src/lib/insights/__tests__/labs-changes.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; + +import { + summariseLabChanges, + type LabChangeRow, +} from "@/lib/insights/labs-changes"; + +function row( + analyte: string, + value: number, + day: string, + opts: { unit?: string; low?: number | null; high?: number | null } = {}, +): LabChangeRow { + return { + analyte, + unit: opts.unit ?? "mg/dL", + value, + referenceLow: opts.low ?? null, + referenceHigh: opts.high ?? null, + takenAt: new Date(`${day}T08:00:00.000Z`), + }; +} + +describe("summariseLabChanges", () => { + it("is absent with no rows", () => { + expect(summariseLabChanges([])).toMatchObject({ present: false }); + }); + + it("is absent with a single panel", () => { + const s = summariseLabChanges([row("LDL", 130, "2026-06-01")]); + expect(s.present).toBe(false); + }); + + it("is absent when no analyte is shared across panels", () => { + const s = summariseLabChanges([ + row("LDL", 130, "2026-06-01"), + row("HbA1c", 5.4, "2026-05-01"), + ]); + expect(s.present).toBe(false); + }); + + it("pairs the two most-recent panels for a shared analyte", () => { + const s = summariseLabChanges([ + row("LDL", 120, "2026-06-01", { high: 116 }), + row("LDL", 140, "2026-05-01"), + row("LDL", 150, "2026-04-01"), + ]); + expect(s.present).toBe(true); + expect(s.latestDate).toBe("2026-06-01"); + expect(s.previousDate).toBe("2026-05-01"); + expect(s.changes).toHaveLength(1); + const c = s.changes[0]; + expect(c.latest).toBe(120); + expect(c.previous).toBe(140); + expect(c.delta).toBe(-20); + expect(c.direction).toBe("down"); + expect(c.status).toBe("above"); + }); + + it("skips qualitative (non-finite) values", () => { + const s = summariseLabChanges([ + row("LDL", Number.NaN, "2026-06-01"), + row("LDL", 140, "2026-05-01"), + ]); + expect(s.present).toBe(false); + }); +}); diff --git a/src/lib/insights/breathing-screening.ts b/src/lib/insights/breathing-screening.ts new file mode 100644 index 000000000..94b294cb4 --- /dev/null +++ b/src/lib/insights/breathing-screening.ts @@ -0,0 +1,122 @@ +/** + * v1.25 — breathing-disturbance screening shaping. + * + * Reads the per-night sleep-breathing-disturbance index + * (`BREATHING_DISTURBANCES`, lower-better) and the device-flagged + * breathing-disturbance / possible-apnea events (`BREATHING_DISTURBANCE_EVENT`) + * a sync writes, and folds them into a calm awareness summary: how many nights + * are covered, the recent mean index, the short-window trend, and the device's + * own classification. + * + * This is a SCREENING SIGNAL ONLY — never a HealthLog diagnosis. The card built + * on top of this states that explicitly. The classification mirrors the device: + * a night the device flagged with one or more elevated-breathing events reads + * as "elevated"; with index nights and no flagged event it reads as + * "not-elevated". HealthLog invents no numeric threshold of its own. + * + * The math lives here, free of Prisma, so the present / absent / trend states + * are unit-testable in isolation. + */ + +/** A single per-night reading (index) or a device-flagged event row. */ +export interface BreathingRow { + value: number; + measuredAt: Date; +} + +export type BreathingTrend = "up" | "down" | "stable" | null; +export type BreathingClassification = "not-elevated" | "elevated" | null; + +export interface BreathingScreeningSummary { + /** True when there is any index night or any flagged event to surface. */ + present: boolean; + /** Number of nights with a per-night index reading. */ + nights: number; + /** Mean of the index readings (lower-better), rounded to 1dp; null when none. */ + recentMeanIndex: number | null; + /** + * Direction of the recent index vs the prior window. "up" is worse (more + * disturbance). Null when there are too few nights to compare. + */ + trend: BreathingTrend; + /** Count of device-flagged breathing-disturbance / apnea events. */ + eventCount: number; + /** + * The device's own classification: "elevated" when it flagged events, + * "not-elevated" when there are index nights and no flagged event, null when + * there is no data at all. + */ + classification: BreathingClassification; +} + +/** Window (most-recent nights) the trend compares against the prior block. */ +const TREND_WINDOW_NIGHTS = 7; +/** Need at least this many nights total before a trend is meaningful. */ +const MIN_TREND_NIGHTS = 6; +/** Relative band within which the recent mean reads as "stable". */ +const STABLE_BAND = 0.1; + +function mean(xs: readonly number[]): number { + if (xs.length === 0) return 0; + let sum = 0; + for (const x of xs) sum += x; + return sum / xs.length; +} + +const round1 = (n: number): number => Math.round(n * 10) / 10; + +/** + * Summarise the breathing-screening signal. `indexRows` are the per-night + * index readings; `eventRows` are the device-flagged events. Order-independent + * — the helper sorts by `measuredAt` for the trend. + */ +export function summariseBreathing( + indexRows: readonly BreathingRow[], + eventRows: readonly BreathingRow[], +): BreathingScreeningSummary { + const sorted = [...indexRows] + .filter((r) => Number.isFinite(r.value)) + .sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()); + + const nights = sorted.length; + const eventCount = eventRows.length; + + if (nights === 0 && eventCount === 0) { + return { + present: false, + nights: 0, + recentMeanIndex: null, + trend: null, + eventCount: 0, + classification: null, + }; + } + + const values = sorted.map((r) => r.value); + const recentMeanIndex = nights > 0 ? round1(mean(values)) : null; + + let trend: BreathingTrend = null; + if (nights >= MIN_TREND_NIGHTS) { + const recent = values.slice(-TREND_WINDOW_NIGHTS); + const prior = values.slice(0, -TREND_WINDOW_NIGHTS); + if (recent.length > 0 && prior.length > 0) { + const recentMean = mean(recent); + const priorMean = mean(prior); + const band = Math.abs(priorMean) * STABLE_BAND; + const delta = recentMean - priorMean; + trend = Math.abs(delta) <= band ? "stable" : delta > 0 ? "up" : "down"; + } + } + + const classification: BreathingClassification = + eventCount > 0 ? "elevated" : nights > 0 ? "not-elevated" : null; + + return { + present: true, + nights, + recentMeanIndex, + trend, + eventCount, + classification, + }; +} diff --git a/src/lib/insights/health-status.ts b/src/lib/insights/health-status.ts new file mode 100644 index 000000000..419f7dbc1 --- /dev/null +++ b/src/lib/insights/health-status.ts @@ -0,0 +1,84 @@ +/** + * v1.25 — health-status / baseline-drift shaping. + * + * Surfaces what is drifting from the user's own normal by combining two + * existing read-only engines: + * - the personal-band deviations from the coincident-deviation flag + * (`coincident-deviation.ts`, which fans `VITALS_BASELINE` across vitals), + * keeping only the vitals that sit OUTSIDE their band today, and + * - the dated, sustained level shifts from the changepoint detector + * (`changepoint.ts`). + * + * This module owns only the pure reshaping — taking the engine outputs and + * folding them into the flat response the card reads — so the present / absent + * states are unit-testable without Prisma. It never labels a cause and never + * diagnoses; it lists what changed and frames it as awareness only. + */ +import type { MeasurementType } from "@/generated/prisma/client"; +import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation"; +import type { ChangepointSignal } from "@/lib/insights/derived/changepoint"; + +/** One vital sitting outside its personal band today. */ +export interface HealthStatusDeviation { + type: MeasurementType; + /** Today's value. */ + value: number; + /** Band center (median). */ + center: number; + low: number; + high: number; + /** Which side of the band the value falls on. */ + direction: "above" | "below"; +} + +/** One dated, sustained level shift from the changepoint detector. */ +export interface HealthStatusShift { + metric: MeasurementType; + /** YYYY-MM-DD of the first day of the new level. */ + breakDate: string; + beforeMean: number; + afterMean: number; + direction: "up" | "down"; +} + +export interface HealthStatusSummary { + /** True when there is at least one deviation or one shift to surface. */ + present: boolean; + deviations: HealthStatusDeviation[]; + shifts: HealthStatusShift[]; +} + +/** + * Fold the band deviations + level shifts into the response shape. `vitals` is + * the full banded set from the coincident engine (any standing); only the + * out-of-band ones become deviations. `shifts` is the changepoint output as-is. + */ +export function summariseHealthStatus( + vitals: readonly VitalDeviation[], + shifts: readonly ChangepointSignal[], +): HealthStatusSummary { + const deviations: HealthStatusDeviation[] = vitals + .filter((v) => v.outside && v.direction !== "in") + .map((v) => ({ + type: v.type, + value: v.value, + center: v.center, + low: v.low, + high: v.high, + direction: v.direction === "above" ? "above" : "below", + })); + + const mappedShifts: HealthStatusShift[] = shifts.map((s) => ({ + metric: s.metric, + breakDate: s.breakDate, + beforeMean: s.beforeMean, + afterMean: s.afterMean, + direction: s.direction, + })); + + return { + present: deviations.length + mappedShifts.length > 0, + deviations, + shifts: mappedShifts, + }; +} diff --git a/src/lib/insights/labs-changes.ts b/src/lib/insights/labs-changes.ts new file mode 100644 index 000000000..19887667c --- /dev/null +++ b/src/lib/insights/labs-changes.ts @@ -0,0 +1,131 @@ +/** + * v1.25 — "what changed since your last panel" shaping. + * + * Groups the user's numeric lab results by panel DATE (the calendar day of + * `takenAt`, UTC), finds the two most-recent dates, and for every analyte that + * appears in BOTH the latest and the previous panel reports the signed delta, + * the direction, and where the latest value sits against its reference band + * (`classifyReferenceRange`). Qualitative (valueText-only) results carry no + * numeric value, so they are skipped — a delta is meaningless for them. + * + * Pure + Prisma-free so the present / absent states are unit-testable: it is + * absent when there are fewer than two panel dates or no analyte is shared. + * Neutral framing only — a delta is not a diagnosis. + */ +import { + classifyReferenceRange, + type ReferenceRangeStatus, +} from "@/lib/labs/reference-range"; + +/** A single numeric lab reading. */ +export interface LabChangeRow { + analyte: string; + unit: string; + value: number; + referenceLow: number | null; + referenceHigh: number | null; + takenAt: Date; +} + +export interface LabChange { + analyte: string; + unit: string; + latest: number; + previous: number; + /** Signed latest − previous, rounded to 2dp. */ + delta: number; + direction: "up" | "down" | "flat"; + status: ReferenceRangeStatus; +} + +export interface LabChangesSummary { + present: boolean; + /** YYYY-MM-DD of the most-recent panel, or null when absent. */ + latestDate: string | null; + /** YYYY-MM-DD of the prior panel, or null when absent. */ + previousDate: string | null; + changes: LabChange[]; +} + +const ABSENT: LabChangesSummary = { + present: false, + latestDate: null, + previousDate: null, + changes: [], +}; + +/** UTC calendar-day key (YYYY-MM-DD) for a sample instant. */ +function dayKey(d: Date): string { + return d.toISOString().slice(0, 10); +} + +/** Normalised analyte name for cross-panel matching (free-text tolerant). */ +function analyteKey(analyte: string): string { + return analyte.trim().toLowerCase(); +} + +const round2 = (n: number): number => Math.round(n * 100) / 100; + +/** + * Summarise the change between the two most-recent numeric lab panels. `rows` + * should already be the user's live (non-deleted) numeric results; this helper + * does the grouping + pairing. + */ +export function summariseLabChanges( + rows: readonly LabChangeRow[], +): LabChangesSummary { + const numeric = rows.filter((r) => Number.isFinite(r.value)); + if (numeric.length === 0) return ABSENT; + + // Group by panel day, keeping the latest reading per analyte within a day. + const byDay = new Map>(); + for (const row of numeric) { + const day = dayKey(row.takenAt); + let analytes = byDay.get(day); + if (!analytes) { + analytes = new Map(); + byDay.set(day, analytes); + } + const key = analyteKey(row.analyte); + const existing = analytes.get(key); + if (!existing || row.takenAt.getTime() >= existing.takenAt.getTime()) { + analytes.set(key, row); + } + } + + const days = [...byDay.keys()].sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); + if (days.length < 2) return ABSENT; + + const latestDate = days[0]; + const previousDate = days[1]; + const latest = byDay.get(latestDate)!; + const previous = byDay.get(previousDate)!; + + const changes: LabChange[] = []; + for (const [key, latestRow] of latest) { + const previousRow = previous.get(key); + if (!previousRow) continue; + const delta = round2(latestRow.value - previousRow.value); + const direction: LabChange["direction"] = + delta > 0 ? "up" : delta < 0 ? "down" : "flat"; + changes.push({ + analyte: latestRow.analyte, + unit: latestRow.unit, + latest: latestRow.value, + previous: previousRow.value, + delta, + direction, + status: classifyReferenceRange( + latestRow.value, + latestRow.referenceLow, + latestRow.referenceHigh, + ), + }); + } + + if (changes.length === 0) return ABSENT; + + changes.sort((a, b) => a.analyte.localeCompare(b.analyte)); + + return { present: true, latestDate, previousDate, changes }; +} diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index a57314ece..0df9ab23c 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -36,9 +36,11 @@ import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; +import { hydrationPaths } from "./hydration"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; +import { insightsSignalPaths } from "./insights-signals"; import { labsPaths } from "./labs"; import { ocrPaths } from "./ocr"; import { measurementPaths } from "./measurements"; @@ -72,6 +74,7 @@ export const openApiPaths: NonNullable = { ...coachFeedbackPaths, ...adminDiagnosticPaths, ...insightsPaths, + ...insightsSignalPaths, ...moodPaths, ...settingsPaths, ...consentPaths, @@ -82,6 +85,7 @@ export const openApiPaths: NonNullable = { ...biomarkerPaths, ...illnessPaths, ...onboardingPaths, + ...hydrationPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts new file mode 100644 index 000000000..d52b8cfaa --- /dev/null +++ b/src/lib/openapi/routes/insights-signals.ts @@ -0,0 +1,177 @@ +/** + * OpenAPI route module — v1.25 read-only insight signals. + * + * Three calm awareness reads built on existing engines: + * - GET /api/insights/health-status baseline-drift (bands + changepoints) + * - GET /api/insights/breathing-screening sleep-breathing screening signal + * - GET /api/insights/labs-changes "what changed since your last panel" + * + * Each is pure compute over the rollup / lab tier — no provider call. Labels + * are resolved client-side from the type tokens, so the wire carries tokens + * only. 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 healthStatusResponse = z + .object({ + present: z + .boolean() + .describe("True when at least one deviation or shift is surfaced."), + deviations: z.array( + z.object({ + type: z.string().describe("MeasurementType token."), + value: z.number().describe("Today's value."), + center: z.number().describe("Personal band center (median)."), + low: z.number(), + high: z.number(), + direction: z.enum(["above", "below"]), + }), + ), + shifts: z.array( + z.object({ + metric: z.string().describe("MeasurementType token."), + breakDate: z + .string() + .describe("YYYY-MM-DD of the new level's first day."), + beforeMean: z.number(), + afterMean: z.number(), + direction: z.enum(["up", "down"]), + }), + ), + generatedAt: z.string().describe("ISO-8601 instant the read was computed."), + }) + .meta({ + id: "InsightsHealthStatus", + description: + "Vitals drifting from the user's personal normal — band deviations plus dated, sustained level shifts. Awareness only, never a diagnosis.", + }); + +const breathingScreeningResponse = z + .object({ + present: z.boolean(), + nights: z.number().int().describe("Nights with a per-night index reading."), + recentMeanIndex: z + .number() + .nullable() + .describe("Mean of the index readings (lower-better); null when none."), + trend: z + .enum(["up", "down", "stable"]) + .nullable() + .describe("Recent index vs the prior window; null when too few nights."), + eventCount: z + .number() + .int() + .describe("Device-flagged breathing-disturbance / apnea events."), + classification: z + .enum(["not-elevated", "elevated"]) + .nullable() + .describe("The device's own classification; null when no data."), + generatedAt: z.string(), + }) + .meta({ + id: "InsightsBreathingScreening", + description: + "Sleep-breathing-disturbance screening signal — a screening signal only, never a diagnosis.", + }); + +const labsChangesResponse = z + .object({ + present: z.boolean(), + latestDate: z + .string() + .nullable() + .describe("YYYY-MM-DD of the most-recent panel."), + previousDate: z + .string() + .nullable() + .describe("YYYY-MM-DD of the prior panel."), + changes: z.array( + z.object({ + analyte: z.string(), + unit: z.string(), + latest: z.number(), + previous: z.number(), + delta: z.number().describe("Signed latest − previous."), + direction: z.enum(["up", "down", "flat"]), + status: z.enum(["in-range", "below", "above", "unknown"]), + }), + ), + generatedAt: z.string(), + }) + .meta({ + id: "InsightsLabsChanges", + description: + "Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.", + }); + +export const insightsSignalPaths: NonNullable = { + "/api/insights/health-status": { + get: { + tags: ["Insights"], + summary: "Baseline-drift health status", + description: + "Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.", + responses: { + "200": { + description: "The drift summary.", + content: { + "application/json": { + schema: dataEnvelope( + healthStatusResponse, + "InsightsHealthStatusEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/breathing-screening": { + get: { + tags: ["Insights"], + summary: "Sleep-breathing screening signal", + description: + "Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth.", + responses: { + "200": { + description: "The breathing-screening summary.", + content: { + "application/json": { + schema: dataEnvelope( + breathingScreeningResponse, + "InsightsBreathingScreeningEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/labs-changes": { + get: { + tags: ["Insights"], + summary: "What changed since your last lab panel", + description: + "Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth.", + responses: { + "200": { + description: "The lab-change summary.", + content: { + "application/json": { + schema: dataEnvelope( + labsChangesResponse, + "InsightsLabsChangesEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/query-keys/insights.ts b/src/lib/query-keys/insights.ts index 0a3607438..5ce80bdb2 100644 --- a/src/lib/query-keys/insights.ts +++ b/src/lib/query-keys/insights.ts @@ -137,4 +137,29 @@ export const insightsKeys = { */ insightsCoachRead: (metric: string) => ["insights", "coach-read", metric] as const, + + /** + * v1.25 — baseline-drift read (`/api/insights/health-status`). Combines the + * personal-band deviations + the dated changepoint shifts into one awareness + * card. Pure compute over the rollup tier; the `["insights"]` prefix keeps it + * in the standard invalidation fan-out (a measurement write busts it). + */ + insightsHealthStatus: () => ["insights", "health-status"] as const, + + /** + * v1.25 — breathing-disturbance screening read + * (`/api/insights/breathing-screening`). Last ~30 nights of the per-night + * index + device-flagged events. The `["insights"]` prefix carries the + * invalidation fan-out from an Apple Health sleep batch write. + */ + insightsBreathingScreening: () => + ["insights", "breathing-screening"] as const, + + /** + * v1.25 — "what changed since your last panel" read + * (`/api/insights/labs-changes`). The two most-recent numeric lab panels' + * shared-analyte deltas. The `["insights"]` prefix keeps it in the standard + * invalidation fan-out. + */ + insightsLabsChanges: () => ["insights", "labs-changes"] as const, }; From 4bf45efcab338e052b3829d68d44e258893d7f8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 11:39:26 +0200 Subject: [PATCH 3/3] revert(insights): drop hydration logging Remove the in-app water-intake logging feature: the WATER_INTAKE measurement type, the per-user daily-goal field, the goal-ring card and its endpoint, and the related query keys, OpenAPI route, and locale strings. The feature is dropped from this release; the baseline-drift, breathing-screening, and labs-changes insight cards are unaffected, and the pre-existing TOTAL_BODY_WATER body-composition type stays intact. --- docs/api/openapi.yaml | 155 ---------- messages/de.json | 18 +- messages/en.json | 18 +- messages/es.json | 18 +- messages/fr.json | 18 +- messages/it.json | 18 +- messages/pl.json | 18 +- .../0209_v125_water_intake/migration.sql | 21 -- prisma/schema.prisma | 12 - src/app/api/hydration/route.ts | 111 ------- src/app/insights/page.tsx | 10 - src/components/insights/hydration-card.tsx | 288 ------------------ src/components/insights/hydration-ring.tsx | 121 -------- .../insights/insights-edit-mode.tsx | 1 - .../measurements/measurement-list-meta.ts | 4 - src/lib/__tests__/insights-layout.test.ts | 1 - .../measurement-type-enum-coverage.test.ts | 7 - src/lib/hydration/__tests__/hydration.test.ts | 72 ----- src/lib/hydration/hydration.ts | 80 ----- src/lib/insights-layout.ts | 1 - 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/openapi/routes/index.ts | 2 - 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 - 32 files changed, 6 insertions(+), 1136 deletions(-) delete mode 100644 prisma/migrations/0209_v125_water_intake/migration.sql delete mode 100644 src/app/api/hydration/route.ts delete mode 100644 src/components/insights/hydration-card.tsx delete mode 100644 src/components/insights/hydration-ring.tsx delete mode 100644 src/lib/hydration/__tests__/hydration.test.ts delete mode 100644 src/lib/hydration/hydration.ts delete mode 100644 src/lib/openapi/routes/hydration.ts delete mode 100644 src/lib/query-keys/hydration.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f7be19a22..6645c50c6 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -44,7 +44,6 @@ tags: - name: Illness - name: Admin - name: Meta - - name: Hydration paths: /api/cycle/day-logs: get: @@ -2088,7 +2087,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE - in: query name: from schema: @@ -2278,7 +2276,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number measuredAt: @@ -5773,7 +5770,6 @@ paths: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." required: true description: "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s." @@ -8003,44 +7999,6 @@ paths: "401": *a1 "422": *a3 "429": *a4 - /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: - $ref: "#/components/schemas/HydrationTodayEnvelope" - "401": *a1 - "422": *a3 - "429": *a4 - 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: - $ref: "#/components/schemas/HydrationGoalRequest" - responses: - "200": - description: Goal updated. - content: - application/json: - schema: - $ref: "#/components/schemas/HydrationGoalEnvelope" - "401": *a1 - "422": *a3 - "429": *a4 components: schemas: EncryptedExportRequest: @@ -9341,7 +9299,6 @@ components: - cycle-summary - signals - rhythm-events - - hydration - health-status - breathing - labs-changes @@ -9754,17 +9711,6 @@ components: - updatedAt description: Resumable module-tour progress point. `lastStopId` seeds the resume index; `status` is the running/terminal state. - HydrationGoalRequest: - type: object - properties: - goalMl: - type: integer - minimum: 250 - maximum: 8000 - description: Daily hydration goal in millilitres. - required: - - goalMl - description: Set the per-user daily hydration goal (ml). MedicationTreatmentClass: type: string enum: @@ -12366,7 +12312,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number unit: @@ -12683,7 +12628,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE additionalProperties: type: array items: @@ -12761,7 +12705,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number description: Daily mean (spot metrics) or SUM (cumulative). @@ -12860,7 +12803,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE description: Per-type daily series, keyed by MeasurementType. required: - series @@ -13094,7 +13036,6 @@ components: - SLEEP_SCORE - BODY_TEMPERATURE_DEVIATION - RESILIENCE - - WATER_INTAKE value: type: number unit: @@ -23378,101 +23319,6 @@ components: - progress additionalProperties: false description: The persisted completion flag and resume point after the write. - HydrationTodayEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/HydrationToday" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - HydrationToday: - type: object - properties: - date: - type: string - description: ISO-8601 start of the user's local day. - totalMl: - type: number - description: Today's summed intake in ml. - goalMl: - type: number - description: Effective daily goal in ml. - percent: - type: number - description: Progress toward the goal, capped at 100. - rawPercent: - type: number - description: Uncapped progress; can exceed 100. - met: - type: boolean - description: Whether the day's total reached the goal. - remainingMl: - type: number - description: Remaining ml to the goal (0 once met). - entries: - type: array - items: - type: object - properties: - id: - type: string - value: - type: number - description: Logged amount in millilitres. - measuredAt: - type: string - description: ISO-8601 instant the entry was logged. - required: - - id - - value - - measuredAt - additionalProperties: false - required: - - date - - totalMl - - goalMl - - percent - - rawPercent - - met - - remainingMl - - entries - additionalProperties: false - description: Today's water-intake total vs the user's daily goal. - HydrationGoalEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/HydrationGoalResponse" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - HydrationGoalResponse: - type: object - properties: - goalMl: - type: number - required: - - goalMl - additionalProperties: false MedicationScheduleOutput: type: object properties: @@ -23650,7 +23496,6 @@ components: - cycle-summary - signals - rhythm-events - - hydration - health-status - breathing - labs-changes diff --git a/messages/de.json b/messages/de.json index 52333ac05..b5e3eb798 100644 --- a/messages/de.json +++ b/messages/de.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} Messungen gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", "bulkDeleteConfirmTitle": "{count} Messungen löschen?", - "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht.", - "typeWaterIntake": "Wasseraufnahme" + "bulkDeleteConfirmBody": "Die {count} ausgewählten Messungen werden endgültig gelöscht." }, "mood": { "title": "Stimmung", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher. Miss erneut, trink ausreichend und kontaktiere möglichst bald deine Ärztin oder deinen Arzt, wenn es so hoch bleibt.", "glucoseHighEmergency": "Zwei aktuelle Messungen lagen bei {value} mg/dL oder höher und du hast Beschwerden angegeben. Bei Erbrechen, schneller Atmung oder Verwirrtheit sofort den Notruf wählen." } - }, - "hydration": { - "cardTitle": "Flüssigkeitszufuhr", - "editGoal": "Ziel bearbeiten", - "goalLabel": "Tagesziel (ml)", - "metMessage": "Du hast dein heutiges Ziel erreicht.", - "remainingMessage": "Noch {amount} ml heute.", - "add": "Hinzufügen", - "added": "{amount} ml hinzugefügt.", - "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen.", - "invalidAmount": "Gib eine Menge zwischen 1 und 5000 ml ein.", - "invalidGoal": "Gib ein Ziel zwischen 250 und 8000 ml ein.", - "goalSaved": "Ziel aktualisiert.", - "customPlaceholder": "Menge", - "customLabel": "Eigene Menge (ml)" } } diff --git a/messages/en.json b/messages/en.json index c47b3d0b7..8902daa12 100644 --- a/messages/en.json +++ b/messages/en.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteError": "Error deleting the selected measurements", "bulkDeleteConfirmTitle": "Delete {count} measurements?", - "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted.", - "typeWaterIntake": "Water intake" + "bulkDeleteConfirmBody": "The {count} selected measurements will be permanently deleted." }, "mood": { "title": "Mood", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Two recent readings were {value} mg/dL or higher. Re-check, hydrate, and contact your doctor as soon as possible if it stays this high.", "glucoseHighEmergency": "Two recent readings were {value} mg/dL or higher and you noted symptoms. If you have vomiting, fast breathing, or confusion, call emergency services now." } - }, - "hydration": { - "cardTitle": "Hydration", - "editGoal": "Edit goal", - "goalLabel": "Daily goal (ml)", - "metMessage": "You've reached today's goal.", - "remainingMessage": "{amount} ml to go today.", - "add": "Add", - "added": "Added {amount} ml.", - "saveError": "Could not save. Please try again.", - "invalidAmount": "Enter an amount between 1 and 5000 ml.", - "invalidGoal": "Enter a goal between 250 and 8000 ml.", - "goalSaved": "Goal updated.", - "customPlaceholder": "Amount", - "customLabel": "Custom amount (ml)" } } diff --git a/messages/es.json b/messages/es.json index 6e03917c8..4cdd3886a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", "bulkDeleteConfirmTitle": "¿Eliminar {count} mediciones?", - "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente.", - "typeWaterIntake": "Ingesta de agua" + "bulkDeleteConfirmBody": "Las {count} mediciones seleccionadas se eliminarán de forma permanente." }, "mood": { "title": "Estado de ánimo", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Dos lecturas recientes fueron de {value} mg/dL o más. Vuelve a medir, hidrátate y contacta a tu médico cuanto antes si sigue así de alta.", "glucoseHighEmergency": "Dos lecturas recientes fueron de {value} mg/dL o más e indicaste síntomas. Si tienes vómitos, respiración rápida o confusión, llama a emergencias ya." } - }, - "hydration": { - "cardTitle": "Hidratación", - "editGoal": "Editar objetivo", - "goalLabel": "Objetivo diario (ml)", - "metMessage": "Has alcanzado el objetivo de hoy.", - "remainingMessage": "Faltan {amount} ml hoy.", - "add": "Añadir", - "added": "Añadidos {amount} ml.", - "saveError": "No se pudo guardar. Inténtalo de nuevo.", - "invalidAmount": "Introduce una cantidad entre 1 y 5000 ml.", - "invalidGoal": "Introduce un objetivo entre 250 y 8000 ml.", - "goalSaved": "Objetivo actualizado.", - "customPlaceholder": "Cantidad", - "customLabel": "Cantidad personalizada (ml)" } } diff --git a/messages/fr.json b/messages/fr.json index 1e40e6df6..8e50b7488 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", "bulkDeleteConfirmTitle": "Supprimer {count} mesures ?", - "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées.", - "typeWaterIntake": "Apport en eau" + "bulkDeleteConfirmBody": "Les {count} mesures sélectionnées seront définitivement supprimées." }, "mood": { "title": "Humeur", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Deux mesures récentes étaient à {value} mg/dL ou plus. Remesure, hydrate-toi et contacte ton médecin dès que possible si cela reste aussi élevé.", "glucoseHighEmergency": "Deux mesures récentes étaient à {value} mg/dL ou plus et tu as signalé des symptômes. En cas de vomissements, de respiration rapide ou de confusion, appelle les secours immédiatement." } - }, - "hydration": { - "cardTitle": "Hydratation", - "editGoal": "Modifier l'objectif", - "goalLabel": "Objectif quotidien (ml)", - "metMessage": "Vous avez atteint l'objectif du jour.", - "remainingMessage": "Encore {amount} ml aujourd'hui.", - "add": "Ajouter", - "added": "{amount} ml ajoutés.", - "saveError": "Échec de l'enregistrement. Réessayez.", - "invalidAmount": "Saisissez une quantité entre 1 et 5000 ml.", - "invalidGoal": "Saisissez un objectif entre 250 et 8000 ml.", - "goalSaved": "Objectif mis à jour.", - "customPlaceholder": "Quantité", - "customLabel": "Quantité personnalisée (ml)" } } diff --git a/messages/it.json b/messages/it.json index c8b3d58a2..c22449a44 100644 --- a/messages/it.json +++ b/messages/it.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", "bulkDeleteConfirmTitle": "Eliminare {count} misurazioni?", - "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente.", - "typeWaterIntake": "Assunzione di acqua" + "bulkDeleteConfirmBody": "Le {count} misurazioni selezionate verranno eliminate definitivamente." }, "mood": { "title": "Umore", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Due misurazioni recenti erano di {value} mg/dL o superiori. Rimisura, idratati e contatta il medico al più presto se resta così alta.", "glucoseHighEmergency": "Due misurazioni recenti erano di {value} mg/dL o superiori e hai segnalato sintomi. In caso di vomito, respiro affannoso o confusione, chiama subito i soccorsi." } - }, - "hydration": { - "cardTitle": "Idratazione", - "editGoal": "Modifica obiettivo", - "goalLabel": "Obiettivo giornaliero (ml)", - "metMessage": "Hai raggiunto l'obiettivo di oggi.", - "remainingMessage": "Ancora {amount} ml oggi.", - "add": "Aggiungi", - "added": "Aggiunti {amount} ml.", - "saveError": "Salvataggio non riuscito. Riprova.", - "invalidAmount": "Inserisci una quantità tra 1 e 5000 ml.", - "invalidGoal": "Inserisci un obiettivo tra 250 e 8000 ml.", - "goalSaved": "Obiettivo aggiornato.", - "customPlaceholder": "Quantità", - "customLabel": "Quantità personalizzata (ml)" } } diff --git a/messages/pl.json b/messages/pl.json index 603de4300..69f50d7e6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -574,8 +574,7 @@ "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", "bulkDeleteConfirmTitle": "Usunąć pomiary ({count})?", - "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte.", - "typeWaterIntake": "Spożycie wody" + "bulkDeleteConfirmBody": "Wybrane pomiary ({count}) zostaną trwale usunięte." }, "mood": { "title": "Nastrój", @@ -7404,20 +7403,5 @@ "glucoseHighDoctor": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej. Zmierz ponownie, nawodnij się i jak najszybciej skontaktuj się z lekarzem, jeśli pozostaje tak wysoki.", "glucoseHighEmergency": "Dwa ostatnie pomiary wyniosły {value} mg/dL lub więcej i zgłosiłeś objawy. W razie wymiotów, przyspieszonego oddechu lub dezorientacji natychmiast zadzwoń po pomoc." } - }, - "hydration": { - "cardTitle": "Nawodnienie", - "editGoal": "Edytuj cel", - "goalLabel": "Cel dzienny (ml)", - "metMessage": "Osiągnięto dzisiejszy cel.", - "remainingMessage": "Jeszcze {amount} ml dzisiaj.", - "add": "Dodaj", - "added": "Dodano {amount} ml.", - "saveError": "Nie udało się zapisać. Spróbuj ponownie.", - "invalidAmount": "Podaj ilość od 1 do 5000 ml.", - "invalidGoal": "Podaj cel od 250 do 8000 ml.", - "goalSaved": "Cel zaktualizowany.", - "customPlaceholder": "Ilość", - "customLabel": "Własna ilość (ml)" } } diff --git a/prisma/migrations/0209_v125_water_intake/migration.sql b/prisma/migrations/0209_v125_water_intake/migration.sql deleted file mode 100644 index a3212ada1..000000000 --- a/prisma/migrations/0209_v125_water_intake/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ --- 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 85f64b7ff..5bbd4cb75 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -233,10 +233,6 @@ 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") @@ -1111,14 +1107,6 @@ 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 deleted file mode 100644 index 0b056c59d..000000000 --- a/src/app/api/hydration/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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/app/insights/page.tsx b/src/app/insights/page.tsx index 846068a61..2e90e60bc 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -127,15 +127,6 @@ const RhythmEventsCard = dynamic( })), { ssr: false }, ); -// v1.25 — hydration daily-goal ring. Always present (a goal ring at 0 / goal is -// still useful); it owns its own today-total query and the goal editor. -const HydrationCard = dynamic( - () => - import("@/components/insights/hydration-card").then((mod) => ({ - default: mod.HydrationCard, - })), - { ssr: false }, -); // v1.25 — baseline-drift card. Un-mounts when nothing is drifting; owns its own // read-only `/api/insights/health-status` query. const HealthStatusCard = dynamic( @@ -508,7 +499,6 @@ export default function InsightsPage() { ) : null, signals: , "rhythm-events": , - hydration: , "health-status": , breathing: , "labs-changes": , diff --git a/src/components/insights/hydration-card.tsx b/src/components/insights/hydration-card.tsx deleted file mode 100644 index 45e5c3919..000000000 --- a/src/components/insights/hydration-card.tsx +++ /dev/null @@ -1,288 +0,0 @@ -"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 deleted file mode 100644 index 6c73c7c24..000000000 --- a/src/components/insights/hydration-ring.tsx +++ /dev/null @@ -1,121 +0,0 @@ -"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/insights/insights-edit-mode.tsx b/src/components/insights/insights-edit-mode.tsx index bf64ceea6..7d972157d 100644 --- a/src/components/insights/insights-edit-mode.tsx +++ b/src/components/insights/insights-edit-mode.tsx @@ -63,7 +63,6 @@ const SECTION_TITLE_KEYS: Record = { "cycle-summary": "cycle.insightsSummary.title", signals: "insights.derived.coincident.cardTitle", "rhythm-events": "insights.rhythmEvents.sectionTitle", - hydration: "hydration.cardTitle", "health-status": "insights.healthStatus.sectionTitle", breathing: "insights.breathingScreening.sectionTitle", "labs-changes": "insights.labsChanges.sectionTitle", diff --git a/src/components/measurements/measurement-list-meta.ts b/src/components/measurements/measurement-list-meta.ts index 56255d6b0..f27dd5ef2 100644 --- a/src/components/measurements/measurement-list-meta.ts +++ b/src/components/measurements/measurement-list-meta.ts @@ -35,7 +35,6 @@ import { Sun, PersonStanding, Waves, - GlassWater, type LucideIcon, } from "lucide-react"; @@ -55,7 +54,6 @@ 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", @@ -142,7 +140,6 @@ 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, @@ -266,7 +263,6 @@ 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__/insights-layout.test.ts b/src/lib/__tests__/insights-layout.test.ts index 0d216bed3..04dff7b0a 100644 --- a/src/lib/__tests__/insights-layout.test.ts +++ b/src/lib/__tests__/insights-layout.test.ts @@ -30,7 +30,6 @@ describe("insights-layout v2 — section id universe", () => { "cycle-summary", "signals", "rhythm-events", - "hydration", "health-status", "breathing", "labs-changes", diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index ceaf09f2c..a8ac5ef1c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -98,8 +98,6 @@ 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", () => { @@ -246,11 +244,6 @@ 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 deleted file mode 100644 index 8939919be..000000000 --- a/src/lib/hydration/__tests__/hydration.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -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 deleted file mode 100644 index a16bce868..000000000 --- a/src/lib/hydration/hydration.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * 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-layout.ts b/src/lib/insights-layout.ts index f3bff31be..1f7960440 100644 --- a/src/lib/insights-layout.ts +++ b/src/lib/insights-layout.ts @@ -113,7 +113,6 @@ export const INSIGHTS_SECTION_IDS = [ "cycle-summary", "signals", "rhythm-events", - "hydration", "health-status", "breathing", "labs-changes", diff --git a/src/lib/insights/chart-tokens.ts b/src/lib/insights/chart-tokens.ts index 06c669fb0..fca9edb1f 100644 --- a/src/lib/insights/chart-tokens.ts +++ b/src/lib/insights/chart-tokens.ts @@ -38,9 +38,6 @@ 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 ab5dbb6fd..0c0a7ef6b 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -77,12 +77,6 @@ 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 12a6d34aa..d6be7265a 100644 --- a/src/lib/measurements/apple-health-mapping.ts +++ b/src/lib/measurements/apple-health-mapping.ts @@ -706,11 +706,6 @@ 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 aaf1038f6..f9457adf2 100644 --- a/src/lib/measurements/categories.ts +++ b/src/lib/measurements/categories.ts @@ -89,8 +89,6 @@ 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 39b058505..1bc059952 100644 --- a/src/lib/measurements/display-transform.ts +++ b/src/lib/measurements/display-transform.ts @@ -105,8 +105,6 @@ 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 afe62485e..147d50b24 100644 --- a/src/lib/openapi/registry.ts +++ b/src/lib/openapi/registry.ts @@ -77,7 +77,6 @@ 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 deleted file mode 100644 index 58dccb2f2..000000000 --- a/src/lib/openapi/routes/hydration.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * 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/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 0df9ab23c..ae44bef96 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -36,7 +36,6 @@ import { biomarkerPaths } from "./biomarkers"; import { devicePaths } from "./devices"; import { exportPaths } from "./export"; import { healthRecordPaths } from "./health-record"; -import { hydrationPaths } from "./hydration"; import { illnessPaths } from "./illness"; import { importPaths } from "./import"; import { insightsPaths } from "./insights"; @@ -85,7 +84,6 @@ export const openApiPaths: NonNullable = { ...biomarkerPaths, ...illnessPaths, ...onboardingPaths, - ...hydrationPaths, }; export const openApiComponents: NonNullable = { diff --git a/src/lib/personal-records/pr-direction.ts b/src/lib/personal-records/pr-direction.ts index 9671186b8..0058a5131 100644 --- a/src/lib/personal-records/pr-direction.ts +++ b/src/lib/personal-records/pr-direction.ts @@ -194,11 +194,6 @@ 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 deleted file mode 100644 index 867dce37a..000000000 --- a/src/lib/query-keys/hydration.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 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 762f42536..d84854923 100644 --- a/src/lib/query-keys/index.ts +++ b/src/lib/query-keys/index.ts @@ -19,7 +19,6 @@ 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"; @@ -47,7 +46,6 @@ export const queryKeys = { ...measurementReminderKeys, ...labKeys, ...illnessKeys, - ...hydrationKeys, }; /** @@ -88,8 +86,6 @@ 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 97a4876e0..2c11ba6f0 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -122,10 +122,6 @@ 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", ]); /** @@ -243,7 +239,6 @@ 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)", @@ -399,9 +394,6 @@ 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