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

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

No files matched your search

@@ -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);
});