mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* No middle-dot separator in visible strings.
|
||||
*
|
||||
* A value like "Added {date} · {size}" or "Weight · body fat · muscle" reads
|
||||
* as a list of fragments rather than as the app's plain voice. Facts are
|
||||
* joined with a comma, a colon or a real phrase instead. This guard walks
|
||||
* every bundle under `messages/` and fails on any string that contains a
|
||||
* space, U+00B7 and a space. A middle dot inside a unit ("mL/(kg·min)") has
|
||||
* no surrounding spaces and is not matched.
|
||||
*
|
||||
* A key that genuinely needs the pattern goes into `ALLOWED` with the reason
|
||||
* written next to it. An allowed key that no longer contains the pattern
|
||||
* fails too, so the list cannot go stale.
|
||||
*/
|
||||
|
||||
const MESSAGES_DIR = join(__dirname, "../../messages");
|
||||
const SEPARATOR = " · ";
|
||||
|
||||
/** Key path -> why the separator is right there. Empty by intent. */
|
||||
const ALLOWED: Record<string, string> = {};
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): Map<string, string> {
|
||||
if (obj == null || typeof obj !== "object") return out;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
if (typeof v === "string") out.set(key, v);
|
||||
else if (typeof v === "object") flatten(v, key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const bundles = readdirSync(MESSAGES_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.sort();
|
||||
|
||||
describe("message bundles carry no ' · ' separator", () => {
|
||||
it("finds the bundles", () => {
|
||||
expect(bundles.length).toBeGreaterThanOrEqual(7);
|
||||
});
|
||||
|
||||
for (const file of bundles) {
|
||||
const values = flatten(
|
||||
JSON.parse(readFileSync(join(MESSAGES_DIR, file), "utf8")),
|
||||
"",
|
||||
new Map(),
|
||||
);
|
||||
|
||||
it(`${file} walks a non-empty set of strings`, () => {
|
||||
expect(values.size).toBeGreaterThan(1000);
|
||||
});
|
||||
|
||||
it(`${file} has no dot separator outside the allowlist`, () => {
|
||||
const offenders = [...values]
|
||||
.filter(
|
||||
([key, value]) => value.includes(SEPARATOR) && !(key in ALLOWED),
|
||||
)
|
||||
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${file} has no stale allowlist entry`, () => {
|
||||
const stale = Object.keys(ALLOWED).filter(
|
||||
(key) => !(values.get(key) ?? "").includes(SEPARATOR),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -137,11 +137,6 @@ export const AI_ROUTES: Readonly<Record<string, AiRouteEntry>> = {
|
||||
capabilities: ["coach"],
|
||||
why: "The unread signal of a Coach that is unavailable is the quiet empty shape, never a refusal.",
|
||||
},
|
||||
"src/app/api/insights/coach/seeded-question/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["coach"],
|
||||
why: "The opener exists only to open the Coach: `signal: null` while it is unavailable.",
|
||||
},
|
||||
"src/app/api/coach/about-me/route.ts": {
|
||||
kind: "mixed",
|
||||
capabilities: ["aboutMeQuestions"],
|
||||
|
||||
@@ -104,7 +104,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -141,7 +140,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -180,7 +178,6 @@ describe("GET /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -230,7 +227,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -259,7 +255,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -289,7 +284,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -311,7 +305,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -395,7 +388,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -435,7 +427,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -464,7 +455,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -510,7 +500,6 @@ describe("PATCH /api/auth/me/notification-prefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.21.2 (A3) — the seeded-question route resolves today's single most
|
||||
* notable derived signal into a tappable Coach opener, server-side. When the
|
||||
* confidence + notability gate yields nothing the route returns
|
||||
* `{ signal: null }` and the hero keeps its neutral greeting — never a
|
||||
* fabricated opener.
|
||||
*/
|
||||
const findUniqueMock = vi.fn().mockResolvedValue({ notificationPrefs: null });
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: (...args: unknown[]) => findUniqueMock(...args) },
|
||||
},
|
||||
}));
|
||||
|
||||
// The `coach` capability decides whether an opener is served at all; pinned
|
||||
// here so the route test does not depend on the capability loader's reads.
|
||||
const AVAILABLE = { available: true, reason: null, onDeviceAllowed: true };
|
||||
const getAiCapability = vi.fn();
|
||||
vi.mock("@/lib/ai/capabilities/gate", () => ({
|
||||
getAiCapability: (...args: unknown[]) => getAiCapability(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkAnalyticsReadRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 119, resetAt: 0 }),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
// Stub the profile loader + the shared briefing detector so the route test
|
||||
// stays isolated from the compute tier.
|
||||
vi.mock("@/lib/insights/derived", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/insights/derived")>()),
|
||||
loadBaselineProfile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ageYears: 38, sex: "MALE", heightCm: 180 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/insights/derived-briefing", () => ({
|
||||
detectDerivedBriefingSignals: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { GET } from "../route";
|
||||
|
||||
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
|
||||
function makeReq(): NextRequest {
|
||||
return new NextRequest(
|
||||
new URL("http://localhost/api/insights/coach/seeded-question"),
|
||||
);
|
||||
}
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "testuser",
|
||||
role: "USER" as const,
|
||||
locale: "en",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
findUniqueMock.mockResolvedValue({ notificationPrefs: null });
|
||||
getAiCapability.mockResolvedValue(AVAILABLE);
|
||||
});
|
||||
|
||||
describe("GET /api/insights/coach/seeded-question", () => {
|
||||
it("returns the most notable signal (the head) when one crosses the gate", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue({
|
||||
signals: [
|
||||
{
|
||||
sourceMetric: "readiness",
|
||||
label: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
confidence: 72,
|
||||
},
|
||||
{
|
||||
sourceMetric: "recovery",
|
||||
label: "recovery",
|
||||
score: 49,
|
||||
band: "red",
|
||||
confidence: 65,
|
||||
},
|
||||
],
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
signal: { sourceMetric: string; score: number; band: string } | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toEqual({
|
||||
sourceMetric: "readiness",
|
||||
score: 58,
|
||||
band: "yellow",
|
||||
});
|
||||
expect(getAiCapability).toHaveBeenCalledWith("coach");
|
||||
expect((body.data as { ai?: unknown }).ai).toEqual(AVAILABLE);
|
||||
});
|
||||
|
||||
it.each(["operator_disabled", "user_disabled", "no_provider"] as const)(
|
||||
"answers { signal: null, ai } for %s without running the detector",
|
||||
async (reason) => {
|
||||
const ai = { available: false, reason, onDeviceAllowed: false };
|
||||
getAiCapability.mockResolvedValue(ai);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown; ai: unknown };
|
||||
};
|
||||
expect(body.data).toEqual({ signal: null, ai });
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns { signal: null } when nothing crosses the gate (neutral fallback)", async () => {
|
||||
vi.mocked(detectDerivedBriefingSignals).mockResolvedValue(null);
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toBeNull();
|
||||
});
|
||||
|
||||
it("returns { signal: null } and skips the detector when ambient suggestions are off", async () => {
|
||||
findUniqueMock.mockResolvedValue({
|
||||
notificationPrefs: { coach: { ambientSuggestions: false } },
|
||||
});
|
||||
const res = await callGet(makeReq());
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { signal: unknown } | null;
|
||||
error: string | null;
|
||||
};
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.signal).toBeNull();
|
||||
// The opt-out short-circuits before the compute tier.
|
||||
expect(detectDerivedBriefingSignals).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* v1.21.2 (A3) — pre-seeded relevance opener for the unscoped Coach.
|
||||
*
|
||||
* `GET /api/insights/coach/seeded-question` resolves today's single most
|
||||
* notable derived wellness signal (readiness / recovery shift, the same
|
||||
* confidence-gated detector the daily briefing uses) into a tappable
|
||||
* suggested opener for the Coach's blank new-chat hero. When no signal
|
||||
* crosses the gate the route returns `{ signal: null }` and the hero
|
||||
* keeps its neutral greeting — never a fabricated opener.
|
||||
*
|
||||
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
|
||||
* and never recomputes it (server-authoritative parity). It IS registered in
|
||||
* the OpenAPI contract. This comment said the opposite until the route was
|
||||
* published — it shipped as a same-origin web surface and the sentence was
|
||||
* never revisited. A comment that contradicts the registry is worse than no
|
||||
* comment, because the next reader believes it instead of checking.
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
* analytics-read rate limit.
|
||||
*
|
||||
* A mixed read: the opener only exists to open the Coach, so it is served
|
||||
* only while the `coach` capability is available. Otherwise the route answers
|
||||
* 200 `{ signal: null, ai }`, the same neutral shape as "nothing notable",
|
||||
* with the reason beside it.
|
||||
*/
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
|
||||
import { getAiCapability } from "@/lib/ai/capabilities/gate";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { loadBaselineProfile } from "@/lib/insights/derived";
|
||||
import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { resolveCoachAmbientSuggestionsEnabled } from "@/lib/validations/notification-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Resolved opener the hero renders, or null when nothing notable. */
|
||||
export interface CoachSeededQuestionDTO {
|
||||
/**
|
||||
* The notable signal driving the opener, or null when nothing crossed
|
||||
* the briefing detector's confidence + notability gate.
|
||||
*/
|
||||
signal: {
|
||||
/** Sentinel id (`readiness` / `recovery`) the client keys i18n on. */
|
||||
sourceMetric: string;
|
||||
/** Latest 0–100 score. */
|
||||
score: number;
|
||||
/** Band — `yellow` / `red` (green never surfaces as notable). */
|
||||
band: string;
|
||||
} | null;
|
||||
/** Whether the Coach this opener feeds is available, and why not. */
|
||||
ai: AiCapabilityState;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// The opener feeds the Coach hero: with the Coach unavailable there is no
|
||||
// hero to seed, so the neutral shape answers and says why.
|
||||
const ai = await getAiCapability("coach");
|
||||
if (!ai.available) {
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
const rl = await checkAnalyticsReadRateLimit(user.id);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many analytics requests. Please retry later.", 429);
|
||||
}
|
||||
|
||||
// Server-authoritative ambient-suggestions opt-out: when the user turns
|
||||
// proactive suggestions off the hero must not seed an opener, so the route
|
||||
// skips the detector work and returns the neutral `{ signal: null }` shape.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { notificationPrefs: true },
|
||||
});
|
||||
if (
|
||||
!resolveCoachAmbientSuggestionsEnabled(prefsRow?.notificationPrefs ?? null)
|
||||
) {
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: { has_signal: false, source_metric: "none", band: "none" },
|
||||
});
|
||||
return apiSuccess({ signal: null, ai } satisfies CoachSeededQuestionDTO);
|
||||
}
|
||||
|
||||
// Profile read once via the shared loader; passed into the detector,
|
||||
// never re-fetched per candidate (the detector probes readiness +
|
||||
// recovery off the one profile).
|
||||
const profile = await loadBaselineProfile(prisma, user.id);
|
||||
const detected = await detectDerivedBriefingSignals(user.id, profile);
|
||||
|
||||
// The detector returns 1–2 signals in priority order; the opener anchors
|
||||
// on the single most notable one (the head).
|
||||
const top = detected?.signals[0] ?? null;
|
||||
const signal: CoachSeededQuestionDTO["signal"] = top
|
||||
? { sourceMetric: top.sourceMetric, score: top.score, band: top.band }
|
||||
: null;
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.seeded-question.resolve" },
|
||||
meta: {
|
||||
has_signal: signal !== null,
|
||||
source_metric: signal?.sourceMetric ?? "none",
|
||||
band: signal?.band ?? "none",
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ signal, ai } satisfies CoachSeededQuestionDTO);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single client-side gate for proactive ambient Coach
|
||||
* SUGGESTIONS (today: the daily seeded example opener on the Coach hero).
|
||||
*
|
||||
* Reads the per-user opt-out from the shared notification-prefs blob
|
||||
* (`coach.ambientSuggestions`). Default ON: an unauthenticated read, a
|
||||
* still-loading query, or a missing field all resolve to `true` so the
|
||||
* suggestions show out of the box and only hide once the user explicitly turns
|
||||
* them off in Settings. Shares the `authNotificationPrefs` query key with the
|
||||
* settings card, so toggling the switch there flips this read in lockstep.
|
||||
*/
|
||||
interface AmbientSuggestionsPrefsShape {
|
||||
coach?: { ambientSuggestions?: boolean };
|
||||
}
|
||||
|
||||
export function useCoachAmbientSuggestionsEnabled(): boolean {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.authNotificationPrefs(),
|
||||
queryFn: async () =>
|
||||
apiGet<AmbientSuggestionsPrefsShape>("/api/auth/me/notification-prefs"),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return data?.coach?.ambientSuggestions ?? true;
|
||||
}
|
||||
@@ -757,7 +757,7 @@ describe("doctor-report surgical history section", () => {
|
||||
);
|
||||
const text = await extractText(bytes);
|
||||
expect(text).toContain("Operationen und Eingriffe");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("omits the section when there is no procedure", async () => {
|
||||
@@ -819,7 +819,7 @@ describe("doctor-report condition body site", () => {
|
||||
it("prints the site with its side in the report's language", async () => {
|
||||
const text = await render(CONDITIONS, "de");
|
||||
expect(text).toContain("Körperstelle");
|
||||
expect(text).toMatch(/Knee · Links/);
|
||||
expect(text).toMatch(/Knee \(Links\)/);
|
||||
});
|
||||
|
||||
it("keeps the old table when no condition names a site", async () => {
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("readCoachCorrelations", () => {
|
||||
pValue: 0.001,
|
||||
qValue: 0.02,
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
lagDays: 1,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -345,7 +345,7 @@ describe("executeCoachTool", () => {
|
||||
lagDays: 1,
|
||||
n: 42,
|
||||
r: 0.31,
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
note: "Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
},
|
||||
],
|
||||
coincident: {
|
||||
|
||||
@@ -118,7 +118,7 @@ describe("formatRelationsBlock — cross-metric correlations, descriptive only",
|
||||
const rels: RelevantCorrelation[] = [
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with higher next-day sleep duration in your data. It is a pattern to watch, not a cause.",
|
||||
n: 42,
|
||||
r: 0.51,
|
||||
},
|
||||
|
||||
@@ -457,7 +457,7 @@ describe("generateMetricStatus — generation path", () => {
|
||||
vi.mocked(getRelevantCorrelationsForMetric).mockResolvedValueOnce([
|
||||
{
|
||||
interpretation:
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data — a pattern worth watching, not a cause.",
|
||||
"Higher time in daylight tends to go with lower next-day resting heart rate in your data. It is a pattern to watch, not a cause.",
|
||||
n: 35,
|
||||
r: -0.46,
|
||||
},
|
||||
|
||||
@@ -157,8 +157,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
// A sentence that is different but wrong is still a broken page.
|
||||
expect(note).toBe(
|
||||
"Wenn die Symptomstärke höher liegt, scheint die Schlafdauer am " +
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen — ein " +
|
||||
"Muster, das Beachtung verdient, keine Ursache.",
|
||||
"Folgetag nach bisheriger Datenlage eher niedriger auszufallen. Das ist " +
|
||||
"ein Muster, das Beachtung verdient, keine Ursache.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -170,8 +170,8 @@ describe("buildCoachReadStrip — the pattern line speaks the reader's language"
|
||||
expect(strip.driver).not.toBeNull();
|
||||
expect(strip.driver!.note).toBe(
|
||||
"Higher symptom severity looks like it goes with lower next-day sleep " +
|
||||
"duration in your data, on the evidence so far — a pattern worth " +
|
||||
"watching, not a cause.",
|
||||
"duration in your data, on the evidence so far. It is a pattern " +
|
||||
"to watch, not a cause.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
insightsGeneratePostResponse,
|
||||
coachReadQuery,
|
||||
coachReadStripResponse,
|
||||
coachSeededQuestionResponse,
|
||||
narrativeQuery,
|
||||
narrativeResponse,
|
||||
glp1TimelineQuery,
|
||||
@@ -399,28 +398,6 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/seeded-question": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Today's suggested Coach opener",
|
||||
description:
|
||||
"Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. The opener exists only to open the Coach, so it is served only while the `coach` AI capability is available; otherwise the answer is the same neutral `signal: null` and `ai` says why. Never refused for an AI reason. Shared analytics-read budget. Cookie or Bearer auth; not delegable.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opener, or the neutral null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachSeededQuestionResponse,
|
||||
"CoachSeededQuestionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/narrative": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -2747,38 +2747,6 @@ export const coachReadStripResponse = z
|
||||
"The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.",
|
||||
});
|
||||
|
||||
// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ───────
|
||||
|
||||
export const coachSeededQuestionResponse = z
|
||||
.object({
|
||||
signal: z
|
||||
.object({
|
||||
sourceMetric: z
|
||||
.string()
|
||||
.describe(
|
||||
"Sentinel id the client keys its localised copy on (`readiness` / `recovery`).",
|
||||
),
|
||||
score: z.number().describe("The latest 0..100 score."),
|
||||
band: z
|
||||
.string()
|
||||
.describe(
|
||||
"`yellow` or `red`. Green never surfaces — a good day is not notable.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off, AND whenever the `coach` capability is unavailable (then `ai` says why) — all mean the hero keeps its neutral greeting rather than showing a fabricated opener.",
|
||||
),
|
||||
ai: aiCapabilityState.describe(
|
||||
"The `coach` capability the opener exists for.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSeededQuestion",
|
||||
description:
|
||||
"Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.",
|
||||
});
|
||||
|
||||
// ── Period narrative (`/api/insights/narrative`) ──────────────────────
|
||||
|
||||
export const narrativeQuery = z
|
||||
|
||||
@@ -76,12 +76,6 @@ export const coachKeys = {
|
||||
messageId,
|
||||
"trail",
|
||||
] as const,
|
||||
/**
|
||||
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
|
||||
* Coach hero's pre-seeded relevance opener
|
||||
* (`GET /api/insights/coach/seeded-question`).
|
||||
*/
|
||||
coachSeededQuestion: () => ["coach-seeded-question"] as const,
|
||||
/**
|
||||
* v1.22 (B2/B6) — the user's Coach episodic reminders (`GET /api/coach/
|
||||
* reminders`). The optional `status` arg keys the in-app tile read
|
||||
|
||||
@@ -109,7 +109,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -134,7 +133,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -161,7 +159,6 @@ describe("parseNotificationPrefs", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -204,7 +201,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -235,7 +231,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -263,7 +258,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -291,7 +285,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -320,7 +313,6 @@ describe("resolveNotificationPrefs (deep-merge)", () => {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
|
||||
@@ -125,14 +125,6 @@ const coachPrefsSchema = z
|
||||
nudgeRoutine: z.boolean(),
|
||||
/** v1.16.5 — frequency cap: one nudge per 7 or per 14 days. */
|
||||
nudgeFrequency: z.enum(["weekly", "biweekly"]),
|
||||
/**
|
||||
* v1.25.0 — opt-out for the proactive ambient SUGGESTIONS surfaced in the
|
||||
* UI (the daily seeded example opener on the Coach hero + the "try asking"
|
||||
* prompt chips). Default ON. Separate from `nudgesEnabled` (the push-nudge
|
||||
* master switch) so a user can keep the nudges but silence the example
|
||||
* prompts, or vice versa.
|
||||
*/
|
||||
ambientSuggestions: z.boolean(),
|
||||
/**
|
||||
* v1.25.0 — opt-IN for AI-composed nudge copy. Default OFF: the proactive
|
||||
* nudge ships a deterministic warm localized template, and that template is
|
||||
@@ -219,11 +211,6 @@ export interface NotificationPrefs {
|
||||
nudgeRoutine: boolean;
|
||||
/** v1.16.5 — cap interval: "weekly" (7 d) or "biweekly" (14 d). */
|
||||
nudgeFrequency: "weekly" | "biweekly";
|
||||
/**
|
||||
* v1.25.0 — proactive ambient example suggestions in the UI (seeded
|
||||
* opener + suggested-prompt chips). Default `true`.
|
||||
*/
|
||||
ambientSuggestions: boolean;
|
||||
/**
|
||||
* v1.25.0 — opt-in for AI-composed nudge copy. Default `false`; the
|
||||
* deterministic template stays the default + fail-closed fallback.
|
||||
@@ -285,7 +272,6 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
|
||||
nudgeVitals: true,
|
||||
nudgeRoutine: true,
|
||||
nudgeFrequency: "weekly",
|
||||
ambientSuggestions: true,
|
||||
nudgeAiComposed: false,
|
||||
},
|
||||
measurementReminder: {
|
||||
@@ -483,16 +469,6 @@ export interface CoachNudgePrefs {
|
||||
aiComposed: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.25.0 — the single source every proactive ambient SUGGESTION surface
|
||||
* consults (the daily seeded example opener on the Coach hero + the
|
||||
* suggested-prompt chips). Default `true`; a null / drifted row resolves to
|
||||
* the documented default so the suggestions work out of the box.
|
||||
*/
|
||||
export function resolveCoachAmbientSuggestionsEnabled(raw: unknown): boolean {
|
||||
return parseNotificationPrefs(raw).coach.ambientSuggestions;
|
||||
}
|
||||
|
||||
export function resolveCoachNudgePrefs(raw: unknown): CoachNudgePrefs {
|
||||
const coach = parseNotificationPrefs(raw).coach;
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user