perf: consolidate integration status query, add mood-insights SWR, defer below-fold charts

Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api/<provider>/status queries. Extend the consolidated endpoint to carry
every field the four cards render (Withings activity scope, WHOOP/Fitbit
backfill state, moodLog webhook secret + entry count) and drive all four
cards off the single envelope. The legacy per-provider routes stay for the
iOS/test callers; the web cards no longer hit them.

Mood-insights cache was a plain 60 s TTL that was hard-evicted on every
mood write, so an active logger re-paid the multi-second cold compute on
every entry. Add stale-while-revalidate to ServerCache (wrapSwr / cachedSwr
+ a markStaleByPrefix that collapses the fresh TTL without dropping the
value), give the moodInsights bucket a 10-minute stale window, route the
read through cachedSwr, and switch the mood-write invalidation from a hard
evict to a mark-stale so the prior aggregate serves immediately while a
single background recompute warms a fresh one.

Defer the eager Recharts on the mood-insights surface. The trajectory
forecast card pulled Recharts into the initial chunk of every
trajectory-eligible metric page though the main chart is already deferred;
split its fan band into trajectory-fan.tsx behind next/dynamic. The three
below-fold mood mini-charts (distribution / weekday / time-of-day) were
static imports under an already-dynamic hero line chart; defer them with
next/dynamic. Each loader paints a skeleton sized to the chart's own band
so the chunk arrives without a layout shift; the charts stay Recharts and
visually identical.

(cherry picked from commit 2b627893ab6b4d7bc457fc18e6e5585b048190e1)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 03:25:22 +02:00
1 parent 05cff531e4
commit 53e87ba345
11 files changed
+632 -254

No files matched your search

