diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 51d5802b9..b9330ff29 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -34,6 +34,8 @@ import { isDerivedMetricId, type DerivedMetricId, } from "@/lib/insights/derived"; +import { resolveDeterministicAssessment } from "@/lib/insights/derived/derived-assessment"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; export const dynamic = "force-dynamic"; @@ -146,6 +148,35 @@ export const GET = apiHandler(async (request: NextRequest) => { // four computes at a time against the shared pool, not seventeen. const limit = pLimit(BATCH_CONCURRENCY); const now = new Date(); + + // Attach the cheap DETERMINISTIC per-score assessment to the grid read so + // iOS paints the "Einschätzung" straight off the overview without a second + // per-id round-trip (the AI-warm prose stays lazy, served by the single + // route). `resolveDeterministicAssessment` is pure — no DB, no LLM — and + // null for non-assessable ids / non-`ok` status, so a non-score item still + // gets `assessment: null`. A best-effort grid extra must NEVER fail the + // whole batch, so each call is guarded → null on any unexpected shape. + const locale = await resolveServerLocale({ + request, + userLocale: user.locale ?? null, + override: request.nextUrl.searchParams.get("locale"), + }); + const assessmentLocale = locale === "de" ? "de" : "en"; + const safeAssessment = ( + metric: DerivedMetricId, + derived: Parameters[1], + ) => { + try { + return resolveDeterministicAssessment( + metric, + derived, + assessmentLocale, + now, + ); + } catch { + return null; + } + }; const results = await Promise.all( items.map((item) => limit(async () => { @@ -167,11 +198,10 @@ export const GET = apiHandler(async (request: NextRequest) => { provenance: derived.provenance, reason: derived.status === "insufficient" ? derived.reason : null, - // The batch backs the dashboard GRID; per-score assessment text is - // a detail-sheet concern served by the single-metric route. Always - // null here so the wire shape matches `DerivedMetricResponse` - // without paying the assessment cost on a 17-item grid read. - assessment: null, + // Deterministic assessment only (cheap, pure). The AI-warm prose + // stays a single-route concern; here every assessable `ok` score + // carries its template "why" text, everything else stays null. + assessment: safeAssessment(item.metric, derived), }, }; }), diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index 439afa4af..e24b107cd 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -49,14 +49,16 @@ describe("", () => { expect(html).toContain("Logged period"); }); - it("renders the predicted period as a band, not a logged pip", () => { + it("renders the predicted period as a dashed soft fill, distinct from a logged pip", () => { const days = [{ ...dayBase("2026-06-20"), isPredictedPeriod: true }]; const html = render( {}} />, ); - // The predicted band uses a dashed gradient underline + the aria marker. + // Predicted days are a soft FILL with a dashed outline (not the old + // underline), tagged data-predicted; the solid logged pip has no dashes. expect(html).toContain("Predicted period"); - expect(html).toContain("repeating-linear-gradient"); + expect(html).toContain('data-predicted="true"'); + expect(html).toContain("border-dashed"); }); it("labels a fertile-window day (only present when goal-gated server allows it)", () => { diff --git a/src/components/cycle/__tests__/phase-education-card.test.tsx b/src/components/cycle/__tests__/phase-education-card.test.tsx index 35fcb715b..d8769bbb9 100644 --- a/src/components/cycle/__tests__/phase-education-card.test.tsx +++ b/src/components/cycle/__tests__/phase-education-card.test.tsx @@ -30,11 +30,10 @@ const okGate = { predictionEnabled: true, rawChartMode: false, cyclesObserved: 4, - onLogToday: () => {}, }; describe("", () => { - it("renders phase name + descriptive line + the log nudge when the gate passes", () => { + it("renders phase name + descriptive line when the gate passes", () => { const html = render( ", () => { ); expect(html).toContain("Luteal"); expect(html).toContain("progesterone"); // curated whatsHappening line - expect(html).toContain("Log today"); expect(html).toContain('data-phase="LUTEAL"'); }); @@ -69,7 +67,6 @@ describe("", () => { predictionEnabled rawChartMode={false} cyclesObserved={2} - onLogToday={() => {}} />, ); expect(html).toContain("Still learning your cycle"); @@ -85,7 +82,6 @@ describe("", () => { predictionEnabled rawChartMode cyclesObserved={9} - onLogToday={() => {}} />, ); expect(raw).toContain("Still learning your cycle"); @@ -97,7 +93,6 @@ describe("", () => { predictionEnabled={false} rawChartMode={false} cyclesObserved={9} - onLogToday={() => {}} />, ); expect(off).toContain("Still learning your cycle"); @@ -113,6 +108,6 @@ describe("", () => { ); expect(html).toContain("progesterone"); expect(html).toContain("cycle-phase-education-chips"); - expect(html).toContain("Log today"); + expect(html).toContain("Cramps"); }); }); diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index f9b46304a..5712e2cec 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -269,14 +269,18 @@ export function CycleCalendar({ {cell.getDate()} - {/* Predicted-period band: a soft hatched underline, never a dot. */} + {/* Predicted-period day: a soft FILL behind the number (like a + logged period day, but lighter) with a dashed outline so it + still reads as predicted, not confirmed — replaces the old + hatched underline, which looked unfinished. */} {info?.isPredictedPeriod && !info?.isPeriodLogged ? (