diff --git a/CHANGELOG.md b/CHANGELOG.md index defa78106..e6164b733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## [Unreleased] +## [1.16.1] — 2026-06-10 — a chat worth using, honest sleep stages, stock you can record + +### Added + +- **The inventory tab records stock.** Packs and pens with optional expiry, plus corrections and withdrawals — no API detour. The medication detail page also gains a dedicated API tab, an editor entry under lifecycle replacing the misleading header button, and a danger zone on the shared card surface with only its buttons in red. +- **A shared filter bar.** Measurements and mood lists filter through one pill-rail language — active filters read as removable chips, date ranges live in a popover, every page feels the same. +- **Six care achievements.** Miss-free dose streaks (7/30/90 days), a four-week measurement routine, a complete self-context and a week of sleep logging. + +### Changed + +- **The coach chat behaves like one.** Single-line composer that grows with the text, a typing indicator, no inline disclaimer, a context rail that hides until asked, conversations in the full view, and coach preferences in Settings. Clearly stated allergies and conditions are remembered immediately. +- **Settings and admin share one alignment language.** Icon column left, content flush on its own column, neutral header icons, no duplicated titles, and every select on the account-style idiom with a visible chevron. +- **First paint shows silhouettes, not bare cards**, and charts reveal within 1.2 seconds even when one widget lags. + +### Fixed + +- **Sleep stages show again next to coarse sources.** A night carrying granular watch stages alongside coarse phone samples picked the coarse source and showed only awake/asleep; the granular source now wins its session. +- **The next-dose line tells the truth after a schedule change.** It reads from the dose calendar instead of a stale schedule window; the repair script reconciles drifted windows and clears pending rows on abandoned anchors. +- **Nightly insight texts are actually there in the morning.** The warm pass no longer skips the status cards when the briefing step is budget-blocked or fails, and one hanging provider cannot stall the whole run. +- The welcome tour stays inside the viewport — no more sideways scrolling to reach its buttons, with a bottom-sheet fallback on narrow screens. +- The dashboard customize icon matches insights, the target-settings link leaves the home charts, and the mood heatmap adopts the damped fills of its sibling charts. + ## [1.16.0] — 2026-06-10 — a coach that knows you, invites, and a faster, calmer app ### Added diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 48e6f8c6e..5d212b1aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.16.0 + version: 1.16.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/docs/ops/intake-repair.md b/docs/ops/intake-repair.md index 1c09be209..609ae58b3 100644 --- a/docs/ops/intake-repair.md +++ b/docs/ops/intake-repair.md @@ -1,7 +1,8 @@ # Medication intake repair -`scripts/repair-intake-anomalies.ts` repairs two known historic defects in -the medication intake ledger (`medication_intake_events`): +`scripts/repair-intake-anomalies.ts` repairs the known historic defects in +the medication intake ledger (`medication_intake_events`) and its schedule +metadata (`medication_schedules`): 1. **Duplicate dose-slot rows.** More than one live row on the same exact `(user, medication, scheduled_for)` tuple — cross-source duplicates the @@ -12,6 +13,20 @@ the medication intake ledger (`medication_intake_events`): 7 days before `scheduled_for` or more than 1 day in the future — typically the residue of a mis-attributed edit before the v1.15.19 `taken_at` validation landed. +3. **Window / times drift (v1.16.1).** Schedule rows whose legacy + `window_start` / `window_end` no longer contains the canonical + `times_of_day` — typically a degenerate `07:00 / 07:00` point window + left behind by an old write path while the dose times moved on (e.g. + to `09:00 / 21:00`). The dose-band model never reads the window once + `times_of_day` exists, but the stale pair kept feeding every legacy + read (the pre-v1.16.1 cards painted "next intake 07:00" from it). +4. **Stale-anchor pending rows (v1.16.1).** Live pending rows + (`taken_at IS NULL`, not skipped, not auto-missed) whose + `scheduled_for` wall-clock HH:mm — evaluated in the user's timezone — + matches no current dose anchor of the medication (any schedule's + `times_of_day` entry; `window_start` for legacy rows without times). + These are reminder-minted slots on retired schedule times that linger + as phantom open doses in the ledger. ## When to use it @@ -51,8 +66,10 @@ Use `pnpm dlx`, not bare `pnpm tsx` — the standalone image also strips `tsx`. The dry-run prints every duplicate group (which row would be kept, which -would be tombstoned) and a table of implausible rows (id, medication, -`scheduledFor`, `takenAt`, source, timestamps). +would be tombstoned), a table of implausible rows (id, medication, +`scheduledFor`, `takenAt`, source, timestamps), every schedule whose +window has drifted off its `times_of_day` (with the reconciled bounds it +would write), and a table of stale-anchor pending rows. ## Applying the fix @@ -60,7 +77,7 @@ would be tombstoned) and a table of implausible rows (id, medication, pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` -`--fix` does three things: +`--fix` does five things: - **Duplicate groups** are collapsed to one winner per slot. Precedence: an actioned row (taken or skipped) beats a pending one; between two @@ -79,6 +96,14 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix ``` `--tombstone-implausible` is refused without `--fix`. +- **Drifted schedule windows are reconciled** to + `min/max(times_of_day)` so the legacy pair stops contradicting the + canonical dose times. Overnight windows (`window_end < window_start`) + encode a deliberate wrap and are never touched. +- **Stale-anchor pending rows are tombstoned** (same soft-delete shape + as the duplicate losers). A medication without any derivable anchor + (PRN-only / unscheduled) is skipped — there is nothing to compare + against. - **Affected compliance rollups are recomputed** for every touched `(user, medication, day)`. The script executes the same DISTINCT-slot aggregation SQL the shared helper runs (a verbatim twin of diff --git a/docs/ui-guidelines.md b/docs/ui-guidelines.md index 1afa09ad3..fcb14b166 100644 --- a/docs/ui-guidelines.md +++ b/docs/ui-guidelines.md @@ -198,7 +198,51 @@ admin views. Default to `` + list rows for everything else; tables on mobile collapse poorly. When you must use a table on mobile, swap to a vertical list at `sm:` breakpoint. -### 2.6 Status indicators +### 2.6 Filter bars over data lists + +Every list surface with filters (measurements, mood, future admin +lists) uses the unified `` rail from +`src/components/ui/filter-bar.tsx` — never a per-page row of large +labelled ` - setDraft((prev) => ({ ...prev, tone: value as CoachTone })) - } - > - - - - - - {t("insights.coach.settingsToneWarm")} - - - {t("insights.coach.settingsToneNeutral")} - - - {t("insights.coach.settingsToneConcise")} - - - - - - {/* Verbosity */} -
- - -
- - - {/* v1.4.25 W5 — default analysis window. Sets the snapshot - scope window the Coach reads when the client doesn't - supply a per-conversation override. The drawer header - carries a small pill that overrides the picked default - for the current chat only. */} -
- -

