mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/v1436-ui' into release/v1.43.6
This commit is contained in:
commit
3ff860591e
25 files changed
+346
-73
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -54,7 +54,8 @@
|
||||
"durationClockHours": "{time}시간",
|
||||
"errorIdLabel": "오류 ID",
|
||||
"errorIdCopy": "오류 ID 복사",
|
||||
"errorIdCopied": "오류 ID 복사됨"
|
||||
"errorIdCopied": "오류 ID 복사됨",
|
||||
"readThrottled": "지금은 요청이 너무 많아요. 나중에 다시 시도해 주세요."
|
||||
},
|
||||
"apiErrors": {
|
||||
"measurement": {
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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,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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user