mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Never render a failed read as an absence of data
"No data yet" is a claim about the user's record. Making it when the read failed states something false about their health history. The mental-wellbeing surface was the one that mattered: the assessment history read dropped isError entirely, so a failure left both instrument cards showing "not taken yet" and the detail sheet showing an empty trend. Someone with months of screening results was told, on a mental-health surface, that the results did not exist. It now tells loading, failed and genuinely empty apart, with a retry on the failure. Also swept: - the workouts insights page read isEmpty but not isError, so a failed list read fell through every branch to null and painted a titled shell with nothing in it; - the micronutrients card (not in the original list) rendered its "no nutrient data" empty state on a failed read, while its sibling hydration card already used the shared error card; - the medication detail page and the custom-metric values list showed an honest error with no way out. Both keep retry: false — right for a deleted row, wrong for a transient failure — and gain a user-initiated retry instead, which cannot storm; - /achievements rendered a blank page on a direct hit with the module disabled. It now bounces to /insights like every other gated page. The achievements query error branch, the custom-metric error branch and the chronotype notice were already correct and are left alone.
This commit is contained in:
1 parent
c23f2545cc
commit
c87ce161ba
13 files changed
+330
-53
No files matched your search
@@ -34,6 +34,8 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/format";
|
||||
@@ -260,6 +262,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
export default function AchievementsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
// v1.18.0 — the achievements module gate. When the account has the
|
||||
// module turned off the whole page disappears: an empty state stands in
|
||||
@@ -276,14 +279,26 @@ export default function AchievementsPage() {
|
||||
enabled: isAuthenticated && achievementsEnabled,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && isAuthenticated && !achievementsEnabled) {
|
||||
router.push("/insights");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, achievementsEnabled, router]);
|
||||
|
||||
if (authLoading || (achievementsEnabled && isLoading)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry
|
||||
// is hidden by the same gate, so a direct hit on /achievements renders
|
||||
// nothing rather than an orphaned shell. No new i18n string is minted
|
||||
// (the disabled state has no copy of its own).
|
||||
// v1.18.0 — module off ⇒ the surface disappears entirely. The nav entry is
|
||||
// hidden by the same gate, so this only catches a direct URL hit. It used to
|
||||
// render `null` and stop there, which left a blank page with no explanation
|
||||
// and no route anywhere — a dead end rather than a gate. It now bounces to
|
||||
// /insights, matching every other module-gated page (`useModulePageGuard`);
|
||||
// the brief `null` is the pre-redirect frame, not the destination.
|
||||
//
|
||||
// The shared guard is not used wholesale here because it also redirects an
|
||||
// unauthenticated visitor to /auth/login, and this page deliberately answers
|
||||
// that case with a "sign in to see your achievements" header instead.
|
||||
if (isAuthenticated && !achievementsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MetricEmptyState } from "@/components/insights/metric-empty-state";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { WorkoutList } from "@/components/insights/workout-list";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/workouts`.
|
||||
@@ -36,7 +37,13 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
export default function InsightsWorkoutsPageClient() {
|
||||
const { t } = useTranslations();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, isEmpty } = useWorkouts({ limit: 100 });
|
||||
// `isError` / `refetch` were already on the hook and simply not read here, so
|
||||
// a failed list read fell through every branch to `null` and painted a titled
|
||||
// shell with nothing in it — no message, no way back. Not the empty state,
|
||||
// but just as opaque.
|
||||
const { data, isLoading, isEmpty, isError, refetch } = useWorkouts({
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
@@ -57,7 +64,12 @@ export default function InsightsWorkoutsPageClient() {
|
||||
{/* No `<MetricRangeControls>` here: workouts are session records, not a
|
||||
MeasurementType series, so the period-over-period range read has
|
||||
nothing to aggregate. */}
|
||||
{isLoading ? (
|
||||
{isError ? (
|
||||
<QueryErrorCard
|
||||
title={t("insights.workouts.loadError")}
|
||||
onRetry={() => refetch()}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<div data-slot="workouts-loading" className="space-y-2">
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { BackLink } from "@/components/ui/back-link";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import {
|
||||
MedicationDetailTabs,
|
||||
type MedicationDetailSnapshot,
|
||||
@@ -49,6 +49,7 @@ export default function MedicationDetailPage({
|
||||
data: medication,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery<MedicationDetailSnapshot>({
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
queryFn: async () => {
|
||||
@@ -71,20 +72,21 @@ export default function MedicationDetailPage({
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
// `retry: false` above stays: a deleted medication 404s here and automatic
|
||||
// backoff against a row that no longer exists is pure waste. But that flag
|
||||
// also stranded the page on a single transient failure, with no way forward
|
||||
// short of navigating away and back. A user-initiated retry is the right
|
||||
// granularity — it costs one request per click and cannot storm.
|
||||
if (isError || !medication) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<BackLink href="/medications" label={t("medications.back")} />
|
||||
<Card
|
||||
className="p-6"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
data-slot="medication-detail-error-card"
|
||||
>
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.detail.shell.loadFailed")}
|
||||
</p>
|
||||
</Card>
|
||||
<div data-slot="medication-detail-error-card">
|
||||
<QueryErrorCard
|
||||
description={t("medications.detail.shell.loadFailed")}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user