From 53e87ba3455f725a35daef60bea60a2831961dba Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Fri, 5 Jun 2026 03:24:24 +0200
Subject: [PATCH] perf: consolidate integration status query, add mood-insights
SWR, defer below-fold charts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Settings → Integrations fired five status round-trips on every mount —
the consolidated /api/integrations/status alongside the four per-provider
/api//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)
---
src/app/api/integrations/status/route.ts | 44 ++++-
src/app/api/mood/insights/route.ts | 9 +-
.../insights/derived/trajectory-fan.tsx | 79 ++++++++
.../derived/trajectory-forecast-card.tsx | 65 ++-----
.../insights/mood/mood-insights-sections.tsx | 57 ++++--
.../__tests__/integrations-section.test.tsx | 106 ++++-------
.../settings/integrations-section.tsx | 159 +++++-----------
src/lib/cache/__tests__/invalidate.test.ts | 18 ++
src/lib/cache/__tests__/server-cache.test.ts | 178 ++++++++++++++++++
src/lib/cache/invalidate.ts | 7 +-
src/lib/cache/server-cache.ts | 164 +++++++++++++++-
11 files changed, 632 insertions(+), 254 deletions(-)
create mode 100644 src/components/insights/derived/trajectory-fan.tsx
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index 128ae54d4..9b3d768f8 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -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//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;
}
diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts
index a21037371..c6a1b2770 100644
--- a/src/app/api/mood/insights/route.ts
+++ b/src/app/api/mood/insights/route.ts
@@ -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,
user.id,
() => fetchMoodAggregates(user.id),
diff --git a/src/components/insights/derived/trajectory-fan.tsx b/src/components/insights/derived/trajectory-fan.tsx
new file mode 100644
index 000000000..d3f8b11ad
--- /dev/null
+++ b/src/components/insights/derived/trajectory-fan.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/insights/derived/trajectory-forecast-card.tsx b/src/components/insights/derived/trajectory-forecast-card.tsx
index af1e6cd6c..99208be81 100644
--- a/src/components/insights/derived/trajectory-forecast-card.tsx
+++ b/src/components/insights/derived/trajectory-forecast-card.tsx
@@ -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: () => ,
+ },
+);
// 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"
>
-
-
-
-
-
-
-
-
-
-
+
{/* The mandatory conditional caveat — never a certainty, never a
diff --git a/src/components/insights/mood/mood-insights-sections.tsx b/src/components/insights/mood/mood-insights-sections.tsx
index 5f34a0f52..7271c786e 100644
--- a/src/components/insights/mood/mood-insights-sections.tsx
+++ b/src/components/insights/mood/mood-insights-sections.tsx
@@ -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: () => (
+
+ ),
+ },
+);
+const MoodWeekdayChart = dynamic(
+ () =>
+ import("./mood-weekday-chart").then((mod) => ({
+ default: mod.MoodWeekdayChart,
+ })),
+ {
+ ssr: false,
+ loading: () => (
+
+ ),
+ },
+);
+const MoodTimeOfDayChart = dynamic(
+ () =>
+ import("./mood-time-of-day-chart").then((mod) => ({
+ default: mod.MoodTimeOfDayChart,
+ })),
+ {
+ ssr: false,
+ loading: () => ,
+ },
+);
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,
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index 0b82f5539..b1f7511e9 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -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//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
diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx
index f2c127af9..2fbfb4616 100644
--- a/src/components/settings/integrations-section.tsx
+++ b/src/components/settings/integrations-section.tsx
@@ -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() {
-
-
-
+
+
+
{moodLogEnabled && }
);
}
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(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;
diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts
index 47cb3c014..d927feac0 100644
--- a/src/lib/cache/__tests__/invalidate.test.ts
+++ b/src/lib/cache/__tests__/invalidate.test.ts
@@ -59,6 +59,10 @@ async function primeAllCaches(): Promise {
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,
+ });
});
});
diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts
index e2d8bc52e..6088a2e25 100644
--- a/src/lib/cache/__tests__/server-cache.test.ts
+++ b/src/lib/cache/__tests__/server-cache.test.ts
@@ -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({
+ 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({
+ 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({
+ 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((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({
+ 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({
+ 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({
+ 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({
+ 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();
diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts
index f8b57b4bd..c919aa245 100644
--- a/src/lib/cache/invalidate.ts
+++ b/src/lib/cache/invalidate.ts
@@ -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);
}
/**
diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts
index 638df3f07..741af07cb 100644
--- a/src/lib/cache/server-cache.ts
+++ b/src/lib/cache/server-cache.ts
@@ -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 {
- private readonly map = new Map();
+ private readonly map = new Map<
+ string,
+ { expiresAt: number; staleUntil: number; value: T }
+ >();
private readonly pending = new Map>();
private hits = 0;
private misses = 0;
@@ -114,12 +126,61 @@ export class ServerCache {
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 {
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,
+ 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({
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(
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(
+ cache: ServerCache,
+ key: string,
+ builder: () => Promise,
+ annotateFn?: (fields: { meta: Record }) => void,
+ ttlMsOverride?: number,
+): Promise {
+ 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)) {