mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(insights): render AI parts from the capability map, never as errors
The daily briefing, the hero's briefing paragraph, the trend sentences and the status notes follow the briefing and statusText capabilities: hidden while unavailable, never an error card or a per-card "connect a provider" state. The advisor and status reads only fire while their capability is available; a 403 settles as an outcome instead of throwing. A failed comprehensive read no longer replaces the overview, and every overview section is fenced so a section that throws becomes one error row. The period review always renders its composed narrative. Cycle, breathing and lab-change sections go through the surface map. One calm setup hint appears on the overview while no provider is configured and one could be. Signals, rhythm events and the mood patterns card say a failed read with a retry, the score anatomy no longer calls a failed read insufficient data, the catalog marks only the rows whose read failed, and workouts are not probed while the module is off.
This commit is contained in:
1 parent
61d912627d
commit
48d97ef70d
23 files changed
+687
-460
No files matched your search
@@ -22,11 +22,12 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -135,7 +136,8 @@ function CatalogRow({
|
||||
present,
|
||||
}: {
|
||||
entry: CatalogEntry;
|
||||
present: boolean | null;
|
||||
/** `"unknown"` while the read that decides this row failed. */
|
||||
present: boolean | null | "unknown";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
@@ -154,6 +156,7 @@ function CatalogRow({
|
||||
}
|
||||
|
||||
const isPresent = present === true;
|
||||
const unknown = present === "unknown";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -166,12 +169,12 @@ function CatalogRow({
|
||||
<span className="text-foreground text-sm font-medium">
|
||||
{t(entry.nameKey)}
|
||||
</span>
|
||||
<StatusBadge present={isPresent} />
|
||||
{unknown ? null : <StatusBadge present={isPresent} />}
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">{t(entry.sourceKey)}</p>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{isPresent && entry.href ? (
|
||||
{unknown ? null : isPresent && entry.href ? (
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<Link href={entry.href}>
|
||||
{t("metricCatalog.viewCta")}
|
||||
@@ -194,9 +197,35 @@ function CatalogRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Which read decides whether a catalog row has data. */
|
||||
type ProbeId = "analytics" | "comprehensive" | "workouts" | "nutrients" | "ecg";
|
||||
|
||||
function probeForEntry(entry: CatalogEntry): ProbeId {
|
||||
if (entry.id === "ecg") return "ecg";
|
||||
if (entry.metric === "MOOD" || entry.metric === "MEDICATION") {
|
||||
return "comprehensive";
|
||||
}
|
||||
if (entry.metric === "WORKOUTS") return "workouts";
|
||||
if (entry.metric === "NUTRIENTS") return "nutrients";
|
||||
return "analytics";
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface id of the sub-page a catalog row links to, so a row whose
|
||||
* module is switched off drops out through the one surface map. Rows without
|
||||
* a sub-page (info rows, the ECG row) have no owner and always show.
|
||||
*/
|
||||
function entrySurfaceId(entry: CatalogEntry): string | null {
|
||||
const slug = entry.href?.startsWith("/insights/")
|
||||
? entry.href.slice("/insights/".length).split(/[/?#]/)[0]
|
||||
: null;
|
||||
return slug ? `insights-page:${slug}` : null;
|
||||
}
|
||||
|
||||
export default function MetricCatalogPage() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const modules = user?.modules;
|
||||
|
||||
const analyticsQuery = useAnalyticsQuery({ slice: "summaries" });
|
||||
|
||||
@@ -206,7 +235,10 @@ export default function MetricCatalogPage() {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const workoutsProbe = useWorkouts({ limit: 1 });
|
||||
// Probe workouts only while the module shows; a switched-off module is
|
||||
// never asked for rows, and its row is dropped below.
|
||||
const workoutsVisible = isSurfaceVisible("insights-page:workouts", modules);
|
||||
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
|
||||
|
||||
const nutrientsModuleEnabled = user?.modules?.nutrients === true;
|
||||
const nutrientsProbe = useQuery({
|
||||
@@ -236,6 +268,43 @@ export default function MetricCatalogPage() {
|
||||
|
||||
const grouped = catalogEntriesByGroup();
|
||||
|
||||
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
|
||||
// `availability` is async; while one is in flight `hasMetricData()` reads
|
||||
// an undefined summary and would report "absent". Each row waits on the
|
||||
// ONE probe that decides it, and a failed probe marks only its own rows
|
||||
// unknown with a retry inside their group: one failing read never replaces
|
||||
// the whole page, and never reads as an honest "Not tracked yet".
|
||||
const probes: Record<
|
||||
ProbeId,
|
||||
{ isLoading: boolean; isError: boolean; retry: () => void }
|
||||
> = {
|
||||
analytics: {
|
||||
isLoading: analyticsQuery.isLoading,
|
||||
isError: analyticsQuery.isError,
|
||||
retry: () => void analyticsQuery.refetch(),
|
||||
},
|
||||
comprehensive: {
|
||||
isLoading: comprehensiveQuery.isLoading,
|
||||
isError: comprehensiveQuery.isError,
|
||||
retry: () => void comprehensiveQuery.refetch(),
|
||||
},
|
||||
workouts: {
|
||||
isLoading: workoutsProbe.isLoading,
|
||||
isError: workoutsProbe.isError,
|
||||
retry: () => workoutsProbe.refetch(),
|
||||
},
|
||||
nutrients: {
|
||||
isLoading: nutrientsModuleEnabled && nutrientsProbe.isLoading,
|
||||
isError: nutrientsModuleEnabled && nutrientsProbe.isError,
|
||||
retry: () => void nutrientsProbe.refetch(),
|
||||
},
|
||||
ecg: {
|
||||
isLoading: ecgQuery.isLoading,
|
||||
isError: ecgQuery.isError,
|
||||
retry: () => void ecgQuery.refetch(),
|
||||
},
|
||||
};
|
||||
|
||||
function isPresent(entry: CatalogEntry): boolean | null {
|
||||
if (entry.kind === "info") return null;
|
||||
if (entry.id === "ecg") return ecgQuery.data?.hasRecordings === true;
|
||||
@@ -243,49 +312,11 @@ export default function MetricCatalogPage() {
|
||||
return hasMetricData(entry.metric, availability);
|
||||
}
|
||||
|
||||
// M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
|
||||
// `availability` is async; while any of them is still in flight,
|
||||
// `hasMetricData()` reads an undefined summary and reports "absent" for
|
||||
// every metric. Gate the rows on the probes' aggregate loading/error
|
||||
// state instead of letting a cold load or a failed batch masquerade as
|
||||
// an honest "Not tracked yet".
|
||||
const isLoading = Boolean(
|
||||
isAuthenticated &&
|
||||
(analyticsQuery.isLoading ||
|
||||
comprehensiveQuery.isLoading ||
|
||||
workoutsProbe.isLoading ||
|
||||
ecgQuery.isLoading ||
|
||||
(nutrientsModuleEnabled && nutrientsProbe.isLoading)),
|
||||
);
|
||||
|
||||
const isError = Boolean(
|
||||
analyticsQuery.isError ||
|
||||
comprehensiveQuery.isError ||
|
||||
workoutsProbe.isError ||
|
||||
ecgQuery.isError ||
|
||||
(nutrientsModuleEnabled && nutrientsProbe.isError),
|
||||
);
|
||||
|
||||
function retryAll() {
|
||||
void analyticsQuery.refetch();
|
||||
void comprehensiveQuery.refetch();
|
||||
workoutsProbe.refetch();
|
||||
void ecgQuery.refetch();
|
||||
if (nutrientsModuleEnabled) void nutrientsProbe.refetch();
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("metricCatalog.title")}
|
||||
description={t("metricCatalog.description")}
|
||||
>
|
||||
<QueryErrorCard
|
||||
description={t("metricCatalog.loadError")}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
function isEntryVisible(entry: CatalogEntry): boolean {
|
||||
// Nutrients keeps its own module-off row with the way to switch it on.
|
||||
if (entry.id === "nutrients") return true;
|
||||
const surfaceId = entrySurfaceId(entry);
|
||||
return surfaceId === null || isSurfaceVisible(surfaceId, modules);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -295,8 +326,16 @@ export default function MetricCatalogPage() {
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{CATALOG_GROUP_ORDER.map((group) => {
|
||||
const entries = grouped.get(group) ?? [];
|
||||
const entries = (grouped.get(group) ?? []).filter(isEntryVisible);
|
||||
if (entries.length === 0) return null;
|
||||
const failed = [
|
||||
...new Set(
|
||||
entries
|
||||
.filter((entry) => entry.kind !== "info")
|
||||
.map(probeForEntry)
|
||||
.filter((id) => isAuthenticated && probes[id].isError),
|
||||
),
|
||||
];
|
||||
const Icon = GROUP_ICONS[group];
|
||||
// Nutrients rides the metabolic group but needs a module-off
|
||||
// branch (B1 — the tab-strip pill requires the module ON before
|
||||
@@ -314,7 +353,14 @@ export default function MetricCatalogPage() {
|
||||
title={t(CATALOG_GROUP_HEADER_KEYS[group])}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-2">
|
||||
{failed.length > 0 ? (
|
||||
<QueryErrorRow
|
||||
slot="catalog-group-error"
|
||||
message={t("metricCatalog.loadError")}
|
||||
onRetry={() => failed.forEach((id) => probes[id].retry())}
|
||||
/>
|
||||
) : null}
|
||||
<div className="divide-border divide-y">
|
||||
{entries.map((entry) => {
|
||||
if (entry.id === "nutrients" && !nutrientsModuleEnabled) {
|
||||
@@ -348,14 +394,22 @@ export default function MetricCatalogPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && entry.kind !== "info") {
|
||||
const probe =
|
||||
entry.kind === "info"
|
||||
? null
|
||||
: probes[probeForEntry(entry)];
|
||||
if (isAuthenticated && probe?.isLoading) {
|
||||
return <CatalogRowSkeleton key={entry.id} />;
|
||||
}
|
||||
return (
|
||||
<CatalogRow
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
present={isPresent(entry)}
|
||||
present={
|
||||
isAuthenticated && probe?.isError
|
||||
? "unknown"
|
||||
: isPresent(entry)
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { Loader2, Pill } from "lucide-react";
|
||||
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { nextStatusPollInterval } from "@/hooks/use-insight-status";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -93,6 +94,9 @@ interface MedicationComplianceDailyResponse {
|
||||
export default function InsightsMedikamentePage() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t, locale } = useTranslations();
|
||||
// The compliance summary and the per-medication sentences are status notes:
|
||||
// read only while the `statusText` capability is available.
|
||||
const statusText = useAiCapability("statusText");
|
||||
|
||||
const {
|
||||
data: comprehensive,
|
||||
@@ -124,7 +128,7 @@ export default function InsightsMedikamentePage() {
|
||||
clearTimeout(timeoutHandle);
|
||||
}
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && statusText.available,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
// v1.8.4 — bounded preparing poll: stop after the shared attempt
|
||||
|
||||
@@ -13,7 +13,7 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
// pulls neither the ECG label map/helpers nor the dynamically-loaded ECG
|
||||
// component into this eager bundle.
|
||||
import { ECG_OVERVIEW_LIMIT } from "@/lib/insights/ecg-overview-limit";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
@@ -26,10 +26,12 @@ import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HeroStrip } from "@/components/insights/hero-strip";
|
||||
import { AiSetupHint } from "@/components/insights/ai-setup-hint";
|
||||
import { OverviewSectionBoundary } from "@/components/insights/overview-section-boundary";
|
||||
import { isSurfaceVisible } from "@/lib/modules/surface";
|
||||
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import { useDashboardSnapshot } from "@/lib/queries/use-dashboard-snapshot";
|
||||
@@ -74,9 +76,6 @@ function BlockSkeleton({
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable reference so the trends row does not re-select on every render. */
|
||||
const HIDDEN_MOOD = ["mood"] as const;
|
||||
|
||||
const DailyBriefing = dynamic(
|
||||
() =>
|
||||
import("@/components/insights/daily-briefing").then((mod) => ({
|
||||
@@ -295,13 +294,11 @@ export default function InsightsPageClient() {
|
||||
// callbacks).
|
||||
useScrollResetOnRoute();
|
||||
|
||||
// v1.4.27 R3d MB4 — Coach drawer state lives in the layout-level
|
||||
// `<CoachLaunchProvider>`; the drawer is mounted next to the provider in
|
||||
// `src/app/insights/layout.tsx`. v1.18.7 removed the hero-band coach
|
||||
// entry points (the action button + suggested-prompt chips), so the
|
||||
// overview page no longer reads the launch context — the drawer + its
|
||||
// own launcher stay untouched.
|
||||
const flags = useFeatureFlags();
|
||||
// The AI parts of the overview (the daily briefing card, the hero's
|
||||
// briefing paragraph and the trend sentences, which all ride the advisor
|
||||
// read) follow the `briefing` capability on `/api/auth/me`. Everything else
|
||||
// on this page is data and never waits on it.
|
||||
const briefing = useAiCapability("briefing");
|
||||
|
||||
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
|
||||
// block the entire shell on `/api/insights/comprehensive`. The
|
||||
@@ -312,7 +309,7 @@ export default function InsightsPageClient() {
|
||||
// empty-state branch only fires once the query has resolved AND
|
||||
// reported zero measurements; while it's in-flight the page paints
|
||||
// the regular shell and the tiles fill in as their data lands.
|
||||
const { data, isLoading, isFetched, isError, refetch } = useQuery({
|
||||
const { data, isLoading, isFetched, isError } = useQuery({
|
||||
queryKey: queryKeys.insightsComprehensive(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -367,28 +364,17 @@ export default function InsightsPageClient() {
|
||||
// Settings → Insights, reached via the top-right cog on the tab strip.
|
||||
const { layout } = useInsightsLayoutQuery(isAuthenticated);
|
||||
|
||||
// Error branch — a transient 500 / network drop (after the query's
|
||||
// retries are exhausted) settles the comprehensive query with no data.
|
||||
// Without this gate the page would fall through to the "no data yet —
|
||||
// add a measurement" empty-state, which reads false for a user with
|
||||
// history. Surface an error + a Retry that refetches the one query,
|
||||
// mirroring the <VitalsDashboard> error pattern.
|
||||
if (isError) {
|
||||
return (
|
||||
<div data-slot="insights-overview-error">
|
||||
<QueryErrorCard
|
||||
description={t("insights.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A failed comprehensive read no longer replaces the page. The payload only
|
||||
// decides the "no data yet" empty state below; every section on the page
|
||||
// owns its own read and its own error row, so one failing read (#975: the
|
||||
// comprehensive route refused while an AI switch was off) cannot take the
|
||||
// hero, the scores and the other sections down with it. On failure the page
|
||||
// simply does not claim "no data yet" and renders the sections.
|
||||
// Empty-state shortcut — only paint once the comprehensive query has
|
||||
// resolved AND reported zero measurements. While it's in-flight we
|
||||
// fall through to the streamed shell so the user gets the hero +
|
||||
// skeleton tiles inside the first paint budget.
|
||||
if (!isLoading && isFetched && !data) {
|
||||
if (!isLoading && isFetched && !isError && !data) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<TrendingUp className="size-6" />}
|
||||
@@ -414,7 +400,14 @@ export default function InsightsPageClient() {
|
||||
mounted && user?.username?.trim() && user.username.trim().length > 0
|
||||
? user.username.split(/\s+/)[0]
|
||||
: null;
|
||||
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
|
||||
// The advisor read only fires while the briefing is available, so with it
|
||||
// unavailable these stay null: the hero keeps its fallback subtitle and the
|
||||
// trends row its built-in captions.
|
||||
const briefingPayload = briefing.available
|
||||
? (advisor.payload?.dailyBriefing ?? null)
|
||||
: null;
|
||||
const modules = user?.modules;
|
||||
const cycleRingVisible = isSurfaceVisible("overview:cycle-ring", modules);
|
||||
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
|
||||
|
||||
// v1.4.36 QA C2 — no `<Suspense>` wrappers below. The mother page is
|
||||
@@ -471,38 +464,29 @@ export default function InsightsPageClient() {
|
||||
// v1.18.0 — gate on the resolved `modules.cycle` flag (per-user toggle
|
||||
// AND the operator server-wide kill-switch) so an operator-off
|
||||
// instance never renders the ring nor fires its calendar read.
|
||||
extraTile={
|
||||
user?.modules?.cycle === true ? <CycleRingTile /> : undefined
|
||||
}
|
||||
extraTile={cycleRingVisible ? <CycleRingTile /> : undefined}
|
||||
// v1.15.5 — when the cycle ring is shown it TAKES the Strain slot:
|
||||
// hide Strain so the strip stays compact instead of growing a sixth
|
||||
// tile. Strain stays visible for non-cycle accounts.
|
||||
hideStrain={user?.modules?.cycle === true}
|
||||
hideStrain={cycleRingVisible}
|
||||
/>
|
||||
),
|
||||
"daily-briefing": flags.briefing ? (
|
||||
// Hidden, never an error card, while the briefing is unavailable for any
|
||||
// reason (the operator's switch, the AI analysis opt-out, no provider,
|
||||
// missing consent). A missing provider is said once, calmly, by the
|
||||
// setup hint above the sections, not on this card.
|
||||
"daily-briefing": briefing.available ? (
|
||||
<DailyBriefing
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
loading={advisor.isLoading}
|
||||
onRegenerate={advisor.regenerate}
|
||||
regenerating={advisor.isRegenerating}
|
||||
// v1.15.20 — the read path reports a 422 (no provider configured)
|
||||
// as its own outcome; swap the futile regenerate CTA for a quiet
|
||||
// Settings → AI hint instead of an eternal "preparing" loop.
|
||||
noProvider={advisor.readOutcome === "no-provider"}
|
||||
// v1.18.9 (#4) — a (stale) cached briefing is shown but no provider
|
||||
// is connected, so it can never refresh. Pair the relative-age
|
||||
// footer with a discreet connect-provider hint. Only fires when a
|
||||
// briefing is actually rendered (the empty-state path owns the
|
||||
// `noProvider` branch above).
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// v1.25 — the last generation attempt failed. On a held briefing this
|
||||
// adds a discreet "couldn't refresh — retry" footer hint; on an empty
|
||||
// card it swaps the generic empty state for an honest "couldn't
|
||||
// generate" one. Suppressed when no provider is configured (that hint
|
||||
// owns the surface and a retry would be futile).
|
||||
generationFailed={advisor.generationFailed && advisor.hasProvider}
|
||||
// generate" one.
|
||||
generationFailed={advisor.generationFailed}
|
||||
// v1.25.3 — failure class points the empty-state hint at the right
|
||||
// lever (raise the response timeout vs re-check the provider).
|
||||
generationFailureClass={advisor.generationFailureClass}
|
||||
@@ -516,27 +500,35 @@ export default function InsightsPageClient() {
|
||||
trends: (
|
||||
<TrendsRow
|
||||
briefing={briefingPayload}
|
||||
// Same module gate the tab strip applies to the Mood pill: with
|
||||
// the mood module off, the trends row does not chart mood either.
|
||||
hiddenMetrics={user?.modules?.mood === false ? HIDDEN_MOOD : undefined}
|
||||
annotations={advisor.payload?.trendAnnotations ?? null}
|
||||
loading={advisor.isLoading || advisor.isRegenerating}
|
||||
annotations={
|
||||
briefing.available
|
||||
? (advisor.payload?.trendAnnotations ?? null)
|
||||
: null
|
||||
}
|
||||
loading={
|
||||
briefing.available && (advisor.isLoading || advisor.isRegenerating)
|
||||
}
|
||||
/>
|
||||
),
|
||||
"period-review": flags.briefing ? (
|
||||
<PeriodNarrativeCard enabled={isAuthenticated} />
|
||||
) : null,
|
||||
// The period review always renders: its narrative is composed from the
|
||||
// numbers, and the server only swaps in model-written text while the
|
||||
// `periodNarrative` capability is available for this record.
|
||||
"period-review": <PeriodNarrativeCard enabled={isAuthenticated} />,
|
||||
// v1.15.2 — gated cycle teaser. Render only for a cycle-tracking account;
|
||||
// for everyone else this is nothing (no card, no layout gap). The card
|
||||
// itself stays silent until its reads resolve.
|
||||
"cycle-summary": user?.cycleTrackingEnabled ? (
|
||||
"cycle-summary": isSurfaceVisible("overview:cycle-summary", modules) ? (
|
||||
<CycleInsightSummaryCard />
|
||||
) : null,
|
||||
signals: <CoincidentDeviationCard enabled={isAuthenticated} />,
|
||||
"rhythm-events": <RhythmEventsCard enabled={isAuthenticated} />,
|
||||
"health-status": <HealthStatusCard enabled={isAuthenticated} />,
|
||||
breathing: <BreathingScreeningCard enabled={isAuthenticated} />,
|
||||
"labs-changes": <LabsChangesCard enabled={isAuthenticated} />,
|
||||
breathing: isSurfaceVisible("overview:breathing", modules) ? (
|
||||
<BreathingScreeningCard enabled={isAuthenticated} />
|
||||
) : null,
|
||||
"labs-changes": isSurfaceVisible("overview:labs-changes", modules) ? (
|
||||
<LabsChangesCard enabled={isAuthenticated} />
|
||||
) : null,
|
||||
ecg: <EcgSection enabled={isAuthenticated} limit={ECG_OVERVIEW_LIMIT} />,
|
||||
};
|
||||
|
||||
@@ -562,11 +554,6 @@ export default function InsightsPageClient() {
|
||||
briefing={briefingPayload}
|
||||
updatedAt={heroStripUpdatedAt}
|
||||
userName={heroGreetingName}
|
||||
// v1.18.9 (#4) — the hero subtitle is the cached briefing paragraph;
|
||||
// when it can never refresh (no AI provider) pair the "Generated
|
||||
// <relative>" line with a discreet connect-provider hint, matching
|
||||
// the briefing card footer. Only when a briefing is actually shown.
|
||||
noProviderStale={briefingPayload !== null && !advisor.hasProvider}
|
||||
// The Health Score lives in the band's right column and NOWHERE else
|
||||
// on this page. It is deliberately not a registry section: an id a
|
||||
// saved layout has never seen merges in as default-INVISIBLE
|
||||
@@ -588,6 +575,10 @@ export default function InsightsPageClient() {
|
||||
onScoreRetry={() => void analyticsQuery.refetch()}
|
||||
/>
|
||||
|
||||
{/* The one place the overview mentions AI setup: only while no
|
||||
provider is configured and this person could set one up. */}
|
||||
<AiSetupHint />
|
||||
|
||||
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
|
||||
overview (section show/hide + order) and sorting the nav pills now
|
||||
live ONLY in Settings → Insights, reached via the top-right cog on the
|
||||
@@ -617,7 +608,13 @@ export default function InsightsPageClient() {
|
||||
Each registry node is wrapped in a keyed Fragment so React keeps a
|
||||
stable identity across a reorder. */
|
||||
orderedSectionIds.map((id) => (
|
||||
<Fragment key={id}>{SECTION_REGISTRY[id]}</Fragment>
|
||||
<Fragment key={id}>
|
||||
{SECTION_REGISTRY[id] === null ? null : (
|
||||
<OverviewSectionBoundary sectionId={id}>
|
||||
{SECTION_REGISTRY[id]}
|
||||
</OverviewSectionBoundary>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -268,28 +268,18 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).not.toContain('data-slot="daily-briefing-meta-slot"');
|
||||
});
|
||||
|
||||
// v1.15.20 — no-provider state: the regenerate CTA would 422 forever,
|
||||
// so the card points at Settings → AI instead.
|
||||
it("renders the connect-provider hint with a settings link when noProvider", () => {
|
||||
// v1.39 (J1) — the card has no "no provider" state of its own any more.
|
||||
// It only mounts while the briefing capability is available, which
|
||||
// implies a provider; a missing provider is said once by the setup hint on
|
||||
// the overview. The empty state therefore always offers the regenerate CTA.
|
||||
it("never renders a connect-provider state; the empty state offers regenerate", () => {
|
||||
const html = render(
|
||||
<DailyBriefing briefing={null} onRegenerate={() => {}} noProvider />,
|
||||
<DailyBriefing briefing={null} onRegenerate={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="daily-briefing-no-provider"');
|
||||
expect(html).toContain("No AI provider connected");
|
||||
expect(html).toMatch(/href="\/settings\/ai"/);
|
||||
// The futile regenerate CTA must NOT render in this state.
|
||||
expect(html).not.toContain('data-slot="daily-briefing-empty-cta"');
|
||||
});
|
||||
|
||||
it("renders the German connect-provider hint under the de locale", () => {
|
||||
const html = render(<DailyBriefing briefing={null} noProvider />, "de");
|
||||
expect(html).toContain("Kein KI-Anbieter verbunden");
|
||||
});
|
||||
|
||||
it("prefers the briefing content over the no-provider hint when both exist", () => {
|
||||
const html = render(<DailyBriefing briefing={baseBriefing} noProvider />);
|
||||
expect(html).toContain('data-slot="daily-briefing-findings"');
|
||||
expect(html).not.toContain('data-slot="daily-briefing-no-provider"');
|
||||
expect(html).not.toContain("daily-briefing-no-provider");
|
||||
expect(html).not.toContain("daily-briefing-stale-no-provider");
|
||||
expect(html).not.toMatch(/href="\/settings\/ai"/);
|
||||
expect(html).toContain('data-slot="daily-briefing-empty-cta"');
|
||||
});
|
||||
|
||||
// v1.25 — generation-failed affordances. The briefing keeps its last good
|
||||
@@ -316,19 +306,6 @@ describe("<DailyBriefing>", () => {
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
});
|
||||
|
||||
it("suppresses the refresh-failed hint when no provider is connected", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
briefing={baseBriefing}
|
||||
onRegenerate={() => {}}
|
||||
generationFailed
|
||||
noProviderStale
|
||||
/>,
|
||||
);
|
||||
expect(html).not.toContain('data-slot="daily-briefing-refresh-failed"');
|
||||
expect(html).toContain('data-slot="daily-briefing-stale-no-provider"');
|
||||
});
|
||||
|
||||
it("renders the 'couldn't generate' empty state with a retry CTA when failed and empty", () => {
|
||||
const html = render(
|
||||
<DailyBriefing
|
||||
|
||||
@@ -16,6 +16,15 @@ import type { InsightStatusData } from "@/hooks/use-insight-status";
|
||||
* so SSR renders its stub — enough to verify the assessment slot.
|
||||
*/
|
||||
|
||||
// AI surfaces paint only while their capability is available; these tests
|
||||
// cover the painted surface, so every capability reads available here.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: () => {
|
||||
const Stub = () => <div data-slot="healthkit-chart-stub" />;
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* A status note is model-written text: the card paints only while the
|
||||
* `statusText` capability is available and is gone, never an error or a
|
||||
* "connect a provider" hint, for every reason it is not. The score anatomy's
|
||||
* assessment is composed from the numbers (`aiAuthored={false}`) and shows
|
||||
* whatever the AI state.
|
||||
*/
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import {
|
||||
AI_UNAVAILABLE_REASONS,
|
||||
type AiCapabilityKey,
|
||||
type AiCapabilityState,
|
||||
} from "@/lib/ai/capabilities/types";
|
||||
import {
|
||||
AI_AVAILABLE,
|
||||
aiUnavailable,
|
||||
} from "@/__tests__/helpers/ai-capability-fixtures";
|
||||
|
||||
const capability = vi.hoisted(() => ({
|
||||
current: null as AiCapabilityState | null,
|
||||
asked: [] as AiCapabilityKey[],
|
||||
}));
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: (key: AiCapabilityKey) => {
|
||||
capability.asked.push(key);
|
||||
return capability.current;
|
||||
},
|
||||
}));
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ user: { timezone: "Europe/Berlin" } }),
|
||||
}));
|
||||
|
||||
import { InsightStatusCard } from "../insight-status-card";
|
||||
|
||||
const baseProps = {
|
||||
title: "Pulse",
|
||||
icon: null,
|
||||
text: "Your pulse is stable.",
|
||||
hasProvider: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
capability.current = AI_AVAILABLE;
|
||||
capability.asked = [];
|
||||
});
|
||||
|
||||
describe("<InsightStatusCard> — statusText capability", () => {
|
||||
it("renders the note while status notes are available", () => {
|
||||
const html = render(<InsightStatusCard {...baseProps} />);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
expect(capability.asked).toContain("statusText");
|
||||
});
|
||||
|
||||
for (const reason of AI_UNAVAILABLE_REASONS) {
|
||||
it(`renders nothing when status notes are unavailable (${reason})`, () => {
|
||||
capability.current = aiUnavailable(reason);
|
||||
expect(render(<InsightStatusCard {...baseProps} />)).toBe("");
|
||||
});
|
||||
}
|
||||
|
||||
it("never renders a connect-a-provider state", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} text={null} hasProvider={false} />,
|
||||
);
|
||||
expect(html).not.toContain("/settings/ai");
|
||||
expect(html).not.toContain("insight-status-no-provider-cta");
|
||||
});
|
||||
|
||||
it("keeps a composed assessment visible whatever the AI state", () => {
|
||||
capability.current = aiUnavailable("operator_disabled");
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} aiAuthored={false} />,
|
||||
);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { InsightStatusCard } from "../insight-status-card";
|
||||
|
||||
/**
|
||||
* v1.4.31 — the per-metric `<InsightStatusCard>` returns `null` when
|
||||
* the operator has disabled the `insightStatus` surface. Renders
|
||||
* normally otherwise.
|
||||
*/
|
||||
function wrap(node: React.ReactNode, client: QueryClient) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>{node}</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
title: "Pulse",
|
||||
icon: null,
|
||||
text: "Your pulse is stable.",
|
||||
hasProvider: true,
|
||||
updatedAt: null,
|
||||
};
|
||||
|
||||
function makeClient(flags: {
|
||||
enabled: boolean;
|
||||
coach: boolean;
|
||||
briefing: boolean;
|
||||
insightStatus: boolean;
|
||||
documentAi: boolean;
|
||||
}): QueryClient {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
client.setQueryData(["feature-flags"], { assistant: flags });
|
||||
return client;
|
||||
}
|
||||
|
||||
describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
|
||||
it("renders the card when the flag is on", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
documentAi: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toContain("Your pulse is stable");
|
||||
});
|
||||
|
||||
it("returns null when the operator disables the sub-flag", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: false,
|
||||
documentAi: true,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when the master flag is off", () => {
|
||||
const html = wrap(
|
||||
<InsightStatusCard {...baseProps} />,
|
||||
makeClient({
|
||||
enabled: false,
|
||||
coach: false,
|
||||
briefing: false,
|
||||
insightStatus: false,
|
||||
documentAi: false,
|
||||
}),
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,17 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// The card paints only while the `statusText` capability is available; these
|
||||
// tests are about the painted card, so the capability reads available.
|
||||
// `insight-status-card-capability-gate.test.tsx` owns the gate itself.
|
||||
vi.mock("@/hooks/use-ai-capability", () => ({
|
||||
useAiCapability: () => ({
|
||||
available: true,
|
||||
reason: null,
|
||||
onDeviceAllowed: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
* v1.4.27 — F16 regression cover. The per-metric status text comes
|
||||
* out of the AI provider with a literal `metric:<TYPE>` token (the
|
||||
@@ -82,17 +93,6 @@ describe("<InsightStatusCard>", () => {
|
||||
expect(html).toContain("Pulse stayed inside the band.");
|
||||
});
|
||||
|
||||
it("renders the no-provider setup state when no terminal text exists", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
// v1.18.6 — the no-provider tile is a guided-setup explainer + a
|
||||
// Settings link, not a bare "unavailable" line.
|
||||
expect(html).toContain("Connect an AI provider");
|
||||
expect(html).toContain("Open AI settings");
|
||||
expect(html).toContain("/settings/ai");
|
||||
});
|
||||
|
||||
it("renders a screened terminal fallback even when its envelope has no provider", () => {
|
||||
const html = render(
|
||||
<InsightStatusCard
|
||||
@@ -159,16 +159,13 @@ describe("<InsightStatusCard>", () => {
|
||||
expect(html).not.toContain("animate-spin");
|
||||
});
|
||||
|
||||
it("keeps loading, preparing, no-provider, empty, and populated states distinct with stable compact geometry", () => {
|
||||
it("keeps loading, preparing, empty, and populated states distinct with stable compact geometry", () => {
|
||||
const loading = render(
|
||||
<InsightStatusCard {...baseProps} text={null} loading />,
|
||||
);
|
||||
const preparing = render(
|
||||
<InsightStatusCard {...baseProps} text={null} preparing />,
|
||||
);
|
||||
const noProvider = render(
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
const empty = render(<InsightStatusCard {...baseProps} text={null} />);
|
||||
const populated = render(
|
||||
<InsightStatusCard
|
||||
@@ -179,11 +176,10 @@ describe("<InsightStatusCard>", () => {
|
||||
|
||||
expect(loading).toContain('data-testid="insight-status-card-loading"');
|
||||
expect(preparing).toContain('data-testid="insight-status-card-preparing"');
|
||||
expect(noProvider).toContain('data-slot="insight-status-no-provider-cta"');
|
||||
expect(empty).toContain("No assessment yet.");
|
||||
expect(populated).toContain('data-slot="insight-assessment"');
|
||||
|
||||
for (const html of [loading, preparing, noProvider, empty]) {
|
||||
for (const html of [loading, preparing, empty]) {
|
||||
expect(html).toContain("gap-2");
|
||||
expect(html).toContain("py-3");
|
||||
expect(html).toContain("md:py-4");
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Sparkles, X } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { useAiProviderState } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* The one place HealthLog mentions that AI could be set up.
|
||||
*
|
||||
* HealthLog works without AI, so a missing provider is not a problem to
|
||||
* report on every card. The per-card "connect an AI provider" states are
|
||||
* gone; this calm hint replaces them, on the Insights overview and in
|
||||
* Settings → AI, and nowhere else.
|
||||
*
|
||||
* It shows only while no provider is configured AND the person could set one
|
||||
* up here (`provider.canConfigure`: false inside somebody else's record and
|
||||
* while the operator has AI switched off, where the hint would point at a
|
||||
* setting nobody on this screen can change). Dismissing it is remembered per
|
||||
* viewer in this browser; the hint stays gone on both surfaces.
|
||||
*/
|
||||
const AI_SETUP_HINT_STORAGE_PREFIX = "healthlog.ai-setup-hint.dismissed";
|
||||
|
||||
function storageKey(viewerId: string): string {
|
||||
return `${AI_SETUP_HINT_STORAGE_PREFIX}.${viewerId}`;
|
||||
}
|
||||
|
||||
function readDismissed(viewerId: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey(viewerId)) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AiSetupHint({
|
||||
surface = "insights",
|
||||
}: {
|
||||
/**
|
||||
* `insights`: a tile on the overview with a link to Settings → AI.
|
||||
* `settings`: a Settings card on Settings → AI itself, with no link.
|
||||
*/
|
||||
surface?: "insights" | "settings";
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
const provider = useAiProviderState();
|
||||
// Storage is browser-only: nothing renders before hydration, so the server
|
||||
// HTML and the first client render agree.
|
||||
const mounted = useMounted();
|
||||
const [dismissedNow, setDismissedNow] = useState(false);
|
||||
|
||||
const viewerId = user?.id ?? null;
|
||||
if (!mounted || viewerId === null) return null;
|
||||
if (provider.configured || !provider.canConfigure) return null;
|
||||
if (dismissedNow || readDismissed(viewerId)) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
setDismissedNow(true);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(viewerId), "1");
|
||||
} catch {
|
||||
// Storage unavailable (private mode): hidden for this visit only.
|
||||
}
|
||||
};
|
||||
|
||||
if (surface === "settings") {
|
||||
return (
|
||||
<SettingsCard data-slot="ai-setup-hint">
|
||||
<SettingsCardHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.aiSetupHint.title")}
|
||||
/>
|
||||
<p className="text-foreground text-sm">
|
||||
{t("insights.aiSetupHint.settingsBody")}
|
||||
</p>
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={dismiss}
|
||||
data-slot="ai-setup-hint-dismiss"
|
||||
>
|
||||
{t("insights.aiSetupHint.dismiss")}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card data-slot="ai-setup-hint" className="gap-2 py-3 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader
|
||||
icon={Sparkles}
|
||||
title={t("insights.aiSetupHint.title")}
|
||||
right={
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={dismiss}
|
||||
aria-label={t("insights.aiSetupHint.dismiss")}
|
||||
title={t("insights.aiSetupHint.dismiss")}
|
||||
data-slot="ai-setup-hint-dismiss"
|
||||
className="-my-2 shrink-0"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-start gap-3">
|
||||
<p className="text-foreground text-sm">
|
||||
{t("insights.aiSetupHint.body")}
|
||||
</p>
|
||||
<Button
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link href="/settings/ai" data-slot="ai-setup-hint-link">
|
||||
{t("insights.aiSetupHint.action")}
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AlertTriangle, Activity } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
|
||||
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
|
||||
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
|
||||
@@ -172,11 +173,32 @@ export function CoincidentDeviationCard({
|
||||
className,
|
||||
}: CoincidentDeviationCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { data } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
|
||||
COINCIDENT_METRIC,
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
// A failed read says so with a retry. Without this branch the card sat on
|
||||
// its skeleton forever, which read as "still loading" rather than failed.
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<section
|
||||
data-slot="coincident-deviation-section"
|
||||
aria-label={t("insights.derived.coincident.cardTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.coincident.cardTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
slot="coincident-deviation-error"
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// CLS-safe placeholder while the single read is in flight.
|
||||
if (!data) {
|
||||
return <CardSkeleton className={className} />;
|
||||
|
||||
@@ -107,21 +107,6 @@ interface DailyBriefingProps {
|
||||
onRegenerate?: () => void;
|
||||
/** Disables the regenerate CTA while a generation is in flight. */
|
||||
regenerating?: boolean;
|
||||
/**
|
||||
* v1.15.20 — no AI provider is configured anywhere, so generating is
|
||||
* futile. The empty state swaps the regenerate CTA for a quiet hint
|
||||
* linking to Settings → AI instead of an eternal "preparing" loop.
|
||||
*/
|
||||
noProvider?: boolean;
|
||||
/**
|
||||
* v1.18.9 (#4) — no AI provider configured WHILE a (stale) cached
|
||||
* briefing is still shown. The read path serves the last good briefing
|
||||
* regardless of provider state, so a provider-less account keeps seeing
|
||||
* a days-old briefing. When true, the footer pairs the honest relative
|
||||
* age with a discreet "this can't refresh — connect a provider" hint so
|
||||
* the staleness reads as intentional, not as a live report.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* v1.25 — the most recent generation attempt failed (provider timeout /
|
||||
* error). The briefing keeps its last good text, so this never blanks a
|
||||
@@ -365,8 +350,6 @@ export function DailyBriefing({
|
||||
loading = false,
|
||||
onRegenerate,
|
||||
regenerating = false,
|
||||
noProvider = false,
|
||||
noProviderStale = false,
|
||||
generationFailed = false,
|
||||
generationFailureClass = null,
|
||||
omittedReason = null,
|
||||
@@ -482,7 +465,7 @@ export function DailyBriefing({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(updatedAt || noProviderStale || generationFailed) && (
|
||||
{(updatedAt || generationFailed) && (
|
||||
<div className="border-border/60 space-y-1.5 border-t pt-3">
|
||||
{updatedAt && (
|
||||
<p
|
||||
@@ -498,33 +481,12 @@ export function DailyBriefing({
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — a stale briefing that can never refresh
|
||||
because no AI provider is connected. State that plainly
|
||||
and point at Settings → AI, so the days-old read is
|
||||
understood as held, not presented as current. */}
|
||||
{noProviderStale && (
|
||||
<p
|
||||
data-slot="daily-briefing-stale-no-provider"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
>
|
||||
<span>
|
||||
{t("insights.dailyBriefing.staleNoProviderHint")}
|
||||
</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="daily-briefing-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{/* v1.25 — the held briefing is shown, but the last refresh
|
||||
attempt failed (provider timeout / error). State it plainly
|
||||
and offer a retry so the staleness reads as held, not
|
||||
current. Suppressed when no provider is configured (that
|
||||
hint owns the footer) — a retry would be futile there. */}
|
||||
{generationFailed && !noProviderStale && (
|
||||
current. The card only mounts while the briefing
|
||||
capability is available, so a provider exists. */}
|
||||
{generationFailed && (
|
||||
<p
|
||||
data-slot="daily-briefing-refresh-failed"
|
||||
className="text-muted-foreground flex flex-wrap items-center justify-end gap-x-1.5 gap-y-1 text-right text-xs"
|
||||
@@ -546,29 +508,6 @@ export function DailyBriefing({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : noProvider ? (
|
||||
// v1.15.20 — no provider configured anywhere: a regenerate CTA
|
||||
// would 422 forever, so point at Settings → AI instead.
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-no-provider"
|
||||
variant="plain"
|
||||
icon={<Sparkles className="size-5" />}
|
||||
title={t("insights.dailyBriefing.noProviderTitle")}
|
||||
description={t("insights.dailyBriefing.noProviderDescription")}
|
||||
action={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
data-slot="daily-briefing-no-provider-cta"
|
||||
>
|
||||
<Link href="/settings/ai">
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
data-slot="daily-briefing-empty"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useDerivedMetric } from "./use-derived-metric";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { InsightStatusCard } from "@/components/insights/insight-status-card";
|
||||
import type {
|
||||
ReadinessValue,
|
||||
@@ -161,8 +162,21 @@ export function CompositeScoreAnatomy({
|
||||
</>
|
||||
);
|
||||
|
||||
if (query.isError) {
|
||||
// A failed read is not "insufficient data": the score may well exist.
|
||||
// Say it failed and offer a retry instead of an empty anatomy.
|
||||
return (
|
||||
<div className={className}>
|
||||
<QueryErrorRow
|
||||
slot="composite-anatomy-error"
|
||||
onRetry={() => void query.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
// Network/abort fallback — render the insufficient state honestly.
|
||||
// Settled without a payload — render the insufficient state honestly.
|
||||
return (
|
||||
<ScoreAnatomyView
|
||||
title={title}
|
||||
@@ -276,6 +290,9 @@ export function CompositeScoreAnatomy({
|
||||
icon={<Sparkles className="h-5 w-5" />}
|
||||
text={assessment.text}
|
||||
hasProvider
|
||||
// Composed from the numbers; the server swaps in model text only
|
||||
// while `statusText` is available, so the card shows either way.
|
||||
aiAuthored={false}
|
||||
updatedAt={assessment.updatedAt}
|
||||
// The outbound edge. Same shared opener + auto-send hand-off the
|
||||
// metric pages use, so the answer lands directly instead of only
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import {
|
||||
useTranslations,
|
||||
@@ -65,15 +64,6 @@ interface HeroStripProps {
|
||||
* Defaults to `new Date()`. Production callers omit this.
|
||||
*/
|
||||
now?: Date;
|
||||
/**
|
||||
* v1.18.9 (#4) — true when the hero subtitle is drawn from a cached
|
||||
* briefing that can never refresh because no AI provider is connected.
|
||||
* The meta row then appends a discreet "connect a provider" link beside
|
||||
* the "Generated <relative>" age, so the score-area context reads as
|
||||
* honestly held rather than current. Consistent with the DailyBriefing
|
||||
* footer hint below.
|
||||
*/
|
||||
noProviderStale?: boolean;
|
||||
/**
|
||||
* The Health Score report. Non-null paints the right column; null with
|
||||
* `scorePending` paints the reserve; null without it keeps the band
|
||||
@@ -117,7 +107,6 @@ export function HeroStrip({
|
||||
updatedAt,
|
||||
userName,
|
||||
now,
|
||||
noProviderStale = false,
|
||||
healthScore = null,
|
||||
scorePending = false,
|
||||
tension = null,
|
||||
@@ -222,30 +211,6 @@ export function HeroStrip({
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{/* v1.18.9 (#4) — the subtitle is a cached briefing that can
|
||||
never refresh (no AI provider). Append a discreet hint +
|
||||
Settings → AI link so the relative age above reads as
|
||||
intentionally held, consistent with the briefing card. */}
|
||||
{noProviderStale && (
|
||||
<>
|
||||
<span aria-hidden="true" className="opacity-50">
|
||||
·
|
||||
</span>
|
||||
<span
|
||||
data-slot="insights-hero-strip-stale-no-provider"
|
||||
className="inline-flex flex-wrap items-center gap-x-1.5"
|
||||
>
|
||||
<span>{t("insights.dailyBriefing.staleNoProviderHint")}</span>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insights-hero-strip-stale-no-provider-link"
|
||||
className="text-foreground/80 hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("insights.dailyBriefing.noProviderAction")}
|
||||
</Link>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -14,7 +12,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
|
||||
import { stripChartTokens } from "@/lib/insights/chart-tokens";
|
||||
import { ProseBlocks } from "@/components/insights/prose-blocks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import type { AssessmentStatus } from "@/lib/insights/status-shared";
|
||||
@@ -83,6 +81,14 @@ interface InsightStatusCardProps {
|
||||
* instead of only seeding the composer. Defaults to false.
|
||||
*/
|
||||
coachAutoSend?: boolean;
|
||||
/**
|
||||
* Whether the card's text is written by a model. True for every status
|
||||
* note, which only shows while the `statusText` capability is available.
|
||||
* The score anatomy passes false: its assessment is composed from the
|
||||
* numbers and only upgraded to model text by the server, so it shows
|
||||
* whatever the AI state.
|
||||
*/
|
||||
aiAuthored?: boolean;
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────
|
||||
@@ -99,13 +105,15 @@ export function InsightStatusCard({
|
||||
coachQuestion,
|
||||
coachScope,
|
||||
coachAutoSend,
|
||||
aiAuthored = true,
|
||||
}: InsightStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const flags = useFeatureFlags();
|
||||
// v1.4.31 — operator can hide every per-metric status card
|
||||
// app-wide. The delta number stays; the LLM narration card is
|
||||
// suppressed in full so the layout collapses naturally.
|
||||
if (!flags.insightStatus) return null;
|
||||
const statusText = useAiCapability("statusText");
|
||||
// A status note is model-written text, so the card is hidden, never an
|
||||
// error or a "connect a provider" hint, while the `statusText` capability
|
||||
// is unavailable for any reason. The numbers around it stay; the layout
|
||||
// collapses naturally.
|
||||
if (aiAuthored && !statusText.available) return null;
|
||||
|
||||
const displayText =
|
||||
assessment?.kind === "generated" ||
|
||||
@@ -213,31 +221,6 @@ export function InsightStatusCard({
|
||||
);
|
||||
}
|
||||
|
||||
const isNoProvider =
|
||||
assessment?.kind === "no-provider" ||
|
||||
(!assessment && !hasProvider && !displayText);
|
||||
if (isNoProvider) {
|
||||
return (
|
||||
<Card className="gap-2 py-3 opacity-80 md:py-4">
|
||||
<CardHeader>
|
||||
<TileHeader icon={nodeIcon(icon)} title={title} />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1.5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("insights.noProviderConfigured")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/ai"
|
||||
data-slot="insight-status-no-provider-cta"
|
||||
className="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
>
|
||||
{t("insights.noProviderAction")}
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!displayText) {
|
||||
return (
|
||||
<Card className="gap-2 py-3 md:py-4">
|
||||
|
||||
@@ -5,7 +5,8 @@ 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 { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useSurfaceVisible } from "@/hooks/use-surface-visible";
|
||||
import { useWorkouts } from "@/hooks/use-workouts";
|
||||
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
|
||||
import {
|
||||
@@ -52,26 +53,18 @@ interface ComprehensivePayload {
|
||||
|
||||
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();
|
||||
// The briefing read and the regenerate button follow the `briefing`
|
||||
// capability on `/api/auth/me`: the server resolves the operator's switch,
|
||||
// the person's AI analysis opt-out, provider presence and consent into one
|
||||
// answer, and the strip offers regenerate only while that answer is yes.
|
||||
// Unavailable while `/me` loads, so nothing fires before the answer lands.
|
||||
const briefing = useAiCapability("briefing");
|
||||
// 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 advisorEnabled = mounted && isAuthenticated && briefing.available;
|
||||
|
||||
const advisor = useInsightsAdvisorQuery(advisorEnabled);
|
||||
|
||||
@@ -104,7 +97,10 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
|
||||
// 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 });
|
||||
// Only while the Workouts module shows: a switched-off module is never
|
||||
// probed (its pill is hidden by the surface map regardless).
|
||||
const workoutsVisible = useSurfaceVisible("insights-page:workouts");
|
||||
const workoutsProbe = useWorkouts({ limit: 1, enabled: workoutsVisible });
|
||||
|
||||
// v1.29 — nutrients gate. `nutrients` is opt-in (default off), unlike
|
||||
// the always-on metric clusters, so the pill's floor is the module
|
||||
|
||||
@@ -82,7 +82,8 @@ export interface InsightsTabStripProps {
|
||||
* v1.15.18 — the outcome of the last settled regenerate. The falling-edge
|
||||
* toast fires "refreshed" ONLY when this is `"fresh"`; a slow generation the
|
||||
* client gave up on (`"timeout"`) shows a "still working" hint instead of a
|
||||
* misleading success, and a missing provider (`"no-provider"`) stays silent
|
||||
* misleading success, and a missing provider (`"no-provider"`) or a refused
|
||||
* briefing (`"unavailable"`) stays silent
|
||||
* (the surface already shows the connect-AI empty state). v1.15.20 —
|
||||
* `"rate-limited"` (quota exhausted) and `"empty"` (transient 503) show
|
||||
* their own honest hints instead of the success toast. Absent (legacy
|
||||
@@ -94,6 +95,7 @@ export interface InsightsTabStripProps {
|
||||
| "rate-limited"
|
||||
| "timeout"
|
||||
| "no-provider"
|
||||
| "unavailable"
|
||||
| "settle-failed"
|
||||
| null;
|
||||
/**
|
||||
@@ -649,6 +651,7 @@ export function regenerateToastKind(
|
||||
case "settle-failed":
|
||||
return { kind: "error", messageKey: "insights.regenerateSettleFailed" };
|
||||
case "no-provider":
|
||||
case "unavailable":
|
||||
return null;
|
||||
default:
|
||||
return { kind: "success", messageKey: "insights.regenerateSuccess" };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { TrendingUp } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
@@ -43,16 +44,15 @@ export function MoodWhatStandsOut({
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsCorrelations(),
|
||||
queryFn: async () => {
|
||||
// 403 = operator disabled the surface; any rejection (ApiError)
|
||||
// degrades to the card's isError → render-nothing path.
|
||||
// Correlations are statistics, never gated on AI; a rejection is a
|
||||
// real failure and renders the error row with a retry.
|
||||
return apiGet<CorrelationDiscoveryResponse>("/api/insights/correlations");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60_000,
|
||||
// The surface is optional — don't retry a deliberate 403 into noise.
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -79,13 +79,11 @@ export function MoodWhatStandsOut({
|
||||
/>
|
||||
) : null}
|
||||
{isError ? (
|
||||
<p
|
||||
data-slot="mood-discovery-error"
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
>
|
||||
{t("insights.pattern.loadError")}
|
||||
</p>
|
||||
<QueryErrorRow
|
||||
slot="mood-discovery-error"
|
||||
message={t("insights.pattern.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !isError && data && !hasDiscovered ? (
|
||||
<p
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactNode } from "react";
|
||||
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* One Insights overview section, fenced.
|
||||
*
|
||||
* The overview is a stack of independent sections, each with its own read.
|
||||
* A section that fails to load already shows its own error row; this fence
|
||||
* covers the other failure, a section that throws while rendering (a payload
|
||||
* shape it did not expect, a chunk that no longer exists after a deploy).
|
||||
* Without it the throw reaches the route boundary and replaces the whole
|
||||
* page, so one broken card takes the hero, the scores and every other
|
||||
* section down with it. Inside the fence the failed section becomes one row
|
||||
* with a retry, and the rest of the page stays.
|
||||
*
|
||||
* Retry clears the fence and renders the section again; a section whose
|
||||
* failure was transient comes back, one whose failure is permanent shows the
|
||||
* row again.
|
||||
*/
|
||||
interface FenceProps {
|
||||
sectionId: string;
|
||||
message: string;
|
||||
retryLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface FenceState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
class SectionFence extends Component<FenceProps, FenceState> {
|
||||
state: FenceState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): FenceState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Same hand-off the chart boundary uses: the section degrades quietly,
|
||||
// the failure still reaches the client error tracker.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (!this.state.failed) return this.props.children;
|
||||
return (
|
||||
<section
|
||||
data-slot="insights-overview-section-error"
|
||||
data-section={this.props.sectionId}
|
||||
>
|
||||
<QueryErrorRow
|
||||
message={this.props.message}
|
||||
retryLabel={this.props.retryLabel}
|
||||
onRetry={() => this.setState({ failed: false })}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function OverviewSectionBoundary({
|
||||
sectionId,
|
||||
children,
|
||||
}: {
|
||||
sectionId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SectionFence
|
||||
sectionId={sectionId}
|
||||
message={t("insights.sectionLoadError")}
|
||||
retryLabel={t("common.retry")}
|
||||
>
|
||||
{children}
|
||||
</SectionFence>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
|
||||
/**
|
||||
* v1.10.0 — device-flagged event awareness surface (categorical events,
|
||||
@@ -89,7 +90,7 @@ export function RhythmEventsCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.insightsRhythmEvents(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
@@ -103,6 +104,28 @@ export function RhythmEventsCard({
|
||||
enabled: enabled && isAuthenticated,
|
||||
});
|
||||
|
||||
// A failed read is said, never swallowed: this card carries health alerts,
|
||||
// and silently rendering nothing on an error would hide them.
|
||||
if (isError && !data) {
|
||||
return (
|
||||
<section
|
||||
data-slot="rhythm-events-section"
|
||||
aria-label={t("insights.rhythmEvents.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.rhythmEvents.sectionTitle")}
|
||||
/>
|
||||
<QueryErrorRow
|
||||
slot="rhythm-events-error"
|
||||
message={t("insights.rhythmEvents.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Data-availability gate — never paint an empty / alarming card.
|
||||
if (isLoading || !data || !data.hasEvents) return null;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
} from "@/lib/ai/schema";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { apiFetchRaw } from "@/lib/api/api-fetch";
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import type { BriefingFailureClass } from "@/lib/insights/briefing-failure-marker";
|
||||
|
||||
/**
|
||||
@@ -245,9 +246,21 @@ export function nextAdvisorPollInterval(
|
||||
* regenerate quota is exhausted, which deserves a "try again later"
|
||||
* hint rather than the success toast the old lump produced. `empty` now
|
||||
* means only the transient 503 surface (provider chain unavailable).
|
||||
*
|
||||
* `unavailable` (403) is the server refusing the briefing capability: the
|
||||
* operator's switch, the person's opt-out, or a consent the chain needs. The
|
||||
* web only asks while `/api/auth/me` says the capability is available, so this
|
||||
* is the backstop for the moment between a switch flipping and `/me`
|
||||
* refreshing. It settles like the other expected refusals instead of
|
||||
* throwing, so the briefing card never turns into an error.
|
||||
*/
|
||||
export type AdvisorFetchOutcome =
|
||||
"fresh" | "empty" | "rate-limited" | "timeout" | "no-provider";
|
||||
| "fresh"
|
||||
| "empty"
|
||||
| "rate-limited"
|
||||
| "timeout"
|
||||
| "no-provider"
|
||||
| "unavailable";
|
||||
|
||||
interface AdvisorFetchResult {
|
||||
payload: InsightAdvisorPayload | null;
|
||||
@@ -316,6 +329,9 @@ export async function fetchAdvisor(
|
||||
if (res.status === 503) {
|
||||
return { payload: null, outcome: "empty" };
|
||||
}
|
||||
if (res.status === 403) {
|
||||
return { payload: null, outcome: "unavailable" };
|
||||
}
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
@@ -384,24 +400,6 @@ export interface UseInsightsAdvisorResult {
|
||||
* assessment is still being prepared; NO toast fires until this falls.
|
||||
*/
|
||||
regenerateSettling: boolean;
|
||||
/**
|
||||
* v1.15.20 — the outcome of the last settled READ. Lets surfaces
|
||||
* distinguish "no briefing yet, a generate could help" (`empty` /
|
||||
* `timeout`) from "no provider configured, generating is futile"
|
||||
* (`no-provider`) and render a connect-AI hint instead of a dead
|
||||
* regenerate CTA.
|
||||
*/
|
||||
readOutcome: AdvisorFetchOutcome | null;
|
||||
/**
|
||||
* v1.18.9 (#4) — false when the GET reported no usable AI provider. The
|
||||
* read path still serves the last cached briefing (no provider is
|
||||
* needed to read the cache), so this is the ONLY honest signal that a
|
||||
* shown-but-stale briefing can never refresh. Surfaces pair it with the
|
||||
* relative-age line to add a discreet connect-provider hint. Defaults
|
||||
* true (no hint) when the field is absent — a pre-field cached payload
|
||||
* or an unsettled query.
|
||||
*/
|
||||
hasProvider: boolean;
|
||||
/**
|
||||
* v1.25 — true when the GET reported the last generation attempt failed.
|
||||
* Pairs a shown-but-held briefing with a discreet "couldn't refresh" hint,
|
||||
@@ -427,10 +425,17 @@ export interface UseInsightsAdvisorResult {
|
||||
/**
|
||||
* Read-only consumer for the advisor payload. Use this on surfaces that
|
||||
* just want to render the cached insight (e.g. dashboard preview).
|
||||
*
|
||||
* The read only fires while the `briefing` capability on `/api/auth/me` is
|
||||
* available, whatever the caller passes: every consumer (the overview, the
|
||||
* tab strip) gets the same gate, and none can re-enable the read behind the
|
||||
* other's back. While `/me` loads the capability reads unavailable, so the
|
||||
* first paint never fires a request only to have it refused.
|
||||
*/
|
||||
export function useInsightsAdvisorQuery(
|
||||
enabled: boolean,
|
||||
): UseInsightsAdvisorResult {
|
||||
const briefing = useAiCapability("briefing");
|
||||
const queryClient = useQueryClient();
|
||||
// Refs #786 — settling state for a timed-out force regenerate (see the
|
||||
// contract comment above `AdvisorSettleState`). `settleOutcome` carries the
|
||||
@@ -445,7 +450,7 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome) so the read path can surface WHY a payload is missing
|
||||
// (`no-provider` → connect-AI hint instead of a dead regenerate CTA).
|
||||
queryFn: () => fetchAdvisor(),
|
||||
enabled,
|
||||
enabled: enabled && briefing.available,
|
||||
// 24h cache window matches the server-side `insightsCachedAt` TTL.
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
@@ -577,11 +582,6 @@ export function useInsightsAdvisorQuery(
|
||||
// outcome ("timeout" while settling is an interim state, not a result).
|
||||
regenerateOutcome: settleOutcome ?? mutation.data?.outcome ?? null,
|
||||
regenerateSettling: settling !== null,
|
||||
readOutcome: query.data?.outcome ?? null,
|
||||
// Absent field (pre-v1.18.9 cached payload, or query still settling) →
|
||||
// assume a provider is present so a transient unknown never flashes a
|
||||
// false "no provider" hint.
|
||||
hasProvider: query.data?.payload?.hasProvider ?? true,
|
||||
// Absent (pre-field cached payload or unsettled query) → not failed, so a
|
||||
// transient unknown never flashes a false "couldn't refresh" hint.
|
||||
generationFailed: query.data?.payload?.generationFailed ?? false,
|
||||
|
||||
@@ -60,9 +60,10 @@ export interface WorkoutDetailDayContextProps {
|
||||
* undated and named for what their targets actually deliver — trends and
|
||||
* history, not "that day".
|
||||
*
|
||||
* Module gating: the intraday tile mounts only when `insights` is on and
|
||||
* the sleep row only when `sleep` is on, so a switched-off module never
|
||||
* paints as a failed request. Mood has no module gate, so the section
|
||||
* Module gating: the sleep row mounts only when `sleep` is on, so a
|
||||
* switched-off module never paints as a failed request. The intraday pulse
|
||||
* tile is heart-rate data and follows no module (the "AI analysis" switch
|
||||
* only governs model-written text). Mood has no module gate, so the section
|
||||
* always carries at least one part and the heading is never an orphan.
|
||||
*/
|
||||
export function WorkoutDetailDayContext({
|
||||
@@ -71,7 +72,6 @@ export function WorkoutDetailDayContext({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const insightsEnabled = useModuleEnabled("insights");
|
||||
const sleepEnabled = useModuleEnabled("sleep");
|
||||
|
||||
const sleep = useSleepNight(workout.dayKey, isAuthenticated && sleepEnabled);
|
||||
@@ -96,12 +96,10 @@ export function WorkoutDetailDayContext({
|
||||
}
|
||||
/>
|
||||
|
||||
{insightsEnabled ? (
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
) : null}
|
||||
<IntradayPulseChart
|
||||
userTimezone={user?.timezone}
|
||||
initialDateKey={workout.dayKey}
|
||||
/>
|
||||
|
||||
<Card className="gap-2 py-3 md:py-4" data-slot="workout-detail-day-card">
|
||||
<CardContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAiCapability } from "@/hooks/use-ai-capability";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry";
|
||||
@@ -295,6 +296,10 @@ const QUERY_KEY_FACTORY: Record<
|
||||
*/
|
||||
export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
// Status notes are model-written: no read while the `statusText`
|
||||
// capability is unavailable (the card is hidden then anyway). Unavailable
|
||||
// while `/me` loads, so the first paint fires nothing.
|
||||
const statusText = useAiCapability("statusText");
|
||||
const { locale } = useTranslations();
|
||||
const queryKey = QUERY_KEY_FACTORY[metric](locale);
|
||||
|
||||
@@ -305,7 +310,7 @@ export function useInsightStatus(metric: InsightStatusMetric) {
|
||||
`/api/insights/${metric}-status?locale=${locale}`,
|
||||
signal,
|
||||
),
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && statusText.available,
|
||||
staleTime: 60 * 1000,
|
||||
// v1.4.28 FB-D2 — the status route returns a deterministic envelope on
|
||||
// a miss/timeout. Retrying inline re-fires work and lengthens the
|
||||
@@ -359,6 +364,7 @@ export function useInsightMetricStatus(
|
||||
enabled = true,
|
||||
) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const statusText = useAiCapability("statusText");
|
||||
const { locale } = useTranslations();
|
||||
const queryKey = queryKeys.insightsMetricStatus(metric, locale);
|
||||
|
||||
@@ -371,7 +377,7 @@ export function useInsightMetricStatus(
|
||||
)}&locale=${locale}`,
|
||||
signal,
|
||||
),
|
||||
enabled: isAuthenticated && enabled,
|
||||
enabled: isAuthenticated && enabled && statusText.available,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
refetchInterval: (query) =>
|
||||
@@ -409,6 +415,7 @@ export function useInsightBiomarkerAssessment(
|
||||
enabled = true,
|
||||
) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const statusText = useAiCapability("statusText");
|
||||
const { locale } = useTranslations();
|
||||
const queryKey = queryKeys.insightsBiomarkerAssessment(biomarkerId, locale);
|
||||
|
||||
@@ -421,7 +428,11 @@ export function useInsightBiomarkerAssessment(
|
||||
)}&locale=${locale}`,
|
||||
signal,
|
||||
),
|
||||
enabled: isAuthenticated && enabled && biomarkerId.length > 0,
|
||||
enabled:
|
||||
isAuthenticated &&
|
||||
enabled &&
|
||||
statusText.available &&
|
||||
biomarkerId.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
retry: 0,
|
||||
refetchInterval: (query) =>
|
||||
|
||||
@@ -70,6 +70,12 @@ export interface UseWorkoutsOptions {
|
||||
since?: string;
|
||||
/** Optional sport-type filter. Pass through the canonical string. */
|
||||
sportType?: string;
|
||||
/**
|
||||
* Whether to read at all. A probe on a surface that shows workouts only
|
||||
* while the Workouts module is on passes the module answer here, so a
|
||||
* switched-off module is never asked for rows. Defaults to true.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
async function fetchWorkouts(
|
||||
@@ -108,7 +114,7 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult {
|
||||
sportType: opts.sportType,
|
||||
}),
|
||||
queryFn: () => fetchWorkouts(opts),
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && opts.enabled !== false,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user