Files
HealthLog/src/app/measurements/page.tsx
T
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

230 lines
9.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import {
MeasurementForm,
resolveAddToken,
} from "@/components/measurements/measurement-form";
import { MeasurementList } from "@/components/measurements/measurement-list";
import { CustomMetricList } from "@/components/custom-metrics/custom-metric-list";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
import { Skeleton } from "@/components/ui/skeleton";
import { Plus } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "@/lib/i18n/context";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
const INSIGHT_METRIC_RETURN_PATHS: Readonly<Record<string, true>> =
Object.fromEntries(
SUB_PAGE_SLUGS.map((slug) => [`/insights/${slug}`, true] as const),
);
export function resolveMeasurementReturnTo(
returnTo: string | null | undefined,
): string | null {
return returnTo && INSIGHT_METRIC_RETURN_PATHS[returnTo] ? returnTo : null;
}
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { t } = useTranslations();
// v1.4.27 — read the `?add=<TYPE>` query param during render so
// the dialog opens on the first paint (no flash of the page behind
// it). The first-load param is consumed in the `useState` lazy
// initializers: they run identically on the server and the hydration
// render (pure token resolution, no router call), so SSR — which sees
// real search params now that the root layout renders every route
// dynamically — stays side-effect free. Calling `router.replace`
// during a server render throws (`location is not defined`); the
// query-strip lives in the effect below instead. The render-driven
// block then only handles LATER client-side navigations back to
// `?add=` (e.g. the insights empty-state CTAs), following the
// state-from-prop pattern in `account-section.tsx` — the lint rule
// `react-hooks/set-state-in-effect` rejects setState from inside an
// effect, so that path stays render-driven.
const addParam = searchParams.get("add");
const initialAdd = addParam ? resolveAddToken(addParam) : null;
const returnToParam = searchParams.get("returnTo");
const initialReturnTo = resolveMeasurementReturnTo(returnToParam);
const addRequestParam = addParam
? `${addParam}\u0000${returnToParam ?? ""}`
: null;
const [dialogOpen, setDialogOpen] = useState(() => initialAdd != null);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
() => initialAdd ?? undefined,
);
const [returnTo, setReturnTo] = useState<string | null>(
() => initialReturnTo,
);
const [consumedAddRequest, setConsumedAddRequest] = useState<string | null>(
() => addRequestParam,
);
// v1.4.27 — DOM handle the form portals its action row into so
// the Sheet branch can sticky-pin Save / Cancel.
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
// invalidation to visible queries). Suspended while the add-sheet is
// open so a drag inside the form can't arm the gesture.
const refreshVisible = useCallback(
() => queryClient.invalidateQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
disabled: dialogOpen,
});
// Later client-side navigation landed on `?add=` again. Once the effect
// below strips the request, clear the consumed key so repeating the same
// type later is still treated as a new one-shot capture.
if (!addRequestParam && consumedAddRequest !== null) {
setConsumedAddRequest(null);
} else if (addRequestParam && addRequestParam !== consumedAddRequest) {
setConsumedAddRequest(addRequestParam);
const resolved = resolveAddToken(addParam);
if (resolved) {
setDefaultType(resolved);
setDialogOpen(true);
setReturnTo(resolveMeasurementReturnTo(returnToParam));
}
}
// Drop the one-shot `?add=` / `?returnTo=` params so the back-button
// leaves the user on `/measurements` rather than re-opening the dialog.
// Every OTHER param is preserved: the list's filter state (`?type=`,
// `?source=`, …) now lives in the URL (see `measurement-list-filters.ts`)
// and stripping it would lose the filters this page is meant to keep.
// Effect-driven (client-only) because `router.replace` is not callable
// during SSR.
useEffect(() => {
if (!addParam && !returnToParam) return;
const next = new URLSearchParams(searchParams.toString());
next.delete("add");
next.delete("returnTo");
const search = next.toString();
router.replace(search ? `/measurements?${search}` : "/measurements");
}, [addParam, returnToParam, searchParams, router]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.push("/auth/login");
}
}, [isLoading, isAuthenticated, router]);
// v1.16.4 — `!mounted` keeps the hydration render identical to the
// SSR HTML when this boundary hydrates after `/api/auth/me` settled
// (React #418 family); see `useMounted`.
if (!mounted || isLoading) {
return <PageAuthGate label={t("common.loading")} />;
}
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<PageHeader
title={
<span data-tour-id="measurements-hero">
{t("measurements.title")}
</span>
}
description={t("measurements.subtitle")}
// v1.36.x — entering a reading is one of the verbs a WRITE grant
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAddMeasurement && !listEmpty ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
onClick={() => {
setReturnTo(null);
setDialogOpen(true);
}}
>
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
) : null
}
/>
{/* v1.36.x — the sheet answers the same question the header button
answers. `?add=<TYPE>` opens it without passing the button, and a
deep link is the same affordance as the control that produces it, so
it gets the same gate. Gating the open rather than only the param
also covers the first-paint window: `canAddMeasurement` reads true until
`/api/auth/me` settles, and a sheet opened in that frame withdraws
when the answer lands instead of standing on a form the server
refuses. */}
<ResponsiveSheet
open={dialogOpen && canAddMeasurement}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setDefaultType(undefined);
setReturnTo(null);
}
}}
title={t("dashboard.quickAddMeasurement")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MeasurementForm
defaultType={defaultType}
onSuccess={() => {
const destination = returnTo;
setDialogOpen(false);
setDefaultType(undefined);
setReturnTo(null);
if (destination) router.replace(destination);
}}
onCancel={() => {
setDialogOpen(false);
setDefaultType(undefined);
setReturnTo(null);
}}
footerSlot={footerEl}
/>
</ResponsiveSheet>
{/* The list's height is the data's; the custom metrics sit below it.
Revealed together once both reads settle, so neither pushes the
other down the page while it loads. */}
<RevealWhenSettled
fallback={<Skeleton className="h-[28rem] w-full rounded-xl" />}
>
<MeasurementList
onEmptyChange={setListEmpty}
onAddFirst={() => {
setReturnTo(null);
setDialogOpen(true);
}}
/>
<CustomMetricList />
</RevealWhenSettled>
</div>
);
}