fix(ux): close medium UX-audit findings across flows, IA, and onboarding

Coach deep-links now carry launch context in the URL instead of dropping it
at the route boundary: /coach reads ?scope= (validated against the closed
CoachScopeSource enum) and ?ask= to seed the drawer's own prefill/scope
props, the Today check-in's "Adjust" action seeds ?ask= with the plan's own
words instead of opening a blank chat, and the workout detail page scopes
its coach button to workouts.

IA: give the document vault a "View N lab values" link on LAB_RESULT-shaped
documents (the auto-stage/OCR-commit linkage existed server-side with no UI
surface); disambiguate the two "Recovery" pages (RECOVERY_SCORE anatomy vs
the composite wearable page) with a distinct label plus a two-way
cross-link; move the opt-in nutrients sync card from Source priority to
Integrations, where every other sync surface lives; rename the user-menu
theme switcher off "Appearance" (collides with the Settings hub) and the
Environment settings section to "Location & weather"; cross-link mood,
mental-wellbeing check-ins, and mood insights, which were three
unconnected islands.

Onboarding: the getting-started checklist's data-source/notifications rows
now gate their fetch on the same checklistRelevant condition the other rows
use, instead of only-while-onboarding — they used to be stuck undone
forever for anyone who finished the wizard with under 5 measurements; the
tour wrap-up no longer promises a replay affordance that was removed;
the wizard's Withings card shows a connected badge once the OAuth
round-trip lands instead of leaving the button looking untouched in the
tab it was opened from; the done screen and Modules hub get a quiet
cross-link; the sleep empty-state CTA reads "Connect a source" instead of
being pinned to one vendor.

All copy changes land across every locale; de.json reviewed for register.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 05:06:24 +02:00
1 parent e9e186dca5
commit 8dd04e0170
23 files changed
+431 -79

No files matched your search

