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

+38 -6
View File
@@ -8,10 +8,12 @@
* credentials configured, token expiry, OAuth-connected; moodLog:
* credentials configured, enabled flag)
*
* Combining both into one fetch removes a third round-trip from the
* Settings page. The legacy /api/withings/status and
* /api/integrations/moodlog/status remain for the existing UI/tests
* — we add this in addition rather than rewriting them.
* This is the single fetch the Settings → Integrations cards read off
* — it carries every field the four cards render (Withings activity
* scope, WHOOP/Fitbit backfill state, moodLog webhook secret + entry
* count) so the per-card /api/<provider>/status round-trips are gone
* from the web. The legacy per-provider routes stay for the iOS/test
* callers; the web cards no longer hit them.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -22,6 +24,8 @@ import {
getPersistentFailureThreshold,
type IntegrationKey,
} from "@/lib/integrations/status";
import { hasActivityScope } from "@/lib/withings/client";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
export const dynamic = "force-dynamic";
@@ -38,6 +42,7 @@ export const GET = apiHandler(async () => {
withingsConn,
whoopConn,
fitbitConn,
moodLogEntryCount,
] = await Promise.all([
getIntegrationStatus(user.id, "withings"),
getIntegrationStatus(user.id, "moodlog"),
@@ -56,6 +61,7 @@ export const GET = apiHandler(async () => {
moodLogApiKeyEncrypted: true,
moodLogEnabled: true,
moodLogLastSyncedAt: true,
moodLogWebhookSecret: true,
},
}),
prisma.withingsConnection.findUnique({
@@ -64,6 +70,7 @@ export const GET = apiHandler(async () => {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
scope: true,
},
}),
prisma.whoopConnection.findUnique({
@@ -72,6 +79,7 @@ export const GET = apiHandler(async () => {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
prisma.fitbitConnection.findUnique({
@@ -80,8 +88,13 @@ export const GET = apiHandler(async () => {
tokenExpiresAt: true,
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
},
}),
// v1.7.0 sync — exclude tombstoned rows from the entry count.
prisma.moodEntry.count({
where: { userId: user.id, deletedAt: null },
}),
]);
const now = Date.now();
@@ -104,13 +117,24 @@ export const GET = apiHandler(async () => {
tokenExpired: withingsConn
? withingsConn.tokenExpiresAt.getTime() <= now
: null,
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string;
// `hasActivityScope` is the derived flag the reconnect banner reads.
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
} satisfies IntegrationViewModel & WithingsExtras,
{
...moodLogStatus,
configured:
!!dbUser?.moodLogUrlEncrypted && !!dbUser?.moodLogApiKeyEncrypted,
// moodLog "configured" tracks the URL alone (the API key is
// optional for the webhook-only path), matching the legacy
// /api/integrations/moodlog/status contract the card relied on.
configured: !!dbUser?.moodLogUrlEncrypted,
enabled: dbUser?.moodLogEnabled ?? false,
legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null,
// V3 audit STILL-V2-C-2: stored secret is AES-GCM encrypted at rest;
// decrypt for the settings page (legacy plaintext is also handled).
webhookSecret: readMoodLogSecret(dbUser?.moodLogWebhookSecret ?? null),
entryCount: moodLogEntryCount,
} satisfies IntegrationViewModel & MoodLogExtras,
{
...whoopStatus,
@@ -124,6 +148,7 @@ export const GET = apiHandler(async () => {
tokenExpired: whoopConn
? whoopConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: whoopConn ? !!whoopConn.backfillCompletedAt : null,
} satisfies IntegrationViewModel & WhoopExtras,
{
...fitbitStatus,
@@ -137,6 +162,7 @@ export const GET = apiHandler(async () => {
tokenExpired: fitbitConn
? fitbitConn.tokenExpiresAt.getTime() <= now
: null,
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
} satisfies IntegrationViewModel & FitbitExtras,
],
});
@@ -157,12 +183,16 @@ interface WithingsExtras {
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
scope: string | null;
hasActivityScope: boolean;
}
interface MoodLogExtras {
configured: boolean;
enabled: boolean;
legacyLastSyncedAt: string | null;
webhookSecret: string | null;
entryCount: number;
}
interface WhoopExtras {
@@ -172,6 +202,7 @@ interface WhoopExtras {
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
interface FitbitExtras {
@@ -181,4 +212,5 @@ interface FitbitExtras {
legacyLastSyncedAt: string | null;
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
}
+7 -2
View File
@@ -1,7 +1,7 @@
import { apiSuccess } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
type MoodAggregates,
@@ -19,11 +19,16 @@ export const dynamic = "force-dynamic";
* once per 60-s cache window, the browser only ever receives pre-shaped
* data (no raw mood rows). This follows the v1.8.3 anti-freeze posture
* — a cheap cached read, never a synchronous LLM call.
*
* v1.12.1 — stale-while-revalidate. On an expired or freshly-marked-
* stale bucket (a mood write marks rather than evicts) the prior
* aggregate is served immediately and a single background recompute
* warms a fresh one, so an active logger never re-pays the cold compute.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const result = await cached(
const result = await cachedSwr(
caches.moodInsights as ServerCache<MoodAggregates>,
user.id,
() => fetchMoodAggregates(user.id),
@@ -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;
+18
View File
@@ -59,6 +59,10 @@ async function primeAllCaches(): Promise<void> {
await cached(caches.moodAnalytics, USER_A, async () => ({ mood: 1 }));
await cached(caches.moodAnalytics, USER_B, async () => ({ mood: 2 }));
// v1.12.1 — mood-insights aggregate cache (SWR bucket).
await cached(caches.moodInsights, USER_A, async () => ({ mi: 1 }));
await cached(caches.moodInsights, USER_B, async () => ({ mi: 2 }));
await cached(caches.dashboardWidgets, USER_A, async () => ({ d: 1 }));
await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 }));
@@ -114,6 +118,20 @@ describe("invalidateUserMood", () => {
expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull();
expect(caches.moodAnalytics.get(USER_B)).not.toBeNull();
expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull();
// v1.12.1 — mood-insights is marked stale, not hard-evicted: the
// prior value is still serveable within the SWR window so the next
// reader pays no cold compute. (A plain `get()` would treat the
// collapsed TTL as expired and drop it, so assert via getAllowStale
// first — it must not consume the entry.) User B stays fresh.
expect(caches.moodInsights.getAllowStale(USER_A)).toEqual({
value: { mi: 1 },
stale: true,
});
expect(caches.moodInsights.getAllowStale(USER_B)).toEqual({
value: { mi: 2 },
stale: false,
});
});
});
+178
View File
@@ -10,6 +10,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
__resetAllCachesForTests,
cached,
cachedSwr,
caches,
hashCacheKey,
ServerCache,
@@ -222,6 +223,183 @@ describe("cached() observability wrapper", () => {
});
});
describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => {
it("serves a fresh hit without rebuilding", async () => {
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 60_000,
staleTtlMs: 600_000,
});
const builder = vi.fn(async () => "v");
const first = await cache.wrapSwr("k", builder);
expect(first.outcome).toBe("miss");
const second = await cache.wrapSwr("k", builder);
expect(second.outcome).toBe("hit");
expect(builder).toHaveBeenCalledTimes(1);
});
it("serves the stale value immediately and revalidates in the background", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let calls = 0;
const builder = vi.fn(async () => `v${++calls}`);
// Cold compute → v1.
const cold = await cache.wrapSwr("k", builder);
expect(cold.value).toBe("v1");
expect(cold.outcome).toBe("miss");
// Past the fresh TTL but inside the stale window.
vi.advanceTimersByTime(1500);
const stale = await cache.wrapSwr("k", builder);
// The active caller gets the prior value immediately — it did NOT
// pay the cold compute.
expect(stale.value).toBe("v1");
expect(stale.outcome).toBe("stale");
// The background recompute settles to a fresh value.
await vi.runAllTimersAsync();
expect(builder).toHaveBeenCalledTimes(2);
const warm = await cache.wrapSwr("k", builder);
expect(warm.value).toBe("v2");
expect(warm.outcome).toBe("hit");
});
it("coalesces a burst of stale reads into a single background rebuild", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
// The cold build resolves immediately; the rebuild is gated on a
// manual promise so all three stale reads fire before it settles —
// proving they coalesce onto one in-flight rebuild.
let releaseRebuild: (() => void) | null = null;
let calls = 0;
const builder = vi.fn(async () => {
calls += 1;
if (calls === 1) return "v1";
await new Promise<void>((r) => {
releaseRebuild = r;
});
return "v2";
});
await cache.wrapSwr("k", builder);
vi.advanceTimersByTime(1500);
const a = await cache.wrapSwr("k", builder);
const b = await cache.wrapSwr("k", builder);
const c = await cache.wrapSwr("k", builder);
expect([a.outcome, b.outcome, c.outcome]).toEqual([
"stale",
"stale",
"stale",
]);
// The single in-flight rebuild is still pending across the burst.
expect(builder).toHaveBeenCalledTimes(2);
releaseRebuild!();
await vi.runAllTimersAsync();
// Still exactly one cold build + one background rebuild.
expect(builder).toHaveBeenCalledTimes(2);
});
it("treats an entry past the stale window as a hard miss", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 2000,
});
let calls = 0;
const builder = vi.fn(async () => `v${++calls}`);
await cache.wrapSwr("k", builder);
// Past ttl + staleTtl → the stale window is closed.
vi.advanceTimersByTime(3500);
const miss = await cache.wrapSwr("k", builder);
expect(miss.outcome).toBe("miss");
expect(miss.value).toBe("v2");
});
it("markStaleByPrefix keeps serving the prior value under SWR (no cold re-pay)", async () => {
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 60_000,
staleTtlMs: 600_000,
});
let calls = 0;
const builder = vi.fn(async () => `v${++calls}`);
await cache.wrapSwr("u1|k", builder);
// Simulate a mood write marking the bucket stale rather than evicting.
expect(cache.markStaleByPrefix("u1|")).toBe(1);
// The next read still serves the prior value immediately (stale),
// not a hard miss — so the active logger doesn't re-pay the compute.
const afterMark = await cache.wrapSwr("u1|k", builder);
expect(afterMark.outcome).toBe("stale");
expect(afterMark.value).toBe("v1");
});
it("a rejected background rebuild keeps the stale value and retries next read", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
let calls = 0;
const builder = vi.fn(async () => {
calls += 1;
if (calls === 2) throw new Error("transient");
return `v${calls}`;
});
await cache.wrapSwr("k", builder);
vi.advanceTimersByTime(1500);
// Stale read triggers a background rebuild that rejects.
const stale = await cache.wrapSwr("k", builder);
expect(stale.value).toBe("v1");
await vi.runAllTimersAsync();
// The prior value still serves; the next read retries the build.
const retry = await cache.wrapSwr("k", builder);
expect(retry.outcome).toBe("stale");
expect(retry.value).toBe("v1");
await vi.runAllTimersAsync();
const warm = await cache.wrapSwr("k", builder);
expect(warm.value).toBe("v3");
});
it("cachedSwr annotates the outcome (hit / stale / miss)", async () => {
vi.useFakeTimers();
const cache = new ServerCache<string>({
name: "moodInsights",
maxEntries: 4,
ttlMs: 1000,
staleTtlMs: 600_000,
});
const annotate = vi.fn();
const builder = vi.fn(async () => "v");
await cachedSwr(cache, "u1", builder, annotate);
expect(annotate).toHaveBeenLastCalledWith({
meta: expect.objectContaining({ "cache.moodInsights.outcome": "miss" }),
});
vi.advanceTimersByTime(1500);
annotate.mockClear();
await cachedSwr(cache, "u1", builder, annotate);
expect(annotate).toHaveBeenLastCalledWith({
meta: expect.objectContaining({ "cache.moodInsights.outcome": "stale" }),
});
});
});
describe("global cache registry", () => {
it("exposes the eight blueprint caches as module-scope singletons", () => {
expect(caches.analytics).toBeDefined();
+6 -1
View File
@@ -78,7 +78,12 @@ export function invalidateUserMood(userId: string): void {
caches.insightsTargets.deleteByPrefix(userId);
// v1.8.5 — mood writes dirty every dimension of the mood-insights
// aggregate (heatmap cell, distribution, weekday, tag breakdown).
caches.moodInsights.deleteByPrefix(userId);
// v1.12.1 — mark stale rather than hard-evict: the mood-insights read
// is a multi-second cold compute, so an active logger writing back to
// back used to re-pay it on every entry. Marking stale keeps serving
// the prior aggregate (within the SWR window) while a single
// background recompute warms a fresh one.
caches.moodInsights.markStaleByPrefix(userId);
}
/**
+160 -4
View File
@@ -41,6 +41,15 @@ export interface ServerCacheOptions {
readonly maxEntries: number;
/** Time-to-live in milliseconds. Expired entries miss on read. */
readonly ttlMs: number;
/**
* v1.12.1 — stale-while-revalidate window in milliseconds. When set,
* an entry whose `ttlMs` has lapsed but is still inside
* `ttlMs + staleTtlMs` is served immediately (stale) by `wrapSwr()`,
* which kicks off a single background recompute to refresh it. Past
* the stale window the entry is a hard miss like any other. Leave
* unset for the legacy hard-TTL behaviour.
*/
readonly staleTtlMs?: number;
/**
* Optional name for observability — surfaced in `stats().name` so the
* wide-event meta keys can carry the cache identity without the
@@ -59,7 +68,10 @@ export interface ServerCacheStats {
}
export class ServerCache<T> {
private readonly map = new Map<string, { expiresAt: number; value: T }>();
private readonly map = new Map<
string,
{ expiresAt: number; staleUntil: number; value: T }
>();
private readonly pending = new Map<string, Promise<T>>();
private hits = 0;
private misses = 0;
@@ -114,12 +126,61 @@ export class ServerCache<T> {
this.evictions += 1;
}
}
const now = Date.now();
const expiresAt = now + (ttlMsOverride ?? this.opts.ttlMs);
this.map.set(key, {
expiresAt: Date.now() + (ttlMsOverride ?? this.opts.ttlMs),
expiresAt,
// The stale window extends past the fresh TTL only when the
// bucket opted into SWR; otherwise `staleUntil === expiresAt`
// so `getAllowStale` behaves exactly like `get`.
staleUntil: expiresAt + (this.opts.staleTtlMs ?? 0),
value,
});
}
/**
* v1.12.1 — read a key allowing a stale entry inside the SWR window.
* Returns the value plus a `stale` flag (true when the fresh TTL has
* lapsed but the entry is still inside `staleTtlMs`). Past the stale
* window the entry is removed and `null` is returned. LRU-touches a
* live or stale entry so a hot key never falls out under pressure.
*/
getAllowStale(key: string): { value: T; stale: boolean } | null {
const entry = this.map.get(key);
if (!entry) return null;
const now = Date.now();
if (entry.staleUntil <= now) {
this.map.delete(key);
return null;
}
// LRU touch.
this.map.delete(key);
this.map.set(key, entry);
return { value: entry.value, stale: entry.expiresAt <= now };
}
/**
* v1.12.1 — mark every entry under `prefix` stale without dropping it.
* Collapses the fresh TTL to "now" so the next `wrapSwr` read serves
* the prior value immediately (within the stale window) while a single
* coalesced recompute warms a fresh value. This replaces the
* hard-evict on a mood write so an active logger no longer re-pays the
* cold compute on every entry. No-op for buckets without `staleTtlMs`
* (the entry is already at/over its hard TTL after this, so the next
* read is a clean miss — identical to the old evict behaviour).
*/
markStaleByPrefix(prefix: string): number {
let marked = 0;
const now = Date.now();
for (const [key, entry] of this.map) {
if (key.startsWith(prefix)) {
entry.expiresAt = now;
marked += 1;
}
}
return marked;
}
/** Single-key eviction. Returns true when the key was present. */
delete(key: string): boolean {
return this.map.delete(key);
@@ -187,6 +248,64 @@ export class ServerCache<T> {
return { value, outcome: "miss" };
}
/**
* v1.12.1 — stale-while-revalidate read. On a fresh hit it returns the
* cached value like `wrap`. On a stale hit (entry past its fresh TTL
* but inside the SWR window) it returns the prior value IMMEDIATELY
* and kicks off a single background recompute — the active caller
* never pays the cold compute. On a hard miss / cold key it falls back
* to `wrap`'s single-flight builder and awaits it.
*
* Background revalidation reuses the `pending` single-flight slot so a
* burst of stale reads coalesces into one recompute. A rejected
* background build is swallowed (the stale value already served) and
* clears `pending` so the next read retries.
*/
async wrapSwr(
key: string,
builder: () => Promise<T>,
ttlMsOverride?: number,
): Promise<{
value: T;
outcome: "hit" | "stale" | "miss" | "stampede";
}> {
const cachedEntry = this.getAllowStale(key);
if (cachedEntry && !cachedEntry.stale) {
this.hits += 1;
return { value: cachedEntry.value, outcome: "hit" };
}
if (cachedEntry && cachedEntry.stale) {
// Serve stale now; revalidate in the background under the
// single-flight slot so concurrent stale reads share one rebuild.
this.hits += 1;
if (!this.pending.has(key)) {
const refresh = builder()
.then((value) => {
this.set(key, value, ttlMsOverride);
return value;
})
.catch((err) => {
// The stale value already went out; don't crash the worker.
// Re-throw so the join path of any awaiting caller sees it.
throw err;
})
.finally(() => {
this.pending.delete(key);
});
this.pending.set(key, refresh);
// Detach: the foreground caller does not await the rebuild.
// Swallow the rejection on the detached handle so an
// unhandled-rejection warning never fires.
refresh.catch(() => {});
}
return { value: cachedEntry.value, outcome: "stale" };
}
// Hard miss / cold key — fall back to the awaited single-flight path.
return this.wrap(key, builder, ttlMsOverride);
}
/** Snapshot of per-instance counters for the wide-event annotation. */
stats(): ServerCacheStats {
return {
@@ -264,13 +383,22 @@ export const caches = {
* v1.8.5 — pre-computed mood-insights aggregates (heatmap, distribution,
* weekday, tags, cross-metric correlations) for the Mood Insights page.
* Read on every `/insights/mood` mount; bounded 365-day live read behind
* the cache. 60 s TTL matches `moodAnalytics`; invalidated alongside it
* on a mood write via `invalidateUserMood`.
* the cache. 60 s fresh TTL matches `moodAnalytics`.
*
* v1.12.1 — stale-while-revalidate. The read route uses `cachedSwr` and
* a mood write marks the bucket stale (not a hard evict) via
* `invalidateUserMood`. An active logger now serves the prior aggregate
* immediately while a single background recompute warms a fresh one,
* instead of re-paying the multi-second cold compute on every entry
* (the v1.8.7 "sync evicting the cache all day" class, on mood). The
* 10-minute stale window bounds how old a served aggregate can be when
* no reader has triggered a revalidation.
*/
moodInsights: new ServerCache<unknown>({
name: "moodInsights",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.4.36 W1 — `/api/insights/targets` is read on every Insights
@@ -342,6 +470,34 @@ export async function cached<T>(
return value;
}
/**
* v1.12.1 — stale-while-revalidate read-through. Same observability
* contract as `cached` (annotates the wide-event with the outcome +
* key hash) but routes through `wrapSwr`: a stale entry inside the
* bucket's `staleTtlMs` window is served immediately while a single
* background recompute refreshes it, so an active caller never pays
* the cold compute on an expired or freshly-marked-stale bucket.
*/
export async function cachedSwr<T>(
cache: ServerCache<T>,
key: string,
builder: () => Promise<T>,
annotateFn?: (fields: { meta: Record<string, unknown> }) => void,
ttlMsOverride?: number,
): Promise<T> {
const { value, outcome } = await cache.wrapSwr(key, builder, ttlMsOverride);
if (annotateFn) {
const name = cache.stats().name;
annotateFn({
meta: {
[`cache.${name}.outcome`]: outcome,
[`cache.${name}.key_hash`]: hashCacheKey(key),
},
});
}
return value;
}
/** Test helper — reset every cache in the registry. */
export function __resetAllCachesForTests(): void {
for (const cache of Object.values(caches)) {