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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 11:39:26 +02:00
1 parent ec3fcea84f
commit 4bf45efcab
32 files changed
+6 -1136

No files matched your search

-155
View File
@@ -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
+1 -17
View File
@@ -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)"
}
}
+1 -17
View File
@@ -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)"
}
}
+1 -17
View File
@@ -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)"
}
}
+1 -17
View File
@@ -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)"
}
}
+1 -17
View File
@@ -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)"
}
}
+1 -17
View File
@@ -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)"
}
}
@@ -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;
-12
View File
@@ -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")
}
-111
View File
@@ -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 });
});
-10
View File
@@ -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: <CoincidentDeviationCard enabled={isAuthenticated} />,
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
hydration: <HydrationCard enabled={isAuthenticated} />,
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
-288
View File
@@ -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<HydrationTodayResponse>("/api/hydration"),
enabled: gated,
staleTime: 60_000,
});
async function logAmount(ml: number) {
if (busy || ml <= 0) return;
setBusy(true);
try {
await apiPost("/api/measurements", {
type: "WATER_INTAKE",
value: ml,
measuredAt: new Date().toISOString(),
});
await invalidateKeys(queryClient, measurementDependentKeys);
toast.success(t("hydration.added", { amount: ml }));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
async function logCustom() {
const ml = Math.round(Number(customMl));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_ENTRY_ML ||
ml > MAX_HYDRATION_ENTRY_ML
) {
toast.error(t("hydration.invalidAmount"));
return;
}
await logAmount(ml);
setCustomMl("");
}
async function saveGoal() {
const ml = Math.round(Number(goalDraft));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_GOAL_ML ||
ml > MAX_HYDRATION_GOAL_ML
) {
toast.error(t("hydration.invalidGoal"));
return;
}
setBusy(true);
try {
await apiPatch("/api/hydration", { goalMl: ml });
await invalidateKeys(queryClient, [queryKeys.hydration()]);
setEditingGoal(false);
toast.success(t("hydration.goalSaved"));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
const totalMl = data?.totalMl ?? 0;
const goalMl = data?.goalMl ?? 0;
const percent = data?.percent ?? 0;
const met = data?.met ?? false;
const remainingMl = data?.remainingMl ?? 0;
return (
<section
data-slot="hydration-section"
aria-label={t("hydration.cardTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={GlassWater}
title={t("hydration.cardTitle")}
action={
editingGoal ? null : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
onClick={() => {
setGoalDraft(String(goalMl || ""));
setEditingGoal(true);
}}
data-slot="hydration-edit-goal"
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span className="text-xs">{t("hydration.editGoal")}</span>
</Button>
)
}
/>
<div
data-slot="hydration-card"
className="bg-card flex w-full min-w-0 flex-col items-center gap-4 rounded-xl border p-4 md:flex-row md:items-center md:gap-6 md:p-6"
>
<HydrationRing
percent={percent}
totalMl={totalMl}
goalMl={goalMl}
met={met}
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<p
className="text-muted-foreground text-sm"
data-slot="hydration-status"
role="status"
>
{met
? t("hydration.metMessage")
: t("hydration.remainingMessage", { amount: remainingMl })}
</p>
{editingGoal ? (
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_GOAL_ML}
max={MAX_HYDRATION_GOAL_ML}
value={goalDraft}
onChange={(e) => setGoalDraft(e.target.value)}
aria-label={t("hydration.goalLabel")}
className="h-9 w-28"
data-slot="hydration-goal-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9"
disabled={busy}
onClick={saveGoal}
>
{t("common.save")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9"
onClick={() => setEditingGoal(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<>
<div className="flex flex-wrap gap-2">
{HYDRATION_QUICK_ADD_ML.map((ml) => (
<Button
key={ml}
type="button"
variant="outline"
size="sm"
className="h-9 gap-1"
disabled={busy}
onClick={() => logAmount(ml)}
data-slot="hydration-quick-add"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{`${ml} ml`}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_ENTRY_ML}
max={MAX_HYDRATION_ENTRY_ML}
placeholder={t("hydration.customPlaceholder")}
value={customMl}
onChange={(e) => setCustomMl(e.target.value)}
aria-label={t("hydration.customLabel")}
className="h-9 w-28"
data-slot="hydration-custom-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9 gap-1"
disabled={busy || customMl.trim() === ""}
onClick={logCustom}
>
{met ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
)}
{t("hydration.add")}
</Button>
</div>
</>
)}
</div>
</div>
</section>
);
}
-121
View File
@@ -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 `<linearGradient>` is real SVG JSX, not innerHTML.
*/
const VIEW = 100;
const STROKE = 9;
const R = (VIEW - STROKE) / 2;
const C = 2 * Math.PI * R;
const CX = VIEW / 2;
export interface HydrationRingProps {
/** Progress toward the goal, 0..100 (already capped by the caller). */
percent: number;
/** Today's total in ml — the centred number. */
totalMl: number;
/** The goal in ml — rendered under the total. */
goalMl: number;
/** Whether the goal has been met (adds a met affordance). */
met?: boolean;
/** Render size in px. */
size?: number;
className?: string;
}
export function HydrationRing({
percent,
totalMl,
goalMl,
met = false,
size = 168,
className,
}: HydrationRingProps) {
const gid = useId();
const clamped = Math.min(
100,
Math.max(0, Number.isFinite(percent) ? percent : 0),
);
const dash = (clamped / 100) * C;
const reduced = prefersReducedMotion();
const [armed, setArmed] = useState(false);
useEffect(() => {
if (reduced) return;
const raf = requestAnimationFrame(() => setArmed(true));
return () => cancelAnimationFrame(raf);
}, [reduced]);
const filled = reduced || armed;
return (
<div
data-slot="hydration-ring"
data-met={met ? "true" : undefined}
role="img"
aria-label={`${totalMl} of ${goalMl} ml (${Math.round(clamped)}%)`}
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
aria-hidden="true"
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--chart-2)" />
<stop offset="100%" stopColor="var(--chart-2)" />
</linearGradient>
</defs>
{/* Faint full-circle track. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.16}
strokeWidth={STROKE}
/>
{/* Progress arc — round cap, subtle first-paint sweep. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={{
strokeDashoffset: filled ? C - dash : C,
transition: reduced
? undefined
: "stroke-dashoffset 900ms var(--ring-ease, ease-out)",
}}
/>
</svg>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-foreground text-3xl font-semibold tracking-tight tabular-nums">
{totalMl}
</span>
<span className="text-muted-foreground text-[11px]">{`/ ${goalMl} ml`}</span>
</div>
</div>
);
}
@@ -63,7 +63,6 @@ const SECTION_TITLE_KEYS: Record<InsightsSectionId, string> = {
"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",
@@ -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<string, string> = {
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
WATER_INTAKE: "measurements.typeWaterIntake",
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
@@ -142,7 +140,6 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
HEART_RATE_VARIABILITY: HeartPulse,
RESTING_HEART_RATE: Heart,
ACTIVE_ENERGY_BURNED: Flame,
WATER_INTAKE: GlassWater,
FLIGHTS_CLIMBED: TrendingUp,
WALKING_RUNNING_DISTANCE: Footprints,
VO2_MAX: Gauge,
@@ -266,7 +263,6 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
WATER_INTAKE: "bg-chart-2/20 text-chart-2",
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",
@@ -30,7 +30,6 @@ describe("insights-layout v2 — section id universe", () => {
"cycle-summary",
"signals",
"rhythm-events",
"hydration",
"health-status",
"breathing",
"labs-changes",
@@ -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", () => {
@@ -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);
});
});
-80
View File
@@ -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),
};
}
-1
View File
@@ -113,7 +113,6 @@ export const INSIGHTS_SECTION_IDS = [
"cycle-summary",
"signals",
"rhythm-events",
"hydration",
"health-status",
"breathing",
"labs-changes",
-3
View File
@@ -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",
@@ -77,12 +77,6 @@ const MEASUREMENT_TYPES_WITHOUT_HK_COUNTERPART = new Set<MeasurementType>([
// the Oura API (source = OURA), never from HealthKit: Apple ships no
// resilience metric. No HK mapping by design.
"RESILIENCE",
// v1.25 — water intake is logged in-app (the hydration goal ring writes
// WATER_INTAKE rows directly via POST /api/measurements). Apple's
// `HKQuantityTypeIdentifierDietaryWater` stays on the nutrition deferred
// hold (HK_QUANTITY_TYPE_DEFERRED), so the type carries no HK import mapping
// by design.
"WATER_INTAKE",
]);
describe("APPLE_HEALTH_TYPE_MAP", () => {
@@ -706,11 +706,6 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> =
// daily total is the meaningful reduction (SUM), matching the other
// cumulative HK counts.
"FALL_COUNT",
// v1.25 — manual hydration intake. Not a HealthKit type, but its daily
// reduction is a SUM (running total of the day's entries), so it rides the
// same cumulative daily-sum path as the counts above rather than averaging
// individual sips.
"WATER_INTAKE",
]);
/**
-2
View File
@@ -89,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"],
@@ -105,8 +105,6 @@ const INTEGER_DISPLAY_TYPES: ReadonlySet<string> = new Set([
// grain for these day-cumulative counts.
"ACTIVE_ENERGY_BURNED",
"TIME_IN_DAYLIGHT",
// ml of water — whole millilitres are the meaningful display grain.
"WATER_INTAKE",
]);
/**
-1
View File
@@ -77,7 +77,6 @@ const openApiBase: Pick<
{ name: "Illness" },
{ name: "Admin" },
{ name: "Meta" },
{ name: "Hydration" },
],
};
-102
View File
@@ -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<ZodOpenApiObject["paths"]> = {
"/api/hydration": {
get: {
tags: ["Hydration"],
summary: "Today's hydration total vs goal",
description:
"Sums the calling user's WATER_INTAKE measurements for the current local day and compares them against the per-user goal (default 2000 ml).",
responses: {
"200": {
description: "Today's hydration summary.",
content: {
"application/json": {
schema: dataEnvelope(
hydrationTodayResponse,
"HydrationTodayEnvelope",
),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Hydration"],
summary: "Set the daily hydration goal",
description: "Updates the per-user daily hydration goal in millilitres.",
requestBody: {
required: true,
content: { "application/json": { schema: hydrationGoalRequest } },
},
responses: {
"200": {
description: "Goal updated.",
content: {
"application/json": {
schema: dataEnvelope(
hydrationGoalResponse,
"HydrationGoalEnvelope",
),
},
},
},
...stdResponses,
},
},
},
};
-2
View File
@@ -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<ZodOpenApiObject["paths"]> = {
...biomarkerPaths,
...illnessPaths,
...onboardingPaths,
...hydrationPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
-5
View File
@@ -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;
}
}
-10
View File
@@ -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,
};
-4
View File
@@ -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(),
];
/**
-8
View File
@@ -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<string, string> = {
HEART_RATE_VARIABILITY: "ms",
RESTING_HEART_RATE: "bpm",
ACTIVE_ENERGY_BURNED: "kcal",
WATER_INTAKE: "ml",
FLIGHTS_CLIMBED: "flights",
WALKING_RUNNING_DISTANCE: "m",
VO2_MAX: "mL/(kg·min)",
@@ -399,9 +394,6 @@ export const VALUE_RANGES: Record<string, { min: number; max: number }> = {
// Active energy kcal/sample — a daily-rollup sample tops out around
// 6–8000 kcal even for ultra-endurance days.
ACTIVE_ENERGY_BURNED: { min: 0, max: 10000 },
// Daily cumulative intake in ml; a single quick-add entry is small but the
// running daily total can reach several litres on an active day.
WATER_INTAKE: { min: 0, max: 20000 },
// Flights — vertical-feet/3 ≈ flights; cap generous.
FLIGHTS_CLIMBED: { min: 0, max: 1000 },
// Walking/running distance per sample (metres). 200 km covers an