feat: hydration logging and daily goal ring

Add a WATER_INTAKE measurement type and a per-user daily goal so the
Insights overview can track water intake against a goal ring. Logging
rides the existing measurements create path (type WATER_INTAKE, unit ml,
summed cumulatively); GET/PATCH /api/hydration read the day total and own
the goal. The goal-ring math (empty / partial / met / exceeded) lives in
a pure helper, free of Prisma and React, and is unit-tested in isolation.

Wire the new type through the measurement enum, value ranges, cumulative
HealthKit handling, categories, display transform, list metadata, record
direction, and the chartable-token allowlist. The HealthKit DietaryWater
import stays on the nutrition hold, so the type carries no import mapping
by design.

Document GET/PATCH /api/hydration in the OpenAPI contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 03:20:28 +02:00
1 parent bc617adc2c
commit 31c35baec2
20 files changed
+865 -1

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, GlassWater, Pencil, Plus } from "lucide-react";
import { toast } from "sonner";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { ApiError, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
import {
queryKeys,
invalidateKeys,
measurementDependentKeys,
} from "@/lib/query-keys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { SectionHeading } from "@/components/insights/section-heading";
import { HydrationRing } from "@/components/insights/hydration-ring";
import {
HYDRATION_QUICK_ADD_ML,
MAX_HYDRATION_ENTRY_ML,
MAX_HYDRATION_GOAL_ML,
MIN_HYDRATION_ENTRY_ML,
MIN_HYDRATION_GOAL_ML,
} from "@/lib/hydration/hydration";
/**
* v1.25 — hydration daily-goal card.
*
* A calm goal tracker for the Insights overview: a goal ring (today's summed
* WATER_INTAKE vs the user's goal), quick-add buttons (+250 / +500 ml and a
* custom amount), and an inline goal editor. Logging rides the existing
* measurements create path (POST /api/measurements, type WATER_INTAKE), so a
* new entry busts `measurementDependentKeys`, which carries the hydration key —
* the ring refreshes in lockstep. The goal write goes to PATCH /api/hydration.
*
* Always present (a goal ring at 0 / goal is still useful), unlike the
* insufficient-history read cards which un-mount.
*/
interface HydrationTodayResponse {
date: string;
totalMl: number;
goalMl: number;
percent: number;
rawPercent: number;
met: boolean;
remainingMl: number;
entries: { id: string; value: number; measuredAt: string }[];
}
interface HydrationCardProps {
enabled?: boolean;
className?: string;
}
export function HydrationCard({
enabled = true,
className,
}: HydrationCardProps) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const queryClient = useQueryClient();
const [busy, setBusy] = useState(false);
const [customMl, setCustomMl] = useState("");
const [editingGoal, setEditingGoal] = useState(false);
const [goalDraft, setGoalDraft] = useState("");
const gated = enabled && isAuthenticated;
const { data } = useQuery({
queryKey: queryKeys.hydrationToday(),
queryFn: () => apiGet<HydrationTodayResponse>("/api/hydration"),
enabled: gated,
staleTime: 60_000,
});
async function logAmount(ml: number) {
if (busy || ml <= 0) return;
setBusy(true);
try {
await apiPost("/api/measurements", {
type: "WATER_INTAKE",
value: ml,
measuredAt: new Date().toISOString(),
});
await invalidateKeys(queryClient, measurementDependentKeys);
toast.success(t("hydration.added", { amount: ml }));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
async function logCustom() {
const ml = Math.round(Number(customMl));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_ENTRY_ML ||
ml > MAX_HYDRATION_ENTRY_ML
) {
toast.error(t("hydration.invalidAmount"));
return;
}
await logAmount(ml);
setCustomMl("");
}
async function saveGoal() {
const ml = Math.round(Number(goalDraft));
if (
!Number.isFinite(ml) ||
ml < MIN_HYDRATION_GOAL_ML ||
ml > MAX_HYDRATION_GOAL_ML
) {
toast.error(t("hydration.invalidGoal"));
return;
}
setBusy(true);
try {
await apiPatch("/api/hydration", { goalMl: ml });
await invalidateKeys(queryClient, [queryKeys.hydration()]);
setEditingGoal(false);
toast.success(t("hydration.goalSaved"));
} catch (err) {
toast.error(
err instanceof ApiError && err.message
? err.message
: t("hydration.saveError"),
);
} finally {
setBusy(false);
}
}
const totalMl = data?.totalMl ?? 0;
const goalMl = data?.goalMl ?? 0;
const percent = data?.percent ?? 0;
const met = data?.met ?? false;
const remainingMl = data?.remainingMl ?? 0;
return (
<section
data-slot="hydration-section"
aria-label={t("hydration.cardTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={GlassWater}
title={t("hydration.cardTitle")}
action={
editingGoal ? null : (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 gap-1 px-2"
onClick={() => {
setGoalDraft(String(goalMl || ""));
setEditingGoal(true);
}}
data-slot="hydration-edit-goal"
>
<Pencil className="h-3.5 w-3.5" aria-hidden="true" />
<span className="text-xs">{t("hydration.editGoal")}</span>
</Button>
)
}
/>
<div
data-slot="hydration-card"
className="bg-card flex w-full min-w-0 flex-col items-center gap-4 rounded-xl border p-4 md:flex-row md:items-center md:gap-6 md:p-6"
>
<HydrationRing
percent={percent}
totalMl={totalMl}
goalMl={goalMl}
met={met}
/>
<div className="flex min-w-0 flex-1 flex-col gap-3">
<p
className="text-muted-foreground text-sm"
data-slot="hydration-status"
role="status"
>
{met
? t("hydration.metMessage")
: t("hydration.remainingMessage", { amount: remainingMl })}
</p>
{editingGoal ? (
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_GOAL_ML}
max={MAX_HYDRATION_GOAL_ML}
value={goalDraft}
onChange={(e) => setGoalDraft(e.target.value)}
aria-label={t("hydration.goalLabel")}
className="h-9 w-28"
data-slot="hydration-goal-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9"
disabled={busy}
onClick={saveGoal}
>
{t("common.save")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9"
onClick={() => setEditingGoal(false)}
>
{t("common.cancel")}
</Button>
</div>
) : (
<>
<div className="flex flex-wrap gap-2">
{HYDRATION_QUICK_ADD_ML.map((ml) => (
<Button
key={ml}
type="button"
variant="outline"
size="sm"
className="h-9 gap-1"
disabled={busy}
onClick={() => logAmount(ml)}
data-slot="hydration-quick-add"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{`${ml} ml`}
</Button>
))}
</div>
<div className="flex items-center gap-2">
<Input
type="number"
inputMode="numeric"
min={MIN_HYDRATION_ENTRY_ML}
max={MAX_HYDRATION_ENTRY_ML}
placeholder={t("hydration.customPlaceholder")}
value={customMl}
onChange={(e) => setCustomMl(e.target.value)}
aria-label={t("hydration.customLabel")}
className="h-9 w-28"
data-slot="hydration-custom-input"
/>
<span className="text-muted-foreground text-xs">ml</span>
<Button
type="button"
size="sm"
className="h-9 gap-1"
disabled={busy || customMl.trim() === ""}
onClick={logCustom}
>
{met ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
)}
{t("hydration.add")}
</Button>
</div>
</>
)}
</div>
</div>
</section>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useId, useState } from "react";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
/**
* v1.25 — hydration daily-goal ring.
*
* A hand-rolled SVG arc gauge in the same idiom as `score-ring.tsx` (fixed
* `0 0 100 100` viewBox, ~9% round-cap stroke, a faint same-hue track, a
* subtle first-paint sweep gated on `prefers-reduced-motion`). It is purpose-
* built for a goal counter rather than a 0–100 band score: the arc fills to the
* percent of goal (capped at 100), the centre shows the day's total in ml, and
* the hue is a single calm water tone — never the green/yellow/red band
* semantics a low score would otherwise paint, so "not much yet" never reads as
* an alarm. The `<linearGradient>` is real SVG JSX, not innerHTML.
*/
const VIEW = 100;
const STROKE = 9;
const R = (VIEW - STROKE) / 2;
const C = 2 * Math.PI * R;
const CX = VIEW / 2;
export interface HydrationRingProps {
/** Progress toward the goal, 0..100 (already capped by the caller). */
percent: number;
/** Today's total in ml — the centred number. */
totalMl: number;
/** The goal in ml — rendered under the total. */
goalMl: number;
/** Whether the goal has been met (adds a met affordance). */
met?: boolean;
/** Render size in px. */
size?: number;
className?: string;
}
export function HydrationRing({
percent,
totalMl,
goalMl,
met = false,
size = 168,
className,
}: HydrationRingProps) {
const gid = useId();
const clamped = Math.min(
100,
Math.max(0, Number.isFinite(percent) ? percent : 0),
);
const dash = (clamped / 100) * C;
const reduced = prefersReducedMotion();
const [armed, setArmed] = useState(false);
useEffect(() => {
if (reduced) return;
const raf = requestAnimationFrame(() => setArmed(true));
return () => cancelAnimationFrame(raf);
}, [reduced]);
const filled = reduced || armed;
return (
<div
data-slot="hydration-ring"
data-met={met ? "true" : undefined}
role="img"
aria-label={`${totalMl} of ${goalMl} ml (${Math.round(clamped)}%)`}
className={cn("relative shrink-0", className)}
style={{ width: size, height: size }}
>
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
aria-hidden="true"
>
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="var(--chart-2)" />
<stop offset="100%" stopColor="var(--chart-2)" />
</linearGradient>
</defs>
{/* Faint full-circle track. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeOpacity={0.16}
strokeWidth={STROKE}
/>
{/* Progress arc — round cap, subtle first-paint sweep. */}
<circle
cx={CX}
cy={CX}
r={R}
fill="none"
stroke={`url(#${gid})`}
strokeWidth={STROKE}
strokeLinecap="round"
strokeDasharray={C}
style={{
strokeDashoffset: filled ? C - dash : C,
transition: reduced
? undefined
: "stroke-dashoffset 900ms var(--ring-ease, ease-out)",
}}
/>
</svg>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
<span className="text-foreground text-3xl font-semibold tracking-tight tabular-nums">
{totalMl}
</span>
<span className="text-muted-foreground text-[11px]">{`/ ${goalMl} ml`}</span>
</div>
</div>
);
}
@@ -35,6 +35,7 @@ import {
Sun,
PersonStanding,
Waves,
GlassWater,
type LucideIcon,
} from "lucide-react";
@@ -54,6 +55,7 @@ export const MEASUREMENT_TYPE_LABEL_KEYS: Record<string, string> = {
HEART_RATE_VARIABILITY: "measurements.typeHeartRateVariability",
RESTING_HEART_RATE: "measurements.typeRestingHeartRate",
ACTIVE_ENERGY_BURNED: "measurements.typeActiveEnergyBurned",
WATER_INTAKE: "measurements.typeWaterIntake",
FLIGHTS_CLIMBED: "measurements.typeFlightsClimbed",
WALKING_RUNNING_DISTANCE: "measurements.typeWalkingRunningDistance",
VO2_MAX: "measurements.typeVo2Max",
@@ -140,6 +142,7 @@ export const MEASUREMENT_TYPE_ICONS: Record<string, LucideIcon> = {
HEART_RATE_VARIABILITY: HeartPulse,
RESTING_HEART_RATE: Heart,
ACTIVE_ENERGY_BURNED: Flame,
WATER_INTAKE: GlassWater,
FLIGHTS_CLIMBED: TrendingUp,
WALKING_RUNNING_DISTANCE: Footprints,
VO2_MAX: Gauge,
@@ -263,6 +266,7 @@ export const MEASUREMENT_TYPE_COLORS: Record<string, string> = {
HEART_RATE_VARIABILITY: "bg-chart-5/20 text-chart-5",
RESTING_HEART_RATE: "bg-chart-3/20 text-chart-3",
ACTIVE_ENERGY_BURNED: "bg-chart-4/20 text-chart-4",
WATER_INTAKE: "bg-chart-2/20 text-chart-2",
FLIGHTS_CLIMBED: "bg-chart-2/20 text-chart-2",
WALKING_RUNNING_DISTANCE: "bg-chart-2/20 text-chart-2",
VO2_MAX: "bg-chart-1/20 text-chart-1",