From 546835a9372b01bc0571161f439504021ddd8544 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 20:10:15 +0200
Subject: [PATCH 1/4] fix(cycle): trim duplicate phase card, drop redundant
disclaimers, tidy settings
Remove the duplicate phase-education card from the calendar tab, keeping the
single instance under the wheel and surfacing its "you often notice this here"
chip row there. Drop the generic prediction-estimate caption on the predictions
tab for every goal except AVOID_PREGNANCY, where the contraceptive-safety caveat
stays as a deliberate medical note. Stop rendering the associations-not-causes
footer on the insights crosstab. In the cycle settings, align card-header
descriptions with their titles, and remove the standalone cycle export + purge
affordances now that cycle data rides the main export and account-level delete.
Move the web-push enable action into the card header row.
---
.../__tests__/phase-education-card.test.tsx | 6 +-
.../__tests__/predictions-panel.test.tsx | 18 ++-
src/components/cycle/cycle-phase-crosstab.tsx | 3 -
src/components/cycle/cycle-settings.tsx | 89 +------------
src/components/cycle/cycle-view.tsx | 17 +--
src/components/cycle/phase-education-card.tsx | 13 +-
src/components/cycle/predictions-panel.tsx | 14 +-
src/components/cycle/use-cycle.ts | 21 ---
src/components/settings/_card-header.tsx | 38 ++++--
src/components/settings/web-push-card.tsx | 123 +++++++++---------
10 files changed, 134 insertions(+), 208 deletions(-)
diff --git a/src/components/cycle/__tests__/phase-education-card.test.tsx b/src/components/cycle/__tests__/phase-education-card.test.tsx
index 7e18e0ae1..35fcb715b 100644
--- a/src/components/cycle/__tests__/phase-education-card.test.tsx
+++ b/src/components/cycle/__tests__/phase-education-card.test.tsx
@@ -103,18 +103,16 @@ describe("", () => {
expect(off).toContain("Still learning your cycle");
});
- it("omits the chip row in the compact variant", () => {
+ it("renders the chip row when the user has symptoms clustering in this phase", () => {
const html = render(
,
);
expect(html).toContain("progesterone");
- expect(html).not.toContain("cycle-phase-education-chips");
- // The compact variant still carries the log nudge.
+ expect(html).toContain("cycle-phase-education-chips");
expect(html).toContain("Log today");
});
});
diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx
index af2b832d4..c1b91adb1 100644
--- a/src/components/cycle/__tests__/predictions-panel.test.tsx
+++ b/src/components/cycle/__tests__/predictions-panel.test.tsx
@@ -37,12 +37,13 @@ const emptyHistory: CycleHistoryResponse = {
};
describe("", () => {
- it("renders the next-period range + the fixed disclaimer", () => {
+ it("renders the next-period range and shows the disclaimer under the AVOID_PREGNANCY goal", () => {
const html = render(
,
);
expect(html).toContain('data-slot="cycle-disclaimer"');
@@ -51,6 +52,21 @@ describe("", () => {
expect(html).toContain("–");
});
+ it("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => {
+ const html = render(
+ ,
+ );
+ expect(html).not.toContain('data-slot="cycle-disclaimer"');
+ expect(html).not.toContain("Estimates only, not medical advice.");
+ // The range still renders.
+ expect(html).toContain("–");
+ });
+
it("renders the fertile window only when the prediction carries one", () => {
const withFertile: CyclePrediction = {
...basePrediction,
diff --git a/src/components/cycle/cycle-phase-crosstab.tsx b/src/components/cycle/cycle-phase-crosstab.tsx
index 8c337d73c..69c9bbe07 100644
--- a/src/components/cycle/cycle-phase-crosstab.tsx
+++ b/src/components/cycle/cycle-phase-crosstab.tsx
@@ -215,9 +215,6 @@ export function CyclePhaseCrosstab({
);
})}
-
- {t("cycle.insights.crosstab.footer")}
-
);
}
diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx
index 2197832d4..74497fa78 100644
--- a/src/components/cycle/cycle-settings.tsx
+++ b/src/components/cycle/cycle-settings.tsx
@@ -18,7 +18,7 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Separator } from "@/components/ui/separator";
import { useTranslations } from "@/lib/i18n/context";
import type { CycleGoal, CycleProfileDTO } from "./types";
-import { useUpdateCyclePrefs, useDeleteAllCycleData } from "./use-cycle";
+import { useUpdateCyclePrefs } from "./use-cycle";
/**
* v1.15.0 — the single-page cycle settings form.
@@ -50,8 +50,6 @@ function numOrEmpty(v: number | null): string {
export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
const { t } = useTranslations();
const update = useUpdateCyclePrefs();
- const purge = useDeleteAllCycleData();
- const [confirmingPurge, setConfirmingPurge] = useState(false);
const [goal, setGoal] = useState(profile.goal);
const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode);
@@ -228,87 +226,10 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
) : null}
-
-
- {/* Data export / delete */}
-
-
{t("cycle.settings.dataTitle")}
-
- {t("cycle.settings.dataDescription")}
-
-
-
-
-
-
- {/* Cycle-only hard purge (post-Dobbs threat model): removes every
- cycle row + the cycle audit trail + reminder ledger, distinct
- from the account-level delete. Two-step confirm — no accidental
- destructive tap. */}
-
+ {/* Cycle data export + delete live with the main Settings → Export and
+ Account surfaces (cycle rows ride the full-backup export and the
+ account-level delete), so the standalone affordances are not
+ duplicated here. */}
- {/* Compact phase-education card beneath the wheel — chip row omitted
- here (compact); the full card lives on the calendar tab. */}
+ {/* Phase-education card beneath the wheel — the single instance,
+ including the "you often notice this here" chip row sourced from
+ the user's own symptom-by-phase history. */}
{!loading && !calendarError ? (
- {!loading && !calendarError ? (
- openSheet(today)}
- />
- ) : null}
{loading ? (
@@ -292,6 +282,7 @@ export function CycleView() {
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
+ goal={goal}
/>
void;
/** Ride the wheel's once-per-session reveal — no independent animation. */
animate?: boolean;
- /** Compact variant beneath the wheel (tighter padding, no chip row). */
- compact?: boolean;
className?: string;
}
@@ -64,7 +62,6 @@ export function PhaseEducationCard({
cyclesObserved,
onLogToday,
animate = false,
- compact = false,
className,
}: PhaseEducationCardProps) {
const { t } = useTranslations();
@@ -118,8 +115,7 @@ export function PhaseEducationCard({
aria-label={t("cycle.phaseEducation.title")}
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
- "wellness-tile rounded-xl",
- compact ? "px-4 py-4" : "px-5 py-5",
+ "wellness-tile rounded-xl px-5 py-5",
animate && "wellness-tile-rise",
className,
)}
@@ -159,10 +155,9 @@ export function PhaseEducationCard({
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
- {/* Zone 3 — the user's OWN clustered symptoms for this phase. The
- compact variant under the wheel omits the chip row to stay light;
- the calendar-tab variant shows it. */}
- {!compact && chips.length > 0 ? (
+ {/* Zone 3 — the user's OWN clustered symptoms for this phase
+ ("you often notice this here"). */}
+ {chips.length > 0 ? (
{t("cycle.phaseEducation.commonHere")}
diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx
index 5dfd7b204..12752eb12 100644
--- a/src/components/cycle/predictions-panel.tsx
+++ b/src/components/cycle/predictions-panel.tsx
@@ -53,6 +53,10 @@ export interface PredictionsPanelProps {
history: CycleHistoryResponse | undefined;
/** Fallback disclaimer when no prediction carries one. */
fallbackDisclaimer?: string;
+ /** The active cycle goal — only AVOID_PREGNANCY surfaces the (deliberate)
+ * contraceptive-safety caveat here; every other goal drops the generic
+ * "predictions are estimates" line so it does not repeat under every graph. */
+ goal?: string;
}
export function PredictionsPanel({
@@ -60,10 +64,18 @@ export function PredictionsPanel({
rawChartMode,
history,
fallbackDisclaimer,
+ goal,
}: PredictionsPanelProps) {
const { t } = useTranslations();
- const disclaimer = prediction?.disclaimer ?? fallbackDisclaimer ?? "";
+ // Only the AVOID_PREGNANCY goal shows a disclaimer here: it surfaces the
+ // fertile window, so the stronger "not a contraceptive method" caveat is a
+ // deliberate medical safety note. For all other goals the generic
+ // estimate-caveat is suppressed (it already lives once with the data).
+ const disclaimer =
+ goal === "AVOID_PREGNANCY"
+ ? (prediction?.disclaimer ?? fallbackDisclaimer ?? "")
+ : "";
return (
diff --git a/src/components/cycle/use-cycle.ts b/src/components/cycle/use-cycle.ts
index 8b811c1b5..4a3441386 100644
--- a/src/components/cycle/use-cycle.ts
+++ b/src/components/cycle/use-cycle.ts
@@ -335,27 +335,6 @@ export function useDeleteDayLog() {
});
}
-/**
- * Hard-delete EVERY cycle row the user owns (day-logs, cycles, predictions,
- * the cycle audit trail, and the cycle reminder-delivery ledger rows). The
- * privacy "purge" the post-Dobbs threat model promises — distinct from the
- * per-row soft-delete. Invalidates the whole cycle prefix + the nav gate.
- */
-export function useDeleteAllCycleData() {
- const qc = useQueryClient();
- return useMutation({
- mutationFn: async () => {
- const res = await fetch("/api/cycle/all", { method: "DELETE" });
- if (!res.ok) throw new Error(`Request failed: ${res.status}`);
- return unwrap<{ purged: boolean }>(res);
- },
- onSuccess: () => {
- void invalidateKeys(qc, cycleDependentKeys);
- void qc.invalidateQueries({ queryKey: queryKeys.authMe() });
- },
- });
-}
-
export interface CyclePrefsPatch {
enabled?: boolean;
goal?: CycleProfileDTO["goal"];
diff --git a/src/components/settings/_card-header.tsx b/src/components/settings/_card-header.tsx
index 1198dec7d..29d5bf0c1 100644
--- a/src/components/settings/_card-header.tsx
+++ b/src/components/settings/_card-header.tsx
@@ -66,22 +66,32 @@ export function SettingsCardHeader({
className,
}: SettingsCardHeaderProps) {
return (
-
-
-
-
-
- {title}
-
- {titleAccessory}
+
+
+ {/* Title + description share one column to the RIGHT of the icon, so the
+ description left-aligns with the title rather than slipping back under
+ the icon gutter. */}
+
+
+
+
+ {title}
+
+ {titleAccessory}
+
+ {status ? (
+
{status}
+ ) : null}
- {status ?
{status}
: null}
+ {description ? (
+
+ {description}
+
+ ) : null}
- {description ? (
-
- {description}
-
- ) : null}
);
}
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index ccf3a50e3..9e4491cfe 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -155,73 +155,80 @@ export function WebPushCard() {
}
}
+ // The primary action sits in the header row (right of the title) rather than
+ // bottom-left, so an inactive card does not waste a wide empty band before
+ // its enable button. The loading spinner rides the same slot; the
+ // unsupported / denied notices stay in the body.
+ const headerAction = loading ? (
+
+ ) : !isSupported || isDenied ? null : (
+
);
}
From 2b9d2370e9239822ab6fb3af36b3e33935999c94 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 20:36:29 +0200
Subject: [PATCH 2/4] 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.
---
src/app/api/insights/derived/batch/route.ts | 40 ++++++++++++++++---
.../cycle/__tests__/cycle-calendar.test.tsx | 8 ++--
.../__tests__/phase-education-card.test.tsx | 9 +----
src/components/cycle/cycle-calendar.tsx | 10 +++--
src/components/cycle/cycle-view.tsx | 1 -
src/components/cycle/phase-education-card.tsx | 22 ++--------
6 files changed, 52 insertions(+), 38 deletions(-)
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 ? (
) : null}
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx
index 5fd17b3dd..131d21dae 100644
--- a/src/components/cycle/cycle-view.tsx
+++ b/src/components/cycle/cycle-view.tsx
@@ -219,7 +219,6 @@ export function CycleView() {
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
- onLogToday={() => openSheet(today)}
/>
) : null}
diff --git a/src/components/cycle/phase-education-card.tsx b/src/components/cycle/phase-education-card.tsx
index 34d104f31..8ca2b10a0 100644
--- a/src/components/cycle/phase-education-card.tsx
+++ b/src/components/cycle/phase-education-card.tsx
@@ -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({
) : null}
-
- {/* The "log today" nudge — opens the existing day-log sheet. */}
-
>
)}
From a9f57af6022bc6262b04d30008adbafc03306fcd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 6 Jun 2026 20:47:47 +0200
Subject: [PATCH 3/4] feat(insights): cycle ring in the wellness strip, bound
mood/medication calendar height
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surface the cycle phase ring in the main Insights health-scores strip as a
gated sibling tile. It reuses the cycle wheel SVG, sized to a score-ring
tile, and sources day-of-cycle + phase from the same calendar read +
deriveWheelState the cycle page wheel uses, so the two surfaces never
disagree. Mounted only for a cycle-tracking account (the same /api/auth/me
gate the sidebar nav and the cycle summary card use), so its calendar read
never fires otherwise; it renders nothing while resolving, on error, and
when there is no active cycle — no gap, no placeholder.
Cap the adaptive cell size in the mood and medication compliance heatmaps.
A short window (a single medication, a 30-day mood calendar) left only a
few columns, so the container-width cell sizing ballooned and the square
grid blew the tile to roughly three times its neighbours' height. The cell
ceiling keeps the grid dense and left-aligned within the tile-height rhythm
at mobile and desktop.
---
messages/de.json | 3 +
messages/en.json | 3 +
messages/es.json | 3 +
messages/fr.json | 3 +
messages/it.json | 3 +
messages/pl.json | 3 +
src/app/insights/page.tsx | 19 +++
src/components/charts/compliance-heatmap.tsx | 25 +++-
src/components/charts/mood-heatmap.tsx | 22 ++-
.../cycle/__tests__/cycle-ring-tile.test.tsx | 134 ++++++++++++++++++
src/components/cycle/cycle-ring-tile.tsx | 127 +++++++++++++++++
.../insights/derived/wellness-scores.tsx | 29 +++-
12 files changed, 363 insertions(+), 11 deletions(-)
create mode 100644 src/components/cycle/__tests__/cycle-ring-tile.test.tsx
create mode 100644 src/components/cycle/cycle-ring-tile.tsx
diff --git a/messages/de.json b/messages/de.json
index b4de1c4bb..bed6128bc 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "Noch kein aktiver Zyklus",
"viewDetails": "Zyklus-Erkenntnisse ansehen"
},
+ "wellnessRing": {
+ "label": "Zyklus"
+ },
"disclaimer": "Diese Vorhersagen sind beschreibende Schätzungen aus deinen erfassten Daten, keine Verhütungsmethode und keine medizinische Beratung. Sie zeigen nie an, dass ungeschützter Geschlechtsverkehr sicher ist. Wende dich für Verhütung oder medizinische Entscheidungen an eine medizinische Fachperson.",
"title": "Dein Zyklus",
"subtitle": "Erfasse deine Periode und Symptome und sieh, was als Nächstes kommt.",
diff --git a/messages/en.json b/messages/en.json
index 65956ba5c..d9d9a08ba 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "No active cycle yet",
"viewDetails": "View cycle insights"
},
+ "wellnessRing": {
+ "label": "Cycle"
+ },
"disclaimer": "These predictions are descriptive estimates from your logged data, not a contraceptive method and not medical advice. They never indicate that it is safe to have unprotected sex. For contraception or any medical decision, consult a healthcare professional.",
"title": "Your cycle",
"subtitle": "Log your period and symptoms, see what comes next.",
diff --git a/messages/es.json b/messages/es.json
index 55eeb130d..2629b9c90 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "Aún no hay un ciclo activo",
"viewDetails": "Ver análisis del ciclo"
},
+ "wellnessRing": {
+ "label": "Ciclo"
+ },
"disclaimer": "Estas predicciones son estimaciones descriptivas a partir de tus datos registrados, no un método anticonceptivo ni un consejo médico. Nunca indican que sea seguro tener relaciones sin protección. Para anticoncepción o cualquier decisión médica, consulta a un profesional sanitario.",
"title": "Tu ciclo",
"subtitle": "Registra tu periodo y síntomas, y mira qué viene después.",
diff --git a/messages/fr.json b/messages/fr.json
index ee4f7309b..f352bd636 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "Aucun cycle actif pour l'instant",
"viewDetails": "Voir les analyses du cycle"
},
+ "wellnessRing": {
+ "label": "Cycle"
+ },
"disclaimer": "Ces prévisions sont des estimations descriptives issues de vos données enregistrées, ni une méthode de contraception ni un avis médical. Elles n'indiquent jamais qu'il est sûr d'avoir des rapports non protégés. Pour la contraception ou toute décision médicale, consultez un professionnel de santé.",
"title": "Ton cycle",
"subtitle": "Enregistre tes règles et symptômes, et vois ce qui arrive ensuite.",
diff --git a/messages/it.json b/messages/it.json
index 367a129d5..a9325f736 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "Nessun ciclo attivo ancora",
"viewDetails": "Vedi le analisi del ciclo"
},
+ "wellnessRing": {
+ "label": "Ciclo"
+ },
"disclaimer": "Queste previsioni sono stime descrittive basate sui tuoi dati registrati, non un metodo contraccettivo né un parere medico. Non indicano mai che sia sicuro avere rapporti non protetti. Per la contraccezione o qualsiasi decisione medica, consulta un professionista sanitario.",
"title": "Il tuo ciclo",
"subtitle": "Registra il ciclo e i sintomi e scopri cosa arriva dopo.",
diff --git a/messages/pl.json b/messages/pl.json
index 6e5754dce..1e6fe6c4a 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4999,6 +4999,9 @@
"noActiveCycle": "Brak aktywnego cyklu",
"viewDetails": "Zobacz analizy cyklu"
},
+ "wellnessRing": {
+ "label": "Cykl"
+ },
"disclaimer": "Te prognozy to opisowe szacunki na podstawie zapisanych danych, a nie metoda antykoncepcji ani porada medyczna. Nigdy nie oznaczają, że współżycie bez zabezpieczenia jest bezpieczne. W sprawach antykoncepcji lub decyzji medycznych skonsultuj się z pracownikiem ochrony zdrowia.",
"title": "Twój cykl",
"subtitle": "Zapisuj miesiączkę i objawy oraz sprawdzaj, co dalej.",
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index db3a1a1ad..5018a9035 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -167,6 +167,19 @@ const CycleInsightSummaryCard = dynamic(
{ ssr: false },
);
+// v1.15.3 — the compact cycle RING, dropped into the wellness-score strip as a
+// gated sibling tile (NOT the summary teaser — that stays further down). Mounted
+// only for a cycle-tracking account, so its calendar read never fires otherwise.
+// Deferred behind `next/dynamic`; it renders nothing while resolving / on error /
+// when there is no active cycle, so it carries no loading placeholder.
+const CycleRingTile = dynamic(
+ () =>
+ import("@/components/cycle/cycle-ring-tile").then((mod) => ({
+ default: mod.CycleRingTile,
+ })),
+ { ssr: false },
+);
+
/**
* v1.4.25 W4d — Insights mother page.
*
@@ -376,6 +389,12 @@ export default function InsightsPage() {
isLoading={dashboardDerived.isLoading}
isError={dashboardDerived.isError}
refetch={dashboardDerived.refetch}
+ // v1.15.3 — the cycle ring rides the scores strip as a gated sibling
+ // tile, only for a cycle-tracking account, so its calendar read never
+ // fires otherwise (the same `/api/auth/me` gate the sidebar nav uses).
+ extraTile={
+ user?.cycleTrackingEnabled ? : undefined
+ }
/>
{flags.briefing && (
diff --git a/src/components/charts/compliance-heatmap.tsx b/src/components/charts/compliance-heatmap.tsx
index 2cf347f37..c6a23e071 100644
--- a/src/components/charts/compliance-heatmap.tsx
+++ b/src/components/charts/compliance-heatmap.tsx
@@ -24,6 +24,14 @@ const GAP = 3;
// below the touch-friendly 14 px square on narrow viewports. The
// stretch branch computes an adaptive size; both branches clamp here.
const CELL_FLOOR_PX = 14;
+// v1.15.3 — cell-ceiling for the stretch branch. With a short window (a
+// single medication / few covered weeks → only ~5 columns) the adaptive
+// `containerWidth / weeks` cell ballooned to fill a wide card, and since the
+// grid is square the SVG height (`7 * cellSize + 6 * GAP`) blew up to ~3× the
+// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned
+// (extra width is whitespace) so the heatmap holds the tile-height rhythm:
+// height ≈ 18 + 7·22 + 6·3 = 190 px, in line with the surrounding tiles.
+const CELL_CEIL_PX = 22;
function getColor(data: DailyData): string {
if (data.expected === 0) return "var(--secondary)";
@@ -229,10 +237,13 @@ export function ComplianceHeatmap({
// narrows. Static (non-stretch) instances use the canonical 18 px.
const cellSize =
stretch && containerWidth > 0
- ? Math.max(
- CELL_FLOOR_PX,
- (containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
- Math.max(weeks, 1),
+ ? Math.min(
+ CELL_CEIL_PX,
+ Math.max(
+ CELL_FLOOR_PX,
+ (containerWidth - labelWidth - Math.max(0, weeks - 1) * GAP) /
+ Math.max(weeks, 1),
+ ),
)
: CELL_SIZE;
const step = cellSize + GAP;
@@ -253,7 +264,11 @@ export function ComplianceHeatmap({