mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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}”
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'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([]);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user