mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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'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")}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user