@@ -0,0 +1,79 @@
"use client";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
ReferenceLine,
} from "recharts";
/**
* v1.12.1 — the Recharts fan band of the trajectory forecast card,
* split into its own module so it can be `next/dynamic`-deferred.
*
* `TrajectoryForecastCard` is mounted by the shared `HealthKitMetricPage`
* on every trajectory-eligible metric page; before this split it pulled
* Recharts into the page's initial chunk eagerly (the main metric chart
* is already deferred via `HealthChartDynamic`). Isolating the only
* Recharts-touching subtree here lets the card chrome render without the
* chart library and lazy-load this chunk only when there's a projection
* to draw — keeping the chart visually identical (same ComposedChart /
* Area / Line / ReferenceLine geometry, same `isAnimationActive={false}`).
*/
export interface TrajectoryFanPoint {
date: string;
projected: number;
/** Stacked transparent base (= bandLow). */
base: number;
/** Shaded band height (= bandHigh − bandLow). */
range: number;
}
export function TrajectoryFan({
points,
color,
}: {
points: TrajectoryFanPoint[];
color: string;
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={points}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
>
<XAxis dataKey="date" hide />
<YAxis domain={["auto", "auto"]} hide />
<Area
dataKey="base"
stackId="band"
stroke="none"
fill="none"
isAnimationActive={false}
/>
<Area
dataKey="range"
stackId="band"
stroke="none"
fill={color}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Line
dataKey="projected"
type="monotone"
stroke={color}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
isAnimationActive={false}
/>
<ReferenceLine x="now" stroke="var(--border)" strokeDasharray="2 2" />
</ComposedChart>
</ResponsiveContainer>
);
}
@@ -1,15 +1,7 @@
"use client";
import { useMemo } from "react";
import {
ResponsiveContainer,
ComposedChart,
Area,
Line,
XAxis,
YAxis,
ReferenceLine,
} from "recharts";
import dynamic from "next/dynamic";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
@@ -18,6 +10,22 @@ import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
import { METRIC_PROVENANCE } from "@/components/insights/derived/standards";
import { useDerivedMetric } from "@/components/insights/derived/use-derived-metric";
// v1.12.1 — defer the Recharts fan band so the card chrome (and the whole
// HealthKit metric page that always mounts this card on a trajectory-
// eligible type) no longer pulls Recharts into the initial chunk. The
// loading shell is a plain box that fills the same fixed-height band the
// chart paints, so there is no layout shift when the chunk arrives.
const TrajectoryFanDynamic = dynamic(
() =>
import("@/components/insights/derived/trajectory-fan").then((mod) => ({
default: mod.TrajectoryFan,
})),
{
ssr: false,
loading: () => <div className="bg-muted/20 h-full w-full rounded" />,
},
);
// Type-only — the compute payload never drags the server graph into the
// client bundle (the v1.9.0 lesson, mirrored across the derived surfaces).
import type { TrajectoryValue } from "@/lib/insights/derived/trajectory";
@@ -303,44 +311,7 @@ export function TrajectoryForecastCard({
className={cn("w-full", compact ? "h-20" : "h-28")}
data-slot="trajectory-chart"
>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chart.points}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
>
<XAxis dataKey="date" hide />
<YAxis domain={["auto", "auto"]} hide />
<Area
dataKey="base"
stackId="band"
stroke="none"
fill="none"
isAnimationActive={false}
/>
<Area
dataKey="range"
stackId="band"
stroke="none"
fill={color}
fillOpacity={0.15}
isAnimationActive={false}
/>
<Line
dataKey="projected"
type="monotone"
stroke={color}
strokeWidth={2}
strokeDasharray="4 3"
dot={false}
isAnimationActive={false}
/>
<ReferenceLine
x="now"
stroke="var(--border)"
strokeDasharray="2 2"
/>
</ComposedChart>
</ResponsiveContainer>
<TrajectoryFanDynamic points={chart.points} color={color} />
</div>
{/* The mandatory conditional caveat — never a certainty, never a
@@ -1,5 +1,6 @@
"use client";
import dynamic from "next/dynamic";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -9,14 +10,50 @@ import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodDistributionChart,
type MoodDistributionRow,
} from "./mood-distribution-chart";
import {
MoodWeekdayChart,
type MoodWeekdayRow,
} from "./mood-weekday-chart";
// v1.12.1 — the three Recharts mini-charts on this below-fold cluster are
// deferred via `next/dynamic`. The mood hero line chart above them is
// already dynamic on the page, so static-importing these pulled Recharts
// into the initial chunk for no first-paint benefit. Each loader paints a
// skeleton sized to the chart's own band so the deferred chunk arrives
// without a layout shift (charts stay Recharts, visually identical). Types
// stay value-free imports so they don't drag the chunk back in.
import type { MoodDistributionRow } from "./mood-distribution-chart";
import type { MoodWeekdayRow } from "./mood-weekday-chart";
import type { MoodTimeOfDayPattern } from "./mood-time-of-day-chart";
const MoodDistributionChart = dynamic(
() =>
import("./mood-distribution-chart").then((mod) => ({
default: mod.MoodDistributionChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="aspect-[3/2] min-h-[180px] w-full rounded-md" />
),
},
);
const MoodWeekdayChart = dynamic(
() =>
import("./mood-weekday-chart").then((mod) => ({
default: mod.MoodWeekdayChart,
})),
{
ssr: false,
loading: () => (
<Skeleton className="aspect-[3/2] min-h-[160px] w-full rounded-md" />
),
},
);
const MoodTimeOfDayChart = dynamic(
() =>
import("./mood-time-of-day-chart").then((mod) => ({
default: mod.MoodTimeOfDayChart,
})),
{
ssr: false,
loading: () => <Skeleton className="h-[220px] w-full rounded-md" />,
},
);
import {
MoodTagBreakdown,
type MoodTagRow,
@@ -34,10 +71,6 @@ import {
type MoodNarrativeItem,
} from "./mood-narrative-feed";
import { MoodInTargetTile } from "./mood-in-target-tile";
import {
MoodTimeOfDayChart,
type MoodTimeOfDayPattern,
} from "./mood-time-of-day-chart";
import {
MoodStabilityTile,
type MoodStabilityData,
@@ -41,24 +41,13 @@ vi.mock("@/hooks/use-auth", () => ({
}),
}));
// v1.12.1 — the four cards now read every field off the single
// /api/integrations/status envelope; the per-card /api/<provider>/status
// queries are gone. The test mock only needs the consolidated payload.
let integrationStatusPayload: unknown = null;
let withingsStatusPayload: unknown = { connected: false, configured: false };
let moodLogStatusPayload: unknown = {
configured: false,
enabled: false,
lastSyncedAt: null,
entryCount: 0,
webhookSecret: null,
};
function setIntegrationStatus(payload: unknown) {
integrationStatusPayload = payload;
}
function setWithingsStatus(payload: unknown) {
withingsStatusPayload = payload;
}
function setMoodLogStatus(payload: unknown) {
moodLogStatusPayload = payload;
}
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
@@ -78,16 +67,6 @@ vi.mock("@tanstack/react-query", () => ({
isLoading: false,
};
}
if (key === "withings/status") {
return { data: withingsStatusPayload, isLoading: false };
}
if (key === "moodlog-status") {
return {
data: moodLogStatusPayload,
isLoading: false,
refetch: vi.fn(),
};
}
return { data: null, isLoading: false, refetch: vi.fn() };
},
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
@@ -119,14 +98,6 @@ function count(html: string, needle: string): number {
describe("IntegrationsSection — single-status-display contract (A5)", () => {
beforeEach(() => {
integrationStatusPayload = null;
withingsStatusPayload = { connected: false, configured: false };
moodLogStatusPayload = {
configured: false,
enabled: false,
lastSyncedAt: null,
entryCount: 0,
webhookSecret: null,
};
});
it("renders exactly ONE status pill per card when both integrations are healthy", () => {
@@ -139,6 +110,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
{
integration: "moodlog",
@@ -146,21 +121,14 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-09T17:00:00.000Z",
lastAttemptAt: "2026-05-09T17:00:00.000Z",
lastError: null,
configured: true,
enabled: true,
legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
entryCount: 42,
webhookSecret: "secret123",
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-09T18:00:00.000Z",
});
setMoodLogStatus({
configured: true,
enabled: true,
lastSyncedAt: "2026-05-09T17:00:00.000Z",
entryCount: 42,
webhookSecret: "secret123",
});
const html = render();
// Exactly one pill per card → 4 pills total (Withings, moodLog, WHOOP,
@@ -186,6 +154,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-09T08:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 503 - upstream",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T08:00:00.000Z",
hasActivityScope: true,
},
{
integration: "moodlog",
@@ -196,11 +168,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-09T08:00:00.000Z",
});
const html = render();
// Pill carries the "Error — reconnect" copy via the pill's
@@ -226,6 +193,11 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings refresh error: 100 - invalid_grant",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
tokenExpired: true,
hasActivityScope: true,
},
{
integration: "moodlog",
@@ -236,12 +208,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-08T12:00:00.000Z",
tokenExpired: true,
});
const html = render();
expect(html).toContain('data-state="error"');
@@ -293,6 +259,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-08T12:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: "Withings activity error: 293",
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-08T12:00:00.000Z",
hasActivityScope: true,
},
{
integration: "moodlog",
@@ -303,11 +273,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-08T12:00:00.000Z",
});
const html = render();
expect(html).toContain('data-state="parked"');
@@ -330,6 +295,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-09T18:00:00.000Z",
lastAttemptAt: "2026-05-09T18:00:00.000Z",
lastError: null,
connected: true,
configured: true,
legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
hasActivityScope: true,
},
{
integration: "moodlog",
@@ -337,21 +306,14 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
lastSuccessAt: "2026-05-09T17:00:00.000Z",
lastAttemptAt: "2026-05-09T17:00:00.000Z",
lastError: null,
configured: true,
enabled: true,
legacyLastSyncedAt: "2026-05-09T17:00:00.000Z",
entryCount: 42,
webhookSecret: "secret123",
},
],
});
setWithingsStatus({
connected: true,
configured: true,
lastSyncedAt: "2026-05-09T18:00:00.000Z",
});
setMoodLogStatus({
configured: true,
enabled: true,
lastSyncedAt: "2026-05-09T17:00:00.000Z",
entryCount: 42,
webhookSecret: "secret123",
});
const html = render();
// Every integration card includes the section divider data-testid so the
+49 -110
View File
@@ -42,14 +42,6 @@ import {
queryKeys,
} from "@/lib/query-keys";
interface MoodLogStatus {
configured: boolean;
enabled: boolean;
lastSyncedAt: string | null;
entryCount: number;
webhookSecret: string | null;
}
interface GlobalServiceAvailability {
telegramGlobal: boolean;
ntfyGlobal: boolean;
@@ -88,6 +80,14 @@ interface IntegrationStatusViewModel {
tokenExpiresAt?: string | null;
tokenExpired?: boolean | null;
enabled?: boolean;
// Withings activity-scope reconnect banner.
scope?: string | null;
hasActivityScope?: boolean;
// WHOOP / Fitbit backfill-in-progress note.
backfillCompleted?: boolean | null;
// moodLog webhook secret + entry count.
webhookSecret?: string | null;
entryCount?: number;
}
interface IntegrationStatusEnvelope {
@@ -218,25 +218,17 @@ export function IntegrationsSection() {
</p>
</header>
<WithingsCard
isAuthenticated={isAuthenticated}
viewModel={withingsViewModel}
/>
<WhoopCard isAuthenticated={isAuthenticated} viewModel={whoopViewModel} />
<FitbitCard
isAuthenticated={isAuthenticated}
viewModel={fitbitViewModel}
/>
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
{moodLogEnabled && <MoodLogCard viewModel={moodLogViewModel} />}
</section>
);
}
function WithingsCard({
isAuthenticated,
viewModel,
}: {
isAuthenticated: boolean;
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
@@ -254,28 +246,12 @@ function WithingsCard({
);
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.withingsStatus(),
queryFn: async () => {
const res = await fetch("/api/withings/status");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as {
connected: boolean;
configured: boolean;
lastSyncedAt?: string | null;
connectedAt?: string;
tokenExpired?: boolean;
// v1.4.25 W5d — reconnect banner conditional. `null` =
// legacy v1.4.24 connection without `user.activity`; the
// user needs to re-auth before steps / active energy /
// distance / floors ingest unlocks.
scope?: string | null;
hasActivityScope?: boolean;
};
},
enabled: isAuthenticated,
});
// v1.12.1 — the Withings status is read off the consolidated
// /api/integrations/status envelope (the `viewModel` prop). The
// per-card /api/withings/status round-trip is gone; the envelope
// carries every field this card renders (connected / configured /
// last-sync / activity scope).
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
@@ -383,16 +359,16 @@ function WithingsCard({
}
// The pill state derives from the cross-integration status
// envelope, but the per-card `lastSyncedAt` is sourced from the
// Withings-specific endpoint so it stays accurate immediately
// after a manual "Sync now" (which only invalidates the per-card
// query). When neither endpoint has answered yet we fall back to
// "disconnected" so the card never renders a status-less header.
// envelope. `legacyLastSyncedAt` is the connection row's own
// last-sync timestamp (carried in the same envelope) so the pill
// still paints a fresh "X min ago" right after a manual sync once
// the envelope refetches. When the envelope hasn't answered yet we
// fall back to "disconnected" so the card never renders status-less.
const pillState: IntegrationPillState = status?.connected
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
// v1.4.43 W14 — `parked` and `error` both want the underlying error
// message surfaced under the pill (the pill says "what" — the
// message says "why"). Other states leave the inline line off.
@@ -703,10 +679,8 @@ function WithingsCard({
}
function WhoopCard({
isAuthenticated,
viewModel,
}: {
isAuthenticated: boolean;
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
@@ -724,24 +698,9 @@ function WhoopCard({
);
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.whoopStatus(),
queryFn: async () => {
const res = await fetch("/api/whoop/status");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as {
connected: boolean;
configured: boolean;
lastSyncedAt?: string | null;
connectedAt?: string;
tokenExpired?: boolean;
backfillCompleted?: boolean;
scope?: string | null;
};
},
enabled: isAuthenticated,
});
// v1.12.1 — read off the consolidated /api/integrations/status
// envelope; the per-card /api/whoop/status round-trip is gone.
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
@@ -853,7 +812,7 @@ function WhoopCard({
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
@@ -1131,14 +1090,13 @@ function WhoopCard({
// v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a
// BYO-key credentials form first, then an OAuth connect, then the
// sync/test/disconnect action row + parked-resume banner. Status reads from the
// dedicated /api/fitbit/status (queryKeys.fitbitStatus); the pill/error/parked
// state comes off the cross-integration envelope view-model like WHOOP.
// sync/test/disconnect action row + parked-resume banner. v1.12.1 — status
// reads off the consolidated /api/integrations/status envelope (the per-card
// /api/fitbit/status round-trip is gone); the pill/error/parked state comes off
// the same view-model like WHOOP.
function FitbitCard({
isAuthenticated,
viewModel,
}: {
isAuthenticated: boolean;
viewModel: IntegrationStatusViewModel | undefined;
}) {
const { t } = useTranslations();
@@ -1156,24 +1114,7 @@ function FitbitCard({
);
const queryClient = useQueryClient();
const { data: status } = useQuery({
queryKey: queryKeys.fitbitStatus(),
queryFn: async () => {
const res = await fetch("/api/fitbit/status");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as {
connected: boolean;
configured: boolean;
lastSyncedAt?: string | null;
connectedAt?: string;
tokenExpired?: boolean;
backfillCompleted?: boolean;
scope?: string | null;
};
},
enabled: isAuthenticated,
});
const status = viewModel;
const disconnect = useMutation({
mutationFn: async () => {
@@ -1287,7 +1228,7 @@ function FitbitCard({
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
const errorMessage =
(pillState === "error" || pillState === "parked") && viewModel?.lastError
? viewModel.lastError
@@ -1595,14 +1536,15 @@ function MoodLogCard({
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const { data: status, refetch: refetchStatus } = useQuery({
queryKey: queryKeys.moodlogStatus(),
queryFn: async () => {
const res = await fetch("/api/integrations/moodlog/status");
if (!res.ok) throw new Error("Failed");
return (await res.json()).data as MoodLogStatus;
},
});
// v1.12.1 — read off the consolidated /api/integrations/status
// envelope; the per-card /api/integrations/moodlog/status round-trip
// is gone. Mutations invalidate the envelope so the card's webhook
// secret / entry-count / last-sync repaint on the next refetch.
const status = viewModel;
const refreshStatus = () =>
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
async function handleSave(e: React.FormEvent) {
e.preventDefault();
@@ -1618,8 +1560,7 @@ function MoodLogCard({
setMsgType("success");
setUrl("");
setApiKey("");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
void refreshStatus();
} else {
const json = await res.json();
setMsg(json.error || t("settings.savingError"));
@@ -1646,8 +1587,7 @@ function MoodLogCard({
),
);
setMsgType("success");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
void refreshStatus();
} else {
setMsg(t("settings.moodLogSyncFailed"));
setMsgType("error");
@@ -1664,20 +1604,19 @@ function MoodLogCard({
if (res.ok) {
setMsg(t("settings.moodLogDisconnected"));
setMsgType("success");
await refetchStatus();
queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() });
void refreshStatus();
}
}
// Mirror Withings logic: the pill state comes from the cross-
// integration envelope; per-card `lastSyncedAt` from the Mood Log
// endpoint so a manual sync paints fresh "X min ago" without
// waiting for the envelope to refetch.
// integration envelope, which also carries `legacyLastSyncedAt`
// (the user's moodLog last-sync timestamp) so a manual sync paints
// a fresh "X min ago" once the envelope refetches.
const pillState: IntegrationPillState = status?.configured
? pillStateFor(viewModel)
: "disconnected";
const pillLastSyncAt =
status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
status?.legacyLastSyncedAt ?? viewModel?.lastSuccessAt ?? null;
const errorMessage =
pillState === "error" && viewModel?.lastError ? viewModel.lastError : null;