mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge pull request #790 from MBombeck/release/v1.37.16
Release v1.37.16
This commit is contained in:
79 files changed
+1469
-380
No files matched your search
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [1.37.16] — 2026-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
- The correlations page no longer recomputes its full 180-day discovery on every visit. The first request computes and caches the result server-side; repeat visits within the hour answer from the cache, and any new measurement, mood entry, illness episode or custom metric refreshes it. Accepting or dismissing a pattern still takes effect immediately.
|
||||
- Switching a chart's time range keeps the previous chart visible, slightly dimmed, while the new range loads instead of dropping to a placeholder. The dimming is deliberate: what you see during the switch is marked as the old range, not passed off as the new one.
|
||||
- The checkups and mood pages send their first page of data along with the page itself, so they render with content instead of a loading skeleton. Self-hosters can switch this off with the existing `DASHBOARD_SSR_PREFETCH` flag.
|
||||
- Two background checks are calmer now. The achievement check runs every five minutes instead of every two, since anything you do in the app updates instantly anyway and the poll only exists for data arriving from a device. The Coach plan-proposal check stops on its own instead of polling an open conversation forever.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A checkup accepted from a Coach suggestion appears on the checkups page and the Today rail immediately. Before, it stayed invisible for up to five minutes.
|
||||
- Changing a target, a threshold or the health score configuration is reflected on the dashboard right away, including the hero bands and score context. Before, the dashboard kept showing the old configuration until its next background refresh.
|
||||
- Starting or ending an illness episode updates the dashboard's rest mode immediately, booking an appointment puts it on the Today rail immediately, and logging a vaccination dose updates the booster reminder on the rail immediately.
|
||||
- Completing a mental-health screening refreshes the checkup list right away, and changing a source priority refreshes the affected charts right away.
|
||||
- Background syncs from Fitbit, Oura, Polar, WHOOP, Strava and Nightscout now tell the server-side caches that new data arrived, so analytics and workout views pick the data up on the next read instead of waiting out the cache lifetime. The same applies to illness, cycle and appointment changes.
|
||||
- A structural test now enforces that every place that writes data a daily view reads also triggers that view's refresh, so this class of stale display cannot quietly return.
|
||||
|
||||
## [1.37.15] — 2026-08-13
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.37.15
|
||||
version: 1.37.16
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -97,4 +97,45 @@ test.describe("dashboard hydration with the SSR prefetch on", () => {
|
||||
expect(witness?.seen).toBe(true);
|
||||
expect(witness?.replaced).toBe(0);
|
||||
});
|
||||
|
||||
// The two list-page prefetch wrappers (`/checkups`, `/mood`) hand TanStack a
|
||||
// server-dehydrated state through the same HydrationBoundary mechanism the
|
||||
// dashboard uses. Their page-clients render behind a `useMounted` gate, so
|
||||
// the streamed HTML is the auth-gate shell by design — the node-identity
|
||||
// witness has nothing to latch. What CAN break, and what these assert, is
|
||||
// the hydration itself: a dehydrated state that disagrees with the client
|
||||
// render surfaces as a React #418 bailout on exactly this server (the
|
||||
// mocked suite runs with the prefetch OFF and can never see it).
|
||||
for (const target of [
|
||||
{
|
||||
path: "/checkups",
|
||||
name: "checkups",
|
||||
readySelector: '[data-testid="checkups-tab-vorsorge"]',
|
||||
},
|
||||
{
|
||||
path: "/mood",
|
||||
name: "mood",
|
||||
readySelector: '[data-tour-id="mood-hero"]',
|
||||
},
|
||||
]) {
|
||||
test(`hydrates ${target.name} with its prefetched list state`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const clientErrors: string[] = [];
|
||||
page.on("pageerror", (error) => clientErrors.push(error.message));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") clientErrors.push(message.text());
|
||||
});
|
||||
|
||||
await page.goto(target.path);
|
||||
await expect(page.locator(target.readySelector)).toBeVisible();
|
||||
// Let every boundary hydrate and every post-mount refetch land before
|
||||
// the verdict; a bailout that happens late is still a bailout.
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
expect(
|
||||
clientErrors.filter((message) => /418|hydrat/i.test(message)),
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.37.15",
|
||||
"version": "1.37.16",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
Generated
+7
-7
@@ -7,7 +7,7 @@ settings:
|
||||
overrides:
|
||||
dompurify@<=3.4.10: ^3.4.11
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
nanoid@<3.3.17: ^3.3.17
|
||||
nanoid@<3.3.18: ^3.3.18
|
||||
'@hono/node-server@<1.19.13': ^1.19.13
|
||||
hono@<4.12.25: ^4.12.25
|
||||
picomatch@<4.0.4: ^4.0.4
|
||||
@@ -5557,8 +5557,8 @@ packages:
|
||||
nan@2.27.0:
|
||||
resolution: {integrity: sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==}
|
||||
|
||||
nanoid@3.3.17:
|
||||
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
|
||||
nanoid@3.3.18:
|
||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -12632,7 +12632,7 @@ snapshots:
|
||||
nan@2.27.0:
|
||||
optional: true
|
||||
|
||||
nanoid@3.3.17: {}
|
||||
nanoid@3.3.18: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
@@ -13039,19 +13039,19 @@ snapshots:
|
||||
|
||||
postcss@8.5.14:
|
||||
dependencies:
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.20:
|
||||
dependencies:
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
postcss@8.5.25:
|
||||
dependencies:
|
||||
nanoid: 3.3.17
|
||||
nanoid: 3.3.18
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
overrides:
|
||||
"dompurify@<=3.4.10": "^3.4.11"
|
||||
"postcss@<8.5.10": "^8.5.10"
|
||||
"nanoid@<3.3.17": "^3.3.17"
|
||||
"nanoid@<3.3.18": "^3.3.18"
|
||||
"@hono/node-server@<1.19.13": "^1.19.13"
|
||||
"hono@<4.12.25": "^4.12.25"
|
||||
"picomatch@<4.0.4": "^4.0.4"
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
|
||||
// clean tree are the same sentence otherwise, and this is a leg whose
|
||||
// verdict is an empty list.
|
||||
expect(carrying.length).toBeGreaterThan(4);
|
||||
expect(carrying).toContain("app/mood/page.tsx");
|
||||
expect(carrying).toContain("app/mood/page-client.tsx");
|
||||
expect(carrying).toContain("app/medications/page-client.tsx");
|
||||
for (const rel of Object.keys(NOT_A_SHARED_DESTINATION)) {
|
||||
expect(carrying, rel).toContain(rel);
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
* an allowlisted exception is correct — only that no NEW site invalidates a
|
||||
* daily bundle without the paired refetch, and no PAIRED site loses its refetch,
|
||||
* without someone editing this file.
|
||||
*
|
||||
* Both matcher families were proven live by breaking a real fix and watching
|
||||
* the guard go red: removing the `refetchInactiveDailyReads` pairing from the
|
||||
* illness invalidator (`components/illness/use-illness.ts`) failed the bundle
|
||||
* assertion, and reverting the score-config save
|
||||
* (`components/settings/score-section.tsx`) to its former bare
|
||||
* snapshot/digest `invalidateQueries` pair failed the direct-invalidation
|
||||
* assertion. Both fixes were then restored and the guard re-ran green.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -41,13 +49,38 @@ const FACTORY = "lib/query-keys/index.ts";
|
||||
* per-surface key). Sites routed through `invalidateMedicationReads` do NOT
|
||||
* match — the wrapper carries the refetch internally, so they are compliant by
|
||||
* construction and correctly invisible here.
|
||||
*
|
||||
* The cycle / encounter / vaccination / illness families joined the matcher
|
||||
* alongside the original three: the encounter and illness bundles now carry a
|
||||
* daily-read key directly, the vaccination bundle's reminder root feeds the
|
||||
* Today rail, and cycle rides along so a future daily-read key added to its
|
||||
* bundle cannot ship an unpaired site silently (its current sites are
|
||||
* allowlisted below with the reason).
|
||||
*/
|
||||
const INVALIDATION =
|
||||
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys)\b/g;
|
||||
/invalidateKeys\(\s*[^,]+,\s*\[?\s*(?:\.\.\.)?\s*(?:medicationDependentKeys|measurementDependentKeys|moodDependentKeys|cycleDependentKeys|encounterDependentKeys|vaccinationDependentKeys|illnessDependentKeys)\b/g;
|
||||
// Match the CALL form only (open paren), never the bare import specifier —
|
||||
// otherwise the `import { refetchInactiveDailyReads }` line would inflate the
|
||||
// count and let a file drop one paired call while still "passing".
|
||||
const REFETCH = /refetchInactiveDailyReads\(/g;
|
||||
// `invalidateReminderReads` counts as a paired refetch: it carries
|
||||
// `refetchInactiveDailyReads` internally (see `hooks/use-measurement-reminders`),
|
||||
// and the encounter / vaccination mutation sites pair through it.
|
||||
const REFETCH = /(?:refetchInactiveDailyReads|invalidateReminderReads)\(/g;
|
||||
|
||||
/**
|
||||
* Second matcher family — a DIRECT `invalidateQueries` on a daily-read key.
|
||||
* Captures `<qc>.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })`
|
||||
* / `dailyDigest()` in every observed shape (single line, prettier-broken,
|
||||
* with or without a `refetchType`). Group 1 carries the `refetchType` value
|
||||
* when present: `"inactive"` and `"all"` both force the unmounted daily reads
|
||||
* to refetch and are compliant; a bare call (or `"active"`) marks the
|
||||
* typically-unmounted daily reads stale without refetching them — exactly the
|
||||
* v1.16.11/v1.29.1/v1.32.19 class, one call site at a time instead of via a
|
||||
* bundle. Known limit (mirrors the bundle matcher): a call spelling the key
|
||||
* through a local alias rather than `queryKeys.<key>()` would slip past.
|
||||
*/
|
||||
const DIRECT_DAILY_INVALIDATION =
|
||||
/\.invalidateQueries\(\s*\{\s*queryKey:\s*queryKeys\.(?:dashboardSnapshot|dailyDigest)\(\)\s*,?\s*(?:refetchType:\s*"(\w+)"\s*,?\s*)?\}\s*,?\s*\)/g;
|
||||
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
@@ -99,6 +132,29 @@ const NO_REFETCH_ALLOWLIST = [
|
||||
"components/settings/integrations/oauth-provider-card.tsx",
|
||||
"components/settings/integrations/whoop-card.tsx",
|
||||
"components/settings/integrations/withings-card.tsx",
|
||||
// Cycle writes: `cycleDependentKeys` carries no daily-read key and neither
|
||||
// the dashboard snapshot nor the Today digest reads cycle rows today, so
|
||||
// the forced refetch would be a no-op round-trip. The bundle is in the
|
||||
// matcher so the day a daily-read key joins it, these sites surface here
|
||||
// and must take the pairing.
|
||||
"components/cycle/use-cycle.ts",
|
||||
].sort();
|
||||
|
||||
/**
|
||||
* Files allowed a DIRECT daily-read `invalidateQueries` without an inactive
|
||||
* refetch, deliberately. Frozen like the bundle allowlist above.
|
||||
*/
|
||||
const DIRECT_NO_REFETCH_ALLOWLIST = [
|
||||
// Background sync completion on the Settings surface — same reason as the
|
||||
// integration cards in the bundle allowlist: the user is configuring, not
|
||||
// acting on the hero; poll/focus convergence is intended.
|
||||
"components/settings/integrations/google-health-card.tsx",
|
||||
// The check-in card and the priority-item dismissal both render INSIDE the
|
||||
// Today digest, so the digest query is mounted and the default
|
||||
// `refetchType: "active"` refetches it immediately — the inactive arm has
|
||||
// nothing to cover.
|
||||
"hooks/use-coach-checkin.ts",
|
||||
"hooks/use-priority-item-dismiss.ts",
|
||||
].sort();
|
||||
|
||||
describe("daily-reads refetch guard", () => {
|
||||
@@ -146,6 +202,33 @@ describe("daily-reads refetch guard", () => {
|
||||
expect(nonCompliant.sort()).toEqual(NO_REFETCH_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("every direct daily-read invalidation forces a refetch, except the frozen allowlist", () => {
|
||||
// A direct `invalidateQueries(dashboardSnapshot|dailyDigest)` is the
|
||||
// single-key form of the same class the bundle matcher pins. Compliant
|
||||
// forms: `refetchType: "inactive"` / `"all"` on the call itself, or a
|
||||
// paired `refetchInactiveDailyReads` elsewhere in the file (then the
|
||||
// direct call is the mounted-arm half of an explicit pair).
|
||||
const nonCompliant: string[] = [];
|
||||
let totalMatches = 0;
|
||||
for (const rel of sourceFiles()) {
|
||||
if (rel === FACTORY) continue;
|
||||
const s = read(rel);
|
||||
let bare = 0;
|
||||
for (const m of s.matchAll(DIRECT_DAILY_INVALIDATION)) {
|
||||
totalMatches++;
|
||||
const refetchType = m[1];
|
||||
if (refetchType !== "inactive" && refetchType !== "all") bare++;
|
||||
}
|
||||
if (bare > 0 && count(s, REFETCH) === 0) nonCompliant.push(rel);
|
||||
}
|
||||
// The matcher must be alive: the tree has known compliant call sites
|
||||
// (the dashboard-layout section's `refetchType: "all"` pair among them).
|
||||
// Zero matches would mean the regex rotted, not that the class is gone —
|
||||
// the lesson of the 2026-08-01 bearer-guard blind spot.
|
||||
expect(totalMatches).toBeGreaterThan(3);
|
||||
expect(nonCompliant.sort()).toEqual(DIRECT_NO_REFETCH_ALLOWLIST);
|
||||
});
|
||||
|
||||
it("the fixed intake surfaces route through the blessed helper or pair the refetch", () => {
|
||||
// Positive pin: the v1.32.19 seed sites must carry the fix, so a revert to
|
||||
// a bare `invalidateKeys(medicationDependentKeys)` is caught here as well
|
||||
|
||||
@@ -52,8 +52,11 @@ function read(relativePath: string): string {
|
||||
*/
|
||||
const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [
|
||||
{
|
||||
file: "src/app/api/measurement-reminders/route.ts",
|
||||
surface: "the reminder list the checkups page renders",
|
||||
// The list query moved out of the route into the shared read so the
|
||||
// `/checkups` SSR prefetch and `GET /api/measurement-reminders` cannot
|
||||
// drift; the exclusion travels with the query.
|
||||
file: "src/lib/measurement-reminders/list-read.ts",
|
||||
surface: "the reminder list the checkups page and its SSR prefetch render",
|
||||
},
|
||||
{
|
||||
file: "src/lib/daily/load-digest.ts",
|
||||
|
||||
@@ -172,10 +172,12 @@ const prefetchingPages = walk(APP_DIR)
|
||||
*/
|
||||
const PREFETCHING_ROUTES = [
|
||||
"/",
|
||||
"/checkups",
|
||||
"/coach",
|
||||
"/insights",
|
||||
"/insights/workouts",
|
||||
"/medications",
|
||||
"/mood",
|
||||
] as const;
|
||||
|
||||
describe("server-prefetching pages cannot leak a cacheable record", () => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// with the session user's own data. Absence of a session skips the
|
||||
// prefetch rather than failing the render.
|
||||
//
|
||||
// These five resolve through `getUnswitchedSession()`, not `getSession()`,
|
||||
// These seven resolve through `getUnswitchedSession()`, not `getSession()`,
|
||||
// and they stay listed here BECAUSE of it. The helper is still a session
|
||||
// derivation outside `requireAuth` — moving the call one file deeper into
|
||||
// `lib/` would have taken them off this list while changing nothing about
|
||||
@@ -115,6 +115,8 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
// can only ever hold the caller's own. See
|
||||
// `prefetch-page-caching-guard.test.ts` for the rule that keeps a sixth
|
||||
// prefetching page from reaching for the bare helper.
|
||||
"app/checkups/page.tsx":
|
||||
"RSC prefetch of the session user's reminder list, skipped under a switch",
|
||||
"app/coach/page.tsx":
|
||||
"RSC prefetch of the session user's coach state, skipped under a switch",
|
||||
"app/insights/page.tsx":
|
||||
@@ -123,6 +125,8 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
"RSC prefetch of the session user's workouts, skipped under a switch",
|
||||
"app/medications/page.tsx":
|
||||
"RSC prefetch of the session user's medications, skipped under a switch",
|
||||
"app/mood/page.tsx":
|
||||
"RSC prefetch of the session user's mood list, skipped under a switch",
|
||||
"app/page.tsx":
|
||||
"RSC prefetch of the session user's dashboard snapshot, skipped under a switch",
|
||||
// Onboarding routing. Reads the actor's own onboarding step to redirect;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -105,6 +106,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
details: cleared,
|
||||
});
|
||||
|
||||
// The purge removes every row the cached cycle context was built on —
|
||||
// evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.purge" },
|
||||
meta: cleared,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -78,6 +79,10 @@ export const DELETE = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// A removed cycle changes the cycle context the cached analytics /
|
||||
// snapshot / digest cells carry — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.cycle.delete",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -204,6 +205,10 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// A day-log edit changes the cycle context the cached analytics /
|
||||
// snapshot / digest cells carry — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.update",
|
||||
@@ -282,6 +287,10 @@ export const DELETE = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// A removed day (possibly with its cycle) changes the cycle context the
|
||||
// cached analytics / snapshot / digest cells carry — evict the bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.delete",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
sanitiseZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
@@ -228,6 +229,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
// Landed rows change the cycle context the cached analytics / snapshot /
|
||||
// digest cells carry — evict the analytics bucket.
|
||||
if (inserted > 0 || updated > 0) {
|
||||
invalidateUserHealthContext(user.id);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "cycle.bulk.ingest" },
|
||||
meta: {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -173,6 +174,10 @@ async function postDayLog(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
// A day-log write changes the cycle context the cached analytics /
|
||||
// snapshot / digest cells carry — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.day-log.upsert",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -196,6 +197,10 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
// A period boundary changes the cycle context the cached analytics /
|
||||
// snapshot / digest cells carry — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "cycle.period.boundary",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -92,6 +93,10 @@ export const POST = apiHandler(
|
||||
details: { encounterId: id, occurredAt: row.occurredAt.toISOString() },
|
||||
});
|
||||
|
||||
// A restored visit must reach the cached daily-digest / snapshot cells
|
||||
// (Today rail) on the next read — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "encounter.visit.restore",
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
@@ -342,6 +343,10 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// A rescheduled / re-statused visit must reach the cached daily-digest /
|
||||
// snapshot cells (Today rail) on the next read — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "encounter.visit.update",
|
||||
@@ -392,6 +397,10 @@ export const DELETE = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// A removed visit must leave the cached daily-digest / snapshot cells
|
||||
// (Today rail) on the next read — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "encounter.visit.delete",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -299,6 +300,10 @@ async function postEncounter(request: NextRequest): Promise<Response> {
|
||||
details: { encounterId: created.id, status: created.status },
|
||||
});
|
||||
|
||||
// A booked visit must reach the cached daily-digest / snapshot cells
|
||||
// (Today rail) on the next read — evict the analytics bucket.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "encounter.visit.create",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -94,6 +95,10 @@ export const PATCH = apiHandler(
|
||||
details: { episodeId: id },
|
||||
});
|
||||
|
||||
// Resolving flips Rest Mode off — evict the cached analytics / snapshot /
|
||||
// digest cells so the context is true on the next read.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.resolve",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
@@ -55,6 +56,9 @@ export const POST = apiHandler(
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
// Restoring an unresolved episode can flip Rest Mode back on — evict
|
||||
// the cached analytics / snapshot / digest cells.
|
||||
invalidateUserHealthContext(user.id);
|
||||
}
|
||||
|
||||
const restored = await prisma.illnessEpisode.findUniqueOrThrow({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
@@ -184,6 +185,11 @@ export const PATCH = apiHandler(
|
||||
},
|
||||
});
|
||||
|
||||
// An onset / lifecycle / resolvedAt edit can flip Rest Mode — evict the
|
||||
// cached analytics / snapshot / digest cells so the context is true on
|
||||
// the next read.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.update",
|
||||
@@ -224,6 +230,9 @@ export const DELETE = apiHandler(
|
||||
ipAddress: getClientIp(request),
|
||||
details: { episodeId: id },
|
||||
});
|
||||
// Deleting an active episode can flip Rest Mode off — evict the cached
|
||||
// analytics / snapshot / digest cells.
|
||||
invalidateUserHealthContext(user.id);
|
||||
}
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { invalidateUserHealthContext } from "@/lib/cache/invalidate";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -135,6 +136,10 @@ async function postEpisode(request: NextRequest): Promise<Response> {
|
||||
details: { episodeId: created.id, type: created.type },
|
||||
});
|
||||
|
||||
// A new episode can flip Rest Mode on — evict the cached analytics /
|
||||
// snapshot / digest cells so the context is true on the next read.
|
||||
invalidateUserHealthContext(user.id);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "illness.episode.create",
|
||||
|
||||
@@ -80,6 +80,8 @@ import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
|
||||
import { syncAcceptedPatterns } from "@/lib/insights/correlation-patterns";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -88,6 +90,9 @@ const SESSION_OK = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The route reads through the SWR analytics cache now; a warm cell from a
|
||||
// prior test would serve a hit and skip the builder every assertion spies on.
|
||||
__resetAllCachesForTests();
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true });
|
||||
// v1.15.20 — default to an allowing analytics-read budget.
|
||||
vi.mocked(checkAnalyticsReadRateLimit).mockResolvedValue({
|
||||
@@ -157,6 +162,25 @@ describe("GET /api/insights/correlations", () => {
|
||||
expect(body.data.fdrQ).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("serves a cache hit without re-running the discovery reads or the pattern-sync writes", async () => {
|
||||
// The SWR cell caches the built body for 60 s; the second GET inside that
|
||||
// window must neither re-read the measurement window nor re-run the two
|
||||
// `syncAcceptedPatterns` DB writes — writes on the read path were the
|
||||
// pre-cache defect, and a hit that still wrote would reintroduce it.
|
||||
// Watched red: with the assertion counts set to 2 (i.e. asserting the
|
||||
// builder re-ran) this test fails, so the cache path is proven live.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const first = await callGet(makeReq());
|
||||
expect(first.status).toBe(200);
|
||||
const second = await callGet(makeReq());
|
||||
expect(second.status).toBe(200);
|
||||
expect(prisma.measurement.findMany).toHaveBeenCalledTimes(1);
|
||||
expect(syncAcceptedPatterns).toHaveBeenCalledTimes(2);
|
||||
const firstBody = (await first.json()) as { data: unknown };
|
||||
const secondBody = (await second.json()) as { data: unknown };
|
||||
expect(secondBody.data).toEqual(firstBody.data);
|
||||
});
|
||||
|
||||
it("returns 403 + errorCode when the correlations flag is off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
(
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
@@ -86,16 +87,43 @@ export const GET = apiHandler(async () => {
|
||||
// Operator can hide the correlation surface entirely.
|
||||
await requireAssistantSurface("correlations");
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
// Reader's locale for the narrated `interpretation` — the correlation cards
|
||||
// render this string verbatim, so it MUST be localised (cookie / User.locale /
|
||||
// Accept-Language). Without it the never-causal sentence leaked English into a
|
||||
// non-English UI.
|
||||
// non-English UI. It is part of the cache key for the same reason.
|
||||
const locale = await resolveServerLocale({ userLocale: user.locale ?? null });
|
||||
|
||||
// The discovery scan is the most expensive uncached read the app had:
|
||||
// ~10 parallel window reads over 180 days + Pearson/FDR in-process, paid on
|
||||
// EVERY mount. Cache the built body in the analytics bucket (60 s fresh /
|
||||
// 1 h stale — the bucket defaults are exactly the contract this read wants).
|
||||
// Eviction truth comes free: every measurement / mood / medication /
|
||||
// illness / custom-metric write sweeps the `${userId}|` prefix in this
|
||||
// bucket, and a pattern dismiss goes through
|
||||
// `invalidateUserCorrelationPatterns`, which sweeps it too. The
|
||||
// `syncAcceptedPatterns` calls are DB writes and live INSIDE the builder,
|
||||
// so a cache hit performs no write on the read path.
|
||||
const body = await cachedSwr(
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildCorrelationsResponse>>
|
||||
>,
|
||||
`${user.id}|correlations|${locale}`,
|
||||
() => buildCorrelationsResponse(user.id, locale),
|
||||
annotate,
|
||||
);
|
||||
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
async function buildCorrelationsResponse(
|
||||
userId: string,
|
||||
locale: Awaited<ReturnType<typeof resolveServerLocale>>,
|
||||
) {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const tz = profile?.timezone ?? "Europe/Berlin";
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - WINDOW_DAYS * MS_PER_DAY);
|
||||
|
||||
@@ -121,12 +149,12 @@ export const GET = apiHandler(async () => {
|
||||
moodWindow,
|
||||
priorityJson,
|
||||
] = await Promise.all([
|
||||
fetchMeasurementWindowSeries(user.id, since, [
|
||||
fetchMeasurementWindowSeries(userId, since, [
|
||||
...behaviourTypes,
|
||||
...outcomeTypes,
|
||||
]),
|
||||
fetchMoodWindowSeries(user.id, tz, since),
|
||||
loadUserSourcePriority(user.id),
|
||||
fetchMoodWindowSeries(userId, tz, since),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
const { moodDaily, moodCapped } = moodWindow;
|
||||
|
||||
@@ -141,14 +169,14 @@ export const GET = apiHandler(async () => {
|
||||
environmentSeries,
|
||||
customMetricSeries,
|
||||
] = await Promise.all([
|
||||
fetchComplianceSeries(user.id, tz, since),
|
||||
fetchSymptomSeries(user.id, tz, since),
|
||||
fetchLabDraws(user.id, tz, since),
|
||||
fetchComplianceSeries(userId, tz, since),
|
||||
fetchSymptomSeries(userId, tz, since),
|
||||
fetchLabDraws(userId, tz, since),
|
||||
// v1.25 (W-ENV) — environmental-exposure behaviour channels (weather /
|
||||
// daylight). Empty when the module is off / no home set, so the channels
|
||||
// degrade to absent. The module gate is implicit: no rows ⇒ no channels.
|
||||
fetchEnvironmentSeries(user.id, since),
|
||||
fetchCustomMetricBehaviourSeries(user.id, tz, since),
|
||||
fetchEnvironmentSeries(userId, since),
|
||||
fetchCustomMetricBehaviourSeries(userId, tz, since),
|
||||
]);
|
||||
|
||||
const points = (key: string): DailySeriesPoint[] =>
|
||||
@@ -219,13 +247,13 @@ export const GET = apiHandler(async () => {
|
||||
});
|
||||
const [retrospectiveDecisions, recentDecisions] = await Promise.all([
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.discoveryRetrospective,
|
||||
accepted: result.discovered.map(toEvidence),
|
||||
computedAt: now,
|
||||
}),
|
||||
syncAcceptedPatterns({
|
||||
userId: user.id,
|
||||
userId,
|
||||
family: PATTERN_FAMILIES.discoveryRecent,
|
||||
accepted: emerging.emerging.map(toEvidence),
|
||||
computedAt: now,
|
||||
@@ -285,10 +313,10 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
return {
|
||||
...result,
|
||||
discovered,
|
||||
emerging: emergingWithDecisions,
|
||||
labCorrelations,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
@@ -38,28 +39,16 @@ async function resolveTimezone(userId: string): Promise<string> {
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
// A booked visit's one-shot reminder rides this same engine with
|
||||
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
|
||||
// Vorsorge surface, or the list fills with appointments. Four read
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
|
||||
// to the end so the actionable items float to the top.
|
||||
orderBy: [
|
||||
{ nextDueAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
// The query (with its ENCOUNTER-origin exclusion) lives in the shared
|
||||
// list read so this route and the `/checkups` SSR prefetch cannot drift.
|
||||
const reminders = await listMeasurementReminders(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.list" },
|
||||
meta: { total: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess(reminders.map(toMeasurementReminderDto));
|
||||
return apiSuccess(reminders);
|
||||
});
|
||||
|
||||
async function postReminder(request: NextRequest): Promise<Response> {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { listMoodEntriesPage } from "@/lib/mood/list-read";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -78,87 +79,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
|
||||
deletedAt: null,
|
||||
...(mood && { mood }),
|
||||
// v1.15.13 — management-list source filter.
|
||||
...(source && { source }),
|
||||
...(from || to
|
||||
? {
|
||||
date: {
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
|
||||
// the client can split binary tags from rated factors on hydrate.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
// The query + wire shaping (note decryption, tag split, day context) live
|
||||
// in the shared list read so this route and the `/mood` SSR prefetch
|
||||
// cannot drift.
|
||||
const body = await listMoodEntriesPage(user.id, parsed.data);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood-entries.list" },
|
||||
meta: { total, limit, offset },
|
||||
meta: {
|
||||
total: body.meta.total,
|
||||
limit: body.meta.limit,
|
||||
offset: body.meta.offset,
|
||||
},
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
entries: entriesWithParsedTags,
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The `/checkups` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/checkups/page.tsx`) dehydrates the Vorsorge
|
||||
* reminder list under the SAME zero-arg factory key the client hook
|
||||
* (`useMeasurementReminders`) mounts on, via the shared
|
||||
* `listMeasurementReminders` read — so the checkups list paints from the
|
||||
* first HTML. These tests pin the seeded key (byte-identical hash), the
|
||||
* JSON-wire shape, and that every error path fails soft to the bare client.
|
||||
*/
|
||||
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const listMeasurementReminders = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/list-read", () => ({
|
||||
listMeasurementReminders: (userId: string) =>
|
||||
listMeasurementReminders(userId),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import CheckupsPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function queriesOf(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } }[] {
|
||||
if (el.type !== HydrationBoundary) return [];
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
return props.state?.queries ?? [];
|
||||
}
|
||||
|
||||
describe("/checkups RSC prefetch", () => {
|
||||
it("dehydrates the reminder list under the client's factory key", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
listMeasurementReminders.mockResolvedValue([
|
||||
{ id: "r1", label: "Blood pressure", nextDueAt: null },
|
||||
]);
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
const hashes = queriesOf(el).map((q) => q.queryHash);
|
||||
expect(hashes).toContain(hashKey(queryKeys.measurementReminders()));
|
||||
expect(listMeasurementReminders).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("JSON-round-trips the list to the wire shape", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
const createdAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
listMeasurementReminders.mockResolvedValue([{ id: "r1", createdAt }]);
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
const seeded = queriesOf(el).find(
|
||||
(q) => q.queryHash === hashKey(queryKeys.measurementReminders()),
|
||||
);
|
||||
expect((seeded!.state.data as { createdAt: unknown }[])[0]!.createdAt).toBe(
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
listMeasurementReminders.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMeasurementReminders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
*
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
*/
|
||||
export default function CheckupsPageClient() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+62
-68
@@ -1,80 +1,74 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import CheckupsPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
* Thin RSC wrapper around the (client) checkups page.
|
||||
*
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
* The page's above-the-fold content — the Vorsorge reminder list — waited for
|
||||
* the client `queryKeys.measurementReminders()` cell to fetch after hydrate,
|
||||
* so the first paint flashed a skeleton before the list filled in. This
|
||||
* wrapper runs the SAME read the API route uses (`listMeasurementReminders`,
|
||||
* shared so filter/order/DTO cannot drift) during SSR and hands it to
|
||||
* TanStack through `HydrationBoundary`.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - The query key comes ONLY from the central factory —
|
||||
* `queryKeys.measurementReminders()` is a deterministic zero-arg tuple, so
|
||||
* the server-seeded key equals the client's `useQuery` key by construction.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is
|
||||
* exactly what the client `queryFn` produces from the wire (ISO date
|
||||
* strings), never a Date-carrying sibling that poisons the cell.
|
||||
* - Only the PRIMARY read is prefetched. The visits tab mounts its own read
|
||||
* lazily on first open, and that stays client-lazy on purpose — most
|
||||
* visits to this page never open it.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record —
|
||||
* the prefetch must never serialise the delegate's own reminders into a
|
||||
* page opened on the owner's record.
|
||||
* - Fail-soft: no session or a read hiccup renders the page exactly as
|
||||
* before this wrapper existed — the client cell owns the fetch.
|
||||
*/
|
||||
export default function CheckupsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <PageAuthGate />;
|
||||
export default async function CheckupsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <CheckupsPageClient />;
|
||||
}
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const list = await listMeasurementReminders(session.user.id);
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO date
|
||||
// strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.measurementReminders(),
|
||||
JSON.parse(JSON.stringify(list)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <CheckupsPageClient />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<CheckupsPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size";
|
||||
|
||||
/**
|
||||
* The `/mood` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/mood/page.tsx`) dehydrates the mood list's FIRST
|
||||
* page under the parameterised `queryKeys.moodEntriesList(…)` tuple the
|
||||
* client list mounts with (no filters, page 1, `moodLoggedAt desc`), via the
|
||||
* shared `listMoodEntriesPage` read. These tests pin the seeded key
|
||||
* (byte-identical hash), the first-mount parameters (`MOOD_LIST_PAGE_SIZE`,
|
||||
* offset 0), the module-gate parity, and the fail-soft paths.
|
||||
*/
|
||||
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const listMoodEntriesPage = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/mood/list-read", () => ({
|
||||
listMoodEntriesPage: (userId: string, params: unknown) =>
|
||||
listMoodEntriesPage(userId, params),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import MoodPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
/** The client list's first-mount key (no filters, page 1, default sort). */
|
||||
const FIRST_MOUNT_KEY = queryKeys.moodEntriesList({
|
||||
mood: undefined,
|
||||
source: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 1,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function queriesOf(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } }[] {
|
||||
if (el.type !== HydrationBoundary) return [];
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
return props.state?.queries ?? [];
|
||||
}
|
||||
|
||||
describe("/mood RSC prefetch", () => {
|
||||
it("dehydrates the first list page under the client's first-mount key", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
listMoodEntriesPage.mockResolvedValue({
|
||||
entries: [],
|
||||
meta: { total: 0, limit: MOOD_LIST_PAGE_SIZE, offset: 0 },
|
||||
});
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
const hashes = queriesOf(el).map((q) => q.queryHash);
|
||||
expect(hashes).toContain(hashKey(FIRST_MOUNT_KEY));
|
||||
// The read runs with the client's first-mount slice parameters.
|
||||
expect(listMoodEntriesPage).toHaveBeenCalledWith("u1", {
|
||||
limit: MOOD_LIST_PAGE_SIZE,
|
||||
offset: 0,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
it("JSON-round-trips the page body to the wire shape", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
const loggedAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
listMoodEntriesPage.mockResolvedValue({
|
||||
entries: [{ id: "m1", moodLoggedAt: loggedAt }],
|
||||
meta: { total: 1, limit: MOOD_LIST_PAGE_SIZE, offset: 0 },
|
||||
});
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
const seeded = queriesOf(el).find(
|
||||
(q) => q.queryHash === hashKey(FIRST_MOUNT_KEY),
|
||||
);
|
||||
const data = seeded!.state.data as {
|
||||
entries: { moodLoggedAt: unknown }[];
|
||||
};
|
||||
expect(data.entries[0]!.moodLoggedAt).toBe(loggedAt.toISOString());
|
||||
});
|
||||
|
||||
it("skips the prefetch when the mood module is explicitly off", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ mood: false });
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMoodEntriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
listMoodEntriesPage.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMoodEntriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPageClient() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
// `!mounted` keeps the hydration render identical to the SSR HTML: the
|
||||
// auth query is fired by the early-hydrating shell and can settle before
|
||||
// this page boundary hydrates, so `isLoading` alone flipped the branch
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<PageHeader
|
||||
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
|
||||
description={t("mood.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
of the add button) and responsive 44-px mobile tap floor
|
||||
as the medications header.
|
||||
|
||||
v1.36.x — and gated like the other four wrenches. It was the
|
||||
one of five left open, so a delegate tapped it straight into
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setDialogOpen(false)}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<MoodList onAddFirst={() => setDialogOpen(true)} />
|
||||
|
||||
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
|
||||
screeners, and the mood insights page form one mental-health
|
||||
domain but used to be three unconnected islands. A quiet pointer
|
||||
here (module-gated — no nav change) rather than a merge: capture
|
||||
vs. screener stay two defensible surfaces, just cross-linked. */}
|
||||
{user?.modules?.mentalHealth === true ? (
|
||||
<Link
|
||||
href="/mental-wellbeing"
|
||||
data-slot="mood-mental-wellbeing-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+92
-148
@@ -1,156 +1,100 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { listMoodEntriesPage } from "@/lib/mood/list-read";
|
||||
import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
import MoodPageClient from "./page-client";
|
||||
|
||||
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
// `!mounted` keeps the hydration render identical to the SSR HTML: the
|
||||
// auth query is fired by the early-hydrating shell and can settle before
|
||||
// this page boundary hydrates, so `isLoading` alone flipped the branch
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
/**
|
||||
* Thin RSC wrapper around the (client) mood page.
|
||||
*
|
||||
* The page's primary content — the mood management list — waited for the
|
||||
* client `queryKeys.moodEntriesList(…)` cell to fetch the first page after
|
||||
* hydrate, so the first paint flashed a skeleton before the entries filled
|
||||
* in. This wrapper runs the SAME read the API route uses
|
||||
* (`listMoodEntriesPage`, shared so filter/order/wire shape cannot drift)
|
||||
* with the list's first-mount defaults during SSR and hands it to TanStack
|
||||
* through `HydrationBoundary`.
|
||||
*
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - THE CRUX — the list key is parameterised. The seeded tuple spells the
|
||||
* client's first-mount state exactly: no filters, page 1, the default
|
||||
* `moodLoggedAt desc` sort, and `MOOD_LIST_PAGE_SIZE` rows (the shared
|
||||
* constant the client's `queryFn` also uses). Any other page/filter state
|
||||
* keys a different slot and fetches client-side as before.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is
|
||||
* exactly what the client `queryFn` produces from the wire (ISO date
|
||||
* strings, decrypted note as plain `note`), never a Date-carrying sibling
|
||||
* that poisons the cell.
|
||||
* - Module-gate parity: the client page redirects when `modules.mood` is
|
||||
* explicitly `false`; skip the prefetch then (the client never mounts the
|
||||
* list).
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record —
|
||||
* the prefetch must never serialise the delegate's own entries into a
|
||||
* page opened on the owner's record.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a read hiccup renders
|
||||
* the page exactly as before this wrapper existed — the client cell owns
|
||||
* the fetch.
|
||||
*/
|
||||
export default async function MoodPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <MoodPageClient />;
|
||||
}
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
// Mirror the client gate (`user?.modules?.mood !== false`): an absent
|
||||
// key reads as enabled; only an explicit `false` disables.
|
||||
if (modules.mood !== false) {
|
||||
const body = await listMoodEntriesPage(user.id, {
|
||||
limit: MOOD_LIST_PAGE_SIZE,
|
||||
offset: 0,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO
|
||||
// date strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.moodEntriesList({
|
||||
mood: undefined,
|
||||
source: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 1,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
JSON.parse(JSON.stringify(body)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <MoodPageClient />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<PageHeader
|
||||
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
|
||||
description={t("mood.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
of the add button) and responsive 44-px mobile tap floor
|
||||
as the medications header.
|
||||
|
||||
v1.36.x — and gated like the other four wrenches. It was the
|
||||
one of five left open, so a delegate tapped it straight into
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setDialogOpen(false)}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<MoodList onAddFirst={() => setDialogOpen(true)} />
|
||||
|
||||
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
|
||||
screeners, and the mood insights page form one mental-health
|
||||
domain but used to be three unconnected islands. A quiet pointer
|
||||
here (module-gated — no nav change) rather than a merge: capture
|
||||
vs. screener stay two defensible surfaces, just cross-linked. */}
|
||||
{user?.modules?.mentalHealth === true ? (
|
||||
<Link
|
||||
href="/mental-wellbeing"
|
||||
data-slot="mood-mental-wellbeing-link"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
{t("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<MoodPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
illnessDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
|
||||
import type {
|
||||
IllnessCorrelationResponse,
|
||||
@@ -119,7 +124,14 @@ export function useIllnessDayLogList(
|
||||
|
||||
function useInvalidateIllness() {
|
||||
const qc = useQueryClient();
|
||||
return () => qc.invalidateQueries({ queryKey: queryKeys.illness() });
|
||||
// An episode write can flip Rest Mode, which frames the dashboard hero and
|
||||
// the Today digest — both unmounted from here, so the bundle invalidation
|
||||
// is paired with the forced inactive refetch. The write routes hard-evict
|
||||
// the server analytics bucket, so the refetch returns post-write context.
|
||||
return async () => {
|
||||
await invalidateKeys(qc, illnessDependentKeys);
|
||||
await refetchInactiveDailyReads(qc);
|
||||
};
|
||||
}
|
||||
|
||||
/** Create an episode. */
|
||||
|
||||
@@ -19,6 +19,9 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: undefined }),
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
|
||||
// The remember-message control resolves the client for its about-me
|
||||
// invalidation; the marker tests never trigger it.
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { MessageThread } from "../message-thread";
|
||||
|
||||
@@ -52,6 +52,9 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
isPending: false,
|
||||
isError: false,
|
||||
}),
|
||||
// The remember-message control resolves the client for its about-me
|
||||
// invalidation; these render tests never trigger it.
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BookmarkPlus,
|
||||
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { ApiError, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
|
||||
import {
|
||||
@@ -944,6 +945,7 @@ ChatBubble.displayName = "ChatBubble";
|
||||
*/
|
||||
function RememberUserMessage({ content }: { content: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
// On settle the button unmounts while it holds focus, which would
|
||||
// drop keyboard focus to <body>. The confirmation paragraph takes
|
||||
@@ -961,6 +963,13 @@ function RememberUserMessage({ content }: { content: string }) {
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.adopted) {
|
||||
// The adoption changed the stored self-context — refresh the
|
||||
// Settings → AI about-me read so it shows the new entry.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
}
|
||||
setSettled(data.adopted ? "adopted" : "duplicate");
|
||||
},
|
||||
onError: () => {
|
||||
|
||||
@@ -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,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { BellPlus, Check, Loader2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import type { CoachSuggestion } from "@/lib/ai/coach/types";
|
||||
|
||||
/**
|
||||
@@ -31,6 +32,7 @@ export function ReminderSuggestionCard({
|
||||
suggestion: CoachSuggestion;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [settled, setSettled] = useState<
|
||||
"accepted" | "dismissed" | "stopped" | null
|
||||
>(null);
|
||||
@@ -44,6 +46,12 @@ export function ReminderSuggestionCard({
|
||||
return action;
|
||||
},
|
||||
onSuccess: (action) => {
|
||||
if (action === "accept") {
|
||||
// The accept minted a `MeasurementReminder` — refresh the checkups
|
||||
// list and the Today rail so it does not stay invisible until the
|
||||
// next poll.
|
||||
void invalidateReminderReads(queryClient);
|
||||
}
|
||||
setSettled(
|
||||
action === "accept"
|
||||
? "accepted"
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
* vertical cost once settled.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { BookmarkPlus, Check, Loader2, X } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface SelfContextAdoptOfferProps {
|
||||
/** The clarifying question the user just answered. */
|
||||
@@ -47,6 +48,7 @@ export function SelfContextAdoptOffer({
|
||||
onSettled,
|
||||
}: SelfContextAdoptOfferProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
|
||||
|
||||
const adopt = useMutation({
|
||||
@@ -57,6 +59,13 @@ export function SelfContextAdoptOffer({
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.adopted) {
|
||||
// The adoption changed the stored self-context — refresh the
|
||||
// Settings → AI about-me read so it shows the new entry.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachAboutMe(),
|
||||
});
|
||||
}
|
||||
setSettled(data.adopted ? "adopted" : "duplicate");
|
||||
onSettled?.(data.adopted ? "adopted" : "duplicate");
|
||||
},
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Loader2, Sparkles } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import type { CoachSuggestedAction } from "@/lib/ai/coach/suggest-action";
|
||||
|
||||
/**
|
||||
@@ -33,13 +35,24 @@ export function SuggestedActionCard({
|
||||
action: CoachSuggestedAction;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [settled, setSettled] = useState<"applied" | "dismissed" | null>(null);
|
||||
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost("/api/coach/suggested-actions", action.params);
|
||||
},
|
||||
onSuccess: () => setSettled("applied"),
|
||||
onSuccess: () => {
|
||||
// Both arms mint a reminder row the checkups list and the Today rail
|
||||
// read; the note arm additionally lands on the coach-reminders list.
|
||||
void invalidateReminderReads(queryClient);
|
||||
if (action.params.actionType === "reminder.note") {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.coachRemindersAll(),
|
||||
});
|
||||
}
|
||||
setSettled("applied");
|
||||
},
|
||||
onError: () => toast.error(t("coach.suggestedAction.failed")),
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -93,6 +93,15 @@ export function MentalWellbeing() {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
});
|
||||
// A completed screening can satisfy a due checkup reminder, so the
|
||||
// checkups list and the Today rail must repaint. Honest constraint:
|
||||
// the server satisfy runs async via pg-boss, so this invalidation
|
||||
// narrows the stale window but cannot close it — a refetch racing
|
||||
// the job may still see the pre-satisfy row until the next tick.
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.measurementReminders(),
|
||||
});
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
withBaseToken,
|
||||
} from "@/lib/api/optimistic-token";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
|
||||
import {
|
||||
buildScoreConfigRows,
|
||||
@@ -210,12 +210,12 @@ export function ScoreSection() {
|
||||
queryKey: queryKeys.healthScoreConfig(),
|
||||
});
|
||||
// The number now means something else. Every surface that holds a
|
||||
// computed score has to go, or the change reads as broken.
|
||||
// computed score has to go, or the change reads as broken. The daily
|
||||
// reads are unmounted on the Settings page and run with
|
||||
// `refetchOnMount: false`, so a plain invalidation marks them stale
|
||||
// without ever refetching — force the inactive refetch instead.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.dashboardSnapshot(),
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
toastWrittenOutcome("success", t("settings.sections.score.saved"));
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@@ -48,7 +48,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
DEFAULT_DEVICE_TYPE_PRIORITY,
|
||||
DEFAULT_SOURCE_PRIORITY,
|
||||
@@ -160,10 +160,15 @@ export function SourcesSection() {
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.sourcePriority(), saved);
|
||||
// The analytics aggregator folds source priority into per-day
|
||||
// canonical-row picks — flush its cache so the charts re-paint
|
||||
// with the new picker on the next mount.
|
||||
// The analytics aggregator, the per-chart daily aggregates, the
|
||||
// measurement list and the dashboard snapshot all fold source
|
||||
// priority into their per-day canonical-row picks — flush them so
|
||||
// every chart and tile re-paints with the new picker, not only the
|
||||
// analytics report.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.measurements() });
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
setDraft(null);
|
||||
toast.success(t("settings.sections.sources.saveSuccess"));
|
||||
},
|
||||
@@ -179,7 +184,12 @@ export function SourcesSection() {
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(queryKeys.sourcePriority(), saved);
|
||||
// Same fan-out as the save arm: the defaults change the canonical-row
|
||||
// picks everywhere the overrides did.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.chartDataRoot() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.measurements() });
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
setDraft(null);
|
||||
toast.success(t("settings.sections.sources.resetSuccess"));
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsInfoTile } from "./_info-tile";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
@@ -95,6 +95,9 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
// Every chart/band depends on these thresholds — invalidate everything.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// The dashboard bands + score context read the same thresholds through
|
||||
// the daily reads, which are unmounted on Settings — force the refetch.
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("thresholds.saveSuccess"));
|
||||
},
|
||||
onError: (err) =>
|
||||
@@ -115,6 +118,8 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.userThresholds() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// Same daily-reads truth as the save arm above.
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("thresholds.resetSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("thresholds.saveError")),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ConfirmButton } from "@/components/ui/confirm-button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { queryKeys, refetchInactiveDailyReads } from "@/lib/query-keys";
|
||||
import {
|
||||
METRIC_BOUNDS,
|
||||
type ThresholdMetric,
|
||||
@@ -259,6 +259,9 @@ function TargetEditSheetBody({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// The dashboard bands + score context read the same thresholds through
|
||||
// the daily reads, which are unmounted here — force their refetch.
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("targets.edit.saveSuccess"));
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -285,6 +288,8 @@ function TargetEditSheetBody({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsTargets() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.insightsRoot() });
|
||||
// Same daily-reads truth as the save arm above.
|
||||
void refetchInactiveDailyReads(queryClient);
|
||||
toast.success(t("targets.edit.resetSuccess"));
|
||||
onOpenChange(false);
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ import { invalidateReminderReads } from "@/hooks/use-measurement-reminders";
|
||||
import {
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
vaccinationDependentKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import type {
|
||||
@@ -154,7 +155,14 @@ export function useLinkDocumentToVaccination() {
|
||||
targetKind: "document",
|
||||
targetIds: [documentId],
|
||||
}),
|
||||
onSuccess: () => invalidateKeys(qc, vaccinationDependentKeys),
|
||||
onSuccess: () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, vaccinationDependentKeys),
|
||||
// The linked-document badge can surface on the daily reads' checkup
|
||||
// items; force the unmounted snapshot/digest to refetch like the
|
||||
// sibling mutations do (via `invalidateReminderReads`).
|
||||
refetchInactiveDailyReads(qc),
|
||||
]),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
* prose editor by design: the PATCH contract never accepts the encrypted
|
||||
* free-text fields.
|
||||
*/
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type UseQueryOptions,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
|
||||
@@ -63,12 +68,14 @@ export function useCoachPlans(opts?: {
|
||||
filter?: PlanFilter;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Optional poll interval (ms). The chat thread uses a slow poll because
|
||||
* proposals land asynchronously (the memory-refresh worker runs after the
|
||||
* turn); the management page reads once. TanStack pauses the interval in
|
||||
* background tabs by default, so the poll never runs unwatched.
|
||||
* Optional poll interval (ms), or TanStack's interval callback so a caller
|
||||
* can bound its poll (the chat thread caps attempts — see
|
||||
* `nextProposalPollInterval` in `plan-proposal-card.tsx`). Proposals land
|
||||
* asynchronously (the memory-refresh worker runs after the turn); the
|
||||
* management page reads once. TanStack pauses the interval in background
|
||||
* tabs by default, so the poll never runs unwatched.
|
||||
*/
|
||||
refetchInterval?: number;
|
||||
refetchInterval?: UseQueryOptions<CoachPlanDTO[]>["refetchInterval"];
|
||||
}) {
|
||||
const filter = opts?.filter;
|
||||
return useQuery({
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
encounterDependentKeys,
|
||||
invalidateKeys,
|
||||
queryKeys,
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import type { PractitionerDTO } from "@/lib/practitioners/dto";
|
||||
|
||||
@@ -45,7 +46,14 @@ export function usePractitioners(q?: string, enabled = true) {
|
||||
|
||||
export function usePractitionerMutations() {
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => invalidateKeys(qc, encounterDependentKeys);
|
||||
// Renaming a practice changes the label a rail appointment renders under,
|
||||
// and the daily reads are unmounted from the address book — pair the
|
||||
// bundle invalidation with the forced inactive refetch.
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
invalidateKeys(qc, encounterDependentKeys),
|
||||
refetchInactiveDailyReads(qc),
|
||||
]);
|
||||
|
||||
const create = useMutation({
|
||||
mutationKey: queryKeys.practitionerCreate(),
|
||||
|
||||
Vendored
+13
@@ -271,6 +271,19 @@ export function invalidateUserMedicationListLayout(userId: string): void {
|
||||
caches.medicationListLayout.deleteByPrefix(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the analytics bucket after an illness / cycle / encounter write.
|
||||
* `resolveRestMode` (active-episode context) feeds both the analytics report
|
||||
* and the dashboard snapshot, and the daily-digest extras cell lives under the
|
||||
* same bucket — all keyed `${userId}|…`. Hard-evict, mirroring the mood-write
|
||||
* posture: these are interactive single-entry writes, and a marked-stale SWR
|
||||
* cell would hand the user back the pre-write rest-mode / appointment context
|
||||
* on the very next read.
|
||||
*/
|
||||
export function invalidateUserHealthContext(userId: string): void {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
}
|
||||
|
||||
/** Invalidate surfaces whose server decision includes pattern dismissal. */
|
||||
export function invalidateUserCorrelationPatterns(userId: string): void {
|
||||
caches.analytics.deleteByPrefix(`${userId}|`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Fitbit sync orchestration and bounded cohort polling. */
|
||||
import pLimit from "p-limit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import { isReauthRequired, recordSyncSuccess } from "@/lib/integrations/status";
|
||||
@@ -161,6 +162,14 @@ export async function syncUserFitbit(
|
||||
await recordSyncSuccess(userId, "fitbit");
|
||||
}
|
||||
|
||||
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
||||
// from a poll) and sweep the workouts cache so the imported rows reach the
|
||||
// cached readers. Fires on a partial failure too — rows that DID land must
|
||||
// not stay invisible for the rest of the TTL window.
|
||||
if (total > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "fitbit.sync", details: { imported: total, failed } },
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* schedules, and boss.work registrations.
|
||||
*/
|
||||
import { type Job } from "pg-boss";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { recordError } from "@/lib/jobs/worker-status";
|
||||
import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
@@ -124,6 +125,12 @@ export async function runWhoopResourceSync(
|
||||
// v1.18.1 — a fresh reading landed; resolve this user's Vorsorge
|
||||
// reminders eventfully. Fire-and-forget; the cron is the net.
|
||||
if (imported > 0) {
|
||||
// Background-sync posture: mark the analytics cells stale (never
|
||||
// hard-evict from a poll) and sweep the workouts cache so the
|
||||
// imported rows reach the cached readers. This driver is the only
|
||||
// sync tail the webhook / per-resource cron paths pass through —
|
||||
// `syncUserWhoop` covers the manual + backfill paths itself.
|
||||
invalidateUserMeasurements(userId);
|
||||
fireAndForget(enqueueReminderSatisfy(userId), {
|
||||
action: "reminder.satisfy.enqueue",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* The one Vorsorge-reminder list read, shared by `GET
|
||||
* /api/measurement-reminders` and the `/checkups` RSC prefetch wrapper so the
|
||||
* two cannot drift in filter, order, or DTO shape — the SSR-seeded cell must
|
||||
* be byte-compatible with what the client `queryFn` gets from the wire.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
toMeasurementReminderDto,
|
||||
type MeasurementReminderDtoShape,
|
||||
} from "@/lib/measurement-reminders/dto";
|
||||
|
||||
export async function listMeasurementReminders(
|
||||
userId: string,
|
||||
): Promise<MeasurementReminderDtoShape[]> {
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
// A booked visit's one-shot reminder rides this same engine with
|
||||
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
|
||||
// Vorsorge surface, or the list fills with appointments. Four read
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
where: { userId, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
|
||||
// to the end so the actionable items float to the top.
|
||||
orderBy: [
|
||||
{ nextDueAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
return reminders.map(toMeasurementReminderDto);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Page size of the mood management list — shared by the client list query
|
||||
* (`components/mood/mood-list.tsx`) and the `/mood` RSC prefetch wrapper, so
|
||||
* the server-seeded first page carries exactly the slice the client's
|
||||
* first-mount `queryFn` would fetch. Deliberately dependency-free: the client
|
||||
* bundle must be able to import it, so it cannot live in the (server-only)
|
||||
* list read next to the query it parameterises.
|
||||
*/
|
||||
export const MOOD_LIST_PAGE_SIZE = 25;
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* The one mood-entry list read, shared by `GET /api/mood-entries` and the
|
||||
* `/mood` RSC prefetch wrapper so the two cannot drift in filter, order, or
|
||||
* wire shape — the SSR-seeded cell must be byte-compatible with what the
|
||||
* client `queryFn` gets from the wire. Ciphertext never leaves this module:
|
||||
* the note is decrypted onto `note` and the context rides `contextForWire`.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire } from "@/lib/mood/context";
|
||||
|
||||
export interface MoodEntryListParams {
|
||||
mood?: string;
|
||||
source?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
sortBy: string;
|
||||
sortDir: "asc" | "desc";
|
||||
}
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
return JSON.parse(tags) as string[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listMoodEntriesPage(
|
||||
userId: string,
|
||||
params: MoodEntryListParams,
|
||||
) {
|
||||
const { mood, source, from, to, limit, offset, sortBy, sortDir } = params;
|
||||
|
||||
const where = {
|
||||
userId,
|
||||
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
|
||||
deletedAt: null,
|
||||
...(mood && { mood }),
|
||||
// v1.15.13 — management-list source filter.
|
||||
...(source && { source }),
|
||||
...(from || to
|
||||
? {
|
||||
date: {
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
|
||||
// the client can split binary tags from rated factors on hydrate.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
entries: entriesWithParsedTags,
|
||||
meta: { total, limit, offset },
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
emitInsertedMeasurementArrivals,
|
||||
type InsertedMeasurementArrivalRow,
|
||||
} from "@/lib/arrivals/measurement-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import {
|
||||
fetchSgvEntries,
|
||||
@@ -185,6 +186,14 @@ export async function syncUserNightscout(
|
||||
entries,
|
||||
);
|
||||
|
||||
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
||||
// from a poll) so the inserted rows reach the cached readers. Fires on a
|
||||
// partial failure too — rows that DID land must not stay invisible for the
|
||||
// rest of the TTL window.
|
||||
if (inserted > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
if (failedRows > 0) {
|
||||
// Partial write failure: keep the tick honest. `transient` because the
|
||||
// ~48 h re-fetch window self-heals a transient DB hiccup, while a
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
emitInsertedMeasurementArrivals,
|
||||
type InsertedMeasurementArrivalRow,
|
||||
} from "@/lib/arrivals/measurement-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
@@ -498,6 +499,14 @@ export async function syncUserOura(
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
||||
// from a poll) so the imported rows reach the cached readers. Fires on a
|
||||
// partial failure too — rows that DID land must not stay invisible for the
|
||||
// rest of the TTL window.
|
||||
if (imported > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
if (result.failures.length > 0) {
|
||||
// Partial failure: keep the cycle honest. Record the failure and do NOT
|
||||
// stamp success, so the freshness surface reflects that some collections
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
@@ -102,6 +103,13 @@ export async function syncUserPolarWorkouts(userId: string): Promise<number> {
|
||||
throw markSyncFailureRecorded(err);
|
||||
}
|
||||
|
||||
// Sweep the workouts cache (and mark the analytics cells stale — workouts
|
||||
// feed achievements + analytics) so the upserted rows reach the cached
|
||||
// readers, matching the `/api/workouts/batch` and Google Health writers.
|
||||
if (imported > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
collapseToTypeDayKeys,
|
||||
recomputeBucketsForMeasurement,
|
||||
} from "@/lib/rollups/measurement-rollups";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate";
|
||||
import type { SyncWriteResult } from "@/lib/outcome/written-outcome";
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
@@ -276,6 +277,14 @@ export async function syncUserPolar(userId: string): Promise<SyncWriteResult> {
|
||||
() => {},
|
||||
);
|
||||
|
||||
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
||||
// from a poll) so the imported rows reach the cached readers. Fires on a
|
||||
// partial failure too — rows that DID land must not stay invisible for the
|
||||
// rest of the TTL window.
|
||||
if (imported > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
// Partial failure: import what the healthy collections returned, but keep
|
||||
// the cycle honest — record ONE failure and do NOT stamp success, so the
|
||||
|
||||
@@ -240,6 +240,22 @@ export const medicationDependentKeys = [
|
||||
*/
|
||||
export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
|
||||
|
||||
/**
|
||||
* Keys invalidated when an illness episode starts, ends, is edited, deleted,
|
||||
* restored, or a day-log lands. Rest Mode derives from the active-episode set
|
||||
* and both daily reads annotate from it (the hero band's "unwell since" frame,
|
||||
* the digest's paused nudges), so an episode write must reach the snapshot and
|
||||
* digest keys, not only the `["illness"]` tree. Pair every call site with
|
||||
* `refetchInactiveDailyReads` — the daily reads are typically unmounted while
|
||||
* the user is on the illness surface, and a bare invalidation would mark them
|
||||
* stale without refetching (the recurring v1.16.11/v1.29.1/v1.32.19 class).
|
||||
*/
|
||||
export const illnessDependentKeys = [
|
||||
queryKeys.illness(),
|
||||
queryKeys.dashboardSnapshot(),
|
||||
queryKeys.dailyDigest(),
|
||||
];
|
||||
|
||||
/**
|
||||
* Keys invalidated when a visit or an address-book entry changes.
|
||||
*
|
||||
@@ -257,6 +273,12 @@ export const cycleDependentKeys = [queryKeys.cycle(), queryKeys.insightsRoot()];
|
||||
export const encounterDependentKeys = [
|
||||
queryKeys.encounters(),
|
||||
queryKeys.practitioners(),
|
||||
// A booked / rescheduled / removed appointment is a row the Today digest
|
||||
// surfaces, so it must appear on the rail in the same tick as the visit
|
||||
// list. Pair call sites with `refetchInactiveDailyReads` (or the
|
||||
// `invalidateReminderReads` helper, which carries it) — the digest is
|
||||
// typically unmounted while the user is on the visits surface.
|
||||
queryKeys.dailyDigest(),
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -72,6 +72,14 @@ export const measurementKeys = {
|
||||
* fetch window; the factory packs those into a single tuple to keep
|
||||
* the cache layout byte-identical with the pre-v1.4.40 layout.
|
||||
*/
|
||||
/**
|
||||
* Prefix key — invalidates every `chartData(…)` / `chartSeriesBatch(…)`
|
||||
* slot at once via TanStack's hierarchical-prefix semantics. For call
|
||||
* sites outside the factory that need the whole per-chart cache flushed
|
||||
* (a source-priority reorder changes every chart's canonical-row picks).
|
||||
*/
|
||||
chartDataRoot: () => ["chart-data"] as const,
|
||||
|
||||
chartData: (
|
||||
types: string,
|
||||
valueMode: string,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit";
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
markSyncFailureRecorded,
|
||||
@@ -244,6 +245,14 @@ export async function syncUserStrava(
|
||||
}
|
||||
|
||||
await recordSyncSuccess(userId, "strava");
|
||||
|
||||
// Background-sync posture: sweep the workouts cache and mark the analytics
|
||||
// cells stale (workouts feed achievements + analytics) so the upserted rows
|
||||
// reach the cached readers, matching the `/api/workouts/batch` writer.
|
||||
if (imported > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "strava.sync.complete", details: { imported } },
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* `sync-core.ts`. Resource leaves depend only on that core; this parent owns
|
||||
* the static leaf graph and the full per-user sequencing policy.
|
||||
*/
|
||||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
isReauthRequired,
|
||||
@@ -99,5 +100,15 @@ export async function syncUserWhoop(
|
||||
if (!anyFailed && !allSoftSkipped) {
|
||||
await recordSyncSuccess(userId, "whoop");
|
||||
}
|
||||
|
||||
// Background-sync posture: mark the analytics cells stale (never hard-evict
|
||||
// from a poll) and sweep the workouts cache so the imported rows reach the
|
||||
// cached readers. Fires on a partial failure too — rows that DID land must
|
||||
// not stay invisible for the rest of the TTL window. The per-resource
|
||||
// webhook/cron jobs run their own call in `runWhoopResourceSync`.
|
||||
if (total > 0) {
|
||||
invalidateUserMeasurements(userId);
|
||||
}
|
||||
|
||||
return { imported: total, failed: anyFailed || allSoftSkipped };
|
||||
}
|
||||
Reference in new issue
Block a user