mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user