mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user