diff --git a/messages/de.json b/messages/de.json index c7a8058ec..216446299 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Periode steht bevor", "eventCyclePeriodSoonDesc": "Ein paar Tage vor deiner vorhergesagten nächsten Periode.", "eventCyclePeriodConfirm": "Hat deine Periode begonnen?", - "eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist." + "eventCyclePeriodConfirmDesc": "Eine sanfte Nachfrage am oder nach dem vorhergesagten Beginn, solange keine Periode erfasst ist.", + "eventCycleFertileSoon": "Fruchtbares Fenster steht bevor", + "eventCycleFertileSoonDesc": "Wird einige Tage vor deinem geschätzten fruchtbaren Fenster gesendet, wenn dein Ziel die Empfängnis ist." }, "i18n": { "maintainershipBanner": { @@ -5138,6 +5140,8 @@ "reminders": "Erinnerungen", "remindersDescription": "Perioden-Erinnerungen pro Kanal in den Benachrichtigungseinstellungen aktivieren.", "remindersLink": "Benachrichtigungseinstellungen", + "fertileReminder": "Erinnerung an das fruchtbare Fenster", + "fertileReminderDescription": "Erhalte einige Tage vor deinem geschätzten fruchtbaren Fenster einen Hinweis. Standardmäßig aus – aktiviere sie pro Kanal in den Benachrichtigungseinstellungen.", "purgeData": "Zyklusdaten löschen", "purgeConfirmAction": "Ja, alles löschen", "purgeDone": "Zyklusdaten gelöscht." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Deine nächste Periode wird in etwa zwei Tagen erwartet.", "periodConfirmTitle": "Hat deine Periode begonnen?", "periodConfirmBody": "Deine Periode wurde für etwa jetzt vorhergesagt. Trage sie ein, damit die Vorhersagen genau bleiben.", + "fertileSoonTitle": "Fruchtbares Fenster steht bevor", + "fertileSoonBody": "Dein geschätztes fruchtbares Fenster beginnt voraussichtlich in etwa zwei Tagen. Dies ist eine Schätzung und keine medizinische oder verhütende Garantie.", "discreetTitle": "HealthLog-Erinnerung", "discreetBody": "Du hast eine Erinnerung in HealthLog." } diff --git a/messages/en.json b/messages/en.json index 7fd9b439b..8b07b879a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Period coming up", "eventCyclePeriodSoonDesc": "Sent a couple of days before your predicted next period.", "eventCyclePeriodConfirm": "Did your period start?", - "eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged." + "eventCyclePeriodConfirmDesc": "A gentle check-in on or after the predicted start while no period is logged.", + "eventCycleFertileSoon": "Fertile window coming up", + "eventCycleFertileSoonDesc": "Sent a few days before your estimated fertile window when your goal is trying to conceive." }, "i18n": { "maintainershipBanner": { @@ -5143,6 +5145,8 @@ "reminders": "Reminders", "remindersDescription": "Turn period reminders on per channel in notification settings.", "remindersLink": "Notification settings", + "fertileReminder": "Fertile-window reminder", + "fertileReminderDescription": "Get a heads-up a few days before your estimated fertile window. Off by default — turn it on per channel in notification settings.", "purgeData": "Delete cycle data", "purgeConfirmAction": "Yes, delete everything", "purgeDone": "Cycle data deleted." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Your next period is predicted in about two days.", "periodConfirmTitle": "Did your period start?", "periodConfirmBody": "Your period was predicted around now. Log it to keep predictions accurate.", + "fertileSoonTitle": "Fertile window coming up", + "fertileSoonBody": "Your estimated fertile window is predicted to start in about two days. This is an estimate, not a medical or contraceptive guarantee.", "discreetTitle": "HealthLog reminder", "discreetBody": "You have a reminder in HealthLog." } diff --git a/messages/es.json b/messages/es.json index 66ad23a33..da63e756a 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Período próximo", "eventCyclePeriodSoonDesc": "Se envía un par de días antes de tu próximo período previsto.", "eventCyclePeriodConfirm": "¿Empezó tu período?", - "eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período." + "eventCyclePeriodConfirmDesc": "Un recordatorio suave en o después del inicio previsto mientras no se registre ningún período.", + "eventCycleFertileSoon": "Ventana fértil próxima", + "eventCycleFertileSoonDesc": "Se envía unos días antes de tu ventana fértil estimada cuando tu objetivo es concebir." }, "i18n": { "maintainershipBanner": { @@ -5138,6 +5140,8 @@ "reminders": "Recordatorios", "remindersDescription": "Activa los recordatorios de período por canal en los ajustes de notificaciones.", "remindersLink": "Ajustes de notificaciones", + "fertileReminder": "Recordatorio de ventana fértil", + "fertileReminderDescription": "Recibe un aviso unos días antes de tu ventana fértil estimada. Desactivado por defecto: actívalo por canal en los ajustes de notificaciones.", "purgeData": "Eliminar datos del ciclo", "purgeConfirmAction": "Sí, eliminar todo", "purgeDone": "Datos del ciclo eliminados." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Se prevé tu próximo periodo en unos dos días.", "periodConfirmTitle": "¿Ha comenzado tu periodo?", "periodConfirmBody": "Tu periodo estaba previsto para ahora. Regístralo para mantener las predicciones precisas.", + "fertileSoonTitle": "Ventana fértil próxima", + "fertileSoonBody": "Se prevé que tu ventana fértil estimada comience en unos dos días. Es una estimación, no una garantía médica ni anticonceptiva.", "discreetTitle": "Recordatorio de HealthLog", "discreetBody": "Tienes un recordatorio en HealthLog." } diff --git a/messages/fr.json b/messages/fr.json index cb6b6825a..ae6869a5c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Règles à venir", "eventCyclePeriodSoonDesc": "Envoyé quelques jours avant vos prochaines règles prévues.", "eventCyclePeriodConfirm": "Vos règles ont-elles commencé ?", - "eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée." + "eventCyclePeriodConfirmDesc": "Un rappel discret le jour prévu ou après, tant qu'aucune règle n'est enregistrée.", + "eventCycleFertileSoon": "Fenêtre fertile à venir", + "eventCycleFertileSoonDesc": "Envoyé quelques jours avant votre fenêtre fertile estimée lorsque votre objectif est de concevoir." }, "i18n": { "maintainershipBanner": { @@ -5138,6 +5140,8 @@ "reminders": "Rappels", "remindersDescription": "Activez les rappels de règles par canal dans les paramètres de notification.", "remindersLink": "Paramètres de notification", + "fertileReminder": "Rappel de fenêtre fertile", + "fertileReminderDescription": "Recevez un avertissement quelques jours avant votre fenêtre fertile estimée. Désactivé par défaut : activez-le par canal dans les paramètres de notification.", "purgeData": "Supprimer les données de cycle", "purgeConfirmAction": "Oui, tout supprimer", "purgeDone": "Données de cycle supprimées." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Vos prochaines règles sont prévues dans environ deux jours.", "periodConfirmTitle": "Vos règles ont-elles commencé ?", "periodConfirmBody": "Vos règles étaient prévues pour maintenant. Enregistrez-les pour garder des prévisions précises.", + "fertileSoonTitle": "Fenêtre fertile à venir", + "fertileSoonBody": "Votre fenêtre fertile estimée devrait commencer dans environ deux jours. Il s'agit d'une estimation, et non d'une garantie médicale ou contraceptive.", "discreetTitle": "Rappel HealthLog", "discreetBody": "Vous avez un rappel dans HealthLog." } diff --git a/messages/it.json b/messages/it.json index 7b560241f..41b5e482e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Ciclo in arrivo", "eventCyclePeriodSoonDesc": "Inviato un paio di giorni prima del ciclo previsto.", "eventCyclePeriodConfirm": "Il ciclo è iniziato?", - "eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo." + "eventCyclePeriodConfirmDesc": "Un promemoria discreto dal giorno previsto in poi finché non registri il ciclo.", + "eventCycleFertileSoon": "Finestra fertile in arrivo", + "eventCycleFertileSoonDesc": "Inviato alcuni giorni prima della finestra fertile stimata quando il tuo obiettivo è il concepimento." }, "i18n": { "maintainershipBanner": { @@ -5138,6 +5140,8 @@ "reminders": "Promemoria", "remindersDescription": "Attiva i promemoria del ciclo per canale nelle impostazioni di notifica.", "remindersLink": "Impostazioni di notifica", + "fertileReminder": "Promemoria finestra fertile", + "fertileReminderDescription": "Ricevi un avviso alcuni giorni prima della tua finestra fertile stimata. Disattivato per impostazione predefinita: attivalo per canale nelle impostazioni di notifica.", "purgeData": "Elimina i dati del ciclo", "purgeConfirmAction": "Sì, elimina tutto", "purgeDone": "Dati del ciclo eliminati." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Il tuo prossimo ciclo è previsto tra circa due giorni.", "periodConfirmTitle": "Il ciclo è iniziato?", "periodConfirmBody": "Il tuo ciclo era previsto per adesso. Registralo per mantenere accurate le previsioni.", + "fertileSoonTitle": "Finestra fertile in arrivo", + "fertileSoonBody": "La tua finestra fertile stimata dovrebbe iniziare tra circa due giorni. È una stima, non una garanzia medica o contraccettiva.", "discreetTitle": "Promemoria HealthLog", "discreetBody": "Hai un promemoria in HealthLog." } diff --git a/messages/pl.json b/messages/pl.json index 27b5b0a66..cc2036172 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4702,7 +4702,9 @@ "eventCyclePeriodSoon": "Zbliża się okres", "eventCyclePeriodSoonDesc": "Wysyłane kilka dni przed przewidywanym okresem.", "eventCyclePeriodConfirm": "Czy okres się zaczął?", - "eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu." + "eventCyclePeriodConfirmDesc": "Delikatne przypomnienie w dniu przewidywanego początku lub później, gdy nie zarejestrowano okresu.", + "eventCycleFertileSoon": "Zbliża się okno płodności", + "eventCycleFertileSoonDesc": "Wysyłane kilka dni przed szacowanym oknem płodności, gdy Twoim celem jest poczęcie." }, "i18n": { "maintainershipBanner": { @@ -5138,6 +5140,8 @@ "reminders": "Przypomnienia", "remindersDescription": "Włącz przypomnienia o okresie dla każdego kanału w ustawieniach powiadomień.", "remindersLink": "Ustawienia powiadomień", + "fertileReminder": "Przypomnienie o oknie płodności", + "fertileReminderDescription": "Otrzymaj powiadomienie kilka dni przed szacowanym oknem płodności. Domyślnie wyłączone – włącz je dla każdego kanału w ustawieniach powiadomień.", "purgeData": "Usuń dane cyklu", "purgeConfirmAction": "Tak, usuń wszystko", "purgeDone": "Dane cyklu usunięte." @@ -5174,6 +5178,8 @@ "periodSoonBody": "Twoja następna miesiączka jest przewidywana za około dwa dni.", "periodConfirmTitle": "Czy miesiączka się zaczęła?", "periodConfirmBody": "Twoja miesiączka była przewidywana mniej więcej teraz. Zapisz ją, aby prognozy pozostały dokładne.", + "fertileSoonTitle": "Zbliża się okno płodności", + "fertileSoonBody": "Szacowane okno płodności rozpocznie się prawdopodobnie za około dwa dni. To szacunek, a nie gwarancja medyczna ani antykoncepcyjna.", "discreetTitle": "Przypomnienie HealthLog", "discreetBody": "Masz przypomnienie w HealthLog." } diff --git a/src/components/cycle/cycle-settings.tsx b/src/components/cycle/cycle-settings.tsx index e93df3d6f..2197832d4 100644 --- a/src/components/cycle/cycle-settings.tsx +++ b/src/components/cycle/cycle-settings.tsx @@ -205,6 +205,29 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) { + {/* Fertile-window reminder — surfaced ONLY under the conception goal. + The toggle itself is the default-OFF per-channel CYCLE_FERTILE_SOON + switch on the notifications page; here we just point a TTC user to + it, so the affordance never appears (and fertile language never + shows) for the other goals (the inclusive-framing rule). */} + {goal === "TRYING_TO_CONCEIVE" ? ( +
+
+

+ {t("cycle.settings.fertileReminder")} +

+

+ {t("cycle.settings.fertileReminderDescription")} +

+
+ +
+ ) : null} + {/* Data export / delete */} diff --git a/src/lib/jobs/__tests__/cycle-reminder.test.ts b/src/lib/jobs/__tests__/cycle-reminder.test.ts index e3de4100d..a6e83b7d1 100644 --- a/src/lib/jobs/__tests__/cycle-reminder.test.ts +++ b/src/lib/jobs/__tests__/cycle-reminder.test.ts @@ -26,10 +26,12 @@ import { buildCycleReminderPayload, cycleReminderLocalDate, evaluateCycleReminder, + evaluateFertileReminder, runCycleReminderTick, CYCLE_REMINDER_LOCAL_HOUR, PERIOD_SOON_LEAD_DAYS, PERIOD_CONFIRM_GRACE_DAYS, + FERTILE_SOON_LEAD_DAYS, } from "../cycle-reminder"; import type { NotificationPayload } from "@/lib/notifications/types"; import type { DispatchOutcome } from "@/lib/notifications/dispatcher"; @@ -57,11 +59,17 @@ interface FakeUser { cycleTrackingEnabled: boolean | null; predictionEnabled: boolean; discreetNotifications: boolean; + goal?: string; } | null; } interface FakeState { - predictions: Array<{ userId: string; nextPeriodStart: string; user: FakeUser }>; + predictions: Array<{ + userId: string; + nextPeriodStart: string; + fertileWindowStart?: string | null; + user: FakeUser; + }>; /** Observed (non-predicted) cycle start dates per userId. */ observedStarts: Record; /** Existing `ok` push attempts: `${userId}:${eventType}` already sent today. */ @@ -110,6 +118,7 @@ function baseUser(over: Partial = {}): FakeUser { cycleTrackingEnabled: null, predictionEnabled: true, discreetNotifications: false, + goal: "GENERAL_HEALTH", }, ...over, }; @@ -214,7 +223,48 @@ describe("evaluateCycleReminder", () => { }); }); +describe("evaluateFertileReminder", () => { + it("fires exactly lead-days before the fertile-window start", () => { + expect( + evaluateFertileReminder({ + today: "2026-05-18", + fertileWindowStart: "2026-05-20", + }), + ).toBe(true); + expect(FERTILE_SOON_LEAD_DAYS).toBe(2); + }); + + it("does not fire one day off the lead", () => { + expect( + evaluateFertileReminder({ + today: "2026-05-19", + fertileWindowStart: "2026-05-20", + }), + ).toBe(false); + expect( + evaluateFertileReminder({ + today: "2026-05-17", + fertileWindowStart: "2026-05-20", + }), + ).toBe(false); + }); +}); + describe("buildCycleReminderPayload", () => { + it("carries non-contraceptive fertile framing when not discreet", () => { + const fertile = buildCycleReminderPayload("CYCLE_FERTILE_SOON", "en", false); + expect(fertile.title.toLowerCase()).toContain("fertile"); + // Honesty boundary: never a contraceptive / safe-day claim. + expect(fertile.body.toLowerCase()).toContain("not"); + expect(fertile.body.toLowerCase()).not.toContain("safe day"); + }); + + it("collapses the fertile event to the generic body when discreet", () => { + const fertile = buildCycleReminderPayload("CYCLE_FERTILE_SOON", "en", true); + expect(fertile.title.toLowerCase()).not.toContain("fertile"); + expect(fertile.title).toContain("HealthLog"); + }); + it("names the event when not discreet", () => { const soon = buildCycleReminderPayload("CYCLE_PERIOD_SOON", "en", false); expect(soon.title.length).toBeGreaterThan(0); @@ -272,6 +322,86 @@ describe("runCycleReminderTick", () => { expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_PERIOD_SOON"); }); + it("dispatches FERTILE_SOON two days before the fertile window for the TTC goal", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + // Period start far out so only the fertile window is in-window. + nextPeriodStart: "2026-06-02", + fertileWindowStart: "2026-05-19", + user: baseUser({ + cycleProfile: { + cycleTrackingEnabled: null, + predictionEnabled: true, + discreetNotifications: false, + goal: "TRYING_TO_CONCEIVE", + }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedFertileSoon).toBe(1); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0].eventType).toBe("CYCLE_FERTILE_SOON"); + }); + + it("never dispatches FERTILE_SOON for a non-TTC goal (inclusive framing)", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-06-02", + fertileWindowStart: "2026-05-19", + user: baseUser({ + cycleProfile: { + cycleTrackingEnabled: null, + predictionEnabled: true, + discreetNotifications: false, + goal: "AVOID_PREGNANCY", + }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedFertileSoon).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it("never dispatches FERTILE_SOON when fertileWindowStart is null", async () => { + const state: FakeState = { + predictions: [ + { + userId: "u1", + nextPeriodStart: "2026-06-02", + fertileWindowStart: null, + user: baseUser({ + cycleProfile: { + cycleTrackingEnabled: null, + predictionEnabled: true, + discreetNotifications: false, + goal: "TRYING_TO_CONCEIVE", + }, + }), + }, + ], + observedStarts: {}, + alreadyOk: new Set(), + }; + const dispatch = vi.fn(async () => OK); + const summary = await run(state, dispatch); + expect(summary.dispatchedFertileSoon).toBe(0); + expect(dispatch).not.toHaveBeenCalled(); + }); + it("dispatches PERIOD_CONFIRM on the predicted day with no logged period", async () => { const state: FakeState = { predictions: [ @@ -485,7 +615,10 @@ describe("runCycleReminderTick — windowing query", () => { expect(findMany).toHaveBeenCalledTimes(1); const arg = findMany.mock.calls[0][0] as unknown as { where: { - nextPeriodStart: { gte: string; lte: string }; + OR: Array<{ + nextPeriodStart?: { gte: string; lte: string }; + fertileWindowStart?: { gte: string; lte: string }; + }>; user: { cycleProfile: { cycleTrackingEnabled: boolean; @@ -497,12 +630,22 @@ describe("runCycleReminderTick — windowing query", () => { // Tracking + prediction gate is in the query, not JS. expect(arg.where.user.cycleProfile.cycleTrackingEnabled).toBe(true); expect(arg.where.user.cycleProfile.NOT.predictionEnabled).toBe(false); - // The window is a YYYY-MM-DD string range straddling "now". At - // 2026-05-17 it spans [today-grace-1 .. today+lead+1] in UTC. - expect(arg.where.nextPeriodStart.gte).toBe("2026-05-13"); - expect(arg.where.nextPeriodStart.lte).toBe("2026-05-20"); - expect(arg.where.nextPeriodStart.gte < arg.where.nextPeriodStart.lte).toBe( - true, - ); + // The query ORs a period-start window with a fertile-window window + // (the fertile window sits ~2 weeks ahead of the period start, so it + // needs its own date range). The period window is a YYYY-MM-DD string + // range straddling "now"; at 2026-05-17 it spans + // [today-grace-1 .. today+lead+1] in UTC. + const periodRange = arg.where.OR.find( + (c) => c.nextPeriodStart != null, + )?.nextPeriodStart; + const fertileRange = arg.where.OR.find( + (c) => c.fertileWindowStart != null, + )?.fertileWindowStart; + expect(periodRange?.gte).toBe("2026-05-13"); + expect(periodRange?.lte).toBe("2026-05-20"); + expect((periodRange?.gte ?? "") < (periodRange?.lte ?? "")).toBe(true); + // Fertile window: [today-1 .. today+lead+1] in UTC. + expect(fertileRange?.gte).toBe("2026-05-16"); + expect(fertileRange?.lte).toBe("2026-05-20"); }); }); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index 11bee8c0d..6e4e72478 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -59,6 +59,14 @@ export const CYCLE_REMINDER_LOCAL_HOUR = 9; /** Days before the predicted next-period start that PERIOD_SOON fires. */ export const PERIOD_SOON_LEAD_DAYS = 2; +/** + * Days before the predicted fertile-window start that FERTILE_SOON fires. + * Same calm two-day lead as PERIOD_SOON — enough notice to act on the + * conception goal without nagging. Only ever reached for the + * `TRYING_TO_CONCEIVE` goal (gated in `runCycleReminderTick`). + */ +export const FERTILE_SOON_LEAD_DAYS = 2; + /** * Days on/after the predicted next-period start that PERIOD_CONFIRM keeps * nudging while no period is logged. Day 0 (the predicted start) through @@ -66,13 +74,17 @@ export const PERIOD_SOON_LEAD_DAYS = 2; */ export const PERIOD_CONFIRM_GRACE_DAYS = 3; -export type CycleReminderEvent = "CYCLE_PERIOD_SOON" | "CYCLE_PERIOD_CONFIRM"; +export type CycleReminderEvent = + | "CYCLE_PERIOD_SOON" + | "CYCLE_PERIOD_CONFIRM" + | "CYCLE_FERTILE_SOON"; export interface CycleReminderSummary { candidatesScanned: number; inWindow: number; dispatchedPeriodSoon: number; dispatchedPeriodConfirm: number; + dispatchedFertileSoon: number; suppressedClientManaged: number; suppressedDiscreet: number; skippedAlreadyNotified: number; @@ -109,6 +121,7 @@ function emptySummary(): CycleReminderSummary { inWindow: 0, dispatchedPeriodSoon: 0, dispatchedPeriodConfirm: 0, + dispatchedFertileSoon: 0, suppressedClientManaged: 0, suppressedDiscreet: 0, skippedAlreadyNotified: 0, @@ -181,6 +194,21 @@ export function evaluateCycleReminder( return null; } +/** + * Pure decision predicate for the fertile-window reminder: fire when the + * user's local date is exactly `leadDays` before the predicted fertile + * window start. DB-free + goal-agnostic by design — the TTC goal gate and + * the `fertileWindowStart != null` check both live in the runner, where the + * profile and forecast are loaded; this only owns the lead-day windowing so + * the boundary (08:59 → no, the lead day → yes) stays unit-testable. + */ +export function evaluateFertileReminder( + args: { today: string; fertileWindowStart: string }, + leadDays: number = FERTILE_SOON_LEAD_DAYS, +): boolean { + return dayDiff(args.fertileWindowStart, args.today) === leadDays; +} + /** * Build the localised title + body for a cycle reminder push. When * `discreet` is true, both collapse to the generic "HealthLog reminder" @@ -205,6 +233,12 @@ export function buildCycleReminderPayload( body: t("cycleReminders.periodSoonBody"), }; } + if (event === "CYCLE_FERTILE_SOON") { + return { + title: t("cycleReminders.fertileSoonTitle"), + body: t("cycleReminders.fertileSoonBody"), + }; + } return { title: t("cycleReminders.periodConfirmTitle"), body: t("cycleReminders.periodConfirmBody"), @@ -267,21 +301,29 @@ export async function runCycleReminderTick( // 1. the user's `cycleProfile` must have `cycleTrackingEnabled` (an // account that never opted in can never receive a cycle push), and // `predictionEnabled` must not be explicitly off, - // 2. `nextPeriodStart` (a `YYYY-MM-DD` string) must fall inside the - // reminder window [today − grace, today + lead]. The string range is - // computed against a tz-generous span (±1 day past the exact - // lead/grace) so a user whose local date differs from UTC by a day is - // never excluded at the query layer — `evaluateCycleReminder` still - // applies the exact per-user-timezone window below. + // 2. either `nextPeriodStart` OR `fertileWindowStart` (both `YYYY-MM-DD` + // strings) must fall inside its reminder window [today − grace, + // today + lead]. The string range is computed against a tz-generous + // span (±1 day past the exact lead/grace) so a user whose local date + // differs from UTC by a day is never excluded at the query layer — + // `evaluateCycleReminder` / `evaluateFertileReminder` still apply the + // exact per-user-timezone window below. The fertile window sits ~2 + // weeks ahead of the period start, so it needs its own date range + // rather than riding the period-start filter. // // The pure-string `gte`/`lte` range is correct because `YYYY-MM-DD` // ordering is lexicographic-equals-chronological. const windowFloor = utcDateString(now, -(PERIOD_CONFIRM_GRACE_DAYS + 1)); const windowCeil = utcDateString(now, PERIOD_SOON_LEAD_DAYS + 1); + const fertileCeil = utcDateString(now, FERTILE_SOON_LEAD_DAYS + 1); + const fertileFloor = utcDateString(now, -1); const predictions = await prisma.cyclePrediction.findMany({ where: { - nextPeriodStart: { gte: windowFloor, lte: windowCeil }, + OR: [ + { nextPeriodStart: { gte: windowFloor, lte: windowCeil } }, + { fertileWindowStart: { gte: fertileFloor, lte: fertileCeil } }, + ], user: { cycleProfile: { cycleTrackingEnabled: true, @@ -292,6 +334,7 @@ export async function runCycleReminderTick( select: { userId: true, nextPeriodStart: true, + fertileWindowStart: true, user: { select: { id: true, @@ -301,6 +344,7 @@ export async function runCycleReminderTick( notificationPrefs: true, cycleProfile: { select: { + goal: true, cycleTrackingEnabled: true, predictionEnabled: true, discreetNotifications: true, @@ -339,6 +383,86 @@ export async function runCycleReminderTick( summary.inWindow += 1; + const timezoneForBounds = timezone; + // Local day's UTC bounds for the ledger idempotency lookup, computed + // once and shared by every event we may fire for this user this tick. + // `end` is the last millisecond of the day, so add 1 ms for half-open. + const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds( + now, + timezoneForBounds, + ); + const dayEndExclusive = new Date(dayEndInclusive.getTime() + 1); + + // Shared dispatch tail: clientManaged suppression → idempotency → + // discreet body → dispatch → counter. Reused verbatim by the + // period-soon / period-confirm nudge and the fertile-window nudge so a + // change to the suppression cascade can never drift between them. + const dispatchEvent = async ( + event: CycleReminderEvent, + ): Promise => { + // clientManaged suppression — iOS owns the local reminders. Mirror + // the medication path: skip the server push, emit the annotation. + if (isCycleReminderClientManaged(user.notificationPrefs)) { + summary.suppressedClientManaged += 1; + getEvent()?.addMeta( + "cycle_reminder_suppressed_client_managed", + `${event}:${today}`, + ); + getEvent()?.addMeta("cycle_reminder_suppressed_meta", { + user_id: user.id, + event, + local_date: today, + }); + return; + } + + // Idempotency: one push per event per local day. + const seen = await alreadyNotifiedToday( + prisma, + user.id, + event, + dayStartUtc, + dayEndExclusive, + ); + if (seen) { + summary.skippedAlreadyNotified += 1; + return; + } + + const discreet = profile?.discreetNotifications === true; + if (discreet) summary.suppressedDiscreet += 1; + + const { title, body } = buildCycleReminderPayload( + event, + user.locale, + discreet, + ); + + const outcome = await dispatchImpl({ + eventType: event, + userId: user.id, + title, + message: body, + // In discreet mode the senders mask the lock-screen routing + // metadata so no cycle event is named on the lock screen. + discreet, + metadata: { + scheduledAt: now.toISOString(), + localDate: today, + }, + }); + + if (!outcome.dispatched) { + summary.skippedNoChannel += 1; + return; + } + + if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1; + else if (event === "CYCLE_FERTILE_SOON") + summary.dispatchedFertileSoon += 1; + else summary.dispatchedPeriodConfirm += 1; + }; + // Has the user already logged an observed (non-predicted) cycle // starting on/around the predicted day? If so, the confirm nudge is // moot. We look for any observed cycle whose start is within the @@ -349,83 +473,32 @@ export async function runCycleReminderTick( pred.nextPeriodStart, ); - const event = evaluateCycleReminder({ + const periodEvent = evaluateCycleReminder({ today, nextPeriodStart: pred.nextPeriodStart, periodAlreadyLogged, }); - if (!event) { + // Fertile-window nudge — TWICE gated: the conception goal AND a + // non-null `fertileWindowStart` (the engine only populates it for the + // TTC goal). Never surfaces fertile language to AVOID_PREGNANCY / + // GENERAL_HEALTH / PERIMENOPAUSE (the inclusive-framing rule). + const fertileDue = + profile?.goal === "TRYING_TO_CONCEIVE" && + pred.fertileWindowStart != null && + evaluateFertileReminder({ + today, + fertileWindowStart: pred.fertileWindowStart, + }); + + if (!periodEvent && !fertileDue) { if (periodAlreadyLogged) summary.skippedAlreadyLogged += 1; else summary.skippedOutsideWindow += 1; continue; } - // clientManaged suppression — iOS owns the local reminders. Mirror the - // medication path: skip the server push, emit the annotation. - if (isCycleReminderClientManaged(user.notificationPrefs)) { - summary.suppressedClientManaged += 1; - getEvent()?.addMeta( - "cycle_reminder_suppressed_client_managed", - `${event}:${today}`, - ); - getEvent()?.addMeta("cycle_reminder_suppressed_meta", { - user_id: user.id, - event, - local_date: today, - }); - continue; - } - - // Idempotency: one push per event per local day. Compute the local - // day's UTC bounds for the ledger lookup. `end` is the last - // millisecond of the day, so add 1 ms to make the range half-open. - const { start: dayStartUtc, end: dayEndInclusive } = getUserTodayBounds( - now, - timezone, - ); - const seen = await alreadyNotifiedToday( - prisma, - user.id, - event, - dayStartUtc, - new Date(dayEndInclusive.getTime() + 1), - ); - if (seen) { - summary.skippedAlreadyNotified += 1; - continue; - } - - const discreet = profile?.discreetNotifications === true; - if (discreet) summary.suppressedDiscreet += 1; - - const { title, body } = buildCycleReminderPayload( - event, - user.locale, - discreet, - ); - - const outcome = await dispatchImpl({ - eventType: event, - userId: user.id, - title, - message: body, - // In discreet mode the senders mask the lock-screen routing - // metadata so no cycle event is named on the lock screen. - discreet, - metadata: { - scheduledAt: now.toISOString(), - localDate: today, - }, - }); - - if (!outcome.dispatched) { - summary.skippedNoChannel += 1; - continue; - } - - if (event === "CYCLE_PERIOD_SOON") summary.dispatchedPeriodSoon += 1; - else summary.dispatchedPeriodConfirm += 1; + if (periodEvent) await dispatchEvent(periodEvent); + if (fertileDue) await dispatchEvent("CYCLE_FERTILE_SOON"); } catch (err: unknown) { summary.failed += 1; const message = err instanceof Error ? err.message : String(err); diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 160935d93..6511137cf 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -34,6 +34,13 @@ export const EVENT_TYPES = [ // named on the lock screen. "CYCLE_PERIOD_SOON", "CYCLE_PERIOD_CONFIRM", + // v1.15.1 — fertile-window reminder. Default OFF (see EVENT_DEFAULT_ENABLED) + // and TTC-gated in the cron: it only fires when `CycleProfile.goal === + // "TRYING_TO_CONCEIVE"`, so fertile language never reaches the + // AVOID_PREGNANCY / GENERAL_HEALTH / PERIMENOPAUSE goals (the inclusive- + // framing rule). Fires a few days before `CyclePrediction.fertileWindowStart` + // and honours discreet mode like the other cycle reminders. + "CYCLE_FERTILE_SOON", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -65,6 +72,10 @@ export const EVENT_DEFAULT_ENABLED: Record = { // also has to be flipped on before the dispatcher surfaces the channel. CYCLE_PERIOD_SOON: false, CYCLE_PERIOD_CONFIRM: false, + // v1.15.1 — default OFF and additionally TTC-gated in the cron, so the + // server never surfaces fertile-window language unless the user has both + // chosen the conception goal and opted the reminder in. + CYCLE_FERTILE_SOON: false, }; export const CHANNEL_TYPE_LABELS: Record = {