mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
style: prettier on touched insights/coach surfaces
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user