From bf84c0c11e2cec18dfcd1ff4dfd668e6b3ec29c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 19:49:11 +0200 Subject: [PATCH 1/2] fix(measurements): file sleep sessions by span, not by night label The SQL page labels a sleep session by the last row of any writer; the reconstruction labels it by its winning writer. A watch stopping before midnight beside a phone whose in-bed span ran past it gave two days, and matching the two by label dropped the night from the list while the total still counted it. Each reconstructed session is now filed under the page night whose span holds it and takes that night's label, which the drill-down also asks by. A sleep drill-down scans 36 hours either side of its day instead of the whole history. Both day branches and the sleep list resolve the zone through the same stored-zone resolver, so a drill-down always cuts the days its list row summed. The list description states the real page bounds: a day-sum page takes `limit` as asked, a sleep page caps at 1000 nights. Refs #1245 --- docs/api/openapi.yaml | 5 +- src/app/api/measurements/route.ts | 76 +++++++++++++------ src/lib/openapi/routes/measurements.ts | 2 +- .../measurements-day-grouped-paging.test.ts | 9 ++- .../measurements-sleep-list.test.ts | 61 ++++++++++++++- 5 files changed, 122 insertions(+), 31 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f1d4e182f..160cc6193 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4204,8 +4204,9 @@ paths: With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way - over nights. On both, `meta.total` counts the days or nights in the filtered window, and a page holds at most - 1000 of them (`meta.limit` echoes the value applied). + over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes + `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the + value applied. parameters: - in: query name: type diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6939d4777..1efeef64c 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -55,7 +55,7 @@ import { pickMainNightAndNaps, SESSION_GAP_MS, } from "@/lib/analytics/sleep-night"; -import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver"; import { isNamedSleepStageBody, sleepStageEntrySchema, @@ -79,7 +79,6 @@ import { type GlucoseContext, type SleepStage, } from "@/generated/prisma/client"; -import { DEFAULT_TIMEZONE, isValidTimezone } from "@/lib/tz/format"; export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "measurements"); @@ -193,10 +192,9 @@ export const GET = apiHandler(async (request: NextRequest) => { // hid. Gated to cumulative HK types — the spot-sample types render // one-row-per-event already and have nothing to drill into. if (dayKey && type && CUMULATIVE_HK_TYPES.has(type as MeasurementType)) { - const tz = - user.timezone && user.timezone.length > 0 - ? user.timezone - : DEFAULT_TIMEZONE; + // The same zone the day-grouped list cuts its days in, so a row's + // drill-down opens exactly the day the row summed. + const tz = await resolveStoredTimezone(user.timezone); // Resolve `[dayStart, dayEnd)` for the requested calendar day in // the user's local zone. v1.4.37 W10 — `localDayWindow` honours // DST: the previous shape (`canonicalDailyTimestamp ± 12 h`) @@ -408,13 +406,10 @@ async function dayGroupedListResponse( }, ) { const { type, limit, offset, sortDir } = opts; - const stored = - user.timezone && user.timezone.length > 0 - ? user.timezone - : DEFAULT_TIMEZONE; - // A zone Postgres does not know would 500 the read; the drill-down cuts - // days in the same zone, so both must agree on it. - const tz = isValidTimezone(stored) ? stored : DEFAULT_TIMEZONE; + // One resolver for the stored zone, shared with the drill-down and the + // sleep list: a usable IANA name or the instance default, never a value + // Postgres would refuse. The drill-down must cut the same days. + const tz = await resolveStoredTimezone(user.timezone); // The request's filters, as SQL: the same ones the plain list applies. const filters = Prisma.sql` @@ -623,7 +618,7 @@ const SLEEP_NIGHT_PAGE_MAX = 1000; * there without saying so to the client. */ async function sleepListResponse( - user: { id: string }, + user: { id: string; timezone?: string | null }, opts: { from?: Date; to?: Date; @@ -636,7 +631,7 @@ async function sleepListResponse( const { from, to, offset, sortDir, dayKey } = opts; const limit = Math.min(opts.limit, SLEEP_NIGHT_PAGE_MAX); const [tz, priorityJson] = await Promise.all([ - resolveUserTimezone(user.id), + resolveStoredTimezone(user.timezone), loadUserSourcePriority(user.id), ]); @@ -647,7 +642,6 @@ async function sleepListResponse( ...(dayKey ? { night: dayKey, limit: 1, offset: 0 } : { limit, offset }), }); const nightKeys = page.nights.map((n) => n.night); - const wanted = new Set(nightKeys); // The page's sessions lie between the first one's start and the last one's // end, and every other session is more than the session gap away from that @@ -692,8 +686,23 @@ async function sleepListResponse( }, }); } - const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter( - (s) => wanted.has(s.night), + // Each reconstructed session is filed under the page night whose span + // holds it, and takes that night's label. Matching by the reconstruction's + // own label lost a night whenever the two named different days: the page + // counted it under one, the reconstruction filed it under the other, and + // the filter dropped it from both. + const sessions = reconstructSleepSessions(rows, tz, priorityJson).flatMap( + (session) => { + // A second of slack absorbs the sub-millisecond rounding between the + // two start computations; sessions sit hours apart, so it can never + // reach a neighbour. + const home = page.nights.find( + (n) => + n.spanStart.getTime() - 1000 <= session.start.getTime() && + session.end.getTime() <= n.spanEnd.getTime() + 1000, + ); + return home ? [{ ...session, night: home.night }] : []; + }, ); // Drill-down — return the requested night's canonical stage segments. @@ -812,11 +821,24 @@ async function sleepListResponse( * filtered set from the same query. `spanStart` / `spanEnd` bound the * night's counting sessions, so the caller reads exactly their rows. * - * The session's night here is taken over every writer, while the JS fold - * takes it over the winning writer only. They part only when a losing writer - * runs past midnight and the winner does not; that night then sits on the - * page and comes back without a row, and `total` counts it. + * This label is the list's own: the page is cut by it, a row carries it and + * a drill-down asks by it. It is taken over every writer, where the JS + * reconstruction takes the winning writer only, so the two can name + * different days (a losing phone's in-bed span running past midnight while + * the watch stops before it). The caller therefore never matches the two by + * label: it files each reconstructed session under the night whose span + * holds it, which is exact, because a reconstructed session is one of these + * sessions narrowed to its winner. + * + * With `night` set (a drill-down) the scan is cut to a window of 36 hours + * either side of that local day. Sessions are split at a three-hour gap, so + * a session ending on that day starts inside the window unless it lasted + * over a day and a half, and every session the cut truncates ends on another + * day. */ +/** How far a drill-down's session scan reaches either side of its day. */ +const DRILL_DOWN_REACH_MS = 36 * 60 * 60 * 1000; + async function sleepNightPage( userId: string, tz: string, @@ -832,8 +854,8 @@ async function sleepNightPage( nights: Array<{ night: string; spanStart: Date; spanEnd: Date }>; total: number; }> { - const zone = isValidTimezone(tz) ? tz : DEFAULT_TIMEZONE; const gapSeconds = SESSION_GAP_MS / 1000; + const window = opts.night ? localDayWindow(opts.night, tz) : null; // Direction comes from the Zod enum, never from raw input. const direction = Prisma.raw(opts.sortDir === "asc" ? "ASC" : "DESC"); const nightsCte = Prisma.sql` @@ -849,6 +871,12 @@ async function sleepNightPage( AND m."deleted_at" IS NULL ${opts.from ? Prisma.sql`AND m."measured_at" >= ${opts.from}` : Prisma.empty} ${opts.to ? Prisma.sql`AND m."measured_at" <= ${opts.to}` : Prisma.empty} + ${ + window + ? Prisma.sql`AND m."measured_at" >= ${new Date(window.dayStart.getTime() - DRILL_DOWN_REACH_MS)} + AND m."measured_at" < ${new Date(window.dayEnd.getTime() + DRILL_DOWN_REACH_MS)}` + : Prisma.empty + } ), o AS ( SELECT r.*, MAX(e) OVER ( ORDER BY s, e ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING @@ -867,7 +895,7 @@ async function sleepNightPage( HAVING BOOL_OR(asleep) ), nights AS ( SELECT - to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${zone}, 'YYYY-MM-DD') AS night, + to_char((e AT TIME ZONE 'UTC') AT TIME ZONE ${tz}, 'YYYY-MM-DD') AS night, MIN(s) AS span_start, MAX(e) AS span_end FROM sess diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index b435f3850..35e46830c 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -804,7 +804,7 @@ export const measurementPaths: NonNullable = { summary: "List measurements", description: "Filter by type + date range. Response is paged via offset/limit; default limit 100.\n\n" + - "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. On both, `meta.total` counts the days or nights in the filtered window, and a page holds at most 1000 of them (`meta.limit` echoes the value applied).", + "With `groupBy=day` on a cumulative type the rows are one sum per local day, and `limit` / `offset` page over those days; every returned day is complete. `type=SLEEP_DURATION` returns one row per night, paged the same way over nights. On both, `meta.total` counts the days or nights in the filtered window. A day-sum page takes `limit` as asked (up to the list's 5000); a sleep page holds at most 1000 nights, and `meta.limit` echoes the value applied.", requestParams: { query: listMeasurementsSchema, }, diff --git a/tests/integration/measurements-day-grouped-paging.test.ts b/tests/integration/measurements-day-grouped-paging.test.ts index 85d60a3a9..738a86f6d 100644 --- a/tests/integration/measurements-day-grouped-paging.test.ts +++ b/tests/integration/measurements-day-grouped-paging.test.ts @@ -88,7 +88,10 @@ async function seedSession(username: string) { }, }); const session = await prisma.session.create({ - data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + // An hour, not a minute: walking every page of a long history can + // outlast a minute on a loaded runner, and an expired session reads as + // a 401 halfway through. + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return user; @@ -207,7 +210,7 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => { ).json()) as ListEnvelope; expect(past.data.measurements).toEqual([]); expect(past.data.meta.total).toBe(expected.size); - }); + }, 240_000); it("starts at the oldest day ascending and counts only the filtered window", async () => { const user = await seedSession("day-paging-window-user"); @@ -235,5 +238,5 @@ describe("GET /api/measurements?groupBy=day paging (#1245)", () => { } expect(windowed.data.meta.total).toBe(inWindow); expect(windowed.data.measurements).toHaveLength(PAGE); - }); + }, 240_000); }); diff --git a/tests/integration/measurements-sleep-list.test.ts b/tests/integration/measurements-sleep-list.test.ts index 2044d7d74..5db0eaf56 100644 --- a/tests/integration/measurements-sleep-list.test.ts +++ b/tests/integration/measurements-sleep-list.test.ts @@ -66,7 +66,10 @@ async function seedSession(username: string) { data: { username, email: `${username}@example.test`, role: "USER" }, }); const session = await prisma.session.create({ - data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + // An hour, not a minute: walking every page of a long history can + // outlast a minute on a loaded runner, and an expired session reads as + // a 401 halfway through. + data: { userId: user.id, expiresAt: new Date(Date.now() + 3_600_000) }, }); cookieJar.set("healthlog_session", session.id); return user; @@ -287,5 +290,61 @@ describe("GET /api/measurements?type=SLEEP_DURATION", () => { ).json()) as ListEnvelope; expect(drill.data.measurements).toHaveLength(1); expect(drill.data.measurements[0].sleepStage).toBe("CORE"); + }, 240_000); + + // Review finding on the SQL paging: the page labels a session by the last + // row of ANY writer, the reconstruction by its winning writer. A watch that + // stops before midnight beside a phone whose in-bed span runs past it gave + // two different days, and matching by label dropped the night from both. + it("keeps a night whose losing writer runs past midnight", async () => { + const prisma = getPrismaClient(); + const user = await seedSession("sleep-midnight-user"); + await prisma.user.update({ + where: { id: user.id }, + data: { timezone: "UTC" }, + }); + const base = { + userId: user.id, + type: "SLEEP_DURATION" as const, + unit: "minutes", + source: "APPLE_HEALTH" as const, + }; + await prisma.measurement.createMany({ + data: [ + // The watch wins the device ladder and ends at 23:50. + { + ...base, + value: 420, + measuredAt: new Date("2026-03-10T23:50:00.000Z"), + externalId: "uuid-watch-core", + sleepStage: "CORE" as const, + deviceType: "watch", + }, + // The phone's in-bed span ends at 00:20 the next day. + { + ...base, + value: 450, + measuredAt: new Date("2026-03-11T00:20:00.000Z"), + externalId: "uuid-phone-inbed", + sleepStage: "IN_BED" as const, + deviceType: "phone", + }, + ], + }); + + const list = (await ( + await callList("type=SLEEP_DURATION") + ).json()) as ListEnvelope; + expect(list.data.meta.total).toBe(1); + // The night is listed, not lost between two labels. + expect(list.data.measurements).toHaveLength(1); + const [night] = list.data.measurements; + expect(night.value).toBe(420); + + // Its drill-down, asked by the label the list gave it, finds it too. + const drill = (await ( + await callList(`type=SLEEP_DURATION&dayKey=${night.dayKey}`) + ).json()) as ListEnvelope; + expect(drill.data.measurements.map((m) => m.sleepStage)).toEqual(["CORE"]); }); }); From 9095b1037e391e7b630785ad0342235cf7bde98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 19:49:14 +0200 Subject: [PATCH 2/2] fix(ui): draw nothing for a refused read and no retry for a spent budget The failure rows added for supplementary reads offered a retry for any error. The usual-range strip reads at manage level, so a viewer on a read-only share saw a permanent failure with a retry that could never succeed; the visit preparation reads measurements under visits that need only the profile section, which a share can grant alone. A refused read (401/403) now draws nothing, a spent read budget (429) says so without a retry, and only a real failure keeps the retry row. The cycle strip holds no cell for a refused calendar read. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../use-cycle-ring-has-dial.test.tsx | 17 ++++- src/components/cycle/cycle-ring-tile.tsx | 5 +- src/components/cycle/use-cycle.ts | 9 ++- src/components/day/since-last-visit.tsx | 9 ++- .../__tests__/visits-section.test.tsx | 31 ++++++--- .../insights/derived/coach-read-strip.tsx | 11 +-- .../insights/mood/mood-prognosis-card.tsx | 7 +- .../insights/nutrients/caffeine-card.tsx | 7 +- .../measurements/measurement-list.tsx | 5 +- src/components/mood/mood-quick-tags.tsx | 7 +- src/components/mood/mood-tag-picker.tsx | 7 +- .../ui/__tests__/read-failure-row.test.tsx | 62 +++++++++++++++++ src/components/ui/read-failure-row.tsx | 68 +++++++++++++++++++ 20 files changed, 224 insertions(+), 42 deletions(-) create mode 100644 src/components/ui/__tests__/read-failure-row.test.tsx create mode 100644 src/components/ui/read-failure-row.tsx diff --git a/messages/de.json b/messages/de.json index 260f50100..7c952a5bb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} Std.", "errorIdLabel": "Fehler-ID", "errorIdCopy": "Fehler-ID kopieren", - "errorIdCopied": "Fehler-ID kopiert" + "errorIdCopied": "Fehler-ID kopiert", + "readThrottled": "Gerade zu viele Anfragen. Versuch es später noch einmal." }, "apiErrors": { "measurement": { diff --git a/messages/en.json b/messages/en.json index 6ac777648..2823b7b14 100644 --- a/messages/en.json +++ b/messages/en.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "Error ID", "errorIdCopy": "Copy error ID", - "errorIdCopied": "Error ID copied" + "errorIdCopied": "Error ID copied", + "readThrottled": "Too many requests just now. Try again later." }, "apiErrors": { "measurement": { diff --git a/messages/es.json b/messages/es.json index 4a05b5498..f9d44b079 100644 --- a/messages/es.json +++ b/messages/es.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID de error", "errorIdCopy": "Copiar ID de error", - "errorIdCopied": "ID de error copiado" + "errorIdCopied": "ID de error copiado", + "readThrottled": "Demasiadas solicitudes ahora mismo. Vuelve a intentarlo más tarde." }, "apiErrors": { "measurement": { diff --git a/messages/fr.json b/messages/fr.json index 31edfead6..69c90687c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID d'erreur", "errorIdCopy": "Copier l'ID d'erreur", - "errorIdCopied": "ID d'erreur copié" + "errorIdCopied": "ID d'erreur copié", + "readThrottled": "Trop de requêtes pour le moment. Réessaie plus tard." }, "apiErrors": { "measurement": { diff --git a/messages/it.json b/messages/it.json index 21210a200..52dc36830 100644 --- a/messages/it.json +++ b/messages/it.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} h", "errorIdLabel": "ID errore", "errorIdCopy": "Copia ID errore", - "errorIdCopied": "ID errore copiato" + "errorIdCopied": "ID errore copiato", + "readThrottled": "Troppe richieste in questo momento. Riprova più tardi." }, "apiErrors": { "measurement": { diff --git a/messages/ko.json b/messages/ko.json index 8c8370cfe..bf8a6fa20 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -54,7 +54,8 @@ "durationClockHours": "{time}시간", "errorIdLabel": "오류 ID", "errorIdCopy": "오류 ID 복사", - "errorIdCopied": "오류 ID 복사됨" + "errorIdCopied": "오류 ID 복사됨", + "readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요." }, "apiErrors": { "measurement": { diff --git a/messages/pl.json b/messages/pl.json index 9abaabaca..0c3a0a646 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -54,7 +54,8 @@ "durationClockHours": "{time} godz.", "errorIdLabel": "ID błędu", "errorIdCopy": "Kopiuj ID błędu", - "errorIdCopied": "Skopiowano ID błędu" + "errorIdCopied": "Skopiowano ID błędu", + "readThrottled": "Zbyt wiele zapytań w tej chwili. Spróbuj ponownie później." }, "apiErrors": { "measurement": { diff --git a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx index 6d7653583..4b872315e 100644 --- a/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx +++ b/src/components/cycle/__tests__/use-cycle-ring-has-dial.test.tsx @@ -13,6 +13,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; +import { ApiError } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { localYmd, useCycleRingDial } from "../use-cycle"; @@ -32,9 +33,11 @@ function Probe({ enabled }: { enabled: boolean }) { /** Marks the calendar read as failed, with no data behind it. */ const FAILED = Symbol("failed"); +/** Marks the calendar read as refused (a share without the section). */ +const REFUSED = Symbol("refused"); function probe( - verdict: unknown | undefined | typeof FAILED, + verdict: unknown | undefined | typeof FAILED | typeof REFUSED, enabled = true, ): string { const client = new QueryClient({ @@ -45,13 +48,16 @@ function probe( }, }); const [from, to] = window(); - if (verdict === FAILED) { + if (verdict === FAILED || verdict === REFUSED) { client .getQueryCache() .build(client, { queryKey: queryKeys.cycleCalendar(from, to) }) .setState({ status: "error", - error: new Error("calendar read failed"), + error: + verdict === REFUSED + ? new ApiError("Forbidden", 403) + : new Error("calendar read failed"), fetchStatus: "idle", }); } else if (verdict !== undefined) { @@ -92,6 +98,11 @@ describe("useCycleRingDial", () => { expect(probe(FAILED)).toBe("false:false:failed"); expect(probe(FAILED, false)).toBe("false:false"); }); + + // A refused read draws nothing in the tile, so it must not hold a cell. + it("holds no cell for a refused read", () => { + expect(probe(REFUSED)).toBe("false:false"); + }); }); describe("the scores strip keeps the ring's cell on a failed read", () => { diff --git a/src/components/cycle/cycle-ring-tile.tsx b/src/components/cycle/cycle-ring-tile.tsx index 0096a0184..4483a177f 100644 --- a/src/components/cycle/cycle-ring-tile.tsx +++ b/src/components/cycle/cycle-ring-tile.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import Link from "next/link"; import { Droplets } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; import { CycleRing } from "./cycle-ring"; @@ -71,7 +71,8 @@ export function CycleRingTile({ className }: { className?: string }) { if (calendar.isLoading && !calendar.data) return null; if (calendar.isError && !calendar.data) { return ( - void calendar.refetch()} diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts index 285c5d7b6..d1857122e 100644 --- a/src/components/cycle/use-cycle.ts +++ b/src/components/cycle/use-cycle.ts @@ -24,6 +24,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { randomId } from "@/lib/random-id"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useCalendarDate } from "@/hooks/use-calendar-date"; +import { readFailureKind } from "@/components/ui/read-failure-row"; import { cycleDependentKeys, @@ -151,7 +152,13 @@ export function useCycleRingDial(enabled: boolean): { return { dial: enabled && verdict?.phase != null && verdict.dayOfCycle != null, pending: enabled && calendar.isPending, - failed: enabled && calendar.isError && !calendar.data, + // A refused read is not a failure to report: the tile would draw nothing + // and leave its cell empty. + failed: + enabled && + calendar.isError && + !calendar.data && + readFailureKind(calendar.error) !== "denied", }; } diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 34982d4d1..54887ad6b 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -18,7 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import type { EncounterDTO } from "@/lib/encounters/dto"; import { apiGet } from "@/lib/api/api-fetch"; import type { @@ -61,7 +61,9 @@ import { useTodayKey } from "./use-today-key"; * most often turns out empty, then collapse it again. Arriving late costs * only the visit list below a shift, once per window; the read is cached for * five minutes. A failed read is not a quiet window, so it says so in a - * compact row with a retry. + * compact row with a retry. A refused one draws nothing: visits need the + * profile section and this window reads measurements, and a share can carry + * the first without the second. */ const EVENT_ICON: Partial> = { @@ -193,7 +195,8 @@ export function SinceLastVisit({ if (window.isError && rows.length === 0) { return ( - void window.refetch()} diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index c945dbe1d..c4b21a037 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -48,6 +48,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }); import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import { ApiError } from "@/lib/api/api-fetch"; import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; @@ -234,8 +235,8 @@ describe(" since the last visit", () => { expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); - it("says a failed read failed, rather than passing it off as a quiet window", () => { - const html = render({ upcoming: [next], past: [last] }, (queryClient) => { + function seedFailure(error: Error) { + return (queryClient: QueryClient) => { const tz = DEFAULT_TIMEZONE; const queryKey = queryKeys.dayNotable( dateKeyOfInstant(last.occurredAt, tz), @@ -247,12 +248,15 @@ describe(" since the last visit", () => { queryClient .getQueryCache() .build(queryClient, { queryKey }) - .setState({ - status: "error", - error: new Error("window read failed"), - fetchStatus: "idle", - }); - }); + .setState({ status: "error", error, fetchStatus: "idle" }); + }; + } + + it("says a failed read failed, rather than passing it off as a quiet window", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedFailure(new ApiError("window read failed", 500)), + ); const baseline = render({ upcoming: [next], past: [] }); expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); @@ -260,6 +264,17 @@ describe(" since the last visit", () => { expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); + // Visits need the profile section, the window reads measurements, and a + // share can carry the first without the second. A refusal is not a failure. + it("draws nothing when the window read is refused", () => { + const html = render( + { upcoming: [next], past: [last] }, + seedFailure(new ApiError("Forbidden", 403)), + ); + expect(html).not.toContain("since-last-visit-error"); + expect(html).not.toContain('role="alert"'); + }); + it("draws one card with the rows once there is something to list", () => { const html = render( { upcoming: [next], past: [last] }, diff --git a/src/components/insights/derived/coach-read-strip.tsx b/src/components/insights/derived/coach-read-strip.tsx index 6bb30c8d3..0519c58a8 100644 --- a/src/components/insights/derived/coach-read-strip.tsx +++ b/src/components/insights/derived/coach-read-strip.tsx @@ -11,7 +11,7 @@ import { queryKeys } from "@/lib/query-keys"; import { metricFractionDigits } from "@/lib/measurements/value-domain"; import { apiGet } from "@/lib/api/api-fetch"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { TileHeader } from "@/components/insights/tile-header"; import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape"; @@ -80,7 +80,7 @@ export function CoachReadStrip({ const transformed = unitDisplay.isTransformed(metricType); const resolvedUnit = transformed ? unitDisplay.unitFor(metricType) : unit; - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.insightsCoachRead(metricType, locale), queryFn: () => apiGet( @@ -97,12 +97,15 @@ export function CoachReadStrip({ // baseline line degrades to the "learning" copy), so the placeholder only // ever gives way to a card of its own height. A failed read gives way to a // notice with a retry: dropping the strip read as "nothing to say about - // today", which is not what happened. + // today", which is not what happened. The route is a manage-level read, so + // a viewer on a read-only share is refused it; that draws nothing, and a + // spent analytics budget says so without offering a retry. if (!mounted || !data) { if (!isAuthenticated) return null; if (isError) { return ( - void refetch()} diff --git a/src/components/insights/mood/mood-prognosis-card.tsx b/src/components/insights/mood/mood-prognosis-card.tsx index 3220acf6c..e6acef61f 100644 --- a/src/components/insights/mood/mood-prognosis-card.tsx +++ b/src/components/insights/mood/mood-prognosis-card.tsx @@ -32,7 +32,7 @@ import { Compass } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { useAuth } from "@/hooks/use-auth"; import { apiGet } from "@/lib/api/api-fetch"; import { formatDate } from "@/lib/date-format"; @@ -62,7 +62,7 @@ export function MoodPrognosisCard() { const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.moodPrognosis(), queryFn: async () => apiGet("/api/mood/prognosis"), enabled: isAuthenticated, @@ -71,7 +71,8 @@ export function MoodPrognosisCard() { if (isError && !data) { return ( - void refetch()} diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index 399053bbe..7d74d06bc 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { Coffee } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { TileHeader } from "@/components/insights/tile-header"; import { NutrientDailyBarChartDynamic } from "@/components/charts/nutrient-daily-bar-chart-dynamic"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; @@ -30,7 +30,7 @@ export function CaffeineCard() { const { t } = useTranslations(); const fmt = useFormatters(); - const { data, isError, refetch } = useQuery({ + const { data, error, isError, refetch } = useQuery({ queryKey: queryKeys.nutrientDaily("caffeine", WINDOW_DAYS), queryFn: () => apiGet( @@ -43,7 +43,8 @@ export function CaffeineCard() { if (isError && !data) { return ( - void refetch()} diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 625be97a8..36fe4fe3d 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { QueryErrorCard } from "@/components/ui/query-error-card"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { ListRow } from "@/components/ui/list-row"; import { @@ -1924,7 +1924,8 @@ function DayDrillDown({ } if (error) { return ( - void refetch()} diff --git a/src/components/mood/mood-quick-tags.tsx b/src/components/mood/mood-quick-tags.tsx index ccb21e07f..c9fae8e23 100644 --- a/src/components/mood/mood-quick-tags.tsx +++ b/src/components/mood/mood-quick-tags.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { Plus } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -63,7 +63,7 @@ export function MoodQuickTags({ const { isAuthenticated } = useAuth(); const { t } = useTranslations(); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, error, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => apiGet("/api/mood/tags"), enabled: isAuthenticated, @@ -75,7 +75,8 @@ export function MoodQuickTags({ } if (isError && !data) { return ( - void refetch()} diff --git a/src/components/mood/mood-tag-picker.tsx b/src/components/mood/mood-tag-picker.tsx index 900a64fbc..c15632b9b 100644 --- a/src/components/mood/mood-tag-picker.tsx +++ b/src/components/mood/mood-tag-picker.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Plus, Tag } from "lucide-react"; -import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ReadFailureRow } from "@/components/ui/read-failure-row"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; import { queryKeys } from "@/lib/query-keys"; @@ -105,7 +105,7 @@ export function MoodTagPicker({ // tile belongs to, or null when closed. const [createGroupKey, setCreateGroupKey] = useState(null); - const { data, isLoading, isError, refetch } = useQuery({ + const { data, error, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.moodTagCatalog(), queryFn: async () => { return apiGet("/api/mood/tags"); @@ -124,7 +124,8 @@ export function MoodTagPicker({ if (isError && !data) { return ( - void refetch()} diff --git a/src/components/ui/__tests__/read-failure-row.test.tsx b/src/components/ui/__tests__/read-failure-row.test.tsx new file mode 100644 index 000000000..f24470030 --- /dev/null +++ b/src/components/ui/__tests__/read-failure-row.test.tsx @@ -0,0 +1,62 @@ +/** + * A supplementary read that fails is reported by what the failure means. + * + * Several strips and tiles answer a failed read with a retry row. Two + * failures are not worth one: a refusal (a read-only share reaching a + * manage-level read, a grant without the section) cannot be retried into + * success and must draw nothing, and a spent read budget would only be + * refused again by an immediate retry. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { ApiError } from "@/lib/api/api-fetch"; +import { I18nProvider } from "@/lib/i18n/context"; +import { ReadFailureRow, readFailureKind } from "../read-failure-row"; + +function ssr(error: unknown) { + return renderToStaticMarkup( + + {}} + slot="probe" + /> + , + ); +} + +describe("readFailureKind", () => { + it("names refusals, a spent budget and everything else", () => { + expect(readFailureKind(new ApiError("x", 401))).toBe("denied"); + expect(readFailureKind(new ApiError("x", 403))).toBe("denied"); + expect(readFailureKind(new ApiError("x", 429))).toBe("throttled"); + expect(readFailureKind(new ApiError("x", 500))).toBe("failed"); + expect(readFailureKind(new ApiError("x", 404))).toBe("failed"); + expect(readFailureKind(new TypeError("Failed to fetch"))).toBe("failed"); + }); +}); + +describe("", () => { + it("draws nothing for a refused read", () => { + expect(ssr(new ApiError("Forbidden", 403))).toBe(""); + expect(ssr(new ApiError("Unauthorized", 401))).toBe(""); + }); + + it("says a spent budget without offering a retry", () => { + const html = ssr(new ApiError("Too many", 429)); + expect(html).toContain('data-slot="probe"'); + expect(html).toContain("Too many requests just now."); + expect(html).not.toContain("query-error-row-retry"); + }); + + it("offers a retry for a real failure", () => { + const html = ssr(new ApiError("Boom", 500)); + expect(html).toContain("Could not load the strip."); + expect(html).toContain('data-slot="query-error-row-retry"'); + expect(ssr(new TypeError("Failed to fetch"))).toContain( + 'data-slot="query-error-row-retry"', + ); + }); +}); diff --git a/src/components/ui/read-failure-row.tsx b/src/components/ui/read-failure-row.tsx new file mode 100644 index 000000000..7d6ab945d --- /dev/null +++ b/src/components/ui/read-failure-row.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { ApiError } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; + +/** + * What a failed read means for the surface that made it. + * + * - `denied`: 401 / 403. The viewer may not read this, typically a shared + * or delegated record whose grant leaves the section out. Nothing failed + * that a retry could fix, and the surface is simply not theirs to see. + * - `throttled`: 429. The read budget is spent for now; an immediate retry + * would only be refused again. + * - `failed`: everything else, a real read failure worth a retry. + */ +export type ReadFailureKind = "denied" | "throttled" | "failed"; + +export function readFailureKind(error: unknown): ReadFailureKind { + if (error instanceof ApiError) { + if (error.status === 401 || error.status === 403) return "denied"; + if (error.status === 429) return "throttled"; + } + return "failed"; +} + +/** + * The compact failure row for a supplementary read, by what the failure + * means: nothing for a refusal, a notice without a retry for a spent budget, + * and `QueryErrorRow` with a retry for a real failure. + * + * For a surface the page can stand without (a strip, a tile, a block under a + * card). A primary list keeps `QueryErrorCard`. + */ +export function ReadFailureRow({ + error, + message, + onRetry, + slot, + className, +}: { + error: unknown; + message?: string; + onRetry: () => void; + slot?: string; + className?: string; +}) { + const { t } = useTranslations(); + const kind = readFailureKind(error); + if (kind === "denied") return null; + if (kind === "throttled") { + return ( + + ); + } + return ( + + ); +}