Merge branch 'fix/v1436-ui' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 20:20:28 +02:00
commit 3ff860591e
25 files changed
+346 -73

No files matched your search

+3 -2
View File
@@ -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
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -54,7 +54,8 @@
"durationClockHours": "{time}시간",
"errorIdLabel": "오류 ID",
"errorIdCopy": "오류 ID 복사",
"errorIdCopied": "오류 ID 복사됨"
"errorIdCopied": "오류 ID 복사됨",
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요."
},
"apiErrors": {
"measurement": {
+2 -1
View File
@@ -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": {
+52 -24
View File
@@ -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
@@ -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", () => {
+3 -2
View File
@@ -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 (
<QueryErrorRow
<ReadFailureRow
error={calendar.error}
slot="wellness-cycle-tile-error"
message={t("cycle.wellnessRing.loadError")}
onRetry={() => void calendar.refetch()}
+8 -1
View File
@@ -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",
};
}
+6 -3
View File
@@ -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<Record<DayEventKind, LucideIcon>> = {
@@ -193,7 +195,8 @@ export function SinceLastVisit({
if (window.isError && rows.length === 0) {
return (
<QueryErrorRow
<ReadFailureRow
error={window.error}
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
onRetry={() => void window.refetch()}
@@ -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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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] },
@@ -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<CoachReadStripData>(
@@ -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 (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
@@ -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<PrognosisReading>("/api/mood/prognosis"),
enabled: isAuthenticated,
@@ -71,7 +71,8 @@ export function MoodPrognosisCard() {
if (isError && !data) {
return (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="mood-prognosis-error"
message={t("insights.mood.prognosis.loadError")}
onRetry={() => void refetch()}
@@ -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<NutrientDailySeries>(
@@ -43,7 +43,8 @@ export function CaffeineCard() {
if (isError && !data) {
return (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
onRetry={() => void refetch()}
@@ -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 (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="measurement-drilldown-error"
message={t("measurements.loadError")}
onRetry={() => void refetch()}
+4 -3
View File
@@ -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<CatalogResponse>("/api/mood/tags"),
enabled: isAuthenticated,
@@ -75,7 +75,8 @@ export function MoodQuickTags({
}
if (isError && !data) {
return (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="mood-quick-tags-error"
message={t("mood.tagCatalogLoadError")}
onRetry={() => void refetch()}
+4 -3
View File
@@ -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<string | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
const { data, error, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodTagCatalog(),
queryFn: async () => {
return apiGet<CatalogResponse>("/api/mood/tags");
@@ -124,7 +124,8 @@ export function MoodTagPicker({
if (isError && !data) {
return (
<QueryErrorRow
<ReadFailureRow
error={error}
slot="mood-tag-picker-error"
message={t("mood.tagCatalogLoadError")}
onRetry={() => void refetch()}
@@ -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(
<I18nProvider initialLocale="en">
<ReadFailureRow
error={error}
message="Could not load the strip."
onRetry={() => {}}
slot="probe"
/>
</I18nProvider>,
);
}
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("<ReadFailureRow>", () => {
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"',
);
});
});
+68
View File
@@ -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 (
<QueryErrorRow
slot={slot}
message={t("common.readThrottled")}
className={className}
/>
);
}
return (
<QueryErrorRow
slot={slot}
message={message}
onRetry={onRetry}
className={className}
/>
);
}
+1 -1
View File
@@ -804,7 +804,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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,
},
@@ -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);
});
@@ -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"]);
});
});