mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'worktree-agent-a9a623139a6af16d5' into release/v1.30.30
This commit is contained in:
commit
d4c4e5d08b
41 files changed
+1138
-248
No files matched your search
@@ -25,6 +25,12 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
|
||||
* matching the `onboarding` page's existing test pattern.
|
||||
*/
|
||||
|
||||
// The page bounces a disabled-module direct hit to /insights (it used to
|
||||
// render a blank page), so the SSR render needs a router in scope.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { localeLanguageNames as LANGUAGE_NAMES } from "@/lib/i18n/config";
|
||||
import {
|
||||
AllProvidersFailedError,
|
||||
runStreamingRawCompletionWithFallback,
|
||||
@@ -386,9 +388,7 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
message,
|
||||
refusalText:
|
||||
replayed.message ??
|
||||
(locale === "de"
|
||||
? "Eine frühere Nachricht in dieser Unterhaltung enthält Anweisungen, die meine Vorgaben überschreiben sollen. Beginne bitte eine neue Unterhaltung."
|
||||
: "An earlier message in this conversation contains wording that overrides my instructions. Please start a new conversation."),
|
||||
getServerTranslator(locale).t("coach.refusal.conversationPoisoned"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -622,7 +622,7 @@ ${groundingBlock}`
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}.`;
|
||||
|
||||
// v1.18.7 (SENIOR-DEV HIGH) — atomically RESERVE the day's budget before
|
||||
// the provider call. The reservation increments the day's total by the
|
||||
@@ -727,7 +727,7 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
CONVERSATION
|
||||
${transcript}
|
||||
|
||||
Reply now as the assistant, in ${locale === "de" ? "German" : "English"}. Fetch any figures you cite with the tools first.`,
|
||||
Reply now as the assistant, in ${LANGUAGE_NAMES[locale]}. Fetch any figures you cite with the tools first.`,
|
||||
},
|
||||
];
|
||||
const loop = await runCoachToolLoop({
|
||||
|
||||
@@ -108,7 +108,7 @@ function groupByRecency(conversations: CoachConversationDTO[]): RecencyGroup[] {
|
||||
}
|
||||
|
||||
function CoachConversationsBody() {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("");
|
||||
// v1.30.2 (QoL H1) — search now drives the server-side title query;
|
||||
@@ -290,7 +290,7 @@ function CoachConversationsBody() {
|
||||
<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>
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,8 @@ import { ChronotypeSection } from "@/components/insights/sleep/chronotype-sectio
|
||||
import { AverageSleepSection } from "@/components/insights/sleep/average-sleep-section";
|
||||
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { useSleepRhythm } from "@/components/insights/sleep/use-sleep-rhythm";
|
||||
|
||||
/**
|
||||
* v1.4.25 W4c — `/insights/sleep`.
|
||||
@@ -41,6 +43,13 @@ export default function InsightsSchlafPage() {
|
||||
const { ready } = useModulePageGuard("sleep");
|
||||
|
||||
const { isEmpty } = useInsightsAnalytics("SLEEP_DURATION");
|
||||
// The three rhythm cards share one `["sleep-rhythm"]` read, so a failure used
|
||||
// to paint the same sentence three times, none of them offering a way out.
|
||||
// The page owns the shared state and renders one notice with a retry; the
|
||||
// cards no longer carry their own copy of it.
|
||||
const { isError: rhythmError, refetch: refetchRhythm } = useSleepRhythm(
|
||||
Boolean(user) && !isEmpty,
|
||||
);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-sleep account to
|
||||
// /insights instead of half-rendering the sleep surface.
|
||||
@@ -127,11 +136,18 @@ export default function InsightsSchlafPage() {
|
||||
viewports and sit side-by-side from `sm` up; each card keeps its own
|
||||
internals (the debt headline and the chronotype band treatment).
|
||||
*/}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SleepRhythmSection enabled={!isEmpty} />
|
||||
<AverageSleepSection enabled={!isEmpty} />
|
||||
<ChronotypeSection enabled={!isEmpty} />
|
||||
</div>
|
||||
{rhythmError ? (
|
||||
<QueryErrorCard
|
||||
description={t("insights.sleep.rhythm.loadError")}
|
||||
onRetry={() => void refetchRhythm()}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SleepRhythmSection enabled={!isEmpty} />
|
||||
<AverageSleepSection enabled={!isEmpty} />
|
||||
<ChronotypeSection enabled={!isEmpty} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*
|
||||
v1.17.1 — "Sleep quality" block. Surfaces the WHOOP / Oura nightly
|
||||
|
||||
@@ -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