- {t("insights.coach.settingsDefaultWindowHint")} -

- -
- - {/* v1.7.0 — clustered, opt-in data sources. */} -
- -

- {t("insights.coach.settingsClustersHint")} -

-
    - {CLUSTER_OPTIONS.map((cluster) => { - const id = `coach-prefs-cluster-${cluster}`; - const checked = enabledClusters.has(cluster); - return ( -
  • -
    - - - {t(`insights.coach.cluster.${cluster}.hint`)} - -
    - toggleCluster(cluster, next)} - /> -
  • - ); - })} -
-
- - {/* Exclude metrics — advanced fine-tune. Subtracts a single - metric inside an otherwise-enabled cluster. */} -
- -

- {t("insights.coach.settingsExcludeHint")} -

-
    - {EXCLUDE_OPTIONS.map((metric) => { - const id = `coach-prefs-exclude-${metric}`; - const checked = draft.excludeMetrics.includes(metric); - return ( -
  • - - toggleExclude(metric, next)} - /> -
  • - ); - })} -
-
- - {/* v1.4.36 W3 T2 — optional context blocks. Mirrors the - EXCLUDE_OPTIONS list mechanically (same on/off switch - pattern, same toggleExclude handler) but groups the - profile / medication context toggles in their own - section so the intent reads distinctly. Empty blocks - are dropped at the snapshot/feature layer when the - toggle is on. */} -
- -

- {t("insights.coach.settingsContextHint")} -

-
    - {CONTEXT_OPTIONS.map((metric) => { - const id = `coach-prefs-context-${metric}`; - const checked = draft.excludeMetrics.includes(metric); - return ( -
  • - - toggleExclude(metric, next)} - /> -
  • - ); - })} -
