mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
230 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|