Files
HealthLog/src/components/insights/insights-layout-shell.tsx
T
Marc-André Bombeck 5f253f7d09 fix(insights): make the nutrients pill reachable once the module is on
The nutrients pill required BOTH the opt-in module on AND at least one
NutrientIntakeDay row — but the in-context enable CTA lives on
/insights/nutrients itself, so a user who had just turned the module on
(or wanted to) had no way to reach the page that would let them. Drop
the row-count floor for this one metric: the pill now gates on the
module toggle alone, matching the deliberate opt-in the user already
made. The page itself is unchanged and still degrades from "enable" to
"no rows yet" to the three-card spine.
2026-07-17 23:50:06 +02:00

183 lines
8.2 KiB
TypeScript

"use client";
import { useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import {
nextAdvisorPollInterval,
useInsightsAdvisorQuery,
} from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.4.25 W4 — client shell for `src/app/insights/layout.tsx`.
*
* Owns the advisor query so the regenerate button on the sticky tab
* strip works from every Insights surface (mother + the seven sub-
* pages). TanStack Query dedup keeps the cost negligible: the mother
* page's own `useInsightsAdvisorQuery` consumer shares the same
* `queryKeys.insightsAdvisor()` key, so the two consumers reuse the
* same cache entry without extra network traffic.
*
* v1.4.27 F19 — also owns the analytics + comprehensive reads that
* power the tab-strip availability gate. The analytics consumer goes
* through the shared `useAnalyticsQuery({ slice: "summaries" })` hook
* (v1.4.33 IW2); the comprehensive consumer still owns its bespoke
* fetch but shares its cache key with the sub-page consumers so the
* payload lands once per route, dedup-shared across consumers.
*
* CRITICAL — the `<CoachDrawer>` does NOT mount here. It lives only in
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer.
*/
interface ComprehensivePayload {
moodSummary: { count: number } | null;
medications: Array<{ id: string }>;
/**
* v1.16.7 — true when the route served a stale cache body while a
* background rebuild runs (stale-while-revalidate). The query polls
* (bounded) while set so the fresh aggregate lands in-session.
*/
revalidating?: boolean;
}
export function InsightsLayoutShell({ children }: { children: ReactNode }) {
const { isAuthenticated, user } = useAuth();
// v1.4.33 F18 — gate the advisor POST on the operator's assistant
// feature flag. Pre-fix, every /insights mount fired POST
// /api/insights/generate even when the operator had disabled the
// briefing surface or the user had no AI provider configured. The
// server returned 422 in that case and the regenerate button on the
// tab strip rendered a non-functional spinner. Reading the flag
// matrix off `/api/feature-flags` keeps the hot path one fetch
// (shared `["feature-flags"]` cache, 60s staleTime) and skips the
// advisor request entirely when the operator has the briefing gate
// off. The fail-open default in `useFeatureFlags` means a network
// hiccup still renders the advisor — the gate only takes effect when
// the operator has explicitly turned the surface off.
const flags = useFeatureFlags();
// v1.16.4 — `mounted` keeps the hydration render in lockstep with the
// SSR HTML: a late-hydrating boundary can see resolved query state on
// its first render (auth + flags settled), which used to flip the
// regenerate affordance on at hydration time and trip React #418.
const mounted = useMounted();
const advisorEnabled =
mounted && isAuthenticated && flags.enabled && flags.briefing;
const advisor = useInsightsAdvisorQuery(advisorEnabled);
// Shared analytics fetch — the layout shell only reads
// `summaries[METRIC].count` for the tab-strip availability gate, so
// it lands on IW1's slim `?slice=summaries` branch (2 SQL passes,
// no correlations / health-score / bp-in-target tail).
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
// Shared comprehensive fetch — mood + medication signals for the
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensivePayload>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
// v1.16.7 — converge a stale-served (SWR) body in-session: while the
// route reports `revalidating: true`, poll on the same bounded
// cadence the advisor query uses until a fresh body lands.
refetchInterval: (query) =>
nextAdvisorPollInterval(
query.state.data?.revalidating,
query.state.dataUpdateCount,
),
});
// v1.4.32 — workout-presence gate. The workouts pill gates on at
// least one canonical row in the list-endpoint response. The
// single-row probe shares its cache slot with the list page +
// dashboard tile so navigation between surfaces is a free cache hit.
const workoutsProbe = useWorkouts({ limit: 1 });
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
// the always-on metric clusters, so the pill's floor is the module
// toggle alone — not a data-presence probe. Gating on BOTH the toggle
// AND a first row was a catch-22: the in-context enable CTA lives on
// `/insights/nutrients` itself, and that page was only reachable once
// data already existed. A user who just flipped the module on (or
// wants to) can now always reach the page; the page itself still
// degrades from "enable" → "no rows yet" → the three-card spine.
const nutrientsEnabled = user?.modules?.nutrients === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
// analytics or comprehensive. The strip is wrapped in
// `React.memo` and would still rerun `buildTabs(availability)` on
// every parent render if this prop kept arriving as a fresh
// reference. Per
// `.planning/research/v15-insights-blocking-bug.md` fix 2.
const summaries = analyticsQuery.data?.summaries;
const hasMood = (comprehensiveQuery.data?.moodSummary?.count ?? 0) > 0;
const hasMedication = (comprehensiveQuery.data?.medications?.length ?? 0) > 0;
const hasWorkouts = (workoutsProbe.data?.workouts.length ?? 0) > 0;
const hasNutrients = nutrientsEnabled;
const availability: InsightInputs | undefined = useMemo(() => {
if (!isAuthenticated) return undefined;
return { summaries, hasMood, hasMedication, hasWorkouts, hasNutrients };
}, [
isAuthenticated,
summaries,
hasMood,
hasMedication,
hasWorkouts,
hasNutrients,
]);
// v1.15.14 W2 — couple the tab strip to the saved insights layout.
// A sub-page pill now shows iff its metric has data AND its slug is
// layout-`visible`; the strip orders pills by the saved `order`. We
// pass the derived visibility set + order map only once the GET has
// SETTLED (`isSuccess`) — while the layout query is in flight the
// strip falls back to the data-only gate (props `undefined`) so the
// first paint is unchanged and a slow/failed layout fetch never
// suppresses a pill the user has data for. The query shares
// `queryKeys.insightsLayout()` with the overview + edit mode, so a
// "Fertig" save repaints the strip in lockstep.
const { layout: insightsLayout, isSuccess: layoutSettled } =
useInsightsLayoutQuery(isAuthenticated);
const visibleTileIds = useMemo(() => {
if (!layoutSettled) return undefined;
return new Set(
insightsLayout.tiles
.filter((tile) => tile.visible)
.map((tile) => tile.id),
);
}, [layoutSettled, insightsLayout]);
const tileOrder = useMemo(() => {
if (!layoutSettled) return undefined;
return new Map(insightsLayout.tiles.map((tile) => [tile.id, tile.order]));
}, [layoutSettled, insightsLayout]);
return (
<div className="space-y-8">
<InsightsTabStrip
onRegenerate={advisorEnabled ? advisor.regenerate : undefined}
regenerating={advisor.isRegenerating}
regenerateOutcome={advisor.regenerateOutcome}
availability={availability}
visibleTileIds={visibleTileIds}
tileOrder={tileOrder}
modules={user?.modules}
/>
{children}
</div>
);
}