mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user