fix(cycle): drop card log button, fill predicted-period days, assessment on batch

- Phase education card no longer carries its own log-today button (the
  top-level log entry covers it).
- Predicted-period calendar days render as a soft dashed fill instead of an
  underline, so they read as period rather than an unfinished dash.
- The derived batch route now attaches the deterministic per-score assessment
  for the five scores so the dashboard grid (and iOS) paints the explanation
  without a second per-id round-trip; guarded so it never fails the grid.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 20:36:29 +02:00
1 parent 546835a937
commit 2b9d2370e9
6 files changed
+52 -38

No files matched your search

+35 -5
View File
@@ -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<typeof resolveDeterministicAssessment>[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),
},
};
}),
@@ -49,14 +49,16 @@ describe("<CycleCalendar>", () => {
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(
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
);
// 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)", () => {
@@ -30,11 +30,10 @@ const okGate = {
predictionEnabled: true,
rawChartMode: false,
cyclesObserved: 4,
onLogToday: () => {},
};
describe("<PhaseEducationCard>", () => {
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(
<PhaseEducationCard
phase="LUTEAL"
@@ -44,7 +43,6 @@ describe("<PhaseEducationCard>", () => {
);
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("<PhaseEducationCard>", () => {
predictionEnabled
rawChartMode={false}
cyclesObserved={2}
onLogToday={() => {}}
/>,
);
expect(html).toContain("Still learning your cycle");
@@ -85,7 +82,6 @@ describe("<PhaseEducationCard>", () => {
predictionEnabled
rawChartMode
cyclesObserved={9}
onLogToday={() => {}}
/>,
);
expect(raw).toContain("Still learning your cycle");
@@ -97,7 +93,6 @@ describe("<PhaseEducationCard>", () => {
predictionEnabled={false}
rawChartMode={false}
cyclesObserved={9}
onLogToday={() => {}}
/>,
);
expect(off).toContain("Still learning your cycle");
@@ -113,6 +108,6 @@ describe("<PhaseEducationCard>", () => {
);
expect(html).toContain("progesterone");
expect(html).toContain("cycle-phase-education-chips");
expect(html).toContain("Log today");
expect(html).toContain("Cramps");
});
});
+7 -3
View File
@@ -269,14 +269,18 @@ export function CycleCalendar({
{cell.getDate()}
</span>
{/* 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 ? (
<span
aria-hidden="true"
className="absolute inset-x-1.5 bottom-1 h-0.5 rounded-full opacity-70"
className="absolute inset-1 rounded-md border border-dashed"
style={{
backgroundColor: FLOW_HUE,
backgroundImage: `repeating-linear-gradient(90deg, ${FLOW_HUE} 0 3px, transparent 3px 5px)`,
opacity: 0.3,
borderColor: FLOW_HUE,
}}
/>
) : null}
-1
View File
@@ -219,7 +219,6 @@ export function CycleView() {
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
onLogToday={() => openSheet(today)}
/>
) : null}
</div>
+3 -19
View File
@@ -1,9 +1,8 @@
"use client";
import { useMemo } from "react";
import { Sparkles, Plus } from "lucide-react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { PHASE_WHATS_HAPPENING_KEY } from "@/lib/cycle/phase-copy";
@@ -23,8 +22,8 @@ import type { SymptomPhaseRow } from "./use-cycle";
* never clinical advice / a medical claim),
* 3. a "you often notice this here" chip row sourced from the user's OWN
* symptom-by-phase history (`symptomPatterns`), filtered to symptoms that
* cluster in THIS phase — never a generic population claim — plus a
* "log today" nudge into the existing day-log sheet.
* cluster in THIS phase — never a generic population claim. Logging lives
* on the top-level log entry, so the card carries no own log button.
*
* Honesty gate (Clue precedent): when prediction is disabled / raw-chart mode
* or fewer than three observed cycles, the predictive framing is suppressed and
@@ -47,8 +46,6 @@ export interface PhaseEducationCardProps {
predictionEnabled: boolean;
rawChartMode: boolean;
cyclesObserved: number;
/** Open the day-log sheet for today (the "log today" nudge). */
onLogToday: () => void;
/** Ride the wheel's once-per-session reveal — no independent animation. */
animate?: boolean;
className?: string;
@@ -60,7 +57,6 @@ export function PhaseEducationCard({
predictionEnabled,
rawChartMode,
cyclesObserved,
onLogToday,
animate = false,
className,
}: PhaseEducationCardProps) {
@@ -189,18 +185,6 @@ export function PhaseEducationCard({
</ul>
</div>
) : null}
{/* The "log today" nudge — opens the existing day-log sheet. */}
<Button
type="button"
variant="outline"
size="sm"
onClick={onLogToday}
className="bg-background/55 mt-4 gap-1.5"
>
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
{t("cycle.phaseEducation.trackNudge")}
</Button>
</>
)}
</section>