feat(cycle): choose how the calendar feed draws a forecast (#1350)

A forecast period was one all-day event over every day it might fall on,
so a period of a few days showed up as a week-long estimate next to the
logged ones. Each feed now carries a forecast style:

- EXACT: the calculated period only, from the most likely start for the
  predicted number of bleeding days.
- MARGIN: that event, plus separate "possible" events for the days it may
  start earlier and the days it may still run on, each only where the
  band reaches that side. Discreet titles use an open dot for them.
- BAND: the single range, unchanged.

Migration 0495 adds the column with BAND as its default, so every live
feed keeps the events it serves. A feed turned on from now on starts on
MARGIN, set by the mint route. PATCH changes it, rotate copies it, the
status and the audit diff carry it, and the settings card offers it with
a one-line explanation of the chosen style. The horizon and its stop rule
are the same in every style, and the period event keeps its UID across
styles so a calendar updates it in place.
This commit is contained in:
Marc-André Bombeck committed 2026-11-09 10:59:18 +01:00
1 parent f86a7a61e1
commit 546b9b2569
30 files changed
+647 -36

No files matched your search

+16
View File
@@ -2,6 +2,22 @@
## [Unreleased]
### Added
- **Choose how the calendar subscription draws a forecast.** A logged
period of a few days next to a forecast spanning more than a week was
hard to read, because the forecast event covered every day the period
might fall on. The calendar subscription card now offers three styles
under "How forecasts look": just the expected days; the expected days
with the days the period may start earlier or run on later as separate
"possible" events (an open dot in discreet mode); or the single event
covering the whole range, as before. A subscription turned on from now
on starts with the expected days and their margin; one that is already
on keeps the single range until you change it, and rotating the link
keeps whichever style it has. `PATCH /api/cycle/feed-subscription`
accepts `forecastStyle` (`EXACT`, `MARGIN` or `BAND`) and the status
carries it. Migration `0495_cycle_feed_forecast_style`. (#1350)
## [1.47.1] — 2026-11-07
### Added
+22 -4
View File
@@ -874,7 +874,7 @@ paths:
summary: Turn the calendar subscription on (#1350)
description: "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live
subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles
ahead, no history, no fertile window, discreet titles. Rate-limited per acting account
ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account
(`cycle-feed:mint:<actorId>`, 10/h, shared with rotate)."
responses:
"201": &a13
@@ -957,9 +957,9 @@ paths:
tags:
- Cycle
summary: Replace the calendar subscription's link (#1350)
description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL
EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers
409 `cycle.feed.exists` and the winner's link stands."
description: "One transaction: the live link is revoked and a new one minted with the same options, the forecast style
included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing,
the one that loses answers 409 `cycle.feed.exists` and the winner's link stands."
responses:
"201": *a13
"401": *a14
@@ -28732,6 +28732,12 @@ components:
enum:
- DISCREET
- DESCRIPTIVE
forecastStyle:
type: string
enum:
- EXACT
- MARGIN
- BAND
additionalProperties: false
description: "The writable options. At least one field. `includeFertile: true` is refused (422
`cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE."
@@ -41370,6 +41376,17 @@ components:
enum:
- DISCREET
- DESCRIPTIVE
forecastStyle:
type: string
enum:
- EXACT
- MARGIN
- BAND
description: "How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely
start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one
for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from
the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on
before the option existed is `BAND`."
required:
- tokenPrefix
- createdAt
@@ -41380,6 +41397,7 @@ components:
- historyCycles
- includeFertile
- titleMode
- forecastStyle
additionalProperties: false
description: "The live calendar subscription, without its secret: never the token hash and never the URL."
CycleFeedMinted:
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Diskret (●)",
"DESCRIPTIVE": "Beschreibend"
},
"forecasts": "Darstellung der Schätzung",
"forecastStyle": {
"EXACT": "Nur erwartete Tage",
"MARGIN": "Erwartete Tage mit Spielraum",
"BAND": "Ein Zeitraum"
},
"forecastStyleHint": {
"EXACT": "Nur die erwarteten Tage.",
"MARGIN": "Die erwarteten Tage, dazu als eigene Termine die Tage davor und danach, falls die Periode früher oder später beginnt.",
"BAND": "Ein Termin über alle Tage, auf die die Periode fallen kann."
},
"fertile": "Fruchtbares Fenster",
"fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.",
"rotate": "Link erneuern",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Periode (Schätzung)",
"periodLogged": "Periode (erfasst)",
"fertileEstimate": "Fruchtbares Fenster (Schätzung)",
"periodPossible": "Periode möglich",
"likelyStart": "Wahrscheinlicher Beginn {date}, {band}",
"bandOne": "±{count} Tag",
"bandFew": "±{count} Tage",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Descriptive"
},
"forecasts": "How forecasts look",
"forecastStyle": {
"EXACT": "Expected days",
"MARGIN": "Expected days with margin",
"BAND": "One range"
},
"forecastStyleHint": {
"EXACT": "Just the expected days.",
"MARGIN": "Expected days, with the days it may start earlier or later as separate entries.",
"BAND": "One entry covering every day it may fall on."
},
"fertile": "Fertile window",
"fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.",
"rotate": "Rotate link",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Period (estimate)",
"periodLogged": "Period (logged)",
"fertileEstimate": "Fertile window (estimate)",
"periodPossible": "Period possible",
"likelyStart": "Most likely start {date}, {band}",
"bandOne": "±{count} day",
"bandFew": "±{count} days",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descriptivo"
},
"forecasts": "Cómo se ven las previsiones",
"forecastStyle": {
"EXACT": "Días previstos",
"MARGIN": "Días previstos y margen",
"BAND": "Un solo intervalo"
},
"forecastStyleHint": {
"EXACT": "Solo los días previstos.",
"MARGIN": "Los días previstos, con los días en que puede empezar antes o después como eventos aparte.",
"BAND": "Un solo evento que cubre todos los días en que puede caer."
},
"fertile": "Ventana fértil",
"fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.",
"rotate": "Renovar enlace",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Periodo (estimación)",
"periodLogged": "Periodo (registrado)",
"fertileEstimate": "Ventana fértil (estimación)",
"periodPossible": "Periodo posible",
"likelyStart": "Inicio más probable {date}, {band}",
"bandOne": "±{count} día",
"bandFew": "±{count} días",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discret (●)",
"DESCRIPTIVE": "Explicite"
},
"forecasts": "Affichage des prévisions",
"forecastStyle": {
"EXACT": "Jours prévus",
"MARGIN": "Jours prévus et marge",
"BAND": "Une seule plage"
},
"forecastStyleHint": {
"EXACT": "Seulement les jours prévus.",
"MARGIN": "Les jours prévus, avec en événements séparés les jours où les règles peuvent commencer plus tôt ou plus tard.",
"BAND": "Un seul événement couvrant tous les jours où elles peuvent tomber."
},
"fertile": "Fenêtre fertile",
"fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.",
"rotate": "Renouveler le lien",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Règles (estimation)",
"periodLogged": "Règles (enregistrées)",
"fertileEstimate": "Fenêtre fertile (estimation)",
"periodPossible": "Règles possibles",
"likelyStart": "Début le plus probable {date}, {band}",
"bandOne": "±{count} jour",
"bandFew": "±{count} jours",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descrittivo"
},
"forecasts": "Aspetto delle previsioni",
"forecastStyle": {
"EXACT": "Giorni previsti",
"MARGIN": "Giorni previsti e margine",
"BAND": "Un unico intervallo"
},
"forecastStyleHint": {
"EXACT": "Solo i giorni previsti.",
"MARGIN": "I giorni previsti, con i giorni in cui possono iniziare prima o dopo come eventi separati.",
"BAND": "Un unico evento che copre tutti i giorni in cui possono cadere."
},
"fertile": "Finestra fertile",
"fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.",
"rotate": "Rinnova link",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Mestruazioni (stima)",
"periodLogged": "Mestruazioni (registrate)",
"fertileEstimate": "Finestra fertile (stima)",
"periodPossible": "Mestruazioni possibili",
"likelyStart": "Inizio più probabile {date}, {band}",
"bandOne": "±{count} giorno",
"bandFew": "±{count} giorni",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "드러나지 않게 (●)",
"DESCRIPTIVE": "내용 표시"
},
"forecasts": "예측 표시 방식",
"forecastStyle": {
"EXACT": "예상일만",
"MARGIN": "예상일과 여유 기간",
"BAND": "하나의 기간"
},
"forecastStyleHint": {
"EXACT": "예상되는 날만 표시합니다.",
"MARGIN": "예상일과 함께, 더 일찍 또는 늦게 시작할 수 있는 날을 별도 일정으로 표시합니다.",
"BAND": "생리가 있을 수 있는 모든 날을 하나의 일정으로 표시합니다."
},
"fertile": "가임기",
"fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.",
"rotate": "링크 다시 발급",
@@ -10075,6 +10086,7 @@
"periodEstimate": "생리 (예상)",
"periodLogged": "생리 (기록)",
"fertileEstimate": "가임기 (예상)",
"periodPossible": "생리 가능",
"likelyStart": "가장 가능성 높은 시작일 {date}, {band}",
"bandOne": "±{count}일",
"bandFew": "±{count}일",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discreet (●)",
"DESCRIPTIVE": "Beschrijvend"
},
"forecasts": "Weergave van voorspellingen",
"forecastStyle": {
"EXACT": "Verwachte dagen",
"MARGIN": "Verwachte dagen met marge",
"BAND": "Eén bereik"
},
"forecastStyleHint": {
"EXACT": "Alleen de verwachte dagen.",
"MARGIN": "De verwachte dagen, met de dagen waarop ze eerder of later kan beginnen als aparte afspraken.",
"BAND": "Eén afspraak over alle dagen waarop ze kan vallen."
},
"fertile": "Vruchtbare periode",
"fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.",
"rotate": "Link vernieuwen",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Menstruatie (schatting)",
"periodLogged": "Menstruatie (geregistreerd)",
"fertileEstimate": "Vruchtbare periode (schatting)",
"periodPossible": "Menstruatie mogelijk",
"likelyStart": "Meest waarschijnlijke begin {date}, {band}",
"bandOne": "±{count} dag",
"bandFew": "±{count} dagen",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Dyskretne (●)",
"DESCRIPTIVE": "Opisowe"
},
"forecasts": "Wygląd prognoz",
"forecastStyle": {
"EXACT": "Przewidywane dni",
"MARGIN": "Przewidywane dni z marginesem",
"BAND": "Jeden zakres"
},
"forecastStyleHint": {
"EXACT": "Tylko przewidywane dni.",
"MARGIN": "Przewidywane dni, a jako osobne wydarzenia dni, w które miesiączka może zacząć się wcześniej lub później.",
"BAND": "Jedno wydarzenie obejmujące wszystkie dni, na które może przypaść."
},
"fertile": "Okno płodne",
"fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.",
"rotate": "Odnów link",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Miesiączka (szacunek)",
"periodLogged": "Miesiączka (zapisana)",
"fertileEstimate": "Okno płodne (szacunek)",
"periodPossible": "Możliwa miesiączka",
"likelyStart": "Najbardziej prawdopodobny początek {date}, {band}",
"bandOne": "±{count} dzień",
"bandFew": "±{count} dni",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Discreto (●)",
"DESCRIPTIVE": "Descritivo"
},
"forecasts": "Como as previsões aparecem",
"forecastStyle": {
"EXACT": "Dias previstos",
"MARGIN": "Dias previstos e margem",
"BAND": "Um único intervalo"
},
"forecastStyleHint": {
"EXACT": "Só os dias previstos.",
"MARGIN": "Os dias previstos, com os dias em que ela pode começar antes ou depois como eventos separados.",
"BAND": "Um único evento cobrindo todos os dias em que ela pode cair."
},
"fertile": "Janela fértil",
"fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.",
"rotate": "Renovar link",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Menstruação (estimativa)",
"periodLogged": "Menstruação (registrada)",
"fertileEstimate": "Janela fértil (estimativa)",
"periodPossible": "Menstruação possível",
"likelyStart": "Início mais provável {date}, {band}",
"bandOne": "±{count} dia",
"bandFew": "±{count} dias",
+12
View File
@@ -10056,6 +10056,17 @@
"DISCREET": "Незаметно (●)",
"DESCRIPTIVE": "Подробно"
},
"forecasts": "Вид прогноза",
"forecastStyle": {
"EXACT": "Ожидаемые дни",
"MARGIN": "Ожидаемые дни с запасом",
"BAND": "Один промежуток"
},
"forecastStyleHint": {
"EXACT": "Только ожидаемые дни.",
"MARGIN": "Ожидаемые дни, а отдельными событиями дни, когда месячные могут начаться раньше или позже.",
"BAND": "Одно событие на все дни, на которые они могут прийтись."
},
"fertile": "Фертильное окно",
"fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.",
"rotate": "Обновить ссылку",
@@ -10075,6 +10086,7 @@
"periodEstimate": "Месячные (оценка)",
"periodLogged": "Месячные (записаны)",
"fertileEstimate": "Фертильное окно (оценка)",
"periodPossible": "Возможны месячные",
"likelyStart": "Наиболее вероятное начало {date}, {band}",
"bandOne": "±{count} день",
"bandFew": "±{count} дня",
@@ -0,0 +1,33 @@
-- How the cycle calendar feed draws a forecast period (#1350):
--
-- EXACT the calculated period only, no margin
-- MARGIN the calculated period, plus the days it may start earlier or end
-- later as separate "possible" events
-- BAND one event covering every day the period may fall on
--
-- The column defaults to BAND, which is what every feed drew until now, so an
-- existing subscription keeps exactly the events it had. A new subscription is
-- given MARGIN by the route that mints it, not by this default.
--
-- One DO block, so the file is one statement that rolls back whole and the
-- lock wait on `cycle_calendar_feeds` is bounded. The type is created only when
-- missing and the column only when missing, so a replay is harmless.
-- Reversibility: dropping the column and the type returns every feed to the
-- single-band forecast.
--
-- @retry-on-lock-timeout
DO $migration$
BEGIN
PERFORM set_config('lock_timeout', '5s', true);
IF NOT EXISTS (
SELECT 1 FROM pg_type WHERE typname = 'cycle_calendar_feed_forecast_style'
) THEN
CREATE TYPE "cycle_calendar_feed_forecast_style" AS ENUM ('EXACT', 'MARGIN', 'BAND');
END IF;
ALTER TABLE "cycle_calendar_feeds"
ADD COLUMN IF NOT EXISTS "forecast_style" "cycle_calendar_feed_forecast_style" NOT NULL DEFAULT 'BAND';
END
$migration$;
+18 -4
View File
@@ -8343,6 +8343,17 @@ enum CycleCalendarFeedTitleMode {
@@map("cycle_calendar_feed_title_mode")
}
/// #1350 — how the feed draws a forecast period. EXACT is the calculated
/// period alone; MARGIN adds the days it may start earlier or end later as
/// separate events; BAND is one event over every day it may fall on.
enum CycleCalendarFeedForecastStyle {
EXACT
MARGIN
BAND
@@map("cycle_calendar_feed_forecast_style")
}
/// #1350 — a subscribable calendar feed of one record's cycle forecast.
///
/// The row is a URL credential. Only the HMAC of the raw `hlf_` token is
@@ -8389,12 +8400,15 @@ model CycleCalendarFeed {
/// Forecast cycles ahead (1..6). The feed caps itself further once the
/// band grows past ±10 days or half a cycle.
horizonCycles Int @default(3) @map("horizon_cycles")
horizonCycles Int @default(3) @map("horizon_cycles")
/// Observed cycles included as history (0..12).
historyCycles Int @default(0) @map("history_cycles")
historyCycles Int @default(0) @map("history_cycles")
/// The fertile window of the next cycle, for TRYING_TO_CONCEIVE only.
includeFertile Boolean @default(false) @map("include_fertile")
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
includeFertile Boolean @default(false) @map("include_fertile")
titleMode CycleCalendarFeedTitleMode @default(DISCREET) @map("title_mode")
/// BAND by default so a feed minted before the option keeps its events. A
/// new feed is minted with MARGIN by the subscription route.
forecastStyle CycleCalendarFeedForecastStyle @default(BAND) @map("forecast_style")
user User @relation("CycleCalendarFeedSubject", fields: [userId], references: [id], onDelete: Cascade)
createdBy User @relation("CycleCalendarFeedCreator", fields: [createdById], references: [id], onDelete: Cascade)
+11 -3
View File
@@ -118,13 +118,20 @@ beforeEach(() => {
});
describe("the calendar feed body", () => {
it.each(["DISCREET", "DESCRIPTIVE"] as const)(
"carries no day-log content in %s mode",
async (titleMode) => {
it.each(
(["DISCREET", "DESCRIPTIVE"] as const).flatMap((titleMode) =>
(["EXACT", "MARGIN", "BAND"] as const).map(
(forecastStyle) => [titleMode, forecastStyle] as const,
),
),
)(
"carries no day-log content in %s mode, %s forecasts",
async (titleMode, forecastStyle) => {
const built = await buildCycleFeed(
{
...FEED,
titleMode,
forecastStyle,
historyCycles: 12,
horizonCycles: 6,
includeFertile: true,
@@ -146,6 +153,7 @@ describe("the calendar feed body", () => {
{
...FEED,
titleMode: "DESCRIPTIVE",
forecastStyle: "MARGIN",
historyCycles: 6,
includeFertile: true,
},
@@ -4,6 +4,8 @@
*
* One transaction: the live feed is revoked and its successor minted with the
* same options, so there is never a moment with two live links or with none.
* The forecast style is copied too: a feed minted before that option keeps
* BAND rather than taking a new feed's default.
* Calendars holding the old link stop updating on their next poll. The new
* URL is returned exactly once. Two rotations racing both revoke the same
* feed, and the second successor then meets the one-live-feed index: that
@@ -78,6 +80,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
},
});
if (!live) return null;
@@ -95,6 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
forecastStyle: live.forecastStyle,
},
select: FEED_SUMMARY_SELECT,
});
+10 -1
View File
@@ -59,7 +59,10 @@ import {
destroyedDetails,
overwriteDetails,
} from "@/lib/sharing/audit-details";
import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed";
import {
NEW_FEED_FORECAST_STYLE,
cycleFeedOptionsPatchSchema,
} from "@/lib/validations/cycle-feed";
export const GET = apiHandler(async () => {
const auth = await requireGuardianAuth();
@@ -110,6 +113,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: {
userId: user.id,
createdById: actor.id,
forecastStyle: NEW_FEED_FORECAST_STYLE,
tokenHash: minted.hash,
tokenPrefix: minted.prefix,
},
@@ -180,6 +184,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
? { includeFertile: input.includeFertile }
: {}),
...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}),
...(input.forecastStyle !== undefined
? { forecastStyle: input.forecastStyle }
: {}),
};
const updated = await prisma.cycleCalendarFeed.update({
where: { id: live.id },
@@ -197,12 +204,14 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
historyCycles: live.historyCycles,
includeFertile: live.includeFertile,
titleMode: live.titleMode,
forecastStyle: live.forecastStyle,
},
after: {
horizonCycles: updated.horizonCycles,
historyCycles: updated.historyCycles,
includeFertile: updated.includeFertile,
titleMode: updated.titleMode,
forecastStyle: updated.forecastStyle,
},
}),
},
@@ -30,6 +30,7 @@ const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "MARGIN" as const,
};
describe("<CycleCalendarFeedCard>", () => {
@@ -58,6 +59,30 @@ describe("<CycleCalendarFeedCard>", () => {
expect(html).not.toContain("Fertile window");
});
it("offers the three forecast styles and explains the chosen one", () => {
const html = render({
feed: FEED,
projectionState: "OK",
fertileAvailable: false,
});
expect(html).toContain("How forecasts look");
for (const value of ["EXACT", "MARGIN", "BAND"]) {
expect(html).toContain(`value="${value}"`);
}
expect(html).toContain('aria-describedby="cycle-feed-forecast-hint"');
expect(html).toMatch(
/data-slot="cycle-feed-forecast-hint"[^>]*>Expected days, with the days it may start earlier or later as separate entries\.</,
);
const band = render({
feed: { ...FEED, forecastStyle: "BAND" },
projectionState: "OK",
fertileAvailable: false,
});
expect(band).toContain("One entry covering every day it may fall on.");
expect(band).not.toContain("as separate entries");
});
it("explains a shorter feed in the muted tone", () => {
const html = render({
feed: FEED,
@@ -28,6 +28,7 @@ import {
type CycleFeedMinted,
type CycleFeedOptionsPatch,
type CycleFeedStatus,
type FeedForecastStyle,
type FeedTitleMode,
} from "./use-cycle-feed";
@@ -44,6 +45,7 @@ import {
const HORIZONS = [1, 3, 6] as const;
const HISTORIES = [0, 3, 6, 12] as const;
const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"];
const FORECAST_STYLES: FeedForecastStyle[] = ["EXACT", "MARGIN", "BAND"];
/** A long URL with its middle elided, so both ends stay recognisable. */
function middleTruncate(url: string, keep = 28): string {
@@ -352,6 +354,31 @@ function FeedOptions({ status }: { status: CycleFeedStatus }) {
</NativeSelect>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="cycle-feed-forecast">{t("cycle.feed.forecasts")}</Label>
<NativeSelect
id="cycle-feed-forecast"
value={feed.forecastStyle}
disabled={update.isPending}
aria-describedby="cycle-feed-forecast-hint"
onChange={(e) =>
save({ forecastStyle: e.target.value as FeedForecastStyle })
}
>
{FORECAST_STYLES.map((s) => (
<option key={s} value={s}>
{t(`cycle.feed.forecastStyle.${s}`)}
</option>
))}
</NativeSelect>
<p
id="cycle-feed-forecast-hint"
className="text-muted-foreground text-sm"
data-slot="cycle-feed-forecast-hint"
>
{t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)}
</p>
</div>
{status.fertileAvailable ? (
<div className="flex items-start justify-between gap-4">
<div className="space-y-0.5">
+7 -1
View File
@@ -17,6 +17,7 @@ import { queryKeys } from "@/lib/query-keys";
import type { ProjectionState } from "./types";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER";
export interface CycleFeedSummary {
@@ -29,6 +30,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -46,7 +48,11 @@ export interface CycleFeedMinted {
export type CycleFeedOptionsPatch = Partial<
Pick<
CycleFeedSummary,
"horizonCycles" | "historyCycles" | "includeFertile" | "titleMode"
| "horizonCycles"
| "historyCycles"
| "includeFertile"
| "titleMode"
| "forecastStyle"
>
>;
+145
View File
@@ -0,0 +1,145 @@
/**
* #1350 — how a forecast period is drawn in each style. Dates only: the
* projection is handed in, so every expected day below is the arithmetic of
* one fixed cycle and nothing else.
*/
import { describe, expect, it } from "vitest";
import type { ProjectedCycle } from "@/lib/cycle/projection";
import { eventUid } from "@/lib/cycle/ics";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "../events";
/** Most likely start 17 Nov, five bleeding days, ±2 days. */
const CYCLE: ProjectedCycle = {
k: 1,
start: "2026-11-17",
periodLength: 5,
bandStart: "2026-11-15",
bandEnd: "2026-11-19",
};
function events(
forecastStyle: FeedForecastStyle,
projected: ProjectedCycle[] = [CYCLE],
titleMode: FeedTitleMode = "DISCREET",
locale: "en" | "de" = "en",
) {
return buildFeedCalendar({
feedId: "feed-1",
titleMode,
forecastStyle,
projected,
history: [],
fertile: null,
translator: getServerTranslator(locale),
}).events;
}
/** start .. endExclusive, the pair DTSTART / DTEND carry. */
const span = (e: { start: string; endExclusive: string }) => [
e.start,
e.endExclusive,
];
describe("forecast styles", () => {
it("BAND draws one event from the earliest start to the latest period's end", () => {
const out = events("BAND");
expect(out.map(span)).toEqual([["2026-11-15", "2026-11-24"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("EXACT draws only the calculated period", () => {
const out = events("EXACT");
expect(out.map(span)).toEqual([["2026-11-17", "2026-11-22"]]);
expect(out[0].summary).toBe(DISCREET_SUMMARY);
});
it("MARGIN draws the calculated period with the possible days on each side", () => {
const out = events("MARGIN");
expect(out.map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
expect(out.map((e) => e.summary)).toEqual([
DISCREET_MARGIN_SUMMARY,
DISCREET_SUMMARY,
DISCREET_MARGIN_SUMMARY,
]);
});
it("MARGIN covers exactly the days BAND covers, without overlap", () => {
const [band] = events("BAND");
const margin = events("MARGIN");
expect(margin[0].start).toBe(band.start);
expect(margin.at(-1)!.endExclusive).toBe(band.endExclusive);
for (let i = 1; i < margin.length; i++) {
expect(margin[i].start).toBe(margin[i - 1].endExclusive);
}
});
it("MARGIN draws no side event where the band is empty", () => {
const exact = { ...CYCLE, bandStart: CYCLE.start, bandEnd: CYCLE.start };
expect(events("MARGIN", [exact]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
]);
const lateOnly = { ...CYCLE, bandStart: CYCLE.start };
expect(events("MARGIN", [lateOnly]).map(span)).toEqual([
["2026-11-17", "2026-11-22"],
["2026-11-22", "2026-11-24"],
]);
const earlyOnly = { ...CYCLE, bandEnd: CYCLE.start };
expect(events("MARGIN", [earlyOnly]).map(span)).toEqual([
["2026-11-15", "2026-11-17"],
["2026-11-17", "2026-11-22"],
]);
});
it("keeps the period's UID across styles and gives each margin its own", () => {
const main = eventUid("feed-1", "pred:1");
expect(events("BAND")[0].uid).toBe(main);
expect(events("EXACT")[0].uid).toBe(main);
expect(events("MARGIN").map((e) => e.uid)).toEqual([
eventUid("feed-1", "pred:1:before"),
main,
eventUid("feed-1", "pred:1:after"),
]);
});
it("draws every forecast cycle it is given", () => {
const next = {
k: 2,
start: "2026-12-15",
periodLength: 5,
bandStart: "2026-12-12",
bandEnd: "2026-12-18",
};
expect(events("EXACT", [CYCLE, next])).toHaveLength(2);
expect(events("MARGIN", [CYCLE, next])).toHaveLength(6);
expect(events("BAND", [CYCLE, next])).toHaveLength(2);
});
it("names the possible days in the record's language when descriptive", () => {
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE").map((e) => e.summary),
).toEqual(["Period possible", "Period (estimate)", "Period possible"]);
expect(
events("MARGIN", [CYCLE], "DESCRIPTIVE", "de").map((e) => e.summary),
).toEqual(["Periode möglich", "Periode (Schätzung)", "Periode möglich"]);
});
it("describes every forecast event with its band", () => {
for (const e of events("MARGIN", [CYCLE], "DESCRIPTIVE")) {
expect(e.description).toBe("Most likely start Nov 17, ±2 days");
}
for (const e of events("EXACT")) expect(e.description).toBe("±2 days");
});
});
+1
View File
@@ -48,6 +48,7 @@ export const FEED = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET" as const,
forecastStyle: "BAND" as const,
};
export const SUBJECT = {
@@ -54,6 +54,7 @@ const FEED_ROW = {
historyCycles: 0,
includeFertile: false,
titleMode: "DISCREET",
forecastStyle: "BAND",
};
function request(path = `${TOKEN}.ics`, headers: Record<string, string> = {}) {
+7 -1
View File
@@ -30,7 +30,11 @@ import { moodDateKey } from "@/lib/mood/date-key";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
import { icsContentHash, type IcsCalendar } from "../ics";
import { buildFeedCalendar, type FeedTitleMode } from "./events";
import {
buildFeedCalendar,
type FeedForecastStyle,
type FeedTitleMode,
} from "./events";
import { historyPeriods, historyWindowStart } from "./history";
/** The feed options the body depends on. */
@@ -40,6 +44,7 @@ export interface FeedOptions {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
/** The record the feed serves, as far as building it needs. */
@@ -147,6 +152,7 @@ export async function buildCycleFeed(
const calendar = buildFeedCalendar({
feedId: feed.id,
titleMode: feed.titleMode,
forecastStyle: feed.forecastStyle,
projected: projection.cycles,
history,
fertile,
+85 -19
View File
@@ -11,6 +11,13 @@
* DISCREET (the default) titles every event with a dot and names the calendar
* "HealthLog", so a glance at a shared screen or a lock-screen notification
* says nothing. DESCRIPTIVE names the period, in the record's own language.
*
* A forecast period is drawn in one of three styles. EXACT is the period as
* calculated: the most likely first day for the predicted number of bleeding
* days. MARGIN is the same event, plus the days the period may start earlier
* or run on later as their own "possible" events, so the calculated days stay
* readable as such. BAND is one event over every day the period may fall on,
* from the earliest likely start to the last bleeding day of the latest.
*/
import { addDays, dayDiff } from "@/lib/cycle/day-math";
import type { ProjectedCycle } from "@/lib/cycle/projection";
@@ -21,9 +28,12 @@ import type { ServerTranslator } from "@/lib/i18n/server-translator";
import { eventUid, type IcsCalendar, type IcsEvent } from "../ics";
export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE";
export type FeedForecastStyle = "EXACT" | "MARGIN" | "BAND";
/** The discreet title: one dot, no word. */
export const DISCREET_SUMMARY = "●";
/** The discreet title of a MARGIN style's possible days: an open dot. */
export const DISCREET_MARGIN_SUMMARY = "○";
/** The discreet calendar name. */
export const DISCREET_CALENDAR_NAME = "HealthLog";
@@ -39,6 +49,7 @@ export interface HistoryPeriod {
export interface FeedEventInput {
feedId: string;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
projected: readonly ProjectedCycle[];
history: readonly HistoryPeriod[];
/** The next fertile window, already gated; null when not shown. */
@@ -63,26 +74,81 @@ function bandText(days: number, translator: ServerTranslator): string {
);
}
function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent {
/** The forecast's description: the likely start and the band, or the band. */
function forecastDescription(p: ProjectedCycle, input: FeedEventInput): string {
const { translator } = input;
const band = bandText(dayDiff(p.bandEnd, p.start), translator);
return input.titleMode === "DESCRIPTIVE"
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band,
})
: band;
}
/**
* One forecast cycle's events. DTEND is exclusive throughout, so the period
* itself ends the day after its last bleeding day.
*/
function forecastEvents(p: ProjectedCycle, input: FeedEventInput): IcsEvent[] {
const { translator } = input;
const band = dayDiff(p.bandEnd, p.start);
const descriptive = input.titleMode === "DESCRIPTIVE";
return {
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start through
// the last bleeding day of the latest likely start. DTEND is exclusive.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY,
description: descriptive
? translator.t("cycle.feed.ics.likelyStart", {
date: shortDate(p.start, translator),
band: bandText(band, translator),
})
: bandText(band, translator),
};
const description = forecastDescription(p, input);
const periodSummary = descriptive
? translator.t("cycle.feed.ics.periodEstimate")
: DISCREET_SUMMARY;
if (input.forecastStyle === "BAND") {
return [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
// The whole window the period may fall in: earliest likely start
// through the last bleeding day of the latest likely start.
start: p.bandStart,
endExclusive: addDays(p.bandEnd, p.periodLength),
summary: periodSummary,
description,
},
];
}
const periodEnd = addDays(p.start, p.periodLength);
const events: IcsEvent[] = [
{
uid: eventUid(input.feedId, `pred:${p.k}`),
start: p.start,
endExclusive: periodEnd,
summary: periodSummary,
description,
},
];
if (input.forecastStyle === "EXACT") return events;
const marginSummary = descriptive
? translator.t("cycle.feed.ics.periodPossible")
: DISCREET_MARGIN_SUMMARY;
// The days before the likely start it may begin on, and the days after the
// likely period it may still run on. A side with no band has no event.
if (dayDiff(p.start, p.bandStart) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:before`),
start: p.bandStart,
endExclusive: p.start,
summary: marginSummary,
description,
});
}
const bandEndExclusive = addDays(p.bandEnd, p.periodLength);
if (dayDiff(bandEndExclusive, periodEnd) > 0) {
events.push({
uid: eventUid(input.feedId, `pred:${p.k}:after`),
start: periodEnd,
endExclusive: bandEndExclusive,
summary: marginSummary,
description,
});
}
return events;
}
function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent {
@@ -116,7 +182,7 @@ function fertileEvent(
export function buildFeedCalendar(input: FeedEventInput): IcsCalendar {
const events: IcsEvent[] = [
...input.history.map((h) => historyEvent(h, input)),
...input.projected.map((p) => forecastEvent(p, input)),
...input.projected.flatMap((p) => forecastEvents(p, input)),
...(input.fertile ? [fertileEvent(input.fertile, input)] : []),
].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid));
return {
+1
View File
@@ -116,6 +116,7 @@ const FEED_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
/**
+5 -1
View File
@@ -10,7 +10,7 @@ import type { ProjectionState } from "@/lib/cycle/projection";
import { buildCycleFeed, type FeedSubject } from "./build";
import { fetcherFamily, type FetcherFamily } from "./fetcher";
import type { FeedTitleMode } from "./events";
import type { FeedForecastStyle, FeedTitleMode } from "./events";
export const FEED_SUMMARY_SELECT = {
id: true,
@@ -23,6 +23,7 @@ export const FEED_SUMMARY_SELECT = {
historyCycles: true,
includeFertile: true,
titleMode: true,
forecastStyle: true,
} as const;
export interface FeedSummaryRow {
@@ -36,6 +37,7 @@ export interface FeedSummaryRow {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedSummary {
@@ -49,6 +51,7 @@ export interface CycleFeedSummary {
historyCycles: number;
includeFertile: boolean;
titleMode: FeedTitleMode;
forecastStyle: FeedForecastStyle;
}
export interface CycleFeedStatus {
@@ -74,6 +77,7 @@ export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary {
historyCycles: row.historyCycles,
includeFertile: row.includeFertile,
titleMode: row.titleMode,
forecastStyle: row.forecastStyle,
};
}
+6 -2
View File
@@ -11,6 +11,7 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
cycleFeedForecastStyleEnum,
cycleFeedOptionsPatchSchema,
cycleFeedTitleModeEnum,
} from "@/lib/validations/cycle-feed";
@@ -76,6 +77,9 @@ const feedSummary = z
historyCycles: z.number().int().min(0).max(12),
includeFertile: z.boolean(),
titleMode: cycleFeedTitleModeEnum,
forecastStyle: cycleFeedForecastStyleEnum.describe(
"How a forecast period is drawn. `EXACT`: one event over the predicted bleeding days from the most likely start. `MARGIN`: that event, plus a separate event for the days before it the period may start on and one for the days after it may still run on (each only when the band reaches that side). `BAND`: one event from the earliest likely start through the last bleeding day of the latest likely start. A subscription turned on before the option existed is `BAND`.",
),
})
.meta({
id: "CycleFeedSummary",
@@ -202,7 +206,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Turn the calendar subscription on (#1350)",
description:
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
"Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles, `MARGIN` forecasts. Rate-limited per acting account (`cycle-feed:mint:<actorId>`, 10/h, shared with rotate).",
responses: {
...mintResponses,
"409": {
@@ -271,7 +275,7 @@ export const cycleFeedPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Cycle"],
summary: "Replace the calendar subscription's link (#1350)",
description:
"One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
"One transaction: the live link is revoked and a new one minted with the same options, the forecast style included. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating. Of two rotations racing, the one that loses answers 409 `cycle.feed.exists` and the winner's link stands.",
responses: {
...mintResponses,
"404": {
+13
View File
@@ -10,6 +10,18 @@ import { z } from "zod/v4";
/** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */
export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]);
/** How a forecast period is drawn. Mirrors `CycleCalendarFeedForecastStyle`. */
export const cycleFeedForecastStyleEnum = z.enum(["EXACT", "MARGIN", "BAND"]);
/**
* The style a newly minted feed starts with. The column's own default is BAND,
* which keeps every feed minted before the option on the events it had; a new
* one starts with the calculated days and their margin drawn apart.
*/
export const NEW_FEED_FORECAST_STYLE: z.infer<
typeof cycleFeedForecastStyleEnum
> = "MARGIN";
/** Forecast cycles ahead. The feed caps itself further by band width. */
export const FEED_HORIZON_MIN = 1;
export const FEED_HORIZON_MAX = 6;
@@ -33,6 +45,7 @@ export const cycleFeedOptionsPatchSchema = z
.optional(),
includeFertile: z.boolean().optional(),
titleMode: cycleFeedTitleModeEnum.optional(),
forecastStyle: cycleFeedForecastStyleEnum.optional(),
})
.strict()
.refine((v) => Object.keys(v).length > 0, {
@@ -407,6 +407,96 @@ describe("turning the subscription on", () => {
});
});
describe("the forecast style", () => {
/** The live row's style, read from the table rather than the route. */
async function liveStyle(userId: string) {
const row = await db().cycleCalendarFeed.findFirstOrThrow({
where: { userId, revokedAt: null },
select: { forecastStyle: true },
});
return row.forecastStyle;
}
/** How many VEVENTs the live link serves. */
async function eventCount(url: string) {
const res = await fetchFeed(segmentOf(url));
expect(res.status).toBe(200);
return (await res.text()).split("BEGIN:VEVENT").length - 1;
}
it("starts a new subscription on MARGIN and publishes it in the status", async () => {
const owner = await makeUser("owner");
await seedCycles(owner.id);
await signIn(owner.id);
const res = await manage("POST");
expect(res.status).toBe(201);
expect((await res.json()).data.feed.forecastStyle).toBe("MARGIN");
expect(await liveStyle(owner.id)).toBe("MARGIN");
const status = (await (await manage("GET")).json()).data;
expect(status.feed.forecastStyle).toBe("MARGIN");
});
it("keeps a feed minted before the option on BAND, through a rotation", async () => {
const owner = await makeUser("owner");
// A row written without the column, as every feed minted before it was.
await db().cycleCalendarFeed.create({
data: {
userId: owner.id,
createdById: owner.id,
tokenHash: "pre-option",
tokenPrefix: "hlf_0000",
},
});
expect(await liveStyle(owner.id)).toBe("BAND");
await signIn(owner.id);
const res = await rotate();
expect(res.status).toBe(201);
expect((await res.json()).data.feed.forecastStyle).toBe("BAND");
expect(await liveStyle(owner.id)).toBe("BAND");
});
it("changes on PATCH, is audited, survives a rotation, and changes the body", async () => {
const owner = await makeUser("owner");
await seedCycles(owner.id);
const minted = await mint(owner.id);
const marginEvents = await eventCount(minted.url);
await signIn(owner.id);
const res = await manage("PATCH", { forecastStyle: "EXACT" });
expect(res.status).toBe(200);
expect((await res.json()).data.feed.forecastStyle).toBe("EXACT");
expect(await liveStyle(owner.id)).toBe("EXACT");
// The calculated period alone: fewer events than with its margins, and
// one per forecast cycle.
const exactEvents = await eventCount(minted.url);
expect(exactEvents).toBeGreaterThan(0);
expect(marginEvents).toBeGreaterThan(exactEvents);
const audit = await db().auditLog.findFirstOrThrow({
where: { action: "cycle.feed.update", userId: owner.id },
});
// The audit names the changed field and the value it replaced.
const details = JSON.parse(audit.details ?? "{}");
expect(details.fields).toEqual(["forecastStyle"]);
expect(details.previous).toEqual({ forecastStyle: "MARGIN" });
await signIn(owner.id);
const rotated = await rotate();
expect(rotated.status).toBe(201);
expect((await rotated.json()).data.feed.forecastStyle).toBe("EXACT");
expect(await liveStyle(owner.id)).toBe("EXACT");
});
it("refuses a style that does not exist", async () => {
const owner = await makeUser("owner");
await mint(owner.id);
const res = await manage("PATCH", { forecastStyle: "WIDE" });
expect(res.status).toBe(422);
expect(await liveStyle(owner.id)).toBe("MARGIN");
});
});
describe("fetching the feed", () => {
it("serves iCalendar with an ETag and answers 304 to it", async () => {
const owner = await makeUser("owner");