+42 -2
View File
@@ -6,11 +6,13 @@ import { useQuery } from "@tanstack/react-query";
import { CoachConversation } from "@/components/insights/coach-panel/coach-conversation";
import type { CoachNudgeStatus } from "@/components/insights/layout-coach-fab";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { coachScopeSourceSchema } from "@/lib/ai/coach/types";
/**
* v1.12.0 (Coach v2 #6) — full-page Coach conversation.
@@ -57,6 +59,23 @@ import { apiGet } from "@/lib/api/api-fetch";
* most-recent thread so the user lands on what the Coach said.
* The search-param read sits in a Suspense child so the client-bailout
* never de-opts the route.
*
* 2026-07-17 UX-flows audit — the route understood only `?c=` / `?doc=`, so
* every other cross-surface hand-off (a metric card's "ask about this", the
* Today check-in's "Adjust", a workout detail's coach button) dropped its
* context at the URL boundary and landed on a blank composer (F1-2 / F4-1 /
* F6-1). Two additive params close that gap, both seeding the SAME props
* `<CoachConversation>` already exposes for the drawer's suggested-prompt
* chips — no new plumbing inside the conversation surface itself:
* - `?scope=<CoachScopeSource>` — narrows the snapshot the FIRST turn of a
* fresh conversation reads (validated against the closed enum; an
* unrecognised value is silently dropped rather than reaching the chat
* route with a free-form string).
* - `?ask=<text>` — seeds the composer prefill (mirrors `prefill` on the
* in-app `askCoach()` launch call). The user still reviews/sends it —
* this is a prefill, not an auto-send.
* Both are ignored once `?c=` or `?doc=` pin an existing/scoped thread — scope
* only ever applies to a fresh conversation's first turn.
*/
function CoachPageBody() {
const searchParams = useSearchParams();
@@ -71,13 +90,30 @@ function CoachPageBody() {
const rawDoc = searchParams.get("doc");
const seedDocumentId = deepLinkedId === null && rawDoc ? rawDoc : null;
// A fresh conversation only — an existing thread (`?c=`) or a doc-scoped
// chat (`?doc=`) keeps its own established scope.
const freshChat = deepLinkedId === null && seedDocumentId === null;
const rawScope = freshChat ? searchParams.get("scope") : null;
const scopeResult = rawScope
? coachScopeSourceSchema.safeParse(rawScope)
: null;
const launchScope: CoachLaunchScope | null = scopeResult?.success
? { metric: scopeResult.data }
: null;
const seedPrefill = freshChat ? searchParams.get("ask") : null;
// C1 exception — an unread coach-initiated message opens the most-recent
// conversation (which holds that proactive turn). Only consulted when the
// entry did not pin a specific thread or ask for a fresh chat.
// A `?doc=` open is an explicit fresh doc-scoped chat — never override it by
// resuming the most-recent thread on an unread nudge.
// resuming the most-recent thread on an unread nudge. A `?scope=`/`?ask=`
// hand-off is likewise an explicit fresh-chat request.
const exceptionEligible =
deepLinkedId === null && rawC !== "new" && seedDocumentId === null;
deepLinkedId === null &&
rawC !== "new" &&
seedDocumentId === null &&
launchScope === null &&
!seedPrefill;
const { data: nudge } = useQuery({
queryKey: queryKeys.coachNudgeStatus(),
queryFn: async (): Promise<CoachNudgeStatus> =>
@@ -95,6 +131,10 @@ function CoachPageBody() {
// v1.28.51 — seed the document scope for a `?doc=<id>` open so the first
// turn is created + sent through the hardened fenced document endpoint.
initialDocumentId={seedDocumentId}
// 2026-07-17 UX-flows audit F1-2/F4-1/F6-1 — seed the scope/prefill a
// cross-surface hand-off carried in the URL.
launchScope={launchScope}
prefill={seedPrefill}
// Default is the new-chat hero; only resume most-recent for the
// unread coach-initiated exception.
autoOpenMostRecent={hasUnreadCoachMessage}
+28 -2
View File
@@ -3,7 +3,7 @@
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Smile } from "lucide-react";
import { ArrowRight, Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
@@ -41,7 +41,7 @@ interface ComprehensiveMoodData {
}
export default function InsightsStimmungPage() {
const { isAuthenticated } = useAuth();
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
@@ -101,6 +101,32 @@ export default function InsightsStimmungPage() {
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
{/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and
this insights page form one mental-health domain; this cross-links
into the other two so the trend a user reads here can lead
somewhere. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<Link
href="/mood"
data-slot="insights-mood-log-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.logLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="insights-mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
+16 -1
View File
@@ -1,6 +1,7 @@
"use client";
import { Loader2 } from "lucide-react";
import Link from "next/link";
import { ArrowRight, Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useModulePageGuard } from "@/hooks/use-module-page-guard";
@@ -46,6 +47,20 @@ export default function InsightsRecoveryPage() {
explainerMetric="recoveryPage"
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — reverse direction of the cross-link added
to the score-anatomy page: this composite wearable page shares the
"Recovery" nav label with the RECOVERY_SCORE anatomy view at
`/insights/scores/recovery`, so a user who arrived here looking for
the score ring / contributor breakdown gets a pointer instead of a
dead end. */}
<Link
href="/insights/scores/recovery"
data-slot="recovery-anatomy-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.recovery.scoreCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
<RecoverySection />
</SubPageShell>
);
+18
View File
@@ -1,6 +1,8 @@
"use client";
import { useParams } from "next/navigation";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { BackLink } from "@/components/ui/back-link";
@@ -94,6 +96,22 @@ export default function CompositeScorePage() {
}
coachLaunch
>
{/* 2026-07-17 UX/IA audit M4 — this anatomy view and the `/insights/recovery`
composite-wearable page both used to render as plain "Recovery", a
same-name/different-content trap: the score ring here promised one
thing, the nav pill led somewhere else with no pointer back. The
label above is now "Recovery score" (matches `measurements.typeRecoveryScore`);
this line closes the loop with an explicit cross-link. */}
{metric === "RECOVERY_SCORE" ? (
<Link
href="/insights/recovery"
data-slot="recovery-score-cross-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.derived.scores.recoveryCrossLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<CompositeScoreAnatomy metric={metric} />
</SubPageShell>
);
+5 -1
View File
@@ -78,7 +78,11 @@ export default function InsightsWorkoutDetailPage({
<WorkoutDetailStats workout={data} />
<WorkoutDetailRoute workout={data} />
<WorkoutDetailHRChart workout={data} />
<CoachLaunchButton />
{/* 2026-07-17 UX-flows audit F6-1 — the button used to open an
unscoped chat; the user had to restate which session they
meant. `workouts` narrows the snapshot the first turn reads
(pairs with the coach-scope param added for F4-1). */}
<CoachLaunchButton scope={{ metric: "workouts" }} />
</>
)}
</SubPageShell>
+17 -1
View File
@@ -13,7 +13,7 @@ import { PageHeader } from "@/components/ui/page-header";
import { PageAuthGate } from "@/components/ui/page-auth-gate";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Plus, Wrench } from "lucide-react";
import { ArrowRight, Plus, Wrench } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslations } from "@/lib/i18n/context";
@@ -120,6 +120,22 @@ export default function MoodPage() {
</ResponsiveSheet>
<MoodList onAddFirst={() => setDialogOpen(true)} />
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
screeners, and the mood insights page form one mental-health
domain but used to be three unconnected islands. A quiet pointer
here (module-gated — no nav change) rather than a merge: capture
vs. screener stay two defensible surfaces, just cross-linked. */}
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
);
}
@@ -18,8 +18,10 @@
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Check,
Download,
FlaskConical,
Pencil,
Plus,
Share2,
@@ -32,6 +34,7 @@ import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { useIllnessEpisodes } from "@/components/illness/use-illness";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { DateField } from "@/components/ui/date-field";
import { Input } from "@/components/ui/input";
@@ -230,6 +233,21 @@ export function DocumentDetailSheet({
});
const doc = detail.data;
// 2026-07-17 UX/IA audit M5 / F5-5 — the document → lab-values direction
// had no UI link at all: an auto-staged (PENDING) or OCR-confirmed
// (APPROVED, see `linkOcrLabsToVaultDocument`) OBSERVATION fact already
// ties this document to specific lab rows, but nothing on the sheet said
// so. There is no per-value route yet (`/labs` has no `?analyte=`
// deep-link — see the labs page's own "no per-value relation" comment),
// so this links to the labs list rather than a single biomarker; still a
// real jump where there was none before.
const labFactCount =
doc?.facts.filter(
(fact) => fact.factType === "OBSERVATION" && fact.status !== "REJECTED",
).length ?? 0;
const { user } = useAuth();
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
// AI layer — capability drives the vision/text transport; the mutations run
@@ -792,6 +810,18 @@ export function DocumentDetailSheet({
</div>
</div>
{labsModuleEnabled && labFactCount > 0 ? (
<Link
href="/labs"
data-slot="document-detail-lab-values-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 self-start rounded-md text-sm transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
>
<FlaskConical className="size-4" aria-hidden="true" />
{t("documents.detail.labValuesLink", { count: labFactCount })}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
<p className="text-muted-foreground text-xs">
{t("documents.detail.uploadedMeta", {
date: format.date(doc.createdAt),
@@ -5,6 +5,7 @@ import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
@@ -31,6 +32,13 @@ export interface CoachLaunchButtonProps {
label?: string;
/** Optional prefill seed for the next Coach turn. */
prefill?: string;
/**
* 2026-07-17 UX-flows audit F6-1 — optional scope so the conversation this
* button opens narrows to the relevant source(s) instead of starting from
* a blank, unscoped chat. Passed straight through to `askCoach`'s second
* argument (mirrors every other ambient-scope call site).
*/
scope?: CoachLaunchScope;
/** Optional className passthrough for inline overrides. */
className?: string;
/**
@@ -49,6 +57,7 @@ export interface CoachLaunchButtonProps {
export function CoachLaunchButton({
label,
prefill,
scope,
className,
variant = "inline",
}: CoachLaunchButtonProps) {
@@ -83,7 +92,7 @@ export function CoachLaunchButton({
data-slot="coach-launch-icon"
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => launch.askCoach(prefill ?? null)}
onClick={() => launch.askCoach(prefill ?? null, scope)}
className={cn("text-muted-foreground hover:text-foreground", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
@@ -101,7 +110,7 @@ export function CoachLaunchButton({
variant="outline"
size="sm"
data-slot="coach-launch-inline"
onClick={() => launch.askCoach(prefill ?? null)}
onClick={() => launch.askCoach(prefill ?? null, scope)}
className={cn("inline-flex h-10 gap-2 self-end", className)}
>
<Sparkles className="size-4" aria-hidden="true" />
@@ -28,7 +28,9 @@
* the page someone arrives at to take stock.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
@@ -143,6 +145,19 @@ export function MentalWellbeing() {
</ul>
</section>
{/* 2026-07-17 UX/IA audit M9 — mood tracking, this screener surface,
and the mood insights page form one mental-health domain but used
to be three unconnected islands. A quiet pointer to mood history,
mirroring the one added on `/mood` pointing back here. */}
<Link
href="/mood"
data-slot="mental-wellbeing-mood-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -mt-2 inline-flex items-center gap-1.5 self-start text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("mentalHealth.moodLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{/* Per-instrument detail, opened from a card body: last result,
trend chart, dated history, Start — the Verlauf lives here,
not on the landing. */}
@@ -15,7 +15,11 @@ const integrationStatusState = vi.hoisted(() => ({
data: undefined as
| {
threshold: number;
integrations: Array<{ integration: string; configured?: boolean }>;
integrations: Array<{
integration: string;
configured?: boolean;
connected?: boolean;
}>;
}
| undefined,
}));
@@ -88,4 +92,23 @@ describe("<SourceCardGrid> Withings card", () => {
expect(html).not.toContain('data-testid="source-card-withings-setup"');
expect(html).toContain("Connect Withings");
});
// 2026-07-17 UX-onboarding audit M6 — the connect CTA opens OAuth in a
// new tab; the callback flips the connection in THAT tab while this
// wizard tab used to render the same unchanged button forever. Once
// `useIntegrationStatuses` (refetches on window focus) reports
// `connected: true`, the card must swap to a badge instead of a dead
// "Connect Withings" button that would just reopen a redundant flow.
it("shows a connected badge instead of the connect button once Withings is linked", () => {
integrationStatusState.data = {
threshold: 3,
integrations: [
{ integration: "withings", configured: true, connected: true },
],
};
const html = render();
expect(html).toContain('data-testid="source-card-withings-connected"');
expect(html).not.toContain('href="/api/withings/connect"');
expect(html).not.toContain("Connect Withings");
});
});
+31 -8
View File
@@ -3,7 +3,14 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, FileUp, PlusCircle, Plug, Sparkles } from "lucide-react";
import {
CheckCircle2,
FileUp,
PlusCircle,
Plug,
Settings2,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { MedicalDisclaimer } from "@/components/common/medical-disclaimer";
@@ -220,13 +227,29 @@ export function DoneScreen() {
</Button>
</div>
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
{/* 2026-07-17 UX-onboarding audit M5 — the module system (default-on
set + opt-ins like nutrients / mental health / documents) is
otherwise undiscoverable during first-run: the wizard never
mentions it, and the only pointer used to be one tour stop.
A quiet link here, beside the existing import link, gives every
new account one honest pointer to Settings → Modules before they
leave the wizard. */}
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5">
<Link
href="/settings/export"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<FileUp className="size-3.5" />
{t("onboarding.done.importCta")}
</Link>
<Link
href="/settings/modules"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm underline-offset-4 hover:underline"
>
<Settings2 className="size-3.5" />
{t("onboarding.done.modulesCta")}
</Link>
</div>
</section>
);
}
@@ -249,13 +249,19 @@ export function GettingStartedChecklist() {
enabled: checklistRelevant,
});
// v1.5 perf audit: skip these two fetches once the user is past
// onboarding. The checklist hides itself anyway via shouldShowChecklist
// when onboardingCompletedAt != null AND measurementCount >= 5;
// v1.5 perf audit: skip these two fetches once the card can't render at
// all (`checklistRelevant`, same gate the meds/AI queries above use).
// withings/status and notifications/preferences are unique to this
// component and were burning ~950 ms of network on every dashboard load
// for established users. See docs/audit/v15-performance.md.
const onboardingPending = !!user && user.onboardingCompletedAt == null;
//
// 2026-07-17 UX audit M1 — this used to read a separate `onboardingPending`
// (`onboardingCompletedAt == null`) instead of `checklistRelevant`, so a
// user who finished the wizard but still had under 5 measurements kept
// seeing the card (`shouldShowChecklist` allows that) while these two
// queries stayed disabled forever: "Connect a data source" and
// "Configure notifications" could never flip done even after the user
// actually connected Withings or a push channel post-wizard.
// v1.17.0 — any connected data source satisfies the step, so we read
// the consolidated integrations envelope (Withings / WHOOP / Fitbit /
@@ -265,7 +271,7 @@ export function GettingStartedChecklist() {
queryFn: async () => {
return apiGet("/api/integrations/status");
},
enabled: onboardingPending,
enabled: checklistRelevant,
});
const { data: notificationsData } = useQuery<NotificationsPreferences>({
@@ -273,7 +279,7 @@ export function GettingStartedChecklist() {
queryFn: async () => {
return apiGet("/api/notifications/preferences");
},
enabled: onboardingPending,
enabled: checklistRelevant,
});
const medicationCount = medsData?.length ?? 0;
+31 -1
View File
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
CheckCircle2,
ClipboardCheck,
FileUp,
Plug,
@@ -119,6 +120,16 @@ export function SourceCardGrid() {
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
const withingsConfigured =
pickStatus(integrationStatus, "withings")?.configured ?? false;
// 2026-07-17 UX-onboarding audit M6 — the connect CTA opens the OAuth
// handshake in a new tab; the callback lands THAT tab on
// Settings → Integrations while this wizard tab never learned the
// connection succeeded. `useIntegrationStatuses` already refetches on
// window focus, so returning to this tab after the OAuth round trip
// picks up the fresh `connected` flag for free — the only missing piece
// was rendering it as a badge instead of leaving the button looking
// untouched.
const withingsConnected =
pickStatus(integrationStatus, "withings")?.connected ?? false;
async function advance() {
if (advancing) return;
@@ -164,6 +175,8 @@ export function SourceCardGrid() {
}
withingsCtaLabel={t("onboarding.source.withings.cta")}
withingsConfigured={withingsConfigured}
withingsConnected={withingsConnected}
withingsConnectedLabel={t("settings.integrationPill.connected")}
withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")}
withingsSetupHint={t("onboarding.source.withings.setupHint")}
/>
@@ -260,6 +273,13 @@ interface SourceCardItemProps {
* credentials saved — i.e. whether `/api/withings/connect` can
* actually succeed right now. */
withingsConfigured: boolean;
/** Whether Withings is already connected (OAuth completed in the tab the
* connect CTA opened). Replaces the connect button with a badge so
* returning to this wizard tab shows the outcome instead of an
* unchanged card. */
withingsConnected: boolean;
/** Localised "Connected" label for the badge above. */
withingsConnectedLabel: string;
/** CTA label shown instead of "Connect Withings" when unconfigured. */
withingsSetupCtaLabel: string;
/** One-sentence explanation of the BYO credential prerequisite. */
@@ -275,6 +295,8 @@ function SourceCardItem({
recommendedLabel,
withingsCtaLabel,
withingsConfigured,
withingsConnected,
withingsConnectedLabel,
withingsSetupCtaLabel,
withingsSetupHint,
}: SourceCardItemProps) {
@@ -325,7 +347,15 @@ function SourceCardItem({
>
{headerRow}
{body}
{withingsConfigured ? (
{withingsConnected ? (
<div
className="text-primary flex items-center gap-1.5 pt-1 text-sm font-medium"
data-testid="source-card-withings-connected"
>
<CheckCircle2 className="size-3.5" aria-hidden="true" />
{withingsConnectedLabel}
</div>
) : withingsConfigured ? (
<div className="flex items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm">
<a
@@ -19,6 +19,13 @@
*
* `parseOAuthOutcome` / `oauthReasonKey` are re-exported from
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*
* 2026-07-17 UX/IA audit M7 — `<NutrientIntakeCard>` (v1.28) moved here from
* Settings → Sources. It is a read-only sync inventory (what the Apple
* Health nutrients opt-in has stored), not a which-connection-wins ranking —
* "Source priority" describes the latter only. Matches the standing
* providers→Integrations rule; the card still renders nothing while the
* `nutrients` module is off.
*/
import Link from "next/link";
@@ -28,6 +35,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { Button } from "@/components/ui/button";
export {
@@ -55,6 +63,15 @@ export function IntegrationsSection() {
)}
/>
<ConnectionsPanel />
{/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory
(moved from Settings → Sources, where "source priority" only
ever meant which-connection-wins ranking, not a read-only sync
list). The card carries its own header + renders nothing while
the `nutrients` module is off, so it slots in as a sibling card
under the same Connections group rather than a new labelled
section that could paint an orphaned heading over nothing. */}
<NutrientIntakeCard />
</section>
{/* Delivery channels ("where it's delivered"). The existing channels
+5 -4
View File
@@ -45,7 +45,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -585,9 +584,11 @@ export function SourcesSection() {
</p>
</SettingsCard>
{/* v1.28 — read-only synced-nutrient list for the opt-in `nutrients`
module (renders nothing while the module is off). */}
<NutrientIntakeCard />
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
audit (M7) moved it to Settings → Integrations — it is a read-only
Apple-Health sync inventory, not a which-connection-wins ranking,
so it never belonged on the "source priority" ladder. See
`<IntegrationsSection>` for the card's new home. */}
</div>
);
}
+8 -1
View File
@@ -335,7 +335,14 @@ describe("buildDailyDigest — coach check-in (S3)", () => {
expect(item?.body).toContain("every morning → weigh in");
// Keep / let-go carry the plan id; adjust is a plain navigation href.
expect(item?.actions[0].intent).toBe(`${COACH_CHECKIN_KEEP_INTENT}:p1`);
expect(item?.actions[1].href).toBe("/coach");
// 2026-07-17 UX-flows audit F1-2 — adjust used to be a bare `/coach` link
// dropping all plan context; it now carries `?ask=` seeding the coach
// composer with the plan's own words, so the target is the coach route
// with the plan text echoed in the query, not a blank chat.
expect(item?.actions[1].href).toContain("/coach?ask=");
expect(item?.actions[1].href).toContain(
encodeURIComponent("every morning → weigh in"),
);
expect(item?.actions[2].intent).toBe(`${COACH_CHECKIN_LETGO_INTENT}:p1`);
});
+13 -1
View File
@@ -451,6 +451,18 @@ function buildCoachCheckinItem(
? t("daily.item.coachCheckin.bodyPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.body");
// 2026-07-17 UX-flows audit F1-2 — "Adjust" used to be a bare `/coach`
// link carrying no plan context: keep/let-go both thread the plan id
// into their intent, but adjust dropped the user into an empty new chat
// with no memory of which plan prompted the tap. The coach route's
// full-page surface now reads `?ask=` and seeds it as the composer
// prefill for a fresh conversation (`CoachConversation`'s `prefill` prop,
// same mechanism the drawer's suggested-prompt chips use) — the user
// lands with the plan already named instead of a blank composer.
const adjustPrompt = plan.planText
? t("daily.item.coachCheckin.adjustPromptPlan", { plan: plan.planText })
: t("daily.item.coachCheckin.adjustPrompt");
return {
kind: "coach_checkin",
title: t("daily.item.coachCheckin.title"),
@@ -464,7 +476,7 @@ function buildCoachCheckinItem(
{
labelKey: "daily.action.checkinAdjust",
intent: COACH_CHECKIN_ADJUST_INTENT,
href: "/coach",
href: `/coach?ask=${encodeURIComponent(adjustPrompt)}`,
},
{
labelKey: "daily.action.checkinLetGo",