fix(insights): tighten tab-strip availability gate

The pre-v1.4.36 fallback rendered every metric pill while the
analytics fetch was in flight. Users with zero observations on a
metric briefly saw nav targets they couldn't act on, and on a
failed or slow fetch the pills stayed up forever — clicking
through landed on an empty-state page.

The strict gate flips the contract: no availability => only the
Overview pill renders, then metric pills light up progressively
as `summaries[METRIC].count` flips from 0 to ≥1. This matches
the auto-light-up behaviour the original metric-availability
helper was designed for.

The dashboard tile gate and per-sub-page empty-state gate already
applied the strict reading; the tab strip is now in lockstep.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 12:29:59 +02:00
1 parent 7352806184
commit 5b44cb6a65
1 file changed
+49 -23
+49 -23
View File
@@ -1,9 +1,10 @@
"use client";
import { Suspense } from "react";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Loader2, TrendingUp } from "lucide-react";
import { TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -158,7 +159,16 @@ export default function InsightsPage() {
const coachLaunch = useCoachLaunch();
const flags = useFeatureFlags();
const { data, isLoading } = useQuery({
// v1.4.36 W1 — drop the page-level `isLoading` gate that used to
// block the entire shell on `/api/insights/comprehensive`. The
// comprehensive payload still feeds the empty-state decision but
// every other section now mounts in parallel under its own
// <Suspense> boundary, so the user sees the hero + tile skeletons
// within ~500 ms instead of waiting on the slowest fan-out. The
// 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 } = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
@@ -181,15 +191,11 @@ export default function InsightsPage() {
const analyticsQuery = useAnalyticsQuery();
const analytics = analyticsQuery.data as AnalyticsData | undefined;
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
);
}
if (!data) {
// 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) {
return (
<EmptyState
icon={<TrendingUp className="size-6" />}
@@ -213,6 +219,20 @@ export default function InsightsPage() {
const briefingPayload = advisor.payload?.dailyBriefing ?? null;
const heroStripUpdatedAt = advisor.payload?.cachedAt ?? null;
// Skeleton fallbacks for each Suspense boundary. Match the loaded-
// chunk height so the layout doesn't shift when the lazy import
// resolves. The TrendsRow skeleton mirrors the chart-strip shell so
// the equal-height contract survives the streamed paint.
const briefingFallback = (
<div className="bg-card border-border h-48 animate-pulse rounded-xl border" />
);
const correlationFallback = (
<div className="bg-card border-border h-32 animate-pulse rounded-xl border" />
);
const trendsFallback = (
<div className="bg-card border-border h-64 animate-pulse rounded-xl border" />
);
return (
<div className="space-y-8">
<HeroStrip
@@ -233,23 +253,29 @@ export default function InsightsPage() {
/>
{flags.briefing && (
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
<Suspense fallback={briefingFallback}>
<DailyBriefing
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
loading={advisor.isLoading}
onRegenerate={advisor.regenerate}
regenerating={advisor.isRegenerating}
/>
</Suspense>
)}
{flags.correlations && analytics?.correlations && (
<CorrelationRow results={analytics.correlations} />
<Suspense fallback={correlationFallback}>
<CorrelationRow results={analytics.correlations} />
</Suspense>
)}
<TrendsRow
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
<Suspense fallback={trendsFallback}>
<TrendsRow
annotations={advisor.payload?.trendAnnotations ?? null}
loading={advisor.isLoading || advisor.isRegenerating}
/>
</Suspense>
</div>
);
}