mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Hold data-shaped page bodies until their reads settle
Several pages stacked cards whose final height is the data's: the illness day timeline, the measurement list, the insights arrange list, the system status cards, the score notices that land with the slower report. Each resolving on its own pushed the rest down the page, for a CLS of 0.11 to 0.18 at desktop width. RevealWhenSettled mounts the body at once but keeps it out of layout until no query is in flight (two frames running, two seconds at most), with a placeholder in its place. The chart loading shells were also ~100 px shorter than the card they became: they now reserve the day-dot row, its caption and the collapsed data-table disclosure, and the range-tab row at its 44 px height.
This commit is contained in:
1 parent
1a52384a1d
commit
a0c5ab6591
10 files changed
+464
-240
No files matched your search
@@ -36,6 +36,8 @@ import { ModuleAvailabilitySection } from "@/components/admin/module-availabilit
|
||||
import { RemindersSection } from "@/components/admin/reminders-section";
|
||||
import { ServicesSection } from "@/components/admin/services-section";
|
||||
import { SystemStatusSection } from "@/components/admin/system-status-section";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { MeasurementMaintenanceCard } from "@/components/admin/measurement-maintenance-card";
|
||||
import { InviteTokensSection } from "@/components/admin/invite-tokens-section";
|
||||
import { UserManagementSection } from "@/components/admin/user-management-section";
|
||||
@@ -59,11 +61,18 @@ export function AdminSectionRenderer({
|
||||
switch (slug) {
|
||||
case "system-status":
|
||||
return (
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
</SectionFrame>
|
||||
// The status cards grow as their reads land (host metrics, workers,
|
||||
// queues); revealed together so the maintenance card below is not
|
||||
// pushed down the page three times while they load.
|
||||
<RevealWhenSettled
|
||||
fallback={<Skeleton className="h-[40rem] w-full rounded-xl" />}
|
||||
>
|
||||
<SectionFrame>
|
||||
<SystemStatusSection />
|
||||
{/* v1.42 — the table maintenance after the tombstone purge. */}
|
||||
<MeasurementMaintenanceCard />
|
||||
</SectionFrame>
|
||||
</RevealWhenSettled>
|
||||
);
|
||||
case "general":
|
||||
return (
|
||||
|
||||
@@ -17,6 +17,8 @@ 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";
|
||||
@@ -203,14 +205,21 @@ export default function MeasurementsPage() {
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<MeasurementList
|
||||
onAddFirst={() => {
|
||||
setReturnTo(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
{/* 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
|
||||
onAddFirst={() => {
|
||||
setReturnTo(null);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CustomMetricList />
|
||||
<CustomMetricList />
|
||||
</RevealWhenSettled>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,11 +48,51 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
const SLOW_HINT_DELAY_MS = 3_000;
|
||||
|
||||
/**
|
||||
* The footer a loaded `<HealthChart>` paints under its plot: the row of day
|
||||
* dots and its caption on a day-linked chart, and the collapsed "show as
|
||||
* table" disclosure. A loading shell without it was ~100 px shorter than the
|
||||
* card it turned into (CLS on every metric page, the BMI page most visibly),
|
||||
* so the shells reserve the same boxes: `h-3` rug, `mt-1` caption (two lines
|
||||
* on a phone, one from `sm`), and the disclosure's `mt-3 border-t pt-3` frame
|
||||
* around its `min-h-11` button.
|
||||
*/
|
||||
export function ChartFooterReserve({
|
||||
dayLinks = false,
|
||||
dataTable = false,
|
||||
}: {
|
||||
dayLinks?: boolean;
|
||||
dataTable?: boolean;
|
||||
}) {
|
||||
if (!dayLinks && !dataTable) return null;
|
||||
return (
|
||||
<div aria-hidden="true" data-slot="chart-footer-reserve">
|
||||
{dayLinks ? (
|
||||
<>
|
||||
<div className="h-3" />
|
||||
<div className="mt-1 h-9 sm:h-4" />
|
||||
</>
|
||||
) : null}
|
||||
{dataTable ? (
|
||||
<div className="border-border mt-3 border-t pt-3">
|
||||
<div className="h-11" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartSkeleton({
|
||||
className,
|
||||
mini = false,
|
||||
dayLinks = false,
|
||||
dataTable = false,
|
||||
}: {
|
||||
className?: string;
|
||||
/** Reserve the day-dot row + caption a day-linked chart paints. */
|
||||
dayLinks?: boolean;
|
||||
/** Reserve the collapsed data-table disclosure. */
|
||||
dataTable?: boolean;
|
||||
/**
|
||||
* When true, render the compact variant used inside the trends-row
|
||||
* chart slot (140 px band, lighter chrome). Defaults to false for
|
||||
@@ -106,8 +146,9 @@ export function ChartSkeleton({
|
||||
{/* Header row — title + range tabs match the real chart's
|
||||
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
{/* The range tabs are 44 px tap targets, so the row is too. */}
|
||||
<div className="flex h-11 items-center gap-2">
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
<Skeleton className="h-6 w-12" />
|
||||
@@ -118,6 +159,7 @@ export function ChartSkeleton({
|
||||
{/* Chart band — mirrors the same `h-[var(--chart-height,…)]`
|
||||
contract `health-chart.tsx` paints at line 1087-1089. */}
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
<ChartFooterReserve dayLinks={dayLinks} dataTable={dataTable} />
|
||||
|
||||
{showSlowHint && (
|
||||
<p
|
||||
|
||||
@@ -29,20 +29,46 @@ import { importWithRetry } from "@/lib/retry-import";
|
||||
* to ONE error card with a reload affordance instead of bubbling to the
|
||||
* route-level `error.tsx`.
|
||||
*/
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
const loadHealthChart = () =>
|
||||
importWithRetry(() => import("@/components/charts/chart-runtime")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
);
|
||||
|
||||
// One lazy boundary per footer shape: `next/dynamic`'s loading slot gets no
|
||||
// props, so the shell that must match a day-linked chart with its data table
|
||||
// is its own boundary. All four resolve the same chunk.
|
||||
const HealthChartLazy = dynamic(loadHealthChart, {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton />,
|
||||
});
|
||||
const HealthChartLazyDay = dynamic(loadHealthChart, {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton dayLinks />,
|
||||
});
|
||||
const HealthChartLazyTable = dynamic(loadHealthChart, {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton dataTable />,
|
||||
});
|
||||
const HealthChartLazyDayTable = dynamic(loadHealthChart, {
|
||||
ssr: false,
|
||||
loading: () => <ChartSkeleton dayLinks dataTable />,
|
||||
});
|
||||
|
||||
export function HealthChartDynamic(
|
||||
props: ComponentProps<typeof HealthChartLazy>,
|
||||
): ReactElement {
|
||||
const Lazy = props.mini
|
||||
? HealthChartLazy
|
||||
: props.dayLinks
|
||||
? props.showDataTable
|
||||
? HealthChartLazyDayTable
|
||||
: HealthChartLazyDay
|
||||
: props.showDataTable
|
||||
? HealthChartLazyTable
|
||||
: HealthChartLazy;
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazy {...props} />
|
||||
<Lazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ import { bucketCaptionKey } from "@/lib/charts/bucket-caption";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { ChartOverlayControls } from "./chart-overlay-controls";
|
||||
import { ChartDataTable, type ChartDataTableColumn } from "./chart-data-table";
|
||||
import { ChartFooterReserve } from "./chart-skeleton";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
import { useViewportWidth } from "@/hooks/use-viewport-width";
|
||||
import { computeTickPositions } from "@/lib/charts/x-axis-density";
|
||||
@@ -1945,7 +1946,15 @@ export function HealthChart({
|
||||
// card height never jumps when the data lands — the dashboard's
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
<>
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
{mini ? null : (
|
||||
<ChartFooterReserve
|
||||
dayLinks={dayLinksActive}
|
||||
dataTable={showDataTable}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data in this range" empty state.
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { bodySiteText } from "@/components/encounters/encounter-labels";
|
||||
import { canReadBodySites } from "@/hooks/use-body-sites";
|
||||
@@ -154,49 +155,56 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
|
||||
}
|
||||
/>
|
||||
|
||||
{episode?.note ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("illness.detail.note")}
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
|
||||
{episode.note}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
{/* Every card below has a data-shaped height (the day timeline above
|
||||
all). Revealed together once the reads settle, so none of them
|
||||
pushes the rest down while the page loads. */}
|
||||
<RevealWhenSettled
|
||||
fallback={<Skeleton className="h-96 w-full rounded-xl" />}
|
||||
>
|
||||
{episode?.note ? (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("illness.detail.note")}
|
||||
</p>
|
||||
<p className="text-foreground mt-1 text-sm whitespace-pre-wrap">
|
||||
{episode.note}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{/* The per-day timeline lives only on the detail surface — the list
|
||||
rows stay clean summaries, so navigating here reveals genuinely new
|
||||
content (today's logged symptoms / impact / fever). */}
|
||||
{episode ? (
|
||||
<IllnessDayTimeline
|
||||
episodeId={episode.id}
|
||||
onLogDay={() => setLogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
{/* The per-day timeline lives only on the detail surface — the list
|
||||
rows stay clean summaries, so navigating here reveals genuinely new
|
||||
content (today's logged symptoms / impact / fever). */}
|
||||
{episode ? (
|
||||
<IllnessDayTimeline
|
||||
episodeId={episode.id}
|
||||
onLogDay={() => setLogOpen(true)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* v1.40 — the person's own symptoms filed against this episode or
|
||||
logged while it ran. Renders nothing when there are none. */}
|
||||
{episode ? <EpisodeSymptomsCard episode={episode} /> : null}
|
||||
{/* v1.40 — the person's own symptoms filed against this episode or
|
||||
logged while it ran. Renders nothing when there are none. */}
|
||||
{episode ? <EpisodeSymptomsCard episode={episode} /> : null}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<IllnessCorrelationCard episodeId={episodeId} />
|
||||
)}
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-40 w-full" />
|
||||
) : (
|
||||
<IllnessCorrelationCard episodeId={episodeId} />
|
||||
)}
|
||||
|
||||
{/* Condition ⇄ documents linking, condition side: the episode's
|
||||
linked documents plus link/upload entries into the vault. Renders
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
{/* Condition ⇄ documents linking, condition side: the episode's
|
||||
linked documents plus link/upload entries into the vault. Renders
|
||||
nothing when the documents module is off for this account. */}
|
||||
{episode ? <EpisodeDocumentsCard episodeId={episode.id} /> : null}
|
||||
|
||||
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
|
||||
suggests an episode: an episode spans weeks and a visit is a point,
|
||||
so the reverse match would be wrong often enough to train the person
|
||||
to ignore every suggestion in the product. */}
|
||||
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
|
||||
{/* Condition ⇄ visits linking, condition side ONLY. A visit never
|
||||
suggests an episode: an episode spans weeks and a visit is a point,
|
||||
so the reverse match would be wrong often enough to train the person
|
||||
to ignore every suggestion in the product. */}
|
||||
{episode ? <EpisodeVisitsCard episodeId={episode.id} /> : null}
|
||||
</RevealWhenSettled>
|
||||
|
||||
{episode ? (
|
||||
<>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { InsightsOverviewArrangeSection } from "@/components/settings/insights-overview-arrange-section";
|
||||
import { InsightsPillOrderSection } from "@/components/settings/insights-pill-order-section";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
/**
|
||||
* v1.15.18 — the "Insights" settings section (analogous to the Dashboard
|
||||
@@ -20,9 +22,14 @@ import { InsightsPillOrderSection } from "@/components/settings/insights-pill-or
|
||||
*/
|
||||
export function InsightsSection() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
// The arrange list grows from its loading card to the full set of
|
||||
// sections, which pushed the pill-order card below it down the page.
|
||||
// Both reveal together once their reads settle.
|
||||
<RevealWhenSettled
|
||||
fallback={<Skeleton className="h-[28rem] w-full rounded-xl" />}
|
||||
>
|
||||
<InsightsOverviewArrangeSection id="insights-overview-arrange" />
|
||||
<InsightsPillOrderSection id="insights-pill-order" />
|
||||
</div>
|
||||
</RevealWhenSettled>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { Gauge, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { RevealWhenSettled } from "@/components/ui/reveal-when-settled";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
@@ -319,194 +320,201 @@ export function ScoreSection() {
|
||||
const busy = saveMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="score-section">
|
||||
{showNotice && notice ? (
|
||||
<ScoreChangeNotice
|
||||
configVersion={configQuery.data?.version ?? 0}
|
||||
changedAt={configQuery.data?.changedAt ?? null}
|
||||
dismissing={dismissMutation.isPending}
|
||||
onDismiss={() => dismissMutation.mutate(notice.itemKey)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showComposition && composition ? (
|
||||
<ScoreCompositionNotice
|
||||
left={composition.left}
|
||||
joined={composition.joined}
|
||||
dismissing={dismissCompositionMutation.isPending}
|
||||
onDismiss={() =>
|
||||
dismissCompositionMutation.mutate(composition.itemKey)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{configQuery.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("settings.sections.score.loadFailed")}
|
||||
onRetry={() => void configQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
data-slot="score-config-card"
|
||||
aria-labelledby="score-config-title"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
titleId="score-config-title"
|
||||
title={t("settings.sections.score.card.title")}
|
||||
description={t("settings.sections.score.card.description")}
|
||||
// The two notices land with the score report, a slower read than the
|
||||
// configuration, and arrived above the card it had already painted. The
|
||||
// section reveals once both settle.
|
||||
<RevealWhenSettled
|
||||
fallback={<Skeleton className="h-[35rem] w-full rounded-xl" />}
|
||||
>
|
||||
<div className="space-y-6" data-slot="score-section">
|
||||
{showNotice && notice ? (
|
||||
<ScoreChangeNotice
|
||||
configVersion={configQuery.data?.version ?? 0}
|
||||
changedAt={configQuery.data?.changedAt ?? null}
|
||||
dismissing={dismissMutation.isPending}
|
||||
onDismiss={() => dismissMutation.mutate(notice.itemKey)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* The sentence the whole feature exists for. Foreground, in
|
||||
the body, because it is content and not a meta
|
||||
description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
{showComposition && composition ? (
|
||||
<ScoreCompositionNotice
|
||||
left={composition.left}
|
||||
joined={composition.joined}
|
||||
dismissing={dismissCompositionMutation.isPending}
|
||||
onDismiss={() =>
|
||||
dismissCompositionMutation.mutate(composition.itemKey)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* v1.38 — the breadth sentence. Three areas of health used to
|
||||
be the price of a score, and the write refused anything
|
||||
narrower; it is a recommendation now and one pillar saves.
|
||||
The recommendation stays on the surface anyway, because a
|
||||
one-area score is honest but thin and a person choosing
|
||||
what counts should know that before they save, not after
|
||||
the card tells them what it rests on. Content, so
|
||||
foreground and in the body beside the sentence it
|
||||
qualifies — not a muted footnote under the rows. */}
|
||||
<p data-slot="score-config-recommendation" className="text-sm">
|
||||
{t("settings.sections.score.recommendation")}
|
||||
</p>
|
||||
{configQuery.isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("settings.sections.score.loadFailed")}
|
||||
onRetry={() => void configQuery.refetch()}
|
||||
/>
|
||||
) : (
|
||||
<SettingsCard
|
||||
as="section"
|
||||
data-slot="score-config-card"
|
||||
aria-labelledby="score-config-title"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Gauge}
|
||||
titleId="score-config-title"
|
||||
title={t("settings.sections.score.card.title")}
|
||||
description={t("settings.sections.score.card.description")}
|
||||
/>
|
||||
|
||||
{/* The starting points. Both name data; neither names a
|
||||
person. A template that supplies a rationale for dropping a
|
||||
pillar is the one shape the evidence rules out, so there is
|
||||
none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t(refusal)}
|
||||
<div className="space-y-6">
|
||||
{/* The sentence the whole feature exists for. Foreground, in
|
||||
the body, because it is content and not a meta
|
||||
description. */}
|
||||
<p data-slot="score-config-three-axes" className="text-sm">
|
||||
{t("settings.sections.score.threeAxes")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{/* v1.38 — the breadth sentence. Three areas of health used to
|
||||
be the price of a score, and the write refused anything
|
||||
narrower; it is a recommendation now and one pillar saves.
|
||||
The recommendation stays on the surface anyway, because a
|
||||
one-area score is honest but thin and a person choosing
|
||||
what counts should know that before they save, not after
|
||||
the card tells them what it rests on. Content, so
|
||||
foreground and in the body beside the sentence it
|
||||
qualifies — not a muted footnote under the rows. */}
|
||||
<p data-slot="score-config-recommendation" className="text-sm">
|
||||
{t("settings.sections.score.recommendation")}
|
||||
</p>
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
// One flat list of topics separated by hairline dividers, the
|
||||
// way Anamnese lists its facts. The group headings are gone; the
|
||||
// registry order (domain, then pillar) is preserved by flattening
|
||||
// the groups in order, and each row keeps its `data-domain` for
|
||||
// tests and e2e.
|
||||
<div className="divide-border divide-y">
|
||||
{groups.flatMap((group) =>
|
||||
group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
{/* The starting points. Both name data; neither names a
|
||||
person. A template that supplies a rationale for dropping a
|
||||
pillar is the one shape the evidence rules out, so there is
|
||||
none. */}
|
||||
<div
|
||||
data-slot="score-config-presets"
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
{t("settings.sections.score.omitted", {
|
||||
count: omitted.length,
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.sections.score.presets.label")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
data-slot="score-config-preset-all"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...SCORE_PILLAR_IDS]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{t("settings.sections.score.presets.all")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={busy || !dirty}
|
||||
data-slot="score-config-preset-current"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={() => {
|
||||
setRefusal(null);
|
||||
setDraft([...serverSelection]);
|
||||
}}
|
||||
>
|
||||
{t("settings.sections.score.presets.current")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bottom-right, in its own row, like every other settings card
|
||||
that saves a form. */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
{refusal ? (
|
||||
<p
|
||||
data-slot="score-config-refusal"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t(refusal)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{analyticsQuery.isError ? (
|
||||
<QueryErrorRow
|
||||
slot="score-config-eligibility-error"
|
||||
message={t("settings.sections.score.eligibilityFailed")}
|
||||
onRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{configQuery.isLoading ? (
|
||||
<div data-slot="score-config-loading" className="space-y-3">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
<Skeleton className="h-11 w-full" />
|
||||
</div>
|
||||
) : (
|
||||
// One flat list of topics separated by hairline dividers, the
|
||||
// way Anamnese lists its facts. The group headings are gone; the
|
||||
// registry order (domain, then pillar) is preserved by flattening
|
||||
// the groups in order, and each row keeps its `data-domain` for
|
||||
// tests and e2e.
|
||||
<div className="divide-border divide-y">
|
||||
{groups.flatMap((group) =>
|
||||
group.rows.map((row) => (
|
||||
<ScorePillarRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pending={busy}
|
||||
onToggle={(next) => toggle(row.id, next)}
|
||||
/>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{omitted.length > 0 ? (
|
||||
<p
|
||||
data-slot="score-config-omitted"
|
||||
data-count={omitted.length}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
{t("settings.sections.score.omitted", {
|
||||
count: omitted.length,
|
||||
})}{" "}
|
||||
<Link
|
||||
href="/settings/modules"
|
||||
data-slot="score-config-omitted-link"
|
||||
className="text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("settings.sections.score.omittedLink")}
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bottom-right, in its own row, like every other settings card
|
||||
that saves a form. */}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => saveMutation.mutate(draft)}
|
||||
disabled={busy || !dirty || configQuery.isLoading}
|
||||
data-slot="score-config-save"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : null}
|
||||
{t("settings.sections.score.save")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
)}
|
||||
</div>
|
||||
</RevealWhenSettled>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { RevealWhenSettled } from "../reveal-when-settled";
|
||||
|
||||
/**
|
||||
* The body mounts at once (so its reads start) but stays out of layout until
|
||||
* they settle; the fallback holds the place. Nothing inside a `display: none`
|
||||
* box can register a layout shift, which is the point.
|
||||
*/
|
||||
describe("<RevealWhenSettled>", () => {
|
||||
it("paints the fallback and keeps the mounted body out of layout", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<RevealWhenSettled fallback={<div data-testid="fallback" />}>
|
||||
<p data-testid="body">content</p>
|
||||
</RevealWhenSettled>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(html).toContain('data-testid="fallback"');
|
||||
expect(html).toContain('data-testid="body"');
|
||||
expect(html).toMatch(
|
||||
/data-slot="reveal-when-settled" data-revealed="false" aria-busy="true" class="hidden"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useIsFetching, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Holds a page body out of layout until its first reads have settled, then
|
||||
* shows it in one step.
|
||||
*
|
||||
* Some bodies stack several cards whose final height depends on data — a
|
||||
* day-by-day timeline, a paginated list, a card that turns out empty. Each
|
||||
* resolving on its own pushed everything beneath it down the page a few
|
||||
* times in the first few hundred milliseconds (CLS well above 0.1 at
|
||||
* desktop width). A skeleton cannot know a list's real length; this does
|
||||
* not need to.
|
||||
*
|
||||
* The children mount immediately, so their queries start at once; they sit
|
||||
* in a `hidden` container (out of layout, so nothing in it can shift) while
|
||||
* the `fallback` holds the place. The body reveals when no query is in
|
||||
* flight for two frames running, or after `maxWaitMs` at the latest, so a
|
||||
* slow or polling read elsewhere in the app can delay it but never strand
|
||||
* it. Revealing is latched: a later background refetch does not hide the
|
||||
* body again.
|
||||
*/
|
||||
export function RevealWhenSettled({
|
||||
children,
|
||||
fallback,
|
||||
maxWaitMs = 2000,
|
||||
className = "space-y-6",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** The body's own rhythm: the wrapper is the one child of the page's
|
||||
* stack, so the gap between the cards it holds is set here. */
|
||||
className?: string;
|
||||
/** The placeholder painted until the body reveals. */
|
||||
fallback: ReactNode;
|
||||
maxWaitMs?: number;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const fetching = useIsFetching();
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealed || fetching > 0) return;
|
||||
// Two frames: the children's query observers subscribe in their own
|
||||
// effects, so a count of zero on the first pass can simply mean nothing
|
||||
// has started yet.
|
||||
let second = 0;
|
||||
const first = requestAnimationFrame(() => {
|
||||
second = requestAnimationFrame(() => {
|
||||
if (queryClient.isFetching() === 0) setRevealed(true);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelAnimationFrame(first);
|
||||
cancelAnimationFrame(second);
|
||||
};
|
||||
}, [fetching, revealed, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (revealed) return;
|
||||
const timer = setTimeout(() => setRevealed(true), maxWaitMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [revealed, maxWaitMs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{revealed ? null : fallback}
|
||||
<div
|
||||
data-slot="reveal-when-settled"
|
||||
data-revealed={revealed ? "true" : "false"}
|
||||
aria-busy={revealed ? undefined : true}
|
||||
className={revealed ? className : "hidden"}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user