Merge branch 'fix/v1437-review' into release/v1.43.7

# Conflicts:
#	src/app/api/analytics/route.ts
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 06:20:42 +02:00
commit 45c027aee2
217 files changed
+3636 -2127

No files matched your search

@@ -75,7 +75,9 @@ describe("<PageHeader> — one canonical H1", () => {
describe("<QueryErrorCard> — retry affordance", () => {
it("renders a retry button when onRetry is provided", () => {
const html = render(<QueryErrorCard onRetry={() => {}} />);
const html = render(
<QueryErrorCard error={new Error("boom")} onRetry={() => {}} />,
);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
// The retry affordance is a real <button> so a failed read is
@@ -85,7 +87,7 @@ describe("<QueryErrorCard> — retry affordance", () => {
});
it("omits the retry button when no onRetry is supplied", () => {
const html = render(<QueryErrorCard />);
const html = render(<QueryErrorCard error={new Error("boom")} />);
expect(html).toContain('data-slot="query-error-card"');
expect(html).not.toContain("<button");
});
@@ -26,7 +26,10 @@ import en from "../../../messages/en.json";
* rather than by driving a real fetch.
*/
vi.mock("@/lib/api/api-fetch", () => ({
vi.mock("@/lib/api/api-fetch", async (importOriginal) => ({
// `ApiError` stays real: the error primitives read its status.
ApiError: (await importOriginal<typeof import("@/lib/api/api-fetch")>())
.ApiError,
apiGet: () => new Promise(() => {}),
apiPost: () => new Promise(() => {}),
}));
@@ -25,7 +25,9 @@ function markup(node: ReactNode): string {
);
}
function boundaryWith(fallback: (retry: () => void) => ReactNode) {
function boundaryWith(
fallback: (retry: () => void, error: unknown) => ReactNode,
) {
const boundary = new Boundary({
renderFallback: fallback,
children: <p>card body</p>,
@@ -53,18 +55,27 @@ describe("AdminCardBoundary", () => {
});
it("turns a thrown render into the fallback, not a page failure", () => {
expect(Boundary.getDerivedStateFromError()).toEqual({ failed: true });
const thrown = new Error("render failed");
expect(Boundary.getDerivedStateFromError(thrown)).toEqual({
failed: true,
error: thrown,
});
const fallback = vi.fn(() => <p data-slot="fallback">fallback</p>);
const fallback = vi.fn<(retry: () => void, error: unknown) => ReactNode>(
() => <p data-slot="fallback">fallback</p>,
);
const boundary = boundaryWith(fallback);
boundary.setState(Boundary.getDerivedStateFromError());
boundary.setState(Boundary.getDerivedStateFromError(thrown));
expect(markup(boundary.render())).toContain('data-slot="fallback"');
expect(fallback).toHaveBeenCalledWith(boundary.retry);
// The fallback is handed what was thrown, so its error card reads it.
expect(fallback).toHaveBeenCalledWith(boundary.retry, thrown);
});
it("paints the translated error card with a keyboard-reachable retry", () => {
const html = markup(<AdminCardError onRetry={() => {}} />);
const html = markup(
<AdminCardError error={new Error("render failed")} onRetry={() => {}} />,
);
expect(html).toContain('data-slot="query-error-card"');
expect(html).toContain('role="alert"');
+27 -7
View File
@@ -18,17 +18,28 @@ import { useTranslations } from "@/lib/i18n/context";
*/
export function AdminCardBoundary({ children }: { children: ReactNode }) {
return (
<Boundary renderFallback={(retry) => <AdminCardError onRetry={retry} />}>
<Boundary
renderFallback={(retry, error) => (
<AdminCardError error={error} onRetry={retry} />
)}
>
{children}
</Boundary>
);
}
/** What a failed card paints in its place. */
export function AdminCardError({ onRetry }: { onRetry: () => void }) {
export function AdminCardError({
error,
onRetry,
}: {
error: unknown;
onRetry: () => void;
}) {
const { t } = useTranslations();
return (
<QueryErrorCard
error={error}
title={t("admin.cardError.title")}
description={t("admin.cardError.description")}
onRetry={onRetry}
@@ -37,12 +48,13 @@ export function AdminCardError({ onRetry }: { onRetry: () => void }) {
}
export interface BoundaryProps {
renderFallback: (retry: () => void) => ReactNode;
renderFallback: (retry: () => void, error: unknown) => ReactNode;
children: ReactNode;
}
interface BoundaryState {
failed: boolean;
error?: unknown;
attempt: number;
}
@@ -50,8 +62,10 @@ interface BoundaryState {
export class Boundary extends Component<BoundaryProps, BoundaryState> {
state: BoundaryState = { failed: false, attempt: 0 };
static getDerivedStateFromError(): Pick<BoundaryState, "failed"> {
return { failed: true };
static getDerivedStateFromError(
error: unknown,
): Pick<BoundaryState, "failed" | "error"> {
return { failed: true, error };
}
componentDidCatch(error: Error): void {
@@ -64,11 +78,17 @@ export class Boundary extends Component<BoundaryProps, BoundaryState> {
}
retry = (): void => {
this.setState((s) => ({ failed: false, attempt: s.attempt + 1 }));
this.setState((s) => ({
failed: false,
error: undefined,
attempt: s.attempt + 1,
}));
};
render(): ReactNode {
if (this.state.failed) return this.props.renderFallback(this.retry);
if (this.state.failed) {
return this.props.renderFallback(this.retry, this.state.error);
}
// A new key on retry remounts the card, so it starts from a clean state
// rather than re-rendering the tree that threw.
return <Fragment key={this.state.attempt}>{this.props.children}</Fragment>;
@@ -104,6 +104,7 @@ export function AiQualitySection() {
</div>
) : query.isError ? (
<QueryErrorRow
error={query.error}
message={t("admin.aiQuality.loadError")}
onRetry={() => void query.refetch()}
/>
+2 -1
View File
@@ -847,7 +847,7 @@ export function BackupsSection() {
const fmt = useFormatters();
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminBackups(),
queryFn: async () => {
return apiGet<BackupsList>("/api/admin/backups");
@@ -1243,6 +1243,7 @@ export function BackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
/>
@@ -99,6 +99,7 @@ export function CoachFeedbackSection() {
</div>
) : query.isError ? (
<QueryErrorRow
error={query.error}
message={t("admin.coachFeedback.loadError")}
onRetry={() => void query.refetch()}
/>
+2 -1
View File
@@ -144,7 +144,7 @@ export function HostMetricsChart() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminHostMetrics("2h"),
queryFn: async () => {
return apiGet<HostMetricsApiResponse>("/api/admin/host-metrics?since=2h");
@@ -198,6 +198,7 @@ export function HostMetricsChart() {
}
/>
<QueryErrorRow
error={error}
message={t("admin.hostMetrics.loadError")}
onRetry={() => void refetch()}
/>
+1
View File
@@ -155,6 +155,7 @@ export function KeyBackupCard() {
/>
{status.isError ? (
<QueryErrorRow
error={status.error}
message={t("admin.keyBackup.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -111,6 +111,7 @@ export function MeasurementMaintenanceCard() {
if (status.isError) {
return (
<QueryErrorCard
error={status.error}
title={t("admin.section.maintenance.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -141,7 +141,7 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
export function OffhostBackupsSection() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminBackups(),
queryFn: async () => apiGet<BackupsList>("/api/admin/backups"),
});
@@ -170,6 +170,7 @@ export function OffhostBackupsSection() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
/>
@@ -125,6 +125,7 @@ export function PulseDayMeanCard() {
if (status.isError) {
return (
<QueryErrorCard
error={status.error}
title={t("admin.section.pulseDayMean.loadError")}
onRetry={() => void status.refetch()}
/>
@@ -38,7 +38,7 @@ interface AuditLogResponse {
export function RecentAuditPreview() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminAuditOverview(),
queryFn: async () => {
return apiGet<AuditLogResponse>(
@@ -84,6 +84,7 @@ export function RecentAuditPreview() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.overview.auditLoadError")}
onRetry={() => void refetch()}
/>
@@ -128,7 +128,7 @@ export function FailingJobsCard({
export function SystemStatusSection() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data: status, isError, refetch } = useSystemStatus();
const { data: status, isError, error, refetch } = useSystemStatus();
return (
<div className="space-y-6">
@@ -277,6 +277,7 @@ export function SystemStatusSection() {
// transient 500 (rolling deploy, DB blip) doesn't require a
// full page reload to recover.
<QueryErrorRow
error={error}
message={t("admin.systemStatusLoadError")}
onRetry={() => void refetch()}
retryLabel={t("admin.systemStatusRetry")}
@@ -31,7 +31,7 @@ import { useKeyBackupStatus } from "./use-key-backup-status";
export function SystemStatusSummary() {
const { t } = useTranslations();
const { data: status, isError, refetch } = useSystemStatus();
const { data: status, isError, error, refetch } = useSystemStatus();
const { data: keyBackup } = useKeyBackupStatus();
return (
@@ -110,6 +110,7 @@ export function SystemStatusSummary() {
</div>
) : isError ? (
<QueryErrorRow
error={error}
message={t("admin.overview.snapshotLoadError")}
onRetry={() => void refetch()}
/>
@@ -93,6 +93,7 @@ export function BodySitesSection({
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("bodySites.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -87,6 +87,7 @@ export function CustomMetricDetail({
const {
data: metric,
isError: metricError,
error: metricFailure,
refetch: refetchMetric,
} = useQuery({
queryKey: queryKeys.customMetricDetail(customMetricId),
@@ -98,6 +99,7 @@ export function CustomMetricDetail({
data: list,
isLoading,
isError: listError,
error: listFailure,
refetch: refetchList,
} = useInfiniteQuery({
queryKey: queryKeys.customMetricEntries({
@@ -179,6 +181,7 @@ export function CustomMetricDetail({
description={description}
/>
<QueryErrorCard
error={metricError ? metricFailure : listFailure}
description={t("customMetrics.loadError")}
onRetry={() => {
if (metricError) void refetchMetric();
@@ -45,7 +45,7 @@ export function CustomMetricList() {
const [addOpen, setAddOpen] = useState(false);
const [addFooterEl, setAddFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.customMetrics(),
queryFn: () => apiGet<CustomMetricListResponse>("/api/custom-metrics"),
});
@@ -93,6 +93,7 @@ export function CustomMetricList() {
</Card>
) : isError ? (
<QueryErrorCard
error={error}
description={t("customMetrics.loadError")}
onRetry={() => refetch()}
/>
@@ -42,6 +42,7 @@ export function CustomMetricValuesList({
data: list,
isLoading,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
@@ -76,6 +77,7 @@ export function CustomMetricValuesList({
if (isError) {
return (
<QueryErrorCard
error={error}
description={t("customMetrics.loadError")}
onRetry={() => void refetch()}
/>
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
import { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
@@ -71,7 +71,7 @@ export function CycleRingTile({ className }: { className?: string }) {
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) {
return (
<ReadFailureRow
<QueryErrorRow
error={calendar.error}
slot="wellness-cycle-tile-error"
message={t("cycle.wellnessRing.loadError")}
+4
View File
@@ -362,6 +362,7 @@ export function CycleView() {
// Inside the calendar card already — a borderless row keeps
// it in line with the plain loading line above it (§3/§6).
<QueryErrorRow
error={calendar.error}
message={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
className="border-0 bg-transparent p-0"
@@ -405,6 +406,7 @@ export function CycleView() {
<TabsContent value="predictions" className="mt-4 space-y-4">
{calendarError ? (
<QueryErrorCard
error={calendar.error}
title={t("cycle.loadError")}
onRetry={() => void calendar.refetch()}
/>
@@ -435,6 +437,7 @@ export function CycleView() {
<TabsContent value="insights" className="mt-4 space-y-4">
{insights.isError ? (
<QueryErrorCard
error={insights.error}
title={t("cycle.loadError")}
onRetry={() => void insights.refetch()}
/>
@@ -454,6 +457,7 @@ export function CycleView() {
<TabsContent value="settings" className="mt-4">
{profileQuery.isError ? (
<QueryErrorCard
error={profileQuery.error}
title={t("cycle.loadError")}
onRetry={() => void profileQuery.refetch()}
/>
+2 -2
View File
@@ -24,7 +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 { readFailureKind } from "@/lib/api/read-failure";
import {
cycleDependentKeys,
@@ -158,7 +158,7 @@ export function useCycleRingDial(enabled: boolean): {
enabled &&
calendar.isError &&
!calendar.data &&
readFailureKind(calendar.error) !== "denied",
readFailureKind(calendar.error) !== "refused",
};
}
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
/**
@@ -17,6 +18,13 @@ vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ isAuthenticated: true }),
}));
// The day label follows the reader's date order through `useFormatters()`.
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
useFormatters: () => READER_FORMATTERS,
}));
let mockResult: {
data:
| {
@@ -147,5 +155,8 @@ describe("<RecentWorkoutsTile>", () => {
expect(html).toContain("Cycling");
expect(html).toContain("30m");
expect(html).toContain("1h 0m");
expect(html).toContain(
READER_FORMATTERS.dateWithWeekdaySmart("2026-05-15T07:00:00Z"),
);
});
});
@@ -4,12 +4,11 @@ import Link from "next/link";
import { Activity } from "lucide-react";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import { useDisplayTimezone, useTranslations } from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { Skeleton } from "@/components/ui/skeleton";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { iconForSport } from "@/lib/workouts/sport-icons";
@@ -32,16 +31,6 @@ import { iconForSport } from "@/lib/workouts/sport-icons";
* round-trip per session.
*/
function formatDayLabel(iso: string, locale: string, timeZone: string): string {
const d = new Date(iso);
return getDateTimeFormat(locale, {
timeZone,
weekday: "short",
day: "2-digit",
month: "short",
}).format(d);
}
function renderRow(workout: WorkoutListEntry, sportName: string) {
const Icon = iconForSport(workout.sportType);
return (
@@ -61,9 +50,9 @@ function renderRow(workout: WorkoutListEntry, sportName: string) {
}
export function RecentWorkoutsTile() {
const { t, locale } = useTranslations();
const timeZone = useDisplayTimezone();
const { data, isError, refetch } = useWorkouts({ limit: 3 });
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isError, error, refetch } = useWorkouts({ limit: 3 });
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
// optimistic mount fetch on the client's very first render and cannot on the
@@ -116,6 +105,7 @@ export function RecentWorkoutsTile() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
@@ -147,7 +137,7 @@ export function RecentWorkoutsTile() {
>
{renderRow(workout, sportName)}
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDayLabel(workout.startedAt, locale, timeZone)}
{fmt.dateWithWeekdaySmart(workout.startedAt)}
</span>
<span className="ml-1 shrink-0 text-xs font-medium tabular-nums">
{formatDurationMinutes(
+1
View File
@@ -274,6 +274,7 @@ export function DayView({
{day.isError ? (
<QueryErrorRow
error={day.error}
message={t("day.loadFailed")}
onRetry={() => void day.refetch()}
slot="day-error"
+20 -16
View File
@@ -17,8 +17,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ReadFailureRow } from "@/components/ui/read-failure-row";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { apiGet } from "@/lib/api/api-fetch";
import type {
@@ -35,7 +36,7 @@ import {
import { getUnitForType } from "@/lib/measurements/unit-map";
import { queryKeys } from "@/lib/query-keys";
import { DAY_SECTION_LABEL, dayTitle, useNotableText } from "./day-sections";
import { dayTitle, useNotableText } from "./day-sections";
import { DayLink } from "./day-link";
import { dateKeyOfInstant } from "./day-url";
import { tileKeyOf } from "./day-values-model";
@@ -195,7 +196,7 @@ export function SinceLastVisit({
if (window.isError && rows.length === 0) {
return (
<ReadFailureRow
<QueryErrorRow
error={window.error}
slot="since-last-visit-error"
message={t("day.sinceLastVisit.loadError")}
@@ -212,24 +213,27 @@ export function SinceLastVisit({
const nextDay = dateKeyOfInstant(next.occurredAt, timeZone);
return (
<Card className="gap-3 py-3 md:gap-3 md:py-4">
<Card className="gap-2 py-3 md:py-4">
<CardHeader>
<TileHeader
size="sm"
icon={Stethoscope}
id={`since-last-visit-${next.id}`}
titleAs="h3"
title={t("day.sinceLastVisit.title", { date: lastLabel })}
right={
<span className="text-muted-foreground text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
}
/>
</CardHeader>
<CardContent>
<section
data-slot="since-last-visit"
aria-labelledby={`since-last-visit-${next.id}`}
className="space-y-2"
>
<div className="flex items-baseline justify-between gap-3">
<h3
id={`since-last-visit-${next.id}`}
className={DAY_SECTION_LABEL}
>
{t("day.sinceLastVisit.title", { date: lastLabel })}
</h3>
<span className="text-muted-foreground shrink-0 text-xs">
{tCount("day.sinceLastVisit.count", rows.length)}
</span>
</div>
<ul className="border-border divide-border divide-y rounded-lg border px-3.5">
{visible.map((row) => (
<li
@@ -135,6 +135,7 @@ export function DocumentLinkPicker({
</div>
) : list.isError ? (
<QueryErrorRow
error={list.error}
message={t("documents.list.loadError")}
onRetry={() => list.refetch()}
/>
@@ -287,7 +287,7 @@ export function DocumentRecordLinks({
title={t("documents.detail.vaccinationsLabel")}
slot="document-vaccination-links"
pending={vaccinations.isPending || !seedFresh}
error={vaccinations.isError || seedError}
error={vaccinations.error || seedError}
errorLabel={t("links.picker.loadError")}
onRetry={() => {
if (vaccinations.isError) void vaccinations.refetch();
@@ -1076,6 +1076,7 @@ export function DocumentsView() {
)
) : list.isError ? (
<QueryErrorCard
error={list.error}
title={t("documents.list.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -77,6 +77,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
if (list.isError) {
return (
<QueryErrorCard
error={list.error}
title={t("documents.episodeCard.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -552,6 +552,7 @@ export function DocumentSourcePicker({
</div>
) : search.isError ? (
<QueryErrorRow
error={search.error}
message={sourceErrorMessage(t, errorCodeOf(search.error))}
onRetry={() => void search.refetch()}
slot="document-source-error"
@@ -297,5 +297,20 @@ describe("<VisitsSection> since the last visit", () => {
expect(html).toContain('data-slot="since-last-visit"');
expect(html).toContain('data-slot="since-last-visit-row"');
expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1);
// The card heads like every compact card: TileHeader at the small size,
// a real heading under the section's own, at the compact density.
const card = html.slice(
html.lastIndexOf(
'<div data-slot="card"',
html.indexOf("since-last-visit-"),
),
);
expect(card).toMatch(
/^<div data-slot="card" class="[^"]*gap-2 py-3 md:py-4/,
);
expect(card).toContain('data-slot="tile-header"');
expect(card).toMatch(
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
);
});
});
@@ -96,6 +96,7 @@ export function ProceduresSection({ enabled = true }: { enabled?: boolean }) {
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.procedures.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -110,6 +110,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -52,6 +52,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
if (list.isError) {
return (
<QueryErrorCard
error={list.error}
title={t("encounters.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -58,6 +58,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
data: episode,
isLoading,
isError,
error,
refetch,
} = useIllnessEpisode(episodeId);
const resolve = useResolveEpisode();
@@ -69,6 +70,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
if (isError) {
return (
<QueryErrorCard
error={error}
title={t("illness.loadError")}
onRetry={() => void refetch()}
/>
+2
View File
@@ -370,6 +370,7 @@ export function IllnessView() {
data: episodes,
isLoading,
isError,
error,
refetch,
} = useIllnessEpisodes(true);
const resolve = useResolveEpisode();
@@ -460,6 +461,7 @@ export function IllnessView() {
// A read failure is NOT an empty journal — surface the error + Retry so
// an outage never reads as "no episodes yet".
<QueryErrorCard
error={error}
title={t("illness.listLoadError")}
onRetry={() => void refetch()}
/>
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
/**
@@ -46,12 +45,6 @@ function render(node: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
// The recovery page's empty note waits for the connection status; an
// account with no integrations is the settled answer here.
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
@@ -204,21 +197,20 @@ describe("<SleepQualitySection> data-gating", () => {
describe("<RecoverySection> data-gating", () => {
it("shows the calm empty note when there is no recovery data at all", () => {
analyticsMock.mockReturnValue(
analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }),
);
// The server resolves the empty reason beside the summaries.
const empty = analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) });
analyticsMock.mockReturnValue({
...empty,
data: { ...empty.data, recoveryEmpty: "general" },
});
const html = render(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
// Routed through the shared <EmptyState> (dashed bordered card), not the
// old hand-rolled div — the empty title comes from the unified primitive.
expect(html).toContain("border-dashed");
expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"');
// An Oura account sees this note too: it must say where Oura's readiness
// lives rather than tell a connected user to connect Oura.
expect(html).not.toContain("Connect a WHOOP, Polar or Oura account");
expect(html).toContain(
"Oura&#x27;s readiness appears as your recovery score",
);
expect(html).toContain("WHOOP or Polar");
});
it("renders a canonical chart block for the present signal", () => {
@@ -3,16 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* The recovery page's empty note says what is missing for THIS account. An
* Oura ring fills the page through its resilience level alone, so a connected
* ring with an empty page asks for a reconnect only when the server recorded
* that the grant leaves resilience out, and otherwise says only that no
* level has arrived. The connection facts come from the server's
* integrations read; the page only picks the sentence, and waits for it.
* The recovery page's empty note says what is missing for THIS account, and
* the reason is the server's (`recoveryEmpty` on the analytics slice): the
* page only picks the sentence for it and never reads the connection status
* itself. Every reason offers the same way on, the integrations settings.
*/
vi.mock("next/dynamic", () => ({
@@ -50,26 +47,24 @@ function summary(count: number): DataSummary {
};
}
/** An Oura-only account: readiness, HRV and sleep, no resilience level. */
/** An Oura-only account: readiness, HRV and sleep, no recovery type. */
const OURA_SUMMARIES = {
SLEEP_DURATION: summary(30),
RECOVERY_SCORE: summary(30),
HRV_RMSSD: summary(30),
};
function render(
oura: Record<string, unknown> | null,
opts: { statusLoaded?: boolean } = {},
) {
function render(recoveryEmpty: string | null) {
analyticsMock.mockReturnValue({
data: { summaries: OURA_SUMMARIES, recoveryEmpty },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
defaultOptions: { queries: { retry: false, enabled: false } },
});
if (opts.statusLoaded !== false) {
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: oura ? [{ integration: "oura", ...oura }] : [],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -79,67 +74,24 @@ function render(
);
}
function health(unavailable: Array<{ item: string; cause: string }>) {
return {
state: unavailable.length > 0 ? "limited" : "ok",
reason: unavailable.length > 0 ? "permission_limited" : "data_arriving",
unavailable,
};
}
beforeEach(() => {
analyticsMock.mockReset();
analyticsMock.mockReturnValue({
data: { summaries: OURA_SUMMARIES },
isLoading: false,
isEmpty: false,
error: null,
refetch: () => {},
});
});
describe("<RecoverySection> empty note", () => {
it("asks for the heart-health permission when the grant withholds resilience", () => {
const html = render({
connected: true,
health: health([{ item: "resilience", cause: "permission" }]),
});
expect(html).toContain('data-empty-case="oura-permission"');
expect(html).toContain("allow heart-health access");
it.each([
["oura_permission", "heart-health access"],
["oura_no_resilience", "as soon as Oura provides one"],
["general", "WHOOP or Polar"],
])("renders the server's %s reason with the one action", (reason, text) => {
const html = render(reason);
expect(html).toContain(`data-empty-case="${reason}"`);
expect(html).toContain(text);
expect(html).toContain('href="/settings/integrations"');
});
it("says only that no level has arrived when no withheld permission is recorded", () => {
const html = render({
connected: true,
health: health([{ item: "spo2", cause: "permission" }]),
});
expect(html).toContain('data-empty-case="oura-no-resilience"');
expect(html).toContain("no resilience level has arrived");
// The server cannot prove the grant covers resilience, so the note
// never says it does.
expect(html).not.toContain("permission covers");
expect(html).not.toContain('href="/settings/integrations"');
});
it("stays neutral when the connection carries no health block", () => {
const html = render({ connected: true });
expect(html).toContain('data-empty-case="oura-no-resilience"');
expect(html).not.toContain("permission covers");
});
it("shows no note until the connection status has loaded", () => {
const html = render(
{ connected: true, health: health([]) },
{ statusLoaded: false },
);
expect(html).toContain('data-slot="recovery-loading"');
it("shows no note when the server says the page has data", () => {
const html = render(null);
expect(html).not.toContain("data-empty-case");
});
it("keeps the general note for an account without Oura", () => {
const html = render(null);
expect(html).toContain('data-empty-case="general"');
expect(html).toContain("WHOOP and Polar record");
});
});
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import { WorkoutList } from "../workout-list";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
@@ -18,6 +19,14 @@ vi.mock("@/hooks/use-auth", () => ({
useAccountOnceMounted: () => account.user,
}));
// The reader's date and clock preferences reach the list through
// `useFormatters()`; this reader orders dates year first, on a 24-hour clock.
const READER_FORMATTERS = makeFormatters("en", "UTC", "H24", "YMD");
vi.mock("@/lib/i18n/context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/i18n/context")>()),
useFormatters: () => READER_FORMATTERS,
}));
/**
* v1.4.32 — `<WorkoutList>` unit tests.
*
@@ -54,6 +63,13 @@ describe("<WorkoutList>", () => {
expect(html).toContain("30m");
});
it("writes the start in the reader's own date order and clock", () => {
const html = render(<WorkoutList workouts={[ROW]} />);
expect(html).toContain(
`${READER_FORMATTERS.dateWithWeekdaySmart(ROW.startedAt)}, ${READER_FORMATTERS.time(ROW.startedAt)}`,
);
});
it("renders the distance and energy chips when present", () => {
const html = render(<WorkoutList workouts={[ROW]} />);
expect(html).toContain("5.20 km");
@@ -205,7 +205,7 @@ export function CoachResults({
const expected = hasLive
? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
: countResultsInSection(metas, section);
const { ref, results, isError, refetch } = useCoachMessageResults({
const { ref, results, isError, error, refetch } = useCoachMessageResults({
conversationId,
messageId,
enabled: !hasLive && expected > 0,
@@ -222,6 +222,7 @@ export function CoachResults({
>
{!hasLive && isError ? (
<QueryErrorRow
error={error}
slot="coach-results-error"
retrySlot="coach-results-retry"
message={t(COACH_RESULT_UI_KEYS.loadFailed)}
@@ -12,7 +12,7 @@ import { useCoachConversation } from "./use-coach";
*/
export function ConversationTranscript({ id }: { id: string }) {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useCoachConversation(id);
const { data, isLoading, isError, error, refetch } = useCoachConversation(id);
if (isLoading) {
return (
<p className="text-muted-foreground px-3 pb-3 text-xs">
@@ -23,6 +23,7 @@ export function ConversationTranscript({ id }: { id: string }) {
if (isError || !data) {
return (
<QueryErrorRow
error={error}
className="mx-3 mb-3"
onRetry={() => void refetch()}
slot="coach-conversations-transcript-error"
@@ -270,6 +270,7 @@ export function useCoachConversationHistory(
conversations,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
hasNextPage: query.hasNextPage,
isFetchingNextPage: query.isFetchingNextPage,
fetchNextPage: query.fetchNextPage,
@@ -173,10 +173,8 @@ export function CoincidentDeviationCard({
className,
}: CoincidentDeviationCardProps) {
const { t } = useTranslations();
const { data, isError, refetch } = useDerivedMetric<CoincidentDeviationValue>(
COINCIDENT_METRIC,
{ enabled },
);
const { data, isError, error, refetch } =
useDerivedMetric<CoincidentDeviationValue>(COINCIDENT_METRIC, { enabled });
// A failed read says so with a retry. Without this branch the card sat on
// its skeleton forever, which read as "still loading" rather than failed.
@@ -192,6 +190,7 @@ export function CoincidentDeviationCard({
title={t("insights.derived.coincident.cardTitle")}
/>
<QueryErrorRow
error={error}
slot="coincident-deviation-error"
onRetry={() => void refetch()}
/>
@@ -3,6 +3,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { ApiError } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
/**
* The strip sits above the chart on every metric sub-page. While its read is
@@ -37,4 +39,57 @@ describe("CoachReadStrip — placeholder while the read loads", () => {
expect(markup).toContain('data-state="loading"');
expect(markup).toContain('aria-busy="true"');
});
function renderFailed(error: Error) {
// `retryOnMount: false` keeps the seeded failure from being reset to a
// fresh load on render.
const client = new QueryClient({
defaultOptions: {
queries: { retry: false, enabled: false, retryOnMount: false },
},
});
client
.getQueryCache()
.build(client, { queryKey: queryKeys.insightsCoachRead("WEIGHT", "en") })
.setState({ status: "error", error, fetchStatus: "idle" });
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<CoachReadStrip metricType="WEIGHT" unit="kg" />
</I18nProvider>
</QueryClientProvider>,
);
}
/** The class list of the strip body, the element after the header. */
function bodyClass(markup: string): string | undefined {
return /data-slot="card-content" class="([^"]*)"/.exec(markup)?.[1];
}
it("holds the same frame and body height when the read fails", () => {
const loading = renderToStaticMarkup(
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false, enabled: false } },
})
}
>
<I18nProvider initialLocale="en">
<CoachReadStrip metricType="WEIGHT" unit="kg" />
</I18nProvider>
</QueryClientProvider>,
);
const failed = renderFailed(new ApiError("Boom", 500));
expect(failed).toContain('data-slot="coach-read-strip"');
expect(failed).toContain('data-state="error"');
expect(failed).toContain('data-slot="coach-read-strip-error"');
expect(bodyClass(failed)).toBeDefined();
expect(bodyClass(failed)).toBe(bodyClass(loading));
expect(bodyClass(failed)).toContain("min-h-8");
});
it("draws nothing for a refused read", () => {
expect(renderFailed(new ApiError("Forbidden", 403))).toBe("");
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* A rhythm sub-score withheld for too few nights names the floor the server
* published (`rhythmMinNights`), not a number the sheet carries itself.
*/
const anatomy = vi.fn();
vi.mock("../score-anatomy-view", () => ({
ScoreAnatomyView: (props: Record<string, unknown>) => {
anatomy(props);
return null;
},
}));
vi.mock("@/components/insights/insight-status-card", () => ({
InsightStatusCard: () => null,
}));
vi.mock("../score-history", () => ({
ScoreHistoryChart: () => null,
ScoreTrendChartDynamic: () => null,
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string, vars?: Record<string, string | number>) =>
vars ? `${key}:${JSON.stringify(vars)}` : key,
}),
}));
vi.mock("../use-derived-metric", () => ({
useDerivedMetric: () => ({
isLoading: false,
data: {
status: "ok",
value: {
score: 70,
band: "yellow",
subScores: [
{ key: "consistency", value: null, weight: 0.2, gap: "few_nights" },
],
rhythmNights: 2,
rhythmMinNights: 5,
},
coverage: 1,
confidence: "high",
provenance: {},
},
}),
}));
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
describe("sleep score sheet — the few-nights gap", () => {
it("names the published floor", () => {
renderToStaticMarkup(<CompositeScoreAnatomy metric="SLEEP_SCORE" />);
const props = anatomy.mock.calls.at(-1)![0] as {
contributors: Array<{ key: string; missingLabel?: string }>;
};
const consistency = props.contributors.find((c) => c.key === "consistency");
expect(consistency?.missingLabel).toContain('"count":2');
expect(consistency?.missingLabel).toContain('"needed":5');
});
});
@@ -10,8 +10,9 @@ import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { metricFractionDigits } from "@/lib/measurements/value-domain";
import { apiGet } from "@/lib/api/api-fetch";
import { readFailureKind } from "@/lib/api/read-failure";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import type { CoachReadStripData } from "@/lib/insights/derived/coach-read-shape";
@@ -60,6 +61,13 @@ export interface CoachReadStripProps {
valueScale?: number;
}
/**
* The strip's body holds the height of the error row's Retry button (32 px,
* 44 px on a touch screen) in every state, so the placeholder, a failure and
* a one-line read all stand the same height and the chart below never moves.
*/
const STRIP_BODY = "min-h-8 pointer-coarse:min-h-11";
export function CoachReadStrip({
metricType,
unit,
@@ -97,26 +105,19 @@ 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. 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.
// today", which is not what happened. The notice sits inside the same card
// shell as the placeholder, so a failure does not move the chart either.
// 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 (
<ReadFailureRow
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
/>
);
}
if (isError && readFailureKind(error) === "refused") return null;
return (
<Card
data-slot="coach-read-strip"
data-state="loading"
aria-busy="true"
data-state={isError ? "error" : "loading"}
aria-busy={isError ? undefined : "true"}
className="gap-2 py-3 md:py-4"
>
<CardHeader>
@@ -126,8 +127,18 @@ export function CoachReadStrip({
titleAs="h2"
/>
</CardHeader>
<CardContent>
<Skeleton className="h-5.5 w-3/4" />
<CardContent className={STRIP_BODY}>
{isError ? (
<QueryErrorRow
error={error}
slot="coach-read-strip-error"
message={t("insights.coach.readStrip.loadError")}
onRetry={() => void refetch()}
className="flex-row items-center justify-between rounded-none border-0 bg-transparent p-0"
/>
) : (
<Skeleton className="h-5.5 w-3/4" />
)}
</CardContent>
</Card>
);
@@ -236,12 +247,12 @@ export function CoachReadStrip({
<TileHeader
icon={Sparkles}
title={t("insights.coach.readStrip.label")}
// L3 — top-level section on every metric sub-page, sibling of the
// Top-level section on every metric sub-page, sibling of the
// already-`h2` stat strip directly under the page `h1`.
titleAs="h2"
/>
</CardHeader>
<CardContent>
<CardContent className={STRIP_BODY}>
<div className="min-w-0 space-y-1 text-sm leading-relaxed">
{baselineLine ? (
<p data-slot="coach-read-baseline" className="text-pretty">
@@ -186,6 +186,7 @@ export function CompositeScoreAnatomy({
return (
<div className={className}>
<QueryErrorRow
error={query.error}
slot="composite-anatomy-error"
onRetry={() => void query.refetch()}
/>
@@ -242,6 +243,7 @@ export function CompositeScoreAnatomy({
s.gap === "few_nights"
? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", {
count: v.rhythmNights ?? 0,
needed: v.rhythmMinNights,
})
: s.gap === "no_main_sleep"
? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep")
@@ -891,6 +891,7 @@ export function VitalsDashboard({ batch, layout, className }: DashboardProps) {
subtitle={t("insights.derived.vitals.sectionSubtitle")}
/>
<QueryErrorRow
error={batch.error}
slot="vitals-dashboard-error"
retrySlot="vitals-dashboard-retry"
message={t("insights.derived.vitals.loadError")}
+2 -1
View File
@@ -45,7 +45,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsEcgDetail(recordingId, false),
queryFn: async () => {
try {
@@ -63,6 +63,7 @@ export function EcgDetail({ recordingId }: { recordingId: string }) {
// alert is content, not muted meta — it also has to be recoverable.
return (
<QueryErrorCard
error={error}
title={t("insights.ecg.loadError")}
onRetry={() => refetch()}
/>
@@ -392,6 +392,7 @@ export function HealthKitMetricPage({
explainerParams={explainerParams}
>
<QueryErrorRow
error={error}
slot="healthkit-metric-error"
retrySlot="healthkit-metric-retry"
message={t("insights.subPage.loadError")}
@@ -120,7 +120,7 @@ export function IntradayPulseChart({
setSelectedDateKey(next === todayKey ? null : next);
};
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsPulseIntraday(dateKey),
queryFn: () =>
apiGet<IntradayPulseDto>(`/api/insights/pulse/intraday?date=${dateKey}`),
@@ -255,6 +255,7 @@ export function IntradayPulseChart({
<Skeleton className="h-[180px] w-full rounded-lg" />
) : isError ? (
<QueryErrorCard
error={error}
onRetry={() => refetch()}
className="border-0 bg-transparent shadow-none"
/>
@@ -132,7 +132,8 @@ export function MoodInsightsSections({
} = {}) {
const { t } = useTranslations();
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
const { data, isLoading, isPending, isError, error, refetch } =
useMoodInsights();
// `isPending`, not `isLoading`: before the session resolves the read is
// disabled rather than loading, and the frame must already be there.
@@ -161,7 +162,7 @@ export function MoodInsightsSections({
// "rest" region (mirroring the loading convention above), rather than
// stacking an identical row in all three regions down the page.
return region === "rest" ? (
<QueryErrorRow onRetry={() => refetch()} />
<QueryErrorRow error={error} onRetry={() => refetch()} />
) : null;
}
@@ -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 { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { useAuth } from "@/hooks/use-auth";
import { apiGet } from "@/lib/api/api-fetch";
import { formatDate } from "@/lib/date-format";
@@ -71,7 +71,7 @@ export function MoodPrognosisCard() {
if (isError && !data) {
return (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="mood-prognosis-error"
message={t("insights.mood.prognosis.loadError")}
@@ -45,7 +45,7 @@ export function MoodWhatStandsOut({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsCorrelations(),
queryFn: async () => {
// Correlations are statistics, never gated on AI; a rejection is a
@@ -81,6 +81,7 @@ export function MoodWhatStandsOut({
) : null}
{isError ? (
<QueryErrorRow
error={error}
slot="mood-discovery-error"
message={t("insights.pattern.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 { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-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";
@@ -43,7 +43,7 @@ export function CaffeineCard() {
if (isError && !data) {
return (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="nutrients-caffeine-error"
message={t("nutrients.caffeine.loadError")}
@@ -30,7 +30,7 @@ export function HydrationCard() {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.nutrientDaily("water", WINDOW_DAYS),
queryFn: () =>
apiGet<NutrientDailySeries>(
@@ -39,7 +39,7 @@ export function HydrationCard() {
});
if (isError) {
return <QueryErrorCard onRetry={refetch} />;
return <QueryErrorCard error={error} onRetry={refetch} />;
}
const todayTotal = data?.days.at(-1)?.amount ?? 0;
@@ -159,7 +159,7 @@ export function MicronutrientsCard() {
// its "no micronutrient data" EmptyState — the same output as an account that
// genuinely logs no nutrients. The sibling hydration card already routes
// through QueryErrorCard; this one now matches it.
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
queryFn: () =>
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
@@ -180,6 +180,7 @@ export function MicronutrientsCard() {
<CardContent className="space-y-3">
{isError ? (
<QueryErrorCard
error={error}
title={t("nutrients.micronutrients.loadError")}
onRetry={() => void refetch()}
/>
@@ -30,13 +30,14 @@ interface FenceProps {
interface FenceState {
failed: boolean;
error?: unknown;
}
class SectionFence extends Component<FenceProps, FenceState> {
state: FenceState = { failed: false };
static getDerivedStateFromError(): FenceState {
return { failed: true };
static getDerivedStateFromError(error: unknown): FenceState {
return { failed: true, error };
}
componentDidCatch(error: Error): void {
@@ -58,9 +59,10 @@ class SectionFence extends Component<FenceProps, FenceState> {
data-section={this.props.sectionId}
>
<QueryErrorRow
error={this.state.error}
message={this.props.message}
retryLabel={this.props.retryLabel}
onRetry={() => this.setState({ failed: false })}
onRetry={() => this.setState({ failed: false, error: undefined })}
/>
</section>
);
@@ -16,11 +16,10 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useTranslations } from "@/lib/i18n/context";
import { EmptyState } from "@/components/ui/empty-state";
import { Button } from "@/components/ui/button";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import type {
RECOVERY_SUMMARY_TYPES,
RecoveryEmptyReason,
} from "@/lib/insights/recovery-empty";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
@@ -30,7 +29,8 @@ import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-met
import { ResilienceTile } from "@/components/insights/recovery/resilience-tile";
interface BlockSpec {
type: string;
/** One of the types the server's empty decision counts, by construction. */
type: (typeof RECOVERY_SUMMARY_TYPES)[number];
chartKey: ChartOverlayKey;
statusMetric: MetricStatusMetricId;
title: string;
@@ -47,32 +47,14 @@ interface BlockGroup {
}
/**
* Why this account's recovery page is empty, from the server's view of its
* Oura connection. An Oura ring feeds this page through its resilience level
* alone. When the server records that the grant leaves resilience out, the
* note asks for one reconnect. Otherwise it says only that no level has
* arrived: the server cannot tell "the grant covers it" from "not recorded
* yet" (the scope note is cleared on every reconnect and written only by a
* sync that reached Oura), so the note never claims coverage. Everyone else
* gets the general note naming what fills the page.
* The empty note per server-resolved reason (`recoveryEmpty` on the
* analytics slice). The page never works the reason out itself.
*/
type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience";
const EMPTY_COPY = {
general: "insights.recovery.empty",
"oura-permission": "insights.recovery.emptyOuraPermission",
"oura-no-resilience": "insights.recovery.emptyOuraNoResilience",
} as const satisfies Record<RecoveryEmptyCase, string>;
function recoveryEmptyCase(
oura: IntegrationStatusViewModel | undefined,
): RecoveryEmptyCase {
if (!oura?.connected) return "general";
const withheld = oura.health?.unavailable.some(
(u) => u.item === "resilience" && u.cause === "permission",
);
return withheld ? "oura-permission" : "oura-no-resilience";
}
oura_permission: "insights.recovery.emptyOuraPermission",
oura_no_resilience: "insights.recovery.emptyOuraNoResilience",
} as const satisfies Record<RecoveryEmptyReason, string>;
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
@@ -204,28 +186,11 @@ export function RecoverySection() {
},
];
// v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced
// as a calm dedicated tile rather than a chart block, so it counts toward the
// data gate independently of `groups` (a chart-block list).
const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0;
// Whether the page has anything to show, and why not, is the server's
// answer (`recoveryEmpty`), so web and iOS read one decision.
const emptyReason = data?.recoveryEmpty ?? null;
const hasAny =
summaries != null &&
(hasResilience ||
groups.some((group) =>
group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0),
));
// Only an empty page asks which connection could fill it, so a page with
// data never pays for the integrations read.
const statuses = useIntegrationStatuses(
!isLoading && summaries != null && !hasAny,
);
const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura"));
// The empty note waits for the connection status, so it never reads as
// the general note first and then changes under the reader.
if (isLoading || summaries == null || (!hasAny && statuses.isPending)) {
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
<ChartSkeleton />
@@ -233,22 +198,20 @@ export function RecoverySection() {
);
}
if (!hasAny) {
if (emptyReason) {
return (
<EmptyState
data-slot="recovery-empty"
data-empty-case={emptyCase}
data-empty-case={emptyReason}
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyCase])}
description={t(EMPTY_COPY[emptyReason])}
action={
emptyCase === "oura-permission" ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
) : undefined
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
}
/>
);
@@ -91,7 +91,7 @@ export function RhythmEventsCard({
const { t } = useTranslations();
const fmt = useFormatters();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.insightsRhythmEvents(),
queryFn: async () => {
try {
@@ -119,6 +119,7 @@ export function RhythmEventsCard({
title={t("insights.rhythmEvents.sectionTitle")}
/>
<QueryErrorRow
error={error}
slot="rhythm-events-error"
message={t("insights.rhythmEvents.loadError")}
onRetry={() => void refetch()}
+9 -6
View File
@@ -58,9 +58,11 @@ interface TileHeaderProps {
* owning block (the recovery blocks) or a grid of sibling tiles (dashboard
* tiles, insights overview grids) both nest correctly as `h1 → h2, h2, …`;
* just don't nest a `titleAs="h2"` tile inside another heading's own
* subsection. Default (unset) keeps every other tile a plain CardTitle.
* subsection. `"h3"` is the same for a card that sits inside a section
* which already owns an `h2` (the since-last-visit card under the visits
* section). Default (unset) keeps every other tile a plain CardTitle.
*/
titleAs?: "h2";
titleAs?: "h2" | "h3";
}
export function TileHeader({
@@ -73,6 +75,7 @@ export function TileHeader({
id,
titleAs,
}: TileHeaderProps) {
const Heading = titleAs ?? "h2";
const titleClassNames = cn(
size === "sm" ? "text-sm" : "text-base",
titleClassName,
@@ -93,16 +96,16 @@ export function TileHeader({
aria-hidden="true"
/>
) : null}
{titleAs === "h2" ? (
{titleAs ? (
// Heading semantics with the exact CardTitle visual treatment so the
// tile can carry a real `<h2>` where it owns the subpage's heading.
<h2
// tile can carry a real heading where it owns one.
<Heading
id={id}
data-slot="card-title"
className={cn("leading-none font-semibold", titleClassNames)}
>
{title}
</h2>
</Heading>
) : (
<CardTitle id={id} className={titleClassNames}>
{title}
@@ -120,6 +120,7 @@ export function WorkoutDetailDayContext({
{sleep.isError ? (
// A failed read is not an absent night.
<QueryErrorRow
error={sleep.error}
onRetry={sleep.refetch}
slot="workout-detail-day-sleep-error"
/>
@@ -169,6 +170,7 @@ export function WorkoutDetailDayContext({
</p>
{mood.isError ? (
<QueryErrorRow
error={mood.error}
onRetry={mood.refetch}
slot="workout-detail-day-mood-error"
/>
@@ -1,14 +1,10 @@
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getNumberFormat } from "@/lib/intl/formatter-cache";
import {
applyDisplayTransform,
getQuantityTransform,
paceSecondsPerDistanceUnit,
type UnitPreference,
} from "@/lib/measurements/display-transform";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
/**
* Shared formatters for the workout-detail surface. Pure helpers pulled
@@ -120,28 +116,3 @@ export function formatPaceSeconds(secPerUnit: number, unit: string): string {
}
return `${m}:${s.toString().padStart(2, "0")} min/${unit}`;
}
export function formatDateRange(
startedAt: string,
endedAt: string,
locale: string,
timeFormat: TimeFormatPreference,
timeZone: string,
): string {
const start = new Date(startedAt);
const end = new Date(endedAt);
const dateFmt = getDateTimeFormat(locale, {
timeZone,
weekday: "long",
day: "2-digit",
month: "long",
year: "numeric",
});
const timeFmt = getDateTimeFormat(locale, {
timeZone,
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
});
return `${dateFmt.format(start)}, ${timeFmt.format(start)}–${timeFmt.format(end)}`;
}
@@ -1,16 +1,12 @@
"use client";
import {
useDisplayTimezone,
useTimeFormatPreference,
useTranslations,
} from "@/lib/i18n/context";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MEASUREMENT_SOURCE_LIST_LABEL_KEYS } from "@/lib/i18n/source-labels";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutDetailPayload } from "@/hooks/use-workouts";
import { useUnitDisplay } from "@/hooks/use-unit-display";
import { formatDuration, formatDistance, formatDateRange } from "./format";
import { formatDuration, formatDistance } from "./format";
/**
* Hero header — the at-a-glance verdict row. Sport icon badge, source
@@ -35,8 +31,7 @@ function renderSportIconBadge(sportType: string) {
export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const timeZone = useDisplayTimezone();
const fmt = useFormatters();
const { preference } = useUnitDisplay();
const sportLabelKey = `insights.workouts.sport.${workout.sportType}`;
const sportLabel = t(sportLabelKey);
@@ -68,13 +63,7 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
timeZone,
)}
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}–${fmt.time(workout.endedAt)}`}
</p>
</div>
</div>
+6 -42
View File
@@ -5,17 +5,9 @@ import Link from "next/link";
import { HeartPulse, Loader2, Map as MapIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { getNumberFormat, getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getNumberFormat } from "@/lib/intl/formatter-cache";
import { formatDurationMinutes } from "@/lib/i18n/duration";
import {
useDisplayTimezone,
useTimeFormatPreference,
useTranslations,
} from "@/lib/i18n/context";
import {
hourCycleOptions,
type TimeFormatPreference,
} from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { iconForSport } from "@/lib/workouts/sport-icons";
import type { WorkoutListEntry } from "@/hooks/use-workouts";
import { Button } from "@/components/ui/button";
@@ -40,30 +32,6 @@ function formatEnergy(kcal: number, locale: string): string {
}).format(Math.round(kcal));
}
function formatDate(
iso: string,
locale: string,
timeFormat: TimeFormatPreference,
timeZone: string,
): string {
const d = new Date(iso);
// Show the year whenever the workout is not from the current year, so a
// "Do., 16. Dez." from a previous year is never mistaken for this year's.
const yearOf = (value: Date) =>
getDateTimeFormat(locale, { timeZone, year: "numeric" }).format(value);
const showYear = yearOf(d) !== yearOf(new Date());
return getDateTimeFormat(locale, {
timeZone,
weekday: "short",
day: "2-digit",
month: "short",
...(showYear ? { year: "numeric" as const } : {}),
hour: "2-digit",
minute: "2-digit",
...hourCycleOptions(timeFormat),
}).format(d);
}
export interface WorkoutListProps {
workouts: WorkoutListEntry[];
className?: string;
@@ -82,8 +50,9 @@ export function WorkoutList({
onLoadMore,
}: WorkoutListProps) {
const { t, locale } = useTranslations();
const timeFormat = useTimeFormatPreference();
const timeZone = useDisplayTimezone();
// The reader's date order, hour cycle and zone, as everywhere else; the
// year shows whenever the workout is not from this year.
const fmt = useFormatters();
const { preference } = useUnitDisplay();
return (
@@ -150,12 +119,7 @@ export function WorkoutList({
stays readable beside the stats column. */}
<span className="text-muted-foreground text-xs">
<DayLinkAt at={workout.startedAt} className="z-10">
{formatDate(
workout.startedAt,
locale,
timeFormat,
timeZone,
)}
{`${fmt.dateWithWeekdaySmart(workout.startedAt)}, ${fmt.time(workout.startedAt)}`}
</DayLinkAt>
</span>
</div>
+2 -1
View File
@@ -57,7 +57,7 @@ export function BiomarkerManager() {
const canMerge = canManageDomain("labs");
const [mergeOpen, setMergeOpen] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.biomarkers(),
queryFn: () => apiGet<BiomarkerListResponse>("/api/biomarkers"),
});
@@ -195,6 +195,7 @@ export function BiomarkerManager() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
title={t("labs.biomarker.loadError")}
onRetry={() => void refetch()}
/>
@@ -191,6 +191,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
data: list,
isLoading,
isError: listError,
error: listFailure,
refetch: refetchList,
} = useInfiniteQuery({
queryKey: queryKeys.labResultsInfinite({ biomarkerId, sortDir: "desc" }),
@@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
description={description}
/>
<QueryErrorCard
error={markerError ? markerFailure : listFailure}
title={t("labs.loadError")}
onRetry={() => {
if (markerError) void refetchMarker();
+2 -1
View File
@@ -146,7 +146,7 @@ export function LabList({
// (beneath the heading); the overview rows no longer carry it.
// One entry per biomarker over the whole history, grouped server-side: a
// capped page of raw rows dropped every marker measured only long ago.
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.labResultsOverview(),
queryFn: () => apiGet<LabOverviewResponse>("/api/labs/overview"),
});
@@ -203,6 +203,7 @@ export function LabList({
// outage never reads as "you have no lab results".
return (
<QueryErrorCard
error={error}
title={t("labs.loadError")}
onRetry={() => void refetch()}
/>
+2
View File
@@ -34,6 +34,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
data: list,
isLoading,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
@@ -63,6 +64,7 @@ export function LabValuesList({ biomarkerId }: { biomarkerId: string }) {
if (isError) {
return (
<QueryErrorCard
error={error}
title={t("labs.loadError")}
onRetry={() => void refetch()}
/>
+6 -3
View File
@@ -243,7 +243,7 @@ export function EntityLinkPicker({
onChange,
searchPlaceholder,
emptyLabel,
error = false,
error = null,
errorLabel,
onRetry,
extraAction,
@@ -263,9 +263,11 @@ export function EntityLinkPicker({
/**
* The option read failed. Renders an error row with Retry instead of the
* empty label: a failed read must never pass for "nothing to link", which
* is exactly how a refused page size once hid a whole vault.
* is exactly how a refused page size once hid a whole vault. Pass the
* failed read's error (or any truthy value for a failure without one); the
* row reads its status.
*/
error?: boolean;
error?: unknown;
errorLabel?: string;
onRetry?: () => void;
/**
@@ -369,6 +371,7 @@ export function EntityLinkPicker({
<Skeleton className="h-9 w-full rounded-md" />
) : error ? (
<QueryErrorRow
error={error}
message={errorLabel}
onRetry={onRetry}
slot={`${slot}-error`}
+2 -2
View File
@@ -365,7 +365,7 @@ function MedicationPicker({ selected, onChange, known }: PickerProps) {
title={t("links.related.pick.medication")}
slot="related-link-medication"
pending={list.isPending}
error={list.isError}
error={list.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void list.refetch()}
selected={selected}
@@ -403,7 +403,7 @@ function DosePicker({ selected, onChange, anchor, known }: PickerProps) {
title={t("links.related.pick.vaccination")}
slot="related-link-vaccination"
pending={doses.isPending}
error={doses.isError}
error={doses.error}
errorLabel={t("links.picker.loadError")}
onRetry={() => void doses.refetch()}
selected={selected}
@@ -199,9 +199,11 @@ export function useLinkPickerPages<T extends { id: string }>({
return mergeWindowPages(newer.data?.pages ?? [], older.data?.pages ?? []);
}, [searching, search.data, newer.data, older.data]);
const activeError = searching
? search.isError
: older.isError || (newerRequested && newer.isError);
// The failed read's error itself, not a flag: the picker's error row reads
// the status to tell a refusal or a spent budget from a real failure.
const activeError: unknown = searching
? search.error
: (older.error ?? (newerRequested ? newer.error : null));
return {
items,
@@ -81,7 +81,12 @@ export function VorsorgeDashboardCard() {
// tell that this row had never been asked.
const { canManageDomain } = useRecordCapabilities();
const canManageMeasurements = canManageDomain("measurements");
const { data: reminders, isError, refetch } = useMeasurementReminders();
const {
data: reminders,
isError,
error,
refetch,
} = useMeasurementReminders();
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
// optimistic mount fetch on the client's very first render and cannot on the
@@ -161,7 +166,7 @@ export function VorsorgeDashboardCard() {
) : isError ? (
// A read failure must not read as "no upcoming reminders" — a missed
// checkup is the harm behind that fall-through (§6).
<QueryErrorRow onRetry={() => refetch()} />
<QueryErrorRow error={error} onRetry={() => refetch()} />
) : upcoming.length === 0 ? (
<div className="space-y-2">
<p className="text-muted-foreground text-sm">
@@ -243,6 +243,7 @@ export function VorsorgeSection({
data: reminders,
isLoading,
isError,
error,
refetch,
} = useMeasurementReminders(enabled);
const { create, update, remove, satisfy, skip, snooze } =
@@ -736,6 +737,7 @@ export function VorsorgeSection({
{!isLoading && isError && (
<QueryErrorRow
error={error}
message={t("measurementReminders.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 { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { ListRow } from "@/components/ui/list-row";
import {
@@ -606,7 +606,7 @@ export function MeasurementList({
? "groupBy=day"
: "raw";
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.measurementsList({
type: typeFilter === "ALL" ? undefined : typeFilter,
sourceEq,
@@ -1034,7 +1034,7 @@ export function MeasurementList({
) : isError ? (
// A read failure is NOT an empty list — surface the error + Retry so
// an outage never reads as "you have no measurements".
<QueryErrorCard onRetry={() => void refetch()} />
<QueryErrorCard error={error} onRetry={() => void refetch()} />
) : !data?.measurements?.length ? (
<>
{/* v1.4.15 phase-C5: replaces the bare-text empty rectangle.
@@ -1924,7 +1924,7 @@ function DayDrillDown({
}
if (error) {
return (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="measurement-drilldown-error"
message={t("measurements.loadError")}
@@ -96,7 +96,7 @@ export function EfficacyTab({
const timezone = user?.timezone || DEFAULT_TIMEZONE;
const queryClient = useQueryClient();
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.medicationEfficacy(medicationId),
queryFn: () =>
apiGet<MedicationEfficacyDTO>(
@@ -128,7 +128,7 @@ export function EfficacyTab({
title={t("medications.efficacy.title")}
dataSlot="medication-wirkung-section"
>
<QueryErrorCard onRetry={() => refetch()} />
<QueryErrorCard error={error} onRetry={() => refetch()} />
</MedicationDetailSection>
);
}
@@ -201,28 +201,29 @@ export function IntakeHistoryListV2({
const offset = page * pageSize;
const { data, isLoading, isError, refetch } = useQuery<IntakeListResponse>({
queryKey: queryKeys.medicationIntakeList(medicationId, {
sortBy,
sortDir,
limit: pageSize,
offset,
status: STATUS_FILTER,
}),
queryFn: async () => {
const params = new URLSearchParams({
const { data, isLoading, isError, error, refetch } =
useQuery<IntakeListResponse>({
queryKey: queryKeys.medicationIntakeList(medicationId, {
sortBy,
sortDir,
limit: String(pageSize),
offset: String(offset),
limit: pageSize,
offset,
status: STATUS_FILTER,
});
return apiGet<IntakeListResponse>(
`/api/medications/${medicationId}/intake?${params.toString()}`,
);
},
enabled: isAuthenticated,
});
}),
queryFn: async () => {
const params = new URLSearchParams({
sortBy,
sortDir,
limit: String(pageSize),
offset: String(offset),
status: STATUS_FILTER,
});
return apiGet<IntakeListResponse>(
`/api/medications/${medicationId}/intake?${params.toString()}`,
);
},
enabled: isAuthenticated,
});
const sourceLabels: Record<IntakeSourceValue, string> = useMemo(
() =>
@@ -274,6 +275,7 @@ export function IntakeHistoryListV2({
</div>
) : isError ? (
<QueryErrorCard
error={error}
title={t("medications.loadFailed")}
onRetry={() => void refetch()}
/>
+7 -14
View File
@@ -14,7 +14,6 @@ import { MedicationCardBody } from "@/components/medications/card-parts/medicati
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { formatTimeWindowRange } from "@/lib/time-window-format";
import { formatDateTime, formatTime } from "@/lib/format";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { getMedicationCategoryLabel } from "@/lib/medications/category-label";
import { formatDose } from "@/lib/medications/format-dose";
import { formatUnitsPerDose } from "@/lib/medications/units-per-dose";
@@ -38,7 +37,7 @@ import { LogInjectionSiteDialog } from "@/components/medications/log-injection-s
import { useGlobalExcludedInjectionSites } from "@/lib/medications/use-injection-site-prefs";
import { useAuth } from "@/hooks/use-auth";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
interface Schedule {
id: string;
@@ -381,22 +380,16 @@ export function MedicationCard({
: null;
function formatLastTakenAt(value: string): string {
// Intentionally en-CA: gives YYYY-MM-DD which is locale-independent and
// string-comparable for the today / yesterday / older bucketing below.
// The actual user-facing display goes through formatTime / formatDateTime.
const dayFormatter = getDateTimeFormat("en-CA", {
timeZone: userTz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
// Day keys (YYYY-MM-DD) in the profile zone, string-comparable for the
// today / yesterday / older bucketing below. The user-facing display goes
// through formatTime / formatDateTime.
const now = new Date();
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const intakeDay = dayFormatter.format(new Date(value));
const todayDay = dayFormatter.format(now);
const yesterdayDay = dayFormatter.format(yesterday);
const intakeDay = userDayKey(new Date(value), userTz);
const todayDay = userDayKey(now, userTz);
const yesterdayDay = userDayKey(yesterday, userTz);
const time = formatTime(value);
if (intakeDay === todayDay) return `${t("medications.today")}, ${time}`;
@@ -242,7 +242,10 @@ export function MedicationCategoriesSheet({
>
<div className="space-y-2" data-slot="medication-categories-sheet">
{categories.isError ? (
<QueryErrorRow onRetry={() => void categories.refetch()} />
<QueryErrorRow
error={categories.error}
onRetry={() => void categories.refetch()}
/>
) : (
(categories.data ?? []).map((category) => (
<CategoryRow
@@ -155,6 +155,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
data,
isLoading,
isError,
error,
refetch,
fetchNextPage,
hasNextPage,
@@ -330,7 +331,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)}
{!isLoading && isError && (
<QueryErrorCard onRetry={() => void refetch()} />
<QueryErrorCard error={error} onRetry={() => void refetch()} />
)}
{!isLoading && !isError && items.length === 0 && (
@@ -75,6 +75,7 @@ export function MentalWellbeing() {
data: history,
isPending: historyPending,
isError: historyError,
error: historyFailure,
refetch: refetchHistory,
} = useQuery({
queryKey: queryKeys.mentalHealthAssessments(),
@@ -166,6 +167,7 @@ export function MentalWellbeing() {
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
{historyError ? (
<QueryErrorCard
error={historyFailure}
title={t("mentalHealth.historyLoadError")}
description={t("mentalHealth.historyLoadErrorHint")}
onRetry={() => void refetchHistory()}
+2 -1
View File
@@ -340,7 +340,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
const toParam = toDay || undefined;
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.moodEntriesList({
mood: moodFilter === "ALL" ? undefined : moodFilter,
source: sourceParam,
@@ -714,6 +714,7 @@ export function MoodList({ onAddFirst, onEmptyChange }: MoodListProps = {}) {
// A read failure is NOT an empty list — surface the error + Retry so
// an outage never reads as "you have no mood entries".
<QueryErrorCard
error={error}
title={t("mood.loadError")}
onRetry={() => void refetch()}
/>
+2 -2
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -75,7 +75,7 @@ export function MoodQuickTags({
}
if (isError && !data) {
return (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="mood-quick-tags-error"
message={t("mood.tagCatalogLoadError")}
+2 -2
View File
@@ -4,7 +4,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Plus, Tag } from "lucide-react";
import { ReadFailureRow } from "@/components/ui/read-failure-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -124,7 +124,7 @@ export function MoodTagPicker({
if (isError && !data) {
return (
<ReadFailureRow
<QueryErrorRow
error={error}
slot="mood-tag-picker-error"
message={t("mood.tagCatalogLoadError")}
@@ -129,6 +129,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
{list.isError ? (
<QueryErrorCard
error={list.error}
title={t("practitioners.loadError")}
onRetry={() => void list.refetch()}
/>
@@ -184,6 +184,7 @@ export function AboutMeNoteManager() {
{query.isError && (
<QueryErrorRow
error={query.error}
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
@@ -110,6 +110,7 @@ export function AiProfileInclusionManager() {
if (query.isError) {
return (
<QueryErrorCard
error={query.error}
title={t("records.aiInclusion.loadError")}
onRetry={() => void query.refetch()}
/>
@@ -133,6 +133,7 @@ export function AllergyFreeTextNote({
{query.isError ? (
<>
<QueryErrorRow
error={query.error}
message={t("settings.ai.aboutMe.loadError")}
onRetry={() => query.refetch()}
/>
+2 -1
View File
@@ -43,7 +43,7 @@ export function AllergyManager({
const [editing, setEditing] = useState<AllergyDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.allergyList(true),
queryFn: () => apiGet<AllergyDTO[]>("/api/allergies"),
});
@@ -154,6 +154,7 @@ export function AllergyManager({
</div>
) : isError ? (
<QueryErrorCard
error={error}
title={t("records.allergies.loadError")}
onRetry={() => void refetch()}
/>
@@ -125,6 +125,7 @@ export function ConditionsManager() {
<div className="space-y-4" data-slot="conditions-manager">
{query.isError ? (
<QueryErrorCard
error={query.error}
title={t("records.conditions.loadError")}
onRetry={() => void query.refetch()}
/>
@@ -91,6 +91,7 @@ export function EmergencyProfileManager() {
if (query.isError) {
return (
<QueryErrorCard
error={query.error}
title={t("records.emergency.loadError")}
onRetry={() => void query.refetch()}
/>
@@ -33,7 +33,7 @@ export function FamilyHistoryManager() {
const [editing, setEditing] = useState<FamilyHistoryEntryDTO | null>(null);
const [formFooterEl, setFormFooterEl] = useState<HTMLDivElement | null>(null);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.familyHistoryList(),
queryFn: () => apiGet<FamilyHistoryEntryDTO[]>("/api/family-history"),
});
@@ -116,6 +116,7 @@ export function FamilyHistoryManager() {
</div>
) : isError ? (
<QueryErrorCard
error={error}
title={t("records.family.loadError")}
onRetry={() => void refetch()}
/>
@@ -962,6 +962,7 @@ export function LifeCircumstancesManager() {
if (query.isError) {
return (
<QueryErrorCard
error={query.error}
title={t("records.life.loadError")}
onRetry={() => void query.refetch()}
/>
+4 -1
View File
@@ -140,7 +140,10 @@ export function ProfileSummaryPage() {
</p>
) : null}
{summary.isError ? (
<QueryErrorCard onRetry={() => void summary.refetch()} />
<QueryErrorCard
error={summary.error}
onRetry={() => void summary.refetch()}
/>
) : null}
{summary.data ? <ProfileSummary summary={summary.data} /> : null}
</div>
@@ -40,7 +40,7 @@ import {
export function GrantsGivenCard() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useAccountGrants();
const { data, isLoading, isError, error, refetch } = useAccountGrants();
const revoke = useRevokeGrant();
const given = data?.given ?? [];
@@ -62,6 +62,7 @@ export function GrantsGivenCard() {
)}
{isError && (
<QueryErrorCard
error={error}
title={t("recordSharing.given.loadError")}
onRetry={() => void refetch()}
/>
@@ -54,7 +54,7 @@ import {
*/
export function GrantsReceivedCard() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useAccountGrants();
const { data, isLoading, isError, error, refetch } = useAccountGrants();
const accept = useAcceptGrant();
const renounce = useRenounceGrant();
const switchAccount = useAccountSwitch();
@@ -80,6 +80,7 @@ export function GrantsReceivedCard() {
)}
{isError && (
<QueryErrorCard
error={error}
title={t("recordSharing.received.loadError")}
onRetry={() => void refetch()}
/>
@@ -229,6 +229,7 @@ function ManagedProfileRow({
shows no destructive control for it. */}
{guardians.isError && (
<QueryErrorCard
error={guardians.error}
title={t("recordSharing.managed.rosterLoadError")}
onRetry={() => void guardians.refetch()}
/>
@@ -81,6 +81,7 @@ export function ManagedProfileEditForm({
if (profile.isError) {
return (
<QueryErrorCard
error={profile.error}
title={t("recordSharing.managed.editLoadError")}
onRetry={() => void profile.refetch()}
/>
@@ -88,6 +88,7 @@ export function ManagedProfileHandover({
if (status.isError) {
return (
<QueryErrorCard
error={status.error}
title={t("recordSharing.managed.handover.loadError")}
onRetry={() => void status.refetch()}
/>
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more