Accelerate the chart range switch, prefetch two list pages, calm two polls

placeholderData keeps the previous range painted while a health-chart
range switch fetches, paired with its truth condition: the container
dims and goes inert while isPlaceholderData is true, so the old range
cannot masquerade as the new one. The mood chart needs no pairing — its
range is a client-side slice over one static-key query and never drops
to a skeleton.

/checkups and /mood gain the dashboard-template RSC prefetch: the
primary list read moves into a shared lib read (route + wrapper consume
one query, so filter/order/wire shape cannot drift), the wrapper honours
the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through
getUnswitchedSession, JSON-round-trips the dehydrated state, and fails
soft to the bare client on every error path. Page-prefetch unit tests
pin the seeded keys and the fail-soft arms; the SSR hydration spec
gains an assertion per page.

The achievement-unlock poll drops to five minutes (in-app writes already
invalidate the cell; the poll only covers out-of-band ingest), and the
plan-proposal poll gains the advisor-style bounded attempt ceiling so an
open thread stops polling after its budget.
This commit is contained in:
Marc-André Bombeck committed 2026-08-13 19:06:56 +02:00
1 parent ac96e5e975
commit 61114fab90
31 files changed
+962 -326

No files matched your search

@@ -29,6 +29,9 @@ vi.mock("@/hooks/use-auth", () => ({
function mockQueryError() {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: undefined,
isLoading: false,
@@ -39,6 +39,9 @@ describe("<HealthChart> — accessibility label", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -30,6 +30,9 @@ const sample30Days = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: sample30Days.map((row) => {
const ts = Date.parse(row.measuredAt);
@@ -30,6 +30,9 @@ async function renderChart(
extraProps: Record<string, unknown> = {},
) {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -38,6 +38,9 @@ vi.mock("@/hooks/use-auth", () => ({
async function renderChart(data: unknown[]): Promise<string> {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -50,6 +50,9 @@ describe("<HealthChart> — card heading level", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -22,6 +22,9 @@ const sampleData = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -39,6 +39,9 @@ describe("<HealthChart> — empty-window state (W11-M6)", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -41,6 +41,9 @@ const sampleSeries = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: sampleSeries.map((row) => {
const ts = Date.parse(row.measuredAt);
@@ -22,6 +22,9 @@ import { renderToStaticMarkup } from "react-dom/server";
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: ({
queryFn,
queryKey,
@@ -15,6 +15,9 @@ import { renderToStaticMarkup } from "react-dom/server";
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: ({
queryFn,
queryKey,
+18 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
@@ -772,7 +772,13 @@ export function HealthChart({
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({
// A range-tab change re-keys the query; without placeholder data every
// switch dropped the painted chart to a skeleton for the fetch round-trip.
// Keeping the previous range's series visible is only honest because the
// render below pairs it with a visual dimming while `isPlaceholderData`
// is true — the old range must never masquerade as the new one.
placeholderData: keepPreviousData,
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
// and a fresh measurement evicts every per-chart daily cache in one
@@ -1867,7 +1873,16 @@ export function HealthChart({
/>
) : (
<>
<div className={`relative ${chartHeightClass}`}>
<div
// The truth half of `placeholderData: keepPreviousData` above:
// while the re-keyed range is still fetching, the previous
// range's series stays painted but visibly dimmed and inert, so
// it cannot masquerade as the new range.
aria-busy={isPlaceholderData}
className={`relative ${chartHeightClass} transition-opacity ${
isPlaceholderData ? "pointer-events-none opacity-50" : ""
}`}
>
{visibleBands.length > 0 ? (
// v1.4.27 R3d MB2 — band overlay positioning fix. The
// overlay used to inset `right: 18px` while the chart
@@ -80,7 +80,12 @@ export function AchievementUnlockNotifier({
// to re-render on the same cadence.
const { data } = useAchievementsQuery({
enabled: !!user && achievementsEnabled,
refetchInterval: 2 * 60 * 1000,
// 5 min: every in-app write already invalidates the achievements cell
// (the `*DependentKeys` bundles all carry it), so this poll exists ONLY
// to surface an unlock earned by out-of-band ingest (an iOS batch, a
// Withings sync). A toast for those arriving up to five minutes later is
// an honest trade against an app-global 2-minute poll.
refetchInterval: 5 * 60 * 1000,
});
useEffect(() => {
@@ -5,7 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
import { PlanProposalCards } from "../plan-proposal-card";
import {
PlanProposalCards,
PROPOSAL_POLL_MS,
PROPOSAL_POLL_MAX_ATTEMPTS,
nextProposalPollInterval,
} from "../plan-proposal-card";
/**
* The plan-proposal confirm cards at the thread tail. SSR string render
@@ -80,3 +85,21 @@ describe("<PlanProposalCards>", () => {
expect(html).toContain("after dinner");
});
});
describe("nextProposalPollInterval", () => {
it("polls at the slow interval below the attempt ceiling", () => {
expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
PROPOSAL_POLL_MS,
);
});
it("stops polling once the attempt ceiling is reached", () => {
// Watched red: asserting an interval at the cap fails — the ceiling is
// live, not decorative.
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
false,
);
});
});
@@ -33,7 +33,30 @@ import {
*/
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
const PROPOSAL_POLL_MS = 20_000;
export const PROPOSAL_POLL_MS = 20_000;
/**
* Attempt ceiling for the proposal poll. The extractor runs right after the
* turn and lands its proposal within a couple of minutes; 15 polls (~5 min
* of coverage) catch it with margin, and every settled response counts an
* attempt. Without the cap an open thread polled the proposed list every
* 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval`
* (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and
* the next mount of a fresh conversation restarts the budget.
*/
export const PROPOSAL_POLL_MAX_ATTEMPTS = 15;
/**
* Decide whether the proposal query schedules its next poll. Pure so the
* ceiling is unit-testable: returns the slow interval until the attempt cap
* is reached, then `false` (poll off).
*/
export function nextProposalPollInterval(
dataUpdateCount: number,
): number | false {
if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false;
return PROPOSAL_POLL_MS;
}
type SettledOutcome = "accepted" | "declined";
@@ -49,7 +72,7 @@ export function PlanProposalCards({
const query = useCoachPlans({
filter: { status: "proposed" },
refetchInterval: PROPOSAL_POLL_MS,
refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount),
});
// Quiet on error by design: the thread is the Coach's surface, and a failed
+1 -2
View File
@@ -105,6 +105,7 @@ import {
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size";
import { localizedApiError } from "@/lib/api/localized-error";
// Re-export the score map under the legacy local name to keep the
@@ -140,8 +141,6 @@ interface MoodEntry {
moodLoggedAt: string;
}
const PAGE_SIZE = 25;
const MOOD_LEVELS_LIST = [
"SUPER_GUT",
"GUT",