mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
e9e186dca5
commit
8dd04e0170
23 files changed
+431
-79
No files matched your search
+42
-2
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user