mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #278 from MBombeck/release/v1.15.3
v1.15.3 — cycle summary on the insights page
This commit is contained in:
14 files changed
+418
-5
No files matched your search
@@ -2,6 +2,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.15.3] — 2026-06-06 — cycle summary on the insights page
|
||||
|
||||
### Added
|
||||
|
||||
- **Your cycle at a glance on Insights.** If you track your cycle, the Insights page now shows a compact card with your current phase and cycle day plus the one standout phase relation, with a tap-through to the full cycle insights. It appears only when cycle tracking is on, and the detailed phase analysis stays in the cycle section.
|
||||
|
||||
## [1.15.2] — 2026-06-06 — cycle insights crash fix
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.15.2
|
||||
version: 1.15.3
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Heute eintragen",
|
||||
"stillLearning": "Lerne deinen Zyklus noch kennen. Trage weiter ein, dann füllt sich das mit Mustern aus deinen eigenen Tagen."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Dein Zyklus",
|
||||
"currentDay": "Tag {day} deines Zyklus",
|
||||
"noActiveCycle": "Noch kein aktiver Zyklus",
|
||||
"viewDetails": "Zyklus-Erkenntnisse ansehen"
|
||||
},
|
||||
"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.",
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Log today",
|
||||
"stillLearning": "Still learning your cycle. Keep logging and this fills in with patterns from your own days."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Your cycle",
|
||||
"currentDay": "Day {day} of your cycle",
|
||||
"noActiveCycle": "No active cycle yet",
|
||||
"viewDetails": "View cycle insights"
|
||||
},
|
||||
"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.",
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Registrar hoy",
|
||||
"stillLearning": "Aún estamos conociendo tu ciclo. Sigue registrando y esto se irá completando con patrones de tus propios días."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Tu ciclo",
|
||||
"currentDay": "Día {day} de tu ciclo",
|
||||
"noActiveCycle": "Aún no hay un ciclo activo",
|
||||
"viewDetails": "Ver análisis del 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.",
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Noter aujourd'hui",
|
||||
"stillLearning": "On apprend encore ton cycle. Continue à noter et cela se remplira avec les tendances de tes propres journées."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Ton cycle",
|
||||
"currentDay": "Jour {day} de ton cycle",
|
||||
"noActiveCycle": "Aucun cycle actif pour l'instant",
|
||||
"viewDetails": "Voir les analyses du 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.",
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Registra oggi",
|
||||
"stillLearning": "Stiamo ancora imparando il tuo ciclo. Continua a registrare e questo si completerà con gli schemi delle tue giornate."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Il tuo ciclo",
|
||||
"currentDay": "Giorno {day} del tuo ciclo",
|
||||
"noActiveCycle": "Nessun ciclo attivo ancora",
|
||||
"viewDetails": "Vedi le analisi del 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.",
|
||||
|
||||
@@ -4993,6 +4993,12 @@
|
||||
"trackNudge": "Zapisz dziś",
|
||||
"stillLearning": "Wciąż poznajemy Twój cykl. Zapisuj dalej, a to wypełni się wzorcami z Twoich własnych dni."
|
||||
},
|
||||
"insightsSummary": {
|
||||
"title": "Twój cykl",
|
||||
"currentDay": "Dzień {day} Twojego cyklu",
|
||||
"noActiveCycle": "Brak aktywnego cyklu",
|
||||
"viewDetails": "Zobacz analizy cyklu"
|
||||
},
|
||||
"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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.15.2",
|
||||
"version": "1.15.3",
|
||||
"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",
|
||||
|
||||
+15
-2
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -40,5 +40,18 @@ export default function CyclePage() {
|
||||
);
|
||||
}
|
||||
|
||||
return <CycleView />;
|
||||
// `<CycleView>` reads `useSearchParams` for the `?tab=` deep-link; wrap it in
|
||||
// a Suspense boundary so the client-search-params bailout never de-opts the
|
||||
// build (Next's `missing-suspense-with-csr-bailout`).
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CycleView />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +153,20 @@ const PeriodNarrativeCard = dynamic(
|
||||
},
|
||||
);
|
||||
|
||||
// v1.15.2 — the gated cycle-insights summary teaser. Mounted ONLY when
|
||||
// `user.cycleTrackingEnabled` is true (the same /api/auth/me signal the
|
||||
// sidebar nav entry gates on), so the cycle reads never fire for an account
|
||||
// without the feature. Deferred behind `next/dynamic` like the other
|
||||
// below-the-hero blocks; it owns its own calendar + insights reads and renders
|
||||
// nothing while resolving / on error, so it carries no loading placeholder.
|
||||
const CycleInsightSummaryCard = dynamic(
|
||||
() =>
|
||||
import("@/components/cycle/cycle-insight-summary-card").then((mod) => ({
|
||||
default: mod.CycleInsightSummaryCard,
|
||||
})),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
/**
|
||||
* v1.4.25 W4d — Insights mother page.
|
||||
*
|
||||
@@ -384,6 +398,11 @@ export default function InsightsPage() {
|
||||
|
||||
{flags.briefing && <PeriodNarrativeCard enabled={isAuthenticated} />}
|
||||
|
||||
{/* v1.15.2 — gated cycle teaser. Render only for a cycle-tracking
|
||||
account; for everyone else this is nothing (no card, no layout gap).
|
||||
The card itself stays silent until its reads resolve. */}
|
||||
{user?.cycleTrackingEnabled ? <CycleInsightSummaryCard /> : null}
|
||||
|
||||
<CoincidentDeviationCard enabled={isAuthenticated} />
|
||||
|
||||
<RhythmEventsCard enabled={isAuthenticated} />
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
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";
|
||||
import type { CycleInsightsResponse } from "../use-cycle";
|
||||
import type { CyclePhaseCrosstabRow } from "../cycle-phase-crosstab";
|
||||
|
||||
/**
|
||||
* v1.15.2 — the gated cycle-insights summary teaser on the main Insights page.
|
||||
*
|
||||
* The card itself is mounted ONLY when `user.cycleTrackingEnabled` is true (a
|
||||
* page-level gate); these tests cover the card's own behaviour given that gate:
|
||||
* it sources the phase + cycle day from the SAME calendar read the wheel uses,
|
||||
* renders the shared FDR-gated headline, deep-links to the cycle insights tab,
|
||||
* and stays silent while resolving / on a hard error so the overview never
|
||||
* shows a broken cycle teaser.
|
||||
*
|
||||
* The cycle reads are 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,
|
||||
},
|
||||
}));
|
||||
const insightsState = vi.hoisted(() => ({
|
||||
current: {
|
||||
data: undefined as CycleInsightsResponse | undefined,
|
||||
isError: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../use-cycle", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../use-cycle")>("../use-cycle");
|
||||
return {
|
||||
...actual,
|
||||
useCycleCalendar: () => calendarState.current,
|
||||
useCycleInsights: () => insightsState.current,
|
||||
};
|
||||
});
|
||||
|
||||
import { CycleInsightSummaryCard } from "../cycle-insight-summary-card";
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build a calendar read where `today` sits on a LUTEAL day at day-of-cycle 3
|
||||
* (a short MENSTRUAL run → LUTEAL today, so `deriveWheelState` resolves a
|
||||
* phase + day). */
|
||||
function lutealCalendar(today: string): CalendarResponse {
|
||||
// Three consecutive days ending today: MENSTRUAL, FOLLICULAR, LUTEAL.
|
||||
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")}`;
|
||||
}
|
||||
|
||||
const headlineRow: CyclePhaseCrosstabRow = {
|
||||
metricKey: "restingHeartRate",
|
||||
display: "bpm",
|
||||
lutealDays: 12,
|
||||
follicularDays: 11,
|
||||
lutealAvg: 64,
|
||||
follicularAvg: 60,
|
||||
delta: 4,
|
||||
pValue: 0.01,
|
||||
qValue: 0.03,
|
||||
confidence: "high",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
calendarState.current = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
insightsState.current = { data: undefined, isError: false };
|
||||
});
|
||||
|
||||
describe("<CycleInsightSummaryCard>", () => {
|
||||
it("renders the current phase, cycle day, headline, and the deep-link", () => {
|
||||
calendarState.current = {
|
||||
data: lutealCalendar(todayYmd()),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
insightsState.current = {
|
||||
data: { rows: [headlineRow], headline: headlineRow, symptomPatterns: [] },
|
||||
isError: false,
|
||||
};
|
||||
|
||||
const html = render(<CycleInsightSummaryCard />);
|
||||
|
||||
// Phase name + cycle-day read (day 3 of the 3-day run).
|
||||
expect(html).toContain("Luteal");
|
||||
expect(html).toContain("Day 3 of your cycle");
|
||||
// The shared FDR-gated headline (resting-heart-rate phrasing).
|
||||
expect(html).toContain("resting heart rate");
|
||||
// Deep-link into the single source of truth (cycle insights tab).
|
||||
expect(html).toContain('href="/cycle?tab=insights"');
|
||||
expect(html).toContain("View cycle insights");
|
||||
expect(html).toContain('data-phase="LUTEAL"');
|
||||
});
|
||||
|
||||
it("shows the honest empty headline line when nothing cleared the FDR gate", () => {
|
||||
calendarState.current = {
|
||||
data: lutealCalendar(todayYmd()),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
insightsState.current = {
|
||||
data: { rows: [], headline: null, symptomPatterns: [] },
|
||||
isError: false,
|
||||
};
|
||||
|
||||
const html = render(<CycleInsightSummaryCard />);
|
||||
// Calm "keep logging" line from the shared <CyclePhaseHeadline>.
|
||||
expect(html).toContain("Not enough cycles");
|
||||
// Still a teaser with a working deep-link.
|
||||
expect(html).toContain('href="/cycle?tab=insights"');
|
||||
});
|
||||
|
||||
it("renders nothing while the calendar read is still resolving", () => {
|
||||
calendarState.current = { data: undefined, isLoading: true, isError: false };
|
||||
const html = render(<CycleInsightSummaryCard />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing on a hard calendar read error", () => {
|
||||
calendarState.current = {
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
};
|
||||
const html = render(<CycleInsightSummaryCard />);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, Sparkles } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { deriveWheelState } from "./wheel-state";
|
||||
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
|
||||
|
||||
/**
|
||||
* v1.15.2 — the gated cycle-insights summary card for the main Insights page.
|
||||
*
|
||||
* A compact TEASER + entry point, never a duplicate of the full cycle insights
|
||||
* tab. It surfaces three things and links into the source of truth:
|
||||
* 1. the current phase (name + hue dot) and cycle day, derived from the
|
||||
* SAME calendar read + `deriveWheelState` the cycle wheel uses, so the
|
||||
* two surfaces can never disagree on the day-of-cycle or phase;
|
||||
* 2. the one headline phase finding, rendered through the SHARED
|
||||
* `<CyclePhaseHeadline>` (which already owns the FDR-gated copy + the
|
||||
* honest "not enough cycles yet" empty line) — no re-implementation;
|
||||
* 3. a "view details" deep-link into `/cycle?tab=insights`.
|
||||
*
|
||||
* Visual idiom mirrors `phase-education-card.tsx`: a hue-tinted `wellness-tile`
|
||||
* keyed to the ACTIVE phase via `--tile-hue`, with one calm entrance that the
|
||||
* global `prefers-reduced-motion` block already zeroes. The phase semantic is
|
||||
* never colour-only — the phase word + an aria-label always accompany the hue.
|
||||
*
|
||||
* GATING IS THE CALLER'S JOB: the page only mounts this when
|
||||
* `user.cycleTrackingEnabled` is true (the same `/api/auth/me` signal the
|
||||
* sidebar nav entry uses), so the two cycle reads never fire for an account
|
||||
* without the feature. The component additionally renders nothing while the
|
||||
* calendar read is still resolving and on a hard read error, so it never shows
|
||||
* a broken or empty-framed state on the overview.
|
||||
*/
|
||||
|
||||
/** 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 CycleInsightSummaryCard() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Mirror the cycle-view window so the cache key is shared (no extra request
|
||||
// when the user later 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 insights = useCycleInsights();
|
||||
|
||||
const wheel = useMemo(
|
||||
() => deriveWheelState(calendar.data?.days ?? [], today),
|
||||
[calendar.data, today],
|
||||
);
|
||||
|
||||
// Stay silent until the calendar read resolves, and on a hard error: the
|
||||
// overview must never show a half-painted or error-framed cycle teaser. The
|
||||
// full cycle page owns the retry affordance.
|
||||
if (calendar.isLoading && !calendar.data) return null;
|
||||
if (calendar.isError && !calendar.data) return null;
|
||||
|
||||
const phase = wheel.phase;
|
||||
const hue = phase ? PHASE_HUE[phase] : PHASE_HUE.LUTEAL;
|
||||
const phaseName = phase ? t(`cycle.phase.${phase}`) : t("cycle.phase.none");
|
||||
const dayOfCycle = wheel.dayOfCycle;
|
||||
|
||||
const ariaLabel =
|
||||
phase && dayOfCycle != null
|
||||
? t("cycle.ring.ariaPhase", { day: dayOfCycle, phase: phaseName })
|
||||
: t("cycle.ring.ariaUnknown");
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="cycle-insight-summary"
|
||||
data-revealed="true"
|
||||
data-phase={phase ?? "none"}
|
||||
aria-label={t("cycle.insightsSummary.title")}
|
||||
style={{ "--tile-hue": hue } as React.CSSProperties}
|
||||
className="wellness-tile wellness-tile-rise rounded-xl px-5 py-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Zone 1 — eyebrow + the current phase / cycle-day read. */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles
|
||||
className="h-4 w-4 shrink-0"
|
||||
style={{ color: hue }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t("cycle.insightsSummary.title")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
className="text-foreground mt-2 flex items-center gap-2 text-base font-semibold"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: hue }}
|
||||
/>
|
||||
<span>{phaseName}</span>
|
||||
</h3>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm" aria-hidden="true">
|
||||
{dayOfCycle != null
|
||||
? t("cycle.insightsSummary.currentDay", { day: dayOfCycle })
|
||||
: t("cycle.insightsSummary.noActiveCycle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone 2 — the one headline phase finding (shared component owns the
|
||||
FDR-gated copy AND the honest "not enough cycles yet" empty line, so
|
||||
this can never show a broken state). */}
|
||||
<div className="mt-3">
|
||||
<CyclePhaseHeadline headline={insights.data?.headline ?? null} />
|
||||
</div>
|
||||
|
||||
{/* Zone 3 — the deep-link into the single source of truth: the cycle
|
||||
insights tab. The cycle page reads `?tab=insights` and opens that tab. */}
|
||||
<Link
|
||||
href="/cycle?tab=insights"
|
||||
data-slot="cycle-insight-summary-link"
|
||||
className="text-foreground bg-background/55 border-foreground/15 hover:bg-background/80 focus-visible:ring-ring/50 mt-4 inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
{t("cycle.insightsSummary.viewDetails")}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Loader2, Plus, RefreshCw } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -51,9 +52,26 @@ function shiftToday(days: number): string {
|
||||
return localYmd(d);
|
||||
}
|
||||
|
||||
/** The four cycle tabs, in render order. The deep-link value is validated
|
||||
* against this set so a junk `?tab=` falls back to the calendar tab. */
|
||||
const CYCLE_TABS = ["calendar", "predictions", "insights", "settings"] as const;
|
||||
type CycleTab = (typeof CYCLE_TABS)[number];
|
||||
|
||||
export function CycleView() {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Deep-link support: `/cycle?tab=insights` opens the insights tab. The value
|
||||
// is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal
|
||||
// clicking still works), validated against the known set, and falls back to
|
||||
// the calendar tab on anything else.
|
||||
const searchParams = useSearchParams();
|
||||
const initialTab: CycleTab = useMemo(() => {
|
||||
const raw = searchParams?.get("tab");
|
||||
return raw && (CYCLE_TABS as readonly string[]).includes(raw)
|
||||
? (raw as CycleTab)
|
||||
: "calendar";
|
||||
}, [searchParams]);
|
||||
|
||||
const today = useMemo(() => shiftToday(0), []);
|
||||
const from = useMemo(() => shiftToday(-90), []);
|
||||
const to = useMemo(() => shiftToday(180), []);
|
||||
@@ -206,7 +224,7 @@ export function CycleView() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="calendar">
|
||||
<Tabs defaultValue={initialTab}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="calendar" className="flex-1">
|
||||
{t("cycle.tabs.calendar")}
|
||||
|
||||
Reference in new issue
Block a user