mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
e391150455
commit
93d47ddd05
10 files changed
+376
-90
No files matched your search
+7
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
Reference in new issue
Block a user