mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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,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",
|
||||
|
||||
Reference in new issue
Block a user