diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7cf4bd1d8..b81648db1 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/messages/de.json b/messages/de.json index 9d55f7033..80dbdbd1a 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 c7bb1372c..23a6f6c43 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 776b0ad5b..14faca41b 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 3bc572f44..98bf35b22 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 e6715b220..33213d015 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 cd91ec50d..265a64224 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 7c2769cb9..c5cb87b1b 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/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/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 ( + + ); +} 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"]); }); });