Merge pull request #279 from MBombeck/release/v1.15.4

v1.15.4 — cycle and insights polish
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-06-06 20:57:05 +02:00
commit e62bb9f96c
28 files changed
+566 -260

No files matched your search

+15 -1
View File
@@ -2,7 +2,21 @@
## [Unreleased]
## [1.15.3] — 2026-06-06 — cycle summary on the insights page
## [1.15.4] — 2026-06-06 — cycle and insights polish
### Added
- **Your cycle ring in the health-scores strip.** With cycle tracking on, the Insights health-scores row now includes your cycle ring — current phase and day — alongside the other scores. It shows only when cycle tracking is enabled.
### Changed
- **A calmer cycle calendar and cards.** Predicted-period days are now a soft filled marker instead of a thin underline; the "what's happening now" card sits only under the ring (no duplicate above the calendar) and drops its own log button since logging is one tap away at the top; and the predictions and insights tabs lost their repeated "estimates / associations" footers (the caveat is stated once, where it matters).
- **Tidier settings.** Section descriptions line up under their headings; the web-push enable action sits in the card header; and the standalone cycle-data export/delete actions are gone from the cycle settings (cycle data is already in the full export).
- **Calendar tiles keep their size.** The mood and medication calendar tiles no longer balloon to several times the height of their neighbours when only a short window or a single medication is shown.
### Fixed
- **Per-score explanations load faster.** The dashboard's batch read now carries each score's deterministic explanation, so the score sheets show their "why" without an extra request.
### Added
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.15.3
version: 1.15.4
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+3
View File
@@ -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.",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.15.3",
"version": "1.15.4",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
+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),
},
};
}),
+19
View File
@@ -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 ? <CycleRingTile /> : undefined
}
/>
{flags.briefing && (
+20 -5
View File
@@ -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({
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid (the capped cells already size
// off `containerWidth`, so a full window still fills the row) rather
// than CSS-stretching ~5 columns of capped cells into wide rectangles.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{/* Month labels */}
+17 -5
View File
@@ -35,6 +35,12 @@ interface MoodHeatmapProps {
const CELL_SIZE = 18;
const GAP = 3;
const CELL_FLOOR_PX = 14;
// v1.15.3 — cell-ceiling for the stretch branch. A short window (few covered
// weeks → only ~5 columns) drove the adaptive `containerWidth / weeks` cell to
// fill a wide card; since the grid is square the SVG height blew up to ~3× the
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so
// the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`.
const CELL_CEIL_PX = 22;
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
@@ -183,10 +189,13 @@ export function MoodHeatmap({
const headerHeight = 18;
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;
@@ -205,7 +214,10 @@ export function MoodHeatmap({
<svg
width={svgWidth}
height={svgHeight}
className={stretch ? "block w-full" : "block"}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() => setTooltip((prev) => (prev?.pinned ? prev : null))}
>
{monthMarkers.map((m, i) => (
@@ -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)", () => {
@@ -0,0 +1,134 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { CalendarResponse } from "../types";
/**
* v1.15.3 — the compact cycle ring as a wellness-strip tile.
*
* The tile is mounted ONLY when `user.cycleTrackingEnabled` is true (a
* page-level gate the Insights page owns, mirroring the summary card); these
* tests cover the tile's own behaviour given that gate: it sources the phase +
* cycle day from the SAME calendar read + `deriveWheelState` the wheel uses,
* renders the cycle ring labelled, deep-links to the cycle page, and stays
* silent while resolving / on a hard error / when there is no active cycle so
* the wellness strip never shows a half-painted dial (no gap, no placeholder).
*
* The calendar read is mocked at the hook boundary so the SSR snapshot is
* deterministic without a TanStack-Query client / network round-trip.
*/
// `next/link` → a plain anchor in the static snapshot.
vi.mock("next/link", () => ({
default: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
const calendarState = vi.hoisted(() => ({
current: {
data: undefined as CalendarResponse | undefined,
isLoading: false,
isError: false,
},
}));
vi.mock("../use-cycle", async () => {
const actual =
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
return {
...actual,
useCycleCalendar: () => calendarState.current,
};
});
import { CycleRingTile } from "../cycle-ring-tile";
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
/** A calendar read where `today` sits on a LUTEAL day at day-of-cycle 3. */
function lutealCalendar(today: string): CalendarResponse {
const days = [
{ date: shift(today, -2), phase: "MENSTRUAL" as const },
{ date: shift(today, -1), phase: "FOLLICULAR" as const },
{ date: today, phase: "LUTEAL" as const },
];
return {
days: days as unknown as CalendarResponse["days"],
profile: {} as CalendarResponse["profile"],
prediction: null,
} as CalendarResponse;
}
function shift(ymd: string, delta: number): string {
const [y, m, d] = ymd.split("-").map(Number);
const dt = new Date(y, m - 1, d);
dt.setDate(dt.getDate() + delta);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
}
function todayYmd(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
beforeEach(() => {
calendarState.current = { data: undefined, isLoading: false, isError: false };
});
describe("<CycleRingTile>", () => {
it("renders the cycle ring, phase label, and deep-link for an active cycle", () => {
calendarState.current = {
data: lutealCalendar(todayYmd()),
isLoading: false,
isError: false,
};
const html = render(<CycleRingTile />);
// The wellness-strip tile chrome + the compact cycle ring.
expect(html).toContain('data-slot="wellness-cycle-tile"');
expect(html).toContain('data-slot="cycle-ring"');
expect(html).toContain('data-phase="LUTEAL"');
// The phase band word + the strip-tile label.
expect(html).toContain("Luteal");
expect(html).toContain("Cycle");
// Deep-link into the cycle page.
expect(html).toContain('href="/cycle"');
});
it("renders nothing while the calendar read is still resolving", () => {
calendarState.current = { data: undefined, isLoading: true, isError: false };
expect(render(<CycleRingTile />)).toBe("");
});
it("renders nothing on a hard calendar read error", () => {
calendarState.current = { data: undefined, isLoading: false, isError: true };
expect(render(<CycleRingTile />)).toBe("");
});
it("renders nothing when there is no active cycle today", () => {
// A resolved calendar with no phase on today → no active cycle.
const empty: CalendarResponse = {
days: [] as unknown as CalendarResponse["days"],
profile: {} as CalendarResponse["profile"],
prediction: null,
} as CalendarResponse;
calendarState.current = { data: empty, isLoading: false, isError: false };
expect(render(<CycleRingTile />)).toBe("");
});
});
@@ -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,24 +93,21 @@ describe("<PhaseEducationCard>", () => {
predictionEnabled={false}
rawChartMode={false}
cyclesObserved={9}
onLogToday={() => {}}
/>,
);
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(
<PhaseEducationCard
compact
phase="LUTEAL"
symptomPatterns={[lutealCramps]}
{...okGate}
/>,
);
expect(html).toContain("progesterone");
expect(html).not.toContain("cycle-phase-education-chips");
// The compact variant still carries the log nudge.
expect(html).toContain("Log today");
expect(html).toContain("cycle-phase-education-chips");
expect(html).toContain("Cramps");
});
});
@@ -37,12 +37,13 @@ const emptyHistory: CycleHistoryResponse = {
};
describe("<PredictionsPanel>", () => {
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(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="AVOID_PREGNANCY"
/>,
);
expect(html).toContain('data-slot="cycle-disclaimer"');
@@ -51,6 +52,21 @@ describe("<PredictionsPanel>", () => {
expect(html).toContain("–");
});
it("suppresses the generic estimate disclaimer for non-AVOID_PREGNANCY goals", () => {
const html = render(
<PredictionsPanel
prediction={basePrediction}
rawChartMode={false}
history={emptyHistory}
goal="GENERAL_HEALTH"
/>,
);
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,
+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}
@@ -215,9 +215,6 @@ export function CyclePhaseCrosstab({
);
})}
</ul>
<p className="text-muted-foreground mt-3 text-xs">
{t("cycle.insights.crosstab.footer")}
</p>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { Droplets } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { CycleRing } from "./cycle-ring";
import { PHASE_HUE } from "./phase-tokens";
import { deriveWheelState } from "./wheel-state";
import { localYmd, useCycleCalendar } from "./use-cycle";
/**
* v1.15.3 — the compact cycle ring as a WELLNESS-STRIP element.
*
* A same-size sibling tile that drops into the main Insights "Your health
* scores" grid (`<WellnessScores>`), so the cycle phase reads as part of the
* premium scores strip rather than bolted-on below it. It reuses the SAME
* `<CycleRing>` SVG the cycle page wheel uses, sized down to match a
* `ScoreRing size="sm"` (≈120 px) so the dial sits in the strip's tile
* rhythm; the tile chrome (hue-tinted `wellness-tile`, icon header, once-per-
* session reveal) mirrors the score `RingTile` exactly.
*
* It is NOT a second `<CycleInsightSummaryCard>` — that teaser (phase finding +
* deep-link) already lives further down the overview. This is the RING only,
* as a wellness dial. Both surfaces derive day-of-cycle + phase from the SAME
* `useCycleCalendar` read + `deriveWheelState`, so they can never disagree.
*
* GATING IS THE CALLER'S JOB. The page mounts this only when
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
* sidebar-nav entry + the summary card gate on), so the cycle read never fires
* for an account without the feature. The tile additionally renders nothing
* while the calendar read resolves, on a hard error, and when there is no
* active cycle phase today — never an empty-framed or half-painted dial in the
* strip (no gap, no placeholder).
*/
/** YYYY-MM-DD for `n` days from now in the local tz (shares `localYmd`). */
function shiftToday(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return localYmd(d);
}
export function CycleRingTile({ className }: { className?: string }) {
const { t } = useTranslations();
// Mirror the cycle-view + summary-card window so the cache key is shared —
// no extra request when the user opens the cycle page in the same session.
const today = useMemo(() => shiftToday(0), []);
const from = useMemo(() => shiftToday(-90), []);
const to = useMemo(() => shiftToday(180), []);
const calendar = useCycleCalendar(from, to);
const wheel = useMemo(
() => deriveWheelState(calendar.data?.days ?? [], today),
[calendar.data, today],
);
// The signature reveal plays ONCE per browser session, gated on its own key
// so a background calendar refetch never re-triggers the moment. The
// reduced-motion zeroing lives inside `<CycleRing>`.
const [play] = useState(() => {
if (typeof window === "undefined") return false;
try {
if (sessionStorage.getItem("cycle-strip-ring-revealed")) return false;
sessionStorage.setItem("cycle-strip-ring-revealed", "1");
return true;
} catch {
return true;
}
});
// Stay silent until the read resolves, on a hard error, and when today has
// no phase (no active cycle) — the strip must never show a half-painted or
// empty-framed cycle dial. The full cycle page owns the empty-state CTA.
if (calendar.isLoading && !calendar.data) return null;
if (calendar.isError && !calendar.data) return null;
if (wheel.phase == null || wheel.dayOfCycle == null) return null;
const hue = PHASE_HUE[wheel.phase];
return (
<Link
href="/cycle"
data-slot="wellness-cycle-tile"
data-metric="CYCLE"
data-phase={wheel.phase}
data-revealed={play ? "true" : undefined}
// Match the score `RingTile` chrome: the phase's `--tile-hue` low-opacity
// mix over `--card` + the faint film grain (the `.wellness-tile` family),
// with a calm once-per-session rise. The per-phase hue arc inside the
// ring is the only saturated thing on the tile.
style={{ "--tile-hue": hue } as React.CSSProperties}
className={cn(
"wellness-tile focus-visible:ring-ring flex flex-col gap-4 rounded-xl p-5 focus-visible:ring-2 focus-visible:outline-none",
play && "wellness-tile-rise",
className,
)}
>
<div className="text-foreground flex items-center gap-2">
<Droplets className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="truncate text-base leading-none font-semibold">
{t("cycle.wellnessRing.label")}
</span>
</div>
<div className="flex flex-col items-center gap-1.5">
<CycleRing
dayOfCycle={wheel.dayOfCycle}
cycleLength={wheel.cycleLength}
phase={wheel.phase}
spans={wheel.spans}
animate={play}
size={120}
/>
<span
data-slot="wellness-cycle-band-word"
className="text-muted-foreground text-center text-xs"
>
{t(`cycle.phase.${wheel.phase}`)}
</span>
</div>
</Link>
);
}
+5 -84
View File
@@ -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<CycleGoal>(profile.goal);
const [rawChartMode, setRawChartMode] = useState(profile.rawChartMode);
@@ -228,87 +226,10 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
</div>
) : null}
<Separator />
{/* Data export / delete */}
<div className="space-y-2">
<p className="text-sm font-medium">{t("cycle.settings.dataTitle")}</p>
<p className="text-muted-foreground text-sm">
{t("cycle.settings.dataDescription")}
</p>
<div className="flex flex-wrap gap-2 pt-1">
<Button variant="outline" size="sm" asChild>
<a href="/api/export/health-record" download>
{t("cycle.settings.export")}
</a>
</Button>
<Button variant="outline" size="sm" asChild>
<Link href="/settings/account">
{t("cycle.settings.deleteData")}
</Link>
</Button>
</div>
{/* 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. */}
<div className="border-destructive/30 mt-3 space-y-2 rounded-md border p-3">
<p className="text-muted-foreground text-xs">
{t("cycle.settings.purgeDescription")}
</p>
{confirmingPurge ? (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium" role="alert">
{t("cycle.settings.purgeConfirm")}
</span>
<Button
variant="destructive"
size="sm"
disabled={purge.isPending}
onClick={async () => {
try {
await purge.mutateAsync();
} finally {
setConfirmingPurge(false);
}
}}
>
{purge.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("cycle.settings.purgeConfirmAction")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmingPurge(false)}
>
{t("cycle.settings.purgeCancel")}
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="text-destructive border-destructive/40 hover:bg-destructive/10"
onClick={() => setConfirmingPurge(true)}
>
{t("cycle.settings.purgeData")}
</Button>
)}
{purge.isSuccess ? (
<span className="text-muted-foreground text-sm" role="status">
{t("cycle.settings.purgeDone")}
</span>
) : null}
{purge.isError ? (
<span className="text-destructive text-sm" role="alert">
{t("cycle.settings.purgeError")}
</span>
) : null}
</div>
</div>
{/* 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. */}
<div className="flex items-center justify-end gap-3 pt-2">
{update.isSuccess ? (
+4 -14
View File
@@ -208,18 +208,17 @@ export function CycleView() {
) : null}
</div>
{/* 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 ? (
<PhaseEducationCard
compact
animate={play}
phase={wheel.phase}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
onLogToday={() => openSheet(today)}
/>
) : null}
</div>
@@ -241,16 +240,6 @@ export function CycleView() {
</TabsList>
<TabsContent value="calendar" className="mt-4 space-y-4">
{!loading && !calendarError ? (
<PhaseEducationCard
phase={wheel.phase}
symptomPatterns={insights.data?.symptomPatterns ?? []}
predictionEnabled={calProfile?.predictionEnabled ?? false}
rawChartMode={calProfile?.rawChartMode ?? false}
cyclesObserved={calProfile?.cyclesObserved ?? 0}
onLogToday={() => openSheet(today)}
/>
) : null}
<Card>
<CardContent className="py-4">
{loading ? (
@@ -292,6 +281,7 @@ export function CycleView() {
rawChartMode={calendar.data?.profile.rawChartMode ?? false}
history={history.data}
fallbackDisclaimer={disclaimerText}
goal={goal}
/>
<CycleHistoryChart history={history.data} animate={play} />
<BbtChart
+7 -28
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,12 +46,8 @@ 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;
/** Compact variant beneath the wheel (tighter padding, no chip row). */
compact?: boolean;
className?: string;
}
@@ -62,9 +57,7 @@ export function PhaseEducationCard({
predictionEnabled,
rawChartMode,
cyclesObserved,
onLogToday,
animate = false,
compact = false,
className,
}: PhaseEducationCardProps) {
const { t } = useTranslations();
@@ -118,8 +111,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 +151,9 @@ export function PhaseEducationCard({
{t(PHASE_WHATS_HAPPENING_KEY[phase as CyclePhase])}
</p>
{/* 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 ? (
<div className="mt-4">
<p className="text-muted-foreground text-xs font-medium">
{t("cycle.phaseEducation.commonHere")}
@@ -194,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>
+13 -1
View File
@@ -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 (
<div className="space-y-4">
-21
View File
@@ -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"];
@@ -47,6 +47,16 @@ interface ScoreStripProps {
isError?: boolean;
/** Refetch the one shared batch query. Recovers both strips at once. */
refetch?: () => void;
/**
* An optional extra tile rendered as the LAST sibling inside the same scores
* grid (e.g. the gated cycle ring). It participates in the column-count math
* so it never leaves a dangling empty cell, and it keeps the strip mounted
* even when no wellness score has data (a cycle-only account still sees its
* dial). The caller owns the gating — the strip just slots whatever it gets.
* The element is expected to render `null` on its own when it has nothing to
* show, so the strip subtracts it from the count in that case.
*/
extraTile?: React.ReactNode;
className?: string;
}
@@ -149,6 +159,7 @@ export function WellnessScores({
isLoading,
isError = false,
refetch,
extraTile,
className,
}: ScoreStripProps) {
const { t } = useTranslations();
@@ -310,7 +321,23 @@ export function WellnessScores({
);
}
// Whole strip un-mounts when no score has data — never an empty section.
// The optional caller-supplied tile (e.g. the gated cycle ring) slots in as
// the last sibling. It is counted toward the grid column math so it never
// leaves a dangling cell, and its presence keeps the strip mounted even when
// no wellness score has data (a cycle-only account still sees its dial). The
// tile renders `null` on its own when it has nothing to show; the grid has
// no background, so a transient empty trailing cell shows nothing — never a
// "missing tile" frame.
if (extraTile != null) {
tiles.push(
<div key="EXTRA" data-slot="wellness-extra-tile-slot" className="contents">
{extraTile}
</div>,
);
}
// Whole strip un-mounts when no score has data AND no extra tile — never an
// empty section.
if (tiles.length === 0) return null;
// v1.12.4 — the strip used a fixed `lg:grid-cols-5`, so the common case of
+24 -14
View File
@@ -66,22 +66,32 @@ export function SettingsCardHeader({
className,
}: SettingsCardHeaderProps) {
return (
<header className={cn("space-y-1", className)}>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<Icon className="text-primary h-5 w-5 shrink-0" aria-hidden="true" />
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
{titleAccessory}
<header className={cn("flex items-start gap-2", className)}>
<Icon
className="text-primary mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
{/* 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. */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<h2 id={titleId} className="text-lg font-semibold">
{title}
</h2>
{titleAccessory}
</div>
{status ? (
<div className="flex shrink-0 items-center gap-2">{status}</div>
) : null}
</div>
{status ? <div className="flex shrink-0 items-center gap-2">{status}</div> : null}
{description ? (
<div className="text-muted-foreground space-y-1 text-xs">
{description}
</div>
) : null}
</div>
{description ? (
<div className="text-muted-foreground space-y-1 text-xs">
{description}
</div>
) : null}
</header>
);
}
+65 -58
View File
@@ -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 ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
<div className="flex flex-wrap items-center justify-end gap-2">
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
);
return (
<div className="bg-card border-border rounded-xl border p-6">
<SettingsCardHeader
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
status={headerAction}
/>
<div className="mt-4 space-y-4">
{loading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
</div>
) : !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
) : isDenied ? (
<p className="text-destructive text-sm">
{t("settings.webPushDenied")}
</p>
) : (
<div className="flex flex-wrap items-start gap-2">
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
disabled={actionLoading}
>
{actionLoading && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.webPushUnsubscribe")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
disabled={actionLoading}
>
{actionLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<BellRing className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
)}
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
<div className="mt-4 space-y-4">
{!loading && !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
</p>
) : !loading && isDenied ? (
<p className="text-destructive text-sm">
{t("settings.webPushDenied")}
</p>
) : null}
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
{msg && (
<p
role="alert"
className={`text-sm ${msgType === "success" ? "text-success" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
) : null}
</div>
);
}