mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'fix/v1437-review' into release/v1.43.7
# Conflicts: # src/app/api/analytics/route.ts
This commit is contained in:
commit
45c027aee2
217 files changed
+3636
-2127
No files matched your search
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* A failed read is reported by what the failure means, and both read-failure
|
||||
* shapes decide it the same way from the query's error:
|
||||
*
|
||||
* - 403: the viewer may not read this. Nothing a retry could fix; the surface
|
||||
* draws its refused state, or nothing.
|
||||
* - 429: the read budget is spent. A note, never a retry that would only be
|
||||
* refused again.
|
||||
* - 401: the session ended. Never a silent blank: the reader is offered the
|
||||
* way back to sign-in.
|
||||
* - anything else: a real failure, with a retry.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { readFailureKind } from "@/lib/api/read-failure";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { QueryErrorCard } from "../query-error-card";
|
||||
import { QueryErrorRow } from "../query-error-row";
|
||||
|
||||
function ssr(node: ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (error: unknown, refused?: ReactNode) =>
|
||||
ssr(
|
||||
<QueryErrorRow
|
||||
error={error}
|
||||
refused={refused}
|
||||
message="Could not load the strip."
|
||||
onRetry={() => {}}
|
||||
slot="probe"
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = (error: unknown, refused?: ReactNode) =>
|
||||
ssr(
|
||||
<QueryErrorCard
|
||||
error={error}
|
||||
refused={refused}
|
||||
title="Could not load the list."
|
||||
description="Something on our side."
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("readFailureKind", () => {
|
||||
it("names an ended session, a refusal, a spent budget and the rest", () => {
|
||||
expect(readFailureKind(new ApiError("x", 401))).toBe("unauthenticated");
|
||||
expect(readFailureKind(new ApiError("x", 403))).toBe("refused");
|
||||
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");
|
||||
expect(readFailureKind(null)).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["<QueryErrorRow>", row],
|
||||
["<QueryErrorCard>", card],
|
||||
])("%s", (_name, render) => {
|
||||
it("draws nothing for a refused read, or the surface's refused state", () => {
|
||||
expect(render(new ApiError("Forbidden", 403))).toBe("");
|
||||
expect(
|
||||
render(
|
||||
new ApiError("Forbidden", 403),
|
||||
<p data-slot="probe-refused">Not shared with you.</p>,
|
||||
),
|
||||
).toBe('<p data-slot="probe-refused">Not shared with you.</p>');
|
||||
});
|
||||
|
||||
it("says a spent budget without offering a retry", () => {
|
||||
const html = render(new ApiError("Too many", 429));
|
||||
expect(html).toContain("Too many requests just now.");
|
||||
expect(html).not.toContain("retry");
|
||||
expect(html).not.toContain("Could not load");
|
||||
expect(html).not.toContain("Something on our side.");
|
||||
});
|
||||
|
||||
it("offers sign-in for an ended session, never a blank", () => {
|
||||
const html = render(new ApiError("Unauthorized", 401));
|
||||
expect(html).not.toBe("");
|
||||
expect(html).toContain("Your session has ended.");
|
||||
expect(html).toContain('data-slot="query-error-sign-in"');
|
||||
expect(html).not.toContain("retry");
|
||||
});
|
||||
|
||||
it("offers a retry for a real failure", () => {
|
||||
for (const error of [
|
||||
new ApiError("Boom", 500),
|
||||
new TypeError("Failed to fetch"),
|
||||
]) {
|
||||
const html = render(error);
|
||||
expect(html).toContain("Could not load the");
|
||||
expect(html).toMatch(/data-slot="query-error(-row)?-retry"/);
|
||||
expect(html).not.toContain("query-error-sign-in");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
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 { QueryErrorRow } from "../query-error-row";
|
||||
|
||||
/** A real read failure; the status-specific shapes live in query-error-primitives. */
|
||||
const failed = new ApiError("Boom", 500);
|
||||
|
||||
function ssr(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
@@ -12,7 +16,9 @@ function ssr(node: React.ReactNode) {
|
||||
|
||||
describe("<QueryErrorRow>", () => {
|
||||
it("renders the compact alert row with foreground message text", () => {
|
||||
const html = ssr(<QueryErrorRow message="Could not load vitals" />);
|
||||
const html = ssr(
|
||||
<QueryErrorRow error={failed} message="Could not load vitals" />,
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain('data-slot="query-error-row"');
|
||||
expect(html).toContain("Could not load vitals");
|
||||
@@ -25,13 +31,17 @@ describe("<QueryErrorRow>", () => {
|
||||
});
|
||||
|
||||
it("falls back to the generic load-failed copy", () => {
|
||||
expect(ssr(<QueryErrorRow />)).toContain("Could not load");
|
||||
expect(ssr(<QueryErrorRow error={failed} />)).toContain("Could not load");
|
||||
});
|
||||
|
||||
it("only renders the retry affordance when a handler is wired", () => {
|
||||
expect(ssr(<QueryErrorRow message="boom" />)).not.toContain("<button");
|
||||
expect(ssr(<QueryErrorRow error={failed} message="boom" />)).not.toContain(
|
||||
"<button",
|
||||
);
|
||||
|
||||
const withRetry = ssr(<QueryErrorRow message="boom" onRetry={() => {}} />);
|
||||
const withRetry = ssr(
|
||||
<QueryErrorRow error={failed} message="boom" onRetry={() => {}} />,
|
||||
);
|
||||
expect(withRetry).toContain("<button");
|
||||
expect(withRetry).toContain("Retry");
|
||||
expect(withRetry).toContain('data-slot="query-error-row-retry"');
|
||||
@@ -40,6 +50,7 @@ describe("<QueryErrorRow>", () => {
|
||||
it("honours the slot overrides the converted surfaces depend on", () => {
|
||||
const html = ssr(
|
||||
<QueryErrorRow
|
||||
error={failed}
|
||||
message="boom"
|
||||
onRetry={() => {}}
|
||||
slot="healthkit-metric-error"
|
||||
@@ -54,7 +65,12 @@ describe("<QueryErrorRow>", () => {
|
||||
it("takes a custom retry label", () => {
|
||||
expect(
|
||||
ssr(
|
||||
<QueryErrorRow message="boom" onRetry={() => {}} retryLabel="Again" />,
|
||||
<QueryErrorRow
|
||||
error={failed}
|
||||
message="boom"
|
||||
onRetry={() => {}}
|
||||
retryLabel="Again"
|
||||
/>,
|
||||
),
|
||||
).toContain("Again");
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
/**
|
||||
* 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useReadFailure } from "@/hooks/use-read-failure";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -18,21 +18,37 @@ import { cn } from "@/lib/utils";
|
||||
* the generic `common.*` strings so a caller can drop it in with just
|
||||
* `onRetry`; a surface with its own "couldn't load X" string passes it through
|
||||
* `title` / `description`.
|
||||
*
|
||||
* The card reads the failure itself (`error`), exactly as `QueryErrorRow`
|
||||
* does: a refusal draws `refused` (nothing by default), a spent budget says
|
||||
* so without a retry, an ended session offers sign-in, and only a real
|
||||
* failure shows `title` / `description` with `onRetry`.
|
||||
*/
|
||||
export function QueryErrorCard({
|
||||
error,
|
||||
refused = null,
|
||||
title,
|
||||
description,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
className,
|
||||
}: {
|
||||
/** The query's error. It decides what the card offers. */
|
||||
error: unknown;
|
||||
/** What a refused (403) read draws instead of the card. */
|
||||
refused?: ReactNode;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const failure = useReadFailure(error, { message: title, onRetry });
|
||||
if (!failure) return <>{refused}</>;
|
||||
const { action } = failure;
|
||||
// The description belongs to a real failure; a spent budget or an ended
|
||||
// session replaces the title, and the surface's own detail would not fit.
|
||||
const detail = failure.kind === "failed" ? description : null;
|
||||
return (
|
||||
<Card data-slot="query-error-card" className={cn(className)}>
|
||||
<CardContent
|
||||
@@ -43,11 +59,11 @@ export function QueryErrorCard({
|
||||
className="text-muted-foreground h-8 w-8"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm font-medium">{title ?? t("common.loadFailed")}</p>
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
<p className="text-sm font-medium">{failure.message}</p>
|
||||
{detail ? (
|
||||
<p className="text-muted-foreground text-sm">{detail}</p>
|
||||
) : null}
|
||||
{onRetry ? (
|
||||
{action?.kind === "retry" ? (
|
||||
// The slot is the accessibility suite's handle on the one control
|
||||
// this card offers: the error state has to be recoverable by
|
||||
// keyboard, and asserting that against viewport text would pin the
|
||||
@@ -56,10 +72,19 @@ export function QueryErrorCard({
|
||||
data-slot="query-error-retry"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRetry}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{retryLabel ?? t("common.retry")}
|
||||
{retryLabel ?? action.label}
|
||||
</Button>
|
||||
) : action ? (
|
||||
<Button
|
||||
data-slot="query-error-sign-in"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
|
||||
import { AlertTriangle, RotateCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useReadFailure } from "@/hooks/use-read-failure";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -23,11 +23,16 @@ import { cn } from "@/lib/utils";
|
||||
* `QueryErrorRow`
|
||||
* - chart body → `ChartErrorState`
|
||||
*
|
||||
* One deliberate change from the copies it replaces: the message renders in
|
||||
* `text-foreground`. An alert IS the content of a failed surface, and §3
|
||||
* reserves muted for meta — both copies had it muted.
|
||||
* The row reads the failure itself (`error`): a refusal draws `refused`
|
||||
* (nothing by default), a spent budget says so without a retry, an ended
|
||||
* session offers sign-in, and only a real failure offers `onRetry`.
|
||||
*
|
||||
* The message renders in `text-foreground`: an alert IS the content of a
|
||||
* failed surface, and §3 reserves muted for meta.
|
||||
*/
|
||||
export function QueryErrorRow({
|
||||
error,
|
||||
refused = null,
|
||||
message,
|
||||
onRetry,
|
||||
retryLabel,
|
||||
@@ -35,7 +40,11 @@ export function QueryErrorRow({
|
||||
retrySlot,
|
||||
className,
|
||||
}: {
|
||||
/** The failure line. Defaults to the generic `common.loadFailed`. */
|
||||
/** The query's error. It decides what the row offers. */
|
||||
error: unknown;
|
||||
/** What a refused (403) read draws instead of the row. */
|
||||
refused?: ReactNode;
|
||||
/** The line for a real failure. Defaults to `common.loadFailed`. */
|
||||
message?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
@@ -49,7 +58,9 @@ export function QueryErrorRow({
|
||||
retrySlot?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const failure = useReadFailure(error, { message, onRetry });
|
||||
if (!failure) return <>{refused}</>;
|
||||
const { action } = failure;
|
||||
return (
|
||||
<div
|
||||
data-slot={slot ?? "query-error-row"}
|
||||
@@ -64,19 +75,29 @@ export function QueryErrorRow({
|
||||
className="text-muted-foreground mt-0.5 size-4 shrink-0 sm:mt-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{message ?? t("common.loadFailed")}</span>
|
||||
<span>{failure.message}</span>
|
||||
</span>
|
||||
{onRetry ? (
|
||||
{action?.kind === "retry" ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onRetry}
|
||||
onClick={action.onClick}
|
||||
data-slot={retrySlot ?? "query-error-row-retry"}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RotateCw className="size-3.5" aria-hidden="true" />
|
||||
<span>{retryLabel ?? t("common.retry")}</span>
|
||||
<span>{retryLabel ?? action.label}</span>
|
||||
</Button>
|
||||
) : action ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={action.onClick}
|
||||
data-slot="query-error-sign-in"
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user