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

@@ -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>
);
}