From 546b9b2569c1173e44da49504a01ad5350697adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 9 Nov 2026 10:59:18 +0100 Subject: [PATCH] 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. --- CHANGELOG.md | 16 ++ docs/api/openapi.yaml | 26 +++- messages/de.json | 12 ++ messages/en.json | 12 ++ messages/es.json | 12 ++ messages/fr.json | 12 ++ messages/it.json | 12 ++ messages/ko.json | 12 ++ messages/nl.json | 12 ++ messages/pl.json | 12 ++ messages/pt-BR.json | 12 ++ messages/ru.json | 12 ++ .../migration.sql | 33 ++++ prisma/schema.prisma | 22 ++- .../cycle-feed-content-guard.test.ts | 14 +- .../cycle/feed-subscription/rotate/route.ts | 4 + src/app/api/cycle/feed-subscription/route.ts | 11 +- .../cycle-calendar-feed-card.test.tsx | 25 +++ .../cycle/cycle-calendar-feed-card.tsx | 27 ++++ src/components/cycle/use-cycle-feed.ts | 8 +- src/lib/cycle/feed/__tests__/events.test.ts | 145 ++++++++++++++++++ src/lib/cycle/feed/__tests__/fixtures.ts | 1 + src/lib/cycle/feed/__tests__/serve.test.ts | 1 + src/lib/cycle/feed/build.ts | 8 +- src/lib/cycle/feed/events.ts | 104 ++++++++++--- src/lib/cycle/feed/serve.ts | 1 + src/lib/cycle/feed/status.ts | 6 +- src/lib/openapi/routes/cycle-feed.ts | 8 +- src/lib/validations/cycle-feed.ts | 13 ++ tests/integration/cycle-calendar-feed.test.ts | 90 +++++++++++ 30 files changed, 647 insertions(+), 36 deletions(-) create mode 100644 prisma/migrations/0495_cycle_feed_forecast_style/migration.sql create mode 100644 src/lib/cycle/feed/__tests__/events.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f74563e..3cbbb74f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 45964de7d..3f21603d9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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:`, 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: diff --git a/messages/de.json b/messages/de.json index 6d0139ccd..60cecd436 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 9e86a3f69..295e1c28c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index b20934c4d..b63237607 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 24dba3492..f0ef51d9c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index f0c0734a4..dee5f1d36 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index f8dfaefe1..a14f6a891 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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}일", diff --git a/messages/nl.json b/messages/nl.json index 462f15c05..86a396ba9 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -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", diff --git a/messages/pl.json b/messages/pl.json index f83b58363..b1d73193e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 97989a554..20e581383 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -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", diff --git a/messages/ru.json b/messages/ru.json index b83a16b5a..c53ea555b 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -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} дня", diff --git a/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql new file mode 100644 index 000000000..6e7558438 --- /dev/null +++ b/prisma/migrations/0495_cycle_feed_forecast_style/migration.sql @@ -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$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8b102b41c..dbf05d6e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/src/__tests__/cycle-feed-content-guard.test.ts b/src/__tests__/cycle-feed-content-guard.test.ts index d250d0116..83b938138 100644 --- a/src/__tests__/cycle-feed-content-guard.test.ts +++ b/src/__tests__/cycle-feed-content-guard.test.ts @@ -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, }, diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts index 9b62851c9..fd9449577 100644 --- a/src/app/api/cycle/feed-subscription/rotate/route.ts +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -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, }); diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts index 8b8b3aba9..6d8b94516 100644 --- a/src/app/api/cycle/feed-subscription/route.ts +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -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, }, }), }, diff --git a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx index 6e30cf1ba..da7782cab 100644 --- a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx @@ -30,6 +30,7 @@ const FEED = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET" as const, + forecastStyle: "MARGIN" as const, }; describe("", () => { @@ -58,6 +59,30 @@ describe("", () => { 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 html = render({ feed: FEED, diff --git a/src/components/cycle/cycle-calendar-feed-card.tsx b/src/components/cycle/cycle-calendar-feed-card.tsx index ec6bcf74e..454045904 100644 --- a/src/components/cycle/cycle-calendar-feed-card.tsx +++ b/src/components/cycle/cycle-calendar-feed-card.tsx @@ -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 }) { +
+ + + save({ forecastStyle: e.target.value as FeedForecastStyle }) + } + > + {FORECAST_STYLES.map((s) => ( + + ))} + +

+ {t(`cycle.feed.forecastStyleHint.${feed.forecastStyle}`)} +

+
{status.fertileAvailable ? (
diff --git a/src/components/cycle/use-cycle-feed.ts b/src/components/cycle/use-cycle-feed.ts index 58ab6ed64..997911a3c 100644 --- a/src/components/cycle/use-cycle-feed.ts +++ b/src/components/cycle/use-cycle-feed.ts @@ -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" > >; diff --git a/src/lib/cycle/feed/__tests__/events.test.ts b/src/lib/cycle/feed/__tests__/events.test.ts new file mode 100644 index 000000000..e52df5078 --- /dev/null +++ b/src/lib/cycle/feed/__tests__/events.test.ts @@ -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"); + }); +}); diff --git a/src/lib/cycle/feed/__tests__/fixtures.ts b/src/lib/cycle/feed/__tests__/fixtures.ts index ad65c01e5..e4754d129 100644 --- a/src/lib/cycle/feed/__tests__/fixtures.ts +++ b/src/lib/cycle/feed/__tests__/fixtures.ts @@ -48,6 +48,7 @@ export const FEED = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET" as const, + forecastStyle: "BAND" as const, }; export const SUBJECT = { diff --git a/src/lib/cycle/feed/__tests__/serve.test.ts b/src/lib/cycle/feed/__tests__/serve.test.ts index a822043cc..335d7371a 100644 --- a/src/lib/cycle/feed/__tests__/serve.test.ts +++ b/src/lib/cycle/feed/__tests__/serve.test.ts @@ -54,6 +54,7 @@ const FEED_ROW = { historyCycles: 0, includeFertile: false, titleMode: "DISCREET", + forecastStyle: "BAND", }; function request(path = `${TOKEN}.ics`, headers: Record = {}) { diff --git a/src/lib/cycle/feed/build.ts b/src/lib/cycle/feed/build.ts index 3505a65fb..ab219e017 100644 --- a/src/lib/cycle/feed/build.ts +++ b/src/lib/cycle/feed/build.ts @@ -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, diff --git a/src/lib/cycle/feed/events.ts b/src/lib/cycle/feed/events.ts index 44f399a09..70cdd6111 100644 --- a/src/lib/cycle/feed/events.ts +++ b/src/lib/cycle/feed/events.ts @@ -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 { diff --git a/src/lib/cycle/feed/serve.ts b/src/lib/cycle/feed/serve.ts index c953d44f5..9435ca293 100644 --- a/src/lib/cycle/feed/serve.ts +++ b/src/lib/cycle/feed/serve.ts @@ -116,6 +116,7 @@ const FEED_SELECT = { historyCycles: true, includeFertile: true, titleMode: true, + forecastStyle: true, } as const; /** diff --git a/src/lib/cycle/feed/status.ts b/src/lib/cycle/feed/status.ts index b53a6ef98..7b37514fb 100644 --- a/src/lib/cycle/feed/status.ts +++ b/src/lib/cycle/feed/status.ts @@ -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, }; } diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts index 3e250c994..504bb005e 100644 --- a/src/lib/openapi/routes/cycle-feed.ts +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -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 = { 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:`, 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:`, 10/h, shared with rotate).", responses: { ...mintResponses, "409": { @@ -271,7 +275,7 @@ export const cycleFeedPaths: NonNullable = { 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": { diff --git a/src/lib/validations/cycle-feed.ts b/src/lib/validations/cycle-feed.ts index 646faf3ea..32866e808 100644 --- a/src/lib/validations/cycle-feed.ts +++ b/src/lib/validations/cycle-feed.ts @@ -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, { diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts index 257ab7234..7e271e72b 100644 --- a/tests/integration/cycle-calendar-feed.test.ts +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -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");