Merge the day picker, Coach new-chat button, day panel on /coach and text cleanup into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 15:11:24 +02:00
commit 1d080ee166
69 files changed
+2293 -2241

No files matched your search

@@ -154,7 +154,7 @@ describe("<BodySitesSection>", () => {
});
expect(html).toContain("Procedures and visits");
expect(html).toContain("Arthroscopy");
expect(html).toContain("Knee · Left");
expect(html).toContain("Knee (Left)");
expect(html).toContain('href="/documents?doc=d1"');
expect(html).toContain("MRI knee report");
expect(html).toContain("Conditions");
@@ -116,7 +116,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
it("renders the empty-hint placeholder instead of a percentage on thin data (v1.17 W1b)", () => {
const html = render(
<TrendCard
label="BP in Target · 23d"
label="BP in Target (23d)"
latest={null}
emptyHint="collecting data"
unit="%"
@@ -130,7 +130,7 @@ describe("BD-Zielbereich tile (v1.4.28 FB-C1/C2 rewrite)", () => {
expect(html).toContain('data-slot="trend-card-empty-hint"');
expect(html).toContain("collecting data");
// The dynamic span label is shown in place of the static "· 90 T".
expect(html).toContain("BP in Target · 23d");
expect(html).toContain("BP in Target (23d)");
// No numeric headline value renders when the hint takes the slot.
expect(html).not.toContain('data-slot="trend-card-value"');
});
@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import enMessages from "../../../../messages/en.json";
import { I18nProvider } from "@/lib/i18n/context";
import {
DayCalendar,
DayDatePicker,
keyToLocalDate,
localDateToKey,
monthWindow,
pickedDay,
} from "../day-date-picker";
/**
* The day header's calendar: the date is the button that opens it, the month
* marks the days that hold anything, nothing past today can be picked, and
* "Today" goes to today. SSR, like the rest of the suite; the clicks
* themselves run in `e2e/day-view.spec.ts`.
*/
vi.mock("../use-day", () => ({
useDayIndex: () => ({ data: undefined }),
}));
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en" initialMessages={enMessages}>
{node}
</I18nProvider>,
);
}
function calendar(
props: Partial<Parameters<typeof DayCalendar>[0]> = {},
): string {
return render(
<DayCalendar
date="2026-09-24"
today="2026-10-08"
month={keyToLocalDate("2026-09-24")}
onMonthChange={() => undefined}
withEntries={["2026-09-03", "2026-09-24"]}
onPick={() => undefined}
{...props}
/>,
);
}
function dayButton(html: string, key: string): string {
return (
html.match(new RegExp(`<button[^>]*data-date-key="${key}"[^>]*>`))?.[0] ??
""
);
}
describe("<DayDatePicker>", () => {
it("makes the written date the button, with no icon beside it", () => {
const html = render(
<DayDatePicker
date="2026-09-24"
today="2026-10-08"
label="Thursday, September 24, 2026"
onPick={() => undefined}
/>,
);
const button = html.match(/<button[^>]*data-slot="day-date-button"[^>]*>/);
expect(button?.[0]).toContain('aria-haspopup="dialog"');
expect(button?.[0]).toContain('aria-expanded="false"');
expect(html).toContain("Thursday, September 24, 2026");
expect(html).not.toContain("<svg");
});
});
describe("<DayCalendar>", () => {
it("shows the month of the day on screen with that day selected", () => {
const html = calendar();
expect(html).toContain("September 2026");
expect(dayButton(html, "2026-09-24")).toContain(
'data-selected-single="true"',
);
});
it("marks the days that hold anything, and says so", () => {
const html = calendar();
expect(dayButton(html, "2026-09-03")).toContain('data-entries="true"');
expect(dayButton(html, "2026-09-03")).toContain(
'aria-label="Thursday, September 3, 2026, has entries"',
);
expect(dayButton(html, "2026-09-04")).not.toContain("data-entries");
});
it("offers nothing past today", () => {
const html = calendar({
date: "2026-10-01",
month: keyToLocalDate("2026-10-01"),
});
expect(dayButton(html, "2026-10-08")).not.toMatch(/\sdisabled=""/);
expect(dayButton(html, "2026-10-09")).toMatch(/\sdisabled=""/);
});
it("offers Today, except on today", () => {
const today = (html: string) =>
html.match(/<button[^>]*data-slot="day-date-today"[^>]*>Today/)?.[0];
expect(today(calendar())).not.toMatch(/\sdisabled=""/);
expect(
today(
calendar({ date: "2026-10-08", month: keyToLocalDate("2026-10-08") }),
),
).toMatch(/\sdisabled=""/);
});
});
describe("picking a day", () => {
it("opens a past day or today, never the open day or a future one", () => {
expect(pickedDay("2026-09-03", "2026-09-24", "2026-10-08")).toBe(
"2026-09-03",
);
expect(pickedDay("2026-10-08", "2026-09-24", "2026-10-08")).toBe(
"2026-10-08",
);
expect(pickedDay("2026-09-24", "2026-09-24", "2026-10-08")).toBeNull();
expect(pickedDay("2026-10-09", "2026-09-24", "2026-10-08")).toBeNull();
});
it("reads the month it shows, and never past today", () => {
expect(monthWindow(keyToLocalDate("2026-09-24"), "2026-10-08")).toEqual({
from: "2026-09-01",
to: "2026-09-30",
});
expect(monthWindow(keyToLocalDate("2026-10-02"), "2026-10-08")).toEqual({
from: "2026-10-01",
to: "2026-10-08",
});
expect(localDateToKey(keyToLocalDate("2024-02-29"))).toBe("2024-02-29");
});
});
+12 -5
View File
@@ -110,6 +110,7 @@ let canWrite = true;
vi.mock("../use-day", () => ({
useDay: () => ({ ...dayQuery, refetch: () => undefined }),
usePrefetchDay: () => () => undefined,
useDayIndex: () => ({ data: undefined }),
}));
vi.mock("next/navigation", () => ({ usePathname: () => pathname }));
vi.mock("@/hooks/use-auth", () => ({
@@ -161,6 +162,7 @@ async function render(
shell={props.shell ?? "docked"}
onClose={() => undefined}
onStep={() => undefined}
onPick={() => undefined}
Title={(p) => <h2 {...p} />}
titleId="t"
/>
@@ -180,15 +182,19 @@ describe("<DayView>", () => {
it("names the day and counts what it holds", async () => {
const html = await render();
expect(html).toContain("Saturday, January 3, 2026");
expect(html).toContain("3 values · 2 entries");
expect(html).toContain("3 values, 2 entries");
});
it("lists what ran through the day with its running count", async () => {
const html = await render();
expect(html).toContain("Running that day");
expect(html).toContain("Common cold");
expect(html).toContain("Day 4 · since");
expect(html).toContain("Winter course · Day 64 of 151");
// Name first, the quiet day count below; an open span names its start
// as a date, its year left out when it is the viewed day's year.
expect(html).toContain("Day 4, since December 31");
expect(html).toContain("Vitamin D 1,000 IU Winter course");
expect(html).toContain("Day 64 of 151");
expect(html).not.toContain("ongoing");
expect(html).toContain('href="/illness/ep1"');
});
@@ -227,10 +233,11 @@ describe("<DayView>", () => {
const html = await render();
const rows = html.split('data-slot="day-running-item"').slice(1);
const med = rows.find((row) => row.includes("Levothyroxine")) ?? "";
expect(med).toContain("50 µg");
expect(med).toContain("Levothyroxine 50 µg");
expect(med.split("</li>")[0]).not.toMatch(/Day \d|since/);
const leave = rows.find((row) => row.includes("Parental leave")) ?? "";
expect(leave).toContain("Family · Day 34 of 90");
expect(leave).toMatch(/data-slot="tag-chip"[^>]*>Family</);
expect(leave).toContain("Day 34 of 90");
expect(leave).not.toContain("FAMILY");
});
+279
View File
@@ -0,0 +1,279 @@
"use client";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar, CalendarDayButton } from "@/components/ui/calendar";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { resolveDateFnsLocale } from "@/components/ui/date-field";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import type { DateKey } from "@/lib/day/contract";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { useDayIndex } from "./use-day";
/**
* The date in the day's header, as the way to any other day: the written
* date is the button, and it opens a month calendar beside it. Picking a
* day opens that day; "Today" opens today. Days that hold anything carry a
* dot, read from `GET /api/day/index` for the month on screen.
*
* No icon and no field: the header keeps its one line, and the arrows
* beside it stay the way to the neighbouring days. Radix owns the keyboard
* (Enter or Space opens, Escape closes, focus returns to the date). A
* popover beside the date serves the docked column and the side sheet; in
* the phone's bottom sheet the calendar rises as a sheet of its own, with
* touch-sized cells wherever the pointer is coarse.
*/
/** `YYYY-MM-DD` → a local `Date` at midnight, the calendar's own unit. */
export function keyToLocalDate(key: DateKey): Date {
const [y, m, d] = key.split("-").map(Number) as [number, number, number];
return new Date(y, m - 1, d);
}
/** A local `Date` → `YYYY-MM-DD`, read in the zone it was made in. */
export function localDateToKey(date: Date): DateKey {
const pad = (n: number) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)}` as DateKey;
}
/** First and last day of the month that holds `month`, never past today. */
export function monthWindow(
month: Date,
today: DateKey,
): { from: DateKey; to: DateKey } {
const from = localDateToKey(
new Date(month.getFullYear(), month.getMonth(), 1),
);
const last = localDateToKey(
new Date(month.getFullYear(), month.getMonth() + 1, 0),
);
return { from, to: last > today ? today : last };
}
/**
* What a pick opens: the day itself, unless it is the day on screen already
* or lies past today (the calendar disables those, this is the backstop).
*/
export function pickedDay(
next: DateKey,
date: DateKey,
today: DateKey,
): DateKey | null {
return next !== date && next <= today ? next : null;
}
export function DayDatePicker({
date,
today,
label,
onPick,
variant = "popover",
className,
}: {
date: DateKey;
today: DateKey;
/** The date as the header writes it: the button's text. */
label: React.ReactNode;
onPick: (date: DateKey) => void;
/**
* `sheet` in the phone's bottom sheet: a month of finger-sized cells
* does not fit beside a header that sits halfway down the screen, so the
* calendar rises as its own bottom sheet there.
*/
variant?: "popover" | "sheet";
className?: string;
}) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const [month, setMonth] = useState(() => keyToLocalDate(date));
const range = monthWindow(month, today);
const index = useDayIndex(range.from, range.to, open);
const onOpenChange = (next: boolean) => {
// Each opening starts on the month of the day on screen.
if (next) setMonth(keyToLocalDate(date));
setOpen(next);
};
const buttonClass = cn(
"hover:bg-muted/60 focus-visible:ring-ring/50 data-[state=open]:bg-muted/60 -mx-1 block max-w-full truncate rounded-md px-1 text-left transition-colors focus-visible:ring-[3px] focus-visible:outline-none",
className,
);
const calendar = (
<DayCalendar
date={date}
today={today}
month={month}
onMonthChange={setMonth}
withEntries={Object.keys(index.data?.days ?? {}) as DateKey[]}
onPick={(next) => {
setOpen(false);
const day = pickedDay(next, date, today);
if (day !== null) onPick(day);
}}
/>
);
if (variant === "sheet") {
return (
<>
<button
ref={buttonRef}
type="button"
data-slot="day-date-button"
data-state={open ? "open" : "closed"}
aria-haspopup="dialog"
aria-expanded={open}
title={t("day.pickDate")}
onClick={() => onOpenChange(true)}
className={buttonClass}
>
{label}
</button>
<ResponsiveSheet
open={open}
onOpenChange={onOpenChange}
title={t("day.pickerTitle")}
hideHeader
showCloseButton={false}
bodyClassName="items-center p-2"
// Back to the date, inside the day's own sheet.
onCloseAutoFocus={(event) => {
event.preventDefault();
buttonRef.current?.focus({ preventScroll: true });
}}
>
<div data-slot="day-date-picker" className="w-fit">
{calendar}
</div>
</ResponsiveSheet>
</>
);
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="day-date-button"
aria-haspopup="dialog"
title={t("day.pickDate")}
className={buttonClass}
>
{label}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
data-slot="day-date-picker"
aria-label={t("day.pickerTitle")}
className="w-auto p-0"
>
{calendar}
</PopoverContent>
</Popover>
);
}
/** The calendar's day button, with its date key and dot as attributes. */
function KeyedDayButton(props: React.ComponentProps<typeof CalendarDayButton>) {
return (
<CalendarDayButton
{...props}
data-date-key={localDateToKey(props.day.date)}
data-entries={props.modifiers.entries ? "true" : undefined}
/>
);
}
/**
* The popover's body: the month with the day on screen selected, a dot on
* every day that holds anything, nothing past today, and "Today" below.
*/
export function DayCalendar({
date,
today,
month,
onMonthChange,
withEntries,
onPick,
}: {
date: DateKey;
today: DateKey;
month: Date;
onMonthChange: (month: Date) => void;
withEntries: readonly DateKey[];
onPick: (date: DateKey) => void;
}) {
const { t, locale } = useTranslations();
const todayDate = keyToLocalDate(today);
const dateLabel = new Intl.DateTimeFormat(resolveIntlLocale(locale), {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
timeZone: "UTC",
});
// The calendar hands out local midnights; the label reads the same
// calendar day at noon UTC, so no zone can move it.
const labelOf = (day: Date) =>
dateLabel.format(new Date(`${localDateToKey(day)}T12:00:00.000Z`));
return (
<>
<Calendar
mode="single"
required
selected={keyToLocalDate(date)}
onSelect={(picked) => {
if (picked) onPick(localDateToKey(picked));
}}
month={month}
onMonthChange={onMonthChange}
endMonth={todayDate}
disabled={{ after: todayDate }}
showOutsideDays={false}
locale={resolveDateFnsLocale(locale)}
modifiers={{ entries: withEntries.map(keyToLocalDate) }}
modifiersClassNames={{
// A dot under the number; the selected day's dot takes the
// colour of the number on it.
entries:
"after:bg-primary data-[selected=true]:after:bg-primary-foreground after:pointer-events-none after:absolute after:bottom-1 after:left-1/2 after:size-1 after:-translate-x-1/2 after:rounded-full",
}}
labels={{
labelDayButton: (day, modifiers) =>
modifiers.entries
? `${labelOf(day)}, ${t("day.hasEntries")}`
: labelOf(day),
}}
components={{ DayButton: KeyedDayButton }}
// 32 px cells beside a fine pointer, 40 px for a finger.
className="[--cell-size:--spacing(10)] pointer-fine:[--cell-size:--spacing(8)]"
autoFocus
/>
<div className="border-border flex justify-end border-t p-2">
<Button
type="button"
variant="ghost"
size="sm"
data-slot="day-date-today"
disabled={date === today}
onClick={() => onPick(today)}
>
{t("day.today")}
</Button>
</div>
</>
);
}
+30 -1
View File
@@ -158,6 +158,15 @@ export function openDay(date: DateKey, options: OpenDayOptions = {}): void {
/** Move the open day by `delta` calendar days, without a new history entry. */
export function stepDay(from: DateKey, delta: number): DateKey {
const next = shiftDateKey(from, delta);
jumpDay(next);
return next;
}
/**
* Swap the open day for `next` (a day picked from the header's calendar),
* the way a step does: no new history entry, so one Back still leaves.
*/
export function jumpDay(next: DateKey): void {
memory.focus = null;
emit();
const { pathname, search } = currentLocation();
@@ -169,7 +178,27 @@ export function stepDay(from: DateKey, delta: number): DateKey {
"",
withDayHref(pathname, search, next),
);
return next;
}
/**
* Set while the docked day collapses to make room for another panel (the
* Coach's conversations below 1600 px): focus then stays with the control
* that asked for the room instead of moving to the day's edge.
*/
let yielding = false;
/** Collapse the docked day to its edge for a neighbouring panel. */
export function yieldDay(): void {
yielding = true;
memory.trigger = null;
closeDay();
}
/** Read and clear the flag `yieldDay` set. */
export function takeDayYield(): boolean {
const was = yielding;
yielding = false;
return was;
}
/**
+24 -7
View File
@@ -1,6 +1,6 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import {
Suspense,
useCallback,
@@ -37,11 +37,13 @@ import { cn } from "@/lib/utils";
import {
closeDay,
jumpDay,
openDay,
peekDayTriggerAt,
publishOpenDay,
stepDay,
takeDayTrigger,
takeDayYield,
useDayFocus,
} from "./day-layer-controller";
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
@@ -72,8 +74,10 @@ import { useTodayKey } from "./use-today-key";
* the chart stays in view above it; dragging the handle up (or tapping
* it) takes it to full height, dragging it down closes it.
*
* On `/coach` the conversations panel already holds the side column, and
* there is never more than one docked panel: the day opens as a sheet there.
* On `/coach` the day docks too, right of the Coach's conversations: page,
* conversations, day. Below 1600 px only one of the two is open at a time;
* opening the day folds the conversations to their edge, and opening the
* conversations folds the day to its edge (`yieldDay`).
*
* The parameter is the state. A date that is not a calendar date, or lies in
* the future, is removed from the URL without a word. Collapsing the docked
@@ -121,7 +125,6 @@ function modalSurfaceOpen(): boolean {
function DayLayer() {
const searchParams = useSearchParams();
const pathname = usePathname();
const today = useTodayKey();
const raw = searchParams.get(DAY_QUERY_PARAM);
const date = parseDayParam(raw, today);
@@ -157,7 +160,7 @@ function DayLayer() {
const phone = useIsMobile();
const shell: "docked" | "sheet" | "bottom" = phone
? "bottom"
: wide && !pathname.startsWith("/coach")
: wide
? "docked"
: "sheet";
@@ -206,6 +209,11 @@ function DayLayer() {
return;
}
if (before !== null && shell === "docked") {
// Collapsed for a neighbouring panel: focus stays where it was.
if (takeDayYield()) {
collapsing.current = false;
return;
}
const rail = expandRef.current;
if (collapsing.current && rail) {
takeDayTrigger();
@@ -226,6 +234,14 @@ function DayLayer() {
[date, today],
);
const onPick = useCallback(
(next: DateKey) => {
if (next > today) return;
jumpDay(next);
},
[today],
);
// Keyboard: Escape closes the docked column (a sheet's own Escape is the
// dialog's), Alt with an arrow steps a day. Bare arrows stay with scrolling.
useEffect(() => {
@@ -285,7 +301,7 @@ function DayLayer() {
<div
data-slot="day-rail"
data-day={railDay}
className="bg-card text-card-foreground border-border flex h-full w-12 shrink-0 flex-col border-l"
className="bg-card text-card-foreground border-border order-2 flex h-full w-12 shrink-0 flex-col border-l"
>
{/* The top bar's band, so the two bottom borders draw one line;
the control sits where the open day keeps its own. */}
@@ -343,6 +359,7 @@ function DayLayer() {
shell={shell}
onClose={shell === "docked" ? collapse : closeDay}
onStep={onStep}
onPick={onPick}
Title={Title}
titleId={titleId}
titleRef={titleRef}
@@ -363,7 +380,7 @@ function DayLayer() {
// A short fade, not a slide: the column takes its width at once
// (the page beside it reflows in the same frame either way), so
// only its content eases in. None under reduced motion.
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
>
{view(DockedTitle, {
// No status-bar inset here: the docked column sits inside
+60 -25
View File
@@ -23,7 +23,9 @@ import {
type LucideIcon,
} from "lucide-react";
import { TagChip } from "@/components/ui/tag-chip";
import {
type DateKey,
type DayEvent,
type DayEventKind,
type DayNotable,
@@ -37,6 +39,7 @@ import {
LIFE_EVENT_CATEGORY_KEY,
keyOf,
} from "@/components/timeline/label-keys";
import { resolveIntlLocale } from "@/lib/format-locale";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { MOOD_LABEL_KEYS } from "@/lib/mood/labels";
import { cn } from "@/lib/utils";
@@ -154,7 +157,7 @@ export function DayNotableLines({
<Sparkles className="mt-px size-3.5 shrink-0" aria-hidden="true" />
<span>
{withMetric && notable.type
? `${labelFor(tileKeyOf(notable.type))} · `
? `${labelFor(tileKeyOf(notable.type))}: `
: null}
{text(notable)}
</span>
@@ -289,33 +292,60 @@ export function dayMeta(
}
}
export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
const { t } = useTranslations();
const fmt = useFormatters();
/** Running kinds whose `sub` is a dose and reads as part of the name. */
const DOSE_SUB_KINDS: ReadonlySet<DayRunningKind> = new Set([
"medication",
"medicationCourse",
]);
export function DayRunning({
items,
date,
}: {
items: readonly DayRunningItem[];
/** The viewed day; a start date in another year names its year. */
date: DateKey;
}) {
const { t, locale } = useTranslations();
if (items.length === 0) return null;
const viewedYear = date.slice(0, 4);
const intlLocale = resolveIntlLocale(locale);
const sinceText = (since: string): string =>
new Intl.DateTimeFormat(intlLocale, {
day: "numeric",
month: "long",
...(since.slice(0, 4) === viewedYear ? {} : { year: "numeric" }),
timeZone: "UTC",
}).format(new Date(`${since}T12:00:00.000Z`));
return (
<section className="space-y-2" data-slot="day-running">
<h3 className={DAY_SECTION_LABEL}>{t("day.groups.running")}</h3>
<ul>
{items.map((item) => {
const cycleLine = cycleRunningLine(item, t);
const parts = (
const title = dayTitle(item.kind, item.title, t);
const doseInName = item.sub !== null && DOSE_SUB_KINDS.has(item.kind);
const name = doseInName ? `${title} ${item.sub}` : title;
const chip =
cycleLine === null && item.sub !== null && !doseInName
? dayMeta(item.kind, item.sub, t)
: null;
const since =
item.dayCount === null && item.since !== null
? sinceText(item.since)
: null;
const detail =
cycleLine !== null
? [cycleLine]
: [
item.sub === null ? null : dayMeta(item.kind, item.sub, t),
item.dayIndex !== null
? item.dayCount !== null
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
: t("day.dayN", { n: item.dayIndex })
: null,
item.dayCount === null && item.since !== null
? t("day.since", {
date: fmt.dateShortSmartCalendar(item.since),
})
: null,
]
).filter((part): part is string => !!part);
? cycleLine
: item.dayIndex !== null
? item.dayCount !== null
? t("day.dayOf", { n: item.dayIndex, m: item.dayCount })
: since !== null
? t("day.dayNSince", { n: item.dayIndex, date: since })
: t("day.dayN", { n: item.dayIndex })
: since !== null
? t("day.sinceDate", { date: since })
: null;
const body = (
<>
<span
@@ -324,12 +354,17 @@ export function DayRunning({ items }: { items: readonly DayRunningItem[] }) {
style={{ backgroundColor: RUNNING_COLOR[item.kind] }}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{dayTitle(item.kind, item.title, t)}
</span>
<span className="text-muted-foreground block truncate text-xs">
{parts.join(" · ")}
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-medium">
{name}
</span>
{chip ? <TagChip className="shrink-0">{chip}</TagChip> : null}
</span>
{detail ? (
<span className="text-muted-foreground block truncate text-xs">
{detail}
</span>
) : null}
</span>
{item.href ? (
<ChevronRight
+25 -11
View File
@@ -30,6 +30,7 @@ import { resolveIntlLocale } from "@/lib/format-locale";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { DayDatePicker } from "./day-date-picker";
import type { DayFocus } from "./day-layer-controller";
import {
DayEvents,
@@ -77,6 +78,8 @@ export interface DayViewProps {
shell: "docked" | "sheet" | "bottom";
onClose: () => void;
onStep: (delta: number) => void;
/** Open another day, picked from the header's calendar. */
onPick: (date: DateKey) => void;
/** The heading element: an `h2`, or the sheet primitive's title. */
Title: ComponentType<{
id?: string;
@@ -114,6 +117,7 @@ export function DayView({
shell,
onClose,
onStep,
onPick,
Title,
titleId,
titleRef,
@@ -178,7 +182,7 @@ export function DayView({
? [
tCount("day.countValues", data.counts.values),
tCount("day.countEntries", data.counts.entries),
].join(" · ")
].join(", ")
: null;
const compact = shell === "bottom";
const closeLabel = shell === "docked" ? t("day.hidePanel") : t("day.close");
@@ -230,14 +234,24 @@ export function DayView({
tabIndex={-1}
className="min-w-0 flex-1 truncate px-1 text-base leading-snug font-semibold focus-visible:outline-none"
>
{compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)}
{/* The date is the way to any other day: a calendar opens from
it, so the header needs no icon of its own. */}
<DayDatePicker
date={date}
today={today}
onPick={onPick}
variant={compact ? "sheet" : "popover"}
label={
compact ? (
<>
<span aria-hidden="true">{shortLabel(date)}</span>
<span className="sr-only">{longLabel(date)}</span>
</>
) : (
longLabel(date)
)
}
/>
</Title>
<Button
type="button"
@@ -291,7 +305,7 @@ export function DayView({
className="text-muted-foreground -mb-3 text-xs tabular-nums"
data-slot="day-meta"
>
{meta ?? <span className="invisible">·</span>}
{meta ?? <span className="invisible">0</span>}
</p>
{focus ? (
<div className="space-y-2.5">
@@ -321,7 +335,7 @@ export function DayView({
</p>
) : (
<>
<DayRunning items={data.running} />
<DayRunning items={data.running} date={date} />
{data.values.length > 0 ? (
<div className="space-y-2.5">
<DayValues values={data.values} focusTypes={focusTypes} />
@@ -518,13 +518,13 @@ describe("buildProcedureChoices", () => {
{
encounterId: "enc-knee",
name: "Knee arthroscopy",
detail: "Knee (left) · 2025-10-02",
detail: "Knee (left), 2025-10-02",
},
{ encounterId: "enc-2", name: "Day clinic", detail: "2025-10-02" },
{
encounterId: "enc-3",
name: "Procedure or surgery",
detail: "Knee · 2025-10-02",
detail: "Knee, 2025-10-02",
},
]);
});
+2 -2
View File
@@ -198,8 +198,8 @@ export function DocumentCard({
</DayLinkStated>
) : (
date
)}{" "}
· {size}
)}
, {size}
</p>
{showFilename ? (
// Filename on its OWN muted line so it never clips the date/size
@@ -869,7 +869,7 @@ export function DocumentDetailSheet({
{doc.filename ?? title}
</p>
<p className="text-muted-foreground text-xs">
{t("documents.detail.previewUnavailable")} ·{" "}
{t("documents.detail.previewUnavailable")},{" "}
{formatBytes(doc.byteSize, locale)}
</p>
<DocumentDownloadButton
@@ -1185,8 +1185,15 @@ export function DocumentDetailSheet({
date: format.date(doc.createdAt),
size: formatBytes(doc.byteSize, locale),
})}
{doc.filename ? ` · ${doc.filename}` : ""}
</p>
{doc.filename ? (
<p
className="text-muted-foreground truncate text-xs"
title={doc.filename}
>
{doc.filename}
</p>
) : null}
{/* Where an imported document came from (#1038). Meta, so
muted like the line above; the id is what the person looks
@@ -80,7 +80,7 @@ function factHeadline(fact: ExtractedFactDto): string {
return (fact.data as ConditionFactData).label;
}
const data = fact.data as MedicationStatementFactData;
return data.dose ? `${data.name} · ${data.dose}` : data.name;
return data.dose ? `${data.name} ${data.dose}` : data.name;
}
/** The date the fact states, if any (YYYY-MM-DD). */
@@ -262,18 +262,16 @@ function FactRow({
</p>
<p className="text-muted-foreground text-xs">
{typeLabel}
{date ? ` · ${calendarDate(date)}` : ""}
{isObservation &&
(fact.data as ObservationFactData).referenceText ? (
<>
{" · "}
{t("documents.review.statedRange", {
range: (fact.data as ObservationFactData)
.referenceText as string,
})}
</>
) : null}
{date ? `, ${calendarDate(date)}` : ""}
</p>
{isObservation && (fact.data as ObservationFactData).referenceText ? (
<p className="text-muted-foreground text-xs break-words">
{t("documents.review.statedRange", {
range: (fact.data as ObservationFactData)
.referenceText as string,
})}
</p>
) : null}
{fact.provenance.anchored && fact.provenance.sourceText ? (
<p className="text-muted-foreground text-xs break-words italic">
“{fact.provenance.sourceText}”
+1 -1
View File
@@ -527,7 +527,7 @@ export function DocumentsView() {
label: `${
visit.practitioner?.name ??
encounterKindText(t, visit.kind as EncounterKind)
} · ${format.date(visit.occurredAt)}`,
}, ${format.date(visit.occurredAt)}`,
})),
[visits.data, t, format],
);
@@ -588,7 +588,7 @@ export function DocumentSourcePicker({
: null,
]
.filter(Boolean)
.join(" · ");
.join(", ");
return (
<li key={row.sourceId}>
<button
+1 -1
View File
@@ -546,7 +546,7 @@ export function buildProcedureChoices(
procedure.reason?.trim() ||
procedure.practitionerName?.trim() ||
labels.kindName,
detail: site ? `${site} · ${date}` : date,
detail: site ? `${site}, ${date}` : date,
};
});
}
@@ -138,7 +138,7 @@ describe("<ProceduresSection>", () => {
html.indexOf('data-encounter-id="older"'),
);
expect(html).toContain("Meniscus repair");
expect(html).toContain("Knee · Left");
expect(html).toContain("Knee (Left)");
expect(html).toContain("2 of 2");
});
@@ -182,7 +182,7 @@ describe("<ProceduresSection>", () => {
/data-slot="procedures-site-all"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="procedures-site-all"/,
);
expect(html).toMatch(
/aria-pressed="false"[^>]*>Knee · Left|data-slot="procedures-site"[^>]*aria-pressed="false"/,
/aria-pressed="false"[^>]*>Knee \(Left\)|data-slot="procedures-site"[^>]*aria-pressed="false"/,
);
});
@@ -38,7 +38,7 @@ const okResult: CorrelationResult = {
pValue: 0.001,
confidenceBand: { low: -0.85, high: -0.39, label: "high" },
interpretation:
"Higher medication compliance lines up with lower systolic readings — a pattern worth watching.",
"Higher medication compliance lines up with lower systolic readings, a pattern worth watching.",
points: [
{ x: 60, y: 140 },
{ x: 70, y: 135 },
@@ -31,12 +31,17 @@ describe("<CoachHero>", () => {
expect(html).toContain("Frage mich etwas zu deinen Daten");
});
it("does not render starter-question suggestion chips", () => {
// v1.18.10 (W4) — the two starter chips below the composer were
// removed; the hero is greeting + composer only.
const html = render(<CoachHero composer={null} />);
it("leaves nothing under the composer", () => {
// No starter chips, no seeded opener, no scope pill: the empty
// conversation is the greeting and the field.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).not.toContain('data-slot="coach-hero-chips"');
expect(html).not.toContain('data-slot="coach-hero-chip"');
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
expect(html).not.toContain("coach-scope-hint");
const afterComposer = html.split('data-slot="test-composer"')[1] ?? "";
expect(afterComposer.replace(/<\/div>|composer|>/g, "")).toBe("");
});
});
@@ -0,0 +1,114 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { BOTH_PANELS_QUERY, listYields } from "../conversations-panel";
import { NewChatFab } from "../new-chat-fab";
/**
* The Coach page holds two docked panels beside the conversation: the
* conversations and, right of them, the day. Below 1600 px only one is open.
* The list folds when a day opens or the window narrows with both open; it
* never folds again right after the person opened it beside a day (that
* folds the day instead). The click paths and focus run in
* `e2e/coach-layout.spec.ts`.
*/
const base = {
docked: true,
fitsBoth: false,
listOpen: true,
dayOpen: true,
dayOpened: false,
narrowed: false,
};
describe("listYields", () => {
it("folds the list when a day opens below 1600 px", () => {
expect(listYields({ ...base, dayOpened: true })).toBe(true);
});
it("folds the list when the window narrows with both open", () => {
expect(listYields({ ...base, narrowed: true })).toBe(true);
});
it("keeps both open from 1600 px", () => {
expect(listYields({ ...base, fitsBoth: true, dayOpened: true })).toBe(
false,
);
});
it("leaves a list the person opened beside a day alone", () => {
// No opening and no narrowing: the day is the one that gives way.
expect(listYields(base)).toBe(false);
});
it("does nothing for a folded list, a closed day, or the sheet", () => {
expect(listYields({ ...base, dayOpened: true, listOpen: false })).toBe(
false,
);
expect(listYields({ ...base, narrowed: true, dayOpen: false })).toBe(false);
expect(listYields({ ...base, dayOpened: true, docked: false })).toBe(false);
});
it("splits at 1600 px", () => {
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
});
});
describe("the folded list", () => {
const source = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/conversations-panel.tsx",
),
"utf8",
);
it("keeps an edge with its button, and sits left of the day", () => {
expect(source).toContain('data-slot="coach-panel-rail"');
expect(source).toContain('data-slot="coach-panel-expand"');
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
expect(source).toMatch(/order-1/);
});
it("no longer starts a conversation from the list's header", () => {
expect(source).not.toContain("coach-panel-new-chat");
});
});
describe("<NewChatFab>", () => {
it("is the Coach button's round face with a plus, named New conversation", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<NewChatFab onNewChat={() => undefined} />
</I18nProvider>,
);
const button = html.match(/<button[^>]*>/)?.[0] ?? "";
expect(button).toContain('data-slot="coach-new-chat-fab"');
expect(button).toContain('aria-label="New conversation"');
expect(button).toContain("size-14");
expect(button).toContain("rounded-full");
expect(button).toContain("from-primary");
// Above the composer by default, beside it where the column is wide.
expect(button).toContain("bottom-full");
expect(button).toContain("@min-[54rem]/composer:bottom-8");
expect(html).toContain("lucide-plus");
});
});
describe("coachPageHref", () => {
it("keeps an open day when the conversation changes", async () => {
const { coachPageHref } = await import("../coach-conversation");
expect(coachPageHref("c1")).toBe("/coach?c=c1");
expect(coachPageHref(null)).toBe("/coach");
expect(coachPageHref("c1", "?c=c0&day=2026-09-24")).toBe(
"/coach?c=c1&day=2026-09-24",
);
expect(coachPageHref(null, "?day=2026-09-24&c=c0")).toBe(
"/coach?day=2026-09-24",
);
});
});
@@ -255,7 +255,7 @@ describe("<MessageThread>", () => {
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-turn-steps-done"');
expect(html).toContain("Thought process · 1 step");
expect(html).toContain("Thought process (1 step)");
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain("insights.coach.metric.");
expect(html).not.toContain("n=26");
@@ -479,7 +479,7 @@ describe("<MessageThread>", () => {
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain("138 mmHg");
// The areas the answer drew on stay reachable from the row.
expect(html).toContain("Thought process · 1 step");
expect(html).toContain("Thought process (1 step)");
});
it("names the thought-process row in German under 'de'", () => {
@@ -487,7 +487,7 @@ describe("<MessageThread>", () => {
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain("Denkprozess · 1 Schritt");
expect(html).toContain("Denkprozess (1 Schritt)");
expect(html).not.toContain("Worauf bezieht sich das?");
});
@@ -1,177 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { ScopeHintBadge } from "../scope-hint-badge";
import { CoachHero } from "../coach-hero";
import { metricScopeLabelFallback } from "@/components/insights/coach-metric-scope";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/**
* v1.21.2 (A2 + A3) — the visible scope/opener affordance.
*
* The component renders in the `node` test environment via static markup, so
* the tests assert the structural slots + the caller-resolved label / question
* (those are passed in already-localised, not looked up in the bundle). The
* prefix/tap copy resolves through the live i18n bundle.
*/
describe("<ScopeHintBadge>", () => {
it("renders the scope pill with the active metric and the tappable seed", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint"');
expect(html).toContain('data-variant="scope"');
// The metric label is surfaced in the visible pill.
expect(html).toContain('data-slot="coach-scope-hint-pill"');
expect(html).toContain("blood pressure");
// The seed question is a real button so it is keyboard reachable.
expect(html).toContain('data-slot="coach-scope-hint-seed"');
expect(html).toContain("Walk me through my blood pressure trend.");
});
it("renders the seeded variant for a notable derived signal", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).toContain('data-variant="seeded"');
expect(html).toContain("readiness");
expect(html).toContain("Why is my readiness lower today?");
});
it("invokes onSeed with the question when the seed button is activated", () => {
// The button's onClick is wired to `onSeed(question)`; assert the binding
// directly (the node test env has no DOM event loop to click through).
const onSeed = vi.fn();
const question = "Walk me through my pulse readings.";
// Render once to prove no throw, then exercise the handler contract.
render(
<ScopeHintBadge
variant="scope"
label="pulse"
question={question}
onSeed={onSeed}
/>,
);
onSeed(question);
expect(onSeed).toHaveBeenCalledWith(question);
});
// v1.21.4 (C2) — the seeded opener carries a dismiss control so the user can
// wave off today's suggestion. The control is gated to the seeded variant.
it("renders the dismiss control for the seeded variant when onDismiss is given", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-scope-hint-dismiss"');
});
it("omits the dismiss control for the seeded variant without onDismiss", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
/>,
);
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
});
it("never renders the dismiss control for the non-dismissable scope pill", () => {
const html = render(
<ScopeHintBadge
variant="scope"
label="blood pressure"
question="Walk me through my blood pressure trend."
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
expect(html).not.toContain('data-slot="coach-scope-hint-dismiss"');
});
it("sizes the seeded opener to its text and keeps it single-line", () => {
const html = render(
<ScopeHintBadge
variant="seeded"
label="readiness"
question="Why is my readiness lower today?"
onSeed={() => {}}
onDismiss={() => {}}
/>,
);
const seedTag = html.match(
/<button[^>]*data-slot="coach-scope-hint-seed"[^>]*>/,
);
expect(seedTag).not.toBeNull();
// v1.25.0 — intrinsic width, not stretched to the composer column: the
// opener grows to its text and caps at the full width. The old narrow cap
// and the full-width stretch are both gone.
expect(html).not.toContain("max-w-md");
// Intrinsic width capped at the column: `inline-flex` + `max-w-full`, not
// the old `flex-1` / `w-full` full-width stretch.
expect(seedTag?.[0]).toContain("inline-flex");
expect(seedTag?.[0]).toContain("max-w-full");
expect(seedTag?.[0]).not.toMatch(/\bflex-1\b/);
// The question span truncates with an ellipsis rather than wrapping.
expect(html).toContain("truncate");
});
});
describe("<CoachHero> scope hint slot", () => {
it("renders the scope hint above-composer slot when provided", () => {
const html = render(
<CoachHero
composer={<div data-slot="test-composer">composer</div>}
scopeHint={<div data-slot="injected-hint">hint</div>}
/>,
);
expect(html).toContain('data-slot="coach-hero-scope-hint"');
expect(html).toContain('data-slot="injected-hint"');
});
it("falls back to the neutral hero when no scope hint is given", () => {
// A3 fallback: no signal crossed the gate (scopeHint null), so the hero
// is the calm greeting + composer with NO opener affordance.
const html = render(
<CoachHero composer={<div data-slot="test-composer">composer</div>} />,
);
expect(html).toContain('data-slot="coach-hero"');
expect(html).toContain("Ask me anything about your data");
expect(html).not.toContain('data-slot="coach-hero-scope-hint"');
});
});
describe("metricScopeLabelFallback", () => {
it("maps a scope source to its brand-free domain label", () => {
expect(metricScopeLabelFallback("bp")).toBe("blood pressure");
expect(metricScopeLabelFallback("resting_hr")).toBe("resting heart rate");
});
it("returns null for an absent metric (a generic open)", () => {
expect(metricScopeLabelFallback(null)).toBeNull();
expect(metricScopeLabelFallback(undefined)).toBeNull();
});
});
@@ -413,7 +413,7 @@ describe("after the answer", () => {
);
expect(html).toContain('data-state="done"');
expect(html).toMatch(
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
/data-slot="coach-turn-steps-done"[^>]*>Thought process \(5 steps\)</,
);
expect(html).not.toContain("text-shimmer");
expect(html).not.toContain("waiting-dot");
@@ -428,7 +428,7 @@ describe("after the answer", () => {
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
"de",
);
expect(de).toContain("Denkprozess · 1 Schritt<");
expect(de).toContain("Denkprozess (1 Schritt)<");
const pl = render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
@@ -437,7 +437,7 @@ describe("after the answer", () => {
/>,
"pl",
);
expect(pl).toContain("Tok myślenia · 3 kroki<");
expect(pl).toContain("Tok myślenia (3 kroki)<");
});
it("an older message counts its steps, or the areas it drew on", () => {
@@ -445,7 +445,7 @@ describe("after the answer", () => {
render(
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
),
).toContain("Thought process · 2 steps<");
).toContain("Thought process (2 steps)<");
expect(
render(
<CoachTurnActivity
@@ -455,7 +455,7 @@ describe("after the answer", () => {
areas={["bp"]}
/>,
),
).toContain("Thought process · 1 step<");
).toContain("Thought process (1 step)<");
});
it("says an answer is needed while a question waits, without shimmer", () => {
@@ -681,7 +681,7 @@ describe("the visible row text", () => {
),
),
),
).toBe("Thought process · 5 steps");
).toBe("Thought process (5 steps)");
expect(
textOf(
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
@@ -7,18 +7,17 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Paperclip, Plus, Sparkles } from "lucide-react";
import { Paperclip, Sparkles } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { DAY_QUERY_PARAM } from "@/lib/day/contract";
import { queryKeys } from "@/lib/query-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
import { MAX_COACH_ATTACHMENTS } from "@/lib/validations/inbound-documents";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
@@ -30,7 +29,6 @@ import {
import { CoachDrawerBody } from "./coach-drawer-body";
import { CoachHero } from "./coach-hero";
import { ScopeHintBadge } from "./scope-hint-badge";
import { CoachInput } from "./coach-input";
import {
GuidedQuestionBubble,
@@ -53,12 +51,12 @@ import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
import { MobileRailTray } from "./mobile-rail-tray";
import { NewChatFab } from "./new-chat-fab";
import { SelfContextAdoptOffer } from "./self-context-adopt-offer";
import { SourcesRail } from "./sources-rail";
import { AttachmentPills, type AttachmentPillItem } from "./attachment-pills";
import { AttachmentPicker } from "./attachment-picker";
import { useResettableValue } from "./use-resettable-value";
import { useCoachAmbientSuggestionsEnabled } from "@/hooks/use-coach-ambient-suggestions";
import {
useAttachCoachDocument,
useCoachConversation,
@@ -127,19 +125,6 @@ export function dropInvalidStagedAttachments(
return pendingAttachmentIds.filter((_, i) => isErrorByIndex[i] === true);
}
/**
* v1.21.4 (C2) — the localStorage key that records the seeded "worth a look"
* opener as dismissed for a given LOCAL calendar day. Date-stamped so the
* dismissal resets at midnight: a new day mints a new key the flag has not
* been written under yet, and the opener returns.
*/
function seededDismissStorageKey(now: Date = new Date()): string {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `coach-seeded-dismissed:${year}-${month}-${day}`;
}
export interface CoachConversationProps {
/**
* Pre-fill for the composer (suggested-prompt chip click). Resets the
@@ -269,10 +254,18 @@ export interface CoachConversationProps {
* `useSearchParams` in step with it) so a reload lands on the same thread
* without a server round trip or a new history entry per switch.
*/
function coachPageHref(conversationId: string | null): string {
return conversationId
export function coachPageHref(
conversationId: string | null,
search = "",
): string {
const base = conversationId
? `/coach?c=${encodeURIComponent(conversationId)}`
: "/coach";
// An open day stays open: the day docked beside the conversation is the
// URL's `?day=`, and switching conversations must not close it.
const day = new URLSearchParams(search).get(DAY_QUERY_PARAM);
if (!day) return base;
return `${base}${conversationId ? "&" : "?"}${DAY_QUERY_PARAM}=${encodeURIComponent(day)}`;
}
export function CoachConversation({
@@ -372,7 +365,7 @@ export function CoachConversation({
// a shared tab) lands on it. The drawer has no URL of its own.
function syncPageUrl(conversationId: string | null) {
if (surface !== "page" || typeof window === "undefined") return;
const href = coachPageHref(conversationId);
const href = coachPageHref(conversationId, window.location.search);
if (window.location.pathname + window.location.search === href) return;
window.history.replaceState(window.history.state, "", href);
}
@@ -973,62 +966,16 @@ export function CoachConversation({
pendingQuestions.length === 0 &&
!pendingAdopt;
// v1.21.2 (A2 + A3) — the visible scope/opener affordance for the hero.
//
// A2 (scoped launch): the Coach was opened narrowed to a metric. Make it
// visible — a "the Coach is already on <metric>" pill plus the data-aware
// seed question (the launch prefill) the user can tap into the composer —
// instead of the old hidden prefill.
//
// A3 (unscoped launch): no launch scope, so resolve today's single most
// notable derived signal server-side and offer it as a tappable opener. The
// query only fires when the hero is on screen AND there is no launch scope
// (an A2 launch already has its opener), so a scoped open pays nothing for
// it. When the server returns no signal the hint is null and the neutral
// greeting stands — never a fabricated opener.
// Something to leave: a stored conversation or a turn on its way.
const threadStarted =
currentConversationId !== null ||
send.isStreaming ||
!!send.optimisticUser ||
!!send.streaming.content;
// The launch scope, for the drawer's sources rail: a conversation opened
// from a metric surface names the metric there until its first turn.
const a2Metric = launchScope?.metric ?? null;
// v1.21.4 (C2) — once the user dismisses today's seeded opener it stays gone
// for the rest of the local calendar day; the fetch is skipped too, so a
// dismissed day costs nothing. SSR-safe lazy init guards `window`.
const [seededDismissedToday, setSeededDismissedToday] = useState(() => {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(seededDismissStorageKey()) === "1";
} catch {
return false;
}
});
// v1.25.0 — the per-user opt-out for proactive ambient suggestions gates the
// seeded opener (and skips its fetch when off). The A2 launch scope below is
// NOT ambient — it reflects how the chat was opened — so it stays unaffected.
const ambientSuggestionsEnabled = useCoachAmbientSuggestionsEnabled();
const seededEnabled =
heroActive &&
a2Metric === null &&
!seededDismissedToday &&
ambientSuggestionsEnabled;
const { data: seeded } = useQuery({
queryKey: queryKeys.coachSeededQuestion(),
queryFn: async () =>
apiGet<CoachSeededQuestionDTO>("/api/insights/coach/seeded-question"),
enabled: seededEnabled,
staleTime: 5 * 60 * 1000,
});
function seedComposer(question: string) {
setInputValue(question);
}
function dismissSeeded() {
setSeededDismissedToday(true);
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(seededDismissStorageKey(), "1");
} catch {
// Storage can throw (private mode / quota); the in-memory flag still
// hides the opener for this session.
}
}
// `t()` returns the key string itself when a key is missing, so the only
// reliable "is this key defined" signal is `t(key) !== key`.
@@ -1037,10 +984,9 @@ export function CoachConversation({
return resolved === key ? null : resolved;
}
// The resolved A2 metric label: prefers a per-source i18n key, falls back
// to the brand-free English domain phrase so every source has a label.
// Hoisted so both the hero hint (page) and the sources rail (drawer) read
// the same string. Null when the launch carried no metric.
// The resolved metric label: prefers a per-source i18n key, falls back to
// the brand-free English domain phrase so every source has a label. Null
// when the launch carried no metric.
const a2LabelKey = scopeSourceMetricLabelKey(a2Metric);
const a2MetricLabel = a2Metric
? (tOrNull(`insights.coach.scope.metric.${a2Metric}`) ??
@@ -1049,51 +995,6 @@ export function CoachConversation({
a2Metric)
: null;
let scopeHint: React.ReactNode = null;
if (fenced) {
// v1.29.x (S7) — a fenced conversation has NO health snapshot to scope, so
// the A2/A3 "the Coach is already on <metric>" openers are suppressed. The
// honest fencing banner (below) is the only chrome the fenced hero shows.
scopeHint = null;
} else if (a2Metric && a2MetricLabel) {
// A2 — the launch prefill IS the data-aware opener; fall back to the
// generic per-metric question when the launch carried no prefill.
const seedQuestion =
(prefill ?? "").trim() || t("insights.coach.scope.question");
scopeHint = (
<ScopeHintBadge
variant="scope"
label={a2MetricLabel}
question={seedQuestion}
onSeed={seedComposer}
/>
);
} else if (
seeded?.signal &&
!seededDismissedToday &&
ambientSuggestionsEnabled
) {
// A3 — the notable derived signal. The label + opener are keyed on the
// signal's sourceMetric (`readiness` / `recovery`); an unknown sentinel
// (future detector additions) skips the opener rather than guessing.
const sentinel = seeded.signal.sourceMetric;
const signalLabel = tOrNull(`insights.coach.seeded.signal.${sentinel}`);
const signalQuestion = tOrNull(
`insights.coach.seeded.question.${sentinel}`,
);
if (signalLabel && signalQuestion) {
scopeHint = (
<ScopeHintBadge
variant="seeded"
label={signalLabel}
question={signalQuestion}
onSeed={seedComposer}
onDismiss={dismissSeeded}
/>
);
}
}
// v1.18.11 (W11) — the docked composer column: the quiet adopt offer and
// the guided-questions entry card stack above the live composer. Shared by
// the drawer body (via `CoachDrawerBody`) and the page surface (rendered
@@ -1218,7 +1119,7 @@ export function CoachConversation({
/>
{!heroActive ? <h1 className="sr-only">{title}</h1> : null}
{heroActive ? (
<CoachHero composer={composerNode} scopeHint={scopeHint} />
<CoachHero composer={composerNode} />
) : (
<>
<div className="flex min-h-0 flex-1 flex-col">
@@ -1234,9 +1135,10 @@ export function CoachConversation({
</div>
<div
data-slot="coach-page-composer"
className="shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
className="@container/composer relative shrink-0 px-4 pt-2 pb-3 sm:px-6 sm:pb-4"
>
<div className="mx-auto w-full max-w-2xl">{composerStack}</div>
<NewChatFab onNewChat={handleNewChat} />
</div>
</>
)}
@@ -1300,18 +1202,6 @@ export function CoachConversation({
<p className="text-muted-foreground truncate text-xs">{tagline}</p>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleNewChat}
data-slot="coach-new-chat"
aria-label={t("insights.coach.newChat")}
title={t("insights.coach.newChat")}
className="text-muted-foreground hover:text-foreground size-11 shrink-0"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
{/* The same quick settings the page's panel opens: model and what
the Coach may read. Everything else stays in Settings → AI. */}
<CoachSettingsOverlay
@@ -1342,6 +1232,11 @@ export function CoachConversation({
/>
}
composer={composerStack}
// New conversation floats in the conversation itself, once there is
// one to leave.
newChat={
threadStarted ? <NewChatFab onNewChat={handleNewChat} /> : null
}
onHistoryClick={onRequestFullView ?? (() => setHistoryTrayOpen(true))}
onOpenSourcesTray={() => setSourcesTrayOpen(true)}
/>
@@ -42,6 +42,8 @@ import { useTranslations } from "@/lib/i18n/context";
export interface CoachDrawerBodyProps {
thread: React.ReactNode;
composer: React.ReactNode;
/** The New conversation button, floating above the composer. */
newChat?: React.ReactNode;
/**
* Inline conversation list (page surface only). When set, it renders
* as a collapsible left column on lg+ and the history button collapses
@@ -67,6 +69,7 @@ export interface CoachDrawerBodyProps {
export function CoachDrawerBody({
thread,
composer,
newChat,
historyRail,
historyOpen = false,
onToggleHistory,
@@ -234,9 +237,10 @@ export function CoachDrawerBody({
{/* Composer pinned to the bottom. */}
<div
data-slot="coach-drawer-composer"
className="border-border/70 flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
className="border-border/70 @container/composer relative flex flex-col gap-2 border-t p-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:p-4 sm:pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]"
>
{composer}
{newChat}
</div>
</main>
</div>
@@ -28,25 +28,15 @@ import { useTranslations } from "@/lib/i18n/context";
* in via `motion-safe` `animate-in` with capped delays; reduced-motion
* users get the layout instantly.
*
* v1.21.2 (A2 + A3) — the hero accepts an optional `scopeHint` slot
* (a `<ScopeHintBadge>`): the visible "the Coach is already on <metric>"
* pill + seed question when launched scoped (A2), or the pre-seeded
* notable-signal opener when launched unscoped (A3). When absent the hero
* is the calm greeting + composer exactly as before, so the neutral
* blank-state fallback is structural.
* The space under the composer stays empty: no suggested prompts and no
* openers, so an empty conversation is the greeting and the field.
*/
export interface CoachHeroProps {
/** The live `<CoachInput>` composer, re-parented into the hero. */
composer: React.ReactNode;
/**
* v1.21.2 — optional scope/opener affordance rendered above the
* composer. The parent owns the data (launch scope / seeded signal) and
* passes a resolved `<ScopeHintBadge>`; null keeps the neutral hero.
*/
scopeHint?: React.ReactNode;
}
export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
export function CoachHero({ composer }: CoachHeroProps) {
const { t } = useTranslations();
return (
@@ -104,23 +94,6 @@ export function CoachHero({ composer, scopeHint }: CoachHeroProps) {
>
{composer}
</div>
{/* v1.21.2 (A2/A3) — visible scope pill + tappable opener, beneath
the composer so the greeting → composer rhythm stays intact and
the opener reads as a follow-on suggestion. Renders only when the
parent resolved a scope or a notable signal. */}
{scopeHint ? (
<div
data-slot="coach-hero-scope-hint"
className={cn(
"w-full",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2 motion-safe:duration-500",
)}
style={{ animationDelay: "240ms", animationFillMode: "both" }}
>
{scopeHint}
</div>
) : null}
</div>
</div>
);
@@ -1,9 +1,10 @@
"use client";
import { useRef, useState, useSyncExternalStore } from "react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import Link from "next/link";
import { PanelRightClose, PanelRightOpen, Plus, Target, X } from "lucide-react";
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
import { Button } from "@/components/ui/button";
import {
Sheet,
@@ -42,18 +43,26 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
* Docked, the panel is a column of the shell beside the content column
* (top bar and page): it runs the full height of the window, and the top bar
* ends at its left edge. The panel's header row is the top bar's band, so
* the two bottom borders draw one line. Shut, it is gone entirely (no rail)
* and the top bar runs to the window's edge. The toggle is the last item of
* the top bar, so it sits against the open panel and at the window's edge
* when the panel is shut. The panel header carries the title, a link to
* Plans, the settings gear and, in the sheet, a close button. New chat
* opens the header row, left of the title: a fixed control that never sits
* over the list, so the last conversation needs no reserve below it.
* the two bottom borders draw one line. It folds like the day beside it: the
* header's first control folds it to a narrow edge, and the edge's button
* (in the same place) opens it again. Folded, the edge names the list.
* The header carries the title, a link to Plans, the settings gear and, in
* the sheet, a close button. New conversation is not here: it floats in the
* conversation itself (`NewChatFab`).
*
* Keyboard: Escape closes the docked panel when focus is inside it and
* The day docks right of the panel on this page too (page, conversations,
* day). Below 1600 px only one of the two is open: a day opening folds the
* list to its edge without touching the remembered choice, and opening the
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
*
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
* the top bar.
*
* Keyboard: Escape folds the docked panel when focus is inside it and
* nothing inside (a row menu, the rename field, the settings popover) has
* already claimed the key, then focus returns to the toggle. In the sheet
* Radix owns Escape, the focus trap and the return.
* already claimed the key; focus moves to the edge's button, and opening
* from there lands on the fold control. In the sheet Radix owns Escape, the
* focus trap and the return.
*/
export const COACH_PANEL_ID = "coach-conversations-panel";
@@ -68,6 +77,50 @@ const PANEL_HEADER_ICON = "size-5 pointer-fine:size-4";
const noSubscription = () => () => {};
/** Wide enough for the conversations and a day beside the conversation. */
export const BOTH_PANELS_QUERY = "(min-width: 1600px)";
function subscribeBothPanels(callback: () => void): () => void {
if (typeof window === "undefined" || !window.matchMedia) return () => {};
const mql = window.matchMedia(BOTH_PANELS_QUERY);
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
}
function useBothPanelsFit(): boolean {
return useSyncExternalStore(
subscribeBothPanels,
() =>
typeof window !== "undefined" && !!window.matchMedia
? window.matchMedia(BOTH_PANELS_QUERY).matches
: false,
() => false,
);
}
/**
* Whether the docked list gives way to an open day: it does when the day
* opens, or the window narrows, while both would be open below 1600 px.
* Only those two moments fold it, so opening the list again beside an open
* day (which folds the day) is never undone.
*/
export function listYields(state: {
docked: boolean;
fitsBoth: boolean;
listOpen: boolean;
dayOpened: boolean;
narrowed: boolean;
dayOpen: boolean;
}): boolean {
return (
state.docked &&
!state.fitsBoth &&
state.listOpen &&
state.dayOpen &&
(state.dayOpened || state.narrowed)
);
}
/**
* Whether the phone sheet is open. A settings deep link opens it only once
* the viewport is known: the server and hydration renders take the narrow
@@ -181,22 +234,62 @@ export function ConversationsPanel({
const [settingsOpen, setSettingsOpen] = useState(openSettingsOnData);
const [settingsOnData, setSettingsOnData] = useState(openSettingsOnData);
const dockedOpen = rememberedOpen || revealed;
const expanded = docked ? dockedOpen : sheetOpen;
function toggle() {
if (docked) {
setRevealed(false);
setOpen(!dockedOpen);
} else {
setSheetOpen(!sheetOpen);
// The day beside the list (docked on this page from 1280 px too).
const openDay = useOpenDay();
const fitsBoth = useBothPanelsFit();
// Folded for the day, without rewriting the remembered choice.
const [yielded, setYielded] = useState(false);
const dockedOpen = (rememberedOpen || revealed) && !yielded;
const [seen, setSeen] = useState<{ day: string | null; fitsBoth: boolean }>({
day: null,
fitsBoth,
});
if (seen.day !== openDay || seen.fitsBoth !== fitsBoth) {
setSeen({ day: openDay, fitsBoth });
if (
listYields({
docked,
fitsBoth,
listOpen: dockedOpen,
dayOpen: openDay !== null,
dayOpened: seen.day === null && openDay !== null,
narrowed: seen.fitsBoth && !fitsBoth,
})
) {
setYielded(true);
}
}
function closeDocked() {
const expanded = docked ? dockedOpen : sheetOpen;
const collapseRef = useRef<HTMLButtonElement>(null);
const expandRef = useRef<HTMLButtonElement>(null);
// Where focus goes once the docked panel has folded or opened.
const focusAfter = useRef<"collapse" | "expand" | null>(null);
useEffect(() => {
const target = focusAfter.current;
focusAfter.current = null;
if (target === "collapse") collapseRef.current?.focus();
if (target === "expand") expandRef.current?.focus();
}, [dockedOpen]);
function toggleSheet() {
setSheetOpen(!sheetOpen);
}
function expandDocked() {
focusAfter.current = "collapse";
setYielded(false);
setRevealed(false);
setOpen(true);
// Below 1600 px the day makes room: it folds to its edge.
if (!fitsBoth && openDay !== null) yieldDay();
}
function collapseDocked() {
focusAfter.current = "expand";
setYielded(false);
setRevealed(false);
setOpen(false);
toggleRef.current?.focus();
}
function afterPick() {
@@ -224,32 +317,31 @@ export function ConversationsPanel({
: cn("border-sidebar-border px-3", SHELL_HEADER_BAND),
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
onNewChat();
afterPick();
}}
data-slot="coach-panel-new-chat"
aria-label={t("insights.coach.newChat")}
// The panel's one action, so the primary wash marks it apart
// from the muted utility buttons on the right.
className={cn(
PANEL_HEADER_BUTTON,
"bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary",
)}
>
<Plus className={PANEL_HEADER_ICON} aria-hidden="true" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t("insights.coach.newChat")}
</TooltipContent>
</Tooltip>
{inSheet ? null : (
// A title, not a tooltip: focus lands here when the panel opens
// from its edge, and a tooltip opened by that focus would take the
// next Escape for itself.
<Button
ref={collapseRef}
type="button"
variant="ghost"
size="icon"
onClick={collapseDocked}
data-slot="coach-panel-collapse"
aria-controls={COACH_PANEL_ID}
aria-expanded={true}
aria-label={t("insights.coach.frame.hidePanel")}
title={t("insights.coach.frame.hidePanel")}
className={PANEL_HEADER_BUTTON}
>
{/* The day's own fold control and glyph, mirrored so it points
the way the panel goes. */}
<PanelRightClose
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
)}
{inSheet ? (
<SheetTitle className="min-w-0 flex-1 truncate text-lg leading-tight font-semibold">
{t("insights.coach.historyTitle")}
@@ -321,14 +413,16 @@ export function ConversationsPanel({
return (
<TooltipProvider delayDuration={300}>
<TopBarActions>
<PanelToggle
ref={toggleRef}
expanded={expanded}
label={toggleLabel}
onToggle={toggle}
/>
</TopBarActions>
{docked ? null : (
<TopBarActions>
<PanelToggle
ref={toggleRef}
expanded={expanded}
label={toggleLabel}
onToggle={toggleSheet}
/>
</TopBarActions>
)}
{docked ? (
<ShellSidePanel>
@@ -337,28 +431,81 @@ export function ConversationsPanel({
aria-label={t("insights.coach.historyTitle")}
data-slot="coach-conversations-panel"
data-state={dockedOpen ? "open" : "closed"}
inert={!dockedOpen}
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (
!dockedOpen ||
event.key !== "Escape" ||
event.defaultPrevented
) {
return;
}
event.preventDefault();
closeDocked();
collapseDocked();
}}
className={cn(
// A column of the shell's content row (`ShellSidePanel`), so it
// runs from the top of the window to the bottom beside the top
// bar. The width animates (offcanvas: shut, nothing of it
// shows); the inner column
// keeps 18rem and is pinned to the panel's left edge, so it
// slides out to the right instead of squeezing. The thread column
// keeps its width; only its centring moves.
"bg-sidebar text-sidebar-foreground relative h-full shrink-0 overflow-hidden",
// bar, left of a docked day (`order-1`, the day is `order-2`).
// The width animates between the open column and the folded
// edge; the open column keeps 18rem and is pinned to the
// panel's left edge, so it slides instead of squeezing.
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
dockedOpen ? "w-72" : "w-0",
dockedOpen ? "w-72" : "w-12",
)}
>
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
{dockedOpen ? (
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
{body(false)}
</div>
) : (
<div
data-slot="coach-panel-rail"
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
>
{/* The top bar's band, so the bottom borders draw one line;
the button sits where the open panel keeps its fold
control. */}
<div
className={cn(
SHELL_HEADER_BAND,
"border-sidebar-border flex shrink-0 items-center justify-center",
)}
>
<Button
ref={expandRef}
type="button"
variant="ghost"
size="icon"
onClick={expandDocked}
data-slot="coach-panel-expand"
aria-controls={COACH_PANEL_ID}
aria-expanded={false}
aria-label={t("insights.coach.frame.showPanel")}
title={t("insights.coach.frame.showPanel")}
className={PANEL_HEADER_BUTTON}
>
<PanelRightOpen
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
aria-hidden="true"
/>
</Button>
</div>
{/* The rest of the edge takes a click too and names the
list; the keyboard has the button above. */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={expandDocked}
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
>
<span className="[writing-mode:vertical-rl]">
{t("insights.coach.historyTitle")}
</span>
</button>
</div>
)}
</aside>
</ShellSidePanel>
) : (
@@ -0,0 +1,57 @@
"use client";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
/**
* New conversation, as the round button in the conversation's own window:
* the floating Coach button's size and look, with a plus.
*
* It sits in the composer's row, so it belongs to the conversation and never
* to the list beside it, and it moves wherever that column goes (a docked
* list or day narrows the column, the button stays inside it). Where the
* column is wide enough, it takes the bottom right corner beside the
* composer, where the Coach button sits on other pages; where the composer
* fills the width (a phone, the drawer, a narrow column), it rises just
* above the composer's top edge so it never covers the field.
*
* The parent is the composer's wrapper and must be `relative` and an
* `@container/composer`.
*/
export function NewChatFab({
onNewChat,
className,
}: {
onNewChat: () => void;
className?: string;
}) {
const { t } = useTranslations();
const label = t("insights.coach.newConversation");
return (
<Button
type="button"
size="icon"
data-slot="coach-new-chat-fab"
// Steps aside while the on-screen keyboard is open (globals.css).
data-keyboard-hide=""
onClick={onNewChat}
aria-label={label}
title={label}
className={cn(
"absolute right-4 bottom-full z-10 mb-3 size-14 rounded-full shadow-lg sm:right-6",
// Beside the capped composer, in the column's bottom right corner.
"@min-[54rem]/composer:right-8 @min-[54rem]/composer:bottom-8 @min-[54rem]/composer:mb-0",
// The Coach button's face: a dark glyph on the brand gradient.
"from-primary to-brand-pink text-background bg-gradient-to-br",
"hover:from-primary/90 hover:to-brand-pink/90",
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
className,
)}
>
<Plus className="text-background size-6" aria-hidden="true" />
</Button>
);
}
@@ -1,139 +0,0 @@
"use client";
import { Sparkles, Target, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.21.2 (A2 + A3) — the Coach's visible scope/opener affordance.
*
* Two modes, one surface:
*
* - **scope** (A2): the Coach was launched narrowed to a metric (from a
* metric page or an insight card). Instead of the old hidden composer
* prefill, render a small pill — "The Coach is already on <metric>" —
* plus a tappable seed question that drops the data-aware opener into
* the composer / sends it. This turns the built-but-invisible scope
* plumbing into the core ambient feeling.
*
* - **seeded** (A3): the Coach opened UNSCOPED (the blank hero) and the
* server surfaced today's single most notable derived signal. Render it
* as a tappable suggested opener so the blank box is never cold. When no
* signal crosses the gate the caller renders nothing and the neutral
* greeting stands — there is no fake opener.
*
* Presentational + self-contained: the parent supplies the resolved label
* and the seed question, and an `onSeed` callback wired to the composer.
* The badge owns no data fetching.
*/
export interface ScopeHintBadgeProps {
/**
* Which affordance this is. `scope` shows the "already on <metric>" pill;
* `seeded` shows the notable-signal opener. Drives the icon + the prefix
* copy only — the tappable seed question is shared.
*/
variant: "scope" | "seeded";
/**
* The visible metric / signal label, already resolved to the user's
* locale by the caller (e.g. "blood pressure", "readiness").
*/
label: string;
/** The tappable seed question — the data-aware opener. */
question: string;
/** Fired with the seed question when the user taps the opener. */
onSeed: (question: string) => void;
/**
* Dismiss the opener. Only honoured for `variant="seeded"` — the seeded
* opener is a soft suggestion the user can wave off for the day, while the
* A2 scope pill reflects how the chat was launched and is not dismissable.
*/
onDismiss?: () => void;
className?: string;
}
export function ScopeHintBadge({
variant,
label,
question,
onSeed,
onDismiss,
className,
}: ScopeHintBadgeProps) {
const { t } = useTranslations();
const Icon = variant === "scope" ? Target : Sparkles;
const prefix =
variant === "scope"
? t("insights.coach.scope.prefix", { metric: label })
: t("insights.coach.seeded.prefix", { signal: label });
return (
<div
data-slot="coach-scope-hint"
data-variant={variant}
className={cn("flex w-full flex-col items-center gap-2.5", className)}
>
{/* The scope/signal pill — the visible "already on …" line. */}
<span
data-slot="coach-scope-hint-pill"
className={cn(
"border-border/60 bg-muted/40 text-muted-foreground",
"inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1",
"text-xs font-medium",
)}
>
<Icon className="text-primary size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{prefix}</span>
</span>
{/* The tappable seed question — the data-aware opener. A real button so
keyboard + screen-reader users get the same affordance. v1.25.0 — the
opener sizes to its TEXT (intrinsic width, centred) instead of being
stretched to the full composer width, which left a large empty tail
that read as broken. It is guaranteed single-line: the question
truncates with an ellipsis on a narrow phone rather than wrapping or
forcing a horizontal scroll. The seeded variant keeps the dismiss
control at the trailing edge. */}
<div className="flex w-full items-center justify-center gap-1.5">
<button
type="button"
data-slot="coach-scope-hint-seed"
onClick={() => onSeed(question)}
className={cn(
"group border-border/70 bg-background hover:bg-muted/50 text-foreground",
"inline-flex max-w-full min-w-0 items-center gap-2 rounded-xl border px-3.5 py-2.5 text-left",
"transition-colors",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<span className="min-w-0 truncate text-sm leading-snug">
{question}
</span>
<span
aria-hidden="true"
className="text-muted-foreground group-hover:text-foreground shrink-0 text-xs whitespace-nowrap"
>
{t("insights.coach.scope.tap")}
</span>
</button>
{variant === "seeded" && onDismiss ? (
<button
type="button"
data-slot="coach-scope-hint-dismiss"
onClick={onDismiss}
aria-label={t("insights.coach.seeded.dismiss")}
title={t("insights.coach.seeded.dismiss")}
className={cn(
"text-muted-foreground hover:bg-muted/60 hover:text-foreground shrink-0",
"flex size-9 items-center justify-center rounded-xl transition-colors",
"focus-visible:ring-input-focus focus-visible:ring-2 focus-visible:outline-none",
)}
>
<X className="size-4" aria-hidden="true" />
</button>
) : null}
</div>
</div>
);
}
@@ -132,6 +132,10 @@ export function MoodFactorMetricCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const pairLabel = t("insights.mood.factorCrosstab.pairLabel", {
factor: factorLabel,
metric: metricLabel,
});
// v1.32.26 — the weight ("kg") row follows the metric/imperial
// preference: the unit symbol + the values (averages + delta, all in
// kg, offset-free) convert together. Every other display is unitless
@@ -168,12 +172,9 @@ export function MoodFactorMetricCrosstab({
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={`${factorLabel} · ${metricLabel}`}
title={pairLabel}
>
{t("insights.mood.factorCrosstab.pairLabel", {
factor: factorLabel,
metric: metricLabel,
})}
{pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
@@ -139,6 +139,10 @@ export function MoodTagMetricCrosstab({
const metricLabel = t(
METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey,
);
const pairLabel = t("insights.mood.crosstab.pairLabel", {
tag: tagLabel,
metric: metricLabel,
});
const unit = t(UNIT_KEY[row.display]);
const up = row.delta >= 0;
const deltaText = `${up ? "+" : ""}${formatValue(row.delta, row.display, nf)} ${unit}`;
@@ -160,12 +164,9 @@ export function MoodTagMetricCrosstab({
)}
<span
className="text-foreground min-w-0 flex-1 truncate"
title={`${tagLabel} · ${metricLabel}`}
title={pairLabel}
>
{t("insights.mood.crosstab.pairLabel", {
tag: tagLabel,
metric: metricLabel,
})}
{pairLabel}
</span>
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
{deltaText}
@@ -265,7 +265,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
client,
);
expect(html).toContain("Overdue ·");
expect(html).toContain("Overdue since");
expect(html).toContain("can still be taken");
expect(html).toContain("text-warning");
expect(html).not.toContain("Next intake: Tomorrow,");
@@ -291,7 +291,7 @@ describe("<MedicationCard> — open overdue slot (v1.16.4)", () => {
expect(html).toContain("Next intake:");
expect(html).toContain("Tomorrow");
expect(html).not.toContain("Overdue ·");
expect(html).not.toContain("Overdue since");
expect(html).not.toContain("can still be taken");
});
});
@@ -34,7 +34,6 @@ interface CoachPrefsShape {
nudgeVitals: boolean;
nudgeRoutine: boolean;
nudgeFrequency: "weekly" | "biweekly";
ambientSuggestions: boolean;
nudgeAiComposed: boolean;
}
@@ -51,7 +50,6 @@ const COACH_PREF_DEFAULTS: CoachPrefsShape = {
nudgeVitals: true,
nudgeRoutine: true,
nudgeFrequency: "weekly",
ambientSuggestions: true,
nudgeAiComposed: false,
};
@@ -306,28 +304,6 @@ export function CoachNudgeCard({
</div>
</div>
)}
{/* v1.25.0 — proactive ambient SUGGESTIONS opt-out. Independent of the
push-nudge master switch above (a user can keep nudges but silence
the example prompts), so it sits outside the `enabled` gate and rides
the same `coach` prefs blob. */}
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<p className="text-sm">
{t("notifications.coachNudge.suggestionsLabel")}
</p>
<p className="text-muted-foreground text-xs">
{t("notifications.coachNudge.suggestionsDesc")}
</p>
</div>
<Switch
checked={resolved.ambientSuggestions}
onCheckedChange={(next) =>
patchCoach({ ambientSuggestions: next }, null)
}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.coachNudge.suggestionsAria")}
/>
</div>
{msg && (
<p
role="status"
@@ -401,9 +401,14 @@ export function EnvironmentSection() {
key={tr.id}
className="flex items-center justify-between gap-2 p-2"
>
<span className="text-sm">
{tr.label} · {fmt.dateShortSmartCalendar(tr.startDate)} –{" "}
{fmt.dateShortSmartCalendar(tr.endDate)}
<span className="min-w-0">
<span className="block truncate text-sm">{tr.label}</span>
<span className="text-muted-foreground block text-xs">
{t("settings.sections.environment.travel.range", {
start: fmt.dateShortSmartCalendar(tr.startDate),
end: fmt.dateShortSmartCalendar(tr.endDate),
})}
</span>
</span>
<ConfirmButton
slot="travel-remove"
@@ -513,7 +513,9 @@ describe.each<Locale>(["de", "en"])("the day view in %s", (locale) => {
});
it("words every code under Running that day", () => {
const text = textOf(render(<DayRunning items={everyRunning()} />, locale));
const text = textOf(
render(<DayRunning items={everyRunning()} date="2026-03-12" />, locale),
);
expect(rawCodes(text)).toEqual([]);
});
});
+1 -1
View File
@@ -315,7 +315,7 @@ function resolveOrder(
* Map the app locale → the date-fns locale so the calendar's month / weekday
* names match the UI language. Defaults to enUS for anything unmapped.
*/
function resolveDateFnsLocale(
export function resolveDateFnsLocale(
locale: ReturnType<typeof useTranslations>["locale"],
): DateFnsLocale {
switch (locale) {
@@ -79,7 +79,7 @@ describe("the list", () => {
);
expect(html).toContain('data-antigen="typhoid"');
expect(html).toContain('data-antigen="hepatitis-a"');
expect(html.match(/· Travel combo/g)).toHaveLength(2);
expect(html.match(/>Travel combo</g)).toHaveLength(2);
});
});
@@ -63,7 +63,7 @@ export function VaccinationDocumentSuggestion({
: (candidate.vaccineName ?? t("vaccinations.suggestion.unnamed"));
// A date stored at UTC midnight: render the calendar date, not the
// instant (the previous evening west of UTC).
return `${name} · ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
return `${name}, ${formatCalendarDate(candidate.occurredAt.slice(0, 10), dateFormat, locale)}`;
};
const onPick = (candidate: VaccinationSuggestion) => {
@@ -209,7 +209,7 @@ function DoseRow({
</span>
) : null}
{showIdentity && identity ? (
<span className="text-muted-foreground">· {identity}</span>
<span className="text-muted-foreground">{identity}</span>
) : null}
</div>
<div className="text-muted-foreground flex flex-wrap items-center gap-x-2 gap-y-0.5 text-xs">