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({