feat(cycle): fertile-window reminder for the conception goal

Add a CYCLE_FERTILE_SOON reminder that fires two days before the
predicted fertile-window start. Twice-gated and default-OFF: it only
surfaces when CycleProfile.goal is TRYING_TO_CONCEIVE and fertileWindowStart
is populated, so fertile language never reaches the avoid / general / peri
goals. Reuses the existing cycle-reminder cron, discreet mode, clientManaged
suppression, idempotency ledger, and push-attempts plumbing verbatim; the
body carries the non-medical, not-a-contraceptive framing.
This commit is contained in:
Marc-André Bombeck committed 2026-06-06 16:39:45 +02:00
1 parent e391150455
commit 93d47ddd05
10 files changed
+376 -90

No files matched your search

+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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."
}
+7 -1
View File
@@ -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."
}
+23
View File
@@ -205,6 +205,29 @@ export function CycleSettings({ profile }: { profile: CycleProfileDTO }) {
</Button>
</div>
{/* 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" ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("cycle.settings.fertileReminder")}
</p>
<p className="text-muted-foreground text-xs">
{t("cycle.settings.fertileReminderDescription")}
</p>
</div>
<Button variant="outline" size="sm" asChild className="shrink-0">
<Link href="/notifications">
{t("cycle.settings.remindersLink")}
</Link>
</Button>
</div>
) : null}
<Separator />
{/* Data export / delete */}
+152 -9
View File
@@ -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<string, string[]>;
/** Existing `ok` push attempts: `${userId}:${eventType}` already sent today. */
@@ -110,6 +118,7 @@ function baseUser(over: Partial<FakeUser> = {}): 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<DispatchFn>(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<DispatchFn>(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<DispatchFn>(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");
});
});
+148 -75
View File
@@ -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<void> => {
// 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);
+11
View File
@@ -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<EventType, boolean> = {
// 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<ChannelType, string> = {