mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(charts): degrade chart failures to one card and heal stale shells per version
The update poller and the chunk-error reload guard key on the target version instead of disarming for the whole session, so a second deploy re-arms the self-heal and a stale shell can no longer strand with 404ing chunks. Lazy chart imports retry once and mount inside a per-card error boundary with a reload affordance — a failed chunk degrades to one card instead of replacing the page. The JSON API client attaches a fifteen-second timeout unless the caller brings its own signal (the raw streaming path is exempt), chart queries surface errors with a retry instead of rendering as an empty period, per-type fetch failures reject instead of caching an empty series as fresh, and the session and snapshot queries retry once on transient network or server errors.
This commit is contained in:
1 parent
b346e78cc7
commit
b5e799c2d6
27 files changed
+892
-89
No files matched your search
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Unit tests for the version-poller reload decision.
|
||||
*
|
||||
* The guard is keyed on the TARGET (live) version: a repeat mismatch
|
||||
* against the SAME version is suppressed (no reload loop on a misset
|
||||
* server), but a SECOND deploy in one session moves the live version
|
||||
* past the recorded one and the heal flow re-arms. The pre-v1.16.8
|
||||
* guard disabled all polling after one attempt, which stranded stale
|
||||
* shells on multi-deploy days.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveVersionPollDecision } from "../version-poller";
|
||||
|
||||
describe("resolveVersionPollDecision", () => {
|
||||
it("is up-to-date when live matches the shell", () => {
|
||||
expect(resolveVersionPollDecision("1.16.8", "1.16.8", null)).toBe(
|
||||
"up-to-date",
|
||||
);
|
||||
});
|
||||
|
||||
it("is up-to-date when the live version cannot be read", () => {
|
||||
expect(resolveVersionPollDecision(null, "1.16.8", null)).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("reloads on a mismatch with no prior attempt", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", null)).toBe("reload");
|
||||
});
|
||||
|
||||
it("suppresses a repeat reload for the SAME target version", () => {
|
||||
expect(resolveVersionPollDecision("1.16.9", "1.16.8", "1.16.9")).toBe(
|
||||
"already-attempted",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-arms when a SECOND deploy moves the live version past the recorded one", () => {
|
||||
expect(resolveVersionPollDecision("1.16.10", "1.16.8", "1.16.9")).toBe(
|
||||
"reload",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats a legacy timestamp guard value as no attempt", () => {
|
||||
// Pre-v1.16.8 sessions stored `String(Date.now())`; it never matches
|
||||
// a real version string, so a stranded session heals on its next poll.
|
||||
expect(
|
||||
resolveVersionPollDecision("1.16.9", "1.16.8", "1765400000000"),
|
||||
).toBe("reload");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
// vi.doMock below invalidates the module registry, so the re-imported
|
||||
// provider gets a fresh (unprimed) locale cache — pass the DE bundle
|
||||
// explicitly instead of relying on the vitest.setup.ts seeding.
|
||||
import deMessages from "../../../../messages/de.json";
|
||||
|
||||
/**
|
||||
* v1.16.8 — failed chart queries must look like ERRORS, not "no data".
|
||||
*
|
||||
* Pre-fix all three dashboard charts destructured only
|
||||
* `{ data, isLoading }`: a failed query fell into the empty-state copy
|
||||
* ("Keine Daten in diesem Zeitraum" / "Keine Daten verfügbar") — and
|
||||
* the mood chart unmounted its card entirely. The fix surfaces
|
||||
* `isError` as a `<ChartErrorState>` card with a retry affordance in
|
||||
* each chart, keeping the card chrome mounted so the dashboard
|
||||
* composition stays intact.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
user: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockQueryError() {
|
||||
vi.doMock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch: () => Promise.resolve(),
|
||||
}),
|
||||
useQueryClient: () => ({
|
||||
cancelQueries: () => Promise.resolve(),
|
||||
getQueryData: () => undefined,
|
||||
setQueryData: () => undefined,
|
||||
invalidateQueries: () => Promise.resolve(),
|
||||
}),
|
||||
useMutation: () => ({ mutate: () => undefined, isPending: false }),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("chart isError rendering — error state, not empty state", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("<HealthChart> paints the error card with retry, keeps the card chrome", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { HealthChart } = await import("../health-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
// Card chrome must stay mounted.
|
||||
expect(html).toContain("Pulse");
|
||||
// The empty-state copy must NOT paint — an outage is not "no data".
|
||||
expect(html).not.toContain("Keine Daten in diesem Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MoodChart> keeps the card mounted and paints the error card", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MoodChart } = await import("../mood-chart");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MoodChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
// Pre-fix the component returned null here — html was empty.
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
|
||||
it("<MedicationComplianceChart> paints the error card, not the no-data hint", async () => {
|
||||
mockQueryError();
|
||||
|
||||
const { I18nProvider } = await import("@/lib/i18n/context");
|
||||
const { MedicationComplianceChart } = await import(
|
||||
"../medication-compliance-chart"
|
||||
);
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de" initialMessages={deMessages}>
|
||||
<MedicationComplianceChart />
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain('data-slot="chart-error-state"');
|
||||
expect(html).toContain("Daten konnten nicht geladen werden");
|
||||
expect(html).toContain("Erneut versuchen");
|
||||
expect(html).not.toContain("Keine Daten im gewählten Zeitraum");
|
||||
|
||||
vi.doUnmock("@tanstack/react-query");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ReactElement, type ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* Error-state card + per-chart error boundary.
|
||||
*
|
||||
* Two failure classes used to look like something else:
|
||||
* - A failed data query fell into the "no data in this range" empty
|
||||
* state (the queries swallowed per-type failures as empty success),
|
||||
* so an outage read as "you have no measurements".
|
||||
* - A rejected lazy chunk import (stale shell after a deploy) bubbled
|
||||
* to the route-level `error.tsx` and replaced the WHOLE page.
|
||||
*
|
||||
* `<ChartErrorState>` is the shared presentational card — same dashed
|
||||
* footprint as `<ChartEmptyState>` so the dashboard layout never
|
||||
* reflows — with a compact action button. `<ChartErrorBoundary>` wraps
|
||||
* each lazy chart mount so a failed chunk degrades to one card with a
|
||||
* reload affordance instead of taking the page down.
|
||||
*/
|
||||
|
||||
export interface ChartErrorStateProps {
|
||||
/** Translated headline, e.g. "Data could not be loaded". */
|
||||
title: string;
|
||||
/** Translated action-button label, e.g. "Retry". */
|
||||
actionLabel: string;
|
||||
/** Action handler — query `refetch` or a full page reload. */
|
||||
onAction: () => void;
|
||||
/**
|
||||
* Optional explicit height in pixels to match the surrounding chart
|
||||
* card. Defaults to 240 (matches `<ChartEmptyState>`).
|
||||
*/
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function ChartErrorState({
|
||||
title,
|
||||
actionLabel,
|
||||
onAction,
|
||||
height = 240,
|
||||
}: ChartErrorStateProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-error-state"
|
||||
className="border-border/40 bg-muted/10 text-muted-foreground flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed px-6 text-center"
|
||||
style={{ height }}
|
||||
>
|
||||
<AlertTriangle
|
||||
className="text-muted-foreground/60 h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<Button variant="outline" size="sm" onClick={onAction}>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BoundaryProps {
|
||||
fallback: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface BoundaryState {
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
class Boundary extends Component<BoundaryProps, BoundaryState> {
|
||||
state: BoundaryState = { failed: false };
|
||||
|
||||
static getDerivedStateFromError(): BoundaryState {
|
||||
return { failed: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
// Surface to the client-side error tracker the same way the
|
||||
// route-level boundary does — the card degrades quietly but the
|
||||
// failure still lands in monitoring.
|
||||
if (typeof window !== "undefined") {
|
||||
const g = window as typeof window & {
|
||||
__healthlog_onError?: (err: Error) => void;
|
||||
};
|
||||
g.__healthlog_onError?.(error);
|
||||
}
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
return this.state.failed ? this.props.fallback : this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card boundary for lazy chart mounts. A chunk that still rejects
|
||||
* after the `importWithRetry` attempt (see `src/lib/retry-import.ts`)
|
||||
* renders this card instead of bubbling to the route boundary; the
|
||||
* reload affordance fetches the fresh shell + chunk graph (a rejected
|
||||
* lazy import is cached, so a remount alone cannot recover).
|
||||
*/
|
||||
export function ChartErrorBoundary({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<Boundary
|
||||
fallback={
|
||||
<ChartErrorState
|
||||
title={t("charts.loadErrorTitle")}
|
||||
actionLabel={t("charts.loadErrorReload")}
|
||||
onAction={() => window.location.reload()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Boundary>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ComponentProps, ReactElement } from "react";
|
||||
|
||||
import { ChartErrorBoundary } from "@/components/charts/chart-error-state";
|
||||
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
import { importWithRetry } from "@/lib/retry-import";
|
||||
|
||||
/**
|
||||
* v1.4.28 (BK-F-M2) — single `next/dynamic` boundary for the
|
||||
@@ -17,17 +20,33 @@ import { ChartSkeleton } from "@/components/charts/chart-skeleton";
|
||||
*
|
||||
* Re-exporting through this module gives every consumer the same
|
||||
* pre-configured `<ChartSkeleton>`-backed lazy boundary. Consumers
|
||||
* still forward the same props they did before — the type contract
|
||||
* mirrors `<HealthChart>` exactly via `dynamic()`'s own inference.
|
||||
* still forward the same props they did before.
|
||||
*
|
||||
* v1.16.8 — the loader retries a rejected chunk import once (a lazy
|
||||
* import caches its rejection permanently, so a transient 404 from a
|
||||
* stale shell used to brick the card for the session) and every mount
|
||||
* wraps in `<ChartErrorBoundary>` so a chunk that still fails degrades
|
||||
* to ONE error card with a reload affordance instead of bubbling to the
|
||||
* route-level `error.tsx`.
|
||||
*/
|
||||
export const HealthChartDynamic = dynamic(
|
||||
const HealthChartLazy = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamic(
|
||||
props: ComponentProps<typeof HealthChartLazy>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazy {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.36 W2 — mini variant for the trends-row 140 px chart slot. Same
|
||||
* underlying `<HealthChart>` import, but the loading shell renders the
|
||||
@@ -38,10 +57,20 @@ export const HealthChartDynamic = dynamic(
|
||||
* `trends-row.tsx`) pass `mini` on the chart itself; this wrapper
|
||||
* ensures the next/dynamic loader paints the matching skeleton.
|
||||
*/
|
||||
export const HealthChartDynamicMini = dynamic(
|
||||
const HealthChartLazyMini = dynamic(
|
||||
() =>
|
||||
import("@/components/charts/health-chart").then((mod) => ({
|
||||
default: mod.HealthChart,
|
||||
})),
|
||||
importWithRetry(() => import("@/components/charts/health-chart")).then(
|
||||
(mod) => ({ default: mod.HealthChart }),
|
||||
),
|
||||
{ ssr: false, loading: () => <ChartSkeleton mini /> },
|
||||
);
|
||||
|
||||
export function HealthChartDynamicMini(
|
||||
props: ComponentProps<typeof HealthChartLazyMini>,
|
||||
): ReactElement {
|
||||
return (
|
||||
<ChartErrorBoundary>
|
||||
<HealthChartLazyMini {...props} />
|
||||
</ChartErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { computePaddedYDomain } from "@/lib/insights/chart-y-domain";
|
||||
@@ -631,7 +632,7 @@ export function HealthChart({
|
||||
};
|
||||
}, [rangePoints, effectiveCompareBaseline]);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
|
||||
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
|
||||
// and a fresh measurement evicts every per-chart daily cache in one
|
||||
@@ -708,16 +709,14 @@ export function HealthChart({
|
||||
"days",
|
||||
String(Math.max(1, Math.min(3650, Math.ceil(fetchWindow.windowDays)))),
|
||||
);
|
||||
let points: Array<{ at: string; value: number }>;
|
||||
try {
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
points = data?.points ?? [];
|
||||
} catch {
|
||||
// Sleep overlay is additive — skip it on failure, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed fetch rejects the whole query instead of
|
||||
// silently resolving an empty series: the swallowed-catch
|
||||
// variant cached the empty result as fresh success, so an
|
||||
// outage painted "no data in this range" with no retry path.
|
||||
const data = await apiGet<{
|
||||
points?: Array<{ at: string; value: number }>;
|
||||
}>(`/api/measurements/series?${sleepParams}`);
|
||||
const points = data?.points ?? [];
|
||||
for (const point of points) {
|
||||
const value = point.value * valueScale;
|
||||
if (value == null || !Number.isFinite(value)) continue;
|
||||
@@ -773,16 +772,16 @@ export function HealthChart({
|
||||
typeParams.set("source", "rollup");
|
||||
}
|
||||
|
||||
let page: MeasurementApiRow[];
|
||||
try {
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
page = data?.measurements ?? [];
|
||||
} catch {
|
||||
// Per-type fetch failure degrades that series only, as before.
|
||||
return;
|
||||
}
|
||||
// v1.16.8 — a failed per-type fetch rejects the whole query
|
||||
// instead of silently dropping the series: the swallowed-catch
|
||||
// variant cached the partial/empty result as fresh success, so
|
||||
// an outage painted the empty state (or a half chart) with no
|
||||
// retry path. Rejecting lets TanStack's retry semantics and the
|
||||
// in-card error state below do their jobs.
|
||||
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
|
||||
`/api/measurements?${typeParams}`,
|
||||
);
|
||||
const page = data?.measurements ?? [];
|
||||
|
||||
for (const measurement of page) {
|
||||
// v1.7.0 — fold the display-time scale into the raw value at
|
||||
@@ -1545,6 +1544,16 @@ export function HealthChart({
|
||||
// shared-reveal overlay and every insights mount stay
|
||||
// layout-shift-free.
|
||||
<Skeleton className={`w-full ${chartHeightClass}`} />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data in this range" empty state.
|
||||
// Pre-fix `isError` fell through to the empty branch and an
|
||||
// outage read as "you have no measurements".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
// v1.4.43 W11-M6 — empty-window state.
|
||||
//
|
||||
|
||||
@@ -56,6 +56,7 @@ import { makeFormatters } from "@/lib/format-locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { ChartOverlayControls } from "./chart-overlay-controls";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { useChartOverlayPrefs } from "@/hooks/use-chart-overlay-prefs";
|
||||
@@ -242,7 +243,7 @@ export function MedicationComplianceChart({
|
||||
// `["dashboard-medication-compliance"]` lands in
|
||||
// `medicationDependentKeys` and an intake POST refreshes the chart
|
||||
// immediately rather than waiting for `staleTime` (audit L4).
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.dashboardMedicationCompliance(days),
|
||||
queryFn: async (): Promise<DailyCompliancePoint[]> => {
|
||||
return apiGet<DailyCompliancePoint[]>(`/api/medications/intake?scope=compliance&days=${days}`);
|
||||
@@ -415,6 +416,16 @@ export function MedicationComplianceChart({
|
||||
// `h-48` spinner box; matches the painted chart body below so
|
||||
// the card never jumps when the data lands.
|
||||
<Skeleton className="h-[var(--chart-height,240px)] w-full md:h-[var(--chart-height-md,280px)]" />
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance, not as the "no data" hint. Pre-fix `isError` fell
|
||||
// through to the empty branch (data undefined → hasData false)
|
||||
// and an outage read as "no doses tracked".
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
/>
|
||||
) : !hasData ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from "@/lib/charts/bucket-time-series";
|
||||
import { RichChartTooltip, type RichTooltipRow } from "./chart-tooltip";
|
||||
import { ChartEmptyState } from "./chart-empty-state";
|
||||
import { ChartErrorState } from "./chart-error-state";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
import { shiftDailySeriesForward } from "@/lib/charts/comparison-shift";
|
||||
import type {
|
||||
@@ -346,7 +347,7 @@ export function MoodChart({
|
||||
// cache fresh; the chart's own subscription becomes the cache writer
|
||||
// for any insights / mood sub-page that mounts the chart without the
|
||||
// dashboard parent having fetched first.
|
||||
const { data, isLoading } = useQuery({
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: queryKeys.moodAnalytics(),
|
||||
queryFn: async (): Promise<MoodAnalyticsData> => {
|
||||
return apiGet<MoodAnalyticsData>("/api/mood/analytics");
|
||||
@@ -580,7 +581,11 @@ export function MoodChart({
|
||||
|
||||
const animationsEnabled = !prefersReducedMotion();
|
||||
|
||||
if (!isLoading && !data?.entries?.length) return null;
|
||||
// v1.16.8 — only an EMPTY success hides the card (the dashboard gates
|
||||
// the mount on `hasMood` anyway). A failed query must keep the card
|
||||
// mounted so the error state + retry affordance below can paint;
|
||||
// pre-fix the `return null` made an outage erase the widget entirely.
|
||||
if (!isLoading && !isError && !data?.entries?.length) return null;
|
||||
|
||||
// v1.13.1 — TRENDS-ROW MOOD CARD HEIGHT. In mini mode the title +
|
||||
// chart body render inside the exact same flat shell `<HealthChart
|
||||
@@ -602,6 +607,15 @@ export function MoodChart({
|
||||
: "h-[var(--chart-height,200px)] w-full md:h-[var(--chart-height-md,220px)]"
|
||||
}
|
||||
/>
|
||||
) : isError ? (
|
||||
// v1.16.8 — a failed query paints as an ERROR with a retry
|
||||
// affordance instead of unmounting the card (see the gate above).
|
||||
<ChartErrorState
|
||||
title={t("charts.errorTitle")}
|
||||
actionLabel={t("common.retry")}
|
||||
onAction={() => void refetch()}
|
||||
height={CHART_HEIGHT_PX}
|
||||
/>
|
||||
) : !chartData?.length ? (
|
||||
<div className="text-muted-foreground flex h-48 items-center justify-center text-sm">
|
||||
{t("charts.noData")}
|
||||
|
||||
@@ -30,15 +30,41 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
* 3. `window.location.reload()` — fetches the fresh shell + new
|
||||
* chunk graph.
|
||||
*
|
||||
* `sessionStorage` gates the reload to once per session so a misset
|
||||
* version (server briefly serves a stale image, e.g. mid-deploy
|
||||
* webhook race) cannot loop.
|
||||
* The reload guard is keyed on the TARGET version: `sessionStorage`
|
||||
* records which live version we last reloaded for, and only a repeat
|
||||
* mismatch against that SAME version is suppressed (a misset version —
|
||||
* server briefly serves a stale image mid-deploy — cannot loop).
|
||||
* Polling itself never stops, so on a multi-deploy day a SECOND
|
||||
* release moves the live version past the recorded one and the heal
|
||||
* flow re-arms. The pre-v1.16.8 guard disabled all polling for the
|
||||
* rest of the session after one attempt, which left a stale shell
|
||||
* with 404ing chunks stuck forever once the guard was spent.
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const SESSION_GUARD_KEY = "healthlog:version-reload-attempted";
|
||||
const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? "";
|
||||
|
||||
export type VersionPollDecision = "up-to-date" | "reload" | "already-attempted";
|
||||
|
||||
/**
|
||||
* Pure reload decision so the guard semantics carry direct unit
|
||||
* coverage. `lastReloadedFor` is the live version recorded by the most
|
||||
* recent heal attempt in this session (`null` when none happened);
|
||||
* legacy guard values (the pre-v1.16.8 timestamp) never match a real
|
||||
* version string, so a stuck session from before the change heals on
|
||||
* its next mismatch.
|
||||
*/
|
||||
export function resolveVersionPollDecision(
|
||||
liveVersion: string | null,
|
||||
shellVersion: string,
|
||||
lastReloadedFor: string | null,
|
||||
): VersionPollDecision {
|
||||
if (!liveVersion || liveVersion === shellVersion) return "up-to-date";
|
||||
if (lastReloadedFor === liveVersion) return "already-attempted";
|
||||
return "reload";
|
||||
}
|
||||
|
||||
async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
try {
|
||||
const data = await apiGet<{ version?: string } | undefined>(
|
||||
@@ -51,9 +77,9 @@ async function fetchLiveVersion(signal: AbortSignal): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function evictAndReload(): Promise<void> {
|
||||
async function evictAndReload(targetVersion: string): Promise<void> {
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, String(Date.now()));
|
||||
sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion);
|
||||
} catch {
|
||||
// sessionStorage can throw under strict-privacy modes; fall
|
||||
// through to the reload anyway — worst case the next deploy
|
||||
@@ -85,19 +111,24 @@ export function VersionPoller(): null {
|
||||
useEffect(() => {
|
||||
if (!SHELL_VERSION) return;
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (sessionStorage.getItem(SESSION_GUARD_KEY)) return;
|
||||
} catch {
|
||||
/* fall through — we'll try the reload anyway if mismatch hits */
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function checkOnce(): Promise<void> {
|
||||
const live = await fetchLiveVersion(controller.signal);
|
||||
if (!live) return;
|
||||
if (live === SHELL_VERSION) return;
|
||||
await evictAndReload();
|
||||
let lastReloadedFor: string | null = null;
|
||||
try {
|
||||
lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY);
|
||||
} catch {
|
||||
/* fall through — treat as "no attempt recorded" */
|
||||
}
|
||||
const decision = resolveVersionPollDecision(
|
||||
live,
|
||||
SHELL_VERSION,
|
||||
lastReloadedFor,
|
||||
);
|
||||
if (decision !== "reload") return;
|
||||
await evictAndReload(live as string);
|
||||
}
|
||||
|
||||
// First check 5 s after mount — gives the app a chance to settle
|
||||
|
||||
Reference in new issue
Block a user