-
- - {/* v1.4.27 F14 — the `showEvidenceByDefault` switch was - retired. The evidence disclosure is now always closed - by default and the user expands by click; surfacing - raw values automatically created an UX trap. The - persisted `coachPrefs.showEvidenceByDefault` field - stays in the schema for backward compatibility, but no - UI reads or writes it. */} - - )} - -
- - -
- - - ); -} diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 96d1fa99a..477d6f314 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -78,7 +78,7 @@ export function HistoryRail({ (the rail still has to stand alone on desktop). */}

{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from @@ -452,12 +451,19 @@ function ChatBubble({ raw `content` is the streamed Coach reply (or the persisted twin after `done` fires); both paths are AI- authored so they share the same leak surface as the - insight prose elsewhere. */} - {content - ? stripChartTokens(content) - : inProgress - ? t("insights.coach.thinking") - : ""} + insight prose elsewhere. + + v1.16.1 — before the first token lands, the bubble shows + a classic typing indicator (three pulsing dots) instead + of the old whole-bubble pulse + "Thinking…" prose. The + prose stays as the screen-reader announcement. */} + {content ? ( + stripChartTokens(content) + ) : inProgress ? ( + + ) : ( + "" + )} {safeError && (

{safeError}

@@ -572,6 +578,33 @@ function ChatBubble({ ); } +/** + * v1.16.1 — classic chat typing indicator: three dots pulsing in + * sequence inside the assistant bubble, shown only between submit and + * the first streamed token. Uses the stock `animate-pulse` keyframe + * with staggered delays so no custom keyframe is introduced; + * `motion-reduce` freezes the dots and the `label` stays as the + * screen-reader text either way. + */ +function TypingDots({ label }: { label: string }) { + return ( + + {label} + {[0, 1, 2].map((i) => ( + + ); +} + interface CoachMessageFeedbackProps { messageId: string; } diff --git a/src/components/insights/coach-panel/mobile-rail-tray.tsx b/src/components/insights/coach-panel/mobile-rail-tray.tsx index a364c680d..e88dba14e 100644 --- a/src/components/insights/coach-panel/mobile-rail-tray.tsx +++ b/src/components/insights/coach-panel/mobile-rail-tray.tsx @@ -68,13 +68,15 @@ export function MobileRailTray({
{historyRail}
- {/* Sources tray — slides in from the right edge. `xl:hidden` - because the sources rail is inline on xl+. */} + {/* Sources tray — slides in from the right edge. v1.16.1 — the + rail no longer has an inline xl+ column; the tray is the one + sources surface on every viewport (hidden by default, opened + via the toggle in the rail-tray strip). */} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index 49121a348..318cdc4b4 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -95,11 +95,20 @@ export function SourcesRail({ className }: SourcesRailProps) { return (

); } + +/** + * Register a new pen / pack. Two fields (quantity + optional printed + * expiry) → Dialog per ui-guidelines §2.3. The quantity prefills from + * the medication's `dosesPerUnit` so the common case is one tap. + */ +function AddInventoryDialog({ + medicationId, + defaultDosesTotal, + onClose, +}: { + medicationId: string; + defaultDosesTotal: number | null; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [dosesTotal, setDosesTotal] = useState( + defaultDosesTotal && defaultDosesTotal >= 1 && defaultDosesTotal <= 100 + ? String(defaultDosesTotal) + : "", + ); + const [expiry, setExpiry] = useState(""); + const [busy, setBusy] = useState(false); + + const parsed = Number(dosesTotal); + const quantityValid = + Number.isInteger(parsed) && parsed >= 1 && parsed <= 100; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!quantityValid || busy) return; + setBusy(true); + try { + const res = await fetch(`/api/medications/${medicationId}/inventory`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + dosesTotal: parsed, + printedExpiry: expiry + ? new Date(`${expiry}T00:00:00`).toISOString() + : null, + }), + }); + if (!res.ok) { + toast.error(t("medications.detail.bestand.addFailed")); + return; + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }); + toast.success(t("medications.detail.bestand.addSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.addFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()}> + + + {t("medications.detail.bestand.addTitle")} + + {t("medications.detail.bestand.addDescription")} + + +
+
+ + setDosesTotal(e.target.value)} + aria-describedby="inventory-add-quantity-helper" + /> +

+ {t("medications.detail.bestand.addQuantityHelper")} +

+
+
+ + setExpiry(e.target.value)} + /> +
+ + + + +
+
+
+ ); +} + +/** + * Stock correction for one item: sets the remaining-dose count + * absolutely (covers both "I miscounted" and a manual withdrawal). The + * server clamps to the item's capacity and re-derives the state; 0 + * marks the item used up. + */ +function AdjustInventoryDialog({ + medicationId, + item, + onClose, +}: { + medicationId: string; + item: InventoryItem; + onClose: () => void; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [value, setValue] = useState(String(item.dosesRemaining)); + const [busy, setBusy] = useState(false); + + const parsed = Number(value); + const valid = + Number.isInteger(parsed) && parsed >= 0 && parsed <= item.dosesTotal; + + async function submit(e: FormEvent) { + e.preventDefault(); + if (!valid || busy) return; + setBusy(true); + try { + const res = await fetch( + `/api/medications/${medicationId}/inventory/${item.id}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dosesRemaining: parsed }), + }, + ); + if (!res.ok) { + toast.error(t("medications.detail.bestand.adjustFailed")); + return; + } + await queryClient.invalidateQueries({ + queryKey: queryKeys.medicationInventory(medicationId), + }); + toast.success(t("medications.detail.bestand.adjustSuccess")); + onClose(); + } catch { + toast.error(t("medications.detail.bestand.adjustFailed")); + } finally { + setBusy(false); + } + } + + return ( + !open && onClose()}> + + + + {t("medications.detail.bestand.adjustTitle")} + + + {t("medications.detail.bestand.adjustDescription")} + + +
+
+ + setValue(e.target.value)} + aria-describedby="inventory-adjust-helper" + /> +

+ {t("medications.detail.bestand.adjustHelper", { + total: item.dosesTotal, + })} +

+
+ + + + +
+
+
+ ); +} diff --git a/src/components/mood/__tests__/mood-list-filter-select.test.tsx b/src/components/mood/__tests__/mood-list-filter-select.test.tsx index 774e8e154..64438ff32 100644 --- a/src/components/mood/__tests__/mood-list-filter-select.test.tsx +++ b/src/components/mood/__tests__/mood-list-filter-select.test.tsx @@ -72,15 +72,16 @@ function render(locale: "en" | "de" = "en") { } describe("MoodList — filter bar + multi-select chrome", () => { - it("renders the source filter control", () => { + // v1.16.1 — the per-page Select/date-input row migrated to the unified + // `` pill rail; same grammar as the measurements list. + it("renders the filter rail with mood, source and date-range pills", () => { const html = render("en"); - expect(html).toContain("Filter by source"); - }); - - it("renders the date-range labels wired to from/to", () => { - const html = render("en"); - expect(html).toContain('for="mood-from"'); - expect(html).toContain('for="mood-to"'); + expect(html).toContain('data-slot="filter-bar"'); + expect(html).toContain('aria-label="Mood"'); + expect(html).toContain('aria-label="Source"'); + expect(html).toContain('aria-label="Date range"'); + const pills = html.match(/data-slot="filter-bar-pill"/g); + expect(pills?.length).toBe(3); }); it("renders labelled selection checkboxes (per-row + select-all)", () => { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index b3c4e6bde..57b87fbfe 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -6,12 +6,10 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState } from "@/components/ui/empty-state"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + FilterBar, + FilterBarDateRange, + FilterBarSelect, +} from "@/components/ui/filter-bar"; import { AlertDialog, AlertDialogAction, @@ -37,7 +35,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DateInput, DateTimeInput } from "@/components/ui/date-input"; +import { DateTimeInput } from "@/components/ui/date-input"; import { Label } from "@/components/ui/label"; import { Table, @@ -415,93 +413,60 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { return ( <>
-
-
-
- - -
- - {/* v1.15.13 — source filter. */} -
- - -
- - {/* v1.15.13 — optional date range, wired to from/to. */} -
- - setFromDay(e.target.value)} - /> -
-
- - setToDay(e.target.value)} - /> -
-
- - {data?.meta?.total !== undefined && ( - - {t("mood.entryCount", { - count: fmt.integer(data.meta.total), - })} - - )} -
+ {/* v1.16.1 — unified filter rail (``), same grammar as + the measurements list: date range · mood · source as compact + pills, active filters as removable chips, reset + count. + Filter state and query keys unchanged. */} + { + setMoodFilter("ALL"); + setSourceFilter("ALL"); + setFromDay(""); + setToDay(""); + }} + count={ + data?.meta?.total !== undefined + ? t("mood.entryCount", { + count: fmt.integer(data.meta.total), + }) + : undefined + } + > + + ({ + value: val, + label: `${MOOD_SCORES[val]} (${t(MOOD_LABEL_KEYS[val])})`, + }))} + /> + ({ + value: src, + label: formatMoodSource(src, t), + }))} + /> + {isLoading ? (
diff --git a/src/components/onboarding/tour.tsx b/src/components/onboarding/tour.tsx index f5946e7a3..282fb529c 100644 --- a/src/components/onboarding/tour.tsx +++ b/src/components/onboarding/tour.tsx @@ -164,28 +164,51 @@ function computeTooltipPosition( left: rect.left - tooltipSize.width - GAP, }); } - // Pick the first candidate that fits the viewport; otherwise the - // first candidate (the renderer will clamp inside the box). + // Pick the first candidate that fits the viewport; otherwise fall back to + // centred. EVERY returned position is clamped into the viewport with a + // collision padding — a candidate computed off a target near the edge must + // never push the card (and its footer buttons) out of view. + const clamp = (c: { + placement: TourStop["placement"]; + top: number; + left: number; + }) => ({ + placement: c.placement, + top: Math.min( + Math.max(COLLISION_PAD, c.top), + Math.max(COLLISION_PAD, vh - tooltipSize.height - COLLISION_PAD), + ), + left: Math.min( + Math.max(COLLISION_PAD, c.left), + Math.max(COLLISION_PAD, vw - tooltipSize.width - COLLISION_PAD), + ), + }); for (const c of candidates) { if ( - c.top >= 8 && - c.left >= 8 && - c.top + tooltipSize.height <= vh - 8 && - c.left + tooltipSize.width <= vw - 8 + c.top >= COLLISION_PAD && + c.left >= COLLISION_PAD && + c.top + tooltipSize.height <= vh - COLLISION_PAD && + c.left + tooltipSize.width <= vw - COLLISION_PAD ) { return c; } } - // Last-resort: centred bottom of viewport. - return { + // None fits as-is: prefer the first requested candidate clamped into the + // viewport (keeps the card near its target) over jumping to centre. + if (candidates.length > 0) return clamp(candidates[0]); + return clamp({ placement: "center", - top: Math.max(16, (vh - tooltipSize.height) / 2), - left: Math.max(16, (vw - tooltipSize.width) / 2), - }; + top: (vh - tooltipSize.height) / 2, + left: (vw - tooltipSize.width) / 2, + }); } const TOOLTIP_WIDTH = 320; const TOOLTIP_HEIGHT = 220; // enough headroom for two-line title + 4-line body + footer +/** Minimum gap the tooltip keeps from every viewport edge. */ +const COLLISION_PAD = 8; +/** Below this viewport width the tooltip renders as a bottom sheet. */ +const SHEET_BREAKPOINT = 480; export function OnboardingTour({ includeAchievements = true, @@ -333,8 +356,19 @@ export function OnboardingTour({ const counter = stepCounter(state); const isLast = counter.current === counter.total; + // Responsive sizing: the card never claims more width than the viewport + // minus the collision padding, and very small viewports skip the anchored + // popover entirely in favour of a bottom sheet — anchoring a 20 rem card + // next to a target on a ~400 px screen always ends up clipped. + const viewportWidth = + typeof window !== "undefined" ? window.innerWidth : 1024; + const asSheet = viewportWidth < SHEET_BREAKPOINT; + const tooltipWidth = Math.min( + TOOLTIP_WIDTH, + viewportWidth - COLLISION_PAD * 2, + ); const tooltipPos = computeTooltipPosition(rect, stop.placement, { - width: TOOLTIP_WIDTH, + width: tooltipWidth, height: TOOLTIP_HEIGHT, }); @@ -481,13 +515,24 @@ export function OnboardingTour({
@@ -516,7 +561,10 @@ export function OnboardingTour({ on the iPad / iPhone PWA shell. Bumping to `min-h-11` (44 px) keeps the desktop visual close enough — the buttons grow ~4 px taller — while making mobile usable. */} -
+ {/* `flex-wrap` keeps long localised labels (Skip / Back / Next) from + forcing a horizontal scrollbar inside the card — the row wraps + instead, so the primary action is always reachable. */} +
-
+
{structuredField( "conditions", t("settings.ai.aboutMe.conditionsLabel"), @@ -249,7 +249,7 @@ export function AboutMeSection({
-
+
@@ -267,7 +267,7 @@ export function AboutMeSection({ />
-
+

)} -

+

{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}

diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 90728b3a2..0c11895d6 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -238,7 +238,7 @@ export function AboutSection() {

{t("settings.sections.about.title")}

@@ -255,14 +255,14 @@ export function AboutSection() { {isLoading || !version ? ( -
+
{t("common.loading")}
) : ( -
+
{t("settings.about.version")} @@ -327,7 +327,7 @@ export function AboutSection() { title={t("settings.about.linksHeading")} className="mb-4" /> -
+
+ + + ); +} diff --git a/src/components/settings/cycle-tracking-card.tsx b/src/components/settings/cycle-tracking-card.tsx index 2f1bb4930..b6aab2f7f 100644 --- a/src/components/settings/cycle-tracking-card.tsx +++ b/src/components/settings/cycle-tracking-card.tsx @@ -72,7 +72,7 @@ export function CycleTrackingCard({ title={t("settings.cycleTracking.title")} description={t("settings.cycleTracking.description")} /> -
+