style: prettier on touched insights/coach surfaces

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-10 18:23:20 +02:00
1 parent 21d064cfc6
commit 73e51d785e
40 files changed
+415 -413

No files matched your search

@@ -19,7 +19,10 @@ import { resolveAnnotationPositions } from "../health-chart";
const DAY_MS = 24 * 60 * 60 * 1000;
function makeSeries(start: number, count: number): Array<{ timestamp: number }> {
function makeSeries(
start: number,
count: number,
): Array<{ timestamp: number }> {
return Array.from({ length: count }, (_, i) => ({
timestamp: start + i * DAY_MS,
}));
@@ -54,9 +57,7 @@ describe("resolveAnnotationPositions", () => {
it("pins an exact-day annotation to its visible bucket", () => {
const positions = resolveAnnotationPositions(
[
{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" },
],
[{ date: "2026-04-15", label: "Started Ramipril", color: "#ff79c6" }],
dailySeries,
);
expect(positions).toHaveLength(1);
+3 -2
View File
@@ -276,7 +276,9 @@ export interface ResolvedAnnotation {
}
export function resolveAnnotationPositions(
annotations: Array<{ date: string; label: string; color: string }> | undefined,
annotations:
| Array<{ date: string; label: string; color: string }>
| undefined,
chartData: Array<{ timestamp: number }> | undefined,
): ResolvedAnnotation[] {
if (!annotations || !chartData || chartData.length === 0) return [];
@@ -906,7 +908,6 @@ export function HealthChart({
chartData,
);
const showContextDetails = showMA || showTrend || showBands;
const animationsEnabled = !prefersReducedMotion();
@@ -94,8 +94,7 @@ describe("<CorrelationRow>", () => {
}}
/>,
);
const matches =
html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
const matches = html.match(/data-slot="correlation-row-disclaimer"/g) ?? [];
expect(matches.length).toBe(1);
expect(html).toContain("Patterns are observational, not causal");
});
@@ -134,9 +134,7 @@ describe("<DailyBriefing>", () => {
it("hides the keyFindings section when the array is empty", () => {
const html = render(
<DailyBriefing
briefing={{ ...baseBriefing, keyFindings: [] }}
/>,
<DailyBriefing briefing={{ ...baseBriefing, keyFindings: [] }} />,
);
expect(html).not.toContain('data-slot="daily-briefing-findings-title"');
expect(html).not.toContain('data-slot="daily-briefing-findings"');
@@ -165,15 +163,9 @@ describe("<DailyBriefing>", () => {
it("disables the empty-state CTA while regenerating", () => {
const html = render(
<DailyBriefing
briefing={null}
onRegenerate={() => {}}
regenerating
/>,
);
expect(html).toMatch(
/data-slot="daily-briefing-empty-cta"[^>]*disabled/,
<DailyBriefing briefing={null} onRegenerate={() => {}} regenerating />,
);
expect(html).toMatch(/data-slot="daily-briefing-empty-cta"[^>]*disabled/);
expect(html).toContain("Regenerating");
});
@@ -95,9 +95,7 @@ describe("<HealthScoreCard>", () => {
);
expect(html).toContain("No history yet");
// No delta chip in the corner.
expect(html).not.toContain(
'data-slot="health-score-card-delta-chip"',
);
expect(html).not.toContain('data-slot="health-score-card-delta-chip"');
});
it("renders the up-arrow + delta chip when delta > 0", () => {
@@ -190,9 +188,7 @@ describe("<HealthScoreCard>", () => {
delta={null}
/>,
);
expect(html).not.toContain(
'data-slot="health-score-card-ask-coach"',
);
expect(html).not.toContain('data-slot="health-score-card-ask-coach"');
});
it("renders the Ask-the-Coach button when onAskCoach is supplied", () => {
@@ -45,10 +45,7 @@ describe("<HeroStrip>", () => {
});
it("renders the morning greeting in German", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
expect(html).toContain("Guten Morgen");
});
@@ -136,11 +133,7 @@ describe("<HeroStrip>", () => {
it("enables the ask-the-coach button when onAskCoach is supplied (B2b)", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onAskCoach={() => {}}
/>,
<HeroStrip briefing={null} now={morningLocal} onAskCoach={() => {}} />,
);
const coachTag = html.match(
/<button[^>]*data-slot="insights-hero-strip-action-coach"[^>]*>/,
@@ -189,11 +182,7 @@ describe("<HeroStrip>", () => {
it("forwards onPickPrompt down to the prompt strip without throwing", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
onPickPrompt={() => {}}
/>,
<HeroStrip briefing={null} now={morningLocal} onPickPrompt={() => {}} />,
);
// The chips render verbatim; SSR doesn't run click handlers.
expect(html).toMatch(/data-slot="insights-suggested-prompts-chip"/);
@@ -201,10 +190,7 @@ describe("<HeroStrip>", () => {
// ── Misc ───────────────────────────────────────────────────────────
it("supports the German locale fallback subtitle", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} />,
"de",
);
const html = render(<HeroStrip briefing={null} now={morningLocal} />, "de");
expect(html).toContain("Täglicher Blick auf deine Trends");
});
@@ -259,7 +245,9 @@ describe("<HeroStrip>", () => {
);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-read"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-share"/);
expect(html).toMatch(/data-slot="insights-hero-strip-weekly-banner-export"/);
expect(html).toMatch(
/data-slot="insights-hero-strip-weekly-banner-export"/,
);
expect(html).toContain("Read");
expect(html).toContain("Share");
expect(html).toContain("Export PDF");
@@ -39,7 +39,10 @@ describe("<TrendAnnotation>", () => {
});
it("renders the empty hint in German when locale=de", () => {
const html = render(<TrendAnnotation metric="weight" annotation={null} />, "de");
const html = render(
<TrendAnnotation metric="weight" annotation={null} />,
"de",
);
expect(html).toContain("Mehr Daten");
});
@@ -57,14 +60,22 @@ describe("<TrendAnnotation>", () => {
it("renders the moderate-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="moderate" />,
<TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="moderate"
/>,
);
expect(html).toContain("Moderate confidence");
});
it("renders the low-confidence chip", () => {
const html = render(
<TrendAnnotation metric="mood" annotation="Mood stable." confidence="low" />,
<TrendAnnotation
metric="mood"
annotation="Mood stable."
confidence="low"
/>,
);
expect(html).toContain("Low confidence");
});
@@ -48,9 +48,7 @@ describe("<CoachInput>", () => {
// localised v1.5 voice tooltip on aria-label. Attribute order is
// not guaranteed across React versions, so we check for both
// independently inside the same tag.
const micTag = html.match(
/<button[^>]*data-slot="coach-input-mic"[^>]*>/,
);
const micTag = html.match(/<button[^>]*data-slot="coach-input-mic"[^>]*>/);
expect(micTag).not.toBeNull();
expect(micTag?.[0]).toContain("disabled");
expect(micTag?.[0]).toContain(
@@ -120,9 +118,7 @@ describe("<CoachInput>", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
const handler = vi.fn();
render(
<CoachInput value="" onChange={handler} onSubmit={() => {}} />,
);
render(<CoachInput value="" onChange={handler} onSubmit={() => {}} />);
handler("typed");
expect(handler).toHaveBeenCalledWith("typed");
});
@@ -131,8 +127,6 @@ describe("<CoachInput>", () => {
const html = render(
<CoachInput value="" onChange={() => {}} onSubmit={() => {}} />,
);
expect(html).toMatch(
/data-slot="coach-input-textarea"[^>]*rows="2"/,
);
expect(html).toMatch(/data-slot="coach-input-textarea"[^>]*rows="2"/);
});
});
@@ -23,7 +23,11 @@ function makeClientWithConversations(page: CoachConversationsPage) {
return client;
}
function render(node: React.ReactNode, client: QueryClient, locale: "en" | "de" = "en") {
function render(
node: React.ReactNode,
client: QueryClient,
locale: "en" | "de" = "en",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<QueryClientProvider client={client}>{node}</QueryClientProvider>
@@ -81,9 +85,7 @@ describe("<HistoryRail>", () => {
client,
);
// c2 is active and gets the data-active flag.
expect(html).toMatch(
/data-slot="coach-history-item" data-active="true"/,
);
expect(html).toMatch(/data-slot="coach-history-item" data-active="true"/);
});
it("renders the empty-state copy when no conversations are cached", () => {
@@ -30,7 +30,8 @@ const baseConversation: CoachConversationDetailDTO = {
{
id: "m2",
role: "assistant",
content: "Looking at your last 6 Mondays, the morning systolic averaged …",
content:
"Looking at your last 6 Mondays, the morning systolic averaged …",
createdAt: "2026-05-10T09:00:30.000Z",
metricSource: {
windows: ["last30days"],
@@ -84,9 +85,8 @@ describe("<MessageThread>", () => {
/>,
);
// 1 persisted user + 1 persisted assistant + 1 streaming assistant.
const userBubbles = (
html.match(/data-slot="coach-bubble-user"/g) ?? []
).length;
const userBubbles = (html.match(/data-slot="coach-bubble-user"/g) ?? [])
.length;
const assistantBubbles = (
html.match(/data-slot="coach-bubble-assistant"/g) ?? []
).length;
@@ -71,9 +71,7 @@ describe("<SourceChips>", () => {
it("uses German labels when locale is 'de'", () => {
const html = render(
<SourceChips
provenance={{ windows: ["last30days"], metrics: ["bp"] }}
/>,
<SourceChips provenance={{ windows: ["last30days"], metrics: ["bp"] }} />,
"de",
);
expect(html).toContain("Blutdruck");
@@ -82,9 +80,7 @@ describe("<SourceChips>", () => {
it("renders a metric chip without window when windows are empty", () => {
const html = render(
<SourceChips
provenance={{ windows: [], metrics: ["general"] }}
/>,
<SourceChips provenance={{ windows: [], metrics: ["general"] }} />,
);
expect(html).toContain('data-metric="general"');
expect(html).toContain("General");
@@ -104,8 +104,7 @@ describe("parseSseChunk", () => {
});
it("ignores malformed frames silently", () => {
const chunk =
"data: not-json\n\n" + frame({ type: "token", token: "ok" });
const chunk = "data: not-json\n\n" + frame({ type: "token", token: "ok" });
const { events } = parseSseChunk("", chunk);
expect(events).toHaveLength(1);
expect(events[0]).toEqual({ type: "token", token: "ok" });
@@ -184,8 +183,9 @@ describe("parseSseChunk", () => {
}
const tokenText = events
.filter((e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
.filter(
(e): e is Extract<CoachStreamEvent, { type: "token" }> =>
e.type === "token",
)
.map((e) => e.token)
.join("");
@@ -70,7 +70,7 @@ export function CoachDrawerBody({
onClick={onOpenHistoryTray}
data-slot="coach-drawer-history-tray-trigger"
aria-label={t("insights.coach.historyTitle")}
className="bg-background/80 border-border/60 absolute left-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
className="bg-background/80 border-border/60 absolute top-2 left-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur lg:hidden"
>
<ChevronRight className="size-3" aria-hidden="true" />
{t("insights.coach.historyTitle")}
@@ -84,7 +84,7 @@ export function CoachDrawerBody({
onClick={onOpenSourcesTray}
data-slot="coach-drawer-sources-tray-trigger"
aria-label={t("insights.coach.sourcesTitle")}
className="bg-background/80 border-border/60 absolute right-2 top-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
className="bg-background/80 border-border/60 absolute top-2 right-2 z-10 h-7 gap-1 border px-2 text-[11px] backdrop-blur xl:hidden"
>
{t("insights.coach.sourcesTitle")}
<ChevronLeft className="size-3" aria-hidden="true" />
@@ -11,7 +11,12 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -20,10 +25,7 @@ import { CoachInput } from "./coach-input";
import { HistoryRail } from "./history-rail";
import { MessageThread } from "./message-thread";
import { SourcesRail } from "./sources-rail";
import {
useCoachConversation,
useSendCoachMessage,
} from "./use-coach";
import { useCoachConversation, useSendCoachMessage } from "./use-coach";
/**
* v1.4.20 phase B2b — AI Coach drawer (right-side `<Sheet>` overlay).
@@ -289,4 +291,3 @@ export function CoachDrawer({
</Sheet>
);
}
@@ -9,10 +9,7 @@ import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import {
useCoachConversations,
useDeleteCoachConversation,
} from "./use-coach";
import { useCoachConversations, useDeleteCoachConversation } from "./use-coach";
/**
* v1.4.20 phase B2b — conversation history rail.
@@ -136,9 +133,7 @@ export function HistoryRail({
className="min-w-0 flex-1 truncate text-left"
data-slot="coach-history-select"
>
<span className="block truncate font-medium">
{c.title}
</span>
<span className="block truncate font-medium">{c.title}</span>
<span className="text-muted-foreground block text-[10px]">
{formatRelativeTime(c.updatedAt, t)}
</span>
@@ -54,8 +54,13 @@ export function SourceChips({ provenance, className }: SourceChipsProps) {
const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => {
const metricLabel = t(METRIC_KEYS[metric]);
const windowLabel = primaryWindowKey ? t(WINDOW_KEYS[primaryWindowKey]) : null;
const count = counts && metric in counts ? counts[metric as keyof typeof counts] : undefined;
const windowLabel = primaryWindowKey
? t(WINDOW_KEYS[primaryWindowKey])
: null;
const count =
counts && metric in counts
? counts[metric as keyof typeof counts]
: undefined;
return {
key: `${metric}-${primaryWindowKey ?? "none"}`,
metric,
@@ -1,6 +1,14 @@
"use client";
import { Activity, Eye, Heart, HeartPulse, Pill, Scale, Smile } from "lucide-react";
import {
Activity,
Eye,
Heart,
HeartPulse,
Pill,
Scale,
Smile,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -72,10 +80,7 @@ export function SourcesRail({ className }: SourcesRailProps) {
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
<Eye
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
@@ -94,7 +99,10 @@ export function SourcesRail({ className }: SourcesRailProps) {
"rounded-md border px-2.5 py-2",
)}
>
<row.Icon className={cn("size-3.5", row.accentClass)} aria-hidden="true" />
<row.Icon
className={cn("size-3.5", row.accentClass)}
aria-hidden="true"
/>
<span className="text-foreground flex-1 text-xs font-medium">
{t(row.metricKey)}
</span>
@@ -1,11 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CoachConversationDetailDTO,
+29 -11
View File
@@ -1,6 +1,14 @@
"use client";
import { Activity, FileText, Heart, Pill, Scale, Smile, Sparkles } from "lucide-react";
import {
Activity,
FileText,
Heart,
Pill,
Scale,
Smile,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
@@ -46,7 +54,10 @@ interface DailyBriefingProps {
metaSlot?: React.ReactNode;
}
const METRIC_ICON: Record<DailyBriefingKeyFinding["sourceMetric"], React.ComponentType<{ className?: string }>> = {
const METRIC_ICON: Record<
DailyBriefingKeyFinding["sourceMetric"],
React.ComponentType<{ className?: string }>
> = {
bp: Heart,
weight: Scale,
pulse: Activity,
@@ -77,7 +88,10 @@ function DeltaBadge({
return (
<span
data-slot="daily-briefing-delta"
className={cn("text-xs font-semibold tabular-nums", TONE_TEXT_CLASSNAME[tone])}
className={cn(
"text-xs font-semibold tabular-nums",
TONE_TEXT_CLASSNAME[tone],
)}
>
{delta}
</span>
@@ -99,7 +113,10 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
)}
/>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_TEXT_CLASSNAME[finding.tone])}
className={cn(
"mt-0.5 h-4 w-4 shrink-0",
TONE_TEXT_CLASSNAME[finding.tone],
)}
aria-hidden="true"
/>
<div className="min-w-0 flex-1 space-y-1">
@@ -107,7 +124,9 @@ function KeyFindingRow({ finding }: { finding: DailyBriefingKeyFinding }) {
<p className="text-sm leading-snug font-medium">{finding.headline}</p>
<DeltaBadge delta={finding.delta} tone={finding.tone} />
</div>
<p className="text-muted-foreground text-xs leading-snug">{finding.detail}</p>
<p className="text-muted-foreground text-xs leading-snug">
{finding.detail}
</p>
</div>
</div>
);
@@ -164,7 +183,10 @@ export function DailyBriefing({
</CardTitle>
</div>
{metaSlot && (
<div data-slot="daily-briefing-meta-slot" className="flex items-center gap-2">
<div
data-slot="daily-briefing-meta-slot"
className="flex items-center gap-2"
>
{metaSlot}
</div>
)}
@@ -194,10 +216,7 @@ export function DailyBriefing({
>
{t("insights.dailyBriefing.keyFindingsTitle")}
</p>
<div
data-slot="daily-briefing-findings"
className="space-y-2"
>
<div data-slot="daily-briefing-findings" className="space-y-2">
{briefing.keyFindings.map((finding, index) => (
<KeyFindingRow
key={`${finding.sourceMetric}-${index}`}
@@ -251,4 +270,3 @@ export function DailyBriefing({
</Card>
);
}
@@ -114,7 +114,7 @@ export function HealthScoreCard({
<span
data-slot="health-score-card-number"
className={cn(
"text-4xl font-semibold tabular-nums leading-none",
"text-4xl leading-none font-semibold tabular-nums",
BAND_NUMBER_CLASS[band],
)}
>
@@ -234,9 +234,7 @@ export function HealthScoreCard({
data-slot="health-score-card-ask-coach"
className="w-full gap-1.5"
onClick={() =>
onAskCoach(
t("insights.healthScore.coachPrompt", { score }),
)
onAskCoach(t("insights.healthScore.coachPrompt", { score }))
}
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
+122 -129
View File
@@ -188,139 +188,137 @@ export function HeroStrip({
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-5">
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Sparkles
className="text-dracula-purple h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h1
data-slot="insights-hero-strip-greeting"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{greeting}
</h1>
</div>
<p
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
>
{subtitle}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
</>
)}
</div>
</div>
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
<h1
data-slot="insights-hero-strip-greeting"
className="text-2xl leading-tight font-semibold tracking-tight sm:text-[28px]"
>
{greeting}
</h1>
</div>
<p
data-slot="insights-hero-strip-subtitle"
className="text-muted-foreground max-w-3xl text-sm leading-relaxed"
>
{subtitle}
</p>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span data-slot="insights-hero-strip-baseline">
{t("insights.heroPersonalBaseline")}
</span>
{generatedLine && (
<>
<span aria-hidden="true" className="opacity-50">
·
</span>
<span data-slot="insights-hero-strip-generated">
{generatedLine}
</span>
</>
)}
</div>
</div>
)}
{weeklyReportReady && (
<WeeklyReportBanner
weekISO={weeklyReportReady.weekISO}
href={weeklyReportReady.href}
/>
)}
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>
<div className="flex flex-wrap items-center gap-2">
{/*
* v1.4.20 phase B4 shipped /insights/report/[week]; phase D
* reconcile enables this button as a real link to the
* current ISO week. Older parents that haven't adopted the
* weeklyReportHref prop still get the disabled affordance.
*/}
{weeklyReportHref ? (
<Button
asChild
variant="default"
size="sm"
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<Link href={weeklyReportHref}>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Link>
</Button>
) : (
<Button
type="button"
variant="default"
size="sm"
disabled
title={comingSoon}
data-slot="insights-hero-strip-action-weekly-report"
className="gap-1.5"
>
<FileText className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionWeeklyReport")}</span>
</Button>
)}
{/* B2b wires this into the Coach drawer. The button is
</Button>
)}
{/* B2b wires this into the Coach drawer. The button is
enabled whenever the parent supplies an `onAskCoach`
handler; older parents that haven't adopted B2b yet
still get the disabled "Coming soon" affordance so the
hero doesn't break. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
{onRegenerate && (
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
onClick={onAskCoach ? () => onAskCoach() : undefined}
disabled={!onAskCoach}
title={onAskCoach ? undefined : comingSoon}
data-slot="insights-hero-strip-action-coach"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
<Sparkles className="h-3.5 w-3.5" aria-hidden="true" />
<span>{t("insights.heroActionAskCoach")}</span>
</Button>
)}
</div>
{onRegenerate && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onRegenerate}
disabled={regenerating}
data-slot="insights-hero-strip-action-rerun"
className="gap-1.5"
>
{regenerating ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span>
{regenerating
? t("insights.heroRegenerating")
: t("insights.heroActionRerun")}
</span>
</Button>
)}
</div>
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
* with the localised prompt pre-filled in the composer.
* The parent owns drawer state so the chip strip stays
* presentational.
*/}
<SuggestedPrompts
onPick={onPickPrompt ?? (() => undefined)}
/>
</div>
<div
data-slot="insights-hero-strip-prompts"
className="border-border/50 border-t pt-4"
>
{/*
* v1.4.20 phase B2b — chip clicks open the Coach drawer
* with the localised prompt pre-filled in the composer.
* The parent owns drawer state so the chip strip stays
* presentational.
*/}
<SuggestedPrompts onPick={onPickPrompt ?? (() => undefined)} />
</div>
</div>
{healthScore && (
@@ -330,9 +328,7 @@ export function HeroStrip({
components={healthScore.components}
delta={healthScore.delta}
onAskCoach={
onAskCoach
? (prefill: string) => onAskCoach(prefill)
: undefined
onAskCoach ? (prefill: string) => onAskCoach(prefill) : undefined
}
/>
)}
@@ -360,11 +356,11 @@ function WeeklyReportBanner({
href: string;
}) {
const { t } = useTranslations();
const printHref = href.includes("?")
? `${href}&print=1`
: `${href}?print=1`;
const printHref = href.includes("?") ? `${href}&print=1` : `${href}?print=1`;
const shareUrl =
typeof window !== "undefined" ? new URL(href, window.location.origin).toString() : href;
typeof window !== "undefined"
? new URL(href, window.location.origin).toString()
: href;
async function handleShare() {
const title = t("insights.heroBanner.shareTitle");
@@ -379,10 +375,7 @@ function WeeklyReportBanner({
if ((err as DOMException)?.name === "AbortError") return;
}
}
if (
typeof navigator !== "undefined" &&
navigator.clipboard?.writeText
) {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t("insights.heroBanner.shareCopied"));
@@ -72,7 +72,7 @@ export function SuggestedPrompts({
"border-dracula-purple/18 hover:border-dracula-purple/40 hover:text-foreground",
"text-muted-foreground inline-flex min-h-9 items-center gap-1.5",
"rounded-full border bg-transparent px-3.5 py-2 text-[13px]",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
"focus-visible:ring-dracula-purple/50",
)}
>
@@ -93,9 +93,7 @@ export function WeeklyReportView({
);
}
return (
<WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />
);
return <WeeklyReportPresentation weekISO={weekISO} report={matchedReport} />;
}
/**
@@ -158,11 +156,11 @@ export function WeeklyReportPresentation({
{/* Hero — title + date range + meta. Always visible (incl. print). */}
<section
data-slot="weekly-report-hero"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:p-0 print:bg-none"
className="border-border/60 from-dracula-purple/10 space-y-2 rounded-xl border bg-gradient-to-b to-transparent p-5 sm:p-6 print:rounded-none print:border-0 print:bg-none print:p-0"
>
<span
data-slot="weekly-report-eyebrow"
className="bg-dracula-purple/15 text-dracula-purple inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0 print:text-foreground"
className="bg-dracula-purple/15 text-dracula-purple print:text-foreground inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-medium print:bg-transparent print:px-0"
>
<FileText className="h-3 w-3" aria-hidden="true" />
{t("insights.report.eyebrow")}
@@ -276,7 +274,7 @@ function Section({ slot, title, accent, icon, children }: SectionProps) {
className="border-border/60 space-y-2 rounded-xl border p-4 sm:p-5 print:rounded-none print:border-0 print:p-0"
>
<h2
className={`flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider ${accent}`}
className={`flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase ${accent}`}
>
{icon}
<span>{title}</span>