mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
05cff531e4
commit
53e87ba345
11 files changed
+632
-254
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
Vendored
+6
-1
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+160
-4
@@ -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)) {
|
||||
|
||||
Reference in new issue
Block a user