Merge pull request #529 from MBombeck/release/v1.30.5

release/v1.30.5 — reachable ECG surface and a sync-status fix
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 09:44:36 +02:00
commit b6a1fc1c2b
28 files changed
+810 -43

No files matched your search

+9
View File
@@ -2,6 +2,15 @@
## [Unreleased]
## [1.30.5] — 2026-07-18
Making a shipped feature findable, and a sync-status fix.
- **ECG has a home you can reach.** Recordings now have their own page in Insights, a pill in the Heart group that appears once you have a recording, and links to it from the resting-pulse and heart-rate-variability pages. Before this they were reachable from a single spot, and a deep link into the overview could land before the section had drawn. The page is unchanged in substance — the same non-diagnostic, device-attributed view.
- **The "last synced" time stops lagging.** A sync driven by an incoming update now records the connection's last-success time straight away, instead of leaving it frozen until the next full poll — so the Settings status and the assistant's integration read reflect a sync that already happened.
No migration. No breaking changes.
## [1.30.4] — 2026-07-18
The read-only assistant connector now reaches the full record.
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.30.4
version: 1.30.5
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Zurück zu Insights",
"valuesBackToMetric": "Zurück zu {metric}",
"scoresBack": "Zurück zu Insights"
"scoresBack": "Zurück zu Insights",
"ecgDescription": "Einkanal-EKG-Aufzeichnungen deines Geräts, jeweils mit dem Ergebnis des Geräts. HealthLog zeigt die Kurve und die Aufzeichnung – die Kurve wird nicht interpretiert."
},
"sleep": {
"title": "Schlaf",
@@ -3899,7 +3900,10 @@
"recordingsMany": "{count} Aufzeichnungen vorhanden.",
"latestResult": "Letztes Geräte-Ergebnis: {result}.",
"cta": "Aufzeichnungen ansehen"
}
},
"emptyTitle": "Noch keine EKG-Aufzeichnungen",
"emptyDescription": "EKG-Aufzeichnungen werden von einem kompatiblen Einkanal-Gerät synchronisiert. Sobald dein Gerät eine Aufzeichnung überträgt, erscheint sie hier.",
"emptyCta": "Gerät verbinden"
},
"intradayPulse": {
"title": "Der Verlauf deines Tages",
@@ -3918,7 +3922,8 @@
"evening": "Eine anhaltend erhöhte Ruhephase heute Abend — möglicherweise etwas Anspannung.",
"night": "Eine anhaltend erhöhte Ruhephase in der Nacht — möglicherweise etwas Anspannung."
}
}
},
"navEcg": "EKG"
},
"thresholds": {
"defaultLabel": "Default",
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Back to insights",
"valuesBackToMetric": "Back to {metric}",
"scoresBack": "Back to insights"
"scoresBack": "Back to insights",
"ecgDescription": "Single-lead ECG recordings from your device, each with the device's own result. HealthLog shows the trace and the recording — it does not interpret the waveform."
},
"sleep": {
"title": "Sleep",
@@ -3899,7 +3900,10 @@
"recordingsMany": "{count} recordings on file.",
"latestResult": "Latest device result: {result}.",
"cta": "View recordings"
}
},
"emptyTitle": "No ECG recordings yet",
"emptyDescription": "ECG recordings sync from a compatible single-lead device. Once your device uploads a recording, it appears here.",
"emptyCta": "Connect a device"
},
"intradayPulse": {
"title": "Your day's shape",
@@ -3918,7 +3922,8 @@
"evening": "A sustained elevated-at-rest stretch this evening — possibly some tension.",
"night": "A sustained elevated-at-rest stretch overnight — possibly some tension."
}
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Default",
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Volver a Insights",
"valuesBackToMetric": "Volver a {metric}",
"scoresBack": "Volver a Insights"
"scoresBack": "Volver a Insights",
"ecgDescription": "Registros de ECG de una derivación de tu dispositivo, cada uno con el resultado del propio dispositivo. HealthLog muestra el trazado y el registro, pero no interpreta la onda."
},
"sleep": {
"title": "Sueño",
@@ -3899,7 +3900,10 @@
"recordingsMany": "{count} registros guardados.",
"latestResult": "Último resultado del dispositivo: {result}.",
"cta": "Ver registros"
}
},
"emptyTitle": "Aún no hay registros de ECG",
"emptyDescription": "Los registros de ECG se sincronizan desde un dispositivo de una derivación compatible. En cuanto tu dispositivo suba un registro, aparecerá aquí.",
"emptyCta": "Conectar un dispositivo"
},
"intradayPulse": {
"title": "La forma de tu día",
@@ -3918,7 +3922,8 @@
"evening": "Un tramo elevado en reposo al anochecer; posiblemente algo de tensión.",
"night": "Un tramo elevado en reposo por la noche; posiblemente algo de tensión."
}
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Por defecto",
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Retour aux Insights",
"valuesBackToMetric": "Retour à {metric}",
"scoresBack": "Retour aux Insights"
"scoresBack": "Retour aux Insights",
"ecgDescription": "Enregistrements ECG à dérivation unique de votre appareil, chacun accompagné du résultat de l'appareil. HealthLog affiche le tracé et l'enregistrement, mais n'interprète pas la courbe."
},
"sleep": {
"title": "Sommeil",
@@ -3899,7 +3900,10 @@
"recordingsMany": "{count} enregistrements disponibles.",
"latestResult": "Dernier résultat de l’appareil : {result}.",
"cta": "Voir les enregistrements"
}
},
"emptyTitle": "Aucun enregistrement ECG pour l'instant",
"emptyDescription": "Les enregistrements ECG se synchronisent depuis un appareil à dérivation unique compatible. Dès que votre appareil transfère un enregistrement, il apparaît ici.",
"emptyCta": "Connecter un appareil"
},
"intradayPulse": {
"title": "Le rythme de votre journée",
@@ -3918,7 +3922,8 @@
"evening": "Une période élevée au repos ce soir — peut-être un peu de tension.",
"night": "Une période élevée au repos cette nuit — peut-être un peu de tension."
}
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Par défaut",
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Torna a Insights",
"valuesBackToMetric": "Torna a {metric}",
"scoresBack": "Torna a Insights"
"scoresBack": "Torna a Insights",
"ecgDescription": "Registrazioni ECG a singola derivazione dal tuo dispositivo, ciascuna con il risultato del dispositivo. HealthLog mostra il tracciato e la registrazione, ma non interpreta l'onda."
},
"sleep": {
"title": "Sonno",
@@ -3899,7 +3900,10 @@
"recordingsMany": "{count} registrazioni disponibili.",
"latestResult": "Ultimo risultato del dispositivo: {result}.",
"cta": "Vedi registrazioni"
}
},
"emptyTitle": "Ancora nessuna registrazione ECG",
"emptyDescription": "Le registrazioni ECG si sincronizzano da un dispositivo a singola derivazione compatibile. Non appena il dispositivo carica una registrazione, comparirà qui.",
"emptyCta": "Collega un dispositivo"
},
"intradayPulse": {
"title": "L'andamento della tua giornata",
@@ -3918,7 +3922,8 @@
"evening": "Un tratto elevato a riposo stasera — forse un po' di tensione.",
"night": "Un tratto elevato a riposo durante la notte — forse un po' di tensione."
}
}
},
"navEcg": "ECG"
},
"thresholds": {
"defaultLabel": "Predefinito",
+8 -3
View File
@@ -2760,7 +2760,8 @@
},
"valuesBack": "Powrót do Insights",
"valuesBackToMetric": "Powrót do {metric}",
"scoresBack": "Powrót do Insights"
"scoresBack": "Powrót do Insights",
"ecgDescription": "Zapisy EKG jednoodprowadzeniowe z Twojego urządzenia, każdy z wynikiem samego urządzenia. HealthLog pokazuje przebieg i zapis — nie interpretuje krzywej."
},
"sleep": {
"title": "Sen",
@@ -3899,7 +3900,10 @@
"recordingsMany": "Zapisy w kartotece: {count}.",
"latestResult": "Ostatni wynik urządzenia: {result}.",
"cta": "Zobacz zapisy"
}
},
"emptyTitle": "Brak zapisów EKG",
"emptyDescription": "Zapisy EKG synchronizują się z kompatybilnego urządzenia jednoodprowadzeniowego. Gdy urządzenie prześle zapis, pojawi się tutaj.",
"emptyCta": "Połącz urządzenie"
},
"intradayPulse": {
"title": "Przebieg Twojego dnia",
@@ -3918,7 +3922,8 @@
"evening": "Utrzymujący się podwyższony spoczynek wieczorem — możliwe napięcie.",
"night": "Utrzymujący się podwyższony spoczynek w nocy — możliwe napięcie."
}
}
},
"navEcg": "EKG"
},
"thresholds": {
"defaultLabel": "Domyślne",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.30.4",
"version": "1.30.5",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.30.4";
/* @sw-version-fallback */ "v1.30.5";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.30 (UX/IA audit H1) — `<EcgCrossLink>` now mounts on the resting-pulse
* and HRV sub-pages (previously only on `/insights/pulse`), threaded through
* `<HealthKitMetricPage afterAssessment>`. The pointer self-gates on
* recordings, so the shared `insightsEcgList` cache is pre-seeded with one
* recording to exercise the mounted path.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="healthkit-chart-stub" />;
Stub.displayName = "HealthChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { timezone: "UTC", dateOfBirth: null, gender: null },
isAuthenticated: true,
}),
}));
vi.mock("@/hooks/use-insights-layout-prefs", () => ({
useInsightsLayoutPrefs: () => ({ layout: null, compareBaseline: false }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
const metricStatusMock = vi.fn();
vi.mock("@/hooks/use-insight-status", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/hooks/use-insight-status")>();
return {
...actual,
useInsightMetricStatus: (...args: unknown[]) => metricStatusMock(...args),
};
});
import RestingPulsePage from "@/app/insights/resting-pulse/page";
import HrvPage from "@/app/insights/hrv/page";
function render(node: React.ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// Seed the ECG list the cross-link reads so its data-availability gate opens.
client.setQueryData(queryKeys.insightsEcgList(), {
recordings: [{ id: "a", classification: null }],
hasRecordings: true,
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
analyticsMock.mockReset();
metricStatusMock.mockReset();
// Data-bearing branch for both metric pages.
analyticsMock.mockReturnValue({
data: {
summaries: {
RESTING_HEART_RATE: { count: 5 },
HEART_RATE_VARIABILITY: { count: 5 },
},
},
isEmpty: false,
isLoading: false,
error: null,
refetch: vi.fn(),
});
metricStatusMock.mockReturnValue({ data: undefined, isLoading: false });
});
describe("EcgCrossLink mount on Heart sub-pages (H1)", () => {
it("mounts the ECG cross-link on the resting-pulse page", () => {
const html = render(<RestingPulsePage />);
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights/ecg"');
});
it("mounts the ECG cross-link on the HRV page", () => {
const html = render(<HrvPage />);
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights/ecg"');
});
});
@@ -0,0 +1,88 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* v1.30 (UX/IA audit H1) — `/insights/ecg` routed sub-page.
*
* The page reuses `<EcgSection>` (verbatim, `hideHeading` set so the shell
* owns the heading) and self-gates to an empty state when the account has no
* recordings. `useAuth` is mocked; the shared `insightsEcgList` cache is
* pre-seeded so both the page probe and the section read the same payload.
*/
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true, user: null }),
}));
import InsightsEcgPage from "../page";
interface EcgRecord {
id: string;
recordedAt: string;
durationSeconds: number | null;
samplingFrequency: number;
sampleCount: number;
averageHeartRate: number | null;
lead: string | null;
classification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null;
source: string;
hasWaveform: boolean;
}
function seededRecording(): EcgRecord {
return {
id: "rec-1",
recordedAt: "2026-07-10T08:00:00.000Z",
durationSeconds: 30,
samplingFrequency: 300,
sampleCount: 9000,
averageHeartRate: 62,
lead: null,
classification: null,
source: "withings",
hasWaveform: true,
};
}
function render(data: { recordings: EcgRecord[]; hasRecordings: boolean }) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
client.setQueryData(queryKeys.insightsEcgList(), data);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsEcgPage />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("/insights/ecg page (H1)", () => {
it("renders the EcgSection with its disclaimer when recordings exist", () => {
const html = render({
recordings: [seededRecording()],
hasRecordings: true,
});
// The reused section + its load-bearing non-diagnostic disclaimer render.
expect(html).toContain('data-slot="ecg-section"');
expect(html).toContain('data-slot="ecg-card"');
expect(html).toContain('data-slot="ecg-disclaimer"');
// The shell owns the page heading (`<h1>`); the section's own
// `<SectionHeading>` (`<h2>`) is suppressed via `hideHeading`.
expect(html).toContain('id="insights-subpage-title"');
expect(html).not.toContain("<h2");
});
it("shows the empty state (no section) when the account has no recordings", () => {
const html = render({ recordings: [], hasRecordings: false });
expect(html).not.toContain('data-slot="ecg-section"');
expect(html).toContain("No ECG recordings yet");
// The empty-state CTA points at the integrations surface.
expect(html).toContain('href="/settings/integrations"');
});
});
+82
View File
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { Activity } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { Button } from "@/components/ui/button";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { EcgSection } from "@/components/insights/ecg-section";
/**
* v1.30 — `/insights/ecg` (UX/IA audit finding H1).
*
* Routed home for the ECG recording surface. Before this page the only
* inbound pointer to a user's ECGs was a cross-link card on the spot-pulse
* page; hiding the overview ECG teaser in "Anpassen" left no route back at
* all. This page gives ECG its own address, reached from the Heart-group
* pill in the tab strip (gated on `hasRecordings`) and the metric catalog.
*
* The surface itself is the existing `<EcgSection>`, reused verbatim — its
* non-diagnostic disclaimer + waveform logic are load-bearing and untouched.
* We only suppress its internal `<SectionHeading>` (via `hideHeading`) because
* `<SubPageShell>` already renders the page heading, mirroring how every other
* routed sub-page lets the shell own the `<h1>`.
*
* The overview `<EcgSection>` teaser on `/insights` stays as-is.
*/
export default function InsightsEcgPage() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
// Reuse the SAME query cell `<EcgSection>` reads (`insightsEcgList`) so the
// page and the section share one cache entry. The page only needs the
// `hasRecordings` flag to choose between the empty state and the section.
const { data, isLoading } = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
// Data-availability empty state — a direct URL hit (or deleted recordings)
// lands here without a section to show. Mirrors the sub-page convention:
// a calm one-line scaffold + a connect-a-device CTA, never a blank surface.
// While the probe is in flight we render nothing extra (the section self-
// gates too), so first paint stays quiet rather than flashing an empty card.
if (!isLoading && data && !data.hasRecordings) {
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<MetricEmptyState
icon={<Activity className="size-6" />}
title={t("insights.ecg.emptyTitle")}
description={t("insights.ecg.emptyDescription")}
cta={
<Button size="sm" asChild>
<Link href="/settings/integrations">
{t("insights.ecg.emptyCta")}
</Link>
</Button>
}
coachPrefill={null}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.ecg.sectionTitle")}
description={t("insights.subPage.ecgDescription")}
>
<EcgSection enabled={isAuthenticated} hideHeading />
</SubPageShell>
);
}
+4
View File
@@ -3,6 +3,7 @@
import { Activity } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
/**
* v1.4.32 — `/insights/hrv`.
@@ -33,6 +34,9 @@ export default function InsightsHrvPage() {
emptyStateIcon={<Activity className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any HRV data yet — what does heart-rate variability tell me, and how do I capture it?"
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// HRV / heart context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
/>
);
}
+4
View File
@@ -3,6 +3,7 @@
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { EcgCrossLink } from "@/components/insights/ecg-cross-link";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -30,6 +31,9 @@ export default function InsightsRestingHrPage() {
emptyStateIcon={<Heart className="size-6" />}
emptyStateCtaType={null}
coachPrefill={t("insights.restingHr.coachPrefill")}
// S10 / H1 — device-attributed pointer into the ECG viewer, in the
// resting-HR context. Self-gates to nothing without recordings.
afterAssessment={<EcgCrossLink />}
/>
);
}
@@ -62,7 +62,7 @@ describe("<EcgCrossLink>", () => {
hasRecordings: true,
});
expect(html).toContain('data-slot="ecg-cross-link"');
expect(html).toContain('href="/insights#ecg"');
expect(html).toContain('href="/insights/ecg"');
expect(html).toContain("2 recordings on file.");
// The latest (first) recording's DEVICE result, attributed to the device.
expect(html).toContain("Latest device result:");
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
/**
* v1.30 (UX/IA audit H1) — the ECG entry in the Heart-group tab strip.
*
* ECG carries no `MeasurementType`, so it can't ride the metric
* `availability` model; the strip gates it on the dedicated
* `hasEcgRecordings` signal instead. The load-bearing product rule under
* test: the Heart group (hence the ECG entry) appears ONLY when the account
* has recordings — we never surface a data-less ECG target on the busy nav.
*
* The ECG child lives inside the Heart popover, which Radix renders only on
* open (portal); SSR markup therefore can't show the child label directly.
* These tests pin the gate through the Heart group's PRESENCE with no other
* heart-metric data, so the group appears solely because of ECG.
*/
vi.mock("next/navigation", () => ({
usePathname: () => "/insights",
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn() } }));
import { InsightsTabStrip } from "../insights-tab-strip";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<InsightsTabStrip> — ECG gate (H1)", () => {
it("hides the Heart group (no ECG pill) when the account has no recordings", () => {
// No availability + no ECG → only the always-present pills, no Heart group.
const html = render(<InsightsTabStrip hasEcgRecordings={false} />);
expect(html).not.toContain('data-group="heart"');
});
it("hides the ECG pill when `hasEcgRecordings` is absent (default off, first paint)", () => {
const html = render(<InsightsTabStrip />);
expect(html).not.toContain('data-group="heart"');
});
it("shows the Heart group for an ECG-only account (recordings, no other heart data)", () => {
// An ECG-only device has no pulse / resting-HR / HRV rows, so no
// Heart-metric pill would light the group — the group appears solely
// because the ECG fallback emits it.
const html = render(<InsightsTabStrip hasEcgRecordings={true} />);
expect(html).toContain('data-group="heart"');
});
});
+4 -2
View File
@@ -13,7 +13,9 @@ import { TileHeader } from "@/components/insights/tile-header";
/**
* S10 — ECG cross-link, the pointer from the resting-HR / pulse context into
* the ECG viewer (`/insights#ecg`).
* the ECG viewer (`/insights/ecg`). Points at the routed sub-page, which
* always mounts, rather than the overview `#ecg` fragment, which is absent
* until the overview section's own fetch resolves (UX-flows finding F1-1).
*
* NON-DIAGNOSTIC (load-bearing, mirrors `EcgSection` / `RhythmEventsCard`): the
* card surfaces ONLY that recordings exist + the RECORDING DEVICE's OWN latest
@@ -86,7 +88,7 @@ export function EcgCrossLink({ enabled = true, className }: EcgCrossLinkProps) {
return (
<Link
href="/insights#ecg"
href="/insights/ecg"
data-slot="ecg-cross-link"
className={cn(
// `.metric-accent` — the heart-family identity edge (`--tile-strain`,
+19 -2
View File
@@ -82,9 +82,21 @@ function isNonNormal(classification: EcgClassification): boolean {
interface EcgSectionProps {
enabled?: boolean;
className?: string;
/**
* v1.30 — suppress the internal `<SectionHeading>` when the section is
* hosted on the routed `/insights/ecg` sub-page, whose `<SubPageShell>`
* already renders the page `<h1>`. Defaults to `false` so the overview
* teaser keeps its own heading unchanged. Purely presentational — the
* non-diagnostic disclaimer + waveform logic are untouched.
*/
hideHeading?: boolean;
}
export function EcgSection({ enabled = true, className }: EcgSectionProps) {
export function EcgSection({
enabled = true,
className,
hideHeading = false,
}: EcgSectionProps) {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const fmt = useFormatters();
@@ -123,7 +135,12 @@ export function EcgSection({ enabled = true, className }: EcgSectionProps) {
aria-label={t("insights.ecg.sectionTitle")}
className={cn("scroll-mt-24 space-y-3", className)}
>
<SectionHeading icon={Activity} title={t("insights.ecg.sectionTitle")} />
{!hideHeading && (
<SectionHeading
icon={Activity}
title={t("insights.ecg.sectionTitle")}
/>
)}
<div
data-slot="ecg-card"
className="bg-card border-border space-y-4 rounded-xl border p-4 md:p-6"
@@ -174,6 +174,15 @@ export interface HealthKitMetricPageProps {
* omits it and renders byte-identically.
*/
afterChart?: ReactNode;
/**
* v1.30 (UX/IA audit H1) — optional content rendered at the very foot of the
* data-bearing spine, AFTER the metric-status assessment card. The
* resting-pulse + HRV pages mount `<EcgCrossLink>` here so the ECG pointer
* trails the assessment, matching the placement it has on `/insights/pulse`.
* Self-gating nodes only (the cross-link un-mounts without recordings), so
* the empty / loading / error branches skip it and no void card appears.
*/
afterAssessment?: ReactNode;
}
export function HealthKitMetricPage({
@@ -198,6 +207,7 @@ export function HealthKitMetricPage({
statFractionDigits,
statMedianLabel,
afterChart,
afterAssessment,
}: HealthKitMetricPageProps) {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
@@ -397,6 +407,9 @@ export function HealthKitMetricPage({
enabled={!isEmpty}
/>
) : null}
{/* v1.30 (H1) — foot-of-spine slot, after the assessment card. The
resting-pulse + HRV pages mount the ECG cross-link here. */}
{afterAssessment}
</SubPageShell>
);
}
@@ -116,6 +116,20 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// degrades from "enable" → "no rows yet" → the three-card spine.
const nutrientsEnabled = user?.modules?.nutrients === true;
// v1.30 (UX/IA audit H1) — ECG recording presence for the Heart-group ECG
// pill. ECG carries no `MeasurementType`, so the metric `availability`
// model can't gate it; it rides its own `hasRecordings` probe instead. The
// query shares `queryKeys.insightsEcgList()` with `<EcgSection>` and the
// ECG page, so the flag lands once per route (dedup-shared, no extra
// traffic). Undefined until it resolves → the pill stays off on first paint,
// exactly like the metric pills light up as their summaries land.
const ecgProbe = useQuery({
queryKey: queryKeys.insightsEcgList(),
queryFn: () => apiGet<{ hasRecordings: boolean }>("/api/insights/ecg"),
enabled: isAuthenticated,
});
const hasEcgRecordings = ecgProbe.data?.hasRecordings === true;
// v1.4.31 — memoise the `availability` prop so an unchanged
// payload doesn't recreate the object on every cache-write of
// analytics or comprehensive. The strip is wrapped in
@@ -175,6 +189,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
visibleTileIds={visibleTileIds}
tileOrder={tileOrder}
modules={user?.modules}
hasEcgRecordings={hasEcgRecordings}
/>
{children}
</div>
+70 -5
View File
@@ -121,6 +121,20 @@ export interface InsightsTabStripProps {
* pill — fail-open, matching the gate's default-on contract.
*/
modules?: InsightsModuleMap;
/**
* v1.30 (UX/IA audit H1) — true when the account has at least one ECG
* recording. ECG carries NO `MeasurementType`, so it can't ride the
* `availability` / `SUB_PAGE_METRIC` model like every metric pill; it is
* threaded here as a dedicated data-only signal instead. When true, an
* "ECG" child is appended to the Heart group's popover (and the Heart group
* is emitted even for an ECG-only device with no other heart-metric data).
* When false / absent, no ECG pill renders — we never surface a data-less
* ECG target on the busy nav (the metric catalog is the empty-state home).
* Absent until the shell's `insightsEcgList` probe resolves, so first paint
* is unchanged — a data-only gate, exactly like the other availability
* inputs.
*/
hasEcgRecordings?: boolean;
}
/**
@@ -145,10 +159,26 @@ type TabEntry =
labelKey: string;
/** Translation key for the popover header. */
headerKey: string;
/** Visible sub-pages inside the group (post-availability gating). */
children: Array<{ href: string; labelKey: string; slug: SubPageSlug }>;
/**
* Visible sub-pages inside the group (post-availability gating). `slug`
* is optional because non-metric children (the ECG entry on the Heart
* group) have no `SubPageSlug` — they render from `href` + `labelKey`
* alone and are injected outside the slug-driven availability filter.
*/
children: Array<{ href: string; labelKey: string; slug?: SubPageSlug }>;
};
/**
* v1.30 (UX/IA audit H1) — the ECG entry injected into the Heart group's
* popover when the account has recordings. Not a `SubPageSlug`: ECG carries no
* `MeasurementType`, so it lives on its own routed page (`/insights/ecg`) and
* is gated on `hasEcgRecordings`, never on the metric-availability model.
*/
const ECG_TAB_CHILD = {
href: `${INSIGHTS_OVERVIEW_PATH}/ecg`,
labelKey: "insights.navEcg",
} as const;
/**
* Slug → (label key, gating metric) mapping. Keeping the metric here
* means the tab strip and the empty-state gates on each sub-page can
@@ -442,6 +472,7 @@ function buildTabs(
visibleTileIds: ReadonlySet<string> | undefined,
tileOrder: ReadonlyMap<string, number> | undefined,
modules: InsightsModuleMap | undefined,
hasEcgRecordings: boolean | undefined,
): TabEntry[] {
// v1.4.36 W4d — strict gating contract: a sub-page only surfaces a
// pill when the availability inputs CONFIRM the metric has at
@@ -537,7 +568,11 @@ function buildTabs(
(tileOrder.get(b) ?? Number.MAX_SAFE_INTEGER),
)
: SUB_PAGE_GROUP_ORDER[group];
const children = orderedChildren
const children: Array<{
href: string;
labelKey: string;
slug?: SubPageSlug;
}> = orderedChildren
.filter(
(childSlug) =>
visibleSet.has(childSlug) && !PINNED_SUB_PAGE_SLUGS.has(childSlug),
@@ -547,6 +582,11 @@ function buildTabs(
href: `${INSIGHTS_OVERVIEW_PATH}/${childSlug}`,
labelKey: SUB_PAGE_TABS[childSlug].labelKey,
}));
// v1.30 (H1) — ECG is a non-metric child of the Heart group, gated on
// its own `hasEcgRecordings` signal (ECG has no `MeasurementType`).
if (group === "heart" && hasEcgRecordings) {
children.push({ ...ECG_TAB_CHILD });
}
if (children.length === 0) continue;
entries.push({
kind: "group",
@@ -564,6 +604,23 @@ function buildTabs(
});
}
// v1.30 (H1) — an ECG-only device (a recording, but no pulse / resting-HR /
// HRV rows) never lit up any Heart-group member above, so the loop never
// emitted the group. Emit it now with ECG as its sole child so the user
// still has a nav home for their recordings. Positioned after the metric
// groups (before the catalog tail) since there is no member slug to anchor
// its natural position to.
if (hasEcgRecordings && !emittedGroups.has("heart")) {
emittedGroups.add("heart");
entries.push({
kind: "group",
group: "heart",
labelKey: SUB_PAGE_GROUP_META.heart.labelKey,
headerKey: SUB_PAGE_GROUP_META.heart.headerKey,
children: [{ ...ECG_TAB_CHILD }],
});
}
// 2026-07-17 discoverability audit (R1) — a quiet, always-present pill
// trailing every other pill, linking to the "what can HealthLog track"
// catalog. Unlike every metric pill above, it carries no availability
@@ -587,6 +644,7 @@ function InsightsTabStripImpl({
visibleTileIds,
tileOrder,
modules,
hasEcgRecordings,
}: InsightsTabStripProps) {
const { t } = useTranslations();
const pathname = usePathname();
@@ -603,8 +661,15 @@ function InsightsTabStripImpl({
// "Anpassen". The shell memoises both props so an unchanged layout
// doesn't churn the array.
const tabs = useMemo(
() => buildTabs(availability, visibleTileIds, tileOrder, modules),
[availability, visibleTileIds, tileOrder, modules],
() =>
buildTabs(
availability,
visibleTileIds,
tileOrder,
modules,
hasEcgRecordings,
),
[availability, visibleTileIds, tileOrder, modules, hasEcgRecordings],
);
// Fire a toast on the falling edge of `regenerating`. Same rising-edge ref
@@ -0,0 +1,109 @@
/**
* v1.30 (UX/IA audit H1) — the ECG row in the Insights sub-page manager.
*
* ECG has no `MeasurementType` and therefore no sub-page tile; its overview
* presence is the `"ecg"` SECTION. The manager surfaces it as a Heart-group
* row so hiding the ECG surface is reversible from the same place. The eye
* reflects (and toggles) the `"ecg"` section's `visible` flag.
*
* SSR-only suite per project convention: static markup pins the rendered eye
* state.
*/
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import {
DEFAULT_INSIGHTS_LAYOUT,
type InsightsLayout,
} from "@/lib/insights-layout";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true }),
}));
const layoutSpy = vi.fn<
() => { layout: InsightsLayout; isLoading: boolean; isSuccess: boolean }
>(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
vi.mock("@/hooks/use-insights-layout", () => ({
useInsightsLayoutQuery: () => layoutSpy(),
}));
import { InsightsPillOrderSection } from "../insights-pill-order-section";
function layoutWithEcgVisible(visible: boolean): InsightsLayout {
return {
version: DEFAULT_INSIGHTS_LAYOUT.version,
sections: DEFAULT_INSIGHTS_LAYOUT.sections.map((s) =>
s.id === "ecg" ? { ...s, visible } : { ...s },
),
tiles: DEFAULT_INSIGHTS_LAYOUT.tiles.map((t) => ({ ...t })),
};
}
beforeEach(() => {
layoutSpy.mockImplementation(() => ({
layout: DEFAULT_INSIGHTS_LAYOUT,
isLoading: false,
isSuccess: true,
}));
});
afterEach(() => {
vi.clearAllMocks();
});
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<InsightsPillOrderSection id="insights-pill-order" />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<InsightsPillOrderSection> — ECG row (H1)", () => {
it("lists an ECG row inside the Heart group", () => {
const html = render();
// The ECG row rides the Heart group and carries an eye toggle.
expect(html).toContain('data-group="heart"');
expect(html).toMatch(
/data-tile="ecg"[\s\S]*?data-slot="insights-pill-order-eye"/,
);
});
it("reflects the ECG section visible (default layout)", () => {
const html = render();
const ecgRow = html.match(
/<div[^>]*data-tile="ecg"[\s\S]*?<\/div>\s*<\/div>/,
);
expect(ecgRow).not.toBeNull();
expect(ecgRow![0]).toContain('data-visible="true"');
});
it("reflects a hidden ECG section from the saved layout", () => {
layoutSpy.mockImplementation(() => ({
layout: layoutWithEcgVisible(false),
isLoading: false,
isSuccess: true,
}));
const html = render();
const ecgRow = html.match(
/<div[^>]*data-tile="ecg"[\s\S]*?<\/div>\s*<\/div>/,
);
expect(ecgRow).not.toBeNull();
expect(ecgRow![0]).toContain('data-visible="false"');
});
});
@@ -108,14 +108,31 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
// ref) keeps the reseed render-safe and idempotent while leaving in-flight
// drag edits untouched: the baseline only advances when the *server* order
// differs from what we last seeded from.
const serverSignature = layoutSignature(layout.tiles);
// v1.30 (UX/IA audit H1) — ECG has no `MeasurementType` and therefore no
// sub-page tile; its overview presence is the `"ecg"` SECTION. Surface it
// here as a Heart-group row so hiding the ECG surface is reversible from the
// same place the user manages every other Heart detail page. The eye toggles
// the `"ecg"` section's `visible` flag (the exact flag a user flips in the
// overview "Anpassen" editor). The tab-strip ECG pill stays a permanent,
// data-gated route back and is deliberately NOT gated on this flag, so
// hiding the overview card can never orphan a user's recordings again.
const serverEcgVisible =
layout.sections.find((s) => s.id === "ecg")?.visible ?? true;
// Fold the ECG section flag into the re-seed fingerprint so a server-side
// change to it (e.g. saved from the overview arrange editor) re-seeds the
// ECG draft too, not just tile edits.
const serverSignature = `${layoutSignature(layout.tiles)}|ecg:${
serverEcgVisible ? 1 : 0
}`;
const [seededSignature, setSeededSignature] = useState(serverSignature);
const [draftTiles, setDraftTiles] = useState<InsightsTileConfig[]>(() =>
[...layout.tiles].sort((a, b) => a.order - b.order),
);
const [ecgVisibleDraft, setEcgVisibleDraft] = useState(serverEcgVisible);
if (isSuccess && serverSignature !== seededSignature) {
setSeededSignature(serverSignature);
setDraftTiles([...layout.tiles].sort((a, b) => a.order - b.order));
setEcgVisibleDraft(serverEcgVisible);
}
const sensors = useSensors(
@@ -127,11 +144,19 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
const saveMutation = useMutation({
mutationFn: async (tiles: InsightsTileConfig[]) => {
// Preserve the overview section order/visibility verbatim — this surface
// owns the PILL order. The ONE exception is the `"ecg"` section, whose
// eye lives in this manager (H1); override it only when the user actually
// flipped it so an unchanged save stays a section no-op.
const sections =
ecgVisibleDraft !== serverEcgVisible
? layout.sections.map((s) =>
s.id === "ecg" ? { ...s, visible: ecgVisibleDraft } : s,
)
: layout.sections;
return apiPut<InsightsLayout>("/api/insights/layout", {
version: 2,
// Preserve the overview section order/visibility verbatim — this
// surface owns the PILL order only.
sections: layout.sections,
sections,
tiles,
});
},
@@ -200,8 +225,10 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
}
const dirty = useMemo(
() => layoutSignature(layout.tiles) !== layoutSignature(draftTiles),
[layout.tiles, draftTiles],
() =>
layoutSignature(layout.tiles) !== layoutSignature(draftTiles) ||
ecgVisibleDraft !== serverEcgVisible,
[layout.tiles, draftTiles, ecgVisibleDraft, serverEcgVisible],
);
return (
@@ -284,6 +311,22 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
</div>
</SortableContext>
</DndContext>
{/* v1.30 (H1) — ECG rides the Heart group as a non-sortable row:
it has no `MeasurementType` and no tile order, so there is
nothing to drag. The eye toggles the `"ecg"` overview section
(see the draft plumbing above). */}
{group === "heart" && (
<div className="mt-1.5">
<EcgManagerRow
title={t("insights.navEcg")}
visible={ecgVisibleDraft}
disabled={busy}
showLabel={t("insights.editMode.show")}
hideLabel={t("insights.editMode.hide")}
onToggle={() => setEcgVisibleDraft((v) => !v)}
/>
</div>
)}
</div>
))}
</div>
@@ -295,6 +338,61 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
const DRAG_HANDLE_CLASS =
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background relative inline-flex h-11 w-11 shrink-0 cursor-grab touch-none items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9 sm:before:absolute sm:before:inset-[-6px] sm:before:content-['']";
const EYE_TOGGLE_CLASS =
"text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex h-11 w-11 shrink-0 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none sm:h-9 sm:w-9";
/**
* v1.30 (H1) — the ECG row in the Heart group. Unlike {@link SortablePillRow}
* it is not draggable (ECG has no tile order), so it carries an aligned spacer
* where the drag handle sits on its siblings and only the eye toggle. The eye
* flips the `"ecg"` overview section's visibility.
*/
function EcgManagerRow({
title,
visible,
disabled,
showLabel,
hideLabel,
onToggle,
}: {
title: string;
visible: boolean;
disabled: boolean;
showLabel: string;
hideLabel: string;
onToggle: () => void;
}) {
return (
<div
data-slot="insights-pill-order-row"
data-tile="ecg"
className="border-border bg-background/30 flex items-center gap-2 rounded-md border px-2 py-1.5"
>
{/* Alignment spacer standing in for the sortable rows' drag handle. */}
<span
aria-hidden="true"
className="inline-flex h-11 w-11 shrink-0 sm:h-9 sm:w-9"
/>
<span className="min-w-0 flex-1 truncate text-sm" title={title}>
{title}
</span>
<button
type="button"
onClick={onToggle}
disabled={disabled}
aria-pressed={visible}
aria-label={`${visible ? hideLabel : showLabel} — ${title}`}
title={visible ? hideLabel : showLabel}
data-slot="insights-pill-order-eye"
data-visible={visible ? "true" : "false"}
className={EYE_TOGGLE_CLASS}
>
{visible ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
);
}
function SortablePillRow({
id,
title,
+3 -1
View File
@@ -202,7 +202,9 @@ const ECG_ENTRY: CatalogEntry = {
nameKey: "insights.ecg.sectionTitle",
sourceKey: "metricCatalog.ecg.source",
kind: "metric",
href: INSIGHTS_OVERVIEW_PATH,
// v1.30 (H1) — ECG now has its own routed page; the catalog "View" button
// points there instead of the overview teaser anchor.
href: `${INSIGHTS_OVERVIEW_PATH}/ecg`,
connectable: true,
};
+7 -4
View File
@@ -21,6 +21,7 @@ import {
syncUserWorkout,
syncWhoopWorkoutById,
} from "@/lib/whoop/sync-workout";
import { syncWhoopResourceWithStatus } from "@/lib/whoop/sync";
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import { getWorkerPrisma } from "./shared";
@@ -93,10 +94,12 @@ export async function runWhoopResourceSync(
let measurementsImported = 0;
for (const { userId, resourceId } of targets) {
try {
const imported =
resourceId && byIdFn
? await byIdFn(userId, resourceId)
: await syncFn(userId);
// Stamp `IntegrationStatus.lastSuccessAt` on a genuine per-resource
// import — a webhook-driven refresh advanced the cursor but used to
// leave the sync-status ledger frozen until the next full poll.
const imported = await syncWhoopResourceWithStatus(userId, () =>
resourceId && byIdFn ? byIdFn(userId, resourceId) : syncFn(userId),
);
measurementsImported += imported;
usersSynced++;
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
@@ -71,7 +71,11 @@ vi.mock("../sync-body", () => ({
syncUserBody: (...a: unknown[]) => syncUserBody(...a),
}));
import { handleCollectionFetchError, syncUserWhoop } from "../sync";
import {
handleCollectionFetchError,
syncUserWhoop,
syncWhoopResourceWithStatus,
} from "../sync";
import { WhoopApiError } from "../response-classifier";
/** A resource sync that 403s its collection and soft-skips, like the real one. */
@@ -168,3 +172,40 @@ describe("syncUserWhoop — all-403 looks-healthy guard", () => {
expect(syncUserRecovery).not.toHaveBeenCalled();
});
});
describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", () => {
it("stamps success when the resource imported rows", async () => {
const imported = await syncWhoopResourceWithStatus("user1", async () => 5);
expect(imported).toBe(5);
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
});
it("stamps success on a clean fetch that imported nothing new", async () => {
// No 403 soft-skip → a genuine "nothing changed" webhook/cron tick still
// proves the connection is alive, so `lastSuccessAt` advances.
const imported = await syncWhoopResourceWithStatus("user1", async () => 0);
expect(imported).toBe(0);
expect(recordSyncSuccess).toHaveBeenCalledWith("user1", "whoop");
});
it("does NOT stamp success when the resource soft-skipped a 403 and imported nothing", async () => {
const imported = await syncWhoopResourceWithStatus("user1", () =>
softSkip403("user1"),
);
expect(imported).toBe(0);
expect(recordSyncSuccess).not.toHaveBeenCalled();
expect(recordSyncFailure).not.toHaveBeenCalled();
});
it("propagates a hard failure without stamping success", async () => {
await expect(
syncWhoopResourceWithStatus("user1", async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
expect(recordSyncSuccess).not.toHaveBeenCalled();
});
});
+31
View File
@@ -524,6 +524,37 @@ export async function syncUserWhoop(
return total;
}
/**
* Run a SINGLE WHOOP resource sync (webhook-driven refresh or the per-resource
* cron walk) under the soft-skip tracker and stamp `recordSyncSuccess` iff the
* resource actually reached WHOOP — it did not throw and was not a pure
* soft-skip (an all-403 grant-revoke that imported nothing).
*
* The per-resource path (`runWhoopResourceSync`) advances the resource cursor
* via `markResourceSynced` but historically never touched `IntegrationStatus`,
* so `lastSuccessAt` stayed frozen at the last full `syncUserWhoop` run — the
* Settings "last synced" pill and the MCP `get_integration_status` read lied
* for up to an hour after a webhook already landed last night's data. Stamp it
* here, reusing `syncUserWhoop`'s exact success guard scoped to one resource:
* a genuine import clears the error state; a 403 soft-skip leaves the
* "looks-healthy" window closed.
*/
export async function syncWhoopResourceWithStatus(
userId: string,
run: () => Promise<number>,
): Promise<number> {
const tracker: SoftSkipTracker = { count: 0 };
let imported = 0;
await softSkipStorage.run(tracker, async () => {
imported = await run();
});
const softSkippedOnly = tracker.count > 0 && imported === 0;
if (!softSkippedOnly) {
await recordSyncSuccess(userId, "whoop");
}
return imported;
}
/**
* Map a WHOOP response classification onto a `FailureKind` and record it.
* Shared by every per-resource catch-block and the token-refresh path.