mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'main' into release/v1.30.31
# Conflicts: # CHANGELOG.md # docs/api/openapi.yaml # package.json # public/sw.js
This commit is contained in:
commit
02a56047a4
74 files changed
+1935
-2323
No files matched your search
@@ -0,0 +1,235 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import en from "../../../messages/en.json";
|
||||
|
||||
/**
|
||||
* A failed read must never render as an absence of data.
|
||||
*
|
||||
* "No data yet" is a factual claim about the user's record. Making it when the
|
||||
* read failed is a false statement about their health history — worst of all on
|
||||
* the mental-wellbeing surface, where a user with months of screening results
|
||||
* was told, in a mental-health context, that they had never taken an
|
||||
* assessment.
|
||||
*
|
||||
* Each surface must tell three states apart: loading, failed, and genuinely
|
||||
* empty. These tests pin the failed/empty split; the loading branch is asserted
|
||||
* where it is SSR-observable.
|
||||
*
|
||||
* SSR-only convention (`renderToStaticMarkup`, no `@testing-library/react`), so
|
||||
* the error state is produced by seeding the query cache with a settled failure
|
||||
* rather than by driving a real fetch.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", () => ({
|
||||
apiGet: () => new Promise(() => {}),
|
||||
apiPost: () => new Promise(() => {}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { gender: "FEMALE", modules: {} },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
|
||||
import { MicronutrientsCard } from "@/components/insights/nutrients/micronutrients-card";
|
||||
|
||||
function makeClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
// An errored query would otherwise refetch on mount, and the observer
|
||||
// reports that intent as an optimistic pending state during the server
|
||||
// render — which would hide the very branch under test.
|
||||
retryOnMount: false,
|
||||
staleTime: Infinity,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive `queryKey` to a settled failure through the public prefetch path, so
|
||||
* the SSR render below observes exactly the state a real failed read leaves in
|
||||
* the cache.
|
||||
*/
|
||||
async function seedFailure(
|
||||
client: QueryClient,
|
||||
queryKey: readonly unknown[],
|
||||
): Promise<void> {
|
||||
await client.prefetchQuery({
|
||||
queryKey: queryKey as unknown[],
|
||||
queryFn: () => Promise.reject(new Error("read failed")),
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode, client: QueryClient): string {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("mental wellbeing — screening history", () => {
|
||||
const mh = en.mentalHealth;
|
||||
|
||||
it("renders the error state with a retry when the history read fails", async () => {
|
||||
const client = makeClient();
|
||||
await seedFailure(client, queryKeys.mentalHealthAssessments());
|
||||
const html = render(<MentalWellbeing />, client);
|
||||
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(mh.historyLoadError);
|
||||
expect(html).toContain(en.common.retry);
|
||||
// The load-bearing assertion: the instrument cards' "not taken yet" copy
|
||||
// must NOT appear. That sentence is a claim about the user's record.
|
||||
expect(html).not.toContain(mh.noResultYet);
|
||||
});
|
||||
|
||||
it("still renders the instrument cards when the record is genuinely empty", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.mentalHealthAssessments(), {
|
||||
assessments: [],
|
||||
});
|
||||
const html = render(<MentalWellbeing />, client);
|
||||
|
||||
expect(html).not.toContain('data-slot="query-error-card"');
|
||||
// An honest empty record: the cards render and invite a first check-in.
|
||||
expect(html).toContain(mh.noResultYet);
|
||||
});
|
||||
|
||||
it("shows neither the empty copy nor an error while the read is in flight", () => {
|
||||
const html = render(<MentalWellbeing />, makeClient());
|
||||
expect(html).not.toContain('data-slot="query-error-card"');
|
||||
expect(html).not.toContain(mh.noResultYet);
|
||||
});
|
||||
});
|
||||
|
||||
describe("micronutrients card", () => {
|
||||
const mn = en.nutrients.micronutrients;
|
||||
|
||||
it("renders the error state with a retry when the nutrient read fails", async () => {
|
||||
const client = makeClient();
|
||||
await seedFailure(client, queryKeys.nutrientIntake(30));
|
||||
const html = render(<MicronutrientsCard />, client);
|
||||
|
||||
expect(html).toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(mn.loadError);
|
||||
expect(html).toContain(en.common.retry);
|
||||
expect(html).not.toContain(mn.emptyTitle);
|
||||
});
|
||||
|
||||
it("still renders the empty state when the record is genuinely empty", () => {
|
||||
const client = makeClient();
|
||||
client.setQueryData(queryKeys.nutrientIntake(30), { nutrients: [] });
|
||||
const html = render(<MicronutrientsCard />, client);
|
||||
|
||||
expect(html).not.toContain('data-slot="query-error-card"');
|
||||
expect(html).toContain(mn.emptyTitle);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The remaining surfaces in the sweep are pages whose render depends on the
|
||||
* router / module gate, which the SSR-only convention cannot drive. Pin their
|
||||
* contract structurally instead: each must read `isError` from its query and
|
||||
* hand a retry to the shared error card, so a later edit cannot quietly drop
|
||||
* the branch and fall back through to the empty state.
|
||||
*/
|
||||
describe("read-failure branch is wired on every swept surface", () => {
|
||||
const surfaces = [
|
||||
"src/components/mental-health/mental-wellbeing.tsx",
|
||||
"src/app/insights/workouts/page-client.tsx",
|
||||
"src/app/medications/[id]/page.tsx",
|
||||
"src/components/custom-metrics/custom-metric-values-list.tsx",
|
||||
"src/app/achievements/page.tsx",
|
||||
"src/components/insights/nutrients/micronutrients-card.tsx",
|
||||
// The three sleep-rhythm cards share one read, so the PAGE owns the single
|
||||
// error notice rather than each card painting its own copy of it.
|
||||
"src/app/insights/sleep/page.tsx",
|
||||
] as const;
|
||||
|
||||
for (const rel of surfaces) {
|
||||
it(`${rel} reads isError and offers a retry`, () => {
|
||||
const source = readFileSync(join(process.cwd(), rel), "utf8");
|
||||
expect(source).toMatch(/isError/);
|
||||
expect(source).toMatch(/onRetry=/);
|
||||
expect(source).toMatch(/refetch/);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("the shared sleep-rhythm read has exactly one error notice", () => {
|
||||
it("no rhythm card renders the load-error string itself", () => {
|
||||
// One failed read used to paint the same sentence three times on
|
||||
// /insights/sleep, none of them offering a retry.
|
||||
for (const rel of [
|
||||
"src/components/insights/sleep/chronotype-section.tsx",
|
||||
"src/components/insights/sleep/average-sleep-section.tsx",
|
||||
"src/components/insights/sleep/sleep-rhythm-section.tsx",
|
||||
]) {
|
||||
const source = readFileSync(join(process.cwd(), rel), "utf8");
|
||||
expect(source).not.toContain("rhythm.loadError");
|
||||
}
|
||||
const page = readFileSync(
|
||||
join(process.cwd(), "src/app/insights/sleep/page.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(page).toContain("rhythm.loadError");
|
||||
expect(page).toContain("QueryErrorCard");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The /insights/sleep page renders the shared rhythm error itself, so the
|
||||
* behaviour is observable — a structural check would still pass if the gate
|
||||
* were short-circuited, which the mutation check proved.
|
||||
*/
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => "/insights/sleep",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-insights-analytics", () => ({
|
||||
useInsightsAnalytics: () => ({ isEmpty: false }),
|
||||
}));
|
||||
|
||||
// The shared rhythm read is mocked at the hook rather than seeded in the cache:
|
||||
// the hook sets its own staleTime, which makes a cache-seeded failure look like
|
||||
// a pending refetch during a server render and hides the branch under test.
|
||||
vi.mock("@/components/insights/sleep/use-sleep-rhythm", () => ({
|
||||
useSleepRhythm: () => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
refetch: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const { default: SleepPage } = await import("@/app/insights/sleep/page");
|
||||
|
||||
describe("/insights/sleep — shared rhythm read", () => {
|
||||
it("renders exactly one error notice with a retry when the read fails", () => {
|
||||
const html = render(<SleepPage />, makeClient());
|
||||
|
||||
// React escapes the apostrophe in the copy, so count the rendered cards
|
||||
// rather than the raw bundle string.
|
||||
const notices = html.split('data-slot="query-error-card"').length - 1;
|
||||
expect(notices).toBe(1);
|
||||
expect(html).toContain(en.common.retry);
|
||||
// The three sibling cards must not render alongside the notice.
|
||||
expect(html).not.toContain('data-slot="chronotype-error"');
|
||||
});
|
||||
});
|
||||
@@ -130,7 +130,7 @@ export function TrendCard({
|
||||
emptyHint = null,
|
||||
valueAdornment = null,
|
||||
}: TrendCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, tCount } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
|
||||
// v1.4.33 F5 — pick a single signal to drive both the arrow and the
|
||||
@@ -370,20 +370,10 @@ export function TrendCard({
|
||||
}
|
||||
if (staleDays <= 60) {
|
||||
const weeks = Math.floor(staleDays / 7);
|
||||
return t(
|
||||
weeks === 1
|
||||
? "dashboard.staleHintWeeksOne"
|
||||
: "dashboard.staleHintWeeksOther",
|
||||
{ count: weeks },
|
||||
);
|
||||
return tCount("dashboard.staleHintWeeks", weeks);
|
||||
}
|
||||
const months = Math.floor(staleDays / 30);
|
||||
return t(
|
||||
months === 1
|
||||
? "dashboard.staleHintMonthsOne"
|
||||
: "dashboard.staleHintMonthsOther",
|
||||
{ count: months },
|
||||
);
|
||||
return tCount("dashboard.staleHintMonths", months);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -41,6 +42,7 @@ export function CustomMetricValuesList({
|
||||
data: list,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -68,11 +70,15 @@ export function CustomMetricValuesList({
|
||||
);
|
||||
const total = list?.pages[0]?.meta.total ?? 0;
|
||||
|
||||
// The error state was honest but a dead end: one red sentence, no way to try
|
||||
// again short of navigating away and back. QueryErrorCard carries the same
|
||||
// message with a retry, matching the sibling custom-metric list.
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-destructive py-8 text-center text-sm">
|
||||
{t("customMetrics.loadError")}
|
||||
</p>
|
||||
<QueryErrorCard
|
||||
description={t("customMetrics.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import type { CalendarDay } from "./types";
|
||||
import {
|
||||
FERTILE_HUE,
|
||||
@@ -30,9 +32,17 @@ import {
|
||||
* grid is never colour-only. WCAG 2.5.5 — each cell is ≥ 40 px.
|
||||
*/
|
||||
|
||||
/** Monday-first short weekday labels, localized via Intl (no i18n key). */
|
||||
function shortWeekdays(): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(undefined, { weekday: "short" });
|
||||
/**
|
||||
* Monday-first short weekday labels, localized via Intl (no i18n key).
|
||||
*
|
||||
* Takes the APP locale explicitly. It used to pass `undefined`, which resolves
|
||||
* to the browser's locale — invisible whenever the two agree, and wrong for
|
||||
* every user who reads the app in a language their browser is not set to.
|
||||
*/
|
||||
function shortWeekdays(locale: Locale): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
|
||||
weekday: "short",
|
||||
});
|
||||
// 2024-01-01 is a Monday — walk 7 days for a Monday-first header row.
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
fmt.format(new Date(2024, 0, 1 + i)),
|
||||
@@ -71,8 +81,8 @@ export function CycleCalendar({
|
||||
onSelectDay,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
const { t } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(), []);
|
||||
const { t, locale } = useTranslations();
|
||||
const weekdays = useMemo(() => shortWeekdays(locale), [locale]);
|
||||
const byDate = useMemo(() => new Map(days.map((d) => [d.date, d])), [days]);
|
||||
|
||||
// Anchor the visible month on today's month.
|
||||
@@ -104,7 +114,7 @@ export function CycleCalendar({
|
||||
return cells;
|
||||
}, [monthAnchor]);
|
||||
|
||||
const monthLabel = monthAnchor.toLocaleDateString(undefined, {
|
||||
const monthLabel = monthAnchor.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export function HistoryRail({
|
||||
className,
|
||||
hideHeading = false,
|
||||
}: HistoryRailProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
// v1.30.2 (QoL H1) — the search box now drives a SERVER-side query
|
||||
// (title-only substring, see the route doc comment), so debounce the
|
||||
@@ -242,7 +242,7 @@ export function HistoryRail({
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t)}
|
||||
{formatRelativeTime(c.updatedAt, t, locale)}
|
||||
</span>
|
||||
</button>
|
||||
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0`
|
||||
|
||||
@@ -10,7 +10,7 @@ export type ConfidenceBand = "high" | "medium" | "low" | "draft";
|
||||
export type ConfidenceMeterVariant = "bars" | "ring";
|
||||
|
||||
export interface ConfidenceMeterProps {
|
||||
/** Score from `computeConfidence()` — 0..100, integer expected. */
|
||||
/** Score carried on the insight payload — 0..100, integer expected. */
|
||||
value: number;
|
||||
/** Visual variant. Defaults to `bars`. */
|
||||
variant?: ConfidenceMeterVariant;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ComponentType } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { DataSummary } from "@/lib/analytics/trends";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
@@ -72,7 +73,7 @@ export function DeviceScoreTile({
|
||||
learningThreshold = 3,
|
||||
className,
|
||||
}: DeviceScoreTileProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const { user } = useAuth();
|
||||
|
||||
const count = summary?.count ?? 0;
|
||||
@@ -83,7 +84,7 @@ export function DeviceScoreTile({
|
||||
const isLearning = count < learningThreshold;
|
||||
|
||||
const fmt = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
value.toLocaleString(resolveIntlLocale(locale), {
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Leaf } from "lucide-react";
|
||||
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@@ -53,7 +54,11 @@ export function MicronutrientsCard() {
|
||||
const sex =
|
||||
user?.gender === "MALE" || user?.gender === "FEMALE" ? user.gender : null;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
// A failed read left `data` undefined, `rows` empty, and the card rendered
|
||||
// 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({
|
||||
queryKey: queryKeys.nutrientIntake(WINDOW_DAYS),
|
||||
queryFn: () =>
|
||||
apiGet<NutrientIntakeOverview>(`/api/nutrients?days=${WINDOW_DAYS}`),
|
||||
@@ -72,7 +77,12 @@ export function MicronutrientsCard() {
|
||||
<TileHeader icon={Leaf} title={t("nutrients.micronutrients.title")} />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{isLoading ? (
|
||||
{isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("nutrients.micronutrients.loadError")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div className="space-y-1" aria-hidden="true">
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-full rounded-md" />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
|
||||
/**
|
||||
* v1.28.x — the discreet "sources disagree" marker, extracted from
|
||||
@@ -29,15 +30,6 @@ export interface SleepSourceDiscrepancyDto {
|
||||
}[];
|
||||
}
|
||||
|
||||
export function formatMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Localised label keys per `MeasurementSource` — the same map the
|
||||
* source-priority settings surface uses (`sources-section.tsx`), so both
|
||||
@@ -110,7 +102,7 @@ export function SleepSourceDiscrepancyMarker({
|
||||
discrepancy,
|
||||
align = "start",
|
||||
}: SleepSourceDiscrepancyMarkerProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
if (!discrepancy) return null;
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
@@ -137,7 +129,7 @@ export function SleepSourceDiscrepancyMarker({
|
||||
{discrepancy.sources
|
||||
.map(
|
||||
(b) =>
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatMinutes(b.asleepMinutes, locale)}`,
|
||||
`${sourceDisplayName(b.source, b.deviceType, t)} ${formatDurationMinutes(b.asleepMinutes, t)}`,
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c → W3f — sleep-stage composition chart.
|
||||
@@ -97,15 +100,6 @@ export const STAGE_COLORS: Record<string, string> = {
|
||||
IN_BED: "var(--chart-inbed)", // muted blue-grey — pre-asleep
|
||||
};
|
||||
|
||||
function formatMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Berlin-tz day key (YYYY-MM-DD) as a short x-axis tick.
|
||||
* 7-day window → "Mon" / "Tue" / …; 14-day → "M 10" / "T 11" / …;
|
||||
@@ -123,13 +117,13 @@ function formatMinutes(total: number, locale: string): string {
|
||||
function formatDayTick(
|
||||
dayKey: string,
|
||||
window: WindowSize,
|
||||
locale: string,
|
||||
locale: Locale,
|
||||
): string {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
if (!y || !m || !d) return dayKey;
|
||||
const date = new Date(Date.UTC(y, m - 1, d));
|
||||
if (window === 7) {
|
||||
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
|
||||
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
timeZone: "UTC",
|
||||
weekday: "short",
|
||||
});
|
||||
@@ -137,7 +131,7 @@ function formatDayTick(
|
||||
if (window === 14) {
|
||||
return `${date.getUTCDate()}.`;
|
||||
}
|
||||
return date.toLocaleDateString(locale === "de" ? "de-DE" : "en-US", {
|
||||
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
||||
timeZone: "UTC",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -322,7 +316,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
{stageLabels[stage] ?? stage}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatMinutes(minutes, locale)} · {pct}%
|
||||
{formatDurationMinutes(minutes, t)} · {pct}%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -330,7 +324,7 @@ export function SleepStageStackedBar({ breakdown }: SleepStageStackedBarProps) {
|
||||
{totalNight > 0 && (
|
||||
<div className="border-border mt-1 flex items-center justify-between gap-3 border-t pt-1 font-medium">
|
||||
<span>{t("insights.sleep.headlineTitle")}</span>
|
||||
<span>{formatMinutes(totalNight, locale)}</span>
|
||||
<span>{formatDurationMinutes(totalNight, t)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -65,14 +65,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("<ChronotypeSection>", () => {
|
||||
it("renders the quiet inline notice on a query error", () => {
|
||||
it("renders nothing on a query error — the page owns the one notice", () => {
|
||||
// All three rhythm cards resolve the same query, so a per-card notice
|
||||
// printed the identical sentence three times. /insights/sleep now renders
|
||||
// a single QueryErrorCard with a retry in place of the row. The card must
|
||||
// still not fall through to its skeleton, which would spin forever.
|
||||
rhythmMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = render(<ChronotypeSection enabled />);
|
||||
expect(html).toContain('data-slot="chronotype-error"');
|
||||
expect(html).toBe("");
|
||||
expect(html).not.toContain('data-slot="chronotype-loading"');
|
||||
});
|
||||
|
||||
|
||||
@@ -73,16 +73,17 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("<SleepRhythmSection>", () => {
|
||||
it("renders the quiet inline notice on a query error, not an infinite skeleton", () => {
|
||||
it("renders nothing on a query error, and never an infinite skeleton", () => {
|
||||
// The shared-read notice moved to /insights/sleep so one failure prints
|
||||
// one message instead of three. The card must still bail rather than
|
||||
// fall through to a skeleton that can never resolve.
|
||||
rhythmMock.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
});
|
||||
const html = render(<SleepRhythmSection enabled />);
|
||||
expect(html).toContain('data-slot="sleep-rhythm-error"');
|
||||
expect(html).toContain("Your sleep rhythm");
|
||||
// No skeletons while errored.
|
||||
expect(html).toBe("");
|
||||
expect(html).not.toContain('data-slot="sleep-rhythm-loading"');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useSleepRhythm } from "./use-sleep-rhythm";
|
||||
import { AverageSleepCard } from "./average-sleep-card";
|
||||
|
||||
@@ -21,24 +20,17 @@ import { AverageSleepCard } from "./average-sleep-card";
|
||||
*/
|
||||
export function AverageSleepSection({ enabled }: { enabled: boolean }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useSleepRhythm(
|
||||
isAuthenticated && enabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="average-sleep-error"
|
||||
role="status"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.sleep.rhythm.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The page owns the single error notice for this shared read (all three
|
||||
// cards resolve the same query, so a per-card notice printed it three
|
||||
// times). Bail out here rather than falling through to the skeleton below,
|
||||
// which would otherwise spin forever on a failed read.
|
||||
if (isError) return null;
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useSleepRhythm } from "./use-sleep-rhythm";
|
||||
import { ChronotypeCard } from "./chronotype-card";
|
||||
|
||||
@@ -26,24 +25,17 @@ import { ChronotypeCard } from "./chronotype-card";
|
||||
*/
|
||||
export function ChronotypeSection({ enabled }: { enabled: boolean }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useSleepRhythm(
|
||||
isAuthenticated && enabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="chronotype-error"
|
||||
role="status"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.sleep.rhythm.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The page owns the single error notice for this shared read (all three
|
||||
// cards resolve the same query, so a per-card notice printed it three
|
||||
// times). Bail out here rather than falling through to the skeleton below,
|
||||
// which would otherwise spin forever on a failed read.
|
||||
if (isError) return null;
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useSleepRhythm } from "./use-sleep-rhythm";
|
||||
import { SleepDebtCard } from "./sleep-debt-card";
|
||||
|
||||
@@ -27,24 +26,17 @@ import { SleepDebtCard } from "./sleep-debt-card";
|
||||
*/
|
||||
export function SleepRhythmSection({ enabled }: { enabled: boolean }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useSleepRhythm(
|
||||
isAuthenticated && enabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sleep-rhythm-error"
|
||||
role="status"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.sleep.rhythm.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// The page owns the single error notice for this shared read (all three
|
||||
// cards resolve the same query, so a per-card notice printed it three
|
||||
// times). Bail out here rather than falling through to the skeleton below,
|
||||
// which would otherwise spin forever on a failed read.
|
||||
if (isError) return null;
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
|
||||
@@ -100,6 +100,7 @@ import {
|
||||
INSIGHTS_OVERVIEW_PATH,
|
||||
subPageSlugForType,
|
||||
} from "@/lib/insights/sub-page-metric";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7c — cumulative HK types whose list view collapses to one
|
||||
@@ -140,19 +141,6 @@ interface Measurement {
|
||||
awakenings?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.11.5 — format a minutes total as an "8h 12m" / "8 Std. 12 Min."
|
||||
* sleep headline.
|
||||
*/
|
||||
function formatSleepMinutes(total: number, locale: string): string {
|
||||
const hours = Math.floor(total / 60);
|
||||
const mins = Math.round(total - hours * 60);
|
||||
if (locale === "de") {
|
||||
return hours > 0 ? `${hours} Std. ${mins} Min.` : `${mins} Min.`;
|
||||
}
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/** v1.11.5 — i18n labels for the sleep stages shown in the drill-down. */
|
||||
function sleepStageLabel(
|
||||
stage: string | null | undefined,
|
||||
@@ -287,7 +275,7 @@ export function MeasurementList({
|
||||
lockedType,
|
||||
initialType,
|
||||
}: MeasurementListProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// v1.28.42 (H3) — the desktop table and the mobile card list used to BOTH
|
||||
@@ -1023,7 +1011,7 @@ export function MeasurementList({
|
||||
TIME ASLEEP as "8h 12m" + a nap caption. */}
|
||||
{isSleep ? (
|
||||
<>
|
||||
{formatSleepMinutes(m.value, locale)}
|
||||
{formatDurationMinutes(m.value, t)}
|
||||
<SleepNightCaption m={m} />
|
||||
</>
|
||||
) : (
|
||||
@@ -1228,7 +1216,7 @@ export function MeasurementList({
|
||||
"78.4 kg" no longer truncates to "78".
|
||||
v1.11.5 — sleep rows render TIME ASLEEP. */}
|
||||
{isSleep ? (
|
||||
formatSleepMinutes(m.value, locale)
|
||||
formatDurationMinutes(m.value, t)
|
||||
) : (
|
||||
<>
|
||||
{isGrouped
|
||||
@@ -1590,7 +1578,7 @@ function DayDrillDown({
|
||||
unit: string;
|
||||
layout: "desktop" | "mobile";
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.measurementDrilldown(type, dayKey),
|
||||
@@ -1652,7 +1640,7 @@ function DayDrillDown({
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{isSleep
|
||||
? formatSleepMinutes(s.value, locale)
|
||||
? formatDurationMinutes(s.value, t)
|
||||
: `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1673,9 +1661,7 @@ function DayDrillDown({
|
||||
: formatDateTime(s.measuredAt)}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{isSleep
|
||||
? formatSleepMinutes(s.value, locale)
|
||||
: `${s.value} ${unit}`}
|
||||
{isSleep ? formatDurationMinutes(s.value, t) : `${s.value} ${unit}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 en from "../../../../messages/en.json";
|
||||
|
||||
/**
|
||||
@@ -35,6 +36,15 @@ function withProviders(node: React.ReactNode): string {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
// The landing now distinguishes loading from an empty record: while the
|
||||
// history read is in flight it paints skeletons rather than the instrument
|
||||
// cards' "not taken yet" copy, since that copy is a claim about the user's
|
||||
// record. These tests exercise the LOADED landing, so seed the read as a
|
||||
// settled empty result. The failed and in-flight states are covered in
|
||||
// `failed-read-never-empty.test.tsx`.
|
||||
queryClient.setQueryData(queryKeys.mentalHealthAssessments(), {
|
||||
assessments: [],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
|
||||
@@ -38,6 +38,8 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
import { INSTRUMENTS, INSTRUMENT_ORDER } from "@/lib/mental-health/instruments";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
import { AssessmentResult } from "./assessment-result";
|
||||
import { CheckInWizard } from "./check-in-wizard";
|
||||
@@ -63,7 +65,20 @@ export function MentalWellbeing() {
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: history } = useQuery({
|
||||
// The three states are kept apart deliberately. This read carries the user's
|
||||
// screening history; when it fails, `history` is undefined and every
|
||||
// downstream consumer (`lastByInstrument`, the detail sheet's `rows`) sees an
|
||||
// empty array — which used to render the instrument cards' "not taken yet"
|
||||
// copy. On a mental-health surface that is not a missing error message, it is
|
||||
// a false statement about the user's record: someone with months of
|
||||
// assessments was told the assessments do not exist. Error is now rendered as
|
||||
// error, loading as loading, and only a genuinely empty result as empty.
|
||||
const {
|
||||
data: history,
|
||||
isPending: historyPending,
|
||||
isError: historyError,
|
||||
refetch: refetchHistory,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.mentalHealthAssessments(),
|
||||
queryFn: () =>
|
||||
apiGet<{ assessments: AssessmentRow[] }>(
|
||||
@@ -131,18 +146,37 @@ export function MentalWellbeing() {
|
||||
|
||||
<section aria-label={t("mentalHealth.choosePrompt")}>
|
||||
<h2 className="sr-only">{t("mentalHealth.choosePrompt")}</h2>
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{INSTRUMENT_ORDER.map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<InstrumentCard
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{historyError ? (
|
||||
<QueryErrorCard
|
||||
title={t("mentalHealth.historyLoadError")}
|
||||
description={t("mentalHealth.historyLoadErrorHint")}
|
||||
onRetry={() => void refetchHistory()}
|
||||
/>
|
||||
) : historyPending ? (
|
||||
<ul
|
||||
className="grid list-none gap-4 p-0 sm:grid-cols-2"
|
||||
aria-busy="true"
|
||||
>
|
||||
{INSTRUMENT_ORDER.map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<Skeleton className="h-44 w-full rounded-xl" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<ul className="grid list-none gap-4 p-0 sm:grid-cols-2">
|
||||
{INSTRUMENT_ORDER.map((id) => (
|
||||
<li key={id} className="contents">
|
||||
<InstrumentCard
|
||||
instrument={id}
|
||||
last={lastByInstrument.get(id)}
|
||||
onStart={() => begin(id)}
|
||||
onOpenDetail={() => setDetailInstrument(id)}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface,
|
||||
|
||||
Reference in new issue
Block a user