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