mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge branch 'main' into refactor/settings-admin-one-piece
This commit is contained in:
65 files changed
+4603
-307
No files matched your search
@@ -10,18 +10,21 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
# The suite outgrew a single runner: 128 files took ~13 minutes when the
|
||||
# 25-minute guard was written, and the v1.37 sharing work took it to 173
|
||||
# files and past 25 minutes of wall clock, so every run died at the timeout
|
||||
# with every test to that point green. The guard's own rule applies — split,
|
||||
# don't raise — so the files run as two vitest shards. Each shard boots its
|
||||
# own Postgres testcontainer; the timeout stays a hang detector, sized so a
|
||||
# healthy half-suite clears it on a slow runner.
|
||||
shard:
|
||||
runs-on: ubuntu-latest
|
||||
# This guard exists to kill a hung testcontainer or a deadlocked query, not
|
||||
# to hold the suite to a performance budget. At 15 it had stopped telling
|
||||
# those apart: the suite itself takes around 13 minutes of job time (128
|
||||
# files against a real Postgres), so two consecutive runs of the SAME
|
||||
# commit range came in at 12m50s and then over 15m, and the second was
|
||||
# killed mid-run with every test to that point green. A cap that a healthy
|
||||
# run clears only on a fast runner reports runner weather, not health.
|
||||
# 25 leaves real headroom and still catches a hang inside one CI cycle.
|
||||
# If the suite ever approaches this number, split it rather than raise it.
|
||||
timeout-minutes: 25
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
name: integration shard ${{ matrix.shard }}/2
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
@@ -36,7 +39,12 @@ jobs:
|
||||
|
||||
- run: pnpm db:generate
|
||||
|
||||
- run: pnpm test:integration
|
||||
# vitest is invoked directly: pnpm passes the `--` separator through to
|
||||
# the script literally, so `pnpm test:integration -- --shard=…` hands
|
||||
# vitest a stray positional argument and the shard flag never applies —
|
||||
# both "shards" then run the full suite. Proven by a paired local run
|
||||
# before this workflow shipped.
|
||||
- run: pnpm exec vitest run --config vitest.integration.config.mts --shard=${{ matrix.shard }}/2
|
||||
env:
|
||||
# The integration suite boots a Postgres testcontainer and points
|
||||
# Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`.
|
||||
@@ -50,3 +58,19 @@ jobs:
|
||||
API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
SESSION_SECRET: "integration-tests"
|
||||
|
||||
# Branch protection requires a check named exactly "integration". This job
|
||||
# carries that name and answers for both shards: it fails when any shard
|
||||
# failed or was cancelled, so a half-green matrix cannot satisfy the gate.
|
||||
integration:
|
||||
runs-on: ubuntu-latest
|
||||
needs: shard
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Every shard finished green
|
||||
run: |
|
||||
if [ "${{ needs.shard.result }}" != "success" ]; then
|
||||
echo "shard result: ${{ needs.shard.result }}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -16789,6 +16789,9 @@ components:
|
||||
type: boolean
|
||||
isPeriodLogged:
|
||||
type: boolean
|
||||
isCycleStart:
|
||||
type: boolean
|
||||
description: Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.
|
||||
flow:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -16856,6 +16859,7 @@ components:
|
||||
- isFertileWindow
|
||||
- isPredictedOvulation
|
||||
- isPeriodLogged
|
||||
- isCycleStart
|
||||
- flow
|
||||
- hasSymptoms
|
||||
- confidence
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Workout nicht gefunden.",
|
||||
"hrFromPulse": "Aus deinen Herzfrequenzdaten rund um diese Einheit",
|
||||
"hrAxisMinutes": "Min.",
|
||||
"hrAvgMarker": "Ø {bpm}",
|
||||
"hrMaxMarker": "Max. {bpm}",
|
||||
"hrRangeValue": "{low} bis {high} bpm",
|
||||
"hrRangeLabel": "Spanne",
|
||||
"hrNoProfile": "Für diese Einheit liegen keine einzelnen Pulswerte vor.",
|
||||
"sportAverageLabel": "Dein aktueller Durchschnitt:",
|
||||
"zonesTitle": "Belastungszonen",
|
||||
"zonesCaption": "Wie sich deine Belastung auf die Herzfrequenzzonen verteilt hat.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Tag erfassen",
|
||||
"description": "Erfasse Stärke, Symptome und Notizen von heute.",
|
||||
"description": "Stärke, Symptome und Notizen für diesen Tag.",
|
||||
"flow": "Stärke",
|
||||
"symptoms": "Symptome",
|
||||
"temperature": "Basaltemperatur",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intimität & Verhütung",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Tag {day} · {phase}",
|
||||
"bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen."
|
||||
"bbtHint": "Miss sie morgens als Erstes, noch vor dem Aufstehen.",
|
||||
"titleForDate": "Eintrag für {date}",
|
||||
"descriptionToday": "Heute. Stärke, Symptome und Notizen.",
|
||||
"deleteBodyCycleStart": "Dieser Tag eröffnet einen Zyklus. Beim Löschen geht der Periodenbeginn mit, sodass der vorherige Zyklus wie zuvor weiterläuft. Alles hier Erfasste geht ebenfalls: Stärke, Temperatur, Symptome und deine Notiz. Es gibt keinen Weg zurück."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Zyklus-Einstellungen",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "Was erfasst Geschlechtsverkehr?",
|
||||
"protected": "Ob beim Verkehr verhütet wurde.",
|
||||
"protectedLabel": "Was bedeutet geschützt?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Eintrag gespeichert.",
|
||||
"deleteSuccess": "Eintrag gelöscht.",
|
||||
"periodStartSaved": "Periodenbeginn gespeichert.",
|
||||
"periodEndSaved": "Periodenende gespeichert.",
|
||||
"startedPeriodOnDate": "Periode hat an diesem Tag begonnen",
|
||||
"endedPeriodOnDate": "Periode an diesem Tag beendet"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Periode steht wahrscheinlich bevor",
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Workout not found.",
|
||||
"hrFromPulse": "From your heart-rate data around this session",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Avg {bpm}",
|
||||
"hrMaxMarker": "Peak {bpm}",
|
||||
"hrRangeValue": "{low} to {high} bpm",
|
||||
"hrRangeLabel": "Range",
|
||||
"hrNoProfile": "No reading-by-reading heart-rate data covers this session.",
|
||||
"sportAverageLabel": "Your recent average:",
|
||||
"zonesTitle": "Effort zones",
|
||||
"zonesCaption": "How your effort was distributed across heart-rate zones.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Log your day",
|
||||
"description": "Capture today's flow, symptoms, and notes.",
|
||||
"description": "Flow, symptoms and notes for this day.",
|
||||
"flow": "Flow",
|
||||
"symptoms": "Symptoms",
|
||||
"temperature": "Basal body temperature",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intimacy & contraception",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Day {day} · {phase}",
|
||||
"bbtHint": "Take it first thing in the morning, before getting up."
|
||||
"bbtHint": "Take it first thing in the morning, before getting up.",
|
||||
"titleForDate": "Log for {date}",
|
||||
"descriptionToday": "Today. Flow, symptoms and notes.",
|
||||
"deleteBodyCycleStart": "This day opens a cycle. Deleting it takes the period start with it, so the previous cycle runs on as before. Everything logged here goes too: flow, temperature, symptoms and your note. There is no way to bring it back."
|
||||
},
|
||||
"testResult": {
|
||||
"POSITIVE": "Positive",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "What does intercourse track?",
|
||||
"protected": "Whether contraception was used during intercourse.",
|
||||
"protectedLabel": "What does protected mean?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Entry saved.",
|
||||
"deleteSuccess": "Entry deleted.",
|
||||
"periodStartSaved": "Period start saved.",
|
||||
"periodEndSaved": "End of period saved.",
|
||||
"startedPeriodOnDate": "My period started this day",
|
||||
"endedPeriodOnDate": "My period ended this day"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Period likely soon",
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Entrenamiento no encontrado.",
|
||||
"hrFromPulse": "A partir de tus datos de frecuencia cardíaca en torno a esta sesión",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Media {bpm}",
|
||||
"hrMaxMarker": "Máx. {bpm}",
|
||||
"hrRangeValue": "{low} a {high} bpm",
|
||||
"hrRangeLabel": "Rango",
|
||||
"hrNoProfile": "No hay valores de pulso individuales que cubran esta sesión.",
|
||||
"sportAverageLabel": "Tu promedio reciente:",
|
||||
"zonesTitle": "Zonas de esfuerzo",
|
||||
"zonesCaption": "Cómo se distribuyó tu esfuerzo entre las zonas de frecuencia cardíaca.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registrar tu día",
|
||||
"description": "Captura el flujo, los síntomas y las notas de hoy.",
|
||||
"description": "Flujo, síntomas y notas de este día.",
|
||||
"flow": "Flujo",
|
||||
"symptoms": "Síntomas",
|
||||
"temperature": "Temperatura basal",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intimidad y anticoncepción",
|
||||
"testsSection": "Pruebas",
|
||||
"phaseDayHeader": "Día {day} · {phase}",
|
||||
"bbtHint": "Mídela a primera hora de la mañana, antes de levantarte."
|
||||
"bbtHint": "Mídela a primera hora de la mañana, antes de levantarte.",
|
||||
"titleForDate": "Registro del {date}",
|
||||
"descriptionToday": "Hoy. Flujo, síntomas y notas.",
|
||||
"deleteBodyCycleStart": "Este día abre un ciclo. Al eliminarlo se va con él el inicio del periodo, de modo que el ciclo anterior continúa como antes. También se va todo lo registrado aquí: flujo, temperatura, síntomas y tu nota. No hay forma de recuperarlo."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ajustes del ciclo",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "¿Qué registra relaciones?",
|
||||
"protected": "Si se usó anticoncepción durante las relaciones.",
|
||||
"protectedLabel": "¿Qué significa protegido?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Entrada guardada.",
|
||||
"deleteSuccess": "Entrada eliminada.",
|
||||
"periodStartSaved": "Inicio del periodo guardado.",
|
||||
"periodEndSaved": "Fin del periodo guardado.",
|
||||
"startedPeriodOnDate": "Mi periodo empezó este día",
|
||||
"endedPeriodOnDate": "Mi periodo terminó este día"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "El periodo llegará pronto",
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Entraînement introuvable.",
|
||||
"hrFromPulse": "À partir de vos données de fréquence cardiaque autour de cette séance",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Moy. {bpm}",
|
||||
"hrMaxMarker": "Max. {bpm}",
|
||||
"hrRangeValue": "{low} à {high} bpm",
|
||||
"hrRangeLabel": "Plage",
|
||||
"hrNoProfile": "Aucune valeur de pouls détaillée ne couvre cette séance.",
|
||||
"sportAverageLabel": "Votre moyenne récente :",
|
||||
"zonesTitle": "Zones d'effort",
|
||||
"zonesCaption": "Répartition de votre effort entre les zones de fréquence cardiaque.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Enregistrer ta journée",
|
||||
"description": "Saisis le flux, les symptômes et les notes du jour.",
|
||||
"description": "Flux, symptômes et notes de cette journée.",
|
||||
"flow": "Flux",
|
||||
"symptoms": "Symptômes",
|
||||
"temperature": "Température basale",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intimité et contraception",
|
||||
"testsSection": "Tests",
|
||||
"phaseDayHeader": "Jour {day} · {phase}",
|
||||
"bbtHint": "Prends-la dès le matin, avant de te lever."
|
||||
"bbtHint": "Prends-la dès le matin, avant de te lever.",
|
||||
"titleForDate": "Saisie du {date}",
|
||||
"descriptionToday": "Aujourd'hui. Flux, symptômes et notes.",
|
||||
"deleteBodyCycleStart": "Cette journée ouvre un cycle. La supprimer emporte le début des règles, si bien que le cycle précédent se poursuit comme avant. Tout ce qui est saisi ici disparaît aussi : flux, température, symptômes et ta note. Rien ne peut être récupéré."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Réglages du cycle",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "Que suit le rapport ?",
|
||||
"protected": "Si une contraception a été utilisée pendant le rapport.",
|
||||
"protectedLabel": "Que signifie protégé ?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Entrée enregistrée.",
|
||||
"deleteSuccess": "Entrée supprimée.",
|
||||
"periodStartSaved": "Début des règles enregistré.",
|
||||
"periodEndSaved": "Fin des règles enregistrée.",
|
||||
"startedPeriodOnDate": "Mes règles ont commencé ce jour-là",
|
||||
"endedPeriodOnDate": "Mes règles se sont terminées ce jour-là"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Règles probablement bientôt",
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Allenamento non trovato.",
|
||||
"hrFromPulse": "Dai tuoi dati di frequenza cardiaca intorno a questa sessione",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Media {bpm}",
|
||||
"hrMaxMarker": "Max {bpm}",
|
||||
"hrRangeValue": "da {low} a {high} bpm",
|
||||
"hrRangeLabel": "Intervallo",
|
||||
"hrNoProfile": "Nessun valore di battito dettagliato copre questa sessione.",
|
||||
"sportAverageLabel": "La tua media recente:",
|
||||
"zonesTitle": "Zone di sforzo",
|
||||
"zonesCaption": "Come si è distribuito il tuo sforzo tra le zone di frequenza cardiaca.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Registra la tua giornata",
|
||||
"description": "Annota flusso, sintomi e note di oggi.",
|
||||
"description": "Flusso, sintomi e note di questo giorno.",
|
||||
"flow": "Flusso",
|
||||
"symptoms": "Sintomi",
|
||||
"temperature": "Temperatura basale",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intimità e contraccezione",
|
||||
"testsSection": "Test",
|
||||
"phaseDayHeader": "Giorno {day} · {phase}",
|
||||
"bbtHint": "Misurala appena sveglia, prima di alzarti."
|
||||
"bbtHint": "Misurala appena sveglia, prima di alzarti.",
|
||||
"titleForDate": "Registrazione del {date}",
|
||||
"descriptionToday": "Oggi. Flusso, sintomi e note.",
|
||||
"deleteBodyCycleStart": "Questo giorno apre un ciclo. Eliminandolo sparisce anche l'inizio delle mestruazioni, così il ciclo precedente prosegue come prima. Se ne va pure tutto ciò che è annotato qui: flusso, temperatura, sintomi e la tua nota. Non c'è modo di recuperarlo."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Impostazioni del ciclo",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "Cosa registra i rapporti?",
|
||||
"protected": "Se durante il rapporto è stata usata la contraccezione.",
|
||||
"protectedLabel": "Cosa significa protetto?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Voce salvata.",
|
||||
"deleteSuccess": "Voce eliminata.",
|
||||
"periodStartSaved": "Inizio delle mestruazioni salvato.",
|
||||
"periodEndSaved": "Fine delle mestruazioni salvata.",
|
||||
"startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno",
|
||||
"endedPeriodOnDate": "Le mestruazioni sono finite questo giorno"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Ciclo probabilmente in arrivo",
|
||||
|
||||
+17
-3
@@ -3111,6 +3111,11 @@
|
||||
"notFound": "Nie znaleziono treningu.",
|
||||
"hrFromPulse": "Na podstawie Twoich danych tętna z okolic tej sesji",
|
||||
"hrAxisMinutes": "min",
|
||||
"hrAvgMarker": "Śr. {bpm}",
|
||||
"hrMaxMarker": "Maks. {bpm}",
|
||||
"hrRangeValue": "{low} do {high} bpm",
|
||||
"hrRangeLabel": "Zakres",
|
||||
"hrNoProfile": "Brak pojedynczych pomiarów tętna dla tej sesji.",
|
||||
"sportAverageLabel": "Twoja ostatnia średnia:",
|
||||
"zonesTitle": "Strefy wysiłku",
|
||||
"zonesCaption": "Jak Twój wysiłek rozłożył się na strefy tętna.",
|
||||
@@ -7712,7 +7717,7 @@
|
||||
},
|
||||
"sheet": {
|
||||
"title": "Zapisz swój dzień",
|
||||
"description": "Zapisz dzisiejsze krwawienie, objawy i notatki.",
|
||||
"description": "Krwawienie, objawy i notatki z tego dnia.",
|
||||
"flow": "Krwawienie",
|
||||
"symptoms": "Objawy",
|
||||
"temperature": "Podstawowa temperatura ciała",
|
||||
@@ -7747,7 +7752,10 @@
|
||||
"intimacySection": "Intymność i antykoncepcja",
|
||||
"testsSection": "Testy",
|
||||
"phaseDayHeader": "Dzień {day} · {phase}",
|
||||
"bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem."
|
||||
"bbtHint": "Zmierz ją rano zaraz po przebudzeniu, przed wstaniem.",
|
||||
"titleForDate": "Wpis z {date}",
|
||||
"descriptionToday": "Dzisiaj. Krwawienie, objawy i notatki.",
|
||||
"deleteBodyCycleStart": "Ten dzień otwiera cykl. Usunięcie go kasuje razem z nim początek miesiączki, więc poprzedni cykl biegnie dalej jak wcześniej. Znika też wszystko, co tu zapisano: krwawienie, temperatura, objawy i twoja notatka. Nie da się tego cofnąć."
|
||||
},
|
||||
"settings": {
|
||||
"title": "Ustawienia cyklu",
|
||||
@@ -7824,7 +7832,13 @@
|
||||
"intercourseLabel": "Co śledzi stosunek?",
|
||||
"protected": "Czy podczas stosunku zastosowano antykoncepcję.",
|
||||
"protectedLabel": "Co oznacza zabezpieczony?"
|
||||
}
|
||||
},
|
||||
"saveSuccess": "Wpis zapisany.",
|
||||
"deleteSuccess": "Wpis usunięty.",
|
||||
"periodStartSaved": "Początek miesiączki zapisany.",
|
||||
"periodEndSaved": "Koniec miesiączki zapisany.",
|
||||
"startedPeriodOnDate": "Miesiączka zaczęła się tego dnia",
|
||||
"endedPeriodOnDate": "Miesiączka skończyła się tego dnia"
|
||||
},
|
||||
"cycleReminders": {
|
||||
"periodSoonTitle": "Miesiączka prawdopodobnie wkrótce",
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Read-only diagnostic for cycle records the module has stopped reading.
|
||||
*
|
||||
* Two defects put records into states that look, from the app, like no periods
|
||||
* were ever logged. Both are fixed going forward; neither repairs the rows it
|
||||
* already made, and neither is visible from the surface it broke.
|
||||
*
|
||||
* The first is a closed torso. Logging a period start closes the cycle before
|
||||
* it, and removing that start used to leave the closure standing. The previous
|
||||
* cycle keeps an end date that a cycle which no longer exists gave it, and a
|
||||
* record whose last cycle is closed has no open cycle for the engine to
|
||||
* forecast from, so the prediction disappears and does not come back. The
|
||||
* signature is exact and needs no audit trail: a live cycle carrying an end
|
||||
* date with no live cycle after it. A cycle is closed by its successor, so
|
||||
* closed with no successor means the successor is gone.
|
||||
*
|
||||
* The second is a period that only ever existed as flow. Cycle rows used to be
|
||||
* written by the one-tap period boundary alone, so bleeding entered through the
|
||||
* log sheet's chips, the bulk drain or an Apple Health import produced day-logs
|
||||
* and no cycle at all. The engine reads cycle rows, so the record holds periods
|
||||
* and reports none.
|
||||
*
|
||||
* The bleeding-day rule below is imported from the write path rather than
|
||||
* restated, so this report and the app cannot drift on what counts as a period
|
||||
* start.
|
||||
*
|
||||
* Nothing is written. The output is a per-record inventory plus the two flags,
|
||||
* so the state can be read before anything is decided about it.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-cycle-state.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { bleedOpensCycle } from "@/lib/cycle/cycle-boundaries";
|
||||
import { dayDiff } from "@/lib/cycle/day-math";
|
||||
|
||||
/** Below this many observed cycles the app withholds its phase band. */
|
||||
const STILL_LEARNING_BELOW = 3;
|
||||
|
||||
interface CycleRow {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
lengthDays: number | null;
|
||||
periodEndDate: string | null;
|
||||
deletedAt: Date | null;
|
||||
isPredicted: boolean;
|
||||
}
|
||||
|
||||
interface DayRow {
|
||||
date: string;
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
}
|
||||
|
||||
function describeCycle(c: CycleRow): string {
|
||||
const state = c.deletedAt
|
||||
? `deleted ${c.deletedAt.toISOString().slice(0, 10)}`
|
||||
: c.endDate
|
||||
? `closed ${c.endDate}`
|
||||
: "open";
|
||||
const length = c.lengthDays === null ? "-" : `${c.lengthDays}d`;
|
||||
const period = c.periodEndDate ? ` periodEnd=${c.periodEndDate}` : "";
|
||||
const predicted = c.isPredicted ? " PREDICTED" : "";
|
||||
return `${c.startDate} ${state.padEnd(20)} length=${length.padEnd(5)}${period}${predicted}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Every record that has ever touched the module, from either side: a cycle
|
||||
// row, or a day-log. A record with day-logs and no cycles is precisely one
|
||||
// of the cases this exists to find, so it cannot be found by cycles alone.
|
||||
const [cycleOwners, dayOwners] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
distinct: ["userId"],
|
||||
select: { userId: true },
|
||||
}),
|
||||
prisma.cycleDayLog.findMany({
|
||||
distinct: ["userId"],
|
||||
select: { userId: true },
|
||||
}),
|
||||
]);
|
||||
const userIds = Array.from(
|
||||
new Set([
|
||||
...cycleOwners.map((r) => r.userId),
|
||||
...dayOwners.map((r) => r.userId),
|
||||
]),
|
||||
).sort();
|
||||
|
||||
if (userIds.length === 0) {
|
||||
console.log("No cycle records on this instance. Nothing to report.");
|
||||
return;
|
||||
}
|
||||
|
||||
let torsoTotal = 0;
|
||||
let flowOnlyTotal = 0;
|
||||
let staleBoundaryTotal = 0;
|
||||
|
||||
for (const userId of userIds) {
|
||||
const [cycles, days] = await Promise.all([
|
||||
prisma.menstrualCycle.findMany({
|
||||
where: { userId },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
periodEndDate: true,
|
||||
deletedAt: true,
|
||||
isPredicted: true,
|
||||
},
|
||||
}) as Promise<CycleRow[]>,
|
||||
prisma.cycleDayLog.findMany({
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { date: "asc" },
|
||||
select: { date: true, flow: true, intermenstrualBleeding: true },
|
||||
}) as Promise<DayRow[]>,
|
||||
]);
|
||||
|
||||
const live = cycles.filter((c) => c.deletedAt === null);
|
||||
const liveStarts = live.map((c) => c.startDate);
|
||||
const startSet = new Set(liveStarts);
|
||||
|
||||
console.log(`record ${userId}`);
|
||||
console.log(
|
||||
` ${cycles.length} cycle row(s), ${live.length} live, ` +
|
||||
`${days.length} day-log(s)`,
|
||||
);
|
||||
for (const c of cycles) console.log(` ${describeCycle(c)}`);
|
||||
|
||||
// A closed cycle with nothing after it: closed by a start that is gone.
|
||||
const torsos = live.filter(
|
||||
(c, i) => c.endDate !== null && i === live.length - 1,
|
||||
);
|
||||
// A closed cycle whose boundary does not match the successor it has.
|
||||
const stale = live.filter((c, i) => {
|
||||
const next = live[i + 1];
|
||||
if (!next) return false;
|
||||
const expectedLength = dayDiff(next.startDate, c.startDate);
|
||||
return c.endDate === null || c.lengthDays !== expectedLength;
|
||||
});
|
||||
|
||||
// Bleeding days the write path would treat as a period start today, on a
|
||||
// date carrying no live cycle. Walked forward so each decision sees the
|
||||
// starts that already exist plus the ones this pass would infer, exactly
|
||||
// as a replay of the writes would.
|
||||
const inferred: string[] = [...liveStarts].sort();
|
||||
const flowOnly: string[] = [];
|
||||
for (const d of days) {
|
||||
if (startSet.has(d.date)) continue;
|
||||
const prior = inferred.filter((s) => s <= d.date).at(-1) ?? null;
|
||||
if (
|
||||
bleedOpensCycle(
|
||||
d.flow as never,
|
||||
d.intermenstrualBleeding,
|
||||
prior,
|
||||
d.date,
|
||||
)
|
||||
) {
|
||||
flowOnly.push(d.date);
|
||||
inferred.push(d.date);
|
||||
inferred.sort();
|
||||
}
|
||||
}
|
||||
|
||||
if (torsos.length > 0) {
|
||||
torsoTotal += torsos.length;
|
||||
console.log(
|
||||
` CLOSED TORSO: ${torsos
|
||||
.map((c) => `${c.startDate} (ends ${c.endDate})`)
|
||||
.join(", ")}`,
|
||||
);
|
||||
console.log(
|
||||
" The last cycle is closed and has no successor, so the engine has " +
|
||||
"no open cycle to forecast from. Re-opening it means clearing its " +
|
||||
"endDate and lengthDays.",
|
||||
);
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
staleBoundaryTotal += stale.length;
|
||||
console.log(
|
||||
` STALE BOUNDARY: ${stale.map((c) => c.startDate).join(", ")}`,
|
||||
);
|
||||
console.log(
|
||||
" The stored end or length disagrees with the next cycle's start.",
|
||||
);
|
||||
}
|
||||
if (flowOnly.length > 0) {
|
||||
flowOnlyTotal += flowOnly.length;
|
||||
console.log(` PERIOD WITH NO CYCLE ROW: ${flowOnly.join(", ")}`);
|
||||
console.log(
|
||||
" Bleeding was logged on these days and no cycle opens on them. " +
|
||||
"Re-saving each day through the log sheet opens it now.",
|
||||
);
|
||||
}
|
||||
if (live.length > 0 && live.length - 1 < STILL_LEARNING_BELOW) {
|
||||
console.log(
|
||||
` Only ${live.length - 1} observed cycle length(s): under the ` +
|
||||
`${STILL_LEARNING_BELOW}-cycle gate, so the phase band stays withheld.`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${userIds.length} record(s) inspected. ` +
|
||||
`${torsoTotal} closed torso(s), ${staleBoundaryTotal} stale boundary(ies), ` +
|
||||
`${flowOnlyTotal} logged period(s) with no cycle row.`,
|
||||
);
|
||||
console.log("Nothing was changed.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Read-only diagnostic for hand-entered sleep rows stored in the wrong unit.
|
||||
*
|
||||
* `SLEEP_DURATION` is stored in minutes. The manual entry field asked for
|
||||
* hours and sent the typed number through unconverted, so a night entered as
|
||||
* 7.5 was filed as seven and a half MINUTES. Nothing objected: a single sleep
|
||||
* STAGE really can be that short, so the value sits inside the column's
|
||||
* plausibility band and looks like an ordinary row.
|
||||
*
|
||||
* That ambiguity is why this reports and does not repair. A stage segment of
|
||||
* eight minutes and a night typed as eight hours are the same number in the
|
||||
* same column, and only the person who entered it knows which it was. What CAN
|
||||
* be narrowed is the population: a MANUAL, whole-night row (no sleep stage) is
|
||||
* one somebody typed into the field, and a typed night shorter than the
|
||||
* longest plausible hours entry is almost certainly hours that were read as
|
||||
* minutes.
|
||||
*
|
||||
* The output lists the candidates with their date, stored value and the night
|
||||
* they would have been if the number was hours, so the maintainer can decide
|
||||
* per row. Nothing is written.
|
||||
*
|
||||
* Run (inside the app container, which has DATABASE_URL):
|
||||
* pnpm dlx tsx scripts/diagnose-manual-sleep-hours.ts
|
||||
*/
|
||||
import "dotenv/config";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MINUTES_PER_HOUR } from "@/lib/measurements/entry-units";
|
||||
|
||||
/**
|
||||
* Longest night anybody types into an hours field. A stored value at or below
|
||||
* this reads as hours; above it, the number can only ever have been minutes.
|
||||
*/
|
||||
const MAX_PLAUSIBLE_HOURS = 24;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const suspects = await prisma.measurement.findMany({
|
||||
where: {
|
||||
type: "SLEEP_DURATION",
|
||||
source: "MANUAL",
|
||||
deletedAt: null,
|
||||
// Whole-night rows only — a staged row comes from a sync source, never
|
||||
// from the entry field.
|
||||
sleepStage: null,
|
||||
value: { gt: 0, lte: MAX_PLAUSIBLE_HOURS },
|
||||
},
|
||||
orderBy: [{ userId: "asc" }, { measuredAt: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
value: true,
|
||||
measuredAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (suspects.length === 0) {
|
||||
console.log(
|
||||
"No hand-entered sleep rows in the hours-shaped band. Nothing to review.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const byUser = new Map<string, typeof suspects>();
|
||||
for (const row of suspects) {
|
||||
const bucket = byUser.get(row.userId);
|
||||
if (bucket) bucket.push(row);
|
||||
else byUser.set(row.userId, [row]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${suspects.length} hand-entered sleep row(s) across ${byUser.size} record(s) ` +
|
||||
`store a value of ${MAX_PLAUSIBLE_HOURS} or less.\n` +
|
||||
`Each is either a very short recorded sleep, or hours that were read as minutes.\n`,
|
||||
);
|
||||
|
||||
for (const [userId, rows] of byUser) {
|
||||
console.log(`record ${userId} — ${rows.length} row(s)`);
|
||||
for (const row of rows) {
|
||||
const asHours = row.value * MINUTES_PER_HOUR;
|
||||
console.log(
|
||||
` ${row.measuredAt.toISOString().slice(0, 10)} id=${row.id} ` +
|
||||
`stored=${row.value} min if it was hours=${asHours} min ` +
|
||||
`(${(asHours / MINUTES_PER_HOUR).toFixed(2)} h) ` +
|
||||
`entered=${row.createdAt.toISOString().slice(0, 10)}`,
|
||||
);
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Nothing was changed. Correct a row from the record list, or delete and re-enter it.",
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => {
|
||||
void prisma.$disconnect();
|
||||
});
|
||||
@@ -103,6 +103,16 @@ const LONG_CYCLE_STARTS = [
|
||||
"2026-04-30",
|
||||
];
|
||||
|
||||
/**
|
||||
* Four period starts exactly 28 days apart — the textbook regular record. The
|
||||
* last start is open, so where "today" sits relative to `lastStart + 28`
|
||||
* decides what the verdict may claim. Built from a supplied last start so a
|
||||
* test can place today a chosen number of days past the predicted one.
|
||||
*/
|
||||
function regularStarts(lastStart: string): string[] {
|
||||
return [-84, -56, -28, 0].map((offset) => addDays(lastStart, offset));
|
||||
}
|
||||
|
||||
function cycleRows(starts: readonly string[]) {
|
||||
return starts.map((startDate, i) => ({
|
||||
id: `cyc-${i}`,
|
||||
@@ -249,6 +259,7 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
cycleRows(LONG_CYCLE_STARTS) as never,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
resolveLuteal(profile() as never),
|
||||
"2026-05-20",
|
||||
);
|
||||
expect(verdict.dayOfCycle).toBe(fromEngine.dayOfCycle);
|
||||
expect(verdict.phase).toBe(fromEngine.phase);
|
||||
@@ -276,6 +287,259 @@ describe("GET /api/cycle/calendar carries the resolved verdict", () => {
|
||||
expect(prediction!.nextPeriodStart).toBe(addDays("2026-05-20", expected));
|
||||
});
|
||||
|
||||
/**
|
||||
* D-list edge 1 — the verdict at and after the predicted start day, driven
|
||||
* through the real route.
|
||||
*
|
||||
* A regular 28-day record whose period has not arrived is the ORDINARY way
|
||||
* to be late, and it is the case the ring was silent about: the engine ends
|
||||
* the open cycle's phase window at the predicted start, so the day after it
|
||||
* carried no phase, and the verdict resolver read "no phase today" as "no
|
||||
* data at all". The person saw an empty ring and the words insufficient data
|
||||
* on the third day of a late period. `OVERDUE` was only ever reachable when
|
||||
* the engine itself forecast a cycle longer than the grace window.
|
||||
*/
|
||||
describe("a period that has not arrived", () => {
|
||||
/** Place today N days past `lastStart + 28`, the predicted next start. */
|
||||
function overdueBy(days: number): { today: string; lastStart: string } {
|
||||
const today = "2026-06-11";
|
||||
return { today, lastStart: addDays(today, -(28 + days)) };
|
||||
}
|
||||
|
||||
function seedRegular(lastStart: string): void {
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(regularStarts(lastStart)) as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("keeps counting the open cycle past the predicted start", async () => {
|
||||
const { today, lastStart } = overdueBy(3);
|
||||
seedRegular(lastStart);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const data = body.data as Record<string, unknown>;
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
const prediction = data.prediction as { nextPeriodStart: string } | null;
|
||||
|
||||
// The predicted start really is behind us — otherwise this asserts nothing.
|
||||
expect(prediction?.nextPeriodStart).toBe(addDays(today, -3));
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(32);
|
||||
expect(verdict.phase).toBe("LUTEAL");
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
// There is no "until" once the predicted start is behind us.
|
||||
expect(verdict.daysUntilNext).toBeNull();
|
||||
});
|
||||
|
||||
it("says OVERDUE, with the day count, once the grace window is spent", async () => {
|
||||
// Typical 28 + 14 days of grace = 42. Day 44 is past it.
|
||||
const { today, lastStart } = overdueBy(15);
|
||||
seedRegular(lastStart);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("OVERDUE");
|
||||
expect(verdict.dayOfCycle).toBeNull();
|
||||
expect(verdict.phase).toBeNull();
|
||||
expect(verdict.overdueDays).toBe(16);
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
expect(verdict.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("holds the count on the predicted start day itself", async () => {
|
||||
const { today, lastStart } = overdueBy(0);
|
||||
seedRegular(lastStart);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Day 29 of a 28-day cycle: the period is due today and has not been
|
||||
// logged, which is a cycle day, not an absence of data.
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(29);
|
||||
expect(verdict.daysUntilNext).toBe(0);
|
||||
});
|
||||
|
||||
it("stops extending the open window once the next period is logged", async () => {
|
||||
// The same record with one more start logged four days ago: the earlier
|
||||
// cycle closes at that start and today is day 5 of the new one.
|
||||
const { today, lastStart } = overdueBy(3);
|
||||
const withNewStart = [...regularStarts(lastStart), addDays(today, -4)];
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows(withNewStart) as never,
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(5);
|
||||
expect(verdict.cycleStartDate).toBe(addDays(today, -4));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The cold-start gate suppresses the PHASE band while the engine is still
|
||||
* learning — a population-framed phase word off one or two cycles is not
|
||||
* earned. The day count is not a guess: it is the number of days since a
|
||||
* start the person logged themselves. Reading it off the phase labels meant
|
||||
* the gate took both, so a record with three logged periods rendered an
|
||||
* empty ring and offered to log a first period.
|
||||
*/
|
||||
describe("while the engine is still learning", () => {
|
||||
it("still counts the cycle day off the logged start", async () => {
|
||||
const today = "2026-06-11";
|
||||
const lastStart = addDays(today, -10);
|
||||
// Three starts → two observed lengths → under the three-cycle gate.
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows([
|
||||
addDays(lastStart, -56),
|
||||
addDays(lastStart, -28),
|
||||
lastStart,
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const data = body.data as Record<string, unknown>;
|
||||
// The gate really is on, so the phase below is suppressed rather than absent.
|
||||
expect(data.stillLearning).toBe(true);
|
||||
|
||||
const verdict = data.verdict as Record<string, unknown>;
|
||||
expect(verdict.state).toBe("IN_CYCLE");
|
||||
expect(verdict.dayOfCycle).toBe(11);
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
// No phase claim — that part of the gate stands.
|
||||
expect(verdict.phase).toBeNull();
|
||||
// The ring still draws a full wheel rather than an empty circle.
|
||||
expect(verdict.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("still says how late a period is", async () => {
|
||||
const today = "2026-06-11";
|
||||
const lastStart = addDays(today, -50);
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows([
|
||||
addDays(lastStart, -56),
|
||||
addDays(lastStart, -28),
|
||||
lastStart,
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const { body } = await callCalendar();
|
||||
const verdict = (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Day 51 against a stated 28-day typical plus 14 days of grace.
|
||||
expect(verdict.state).toBe("OVERDUE");
|
||||
expect(verdict.overdueDays).toBe(23);
|
||||
expect(verdict.cycleStartDate).toBe(lastStart);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* D-list edge 15 — the day count under a narrowed `from` window.
|
||||
*
|
||||
* The count used to be the length of the labelled run inside the grid, so a
|
||||
* caller that asked for a shorter span got a smaller number for the same day:
|
||||
* the run was cut off at the window's edge, not at the cycle's start. That was
|
||||
* a latent contract question while every caller asked for the same ninety
|
||||
* days. It stopped being latent when the month grid started reading its own
|
||||
* month, which is a thirty-day window by construction.
|
||||
*/
|
||||
describe("under a narrowed window", () => {
|
||||
async function callWindow(
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/cycle/calendar?from=${from}&to=${to}`,
|
||||
),
|
||||
);
|
||||
const body = JSON.parse(await res.text());
|
||||
return (body.data as Record<string, unknown>).verdict as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
}
|
||||
|
||||
it("counts from the logged start, not from the window's edge", async () => {
|
||||
const today = "2026-06-11";
|
||||
const lastStart = "2026-05-20"; // day 23 of the open cycle
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows([
|
||||
addDays(lastStart, -84),
|
||||
addDays(lastStart, -56),
|
||||
addDays(lastStart, -28),
|
||||
lastStart,
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
const wide = await callWindow(addDays(today, -90), addDays(today, 180));
|
||||
expect(wide.dayOfCycle).toBe(23);
|
||||
|
||||
// The month the grid would be showing: the cycle start is inside it, but
|
||||
// the window begins after several earlier cycles.
|
||||
const narrow = await callWindow("2026-06-01", "2026-06-30");
|
||||
expect(narrow.dayOfCycle).toBe(23);
|
||||
expect(narrow.cycleStartDate).toBe(lastStart);
|
||||
});
|
||||
|
||||
it("counts correctly when the window excludes the start entirely", async () => {
|
||||
const today = "2026-06-25";
|
||||
const lastStart = "2026-05-20";
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue(
|
||||
cycleRows([
|
||||
addDays(lastStart, -56),
|
||||
addDays(lastStart, -28),
|
||||
lastStart,
|
||||
]) as never,
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(`${today}T12:00:00Z`));
|
||||
|
||||
// June only: the cycle opened in May, outside the window entirely.
|
||||
const narrow = await callWindow("2026-06-01", "2026-06-30");
|
||||
expect(narrow.cycleStartDate).toBe(lastStart);
|
||||
expect(narrow.dayOfCycle).toBe(37);
|
||||
});
|
||||
});
|
||||
|
||||
it("says INSUFFICIENT_DATA when nothing has been logged", async () => {
|
||||
vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(getSession).mockResolvedValue(session("UTC") as never);
|
||||
|
||||
@@ -195,6 +195,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
nextPeriodStart: predictionDto?.nextPeriodStart ?? null,
|
||||
fertileWindowStart: predictionDto?.fertileWindowStart ?? null,
|
||||
fertileWindowEnd: predictionDto?.fertileWindowEnd ?? null,
|
||||
// The latest start the person logged, read off the cycle rows rather than
|
||||
// the grid. The grid's phase band is withheld while the engine is still
|
||||
// learning, and the day count must not go with it — `cycles` is ordered by
|
||||
// startDate ascending, so the last one at or before today is the anchor.
|
||||
lastPeriodStart:
|
||||
cycles.filter((c) => c.startDate <= today).at(-1)?.startDate ?? null,
|
||||
});
|
||||
|
||||
annotate({
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
* `DELETE /api/cycle/cycles/{id}` — soft-delete a cycle
|
||||
* (ios-contract §2.F): set `deletedAt` + bump `syncVersion`, emit a
|
||||
* tombstone on the next sync page. 204. Idempotent. Owner-scoped + gated.
|
||||
*
|
||||
* The delete also hands the span back to the preceding cycle. Opening a cycle
|
||||
* closes the one before it, so removing it without re-deriving that boundary
|
||||
* left the previous cycle standing as a closed torso with an end date taken
|
||||
* from a cycle that no longer exists — and a record whose last cycle is closed
|
||||
* has no open cycle to forecast from.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
@@ -9,8 +15,10 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { overwriteDetails } from "@/lib/sharing/audit-details";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import { reanchorAfterRemovedStart } from "@/lib/cycle/cycle-boundaries";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -28,21 +36,46 @@ export const DELETE = apiHandler(
|
||||
|
||||
const existing = await prisma.menstrualCycle.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
select: { id: true, userId: true, startDate: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Cycle not found", 404);
|
||||
}
|
||||
|
||||
await prisma.menstrualCycle.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
// Tombstone + re-anchor as one unit: a neighbour re-derived against a
|
||||
// half-applied delete would read the row being removed as still live.
|
||||
const reanchored = await prisma.$transaction(async (db) => {
|
||||
await db.menstrualCycle.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
return reanchorAfterRemovedStart(db, user.id, existing.startDate);
|
||||
});
|
||||
|
||||
await auditLog("cycle.cycle.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { cycleId: id },
|
||||
// The tombstone carries the deleted cycle's own columns, so the only
|
||||
// unrecoverable part of this write is what it moved on the NEIGHBOUR.
|
||||
details: {
|
||||
cycleId: id,
|
||||
startDate: existing.startDate,
|
||||
...(reanchored
|
||||
? {
|
||||
reanchoredCycleId: reanchored.cycleId,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
endDate: reanchored.endDateBefore,
|
||||
lengthDays: reanchored.lengthDaysBefore,
|
||||
},
|
||||
after: {
|
||||
endDate: reanchored.endDateAfter,
|
||||
lengthDays: reanchored.lengthDaysAfter,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -51,6 +84,7 @@ export const DELETE = apiHandler(
|
||||
entity_type: "menstrual_cycle",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: { reanchored_prior: reanchored !== null },
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
|
||||
@@ -23,6 +23,11 @@ import {
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { requireCycleEnabled } from "@/lib/cycle/gate";
|
||||
import {
|
||||
ensureCycleForBleedingDay,
|
||||
removeCycleStartedOn,
|
||||
} from "@/lib/cycle/cycle-boundaries";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { replaceSymptomLinks } from "@/lib/cycle/day-log-write";
|
||||
@@ -157,6 +162,26 @@ export const PATCH = apiHandler(
|
||||
include: dayLogSymptomInclude,
|
||||
});
|
||||
|
||||
// An edit that turns a day into a first bleeding day opens the cycle it
|
||||
// starts, and an edit that takes the bleeding back off it removes the
|
||||
// cycle that day opened. Both directions go through the same shared
|
||||
// boundary work the capture route and the one-tap start use — a cycle that
|
||||
// could be created here but not undone here is how the record ends up
|
||||
// holding a period nothing on the day says happened.
|
||||
const openedCycleId = await ensureCycleForBleedingDay(
|
||||
user.id,
|
||||
existing.date,
|
||||
user.timezone ?? DEFAULT_TIMEZONE,
|
||||
row,
|
||||
);
|
||||
let removedCycleId: string | null = null;
|
||||
if (!openedCycleId && !stillBleeds(row)) {
|
||||
const removed = await prisma.$transaction((db) =>
|
||||
removeCycleStartedOn(db, user.id, existing.date),
|
||||
);
|
||||
removedCycleId = removed?.cycleId ?? null;
|
||||
}
|
||||
|
||||
await auditLog("cycle.day-log.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -166,6 +191,8 @@ export const PATCH = apiHandler(
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...(openedCycleId ? { openedCycleId } : {}),
|
||||
...(removedCycleId ? { removedCycleId } : {}),
|
||||
...overwriteDetails({
|
||||
before: {},
|
||||
after: {},
|
||||
@@ -183,6 +210,10 @@ export const PATCH = apiHandler(
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
opened_cycle: openedCycleId !== null,
|
||||
removed_cycle: removedCycleId !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(toCycleDayLogDTO(row));
|
||||
@@ -202,7 +233,7 @@ export const DELETE = apiHandler(
|
||||
|
||||
const existing = await prisma.cycleDayLog.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true },
|
||||
select: { id: true, userId: true, date: true },
|
||||
});
|
||||
if (!existing || existing.userId !== user.id) {
|
||||
return apiError("Day-log not found", 404);
|
||||
@@ -211,15 +242,44 @@ export const DELETE = apiHandler(
|
||||
// Soft-delete: leave the row in place so the `/api/sync/changes`
|
||||
// delta feed surfaces it as a tombstone. A re-delete re-bumps
|
||||
// `syncVersion` harmlessly (idempotent).
|
||||
await prisma.cycleDayLog.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
//
|
||||
// A day that OPENS a cycle takes that cycle with it. The one-tap "started
|
||||
// my period" writes both rows, and this delete is the only way the web
|
||||
// offers to take it back — leaving the cycle behind meant the previous one
|
||||
// stayed closed against a start whose last trace had just been removed, and
|
||||
// the forecast never came back. The cycle boundary is then re-derived for
|
||||
// the preceding cycle, the same repair the cycle delete does.
|
||||
const removed = await prisma.$transaction(async (db) => {
|
||||
await db.cycleDayLog.update({
|
||||
where: { id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
return removeCycleStartedOn(db, user.id, existing.date);
|
||||
});
|
||||
|
||||
await auditLog("cycle.day-log.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { dayLogId: id },
|
||||
details: {
|
||||
dayLogId: id,
|
||||
date: existing.date,
|
||||
...(removed ? { removedCycleId: removed.cycleId } : {}),
|
||||
...(removed?.reanchored
|
||||
? {
|
||||
reanchoredCycleId: removed.reanchored.cycleId,
|
||||
...overwriteDetails({
|
||||
before: {
|
||||
endDate: removed.reanchored.endDateBefore,
|
||||
lengthDays: removed.reanchored.lengthDaysBefore,
|
||||
},
|
||||
after: {
|
||||
endDate: removed.reanchored.endDateAfter,
|
||||
lengthDays: removed.reanchored.lengthDaysAfter,
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -228,6 +288,10 @@ export const DELETE = apiHandler(
|
||||
entity_type: "cycle_day_log",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
removed_cycle: removed !== null,
|
||||
reanchored_prior: removed?.reanchored != null,
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
@@ -281,3 +345,15 @@ function readStoredSensitive(row: {
|
||||
contraceptive: row.contraceptive,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the edited day still records a period. A cleared or downgraded flow,
|
||||
* or one re-flagged as bleeding between periods, no longer anchors a cycle.
|
||||
*/
|
||||
function stillBleeds(row: {
|
||||
flow: string | null;
|
||||
intermenstrualBleeding: boolean;
|
||||
}): boolean {
|
||||
if (row.intermenstrualBleeding) return false;
|
||||
return row.flow !== null && row.flow !== "NONE" && row.flow !== "SPOTTING";
|
||||
}
|
||||
@@ -176,6 +176,7 @@ export const GET = apiHandler(async () => {
|
||||
profile.lutealPhaseLength ?? LUTEAL_DEFAULT,
|
||||
from,
|
||||
today,
|
||||
today,
|
||||
);
|
||||
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
|
||||
@@ -37,26 +37,20 @@ import {
|
||||
dayLogSymptomInclude,
|
||||
} from "@/lib/cycle/dto";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { addDays, dayDiff } from "@/lib/cycle/day-math";
|
||||
import { openCycleAt, type MovedAnchors } from "@/lib/cycle/cycle-boundaries";
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postPeriod));
|
||||
|
||||
/**
|
||||
* v1.37.0 — the anchors a boundary write moves, carried out of the
|
||||
* transaction so the audit row can name them (C4).
|
||||
* transaction so the audit row can name them (C4). The `start` arm's own
|
||||
* anchors live with the shared boundary helper; the `end` arm adds the one
|
||||
* column it overwrites.
|
||||
*/
|
||||
interface MovedAnchors {
|
||||
priorCycleId?: string;
|
||||
/** Day keys, the shape this domain stores its dates in. */
|
||||
priorEndDateBefore?: string | null;
|
||||
priorEndDateAfter?: string | null;
|
||||
priorLengthBefore?: number | null;
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
type PeriodMovedAnchors = MovedAnchors & {
|
||||
periodEndDateBefore?: string | null;
|
||||
periodEndDateAfter?: string | null;
|
||||
}
|
||||
};
|
||||
|
||||
async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
// v1.37.0 — MANAGE. Setting a boundary re-anchors the neighbouring cycles,
|
||||
@@ -97,73 +91,15 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
// exact-key replay; the transaction guards the interleaving case.
|
||||
const txResult = await prisma.$transaction(async (db) => {
|
||||
if (action === "start") {
|
||||
// Close the prior open cycle (its end is the day before this start)
|
||||
// and record its observed length.
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
// v1.37.0 — `endDate` and `lengthDays` are read for the audit row:
|
||||
// this write overwrites both on the prior cycle and they are the
|
||||
// dates C4 exists to name.
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
},
|
||||
});
|
||||
const moved: MovedAnchors = {};
|
||||
if (prior) {
|
||||
moved.priorCycleId = prior.id;
|
||||
moved.priorEndDateBefore = prior.endDate;
|
||||
moved.priorEndDateAfter = addDays(date, -1);
|
||||
moved.priorLengthBefore = prior.lengthDays;
|
||||
moved.priorLengthAfter = dayDiff(date, prior.startDate);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
endDate: addDays(date, -1),
|
||||
lengthDays: dayDiff(date, prior.startDate),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Upsert the new cycle on the `(userId, startDate)` unique so a
|
||||
// re-tap on the same day is idempotent.
|
||||
const cycle = await db.menstrualCycle.upsert({
|
||||
where: { userId_startDate: { userId: user.id, startDate: date } },
|
||||
create: { userId: user.id, startDate: date, tz, isPredicted: false },
|
||||
update: {
|
||||
deletedAt: null,
|
||||
isPredicted: false,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-anchor the FOLLOWING neighbour: when this start is back-filled
|
||||
// between two existing cycles, the new cycle's end + length are already
|
||||
// known from its successor's start. Without this the inserted cycle keeps
|
||||
// endDate/lengthDays = null even though the next start exists, and the
|
||||
// iOS mirror + history surfaces read a stale open cycle (QA M-1).
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId: user.id, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = addDays(next.startDate, -1);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
endDate: addDays(next.startDate, -1),
|
||||
lengthDays: dayDiff(next.startDate, date),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
return { cycleId: cycle.id as string | null, moved };
|
||||
// Close the prior open cycle, open this one, and re-anchor the following
|
||||
// neighbour when the start is back-filled between two existing cycles.
|
||||
// The same helper runs when a first bleeding day is captured as flow, so
|
||||
// a period start means one thing however it was entered.
|
||||
const { cycleId, moved } = await openCycleAt(db, user.id, date, tz);
|
||||
return {
|
||||
cycleId: cycleId as string | null,
|
||||
moved: moved as PeriodMovedAnchors,
|
||||
};
|
||||
}
|
||||
|
||||
// `end`: stamp the current cycle's periodEndDate.
|
||||
@@ -174,7 +110,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
select: { id: true, periodEndDate: true },
|
||||
});
|
||||
if (!current) {
|
||||
return { cycleId: null, moved: {} satisfies MovedAnchors };
|
||||
return { cycleId: null, moved: {} satisfies PeriodMovedAnchors };
|
||||
}
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: current.id },
|
||||
@@ -185,7 +121,7 @@ async function postPeriod(request: NextRequest): Promise<Response> {
|
||||
moved: {
|
||||
periodEndDateBefore: current.periodEndDate,
|
||||
periodEndDateAfter: date,
|
||||
} satisfies MovedAnchors,
|
||||
} satisfies PeriodMovedAnchors,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -314,6 +314,53 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// A band decision (slot or ad-hoc) is an AUTO binding; it also resets a
|
||||
// stale USER_PIN when this write converges onto a previously-pinned row.
|
||||
attributionSource = "AUTO";
|
||||
|
||||
// The client can NAME the dose it is recording, and when it does, that
|
||||
// answer outranks the band. The Verlauf ledger's "Genommen" posts the
|
||||
// displayed slot's own anchor, and the medication card posts its
|
||||
// display-due slot; both mean "record THIS dose". Band attribution answers
|
||||
// a different question — which slot the take's clock time falls into — so
|
||||
// backfilling a slot from an earlier day matched no band, fell through to
|
||||
// the standalone insert, and left the named slot unserved: the entry
|
||||
// reverted to missed on the next read and the dose the user recorded was
|
||||
// filed on the wrong day.
|
||||
//
|
||||
// Only a REAL slot of this medication is honoured (the same band-anchor
|
||||
// validation the "diesem Slot zuordnen" pin runs), so an arbitrary client
|
||||
// instant still records ad-hoc rather than parking a live row on a
|
||||
// fabricated anchor. And only a slot that does not sit ahead of the take,
|
||||
// by the same forward guard the convergence probe below uses — a
|
||||
// late-morning dose must never consume the evening slot.
|
||||
if (
|
||||
scheduledFor !== undefined &&
|
||||
mayConvergeOntoSuppliedSlot({
|
||||
skipped,
|
||||
takenAt: resolvedTakenAt,
|
||||
suppliedSlot: incomingScheduledFor,
|
||||
})
|
||||
) {
|
||||
const namedSlot = await resolveForcedSlotForWrite({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
userTz: user.timezone,
|
||||
slotInstant: incomingScheduledFor,
|
||||
});
|
||||
// Only when the naming actually decides something the clock did not.
|
||||
// A dose taken inside its own window resolves to the same anchor either
|
||||
// way and stays an AUTO binding, so an ordinary on-time take is
|
||||
// unchanged. When the two disagree the binding rests on the user's
|
||||
// choice, which is what USER_PIN records — and the read ledger binds a
|
||||
// USER_PIN take by its stored anchor rather than by the clock, so a
|
||||
// backfilled dose reads under the slot it was recorded for instead of
|
||||
// falling back to ad-hoc.
|
||||
if (
|
||||
namedSlot !== null &&
|
||||
namedSlot.getTime() !== canonicalSlot?.getTime()
|
||||
) {
|
||||
canonicalSlot = namedSlot;
|
||||
attributionSource = "USER_PIN";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency check (explicit key or server-side dedup window)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
buildWorkoutHrChartData,
|
||||
workoutHrAxisDomain,
|
||||
type WorkoutHrCurvePoint,
|
||||
} from "@/components/charts/workout-hr-chart-data";
|
||||
|
||||
/**
|
||||
* The workout heart-rate curve's data mapping. The assertions that matter
|
||||
* are the two that decide whether the rendered curve tells the truth:
|
||||
* every value the axis sees has to be a bpm reading (so the domain lands
|
||||
* on the band the session was actually in), and a hole in the recording
|
||||
* has to stay a hole.
|
||||
*/
|
||||
|
||||
const BUCKET = 10;
|
||||
|
||||
/** A dense stretch with a two-bucket hole at 20 s and 30 s. */
|
||||
const POINTS: WorkoutHrCurvePoint[] = [
|
||||
{ tSec: 0, mean: 128, min: 122, max: 134 },
|
||||
{ tSec: 10, mean: 141, min: 133, max: 149 },
|
||||
{ tSec: 40, mean: 166, min: 158, max: 174 },
|
||||
];
|
||||
|
||||
describe("buildWorkoutHrChartData", () => {
|
||||
it("lays the buckets on a complete elapsed grid", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data.map((d) => d.tSec)).toEqual([0, 10, 20, 30, 40]);
|
||||
});
|
||||
|
||||
it("keeps a hole in the recording as a hole", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data[2]).toEqual({ tSec: 20, mean: null, band: null });
|
||||
expect(data[3]).toEqual({ tSec: 30, mean: null, band: null });
|
||||
});
|
||||
|
||||
it("carries the envelope as the bucket's own [min, max] pair", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
expect(data[0].band).toEqual([122, 134]);
|
||||
expect(data[4].band).toEqual([158, 174]);
|
||||
});
|
||||
|
||||
/**
|
||||
* The regression this mapping exists to prevent. The band used to ship
|
||||
* as a stacked pair — a base at `min` plus a `max - min` delta — and a
|
||||
* stack is measured from zero, so the axis domain collapsed to include
|
||||
* 0 and the curve was drawn squashed against the top of the plot. Every
|
||||
* number handed to the chart must be a bpm reading in the session's own
|
||||
* range; a spread posing as a value is what broke it.
|
||||
*/
|
||||
it("hands the axis nothing but bpm readings — no spread, no zero baseline", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
const lowestReading = Math.min(...POINTS.map((p) => p.min));
|
||||
const highestReading = Math.max(...POINTS.map((p) => p.max));
|
||||
|
||||
const values = data.flatMap((d) => [
|
||||
...(d.mean == null ? [] : [d.mean]),
|
||||
...(d.band ?? []),
|
||||
]);
|
||||
expect(values.length).toBeGreaterThan(0);
|
||||
for (const value of values) {
|
||||
expect(value).toBeGreaterThanOrEqual(lowestReading);
|
||||
expect(value).toBeLessThanOrEqual(highestReading);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops the band when the server judged the buckets too sparse for one", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, false);
|
||||
expect(data.every((d) => d.band === null)).toBe(true);
|
||||
expect(data[0].mean).toBe(128);
|
||||
});
|
||||
|
||||
it("maps an empty or unusable series to no data at all", () => {
|
||||
expect(buildWorkoutHrChartData([], BUCKET, true)).toEqual([]);
|
||||
expect(buildWorkoutHrChartData(POINTS, 0, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("workoutHrAxisDomain", () => {
|
||||
const data = buildWorkoutHrChartData(POINTS, BUCKET, true);
|
||||
|
||||
it("covers the plotted readings with breathing room", () => {
|
||||
expect(workoutHrAxisDomain(data)).toEqual([112, 184]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A reference line outside the axis domain is discarded by recharts,
|
||||
* not clamped — so a peak above the curve's own highest bucket mean
|
||||
* would simply never be drawn. The reported peak is a single reading
|
||||
* and the curve plots bucket means, so that is the common case, not
|
||||
* the edge one.
|
||||
*/
|
||||
it("stretches to hold a peak the bucketed curve never reaches", () => {
|
||||
const domain = workoutHrAxisDomain(data, [145, 191]);
|
||||
expect(domain).not.toBeNull();
|
||||
expect(domain![1]).toBeGreaterThanOrEqual(191);
|
||||
});
|
||||
|
||||
it("stretches downwards for an average below the curve too", () => {
|
||||
const domain = workoutHrAxisDomain(data, [96, null]);
|
||||
expect(domain![0]).toBeLessThanOrEqual(96);
|
||||
});
|
||||
|
||||
it("ignores markers that are absent or not numbers", () => {
|
||||
expect(workoutHrAxisDomain(data, [null, undefined, Number.NaN])).toEqual(
|
||||
workoutHrAxisDomain(data),
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the axis alone when there is nothing plotted", () => {
|
||||
expect(workoutHrAxisDomain([], [140])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Data mapping for the per-workout heart-rate curve.
|
||||
*
|
||||
* Pure and separate from the chart component so the shape the axis reads
|
||||
* can be asserted without mounting recharts — the y-axis domain is
|
||||
* computed from these values, and getting them wrong flattens the curve
|
||||
* rather than erroring.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrCurvePoint {
|
||||
/** Elapsed seconds from the workout start (bucket left edge). */
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface WorkoutHrChartDatum {
|
||||
tSec: number;
|
||||
/** Bucket mean, or null where the recording has a hole. */
|
||||
mean: number | null;
|
||||
/**
|
||||
* The min→max ribbon as an explicit `[low, high]` pair — recharts reads
|
||||
* an array `dataKey` as a range area and takes its baseline from the
|
||||
* first element.
|
||||
*
|
||||
* This deliberately is NOT a stacked pair of `min` and `max - min`.
|
||||
* Stacked series are measured from zero: recharts derives the axis
|
||||
* domain for a stack from the stacked offsets, whose baseline is 0
|
||||
* (`getDomainOfStackGroups` over `stackOffsetNone`). A chart asking for
|
||||
* `dataMin - 10` then resolves to −10 instead of to the bottom of the
|
||||
* band, and a session spent between 120 and 170 bpm gets drawn on a
|
||||
* −10…180 axis — the shape of the effort, which is the whole point of
|
||||
* the curve, squashes into the top quarter. A range area keeps the
|
||||
* domain on the bpm values that are actually in the data.
|
||||
*/
|
||||
band: [number, number] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the bucket points onto a complete elapsed-time grid.
|
||||
*
|
||||
* A missing bucket becomes an explicit null rather than being skipped, so
|
||||
* a hole in the recording breaks the line (`connectNulls` false) instead
|
||||
* of being drawn across as if the reading had continued. Absence reads as
|
||||
* absence.
|
||||
*
|
||||
* `envelope` is the server's density verdict: below its threshold the
|
||||
* per-bucket min→max spread is noise rather than signal, and the chart
|
||||
* draws the mean line alone.
|
||||
*/
|
||||
export function buildWorkoutHrChartData(
|
||||
points: readonly WorkoutHrCurvePoint[],
|
||||
bucketSec: number,
|
||||
envelope: boolean,
|
||||
): WorkoutHrChartDatum[] {
|
||||
if (points.length === 0 || bucketSec <= 0) return [];
|
||||
const byT = new Map(points.map((p) => [p.tSec, p]));
|
||||
const maxT = points[points.length - 1].tSec;
|
||||
const grid: WorkoutHrChartDatum[] = [];
|
||||
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
|
||||
const p = byT.get(tSec);
|
||||
grid.push({
|
||||
tSec,
|
||||
mean: p ? p.mean : null,
|
||||
band: p && envelope ? [p.min, p.max] : null,
|
||||
});
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
/** Breathing room above and below the plotted readings, in bpm. */
|
||||
const AXIS_PAD_BPM = 10;
|
||||
|
||||
/**
|
||||
* The bpm range the y-axis has to cover: every plotted reading plus every
|
||||
* reference line drawn over them.
|
||||
*
|
||||
* The markers have to be in the domain or they are not drawn at all —
|
||||
* recharts discards an out-of-range `ReferenceLine` rather than clamping
|
||||
* it. The session's reported peak is routinely above the curve's own
|
||||
* highest point, because the curve plots bucket means and a peak is a
|
||||
* single reading, so a domain derived from the data alone drops the peak
|
||||
* marker on exactly the sessions that have one. Silently: no warning, no
|
||||
* gap, just a line that never appears.
|
||||
*
|
||||
* Returns null for an empty series, leaving the axis to recharts.
|
||||
*/
|
||||
export function workoutHrAxisDomain(
|
||||
data: readonly WorkoutHrChartDatum[],
|
||||
markers: readonly (number | null | undefined)[] = [],
|
||||
): [number, number] | null {
|
||||
const values: number[] = [];
|
||||
for (const datum of data) {
|
||||
if (datum.mean != null) values.push(datum.mean);
|
||||
if (datum.band) values.push(datum.band[0], datum.band[1]);
|
||||
}
|
||||
if (values.length === 0) return null;
|
||||
for (const marker of markers) {
|
||||
if (marker != null && Number.isFinite(marker)) values.push(marker);
|
||||
}
|
||||
return [
|
||||
Math.floor(Math.min(...values) - AXIS_PAD_BPM),
|
||||
Math.ceil(Math.max(...values) + AXIS_PAD_BPM),
|
||||
];
|
||||
}
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
} from "recharts";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
buildWorkoutHrChartData,
|
||||
workoutHrAxisDomain,
|
||||
type WorkoutHrCurvePoint,
|
||||
} from "./workout-hr-chart-data";
|
||||
|
||||
/**
|
||||
* Per-workout heart-rate curve. Rendered ONLY through the shared
|
||||
@@ -22,20 +27,15 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* library stays a single shared chunk — never import this file directly
|
||||
* at a call site.
|
||||
*
|
||||
* `ComposedChart`: an `Area` for the min→max envelope (drawn only when
|
||||
* the series is dense enough to be honest), a `Line` for the bucket
|
||||
* mean, optional %HRmax zone bands behind them, and a dashed reference
|
||||
* line at the workout's average HR. Gaps stay gaps (`connectNulls`
|
||||
* `ComposedChart`: a range `Area` for the min→max envelope (drawn only
|
||||
* when the series is dense enough to be honest), a `Line` for the bucket
|
||||
* mean, optional %HRmax zone bands behind them, and dashed reference
|
||||
* lines at the session's average and peak. Gaps stay gaps (`connectNulls`
|
||||
* false) — a hole in the recording never reads as an interpolated
|
||||
* curve.
|
||||
*/
|
||||
|
||||
export interface WorkoutHrChartPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
export type WorkoutHrChartPoint = WorkoutHrCurvePoint;
|
||||
|
||||
export interface WorkoutHrZoneBand {
|
||||
zone: number;
|
||||
@@ -50,6 +50,8 @@ export interface WorkoutHrChartProps {
|
||||
envelope: boolean;
|
||||
/** Workout average HR for the reference line. */
|
||||
avgHr: number | null;
|
||||
/** Workout peak HR for the reference line. */
|
||||
maxHr?: number | null;
|
||||
/** Optional %HRmax zone bands for the shaded background. */
|
||||
zones?: WorkoutHrZoneBand[] | null;
|
||||
}
|
||||
@@ -71,33 +73,19 @@ export function WorkoutHrChart({
|
||||
bucketSec,
|
||||
envelope,
|
||||
avgHr,
|
||||
maxHr,
|
||||
zones,
|
||||
}: WorkoutHrChartProps) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Build a full elapsed-time grid so a gap of missing buckets breaks
|
||||
// the line rather than interpolating across it.
|
||||
const data = useMemo(() => {
|
||||
if (points.length === 0) return [];
|
||||
const byT = new Map(points.map((p) => [p.tSec, p]));
|
||||
const maxT = points[points.length - 1].tSec;
|
||||
const grid: Array<{
|
||||
tSec: number;
|
||||
mean: number | null;
|
||||
lo: number | null;
|
||||
hi: number | null;
|
||||
}> = [];
|
||||
for (let tSec = 0; tSec <= maxT; tSec += bucketSec) {
|
||||
const p = byT.get(tSec);
|
||||
grid.push({
|
||||
tSec,
|
||||
mean: p ? p.mean : null,
|
||||
lo: p ? p.min : null,
|
||||
hi: p && envelope ? p.max - p.min : null,
|
||||
});
|
||||
}
|
||||
return grid;
|
||||
}, [points, bucketSec, envelope]);
|
||||
const data = useMemo(
|
||||
() => buildWorkoutHrChartData(points, bucketSec, envelope),
|
||||
[points, bucketSec, envelope],
|
||||
);
|
||||
const yDomain = useMemo(
|
||||
() => workoutHrAxisDomain(data, [avgHr, maxHr]),
|
||||
[data, avgHr, maxHr],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -137,7 +125,7 @@ export function WorkoutHrChart({
|
||||
unit=""
|
||||
/>
|
||||
<YAxis
|
||||
domain={["dataMin - 10", "dataMax + 10"]}
|
||||
domain={yDomain ?? ["dataMin - 10", "dataMax + 10"]}
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -154,37 +142,41 @@ export function WorkoutHrChart({
|
||||
labelFormatter={(tSec) =>
|
||||
`${minuteTick(Number(tSec))} ${t("insights.workouts.detail.hrAxisMinutes")}`
|
||||
}
|
||||
formatter={(value, name) =>
|
||||
name === "mean"
|
||||
? [`${value} bpm`, t("insights.workouts.detail.hrChartTitle")]
|
||||
: [null, null]
|
||||
}
|
||||
formatter={(value, name) => {
|
||||
if (name === "mean") {
|
||||
return [
|
||||
`${value} bpm`,
|
||||
t("insights.workouts.detail.hrChartTitle"),
|
||||
];
|
||||
}
|
||||
// The envelope's own `[min, max]` pair, read back as the
|
||||
// spread inside that bucket rather than as a second series.
|
||||
if (name === "band" && Array.isArray(value)) {
|
||||
return [
|
||||
t("insights.workouts.detail.hrRangeValue", {
|
||||
low: String(value[0]),
|
||||
high: String(value[1]),
|
||||
}),
|
||||
t("insights.workouts.detail.hrRangeLabel"),
|
||||
];
|
||||
}
|
||||
return [null, null];
|
||||
}}
|
||||
/>
|
||||
{/* Envelope band: a transparent base at `lo` plus a stacked
|
||||
`hi` (= max − min) renders the min→max ribbon. Only present
|
||||
when `envelope` gated it in. */}
|
||||
{/* Envelope band: one range `Area` over the bucket's own
|
||||
`[min, max]` pair. Deliberately not a stack — a stacked pair
|
||||
is measured from zero and drags the y-axis domain down with
|
||||
it, squashing the curve (see `workout-hr-chart-data.ts`). */}
|
||||
{envelope ? (
|
||||
<>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="lo"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="transparent"
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="hi"
|
||||
stackId="band"
|
||||
stroke="none"
|
||||
fill="var(--chart-1)"
|
||||
fillOpacity={0.15}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
</>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="band"
|
||||
stroke="none"
|
||||
fill="var(--chart-1)"
|
||||
fillOpacity={0.15}
|
||||
connectNulls={false}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
) : null}
|
||||
{avgHr != null ? (
|
||||
<ReferenceLine
|
||||
@@ -192,6 +184,33 @@ export function WorkoutHrChart({
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="5 5"
|
||||
strokeOpacity={0.7}
|
||||
label={{
|
||||
value: t("insights.workouts.detail.hrAvgMarker", {
|
||||
bpm: String(Math.round(avgHr)),
|
||||
}),
|
||||
position: "insideTopLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* The session's peak, the second number the stats grid already
|
||||
names. Drawn so the curve can be read against it rather than
|
||||
leaving the reader to eyeball where the top was. */}
|
||||
{maxHr != null ? (
|
||||
<ReferenceLine
|
||||
y={maxHr}
|
||||
stroke="var(--chart-4)"
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.6}
|
||||
label={{
|
||||
value: t("insights.workouts.detail.hrMaxMarker", {
|
||||
bpm: String(Math.round(maxHr)),
|
||||
}),
|
||||
position: "insideBottomLeft",
|
||||
fill: "var(--muted-foreground)",
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<Line
|
||||
|
||||
@@ -19,6 +19,7 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The window arithmetic behind the month grid's own read.
|
||||
*
|
||||
* The bug it exists for is a save that appeared not to happen: the page read a
|
||||
* fixed ninety-days-back window, an April cell in August fell outside it, and
|
||||
* the write landed with nothing on screen to show for it. The month bounds and
|
||||
* the "is this month already covered" question are the whole of the decision,
|
||||
* so they are pinned here rather than left inside a component no test can drive.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { monthBounds, monthOf, monthWindowOutside } from "../calendar-window";
|
||||
|
||||
describe("monthBounds", () => {
|
||||
it("spans a 30-day month", () => {
|
||||
expect(monthBounds("2026-04")).toEqual({
|
||||
from: "2026-04-01",
|
||||
to: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
it("spans a 31-day month", () => {
|
||||
expect(monthBounds("2026-08")).toEqual({
|
||||
from: "2026-08-01",
|
||||
to: "2026-08-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("spans February in a common year and in a leap year", () => {
|
||||
expect(monthBounds("2026-02").to).toBe("2026-02-28");
|
||||
expect(monthBounds("2028-02").to).toBe("2028-02-29");
|
||||
});
|
||||
|
||||
it("zero-pads so a day key compares as a date", () => {
|
||||
expect(monthBounds("2026-01")).toEqual({
|
||||
from: "2026-01-01",
|
||||
to: "2026-01-31",
|
||||
});
|
||||
expect(monthBounds("2026-09").from < monthBounds("2026-10").from).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthWindowOutside", () => {
|
||||
// The page's anchored read on 2026-08-07: 90 days back, 180 forward.
|
||||
const anchor = { from: "2026-05-09", to: "2027-02-03" };
|
||||
|
||||
it("reads nothing more for the month the anchor already carries", () => {
|
||||
expect(monthWindowOutside("2026-08", anchor)).toBeNull();
|
||||
expect(monthWindowOutside("2026-06", anchor)).toBeNull();
|
||||
});
|
||||
|
||||
it("asks for the month the maintainer could not back-fill into", () => {
|
||||
expect(monthWindowOutside("2026-04", anchor)).toEqual({
|
||||
from: "2026-04-01",
|
||||
to: "2026-04-30",
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for a month that only overlaps the anchor's edge", () => {
|
||||
// May 2026 starts before 2026-05-09, so half of it is missing.
|
||||
expect(monthWindowOutside("2026-05", anchor)).toEqual({
|
||||
from: "2026-05-01",
|
||||
to: "2026-05-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for a month past the forward edge too", () => {
|
||||
expect(monthWindowOutside("2027-03", anchor)).toEqual({
|
||||
from: "2027-03-01",
|
||||
to: "2027-03-31",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays a bounded read however far back the grid navigates", () => {
|
||||
// The anchor cannot simply be widened: the calendar route caps a span at
|
||||
// 400 days, and a union with a month two years back blows through it.
|
||||
const far = monthWindowOutside("2024-01", anchor);
|
||||
expect(far).toEqual({ from: "2024-01-01", to: "2024-01-31" });
|
||||
const span =
|
||||
(Date.parse(`${far!.to}T12:00:00Z`) -
|
||||
Date.parse(`${far!.from}T12:00:00Z`)) /
|
||||
86_400_000;
|
||||
expect(span).toBeLessThan(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe("monthOf", () => {
|
||||
it("takes the month a day key belongs to", () => {
|
||||
expect(monthOf("2026-04-18")).toBe("2026-04");
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ function dayBase(date: string): CalendarDay {
|
||||
isFertileWindow: false,
|
||||
isPredictedOvulation: false,
|
||||
isPeriodLogged: false,
|
||||
isCycleStart: false,
|
||||
flow: null,
|
||||
hasSymptoms: false,
|
||||
confidence: 1,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The date range the /cycle page reads, and what a month outside it needs.
|
||||
*
|
||||
* The page reads one anchored window — ninety days back, half a year forward —
|
||||
* and that window drives the ring, the forecast panel and the temperature
|
||||
* chart, all of which are about today. The month grid is not: it navigates
|
||||
* freely, and a month the anchored read does not cover came back with no days
|
||||
* at all. The cells still rendered (they come from the month, not the read) and
|
||||
* still opened the log sheet, and the write behind it still landed — the period
|
||||
* route is built for back-dating and re-anchors the neighbouring cycles for it.
|
||||
* What did not happen is anything visible. Save an April flow in August and the
|
||||
* sheet closes onto a month the read never carried, so the entry appears not to
|
||||
* have been made, and entering it again does not help.
|
||||
*
|
||||
* So the month grid gets its own read when it steps outside the anchor. A month
|
||||
* is a bounded thing to fetch, the anchored window stays exactly as it was for
|
||||
* every surface that means "today", and the two grids merge by date.
|
||||
*
|
||||
* Pure and dateless — the caller supplies both the month and the window.
|
||||
*/
|
||||
|
||||
/** An inclusive `YYYY-MM-DD` range, the shape the calendar read takes. */
|
||||
export interface CalendarWindow {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Zero-padded `YYYY-MM-DD`. */
|
||||
function ymd(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** First and last day of a `YYYY-MM` month. */
|
||||
export function monthBounds(month: string): CalendarWindow {
|
||||
const [year, monthIndex] = month.split("-").map(Number);
|
||||
// Day 0 of the following month is the last day of this one, leap years and all.
|
||||
const lastDay = new Date(year, monthIndex, 0).getDate();
|
||||
return { from: ymd(year, monthIndex, 1), to: ymd(year, monthIndex, lastDay) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The extra window a visible month needs, or null when `anchor` already covers
|
||||
* it and the page should read nothing more. Day keys are zero-padded, so a
|
||||
* plain string compare is a date compare.
|
||||
*/
|
||||
export function monthWindowOutside(
|
||||
month: string,
|
||||
anchor: CalendarWindow,
|
||||
): CalendarWindow | null {
|
||||
const bounds = monthBounds(month);
|
||||
if (bounds.from >= anchor.from && bounds.to <= anchor.to) return null;
|
||||
return bounds;
|
||||
}
|
||||
|
||||
/** The `YYYY-MM` a `YYYY-MM-DD` day key belongs to. */
|
||||
export function monthOf(date: string): string {
|
||||
return date.slice(0, 7);
|
||||
}
|
||||
@@ -76,6 +76,13 @@ export interface CycleCalendarProps {
|
||||
* rather than opening a sheet whose every button the server would refuse.
|
||||
*/
|
||||
onSelectDay?: (date: string) => void;
|
||||
/**
|
||||
* The month the grid moved to (`YYYY-MM`). The page's anchored read covers
|
||||
* a fixed span around today, so a month outside it arrives with no days —
|
||||
* the caller uses this to fetch that month rather than render empty cells
|
||||
* over entries that exist.
|
||||
*/
|
||||
onVisibleMonthChange?: (month: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -84,6 +91,7 @@ export function CycleCalendar({
|
||||
today,
|
||||
confirmedOvulation,
|
||||
onSelectDay,
|
||||
onVisibleMonthChange,
|
||||
className,
|
||||
}: CycleCalendarProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
@@ -125,7 +133,17 @@ export function CycleCalendar({
|
||||
});
|
||||
|
||||
function shiftMonth(delta: number) {
|
||||
setMonthAnchor((a) => new Date(a.getFullYear(), a.getMonth() + delta, 1));
|
||||
const next = new Date(
|
||||
monthAnchor.getFullYear(),
|
||||
monthAnchor.getMonth() + delta,
|
||||
1,
|
||||
);
|
||||
setMonthAnchor(next);
|
||||
// The page reads a window anchored on today, so a month outside it has no
|
||||
// days until somebody asks for them.
|
||||
onVisibleMonthChange?.(
|
||||
`${next.getFullYear()}-${String(next.getMonth() + 1).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,13 +3,7 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -29,6 +23,7 @@ import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
|
||||
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
|
||||
import { CycleSettings } from "./cycle-settings";
|
||||
import { PHASE_HUE } from "./phase-tokens";
|
||||
import { monthOf, monthWindowOutside } from "./calendar-window";
|
||||
import {
|
||||
localYmd,
|
||||
useCycleCalendar,
|
||||
@@ -110,9 +105,7 @@ export function CycleView() {
|
||||
: "calendar";
|
||||
}, [searchParams]);
|
||||
|
||||
// Controlled so the header "cycle settings" wrench (parity with the
|
||||
// labs/medications/illness modules, which expose a customize glyph) can jump
|
||||
// to the settings tab. Initialised from the `?tab=` deep-link, matching the
|
||||
// Controlled tabs, initialised from the `?tab=` deep-link and matching the
|
||||
// prior uncontrolled `defaultValue` behaviour (the initial param is honoured;
|
||||
// a later param change does not force-switch a tab the user is reading).
|
||||
const [tab, setTab] = useState<CycleTab>(initialTab);
|
||||
@@ -126,6 +119,29 @@ export function CycleView() {
|
||||
const profileQuery = useCycleProfile();
|
||||
const insights = useCycleInsights();
|
||||
|
||||
// The month grid navigates freely; the anchored read above does not follow
|
||||
// it, because every other surface on this page (the ring, the forecast panel,
|
||||
// the temperature chart) is about today and the route caps a span at 400
|
||||
// days. A month outside the anchor therefore gets its own bounded read, and
|
||||
// the two grids merge by date. Without it a back-dated entry landed on the
|
||||
// server and had nothing on screen to land in.
|
||||
const [visibleMonth, setVisibleMonth] = useState(() => monthOf(today));
|
||||
const monthWindow = useMemo(
|
||||
() => monthWindowOutside(visibleMonth, { from, to }),
|
||||
[visibleMonth, from, to],
|
||||
);
|
||||
const monthCalendar = useCycleCalendar(
|
||||
monthWindow?.from ?? from,
|
||||
monthWindow?.to ?? to,
|
||||
monthWindow !== null,
|
||||
);
|
||||
// Month days last: on the edge months the two reads overlap and the grid
|
||||
// keys by date, so the later entry wins and both carry the same answer.
|
||||
const calendarDays = useMemo(
|
||||
() => [...(calendar.data?.days ?? []), ...(monthCalendar.data?.days ?? [])],
|
||||
[calendar.data?.days, monthCalendar.data?.days],
|
||||
);
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
|
||||
@@ -167,30 +183,18 @@ export function CycleView() {
|
||||
title={<span data-tour-id="cycle-hero">{t("cycle.title")}</span>}
|
||||
description={t("cycle.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{canManage && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("cycle.tabs.settings")}
|
||||
title={t("cycle.tabs.settings")}
|
||||
data-slot="cycle-settings-wrench"
|
||||
onClick={() => setTab("settings")}
|
||||
>
|
||||
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
// One action. The settings glyph that used to sit beside it was a
|
||||
// second door to the tab directly below, and no other page in the
|
||||
// app carries a gear in that corner.
|
||||
canManage ? (
|
||||
<Button
|
||||
onClick={() => openSheet(today)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("cycle.logToday")}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -365,7 +369,7 @@ export function CycleView() {
|
||||
</div>
|
||||
) : null}
|
||||
<CycleCalendar
|
||||
days={calendar.data?.days ?? []}
|
||||
days={calendarDays}
|
||||
today={today}
|
||||
confirmedOvulation={
|
||||
calendar.data?.prediction?.ovulationConfirmed
|
||||
@@ -374,6 +378,7 @@ export function CycleView() {
|
||||
: null
|
||||
}
|
||||
onSelectDay={canManage ? openSheet : undefined}
|
||||
onVisibleMonthChange={setVisibleMonth}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -458,6 +463,10 @@ export function CycleView() {
|
||||
onOpenChange={setSheetOpen}
|
||||
date={selectedDate}
|
||||
today={today}
|
||||
startsCycle={
|
||||
calendarDays.find((d) => d.date === selectedDate)?.isCycleStart ??
|
||||
false
|
||||
}
|
||||
activePeriod={verdict?.phase === "MENSTRUAL"}
|
||||
phase={verdict?.phase ?? null}
|
||||
dayOfCycle={verdict?.dayOfCycle ?? null}
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { SheetSection, SheetSectionCount } from "@/components/ui/sheet-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import { FieldInfo } from "./field-info";
|
||||
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
|
||||
@@ -321,6 +323,11 @@ export interface LogDaySheetProps {
|
||||
date: string;
|
||||
/** Today (YYYY-MM-DD). */
|
||||
today: string;
|
||||
/**
|
||||
* Whether a logged cycle opens on `date`. Deleting the day-log of a cycle
|
||||
* start removes that start too, so the confirm dialog has to say so.
|
||||
*/
|
||||
startsCycle?: boolean;
|
||||
/**
|
||||
* Whether a period is currently open (today is in the MENSTRUAL phase) — gates
|
||||
* the one-tap "end period" affordance so it never shows when no period is in
|
||||
@@ -463,10 +470,37 @@ function CervixRow<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the sheet is writing to, spelled out in the reader's language.
|
||||
*
|
||||
* The sheet used to be titled "Log your day" whichever cell opened it, so a
|
||||
* back-dated entry gave no clue which date it would land on and "Log today"
|
||||
* gave no clue that any other date was possible. The weekday is included
|
||||
* deliberately: it is how people recognise a date they are recalling.
|
||||
*/
|
||||
export function formatSheetDate(date: string, locale: Locale): string {
|
||||
// A cycle day key is a calendar day, not an instant, so it is anchored at
|
||||
// noon UTC and rendered in UTC — the same anchor the domain's day arithmetic
|
||||
// uses. Formatting it in the browser's zone would let a reader east or west
|
||||
// of it see the sheet name a different day than the one it writes to.
|
||||
return new Date(`${date}T12:00:00Z`).toLocaleDateString(
|
||||
resolveIntlLocale(locale),
|
||||
{
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function LogDaySheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
date,
|
||||
today,
|
||||
startsCycle = false,
|
||||
activePeriod = false,
|
||||
phase = null,
|
||||
dayOfCycle = null,
|
||||
@@ -475,7 +509,7 @@ export function LogDaySheet({
|
||||
rawChartMode = false,
|
||||
cyclesObserved = 0,
|
||||
}: LogDaySheetProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const logDay = useLogDay();
|
||||
const patchDay = usePatchDayLog();
|
||||
const startPeriod = useStartPeriod();
|
||||
@@ -682,8 +716,20 @@ export function LogDaySheet({
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("cycle.sheet.title")}
|
||||
description={t("cycle.sheet.description")}
|
||||
// The target date IS the title. Every button below writes to it and
|
||||
// nothing else on the surface says which day it is.
|
||||
title={
|
||||
<span data-slot="cycle-log-sheet-date">
|
||||
{t("cycle.sheet.titleForDate", {
|
||||
date: formatSheetDate(date, locale),
|
||||
})}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
date === today
|
||||
? t("cycle.sheet.descriptionToday")
|
||||
: t("cycle.sheet.description")
|
||||
}
|
||||
contentWidth="lg"
|
||||
footer={
|
||||
<>
|
||||
@@ -696,7 +742,11 @@ export function LogDaySheet({
|
||||
label={t("cycle.sheet.delete")}
|
||||
ariaLabel={t("cycle.sheet.delete")}
|
||||
title={t("cycle.sheet.deleteTitle")}
|
||||
body={t("cycle.sheet.deleteBody")}
|
||||
body={
|
||||
startsCycle
|
||||
? t("cycle.sheet.deleteBodyCycleStart")
|
||||
: t("cycle.sheet.deleteBody")
|
||||
}
|
||||
confirmLabel={t("cycle.sheet.deleteConfirm")}
|
||||
disabled={busy && !deleteDay.isPending}
|
||||
pending={deleteDay.isPending}
|
||||
@@ -732,7 +782,9 @@ export function LogDaySheet({
|
||||
|
||||
{/* One-tap period boundaries — available on any selected date so a
|
||||
forgotten day-1 can be corrected retroactively (M3). "End period"
|
||||
shows only while a period is actually open (M2). */}
|
||||
shows only while a period is actually open (M2). The labels drop the
|
||||
word "today" on a back-dated day: they always wrote to the selected
|
||||
date, and saying otherwise is how a correction reads as a mistake. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -746,7 +798,9 @@ export function LogDaySheet({
|
||||
) : (
|
||||
<Droplets className="h-4 w-4" style={{ color: FLOW_HUE }} />
|
||||
)}
|
||||
{t("cycle.startedPeriod")}
|
||||
{date === today
|
||||
? t("cycle.startedPeriod")
|
||||
: t("cycle.startedPeriodOnDate")}
|
||||
</Button>
|
||||
{activePeriod ? (
|
||||
<Button
|
||||
@@ -758,7 +812,9 @@ export function LogDaySheet({
|
||||
{endPeriod.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("cycle.endedPeriod")}
|
||||
{date === today
|
||||
? t("cycle.endedPeriod")
|
||||
: t("cycle.endedPeriodOnDate")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ export interface CalendarDay {
|
||||
isFertileWindow: boolean;
|
||||
isPredictedOvulation: boolean;
|
||||
isPeriodLogged: boolean;
|
||||
/** Whether a logged cycle opens on this day. */
|
||||
isCycleStart: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
apiPost,
|
||||
} from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
|
||||
|
||||
import {
|
||||
cycleDependentKeys,
|
||||
@@ -93,9 +94,16 @@ export function useCycleDayLog(date: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCycleCalendar(from: string, to: string) {
|
||||
/**
|
||||
* The calendar read for one window. `enabled` exists for the month grid's own
|
||||
* read: the page's anchored window covers today, and a month the grid
|
||||
* navigates outside it needs a second, bounded read rather than a widened
|
||||
* anchor the route's span cap would refuse.
|
||||
*/
|
||||
export function useCycleCalendar(from: string, to: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.cycleCalendar(from, to),
|
||||
enabled,
|
||||
queryFn: () =>
|
||||
apiGet<CalendarResponse>(`/api/cycle/calendar?from=${from}&to=${to}`),
|
||||
staleTime: 60_000,
|
||||
@@ -220,15 +228,28 @@ function idempotencyKey(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture one day.
|
||||
*
|
||||
* The success toast is not decoration. A day-log write is silent by nature —
|
||||
* the sheet closes and the grid repaints — and for a back-dated entry the grid
|
||||
* had nothing to repaint, so a save that worked and a save that never happened
|
||||
* looked identical. A write says so now, either way: this names the success and
|
||||
* the sheet's inline strip names the failure.
|
||||
*/
|
||||
export function useLogDay() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async (input: CycleDayLogInput) => {
|
||||
return apiPost<unknown>("/api/cycle/day-logs", input, {
|
||||
headers: { "Idempotency-Key": idempotencyKey() },
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.saveSuccess"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -260,6 +281,7 @@ export interface CycleDayLogPatch {
|
||||
/** Edit an existing day-log by id (PATCH — accepts explicit null to clear). */
|
||||
export function usePatchDayLog() {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
@@ -270,7 +292,10 @@ export function usePatchDayLog() {
|
||||
}) => {
|
||||
return apiPatch<CycleDayLogDTO>(`/api/cycle/day-logs/${id}`, patch);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.saveSuccess"));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -285,7 +310,10 @@ export function useStartPeriod() {
|
||||
loggedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.periodStartSaved"));
|
||||
},
|
||||
// v1.16.4 — period boundaries had no failure signal at all (the sheet
|
||||
// simply stayed open); a toast names the rejection.
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
@@ -304,7 +332,10 @@ export function useEndPeriod() {
|
||||
loggedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.periodEndSaved"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.saveError")),
|
||||
});
|
||||
}
|
||||
@@ -317,7 +348,10 @@ export function useDeleteDayLog() {
|
||||
mutationFn: async (id: string) => {
|
||||
await apiDelete(`/api/cycle/day-logs/${id}`);
|
||||
},
|
||||
onSuccess: () => invalidateKeys(qc, cycleDependentKeys),
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(qc, cycleDependentKeys);
|
||||
toastWrittenOutcome("success", t("cycle.deleteSuccess"));
|
||||
},
|
||||
onError: () => toast.error(t("cycle.deleteError")),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ function deviation(
|
||||
high: 62,
|
||||
outside,
|
||||
direction: outside ? "above" : "in",
|
||||
daysAgo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -207,8 +207,27 @@ describe("<WorkoutDetailRoute>", () => {
|
||||
});
|
||||
|
||||
describe("<WorkoutDetailHrSection>", () => {
|
||||
it("returns null without an HR series", () => {
|
||||
expect(render(<WorkoutDetailHrSection workout={FIXTURE} />)).toBe("");
|
||||
it("names the absence when the session has heart-rate figures but no profile", () => {
|
||||
// The stats grid is showing an average and a peak for this session,
|
||||
// so the reader can see heart rate WAS recorded. Rendering nothing
|
||||
// here would read as "this page has no heart-rate section" rather
|
||||
// than as "the shape behind those numbers is not on record".
|
||||
const html = render(<WorkoutDetailHrSection workout={FIXTURE} />);
|
||||
expect(html).toContain('data-slot="workout-detail-hr-empty"');
|
||||
expect(html).toContain("No reading-by-reading heart-rate data");
|
||||
expect(html).not.toContain('data-slot="workout-hr-chart"');
|
||||
});
|
||||
|
||||
it("returns null when the session carries no heart rate at all", () => {
|
||||
// Nothing was measured, so there is no absence to report and an
|
||||
// empty card would be a shell.
|
||||
const noHr: WorkoutDetailPayload = {
|
||||
...FIXTURE,
|
||||
avgHr: null,
|
||||
maxHr: null,
|
||||
minHr: null,
|
||||
};
|
||||
expect(render(<WorkoutDetailHrSection workout={noHr} />)).toBe("");
|
||||
});
|
||||
|
||||
it("renders the card and a provenance chip for a pulse-window series", () => {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ArrowDownRight, ArrowUpRight } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { metricFractionDigits } from "@/lib/measurements/value-domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -44,6 +45,7 @@ export function HealthStatusCard({
|
||||
className,
|
||||
}: HealthStatusCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const { data } = useQuery({
|
||||
@@ -60,6 +62,14 @@ export function HealthStatusCard({
|
||||
return key ? t(key) : type;
|
||||
};
|
||||
|
||||
// The engines hand over raw doubles. Interpolating one straight into the
|
||||
// sentence prints whatever floating-point produced — a pulse mean reads
|
||||
// "68.33333333333333" — and reads in the wrong number convention for every
|
||||
// locale that does not use a dot. Render each figure at the metric's own
|
||||
// decimal precision, through the same formatter the rest of the app uses.
|
||||
const metricValue = (type: string, value: number): string =>
|
||||
fmt.number(value, metricFractionDigits(type));
|
||||
|
||||
return (
|
||||
<section
|
||||
data-slot="health-status-section"
|
||||
@@ -100,9 +110,9 @@ export function HealthStatusCard({
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.rangeContext", {
|
||||
value: d.value,
|
||||
low: d.low,
|
||||
high: d.high,
|
||||
value: metricValue(d.type, d.value),
|
||||
low: metricValue(d.type, d.low),
|
||||
high: metricValue(d.type, d.high),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -137,8 +147,8 @@ export function HealthStatusCard({
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("insights.healthStatus.shiftContext", {
|
||||
before: s.beforeMean,
|
||||
after: s.afterMean,
|
||||
before: metricValue(s.metric, s.beforeMean),
|
||||
after: metricValue(s.metric, s.afterMean),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -35,17 +35,35 @@ export interface WorkoutDetailHrSectionProps {
|
||||
|
||||
/**
|
||||
* Heart-rate curve card — mean line + optional min→max envelope + %HRmax
|
||||
* zone bands + an average-HR reference line. A muted provenance chip
|
||||
* zone bands + average and peak reference lines. A muted provenance chip
|
||||
* discloses when the curve was reconstructed from pulse data around the
|
||||
* session rather than the workout's own sensor stream. Returns `null`
|
||||
* when no series is available (hide, don't render empty).
|
||||
* session rather than the workout's own sensor stream.
|
||||
*
|
||||
* Three states, and the middle one is the reason this is not a plain
|
||||
* hide-when-empty section:
|
||||
*
|
||||
* - a curve, when the server resolved one;
|
||||
* - a named absence, when the session HAS heart-rate figures (the
|
||||
* stats grid is showing an average, a peak) but no reading-by-reading
|
||||
* profile behind them. Silence there reads as "this page has no
|
||||
* heart-rate section", which is wrong — the data exists, the shape of
|
||||
* it does not, and saying so is the difference between an honest gap
|
||||
* and an invisible one;
|
||||
* - nothing at all, when the session carries no heart rate of any kind.
|
||||
* There is no absence to report where nothing was ever measured, and
|
||||
* an empty card would be a shell (page contract: hide, don't render
|
||||
* empty).
|
||||
*/
|
||||
export function WorkoutDetailHrSection({
|
||||
workout,
|
||||
}: WorkoutDetailHrSectionProps) {
|
||||
const { t } = useTranslations();
|
||||
const series = workout.hrSeries;
|
||||
if (!series || series.points.length < 2) return null;
|
||||
const hasCurve = Boolean(series && series.points.length >= 2);
|
||||
const hasHeartRateAtAll =
|
||||
workout.avgHr != null || workout.maxHr != null || workout.minHr != null;
|
||||
|
||||
if (!hasCurve && !hasHeartRateAtAll) return null;
|
||||
|
||||
return (
|
||||
<Card data-slot="workout-detail-hr">
|
||||
@@ -55,7 +73,7 @@ export function WorkoutDetailHrSection({
|
||||
title={t("insights.workouts.detail.hrChartTitle")}
|
||||
titleAs="h2"
|
||||
/>
|
||||
{series.source === "pulse_window" ? (
|
||||
{hasCurve && series!.source === "pulse_window" ? (
|
||||
<p
|
||||
data-slot="workout-detail-hr-provenance"
|
||||
className="text-muted-foreground text-xs"
|
||||
@@ -65,13 +83,23 @@ export function WorkoutDetailHrSection({
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<WorkoutHrChart
|
||||
points={series.points}
|
||||
bucketSec={series.bucketSec}
|
||||
envelope={series.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
{hasCurve ? (
|
||||
<WorkoutHrChart
|
||||
points={series!.points}
|
||||
bucketSec={series!.bucketSec}
|
||||
envelope={series!.envelope}
|
||||
avgHr={workout.avgHr}
|
||||
maxHr={workout.maxHr}
|
||||
zones={workout.zones?.zones ?? null}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
data-slot="workout-detail-hr-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("insights.workouts.detail.hrNoProfile")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@ import { Loader2, MoreHorizontal, Plus, RotateCcw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useUnitDisplay } from "@/hooks/use-unit-display";
|
||||
import {
|
||||
entryValueToCanonical,
|
||||
parseDecimalEntry,
|
||||
} from "@/lib/measurements/entry-units";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
@@ -343,7 +347,17 @@ export function MeasurementForm({
|
||||
// identity path — `fromDisplay` returns the typed number unchanged, so
|
||||
// the payload is byte-identical to before. Round only the converted
|
||||
// imperial value so 210 lb stores as 95.25 kg, not 95.25439770….
|
||||
const typed = parseFloat(value);
|
||||
//
|
||||
// A second boundary sits beside it: a few types are stored in a unit
|
||||
// the form does not ask for. Sleep is entered in hours and stored in
|
||||
// minutes, and sending the typed number through unconverted filed a
|
||||
// 7.5-hour night as seven and a half minutes — inside the column's
|
||||
// plausibility band, so nothing objected.
|
||||
const typed = parseDecimalEntry(value);
|
||||
if (typed === null) {
|
||||
setError(t("measurements.saveError"));
|
||||
return;
|
||||
}
|
||||
let canonicalValue = typed;
|
||||
if (unitDisplay.isTransformed(type)) {
|
||||
const inverted = unitDisplay.fromDisplay(type, typed);
|
||||
@@ -352,6 +366,7 @@ export function MeasurementForm({
|
||||
? Math.round(inverted * 100) / 100
|
||||
: inverted;
|
||||
}
|
||||
canonicalValue = entryValueToCanonical(type, canonicalValue);
|
||||
await apiPost("/api/measurements", {
|
||||
type,
|
||||
value: canonicalValue,
|
||||
@@ -533,9 +548,13 @@ export function MeasurementForm({
|
||||
>
|
||||
<Input
|
||||
id="value"
|
||||
type="number"
|
||||
// Not `type="number"`: its sanitisation drops a decimal comma on
|
||||
// the engines that do not localise the field, and "7,5" then
|
||||
// arrives as an empty string with no explanation. `inputMode`
|
||||
// still raises the numeric keypad, and the submit path reads the
|
||||
// comma — the same shape the labs and custom-metric fields use.
|
||||
inputMode="decimal"
|
||||
enterKeyHint="next"
|
||||
step="any"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder={
|
||||
|
||||
@@ -242,6 +242,7 @@ export async function buildCycleSnapshotBlock(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
|
||||
// Next predicted event — period range + confidence + method, fertile window
|
||||
@@ -280,6 +281,7 @@ export async function buildCycleSnapshotBlock(
|
||||
lutealLength,
|
||||
from,
|
||||
today,
|
||||
today,
|
||||
);
|
||||
const measurements: CrossMetricMeasurement[] = measurementRows.map((m) => ({
|
||||
type: m.type,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Stamp every metric block in the Coach snapshot with the age of its freshest
|
||||
* reading.
|
||||
*
|
||||
* The narrated surfaces — the dashboard hero line, the daily briefing, the
|
||||
* Coach reply — are written from this snapshot. Each metric block carried its
|
||||
* aggregates and a dated timeline, and nothing else: no statement of when the
|
||||
* series actually ends. Reading it, the newest row looks like now, and the
|
||||
* prose came out as "your pulse is well above your 30-day average today" on a
|
||||
* day whose pulse had not been measured for the better part of a week.
|
||||
*
|
||||
* The age was already in the block, buried one level down in the coverage
|
||||
* object as `newestDaysAgo`, where it read as a statistics footnote rather
|
||||
* than as the fact that governs the tense of every sentence about the metric.
|
||||
* This lifts it to the block's own surface and states the consequence
|
||||
* directly, so the answer arrives with the data instead of depending on the
|
||||
* reader working it out.
|
||||
*
|
||||
* Pure and in-place — the snapshot is a plain record by the time it gets here.
|
||||
*/
|
||||
import {
|
||||
TODAY_CLAIM_MAX_AGE_DAYS,
|
||||
isCurrentForTodayClaim,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
|
||||
/** When a metric block's series actually ends, and what may be said about it. */
|
||||
export interface SnapshotAsOf {
|
||||
/** Whole days between the freshest reading and today. `0` is today. */
|
||||
daysAgo: number;
|
||||
/** True when the freshest reading is from today. */
|
||||
isToday: boolean;
|
||||
/**
|
||||
* False when the freshest reading is too old to back a present-tense
|
||||
* sentence. The block's numbers are still true — they are just true about
|
||||
* the day they were taken on, and must be stated with that day.
|
||||
*/
|
||||
currentForTodayClaims: boolean;
|
||||
}
|
||||
|
||||
function readNewestDaysAgo(block: unknown): number | null {
|
||||
if (typeof block !== "object" || block === null) return null;
|
||||
const aggregate = (block as { aggregate?: unknown }).aggregate;
|
||||
if (typeof aggregate !== "object" || aggregate === null) return null;
|
||||
const coverage = (aggregate as { coverage?: unknown }).coverage;
|
||||
if (typeof coverage !== "object" || coverage === null) return null;
|
||||
const newestDaysAgo = (coverage as { newestDaysAgo?: unknown }).newestDaysAgo;
|
||||
return typeof newestDaysAgo === "number" && Number.isFinite(newestDaysAgo)
|
||||
? newestDaysAgo
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `asOf` stamp for one reading age. Exported so the rule is testable
|
||||
* without assembling a snapshot.
|
||||
*/
|
||||
export function asOfFromDaysAgo(daysAgo: number): SnapshotAsOf {
|
||||
return {
|
||||
daysAgo,
|
||||
isToday: daysAgo === 0,
|
||||
currentForTodayClaims: isCurrentForTodayClaim(daysAgo),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach `asOf` to every snapshot block whose aggregate reports a freshest
|
||||
* reading. Blocks with no coverage (narrative memory, plans, the reference
|
||||
* grounding table) are untouched — they carry no measurement to date.
|
||||
*
|
||||
* Returns the metric keys that were stamped stale, so the caller can annotate
|
||||
* them: a briefing narrated off a week-old series is worth seeing in the wide
|
||||
* event, not just in the reader's confusion.
|
||||
*/
|
||||
export function annotateSnapshotFreshness(
|
||||
snapshot: Record<string, unknown>,
|
||||
): string[] {
|
||||
const stale: string[] = [];
|
||||
for (const [key, block] of Object.entries(snapshot)) {
|
||||
const daysAgo = readNewestDaysAgo(block);
|
||||
if (daysAgo === null) continue;
|
||||
const asOf = asOfFromDaysAgo(daysAgo);
|
||||
(block as Record<string, unknown>).asOf = asOf;
|
||||
if (!asOf.currentForTodayClaims) stale.push(key);
|
||||
}
|
||||
return stale;
|
||||
}
|
||||
|
||||
export { TODAY_CLAIM_MAX_AGE_DAYS };
|
||||
@@ -29,6 +29,7 @@ import type { SleepStageRow } from "@/lib/analytics/sleep-night";
|
||||
import { compactSections } from "@/lib/ai/prompts/compact-sections";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import { annotateSnapshotFreshness } from "./snapshot-freshness";
|
||||
import { buildGlp1SnapshotBlock } from "./glp1-snapshot";
|
||||
import { buildDerivedSnapshotBlock } from "./derived-snapshot";
|
||||
import { buildCorrelationsSnapshotBlock } from "./correlations-snapshot";
|
||||
@@ -1209,6 +1210,19 @@ async function buildCoachSnapshotImpl(
|
||||
// side of the prompt.
|
||||
const compactSnapshot = compactSections(snapshot);
|
||||
|
||||
// Date the end of every metric series before anything narrates it. Without
|
||||
// this each block reads as if its newest number were taken just now, and the
|
||||
// hero line said "today" about a metric last measured five days earlier. The
|
||||
// stamp sits on the block itself so the age travels with the numbers into
|
||||
// every surface the snapshot feeds — hero, briefing, Coach reply, tools.
|
||||
const staleBlocks = annotateSnapshotFreshness(compactSnapshot);
|
||||
if (staleBlocks.length > 0) {
|
||||
annotate({
|
||||
action: { name: "coach.snapshot.stale_blocks" },
|
||||
meta: { blocks: staleBlocks.sort() },
|
||||
});
|
||||
}
|
||||
|
||||
// v1.7.0 — assembled-snapshot soft cap. Enabling every cluster at a
|
||||
// long window can balloon the prompt; degrade progressively by
|
||||
// reverse cluster priority until the serialised size fits
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The rule that decides whether a logged bleeding day starts a cycle.
|
||||
*
|
||||
* It is the one piece of the flow-to-cycle inference that is a judgement
|
||||
* rather than bookkeeping, and a wrong yes is worse than a wrong no: a false
|
||||
* cycle start corrupts every length the engine derives after it, while a
|
||||
* missed one leaves the person exactly where they were before, with the
|
||||
* one-tap start still available on any date.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { bleedOpensCycle } from "../cycle-boundaries";
|
||||
import { HARD_CYCLE_MIN } from "../types";
|
||||
import { addDays } from "../day-math";
|
||||
|
||||
const DAY = "2026-06-01";
|
||||
|
||||
describe("bleedOpensCycle", () => {
|
||||
it("opens the first cycle a record ever has", () => {
|
||||
expect(bleedOpensCycle("MEDIUM", false, null, DAY)).toBe(true);
|
||||
expect(bleedOpensCycle("HEAVY", false, null, DAY)).toBe(true);
|
||||
expect(bleedOpensCycle("LIGHT", false, null, DAY)).toBe(true);
|
||||
});
|
||||
|
||||
it("declines a day that records no bleeding", () => {
|
||||
expect(bleedOpensCycle(null, false, null, DAY)).toBe(false);
|
||||
expect(bleedOpensCycle("NONE", false, null, DAY)).toBe(false);
|
||||
});
|
||||
|
||||
it("declines spotting", () => {
|
||||
// As often the tail or the herald of a period as its first day, and the
|
||||
// person has a chip for a real flow when it is one.
|
||||
expect(bleedOpensCycle("SPOTTING", false, null, DAY)).toBe(false);
|
||||
});
|
||||
|
||||
it("declines bleeding the person flagged as between periods", () => {
|
||||
expect(bleedOpensCycle("HEAVY", true, null, DAY)).toBe(false);
|
||||
});
|
||||
|
||||
it("declines a bleeding day inside the current cycle's plausible span", () => {
|
||||
// Day two, day three, and a day somebody forgot and entered late: all of
|
||||
// them are this period, not a new one.
|
||||
for (const offset of [1, 2, 4, HARD_CYCLE_MIN - 1]) {
|
||||
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, -offset), DAY)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("declines a bleeding day on a date a cycle already starts", () => {
|
||||
// What the one-tap boundary's own day-log write hits.
|
||||
expect(bleedOpensCycle("MEDIUM", false, DAY, DAY)).toBe(false);
|
||||
});
|
||||
|
||||
it("opens the next cycle at the hard physiological minimum and beyond", () => {
|
||||
expect(
|
||||
bleedOpensCycle("MEDIUM", false, addDays(DAY, -HARD_CYCLE_MIN), DAY),
|
||||
).toBe(true);
|
||||
expect(bleedOpensCycle("MEDIUM", false, addDays(DAY, -28), DAY)).toBe(true);
|
||||
});
|
||||
|
||||
it("declines when the nearest cycle start is AFTER the day", () => {
|
||||
// Never happens through the resolver (it queries `startDate <= date`), but
|
||||
// a negative gap must not read as a large one.
|
||||
expect(bleedOpensCycle("HEAVY", false, addDays(DAY, 10), DAY)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -11,15 +11,44 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
cycleDayLog: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
cycleSymptom: { findMany: vi.fn() },
|
||||
cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
}));
|
||||
const db = vi.hoisted(() => {
|
||||
const client: Record<string, unknown> = {
|
||||
cycleDayLog: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
// The write path asks whether the logged day opens a cycle. These cases
|
||||
// are about the encryption columns, so the fake answers "no cycles on
|
||||
// record" and lets the boundary work run for real against itself — the
|
||||
// inference itself is pinned by its own unit + integration cases.
|
||||
menstrualCycle: {
|
||||
findFirst: vi.fn(async () => null),
|
||||
upsert: vi.fn(async () => ({ id: "cyc-1", endDate: null })),
|
||||
update: vi.fn(),
|
||||
},
|
||||
cycleSymptom: { findMany: vi.fn() },
|
||||
cycleSymptomLink: { deleteMany: vi.fn(), createMany: vi.fn() },
|
||||
};
|
||||
client.$transaction = async (fn: (tx: unknown) => unknown) => fn(client);
|
||||
return client as {
|
||||
cycleDayLog: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
menstrualCycle: {
|
||||
findFirst: ReturnType<typeof vi.fn>;
|
||||
upsert: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
cycleSymptom: { findMany: ReturnType<typeof vi.fn> };
|
||||
cycleSymptomLink: {
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/cycle/profile", () => ({
|
||||
@@ -70,6 +99,8 @@ function existingRow(overrides: Record<string, unknown> = {}) {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.cycleDayLog.upsert.mockResolvedValue({ id: "cdl-1" });
|
||||
db.menstrualCycle.findFirst.mockResolvedValue(null);
|
||||
db.menstrualCycle.upsert.mockResolvedValue({ id: "cyc-1", endDate: null });
|
||||
});
|
||||
|
||||
describe("upsertCycleDayLog — C-1 undecryptable note preservation", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addDays } from "../day-math";
|
||||
import { FERTILE_POST, FERTILE_PRE } from "../types";
|
||||
import {
|
||||
clampLuteal,
|
||||
confirmSymptothermal,
|
||||
@@ -1091,3 +1092,33 @@ describe("cycle/dto — toCyclePredictionDTO still-learning honesty gate (M-1)",
|
||||
expect(goalGated.predictedOvulation).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cycle/prediction — the fertile window's width (A10)", () => {
|
||||
it("spans seven days, ending the day after the ovulation estimate", () => {
|
||||
// The comment on the constants used to say six days and the constants made
|
||||
// seven. The width is now a decision with a reason written at the constant,
|
||||
// so it is pinned here rather than left to be re-read off the same numbers
|
||||
// the code uses: five days before the estimate, the estimate, and one after.
|
||||
const cycles = cyclesFromGaps("2026-01-01", [28, 28, 28]);
|
||||
const p = predictCycle(cycles, [], BASE_PROFILE, "2026-03-20", []);
|
||||
|
||||
expect(p.predictedOvulation).not.toBeNull();
|
||||
expect(p.fertileWindowStart).toBe(addDays(p.predictedOvulation!, -5));
|
||||
expect(p.fertileWindowEnd).toBe(addDays(p.predictedOvulation!, 1));
|
||||
|
||||
const span =
|
||||
(Date.parse(`${p.fertileWindowEnd}T12:00:00Z`) -
|
||||
Date.parse(`${p.fertileWindowStart}T12:00:00Z`)) /
|
||||
86_400_000;
|
||||
// Inclusive of both ends.
|
||||
expect(span + 1).toBe(7);
|
||||
});
|
||||
|
||||
it("keeps the extra day on the side that costs least to be wrong about", () => {
|
||||
// The trailing day is estimator slack plus ovum viability. Dropping it
|
||||
// would shorten the window for somebody avoiding pregnancy, which is the
|
||||
// direction that misses.
|
||||
expect(FERTILE_PRE).toBe(5);
|
||||
expect(FERTILE_POST).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,60 @@ describe("resolveCycleVerdict — what the record supports", () => {
|
||||
expect(v.cycleLength).toBe(8);
|
||||
});
|
||||
|
||||
it("counts off the logged start when the grid withholds its phase labels", () => {
|
||||
// What the calendar hands over while the engine is still learning: a grid
|
||||
// with every phase suppressed. The count is not the suppressed claim.
|
||||
const days = [day("2026-06-01", null), day("2026-06-11", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
profile: { typicalCycleLength: 28 },
|
||||
lastPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(11);
|
||||
expect(v.cycleStartDate).toBe("2026-06-01");
|
||||
expect(v.phase).toBeNull();
|
||||
expect(v.spans).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("says how late a period is with no phase labels to read", () => {
|
||||
const days = [day("2026-06-11", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-11",
|
||||
profile: { typicalCycleLength: 28 },
|
||||
lastPeriodStart: "2026-04-22",
|
||||
});
|
||||
expect(v.state).toBe("OVERDUE");
|
||||
expect(v.dayOfCycle).toBeNull();
|
||||
expect(v.overdueDays).toBe(23);
|
||||
expect(v.cycleStartDate).toBe("2026-04-22");
|
||||
});
|
||||
|
||||
it("counts off the logged start when today is absent from the grid", () => {
|
||||
const days = [day("2026-06-01", "MENSTRUAL")];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-09",
|
||||
lastPeriodStart: "2026-06-01",
|
||||
});
|
||||
expect(v.state).toBe("IN_CYCLE");
|
||||
expect(v.dayOfCycle).toBe(9);
|
||||
});
|
||||
|
||||
it("keeps INSUFFICIENT_DATA when the anchor is ahead of today", () => {
|
||||
// A start dated in the future is not a cycle the person is in.
|
||||
const days = [day("2026-06-02", null)];
|
||||
const v = resolveCycleVerdict({
|
||||
days,
|
||||
today: "2026-06-02",
|
||||
lastPeriodStart: "2026-06-20",
|
||||
});
|
||||
expect(v.state).toBe("INSUFFICIENT_DATA");
|
||||
expect(v.cycleStartDate).toBeNull();
|
||||
});
|
||||
|
||||
it("draws the canonical four arcs for a low-data tracker", () => {
|
||||
// A two-day first-ever run: the observed-share math would let MENSTRUAL
|
||||
// fill nearly the whole circle, so the ring falls back to the profile's
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Cycle boundary re-derivation — the inverse of what a period-start write does
|
||||
* to its neighbours.
|
||||
*
|
||||
* `POST /api/cycle/period` with `action:"start"` is not an insert. It closes
|
||||
* the previous open cycle (stamps `endDate` and `lengthDays` from the new
|
||||
* start) and, when the start is back-filled between two existing cycles, takes
|
||||
* the inserted cycle's own end from its successor. Removing that start left
|
||||
* every one of those overwrites standing: the previous cycle kept the end date
|
||||
* a cycle that no longer exists gave it, and a record whose last cycle is
|
||||
* closed has no open cycle for the engine to forecast from. The prediction
|
||||
* disappeared and there was no way to bring it back from the app.
|
||||
*
|
||||
* The repair is a re-derivation rather than an undo log, which is why it is
|
||||
* safe to run on a record whose history came from an import or a restore
|
||||
* instead of from the one-tap boundary: a cycle ends the day before the next
|
||||
* one begins, and when there is no next one it is open. That statement is true
|
||||
* of every cycle in the table regardless of how the row got there, so applying
|
||||
* it to the surviving neighbour restores exactly the state the start write
|
||||
* moved — and applying it twice changes nothing the second time.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { addDays, dayDiff } from "./day-math";
|
||||
import { HARD_CYCLE_MIN } from "./types";
|
||||
import type {
|
||||
FlowLevel,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** The Prisma client or an interactive-transaction handle. */
|
||||
type CycleDb = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* The boundary columns an opening write moved on the cycles around it, so the
|
||||
* audit row can name them. They are the only unrecoverable part of the write:
|
||||
* the opened cycle is a row, the re-anchoring is an overwrite of two columns
|
||||
* on somebody else's cycle history.
|
||||
*/
|
||||
export interface MovedAnchors {
|
||||
priorCycleId?: string;
|
||||
/** Day keys, the shape this domain stores its dates in. */
|
||||
priorEndDateBefore?: string | null;
|
||||
priorEndDateAfter?: string | null;
|
||||
priorLengthBefore?: number | null;
|
||||
priorLengthAfter?: number | null;
|
||||
openedEndDateBefore?: string | null;
|
||||
openedEndDateAfter?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a cycle at `date`, closing the one before it and taking the opened
|
||||
* cycle's own end from the one after it when the start is back-filled between
|
||||
* two existing cycles. Without that second half an inserted cycle keeps a null
|
||||
* endDate even though its successor's start is right there, and every history
|
||||
* surface reads a stale open cycle.
|
||||
*
|
||||
* Idempotent on `(userId, startDate)`: a re-tap on the same day, or a flow
|
||||
* entry on a day the one-tap boundary already anchored, updates the existing
|
||||
* row rather than adding a second one.
|
||||
*
|
||||
* Callers run this inside a transaction — the close-prior and open-new pair
|
||||
* must not interleave with a concurrent write, or two taps double-close a
|
||||
* prior cycle or compute a length against a stale read.
|
||||
*/
|
||||
export async function openCycleAt(
|
||||
db: CycleDb,
|
||||
userId: string,
|
||||
date: string,
|
||||
tz: string | null,
|
||||
): Promise<{ cycleId: string; moved: MovedAnchors }> {
|
||||
const moved: MovedAnchors = {};
|
||||
|
||||
// Close the prior open cycle: its end is the day before this start.
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { lt: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true, startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
if (prior) {
|
||||
moved.priorCycleId = prior.id;
|
||||
moved.priorEndDateBefore = prior.endDate;
|
||||
moved.priorEndDateAfter = addDays(date, -1);
|
||||
moved.priorLengthBefore = prior.lengthDays;
|
||||
moved.priorLengthAfter = dayDiff(date, prior.startDate);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: {
|
||||
endDate: addDays(date, -1),
|
||||
lengthDays: dayDiff(date, prior.startDate),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const cycle = await db.menstrualCycle.upsert({
|
||||
where: { userId_startDate: { userId, startDate: date } },
|
||||
create: { userId, startDate: date, tz, isPredicted: false },
|
||||
update: {
|
||||
deletedAt: null,
|
||||
isPredicted: false,
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
// Re-anchor the FOLLOWING neighbour's side of the boundary.
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { gt: date } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (next) {
|
||||
moved.openedEndDateBefore = cycle.endDate;
|
||||
moved.openedEndDateAfter = addDays(next.startDate, -1);
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: cycle.id },
|
||||
data: {
|
||||
endDate: addDays(next.startDate, -1),
|
||||
lengthDays: dayDiff(next.startDate, date),
|
||||
syncVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { cycleId: cycle.id, moved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a logged day's bleeding is the first day of a period, and therefore
|
||||
* opens a cycle.
|
||||
*
|
||||
* Cycle rows used to come only from the one-tap period boundary. Every other
|
||||
* way of recording bleeding — the log sheet's flow chips, the bulk drain, an
|
||||
* Apple Health export of menstrual-flow samples — wrote a day-log and stopped
|
||||
* there, and the engine reads cycle rows. A person could log every period they
|
||||
* had and the module would still hold that none had happened.
|
||||
*
|
||||
* The rule is deliberately conservative, because a false cycle start corrupts
|
||||
* every length the engine derives afterwards:
|
||||
*
|
||||
* - SPOTTING does not open a cycle. It is as often the tail of a period or
|
||||
* its herald as its first day, and the user has an explicit chip for a
|
||||
* real flow when it is one.
|
||||
* - Bleeding flagged as between periods does not open a cycle. That flag
|
||||
* exists to say exactly this.
|
||||
* - A bleeding day within the hard physiological minimum cycle length of an
|
||||
* existing cycle's start is part of that cycle, not a new one. This is
|
||||
* what keeps day three of a period — or a day two somebody forgot and
|
||||
* entered later — from starting a cycle of its own, and it needs no
|
||||
* contiguity check to do it.
|
||||
*
|
||||
* The cost is that a genuinely short cycle (under the hard minimum) is not
|
||||
* inferred from flow alone. The one-tap start still opens one on any date, so
|
||||
* the case has an answer; guessing it from flow does not.
|
||||
*/
|
||||
export function bleedOpensCycle(
|
||||
flow: FlowLevel | null,
|
||||
intermenstrualBleeding: boolean,
|
||||
priorCycleStart: string | null,
|
||||
date: string,
|
||||
): boolean {
|
||||
if (flow === null || flow === "NONE" || flow === "SPOTTING") return false;
|
||||
if (intermenstrualBleeding) return false;
|
||||
if (priorCycleStart === null) return true;
|
||||
return dayDiff(date, priorCycleStart) >= HARD_CYCLE_MIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a cycle for a bleeding day when `bleedOpensCycle` says it starts one.
|
||||
* Returns the opened cycle's id, or null when the day changes nothing.
|
||||
*
|
||||
* Runs its own transaction so the boundary work is atomic even when the caller
|
||||
* is a plain day-log write with no transaction of its own.
|
||||
*/
|
||||
export async function ensureCycleForBleedingDay(
|
||||
userId: string,
|
||||
date: string,
|
||||
tz: string | null,
|
||||
day: { flow: FlowLevel | null; intermenstrualBleeding: boolean },
|
||||
): Promise<string | null> {
|
||||
// Cheap pre-check before a transaction: most logged days are not a first
|
||||
// bleeding day, and the flow grade alone rules most of them out.
|
||||
if (
|
||||
day.flow === null ||
|
||||
day.flow === "NONE" ||
|
||||
day.flow === "SPOTTING" ||
|
||||
day.intermenstrualBleeding
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (db) => {
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { lte: date } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
if (
|
||||
!bleedOpensCycle(
|
||||
day.flow,
|
||||
day.intermenstrualBleeding,
|
||||
prior?.startDate ?? null,
|
||||
date,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const { cycleId } = await openCycleAt(db, userId, date, tz);
|
||||
return cycleId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cycle that opens on `date`, if there is one, and hand its span
|
||||
* back to the cycle before it. The inverse of `openCycleAt`, and the shared
|
||||
* body behind every way of taking a period start back: deleting the day-log
|
||||
* that opened it, clearing that day's flow, and deleting the cycle itself.
|
||||
*
|
||||
* Returns null when no live cycle starts on that date.
|
||||
*/
|
||||
export async function removeCycleStartedOn(
|
||||
db: CycleDb,
|
||||
userId: string,
|
||||
date: string,
|
||||
): Promise<{ cycleId: string; reanchored: ReanchoredCycle | null } | null> {
|
||||
const opened = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: date },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!opened) return null;
|
||||
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: opened.id },
|
||||
data: { deletedAt: new Date(), syncVersion: { increment: 1 } },
|
||||
});
|
||||
return {
|
||||
cycleId: opened.id,
|
||||
reanchored: await reanchorAfterRemovedStart(db, userId, date),
|
||||
};
|
||||
}
|
||||
|
||||
/** The boundary columns the re-derivation moved, for the audit row. */
|
||||
export interface ReanchoredCycle {
|
||||
cycleId: string;
|
||||
endDateBefore: string | null;
|
||||
endDateAfter: string | null;
|
||||
lengthDaysBefore: number | null;
|
||||
lengthDaysAfter: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive the boundary of the cycle that precedes `removedStartDate` from
|
||||
* the cycles that remain. Call it AFTER the cycle at that date is soft-deleted
|
||||
* (the queries below read live rows only, so a still-live row at the removed
|
||||
* start would re-derive against itself and change nothing).
|
||||
*
|
||||
* Returns the columns it moved, or null when there is no preceding cycle or
|
||||
* the preceding cycle already holds the derived boundary — an idempotent
|
||||
* re-run must not bump `syncVersion` and make every device re-pull a row that
|
||||
* did not change.
|
||||
*/
|
||||
export async function reanchorAfterRemovedStart(
|
||||
db: CycleDb,
|
||||
userId: string,
|
||||
removedStartDate: string,
|
||||
): Promise<ReanchoredCycle | null> {
|
||||
const prior = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { lt: removedStartDate } },
|
||||
orderBy: { startDate: "desc" },
|
||||
select: { id: true, startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
if (!prior) return null;
|
||||
|
||||
const next = await db.menstrualCycle.findFirst({
|
||||
where: { userId, deletedAt: null, startDate: { gt: removedStartDate } },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
|
||||
// A cycle ends the day before the next one starts; with no next one it is
|
||||
// open again, which is the state the removed start took away from it.
|
||||
const endDate = next ? addDays(next.startDate, -1) : null;
|
||||
const lengthDays = next ? dayDiff(next.startDate, prior.startDate) : null;
|
||||
|
||||
if (prior.endDate === endDate && prior.lengthDays === lengthDays) return null;
|
||||
|
||||
await db.menstrualCycle.update({
|
||||
where: { id: prior.id },
|
||||
data: { endDate, lengthDays, syncVersion: { increment: 1 } },
|
||||
});
|
||||
|
||||
return {
|
||||
cycleId: prior.id,
|
||||
endDateBefore: prior.endDate,
|
||||
endDateAfter: endDate,
|
||||
lengthDaysBefore: prior.lengthDays,
|
||||
lengthDaysAfter: lengthDays,
|
||||
};
|
||||
}
|
||||
@@ -31,6 +31,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { getOrCreateCycleProfile } from "@/lib/cycle/profile";
|
||||
import { ensureCycleForBleedingDay } from "@/lib/cycle/cycle-boundaries";
|
||||
import type { CycleDayLogInput } from "@/lib/validations/cycle";
|
||||
import type {
|
||||
CervicalMucus,
|
||||
@@ -387,6 +388,25 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
||||
? (entry.cervixOpening ?? null)
|
||||
: (existing?.cervixOpening ?? null);
|
||||
|
||||
// A first bleeding day opens the cycle it belongs to. Cycle rows used to
|
||||
// come only from the one-tap period boundary, so a person who logged every
|
||||
// period as flow had a module that held none had ever happened — no lengths,
|
||||
// no forecast, an empty chart. This is the one shared seam behind the single
|
||||
// capture, the bulk drain, the Apple Health fold and the boundary itself, so
|
||||
// all four infer a start the same way (the boundary's own write lands on a
|
||||
// date that already has a cycle, which the detector declines). The day is
|
||||
// then attributed to the cycle it just opened rather than the one before it.
|
||||
const openedCycleId = await ensureCycleForBleedingDay(
|
||||
userId,
|
||||
entry.date,
|
||||
tz,
|
||||
{
|
||||
flow,
|
||||
intermenstrualBleeding,
|
||||
},
|
||||
);
|
||||
const attributedCycleId = openedCycleId ?? cycleId;
|
||||
|
||||
// Was any of the five sensitive fields actually supplied in THIS update?
|
||||
// Used to decide whether an undecryptable existing envelope may be preserved
|
||||
// verbatim (an unrelated-field edit) or must be rewritten (the user is
|
||||
@@ -479,7 +499,7 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
||||
const updateData: Prisma.CycleDayLogUncheckedUpdateInput = {
|
||||
...baseData,
|
||||
...(entry.externalId ? { date: entry.date } : {}),
|
||||
...(cycleId !== null ? { cycleId } : {}),
|
||||
...(attributedCycleId !== null ? { cycleId: attributedCycleId } : {}),
|
||||
deletedAt: null,
|
||||
// Only bump syncVersion when a field actually changed — an unchanged
|
||||
// re-post must be a true no-op on the /api/sync/changes feed so other
|
||||
@@ -498,7 +518,7 @@ async function writeDayLog(args: WriteArgs): Promise<DayLogWriteResult> {
|
||||
tz,
|
||||
source,
|
||||
externalId: entry.externalId ?? null,
|
||||
cycleId,
|
||||
cycleId: attributedCycleId,
|
||||
flow,
|
||||
intermenstrualBleeding,
|
||||
basalBodyTempC,
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
type CyclePhase,
|
||||
type PhaseCycle,
|
||||
resolveLuteal,
|
||||
clampLuteal,
|
||||
POPULATION_DEFAULT_CYCLE,
|
||||
} from "@/lib/cycle";
|
||||
import type {
|
||||
CycleProfile,
|
||||
@@ -32,6 +34,13 @@ export interface CalendarDayDTO {
|
||||
isFertileWindow: boolean;
|
||||
isPredictedOvulation: boolean;
|
||||
isPeriodLogged: boolean;
|
||||
/**
|
||||
* Whether a logged cycle opens on this day. The client uses it to say what a
|
||||
* delete would take: removing the day-log of a cycle start removes the start
|
||||
* too, and a confirm dialog that does not name that is asking about the
|
||||
* wrong thing.
|
||||
*/
|
||||
isCycleStart: boolean;
|
||||
flow: string | null;
|
||||
hasSymptoms: boolean;
|
||||
confidence: number;
|
||||
@@ -129,9 +138,30 @@ function periodLengthOf(
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a contiguous `PhaseCycle[]` from the observed cycles (each cycle's
|
||||
* span ends where the next begins; the latest open cycle runs to the
|
||||
* predicted next-period start). Used to label every calendar day's phase.
|
||||
* Build a contiguous `PhaseCycle[]` from the observed cycles: each closed
|
||||
* cycle's span ends where the next begins, and the latest open cycle runs to
|
||||
* the predicted next-period start — or, when that day has come and gone with
|
||||
* no period logged, on through `today`.
|
||||
*
|
||||
* That trailing extension is the difference between a late period and no
|
||||
* record at all. A cycle stays open until the next one starts, and a forecast
|
||||
* is a forecast: the day after a missed prediction is still a day of the cycle
|
||||
* the person is in. Ending the span at the predicted start instead made every
|
||||
* day past it carry no phase, which the verdict resolver could only read as
|
||||
* "today falls outside every known cycle window" — so an ordinary three-days-
|
||||
* late period reported INSUFFICIENT_DATA, and the honest OVERDUE state was
|
||||
* unreachable unless the engine itself forecast a cycle longer than the grace
|
||||
* window. The count is bounded on the other side: `resolveCycleVerdict` stops
|
||||
* claiming a cycle day once the run passes the profile's typical length plus
|
||||
* its grace window.
|
||||
*
|
||||
* The extension deliberately does NOT move the ovulation estimate. Left to
|
||||
* `phaseForDay`, an ovulation with no confirmed date is back-calculated from
|
||||
* the span's own length, so a window that grows a day per day of delay would
|
||||
* walk the estimate — and every FOLLICULAR/OVULATORY/LUTEAL boundary already
|
||||
* drawn for that cycle — forward with it. The open cycle therefore carries an
|
||||
* explicit anchor derived from the PREDICTED length, which is the same date
|
||||
* `phaseForDay` computes when the span ends where the forecast said it would.
|
||||
*/
|
||||
export function buildPhaseCycles(
|
||||
cycles: readonly Pick<
|
||||
@@ -140,18 +170,37 @@ export function buildPhaseCycles(
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
/** Today in the USER's timezone. The open cycle runs at least this far. */
|
||||
today: string,
|
||||
): PhaseCycle[] {
|
||||
const sorted = [...cycles].sort((a, b) => dayDiff(a.startDate, b.startDate));
|
||||
const out: PhaseCycle[] = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
const c = sorted[i];
|
||||
const next = sorted[i + 1];
|
||||
if (next) {
|
||||
out.push({
|
||||
startDate: c.startDate,
|
||||
nextStart: next.startDate,
|
||||
ovulationDate: c.ovulationDate,
|
||||
periodLength: periodLengthOf(c),
|
||||
lutealLength,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// The open cycle. `phaseForDay` treats `nextStart` as exclusive, so the
|
||||
// window has to reach the day AFTER today for today to carry a phase.
|
||||
const forecastEnd =
|
||||
predictedNextStart ?? addDays(c.startDate, POPULATION_DEFAULT_CYCLE);
|
||||
const throughToday = addDays(today, 1);
|
||||
const nextStart =
|
||||
next?.startDate ?? predictedNextStart ?? addDays(c.startDate, 28);
|
||||
dayDiff(throughToday, forecastEnd) > 0 ? throughToday : forecastEnd;
|
||||
out.push({
|
||||
startDate: c.startDate,
|
||||
nextStart,
|
||||
ovulationDate: c.ovulationDate,
|
||||
ovulationDate:
|
||||
c.ovulationDate ?? addDays(forecastEnd, -clampLuteal(lutealLength)),
|
||||
periodLength: periodLengthOf(c),
|
||||
lutealLength,
|
||||
});
|
||||
@@ -186,11 +235,14 @@ export function phaseForDate(
|
||||
>[],
|
||||
predictedNextStart: string | null,
|
||||
lutealLength: number,
|
||||
/** Today in the USER's timezone — how far a still-open cycle runs. */
|
||||
today: string,
|
||||
): { phase: CyclePhase | null; dayOfCycle: number | null } {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
for (const pc of phaseCycles) {
|
||||
const r = phaseForDay(date, pc);
|
||||
@@ -219,11 +271,14 @@ export function buildPhaseDayMap(
|
||||
lutealLength: number,
|
||||
from: string,
|
||||
to: string,
|
||||
/** Today in the USER's timezone — how far a still-open cycle runs. */
|
||||
today: string,
|
||||
): Map<string, CyclePhase> {
|
||||
const phaseCycles = buildPhaseCycles(
|
||||
cycles,
|
||||
predictedNextStart,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
const out = new Map<string, CyclePhase>();
|
||||
const span = dayDiff(to, from);
|
||||
@@ -304,6 +359,7 @@ export function buildCalendar(
|
||||
cycles,
|
||||
prediction?.nextPeriodStart ?? null,
|
||||
lutealLength,
|
||||
today,
|
||||
);
|
||||
|
||||
// Forward predicted period bar: [nextPeriodStart, +predictedPeriodLength).
|
||||
@@ -319,6 +375,8 @@ export function buildCalendar(
|
||||
const logByDate = new Map<string, CalendarDayLogRow>();
|
||||
for (const l of dayLogs) logByDate.set(l.date, l);
|
||||
|
||||
const cycleStartDates = new Set(cycles.map((c) => c.startDate));
|
||||
|
||||
const days: CalendarDayDTO[] = [];
|
||||
const span = dayDiff(to, from);
|
||||
for (let i = 0; i <= span; i++) {
|
||||
@@ -356,6 +414,7 @@ export function buildCalendar(
|
||||
isFertileWindow,
|
||||
isPredictedOvulation,
|
||||
isPeriodLogged: log?.flow != null && log.flow !== "NONE",
|
||||
isCycleStart: cycleStartDates.has(date),
|
||||
flow: log?.flow ?? null,
|
||||
hasSymptoms: log?.hasSymptoms ?? false,
|
||||
confidence: prediction?.confidence ?? 0,
|
||||
|
||||
+20
-1
@@ -103,7 +103,26 @@ export const LUTEAL_DEFAULT = 14;
|
||||
export const LUTEAL_MIN = 10;
|
||||
export const LUTEAL_MAX = 16;
|
||||
|
||||
/** §4 — six-day fertile window: ovulation − 5 … ovulation + 1. */
|
||||
/**
|
||||
* §4 — the fertile window: ovulation − 5 … ovulation + 1, seven days.
|
||||
*
|
||||
* Wilcox's six-day window is the five days BEFORE ovulation plus the day
|
||||
* itself, and the trailing day here is a deliberate departure from it. The
|
||||
* six-day figure is measured against a KNOWN ovulation day; ours is estimated,
|
||||
* usually by back-calculating a luteal length from a median cycle, and an
|
||||
* estimate that lands a day early would put the last fertile day outside a
|
||||
* window drawn tight to it. The ovum also stays viable for the better part of a
|
||||
* day after release, so the extra day is not only estimator slack.
|
||||
*
|
||||
* The asymmetry of the cost decides the direction. This window is surfaced to
|
||||
* people trying to conceive and to people avoiding it, and for the second group
|
||||
* a window one day too short is a window that misses. The constants are the
|
||||
* conservative reading; the comment used to claim six days and describe seven,
|
||||
* which is the only part of this that was wrong.
|
||||
*
|
||||
* Changing either constant changes what the iOS client reimplements from the
|
||||
* same spec, so it needs a coordination note, not just an edit here.
|
||||
*/
|
||||
export const FERTILE_PRE = 5;
|
||||
export const FERTILE_POST = 1;
|
||||
|
||||
|
||||
@@ -116,6 +116,21 @@ export interface ResolveCycleVerdictInput {
|
||||
/** Already goal- and cold-start-gated fertile window bounds. */
|
||||
fertileWindowStart?: string | null;
|
||||
fertileWindowEnd?: string | null;
|
||||
/**
|
||||
* The latest LOGGED period start at or before today, or null when the record
|
||||
* holds none. Read straight off the cycle rows, never off the grid.
|
||||
*
|
||||
* The grid's phase labels are gated: the calendar suppresses the whole phase
|
||||
* band while the engine is still learning (fewer than three observed cycles),
|
||||
* because a population-framed phase word off one or two cycles is not earned.
|
||||
* The day count is not that kind of claim — it is the number of days since a
|
||||
* start the person entered themselves — but deriving it from the labelled run
|
||||
* meant the gate took both, and a record with three logged periods rendered an
|
||||
* empty ring under an offer to log a first period. This anchor keeps the count
|
||||
* and the honest overdue judgement available when the labels are withheld;
|
||||
* the phase stays null, which is the part the gate is actually about.
|
||||
*/
|
||||
lastPeriodStart?: string | null;
|
||||
}
|
||||
|
||||
/** Below this many labelled days in the current cycle run, the ring would
|
||||
@@ -234,6 +249,41 @@ function resolveFertileWindow(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict for a record whose grid carries no phase for today but whose
|
||||
* cycle rows carry a logged period start: count from that start, and apply the
|
||||
* same overdue ceiling. No phase is claimed — that is exactly the claim the
|
||||
* missing labels were withholding. Null when no start precedes today, which is
|
||||
* the one case that really is INSUFFICIENT_DATA.
|
||||
*/
|
||||
function verdictFromLoggedStart(
|
||||
input: ResolveCycleVerdictInput,
|
||||
fertileWindow: CycleVerdict["fertileWindow"],
|
||||
daysUntilNext: number | null,
|
||||
): CycleVerdict | null {
|
||||
const { today, profile, lastPeriodStart } = input;
|
||||
if (!lastPeriodStart) return null;
|
||||
const elapsed = dayDiff(today, lastPeriodStart);
|
||||
if (elapsed < 0) return null;
|
||||
|
||||
const dayOfCycle = elapsed + 1;
|
||||
const typicalLength = typicalCycleLength(profile);
|
||||
const ideal = idealizedSpans(profile);
|
||||
const overdue = dayOfCycle > typicalLength + OVERDUE_GRACE_DAYS;
|
||||
|
||||
return {
|
||||
state: overdue ? "OVERDUE" : "IN_CYCLE",
|
||||
dayOfCycle: overdue ? null : dayOfCycle,
|
||||
cycleLength: ideal.cycleLength,
|
||||
phase: null,
|
||||
spans: ideal.spans,
|
||||
cycleStartDate: lastPeriodStart,
|
||||
overdueDays: overdue ? dayOfCycle - typicalLength : null,
|
||||
daysUntilNext,
|
||||
fertileWindow,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve everything a client needs to render the cycle ring and its caption.
|
||||
* The only place in the product that decides any of it.
|
||||
@@ -259,17 +309,39 @@ export function resolveCycleVerdict(
|
||||
fertileWindow,
|
||||
};
|
||||
|
||||
if (!todayDay || todayDay.phase == null) return nothingToSay;
|
||||
if (!todayDay || todayDay.phase == null) {
|
||||
return (
|
||||
verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
|
||||
nothingToSay
|
||||
);
|
||||
}
|
||||
|
||||
// Sort dates ascending and find today's index.
|
||||
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const todayIdx = sorted.findIndex((d) => d.date === today);
|
||||
if (todayIdx < 0) return nothingToSay;
|
||||
if (todayIdx < 0) {
|
||||
return (
|
||||
verdictFromLoggedStart(input, fertileWindow, daysUntilNext) ??
|
||||
nothingToSay
|
||||
);
|
||||
}
|
||||
|
||||
const startIdx = findCycleStartIndex(sorted, todayIdx);
|
||||
const cycleStartDate = sorted[startIdx].date;
|
||||
const run = sorted.slice(startIdx, todayIdx + 1);
|
||||
const dayOfCycle = run.length;
|
||||
|
||||
// The day count comes from the logged start when the caller supplied one,
|
||||
// and only falls back to the length of the labelled run when it did not.
|
||||
// The run is bounded by the grid, so counting rows made the answer depend on
|
||||
// how much calendar the caller happened to ask for: the same day reads as
|
||||
// day 11 over a month and day 23 over three. That was latent while every
|
||||
// caller asked for the same ninety days, and stopped being latent when the
|
||||
// month grid started reading its own month. The arcs below stay grid-derived
|
||||
// — they are about what the ring can draw, not about what day it is.
|
||||
const anchoredStart =
|
||||
input.lastPeriodStart && dayDiff(today, input.lastPeriodStart) >= 0
|
||||
? input.lastPeriodStart
|
||||
: sorted[startIdx].date;
|
||||
const cycleStartDate = anchoredStart;
|
||||
const dayOfCycle = dayDiff(today, anchoredStart) + 1;
|
||||
|
||||
// Honest ceiling: once the run overshoots the typical length + grace, the
|
||||
// count reflects the engine's open-ended cycle window, not an observed
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
function vital(
|
||||
outside: boolean,
|
||||
direction: VitalDeviation["direction"],
|
||||
daysAgo = 0,
|
||||
): VitalDeviation {
|
||||
return {
|
||||
type: "RESTING_HEART_RATE",
|
||||
@@ -16,6 +17,7 @@ function vital(
|
||||
high: 65,
|
||||
outside,
|
||||
direction,
|
||||
daysAgo,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* A reading from five days ago must not produce a sentence about today.
|
||||
*
|
||||
* Two surfaces said it out loud — the day-signals card ("one of your vitals is
|
||||
* outside its usual range today") and the baseline-drift card ("your pulse is
|
||||
* above your usual range") — and the narrated hero line said it in prose. One
|
||||
* class: the freshest stored reading reached each of them with no notion of
|
||||
* when it was taken, so every one of them assumed now.
|
||||
*
|
||||
* These cover the shared rule and the two deterministic consumers. The
|
||||
* narrated surfaces are covered by the snapshot stamp beside them.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
TODAY_CLAIM_MAX_AGE_DAYS,
|
||||
dayKeyAgeInDays,
|
||||
isCurrentForTodayClaim,
|
||||
} from "../measurement-freshness";
|
||||
import { summariseHealthStatus } from "../health-status";
|
||||
import { classifyDeviation } from "../derived/coincident-deviation";
|
||||
import {
|
||||
annotateSnapshotFreshness,
|
||||
asOfFromDaysAgo,
|
||||
} from "@/lib/ai/coach/snapshot-freshness";
|
||||
|
||||
describe("the today-claim window", () => {
|
||||
it("admits today and yesterday and refuses anything older", () => {
|
||||
expect(isCurrentForTodayClaim(0)).toBe(true);
|
||||
expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS)).toBe(true);
|
||||
expect(isCurrentForTodayClaim(TODAY_CLAIM_MAX_AGE_DAYS + 1)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(5)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats an unknown age as not current", () => {
|
||||
expect(isCurrentForTodayClaim(null)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(undefined)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(Number.NaN)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(Number.POSITIVE_INFINITY)).toBe(false);
|
||||
expect(isCurrentForTodayClaim(-1)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts whole calendar days between two local day keys", () => {
|
||||
expect(dayKeyAgeInDays("2026-08-07", "2026-08-07")).toBe(0);
|
||||
expect(dayKeyAgeInDays("2026-08-06", "2026-08-07")).toBe(1);
|
||||
expect(dayKeyAgeInDays("2026-08-02", "2026-08-07")).toBe(5);
|
||||
// Across a month end and across a DST change in the reader's zone.
|
||||
expect(dayKeyAgeInDays("2026-07-31", "2026-08-07")).toBe(7);
|
||||
expect(dayKeyAgeInDays("2026-10-24", "2026-10-26")).toBe(2);
|
||||
expect(dayKeyAgeInDays("not-a-day", "2026-08-07")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the baseline-drift card's deviations", () => {
|
||||
/** A pulse well above its band, taken `daysAgo` days ago. */
|
||||
const raisedPulse = (daysAgo: number) =>
|
||||
classifyDeviation("PULSE", 96, 58, 74, 66, daysAgo);
|
||||
|
||||
it("carries a deviation measured today", () => {
|
||||
const summary = summariseHealthStatus([raisedPulse(0)], []);
|
||||
|
||||
expect(summary.present).toBe(true);
|
||||
expect(summary.deviations.map((d) => d.type)).toEqual(["PULSE"]);
|
||||
});
|
||||
|
||||
it("says nothing about a vital last measured five days ago", () => {
|
||||
const summary = summariseHealthStatus([raisedPulse(5)], []);
|
||||
|
||||
expect(summary.deviations).toEqual([]);
|
||||
expect(summary.present).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a dated level shift, which was never a today claim", () => {
|
||||
const summary = summariseHealthStatus(
|
||||
[raisedPulse(5)],
|
||||
[
|
||||
{
|
||||
metric: "RESTING_HEART_RATE",
|
||||
breakDate: "2026-07-20",
|
||||
beforeMean: 54,
|
||||
afterMean: 61,
|
||||
direction: "up",
|
||||
magnitude: 2.1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(summary.deviations).toEqual([]);
|
||||
expect(summary.shifts).toHaveLength(1);
|
||||
expect(summary.present).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Coach snapshot stamp", () => {
|
||||
it("states the age and the consequence on the block itself", () => {
|
||||
expect(asOfFromDaysAgo(0)).toEqual({
|
||||
daysAgo: 0,
|
||||
isToday: true,
|
||||
currentForTodayClaims: true,
|
||||
});
|
||||
expect(asOfFromDaysAgo(5)).toEqual({
|
||||
daysAgo: 5,
|
||||
isToday: false,
|
||||
currentForTodayClaims: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps every metric block and names the stale ones", () => {
|
||||
const snapshot: Record<string, unknown> = {
|
||||
pulse: {
|
||||
aggregate: { avg30: 66, coverage: { count: 40, newestDaysAgo: 5 } },
|
||||
timeline: { recent: [] },
|
||||
},
|
||||
weight: {
|
||||
aggregate: { latest: 81.4, coverage: { count: 30, newestDaysAgo: 0 } },
|
||||
},
|
||||
// No measurement to date — untouched.
|
||||
memory: { headline: "steady month" },
|
||||
scope: { sources: ["pulse", "weight"] },
|
||||
};
|
||||
|
||||
const stale = annotateSnapshotFreshness(snapshot);
|
||||
|
||||
expect(stale).toEqual(["pulse"]);
|
||||
expect((snapshot.pulse as { asOf: unknown }).asOf).toEqual({
|
||||
daysAgo: 5,
|
||||
isToday: false,
|
||||
currentForTodayClaims: false,
|
||||
});
|
||||
expect((snapshot.weight as { asOf: unknown }).asOf).toEqual({
|
||||
daysAgo: 0,
|
||||
isToday: true,
|
||||
currentForTodayClaims: true,
|
||||
});
|
||||
expect(snapshot.memory).toEqual({ headline: "steady month" });
|
||||
expect(snapshot.scope).toEqual({ sources: ["pulse", "weight"] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A stored value the application declares impossible is not a measurement.
|
||||
*
|
||||
* The personal band ("your usual range") and the deviation line that reads
|
||||
* against it are both means over stored `Measurement.value`. Neither end
|
||||
* checked the value against the metric's own plausibility domain, so a single
|
||||
* impossible pulse reading — the shape a provider unit-decode slip or a writer
|
||||
* without the range gate produces — moved the day mean, the median carried it
|
||||
* into the band, and the card said "your pulse is above your usual range,
|
||||
* currently 111,287,531.01 instead of the usual 36,016.75".
|
||||
*
|
||||
* The numbers below are shaped like the real thing: a resting-pulse series in
|
||||
* the 60s and 70s, one impossible sample dropped into it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildBaselineBand, dayMeansFromRows } from "../baseline";
|
||||
import { latestDayMeanFromRows } from "../coincident-deviation";
|
||||
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
/** A real-shaped resting-pulse day: four readings in the 60s/70s. */
|
||||
function pulseDay(day: string, values: number[]) {
|
||||
return values.map((value, i) => ({
|
||||
value,
|
||||
measuredAt: new Date(`${day}T0${6 + i}:30:00.000Z`),
|
||||
}));
|
||||
}
|
||||
|
||||
describe("metric plausibility domain", () => {
|
||||
it("admits a real pulse and refuses one no heart produces", () => {
|
||||
expect(isPlausibleMetricValue("PULSE", 68)).toBe(true);
|
||||
expect(isPlausibleMetricValue("PULSE", 20)).toBe(true);
|
||||
expect(isPlausibleMetricValue("PULSE", 300)).toBe(true);
|
||||
expect(isPlausibleMetricValue("PULSE", 111287531.01)).toBe(false);
|
||||
expect(isPlausibleMetricValue("PULSE", 36016.75)).toBe(false);
|
||||
expect(isPlausibleMetricValue("PULSE", 19)).toBe(false);
|
||||
});
|
||||
|
||||
it("passes a metric with no declared range and refuses a non-finite value", () => {
|
||||
expect(isPlausibleMetricValue("SOMETHING_UNDECLARED", 12345)).toBe(true);
|
||||
expect(isPlausibleMetricValue("PULSE", Number.NaN)).toBe(false);
|
||||
expect(isPlausibleMetricValue("PULSE", Number.POSITIVE_INFINITY)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("per-day means behind the personal band", () => {
|
||||
it("keeps the day mean on the real readings when one sample is impossible", () => {
|
||||
const rows = pulseDay("2026-08-01", [66, 70, 111287531.01, 68]);
|
||||
|
||||
const [point] = dayMeansFromRows(rows, "PULSE");
|
||||
|
||||
expect(point.day).toBe("2026-08-01");
|
||||
expect(point.mean).toBeCloseTo(68, 6);
|
||||
});
|
||||
|
||||
it("drops a day whose every reading is impossible rather than inventing one", () => {
|
||||
const rows = [
|
||||
...pulseDay("2026-08-01", [66, 70, 68]),
|
||||
...pulseDay("2026-08-02", [36016.75, 111287531.01]),
|
||||
];
|
||||
|
||||
const points = dayMeansFromRows(rows, "PULSE");
|
||||
|
||||
expect(points.map((p) => p.day)).toEqual(["2026-08-01"]);
|
||||
});
|
||||
|
||||
it("holds the band inside the metric's own domain across a poisoned month", () => {
|
||||
const rows: { value: number; measuredAt: Date }[] = [];
|
||||
for (let d = 1; d <= 28; d += 1) {
|
||||
const day = `2026-08-${String(d).padStart(2, "0")}`;
|
||||
// A steady resting pulse with a normal day-to-day wobble.
|
||||
rows.push(...pulseDay(day, [64 + (d % 5), 70 + (d % 3), 67]));
|
||||
}
|
||||
// Three days the provider decoded wrong.
|
||||
rows.push(...pulseDay("2026-08-05", [36016.75]));
|
||||
rows.push(...pulseDay("2026-08-17", [111287531.01]));
|
||||
rows.push(...pulseDay("2026-08-26", [98211.5]));
|
||||
|
||||
const band = buildBaselineBand(
|
||||
dayMeansFromRows(rows, "PULSE").map((p) => p.mean),
|
||||
"PULSE",
|
||||
);
|
||||
|
||||
expect(band).not.toBeNull();
|
||||
expect(band!.center).toBeGreaterThan(40);
|
||||
expect(band!.center).toBeLessThan(120);
|
||||
expect(band!.low).toBeGreaterThan(20);
|
||||
expect(band!.high).toBeLessThan(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the latest-day mean the deviation line reads", () => {
|
||||
it("means only the real readings of the most recent day", () => {
|
||||
const rows = [
|
||||
...pulseDay("2026-08-01", [66, 70, 68]),
|
||||
...pulseDay("2026-08-02", [72, 111287531.01, 74]),
|
||||
];
|
||||
|
||||
const latest = latestDayMeanFromRows(rows, "PULSE", TZ);
|
||||
|
||||
expect(latest).not.toBeNull();
|
||||
expect(latest!.day).toBe("2026-08-02");
|
||||
expect(latest!.value).toBeCloseTo(73, 6);
|
||||
});
|
||||
|
||||
it("reports nothing when the latest day holds no plausible reading", () => {
|
||||
const rows = pulseDay("2026-08-02", [36016.75, 111287531.01]);
|
||||
|
||||
expect(latestDayMeanFromRows(rows, "PULSE", TZ)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,10 @@ import type { MeasurementType, PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getAgeFromDateOfBirth } from "@/lib/analytics/pulse-targets";
|
||||
import { toProfileSex } from "@/lib/profile/sex";
|
||||
import { clampDerivedLowerBound } from "@/lib/measurements/value-domain";
|
||||
import {
|
||||
clampDerivedLowerBound,
|
||||
isPlausibleMetricValue,
|
||||
} from "@/lib/measurements/value-domain";
|
||||
import type { ProfileSex } from "@/lib/profile/sex";
|
||||
import {
|
||||
probeRollupCoverage,
|
||||
@@ -209,6 +212,39 @@ export function buildBaselineBand(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Group raw readings into per-day means, keeping only the values that sit
|
||||
* inside the metric's own plausibility domain.
|
||||
*
|
||||
* Pure, and exported because this is the arithmetic the band rests on: a
|
||||
* single stored value the application itself declares impossible drags the
|
||||
* day's mean with it, the median then carries it into the band, and the
|
||||
* personal "usual range" reads in the tens of thousands. Filtering here rather
|
||||
* than at the edge of the card keeps the number wrong in exactly one place —
|
||||
* nowhere — instead of right on one surface and wrong on the next.
|
||||
*
|
||||
* A day whose every reading is implausible produces no point at all. That is
|
||||
* the honest outcome: the day contributed no measurement, and an absent day is
|
||||
* something the coverage floor already knows how to report.
|
||||
*/
|
||||
export function dayMeansFromRows(
|
||||
rows: readonly { value: number; measuredAt: Date }[],
|
||||
type: MeasurementType,
|
||||
): DayMeanPoint[] {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
if (!isPlausibleMetricValue(type, row.value)) continue;
|
||||
const day = row.measuredAt.toISOString().slice(0, 10);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += row.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -237,11 +273,19 @@ export async function readDayMeanSeries(
|
||||
resolved.granularity === "DAY" &&
|
||||
resolved.rows.length > 0
|
||||
) {
|
||||
const points = resolved.rows.map((row) => ({
|
||||
day: row.bucketStart.toISOString().slice(0, 10),
|
||||
mean: row.mean,
|
||||
}));
|
||||
return { points, source: "DAY" };
|
||||
// A bucket mean is only as good as the rows it averaged. The rollup
|
||||
// writer aggregates whatever is stored, so a reading outside the
|
||||
// metric's plausibility domain rides straight through into the bucket
|
||||
// and from there into the band. Drop those buckets on the same rule the
|
||||
// live read applies to its rows, so the two paths agree about what
|
||||
// counts as a measurement.
|
||||
const points = resolved.rows
|
||||
.filter((row) => isPlausibleMetricValue(type, row.mean))
|
||||
.map((row) => ({
|
||||
day: row.bucketStart.toISOString().slice(0, 10),
|
||||
mean: row.mean,
|
||||
}));
|
||||
if (points.length > 0) return { points, source: "DAY" };
|
||||
}
|
||||
// Coverage probe said "has buckets" but the window resolved to a
|
||||
// coarser tier or zero DAY rows → fall through to the live read so
|
||||
@@ -264,17 +308,10 @@ export async function readDayMeanSeries(
|
||||
if (rows.length === 0) {
|
||||
return { points: [], source: "none" };
|
||||
}
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const day = row.measuredAt.toISOString().slice(0, 10);
|
||||
const acc = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
acc.sum += row.value;
|
||||
acc.count += 1;
|
||||
byDay.set(day, acc);
|
||||
const points = dayMeansFromRows(rows, type);
|
||||
if (points.length === 0) {
|
||||
return { points: [], source: "none" };
|
||||
}
|
||||
const points: DayMeanPoint[] = [...byDay.entries()]
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||
.map(([day, acc]) => ({ day, mean: acc.sum / acc.count }));
|
||||
return { points, source: "live" };
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,11 @@ import { computeVitalsBaseline, type BaselineProfile } from "./baseline";
|
||||
import { VITALS_BASELINE_TYPES } from "./registry";
|
||||
import type { Derived, DerivedProvenanceSource } from "./types";
|
||||
import { resolveRestMode } from "@/lib/illness/rest-mode";
|
||||
import { isPlausibleMetricValue } from "@/lib/measurements/value-domain";
|
||||
import {
|
||||
dayKeyAgeInDays,
|
||||
isCurrentForTodayClaim,
|
||||
} from "@/lib/insights/measurement-freshness";
|
||||
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -69,6 +74,16 @@ export interface VitalDeviation {
|
||||
outside: boolean;
|
||||
/** "above" / "below" the band, or "in" when inside. */
|
||||
direction: "above" | "below" | "in";
|
||||
/**
|
||||
* Whole days between the reading this standing was computed from and the
|
||||
* caller's own local today. `0` is today, `1` yesterday.
|
||||
*
|
||||
* The value used to be presented with no age at all, so a vital last taken
|
||||
* five days ago was narrated as "outside its range today" on a day that
|
||||
* carried no reading. Consumers making a present-tense claim check this;
|
||||
* `contributing` below already does.
|
||||
*/
|
||||
daysAgo: number;
|
||||
}
|
||||
|
||||
export interface CoincidentDeviationValue {
|
||||
@@ -101,6 +116,7 @@ export function classifyDeviation(
|
||||
low: number,
|
||||
high: number,
|
||||
center: number,
|
||||
daysAgo: number = 0,
|
||||
): VitalDeviation {
|
||||
const above = value > high;
|
||||
const below = value < low;
|
||||
@@ -112,6 +128,7 @@ export function classifyDeviation(
|
||||
high,
|
||||
outside: above || below,
|
||||
direction: above ? "above" : below ? "below" : "in",
|
||||
daysAgo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,15 +171,36 @@ async function readLatestDayMean(
|
||||
take: MAX_LATEST_DAY_ROWS,
|
||||
select: { value: true, measuredAt: true },
|
||||
});
|
||||
if (rows.length === 0) return null;
|
||||
return latestDayMeanFromRows(rows, type, tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pure half of the read above: pick the most recent day present in the
|
||||
* rows and mean that day's readings.
|
||||
*
|
||||
* Readings outside the metric's declared plausibility domain are not means
|
||||
* material. This side of the comparison had no gate at all, so one impossible
|
||||
* stored value became "your pulse today" and was then held against a band the
|
||||
* same value had already inflated — the two numbers in the sentence were both
|
||||
* wrong, and wrong by different amounts, which is why the line read as
|
||||
* nonsense rather than as an exaggeration. A day left with no plausible
|
||||
* reading contributes nothing and the vital drops out of the comparison.
|
||||
*/
|
||||
export function latestDayMeanFromRows(
|
||||
rows: readonly { value: number; measuredAt: Date }[],
|
||||
type: MeasurementType,
|
||||
tz: string,
|
||||
): { value: number; day: string } | null {
|
||||
const usable = rows.filter((r) => isPlausibleMetricValue(type, r.value));
|
||||
if (usable.length === 0) return null;
|
||||
// Derive the most-recent day defensively (do not assume the DB ordering)
|
||||
// so the "today" reading is always the genuine latest, then mean its rows.
|
||||
let day = "";
|
||||
for (const r of rows) {
|
||||
for (const r of usable) {
|
||||
const d = userDayKey(r.measuredAt, tz);
|
||||
if (d > day) day = d;
|
||||
}
|
||||
const sameDay = rows.filter((r) => userDayKey(r.measuredAt, tz) === day);
|
||||
const sameDay = usable.filter((r) => userDayKey(r.measuredAt, tz) === day);
|
||||
return {
|
||||
value: sameDay.reduce((s, r) => s + r.value, 0) / sameDay.length,
|
||||
day,
|
||||
@@ -188,6 +226,9 @@ export async function computeCoincidentDeviation(
|
||||
let latestDay = "";
|
||||
let anyDaySource = false;
|
||||
let maxHistoryDays = 0;
|
||||
// The reader's own calendar day — the anchor every vital's age is measured
|
||||
// against, so "today" means the day the reader is having.
|
||||
const todayKey = userDayKey(now, tz);
|
||||
|
||||
// Fan the baseline engine across the supported vitals. Each baseline read
|
||||
// shares the one coverage probe (no per-vital re-probe).
|
||||
@@ -213,6 +254,7 @@ export async function computeCoincidentDeviation(
|
||||
baseline.value.low,
|
||||
baseline.value.high,
|
||||
baseline.value.center,
|
||||
dayKeyAgeInDays(latest.day, todayKey) ?? Number.POSITIVE_INFINITY,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -241,7 +283,16 @@ export async function computeCoincidentDeviation(
|
||||
});
|
||||
}
|
||||
|
||||
const contributing = vitals.filter((v) => v.outside);
|
||||
// Everything this flag says is about TODAY — the card is literally called
|
||||
// "signals of the day" and every line it renders is present tense. A vital
|
||||
// whose freshest reading is older than that cannot contribute to it: the
|
||||
// reading describes the day it was taken on, and saying otherwise tells the
|
||||
// reader something about a day on which nothing was measured. Stale vitals
|
||||
// stay in `vitals` with their age, so the anatomy view still lists them and
|
||||
// nothing silently disappears.
|
||||
const contributing = vitals.filter(
|
||||
(v) => v.outside && isCurrentForTodayClaim(v.daysAgo),
|
||||
);
|
||||
const fired = contributing.length >= COINCIDENT_FIRE_THRESHOLD;
|
||||
|
||||
// v1.18.1 P4 — when the flag fired, reframe it as illness-explained if an
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { VitalDeviation } from "@/lib/insights/derived/coincident-deviation";
|
||||
import type { ChangepointSignal } from "@/lib/insights/derived/changepoint";
|
||||
import { isCurrentForTodayClaim } from "@/lib/insights/measurement-freshness";
|
||||
|
||||
/** One vital sitting outside its personal band today. */
|
||||
export interface HealthStatusDeviation {
|
||||
@@ -58,7 +59,15 @@ export function summariseHealthStatus(
|
||||
shifts: readonly ChangepointSignal[],
|
||||
): HealthStatusSummary {
|
||||
const deviations: HealthStatusDeviation[] = vitals
|
||||
.filter((v) => v.outside && v.direction !== "in")
|
||||
// Present tense, so present readings only. "Your pulse is above your usual
|
||||
// range" describes now; a reading from last week describes last week, and
|
||||
// the card carried no date to say which. A vital whose freshest reading is
|
||||
// outside the claim window is left out rather than dressed as current —
|
||||
// the changepoint shifts below are dated by construction and unaffected.
|
||||
.filter(
|
||||
(v) =>
|
||||
v.outside && v.direction !== "in" && isCurrentForTodayClaim(v.daysAgo),
|
||||
)
|
||||
.map((v) => ({
|
||||
type: v.type,
|
||||
value: v.value,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* How old a reading may be before "today" stops being true about it.
|
||||
*
|
||||
* Several surfaces make present-tense statements out of the freshest stored
|
||||
* reading: the day-signals card ("one of your vitals is outside its usual
|
||||
* range today"), the baseline-drift card ("your pulse is above your usual
|
||||
* range"), and the narrated hero line on the dashboard. Every one of them was
|
||||
* handed "the latest value" with no notion of when it was taken, so a reading
|
||||
* from last week was narrated as this morning's — the reader is told something
|
||||
* about a day on which nothing was measured at all.
|
||||
*
|
||||
* The answer belongs beside the data, not beside each sentence. A reading is
|
||||
* material for a claim about today when it comes from today or from yesterday;
|
||||
* anything older is history, and history is stated with its date.
|
||||
*
|
||||
* The window is deliberately one day rather than zero. A vital taken at 23:40
|
||||
* and read at 00:10 is the same reading it was thirty minutes earlier, and a
|
||||
* morning weigh-in narrated that evening is still current. Two days is not:
|
||||
* by then a day has passed with no reading in it, and the sentence would be
|
||||
* describing a gap.
|
||||
*/
|
||||
|
||||
/** Whole days of age a reading may carry and still support a present claim. */
|
||||
export const TODAY_CLAIM_MAX_AGE_DAYS = 1;
|
||||
|
||||
/**
|
||||
* Whether a reading of this age may back a present-tense statement.
|
||||
*
|
||||
* An absent, negative or non-finite age answers `false`. A missing age is not
|
||||
* a fresh one, and it is exactly the case that produced the wrong sentence:
|
||||
* nothing knew how old the value was, so everything assumed it was new.
|
||||
*/
|
||||
export function isCurrentForTodayClaim(
|
||||
daysAgo: number | null | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
typeof daysAgo === "number" &&
|
||||
Number.isFinite(daysAgo) &&
|
||||
daysAgo >= 0 &&
|
||||
daysAgo <= TODAY_CLAIM_MAX_AGE_DAYS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole days between two `YYYY-MM-DD` local-day keys (`then` before `today`).
|
||||
*
|
||||
* Both keys are already resolved in the reader's own timezone by the caller,
|
||||
* so the arithmetic is plain calendar subtraction — parsing them as UTC
|
||||
* midnights keeps it free of any second zone conversion. A malformed key
|
||||
* answers `null` rather than a fabricated distance.
|
||||
*/
|
||||
export function dayKeyAgeInDays(then: string, today: string): number | null {
|
||||
const a = Date.parse(`${then}T00:00:00.000Z`);
|
||||
const b = Date.parse(`${today}T00:00:00.000Z`);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
||||
return Math.round((b - a) / 86_400_000);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Where the entry form asks for one unit and the column stores another.
|
||||
*
|
||||
* This is not the metric/imperial display transform in `display-transform.ts`:
|
||||
* that one follows a preference and is the identity for anyone on metric. This
|
||||
* one holds regardless of preference, because the storage unit was chosen for
|
||||
* the sync sources rather than for the person typing.
|
||||
*
|
||||
* Sleep is the case. `SLEEP_DURATION` is stored in minutes — every provider
|
||||
* delivers minutes, and a per-stage segment is only a few of them — while a
|
||||
* person logging a night by hand thinks in hours and the field says so. The
|
||||
* form sent the typed number straight through, so a night entered as 7.5 was
|
||||
* stored as seven and a half MINUTES and read back on the list as eight
|
||||
* minutes of sleep. Nothing rejected it: seven and a half minutes is inside
|
||||
* the column's plausibility band, because a single sleep STAGE really can be
|
||||
* that short.
|
||||
*
|
||||
* The storage unit stays minutes. The conversion belongs at the entry
|
||||
* boundary, in one place, so a second surface that starts taking sleep by hand
|
||||
* has one answer to reach for rather than its own.
|
||||
*
|
||||
* Only the direction that exists is here. An inverse and a "does this type
|
||||
* convert" predicate shipped alongside it at first, written for an editing
|
||||
* surface that does not exist and has no ticket; they were nothing but two
|
||||
* more things to keep true. The git history holds them if that surface ever
|
||||
* arrives.
|
||||
*/
|
||||
|
||||
export const MINUTES_PER_HOUR = 60;
|
||||
|
||||
/**
|
||||
* Entry unit → canonical unit multiplier, per measurement type. A type absent
|
||||
* from this table enters in the unit it is stored in.
|
||||
*/
|
||||
const ENTRY_TO_CANONICAL_FACTOR: Readonly<Record<string, number>> = {
|
||||
SLEEP_DURATION: MINUTES_PER_HOUR,
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a value as typed in the entry form to the unit the column stores.
|
||||
*
|
||||
* The result is rounded to the nearest whole canonical unit for a converted
|
||||
* type: 7.3 hours is 438 minutes, and floating-point multiplication would
|
||||
* otherwise store 437.99999999999994 for it. An unconverted type is returned
|
||||
* untouched, so no existing metric changes shape.
|
||||
*/
|
||||
export function entryValueToCanonical(type: string, entered: number): number {
|
||||
const factor = ENTRY_TO_CANONICAL_FACTOR[type];
|
||||
if (factor === undefined || !Number.isFinite(entered)) return entered;
|
||||
return Math.round(entered * factor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a number out of an entry field, accepting the decimal comma.
|
||||
*
|
||||
* Most of the application's readers are German, and "7,5" is how they write
|
||||
* seven and a half. `parseFloat` reads that as 7 and discards the rest without
|
||||
* complaint — the worst shape of wrong, since the entry looks accepted.
|
||||
* Returns `null` for anything that is not a number, so the caller decides what
|
||||
* an empty field means rather than inheriting a `NaN`.
|
||||
*/
|
||||
export function parseDecimalEntry(raw: string): number | null {
|
||||
const trimmed = raw.trim().replace(",", ".");
|
||||
if (trimmed === "") return null;
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
@@ -81,6 +81,36 @@ export function isNonNegativeMetric(type: string | null | undefined): boolean {
|
||||
return range !== undefined && range.min >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the value sits inside the metric's declared plausibility domain —
|
||||
* the same `VALUE_RANGES` band every interactive, import, MCP and Telegram
|
||||
* write path validates against before storing a reading.
|
||||
*
|
||||
* The derivation layer needs the question answered too, and for the opposite
|
||||
* direction. A stored value outside the domain is one the application has
|
||||
* already declared impossible, so it is not a reading: it is a provider glitch,
|
||||
* a unit-decode slip, or a row from a writer that never had the gate. Folding
|
||||
* it into a mean or a median makes the statistic say something the data never
|
||||
* did — a personal pulse band in the tens of thousands, and a "your pulse is
|
||||
* above your usual range" line built on a number no heart has ever produced.
|
||||
* A statistic that quietly digests an impossible input is worse than one that
|
||||
* declines to, because it looks like an answer.
|
||||
*
|
||||
* Unknown types and non-finite values answer `false`-safe in opposite ways on
|
||||
* purpose: an absent range is an absent fact, so the value passes; a NaN is
|
||||
* never an input to anything.
|
||||
*/
|
||||
export function isPlausibleMetricValue(
|
||||
type: string | null | undefined,
|
||||
value: number,
|
||||
): boolean {
|
||||
if (!Number.isFinite(value)) return false;
|
||||
if (!type) return true;
|
||||
const range = VALUE_RANGES[type];
|
||||
if (range === undefined) return true;
|
||||
return value >= range.min && value <= range.max;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when EVERY named metric forbids negative values (and at least one was
|
||||
* named). Used by the chart, which can paint several series in one axis: a
|
||||
|
||||
@@ -181,6 +181,10 @@ const cycleCalendarDayDto = z.object({
|
||||
isFertileWindow: z.boolean(),
|
||||
isPredictedOvulation: z.boolean(),
|
||||
isPeriodLogged: z.boolean(),
|
||||
isCycleStart: z.boolean().meta({
|
||||
description:
|
||||
"Whether a logged cycle opens on this day. Deleting this day's log removes that cycle start with it.",
|
||||
}),
|
||||
flow: flowLevelEnum.nullable(),
|
||||
hasSymptoms: z.boolean(),
|
||||
confidence: z.number(),
|
||||
|
||||
@@ -180,3 +180,74 @@ describe("buildWorkoutHrSeries", () => {
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `durationSec` is a denormalised DURATION, and not every source reports
|
||||
* the elapsed one: Strava sends `moving_time` while the row's `endedAt`
|
||||
* is `startedAt + elapsed_time`. A ride with 20 minutes of stops
|
||||
* therefore carries a `durationSec` 20 minutes shorter than its own
|
||||
* wall-clock span, and folding the curve against it drops every reading
|
||||
* after the moving-time mark — the tail of the session vanishes with no
|
||||
* gap, no marker, nothing that reads as missing.
|
||||
*/
|
||||
describe("buildWorkoutHrSeries — sessions whose duration is not their span", () => {
|
||||
const SPAN_SEC = 3600;
|
||||
const MOVING_SEC = 2400;
|
||||
const strava = {
|
||||
userId: "u1",
|
||||
startedAt: START,
|
||||
endedAt: new Date(startMs + SPAN_SEC * 1000),
|
||||
durationSec: MOVING_SEC,
|
||||
now: new Date(startMs + SPAN_SEC * 1000 + 1000),
|
||||
};
|
||||
|
||||
it("carries a stored series to the end of the session, not to the moving-time mark", async () => {
|
||||
const stored = Array.from({ length: SPAN_SEC / 15 }, (_, i) =>
|
||||
storedSample(i * 15, 120 + (i % 20)),
|
||||
);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
storedSamples: stored,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
const lastTSec = result!.points[result!.points.length - 1].tSec;
|
||||
// The final bucket sits within one bucket width of the session end.
|
||||
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - result!.bucketSec);
|
||||
expect(lastTSec).toBeLessThan(SPAN_SEC);
|
||||
});
|
||||
|
||||
it("carries a pulse-window series past the moving-time mark too", async () => {
|
||||
const rows = Array.from({ length: SPAN_SEC / 20 }, (_, i) => ({
|
||||
value: 125 + (i % 9),
|
||||
measuredAt: new Date(startMs + i * 20_000),
|
||||
externalId: null,
|
||||
}));
|
||||
findMany.mockResolvedValue(rows);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
storedSamples: null,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source).toBe("pulse_window");
|
||||
const lastTSec = result!.points[result!.points.length - 1].tSec;
|
||||
expect(lastTSec).toBeGreaterThan(MOVING_SEC);
|
||||
// The rows sit 20 s apart, so the final one can miss the very last
|
||||
// bucket; landing within a sample interval of the end is the claim.
|
||||
expect(lastTSec).toBeGreaterThanOrEqual(SPAN_SEC - 20 - result!.bucketSec);
|
||||
});
|
||||
|
||||
it("keeps the duration as the floor when the row carries no usable span", async () => {
|
||||
const stored = Array.from({ length: 40 }, (_, i) =>
|
||||
storedSample(i * 15, 130),
|
||||
);
|
||||
const result = await buildWorkoutHrSeries({
|
||||
...strava,
|
||||
// A row whose end is not after its start: the span is unusable, so
|
||||
// the denormalised duration has to stay the fold window.
|
||||
endedAt: START,
|
||||
storedSamples: stored,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.bucketSec).toBe(adaptiveBucketSec(MOVING_SEC));
|
||||
});
|
||||
});
|
||||
@@ -82,27 +82,55 @@ function median(values: number[]): number {
|
||||
: sorted[mid];
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's own length on the wall clock.
|
||||
*
|
||||
* `durationSec` is a denormalised DURATION, and not every source reports
|
||||
* the elapsed one — Strava sends `moving_time` while the row's `endedAt`
|
||||
* is `startedAt + elapsed_time` (`src/lib/strava/client.ts`). A ride with
|
||||
* twenty minutes of stops therefore carries a `durationSec` twenty
|
||||
* minutes shorter than its own span, and a curve folded against it ends
|
||||
* at the moving-time mark with no gap and no marker: the tail of the
|
||||
* session simply is not there. The curve covers the session, so the span
|
||||
* is what it folds over.
|
||||
*
|
||||
* The duration stays the floor. A row whose `endedAt` is not after its
|
||||
* `startedAt` (a manual entry saved with one timestamp, a provider that
|
||||
* shipped an end it could not resolve) has no usable span, and every
|
||||
* source that computes `endedAt` from the duration lands on the same
|
||||
* number either way.
|
||||
*/
|
||||
export function sessionSpanSec(
|
||||
startedAt: Date,
|
||||
endedAt: Date,
|
||||
durationSec: number,
|
||||
): number {
|
||||
const floor = Math.max(0, durationSec);
|
||||
const span = Math.round((endedAt.getTime() - startedAt.getTime()) / 1000);
|
||||
return Number.isFinite(span) && span > floor ? span : floor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold raw HR samples into session-grain buckets. Samples outside the
|
||||
* session window `[0, durationSec)` are dropped from the curve (they
|
||||
* only widen the DB query so a boundary reading still lands in bucket
|
||||
* 0 / the last bucket). Empty buckets stay as gaps — no interpolation.
|
||||
* session window `[0, spanSec)` are dropped from the curve (they only
|
||||
* widen the DB query so a boundary reading still lands in bucket 0 / the
|
||||
* last bucket). Empty buckets stay as gaps — no interpolation.
|
||||
* Pure; exported for unit testing.
|
||||
*/
|
||||
export function foldHrBuckets(
|
||||
samples: readonly RawHrSample[],
|
||||
startMs: number,
|
||||
durationSec: number,
|
||||
spanSec: number,
|
||||
bucketSec: number,
|
||||
): { points: HrSeriesPoint[]; bucketCount: number; medianDensity: number } {
|
||||
const bucketCount = Math.max(1, Math.ceil(durationSec / bucketSec));
|
||||
const bucketCount = Math.max(1, Math.ceil(spanSec / bucketSec));
|
||||
const acc = new Map<
|
||||
number,
|
||||
{ sum: number; min: number; max: number; n: number }
|
||||
>();
|
||||
for (const s of samples) {
|
||||
const rel = (s.tMs - startMs) / 1000;
|
||||
if (rel < 0 || rel >= durationSec) continue;
|
||||
if (rel < 0 || rel >= spanSec) continue;
|
||||
const idx = Math.floor(rel / bucketSec);
|
||||
const bucket = acc.get(idx);
|
||||
if (bucket) {
|
||||
@@ -171,7 +199,11 @@ export async function buildWorkoutHrSeries(
|
||||
const { userId, startedAt, endedAt, durationSec, storedSamples } = input;
|
||||
const now = input.now ?? new Date();
|
||||
const startMs = startedAt.getTime();
|
||||
const bucketSec = adaptiveBucketSec(durationSec);
|
||||
// Fold over the session's span, not its duration — see `sessionSpanSec`.
|
||||
// The bucket width tracks the span too, so a stop-heavy ride keeps the
|
||||
// same on-screen point density as a continuous one of the same length.
|
||||
const spanSec = sessionSpanSec(startedAt, endedAt, durationSec);
|
||||
const bucketSec = adaptiveBucketSec(spanSec);
|
||||
|
||||
// 1. Stored series — the native, highest-fidelity path.
|
||||
const stored = parseStoredSamples(storedSamples);
|
||||
@@ -179,7 +211,7 @@ export async function buildWorkoutHrSeries(
|
||||
const { points, medianDensity } = foldHrBuckets(
|
||||
stored,
|
||||
startMs,
|
||||
durationSec,
|
||||
spanSec,
|
||||
bucketSec,
|
||||
);
|
||||
if (points.length >= 2) {
|
||||
@@ -222,7 +254,7 @@ export async function buildWorkoutHrSeries(
|
||||
const { points, bucketCount, medianDensity } = foldHrBuckets(
|
||||
raw,
|
||||
startMs,
|
||||
durationSec,
|
||||
spanSec,
|
||||
bucketSec,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* A period logged as flow has to count as a period.
|
||||
*
|
||||
* `MenstrualCycle` rows were only ever written by the one-tap period
|
||||
* boundary. Everything else that records bleeding — the log sheet's flow
|
||||
* chips, the bulk drain, an Apple Health export full of menstrual-flow
|
||||
* samples — wrote a `CycleDayLog` and nothing more, and the engine reads
|
||||
* cycle rows. So a person could log every period they had and the module
|
||||
* still held that none had ever happened: no cycle length, no forecast, an
|
||||
* empty chart, and an offer to log a first period. An Apple Health user who
|
||||
* never found the one-tap button had the whole module dead.
|
||||
*
|
||||
* The rule the fix applies is deliberately conservative, and these cases are
|
||||
* as much about what must NOT open a cycle as what must. Spotting does not
|
||||
* (it is as often the tail or the herald of a period as its first day), an
|
||||
* entry flagged as bleeding between periods does not, and a bleeding day
|
||||
* inside an existing cycle's plausible span does not — otherwise day three of
|
||||
* a period, or a forgotten day two, would each start a cycle of their own.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-cycle-flow-opens-integration-32-bytes-min-1234567890";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const USER_ID = "user-cycle-flow";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function loginAs(userId: string): Promise<void> {
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "cycle-flow",
|
||||
email: "cycle-flow@example.test",
|
||||
gender: "FEMALE",
|
||||
},
|
||||
});
|
||||
await loginAs(USER_ID);
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: unknown,
|
||||
): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Log one day through the real capture route. */
|
||||
async function logDay(
|
||||
date: string,
|
||||
fields: Record<string, unknown>,
|
||||
): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/cycle/day-logs/route");
|
||||
return POST(
|
||||
jsonRequest("/api/cycle/day-logs", "POST", {
|
||||
date,
|
||||
loggedAt: `${date}T08:00:00.000Z`,
|
||||
...fields,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The live cycle starts, oldest first. */
|
||||
async function cycleStarts(): Promise<string[]> {
|
||||
const rows = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true },
|
||||
});
|
||||
return rows.map((r) => r.startDate);
|
||||
}
|
||||
|
||||
describe("a bleeding day opens a cycle", () => {
|
||||
it("turns the first flow entry into a cycle the engine can see", async () => {
|
||||
const res = await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
});
|
||||
|
||||
it("does not open a second cycle for the rest of the same period", async () => {
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-05-05", { flow: "MEDIUM" });
|
||||
// Day three with day two missing: the gap must not read as a new period.
|
||||
await logDay("2026-05-07", { flow: "LIGHT" });
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
});
|
||||
|
||||
it("opens the next cycle when the next period arrives", async () => {
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
await logDay("2026-06-29", { flow: "MEDIUM" });
|
||||
|
||||
expect(await cycleStarts()).toEqual([
|
||||
"2026-05-04",
|
||||
"2026-06-01",
|
||||
"2026-06-29",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves spotting alone", async () => {
|
||||
await logDay("2026-05-04", { flow: "SPOTTING" });
|
||||
expect(await cycleStarts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves bleeding flagged as between periods alone", async () => {
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-05-20", {
|
||||
flow: "MEDIUM",
|
||||
intermenstrualBleeding: true,
|
||||
});
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
});
|
||||
|
||||
it("leaves a day with no flow alone", async () => {
|
||||
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
|
||||
expect(await cycleStarts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("re-anchors the neighbours of a back-dated bleed", async () => {
|
||||
// The same boundary work the one-tap start does: the inserted cycle takes
|
||||
// its end from the cycle after it, and the one before it closes.
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
await logDay("2026-07-01", { flow: "HEAVY" });
|
||||
// Remembered later, and earlier than both.
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
|
||||
const rows = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID, deletedAt: null },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: { startDate: true, endDate: true, lengthDays: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ startDate: "2026-05-04", endDate: "2026-05-31", lengthDays: 28 },
|
||||
{ startDate: "2026-06-01", endDate: "2026-06-30", lengthDays: 30 },
|
||||
{ startDate: "2026-07-01", endDate: null, lengthDays: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate the cycle the one-tap start already opened", async () => {
|
||||
const { POST } = await import("@/app/api/cycle/period/route");
|
||||
await POST(
|
||||
jsonRequest("/api/cycle/period", "POST", {
|
||||
action: "start",
|
||||
date: "2026-05-04",
|
||||
loggedAt: "2026-05-04T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
// The boundary write logs MEDIUM for the day; re-saving it richer must not
|
||||
// open a second cycle on the same date.
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
});
|
||||
|
||||
it("opens a cycle when an edit turns a day into a bleeding day", async () => {
|
||||
await logDay("2026-05-04", { basalBodyTempC: 36.4 });
|
||||
expect(await cycleStarts()).toEqual([]);
|
||||
|
||||
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-05-04" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
const res = await PATCH(
|
||||
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", {
|
||||
flow: "HEAVY",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: dayLog.id }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
});
|
||||
|
||||
it("removes the cycle again when an edit clears the flow", async () => {
|
||||
// The inverse of the case above. A cycle that can be created by an edit
|
||||
// and not undone by one leaves the record holding a period that nothing on
|
||||
// the day says happened.
|
||||
await logDay("2026-05-04", { flow: "HEAVY" });
|
||||
await logDay("2026-06-01", { flow: "HEAVY" });
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04", "2026-06-01"]);
|
||||
|
||||
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-06-01" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { PATCH } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
await PATCH(
|
||||
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "PATCH", { flow: null }),
|
||||
{ params: Promise.resolve({ id: dayLog.id }) },
|
||||
);
|
||||
|
||||
expect(await cycleStarts()).toEqual(["2026-05-04"]);
|
||||
// And the May cycle is open again, not left closed against a start that
|
||||
// no longer exists.
|
||||
const may = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: USER_ID, startDate: "2026-05-04", deletedAt: null },
|
||||
select: { endDate: true, lengthDays: true },
|
||||
});
|
||||
expect(may).toEqual({ endDate: null, lengthDays: null });
|
||||
});
|
||||
|
||||
it("gives the record a forecast and a cycle day it never had", async () => {
|
||||
// What the person actually sees. Four periods 28 days apart, logged as
|
||||
// flow and nothing else, and today inside the last one.
|
||||
const today = new Date();
|
||||
const dayKey = (offset: number): string => {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() + offset);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
};
|
||||
for (const offset of [-86, -58, -30, -2]) {
|
||||
await logDay(dayKey(offset), { flow: "HEAVY" });
|
||||
}
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/calendar/route");
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/cycle/calendar"),
|
||||
);
|
||||
const body = JSON.parse(await res.text());
|
||||
|
||||
expect(body.data.profile.cyclesObserved).toBe(3);
|
||||
expect(body.data.prediction).not.toBeNull();
|
||||
expect(body.data.verdict.state).toBe("IN_CYCLE");
|
||||
expect(body.data.verdict.dayOfCycle).toBe(3);
|
||||
expect(body.data.verdict.cycleStartDate).toBe(dayKey(-2));
|
||||
});
|
||||
});
|
||||
@@ -636,3 +636,110 @@ describe("sync/changes — cycle domains", () => {
|
||||
expect(cycleTombs[0].id).toBe(cycle.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("back-filling an entry months into the past", () => {
|
||||
/** `YYYY-MM-DD`, `days` from today, in the process zone the route resolves. */
|
||||
function dayKey(offset: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + offset);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
d.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
// Far enough back to fall outside the page's anchored ninety-day read, the
|
||||
// way an April entry does in August. Dates are relative so the case cannot
|
||||
// slide out of the window it is about as the calendar moves on.
|
||||
const BACK_DATED = dayKey(-111);
|
||||
|
||||
it("writes the day and hands it back on a window that covers it", async () => {
|
||||
await loginAs(FEMALE_USER_ID);
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const dayLogs = await import("@/app/api/cycle/day-logs/route");
|
||||
const written = await dayLogs.POST(
|
||||
jsonRequest("/api/cycle/day-logs", "POST", {
|
||||
date: BACK_DATED,
|
||||
flow: "HEAVY",
|
||||
loggedAt: `${BACK_DATED}T08:00:00.000Z`,
|
||||
}),
|
||||
);
|
||||
// The route accepts a past date: only the future is refused.
|
||||
expect(written.status).toBe(201);
|
||||
|
||||
// The row is really there, not just an accepted response.
|
||||
const row = await prisma.cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: FEMALE_USER_ID, date: BACK_DATED, deletedAt: null },
|
||||
});
|
||||
expect(row.flow).toBe("HEAVY");
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/calendar/route");
|
||||
// The page's own window. The entry exists and this read cannot show it,
|
||||
// which is the whole of what the client saw as "nothing was saved".
|
||||
const anchored = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/cycle/calendar?from=${dayKey(-90)}&to=${dayKey(180)}`,
|
||||
),
|
||||
);
|
||||
const anchoredDays = (await anchored.json()).data.days as {
|
||||
date: string;
|
||||
}[];
|
||||
expect(anchoredDays.some((d) => d.date === BACK_DATED)).toBe(false);
|
||||
|
||||
// A window over the month the grid is showing carries it, flow and all.
|
||||
const month = BACK_DATED.slice(0, 7);
|
||||
const lastDay = new Date(
|
||||
Number(month.slice(0, 4)),
|
||||
Number(month.slice(5, 7)),
|
||||
0,
|
||||
).getDate();
|
||||
const scoped = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`,
|
||||
),
|
||||
);
|
||||
expect(scoped.status).toBe(200);
|
||||
const scopedDays = (await scoped.json()).data.days as {
|
||||
date: string;
|
||||
flow: string | null;
|
||||
isPeriodLogged: boolean;
|
||||
}[];
|
||||
const hit = scopedDays.find((d) => d.date === BACK_DATED);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.flow).toBe("HEAVY");
|
||||
expect(hit?.isPeriodLogged).toBe(true);
|
||||
});
|
||||
|
||||
it("opens a back-dated cycle and marks the day as its start", async () => {
|
||||
await loginAs(FEMALE_USER_ID);
|
||||
const period = await import("@/app/api/cycle/period/route");
|
||||
const res = await period.POST(
|
||||
jsonRequest("/api/cycle/period", "POST", {
|
||||
action: "start",
|
||||
date: BACK_DATED,
|
||||
loggedAt: `${BACK_DATED}T08:00:00.000Z`,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const month = BACK_DATED.slice(0, 7);
|
||||
const lastDay = new Date(
|
||||
Number(month.slice(0, 4)),
|
||||
Number(month.slice(5, 7)),
|
||||
0,
|
||||
).getDate();
|
||||
const { GET } = await import("@/app/api/cycle/calendar/route");
|
||||
const scoped = await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/cycle/calendar?from=${month}-01&to=${month}-${lastDay}`,
|
||||
),
|
||||
);
|
||||
const days = (await scoped.json()).data.days as {
|
||||
date: string;
|
||||
isCycleStart: boolean;
|
||||
}[];
|
||||
// The flag the log sheet reads to say what a delete would take.
|
||||
expect(days.find((d) => d.date === BACK_DATED)?.isCycleStart).toBe(true);
|
||||
expect(days.filter((d) => d.isCycleStart)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Logging a period start and taking it back has to leave the record where it
|
||||
* found it.
|
||||
*
|
||||
* `action:"start"` does not only insert a cycle. It CLOSES the previous open
|
||||
* one — stamps its `endDate` and `lengthDays` from the new start — and, when
|
||||
* the start is back-filled between two existing cycles, re-anchors the one
|
||||
* after it too. Nothing undid any of that. Deleting the start left the
|
||||
* previous cycle standing as a closed torso, and a record whose last cycle is
|
||||
* closed has no open cycle to forecast from, so the prediction disappeared and
|
||||
* did not come back. There is no way to repair that from the app.
|
||||
*
|
||||
* So this is a round trip, not a delete test: it photographs every cycle row
|
||||
* before the start is logged, logs it, removes it, and asserts the rows are
|
||||
* back to the photograph field by field. Two removal paths reach a start —
|
||||
* deleting the cycle, and deleting the day-log that opened it from the log
|
||||
* sheet, which is the only one the web actually offers.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"test-hmac-key-cycle-round-trip-integration-32-bytes-min-1234567890";
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const USER_ID = "user-cycle-round-trip";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
async function loginAs(userId: string): Promise<void> {
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60 * 60 * 1000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "cycle-round-trip",
|
||||
email: "cycle-round-trip@example.test",
|
||||
gender: "FEMALE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function jsonRequest(
|
||||
path: string,
|
||||
method: string,
|
||||
body?: unknown,
|
||||
): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** The cycle boundary state, which is exactly what a start write moves. */
|
||||
interface CycleShape {
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
lengthDays: number | null;
|
||||
periodEndDate: string | null;
|
||||
deletedAt: boolean;
|
||||
}
|
||||
|
||||
async function cycleShapes(): Promise<CycleShape[]> {
|
||||
const rows = await getPrismaClient().menstrualCycle.findMany({
|
||||
where: { userId: USER_ID },
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
lengthDays: true,
|
||||
periodEndDate: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
startDate: r.startDate,
|
||||
endDate: r.endDate,
|
||||
lengthDays: r.lengthDays,
|
||||
periodEndDate: r.periodEndDate,
|
||||
deletedAt: r.deletedAt !== null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Only the cycles a read would see — a tombstone is not part of the record. */
|
||||
async function liveShapes(): Promise<CycleShape[]> {
|
||||
return (await cycleShapes()).filter((c) => !c.deletedAt);
|
||||
}
|
||||
|
||||
async function startPeriod(date: string): Promise<Response> {
|
||||
const { POST } = await import("@/app/api/cycle/period/route");
|
||||
return POST(
|
||||
jsonRequest("/api/cycle/period", "POST", {
|
||||
action: "start",
|
||||
date,
|
||||
loggedAt: `${date}T08:00:00.000Z`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The three months of history the maintainer's record carried. */
|
||||
async function seedHistory(): Promise<void> {
|
||||
await startPeriod("2026-05-04");
|
||||
await startPeriod("2026-06-01");
|
||||
await startPeriod("2026-07-01");
|
||||
}
|
||||
|
||||
describe("logging a period start and taking it back", () => {
|
||||
it("re-opens the previous cycle when the start's own cycle is deleted", async () => {
|
||||
await loginAs(USER_ID);
|
||||
await seedHistory();
|
||||
const before = await liveShapes();
|
||||
// The July cycle is open — that is the state the record has to return to.
|
||||
expect(before.at(-1)).toEqual({
|
||||
startDate: "2026-07-01",
|
||||
endDate: null,
|
||||
lengthDays: null,
|
||||
periodEndDate: null,
|
||||
deletedAt: false,
|
||||
});
|
||||
|
||||
await startPeriod("2026-08-07");
|
||||
const during = await liveShapes();
|
||||
expect(during).toHaveLength(4);
|
||||
// The July cycle really was closed by the new start, so the delete below
|
||||
// has something to undo.
|
||||
expect(during.at(-2)).toMatchObject({
|
||||
startDate: "2026-07-01",
|
||||
endDate: "2026-08-06",
|
||||
lengthDays: 37,
|
||||
});
|
||||
|
||||
const added = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: USER_ID, startDate: "2026-08-07" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
|
||||
const res = await DELETE(
|
||||
jsonRequest(`/api/cycle/cycles/${added.id}`, "DELETE"),
|
||||
{ params: Promise.resolve({ id: added.id }) },
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
expect(await liveShapes()).toEqual(before);
|
||||
});
|
||||
|
||||
it("re-opens the previous cycle when the start's day-log is deleted", async () => {
|
||||
// The path the web actually offers: the log sheet's delete button removes
|
||||
// the day-log, and the cycle that one tap opened has to go with it.
|
||||
await loginAs(USER_ID);
|
||||
await seedHistory();
|
||||
const before = await liveShapes();
|
||||
|
||||
await startPeriod("2026-08-07");
|
||||
expect(await liveShapes()).toHaveLength(4);
|
||||
|
||||
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-08-07" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
const res = await DELETE(
|
||||
jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"),
|
||||
{ params: Promise.resolve({ id: dayLog.id }) },
|
||||
);
|
||||
expect(res.status).toBe(204);
|
||||
|
||||
expect(await liveShapes()).toEqual(before);
|
||||
});
|
||||
|
||||
it("restores the boundary a back-filled start took from BOTH neighbours", async () => {
|
||||
// A start inserted between two existing cycles closes the one before it
|
||||
// and takes its own end from the one after. Removing it has to hand the
|
||||
// whole span back to the earlier cycle.
|
||||
await loginAs(USER_ID);
|
||||
await seedHistory();
|
||||
await startPeriod("2026-08-07");
|
||||
const before = await liveShapes();
|
||||
|
||||
await startPeriod("2026-06-15");
|
||||
const during = await liveShapes();
|
||||
expect(during).toHaveLength(5);
|
||||
expect(during[1]).toMatchObject({
|
||||
startDate: "2026-06-01",
|
||||
endDate: "2026-06-14",
|
||||
lengthDays: 14,
|
||||
});
|
||||
expect(during[2]).toMatchObject({
|
||||
startDate: "2026-06-15",
|
||||
endDate: "2026-06-30",
|
||||
lengthDays: 16,
|
||||
});
|
||||
|
||||
const inserted = await getPrismaClient().menstrualCycle.findFirstOrThrow({
|
||||
where: { userId: USER_ID, startDate: "2026-06-15" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/cycle/cycles/[id]/route");
|
||||
await DELETE(jsonRequest(`/api/cycle/cycles/${inserted.id}`, "DELETE"), {
|
||||
params: Promise.resolve({ id: inserted.id }),
|
||||
});
|
||||
|
||||
expect(await liveShapes()).toEqual(before);
|
||||
});
|
||||
|
||||
it("leaves a day-log that opened no cycle alone", async () => {
|
||||
// A plain entry on a day that is not a cycle start must not take a cycle
|
||||
// with it when it is deleted.
|
||||
await loginAs(USER_ID);
|
||||
await seedHistory();
|
||||
const before = await liveShapes();
|
||||
|
||||
const { POST } = await import("@/app/api/cycle/day-logs/route");
|
||||
await POST(
|
||||
jsonRequest("/api/cycle/day-logs", "POST", {
|
||||
date: "2026-07-14",
|
||||
flow: "SPOTTING",
|
||||
loggedAt: "2026-07-14T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-07-14" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), {
|
||||
params: Promise.resolve({ id: dayLog.id }),
|
||||
});
|
||||
|
||||
expect(await liveShapes()).toEqual(before);
|
||||
});
|
||||
|
||||
it("gives the forecast back", async () => {
|
||||
// The symptom the round trip exists for: after the start is taken back the
|
||||
// engine must forecast again, from the same open cycle as before.
|
||||
await loginAs(USER_ID);
|
||||
await seedHistory();
|
||||
|
||||
const { GET } = await import("@/app/api/cycle/calendar/route");
|
||||
const readForecast = async (): Promise<string | null> => {
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/cycle/calendar"),
|
||||
);
|
||||
const body = JSON.parse(await res.text());
|
||||
return body.data?.prediction?.nextPeriodStart ?? null;
|
||||
};
|
||||
|
||||
const before = await readForecast();
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
await startPeriod("2026-08-07");
|
||||
const dayLog = await getPrismaClient().cycleDayLog.findFirstOrThrow({
|
||||
where: { userId: USER_ID, date: "2026-08-07" },
|
||||
select: { id: true },
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/cycle/day-logs/[id]/route");
|
||||
await DELETE(jsonRequest(`/api/cycle/day-logs/${dayLog.id}`, "DELETE"), {
|
||||
params: Promise.resolve({ id: dayLog.id }),
|
||||
});
|
||||
|
||||
expect(await readForecast()).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The Insights baseline-drift read, driven as a route against real Postgres.
|
||||
*
|
||||
* The unit suite pins the arithmetic; this pins the pipe. A stored pulse value
|
||||
* the application declares impossible used to reach the card twice over — once
|
||||
* as "currently", once through the band it inflated — and the line read
|
||||
* "your pulse is above your usual range, currently 111,287,531.01 instead of
|
||||
* the usual 36,016.75". The shipped `GET` export answers here, so the readers
|
||||
* really have to apply the domain rather than a test's copy of it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
/** The two numbers off the report, kept verbatim so the case stays legible. */
|
||||
const IMPOSSIBLE_PULSE = 111287531.01;
|
||||
/** A raised but real pulse, so the card has a genuine deviation to report. */
|
||||
const REAL_RAISED_PULSE = 112;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser() {
|
||||
const suffix = `hs-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `hs-${suffix}`,
|
||||
email: `hs-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/** A fortnight of ordinary readings for one metric, one per day. */
|
||||
async function seedDaily(
|
||||
userId: string,
|
||||
type: "PULSE" | "WEIGHT",
|
||||
valueFor: (dayIndex: number) => number,
|
||||
) {
|
||||
const rows = [];
|
||||
for (let d = 14; d >= 1; d -= 1) {
|
||||
rows.push({
|
||||
userId,
|
||||
type,
|
||||
value: valueFor(d),
|
||||
unit: type === "PULSE" ? "bpm" : "kg",
|
||||
measuredAt: new Date(Date.now() - d * DAY_MS),
|
||||
source: "MANUAL" as const,
|
||||
});
|
||||
}
|
||||
await getPrismaClient().measurement.createMany({ data: rows });
|
||||
}
|
||||
|
||||
async function readHealthStatus() {
|
||||
const { GET } = await import("@/app/api/insights/health-status/route");
|
||||
const response = await (GET as (request: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/insights/health-status", {
|
||||
method: "GET",
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
present: boolean;
|
||||
deviations: {
|
||||
type: string;
|
||||
value: number;
|
||||
low: number;
|
||||
high: number;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("GET /api/insights/health-status — implausible stored values", () => {
|
||||
it("reports today's real pulse, not the impossible row beside it", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
// Two banded vitals — the coincident engine needs a pair before it reports.
|
||||
await seedDaily(user.id, "PULSE", (d) => 64 + (d % 5));
|
||||
await seedDaily(user.id, "WEIGHT", (d) => 80 + (d % 3) * 0.2);
|
||||
// Today: a genuinely raised pulse — so the card has something real to say —
|
||||
// and beside it one impossible reading, the shape a provider unit-decode
|
||||
// slip leaves behind.
|
||||
await getPrismaClient().measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: REAL_RAISED_PULSE,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(Date.now() - 120_000),
|
||||
source: "MANUAL",
|
||||
},
|
||||
{
|
||||
userId: user.id,
|
||||
type: "PULSE",
|
||||
value: IMPOSSIBLE_PULSE,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(Date.now() - 60_000),
|
||||
source: "MANUAL",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const data = await readHealthStatus();
|
||||
|
||||
const pulse = data.deviations.find((d) => d.type === "PULSE");
|
||||
// The deviation has to be there — otherwise this asserts nothing.
|
||||
expect(pulse).toBeDefined();
|
||||
expect(pulse!.value).toBeCloseTo(REAL_RAISED_PULSE, 6);
|
||||
expect(pulse!.high).toBeLessThan(300);
|
||||
expect(pulse!.low).toBeGreaterThanOrEqual(20);
|
||||
// And the impossible number itself never reaches the wire.
|
||||
expect(JSON.stringify(data)).not.toContain(String(IMPOSSIBLE_PULSE));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Backfilling a missed dose from the Verlauf ledger must record THAT slot.
|
||||
*
|
||||
* The ledger's "Genommen" action posts the displayed slot's own anchor as
|
||||
* `scheduledFor` with `takenAt = now`, meaning "record this dose". The route
|
||||
* resolved the slot by window band from `takenAt` instead, so a backfill of a
|
||||
* slot from an earlier day matched no band, fell through to the standalone
|
||||
* insert, and landed as an ad-hoc row anchored at the recording moment. The
|
||||
* named slot stayed unserved: the optimistic row flipped to taken, the
|
||||
* authoritative refetch read the slot as missed again, and the entry the user
|
||||
* had just made was gone from the day they made it for.
|
||||
*
|
||||
* Everything here goes through the shipped `POST` / `GET` exports so the real
|
||||
* `apiHandler`, the real auth resolution and the real Prisma writes answer.
|
||||
* The assertions are on the persisted row AND on the ledger the tab reads
|
||||
* back — the pipe between them is where the entry disappeared.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { localHmAsUtc } from "@/lib/tz/local-day";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser() {
|
||||
const suffix = `backfill-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `bf-${suffix}`,
|
||||
email: `bf-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: TZ,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot hours placed so neither dose window can contain the moment the test
|
||||
* runs. A band spans roughly [anchor − 1 h, anchor + 4 h]; +5 h and +12 h from
|
||||
* the current local hour keep "now" outside both on every clock, so the
|
||||
* assertions never depend on when the suite happens to run.
|
||||
*/
|
||||
function slotHours(): { a: number; b: number } {
|
||||
const hour = Number(
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TZ,
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date()),
|
||||
);
|
||||
return { a: (hour + 5) % 24, b: (hour + 12) % 24 };
|
||||
}
|
||||
|
||||
async function seedTwiceDaily(userId: string, hours: { a: number; b: number }) {
|
||||
const hh = (h: number) => `${String(h).padStart(2, "0")}:00`;
|
||||
const med = await getPrismaClient().medication.create({
|
||||
data: {
|
||||
userId,
|
||||
name: "Ramipril",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
startsOn: new Date(Date.now() - 30 * DAY_MS),
|
||||
// The ledger clamps its window floor to the medication's creation, so a
|
||||
// medication minted at test time would have no yesterday to read back.
|
||||
createdAt: new Date(Date.now() - 30 * DAY_MS),
|
||||
schedules: {
|
||||
create: {
|
||||
windowStart: hh(hours.a),
|
||||
windowEnd: hh(hours.b),
|
||||
timesOfDay: [hh(hours.a), hh(hours.b)],
|
||||
daysOfWeek: null,
|
||||
scheduleType: "SCHEDULED",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return med.id;
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function postIntake(medicationId: string, body: unknown) {
|
||||
const { POST } = await import("@/app/api/medications/[id]/intake/route");
|
||||
return (POST as Handler)(
|
||||
new NextRequest(`http://localhost/api/medications/${medicationId}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
}
|
||||
|
||||
async function readLedger(medicationId: string, from: Date, to: Date) {
|
||||
const { GET } = await import("@/app/api/medications/[id]/dose-history/route");
|
||||
const search = new URLSearchParams({
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString(),
|
||||
});
|
||||
const response = await (GET as Handler)(
|
||||
new NextRequest(
|
||||
`http://localhost/api/medications/${medicationId}/dose-history?${search}`,
|
||||
{ method: "GET" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: medicationId }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
data: {
|
||||
rows: {
|
||||
kind: string;
|
||||
at: string;
|
||||
status: string;
|
||||
intake: { takenAt: string | null } | null;
|
||||
}[];
|
||||
};
|
||||
};
|
||||
return body.data.rows;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/intake — backfilling a missed slot", () => {
|
||||
it("records the named slot from an earlier day, not an ad-hoc row at 'now'", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
|
||||
// Yesterday's first slot: long past its overdue tail, so the ledger reads
|
||||
// it as missed and the kebab offers "Genommen".
|
||||
const missedSlot = localHmAsUtc(
|
||||
new Date(Date.now() - DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
const response = await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: missedSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// The row lands ON the slot the user marked.
|
||||
const rows = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId, deletedAt: null },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.scheduledFor.getTime()).toBe(missedSlot.getTime());
|
||||
expect(rows[0]?.takenAt).not.toBeNull();
|
||||
expect(rows[0]?.skipped).toBe(false);
|
||||
});
|
||||
|
||||
it("the ledger reads the backfilled slot as taken, not missed", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
const missedSlot = localHmAsUtc(
|
||||
new Date(Date.now() - DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: missedSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const rows = await readLedger(
|
||||
medicationId,
|
||||
new Date(Date.now() - 3 * DAY_MS),
|
||||
new Date(),
|
||||
);
|
||||
const slotRow = rows.find(
|
||||
(r) => r.kind === "slot" && r.at === missedSlot.toISOString(),
|
||||
);
|
||||
expect(slotRow).toBeDefined();
|
||||
expect(slotRow?.status).toMatch(/^taken_/);
|
||||
expect(slotRow?.intake?.takenAt).not.toBeNull();
|
||||
// And no orphan ad-hoc row at the recording moment.
|
||||
expect(rows.filter((r) => r.kind === "ad_hoc")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still refuses to fill a slot that lies ahead of the take", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const hours = slotHours();
|
||||
const medicationId = await seedTwiceDaily(user.id, hours);
|
||||
|
||||
// Tomorrow's first slot — the "late-morning dose must not consume the
|
||||
// evening slot" invariant, stated across a day boundary so it holds
|
||||
// whatever the clock says when the suite runs.
|
||||
const futureSlot = localHmAsUtc(
|
||||
new Date(Date.now() + DAY_MS),
|
||||
TZ,
|
||||
hours.a,
|
||||
0,
|
||||
);
|
||||
|
||||
const response = await postIntake(medicationId, {
|
||||
skipped: false,
|
||||
scheduledFor: futureSlot.toISOString(),
|
||||
takenAt: new Date().toISOString(),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const onFutureSlot = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId,
|
||||
scheduledFor: futureSlot,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
expect(onFutureSlot).toHaveLength(0);
|
||||
|
||||
const all = await prisma.medicationIntakeEvent.findMany({
|
||||
where: { userId: user.id, medicationId, deletedAt: null },
|
||||
});
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0]?.scheduledFor.getTime()).toBeLessThan(futureSlot.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* A night typed as hours must come back as that many hours.
|
||||
*
|
||||
* The manual sleep field says hours and the column stores minutes, and the
|
||||
* form sent the typed number through unchanged: a 7.5-hour night was filed as
|
||||
* seven and a half MINUTES. Nothing rejected it, because a single sleep stage
|
||||
* really can be that short, so the entry looked accepted and the list showed
|
||||
* eight minutes of sleep.
|
||||
*
|
||||
* Both ends were already right on their own — the field's label and the list's
|
||||
* duration formatter. The assembly between them was what dropped the factor,
|
||||
* so this runs the whole of it: the form's conversion, the shipped POST, the
|
||||
* stored row, the shipped GET, and the formatter the list paints with.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import {
|
||||
entryValueToCanonical,
|
||||
parseDecimalEntry,
|
||||
} from "@/lib/measurements/entry-units";
|
||||
import { formatDurationMinutes } from "@/lib/i18n/duration";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser() {
|
||||
const suffix = `sleep-${counter++}`;
|
||||
return getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `sl-${suffix}`,
|
||||
email: `sl-${suffix}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
/** Exactly what the form's submit path does with what the reader typed. */
|
||||
function submitPathValue(type: string, typedInTheField: string): number {
|
||||
const typed = parseDecimalEntry(typedInTheField);
|
||||
expect(typed).not.toBeNull();
|
||||
return entryValueToCanonical(type, typed!);
|
||||
}
|
||||
|
||||
async function postMeasurement(body: unknown) {
|
||||
const { POST } = await import("@/app/api/measurements/route");
|
||||
return (POST as (request: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** The English catalogue's duration strings, as the list renders them. */
|
||||
const t = (key: string, params?: Record<string, string | number>): string =>
|
||||
key === "common.durationHoursMinutes"
|
||||
? `${params?.hours} h ${params?.minutes} min`
|
||||
: `${params?.minutes} min`;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("manual sleep entry — hours in, hours out", () => {
|
||||
it.each([
|
||||
["8", 480, "8 h 0 min"],
|
||||
["7.5", 450, "7 h 30 min"],
|
||||
// The decimal comma most of the readers actually type.
|
||||
["7,5", 450, "7 h 30 min"],
|
||||
["6,25", 375, "6 h 15 min"],
|
||||
])(
|
||||
"a night typed as %s reads back as %s minutes (%s)",
|
||||
async (typedInTheField, expectedMinutes, expectedLabel) => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
const measuredAt = new Date(Date.now() - 3_600_000).toISOString();
|
||||
|
||||
const response = await postMeasurement({
|
||||
type: "SLEEP_DURATION",
|
||||
value: submitPathValue("SLEEP_DURATION", typedInTheField),
|
||||
measuredAt,
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
// The stored row, in the canonical unit.
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: user.id, type: "SLEEP_DURATION", deletedAt: null },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.value).toBe(expectedMinutes);
|
||||
|
||||
// And what the reader is shown when the list paints that row.
|
||||
expect(formatDurationMinutes(rows[0]!.value, t)).toBe(expectedLabel);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves a metric with no entry conversion exactly as typed", async () => {
|
||||
const user = await makeUser();
|
||||
await signIn(user.id);
|
||||
|
||||
const response = await postMeasurement({
|
||||
type: "WEIGHT",
|
||||
value: submitPathValue("WEIGHT", "81,4"),
|
||||
measuredAt: new Date(Date.now() - 3_600_000).toISOString(),
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
const rows = await getPrismaClient().measurement.findMany({
|
||||
where: { userId: user.id, type: "WEIGHT", deletedAt: null },
|
||||
});
|
||||
expect(rows[0]?.value).toBe(81.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Integration suite for the heart-rate curve on `GET /api/workouts/{id}`.
|
||||
*
|
||||
* The curve is the one part of the workout-detail envelope that is
|
||||
* assembled from rows the workout does not own: when a session carries no
|
||||
* stored sensor stream, the server reconstructs it from the account's raw
|
||||
* `PULSE` measurements around the session. Two boundaries decide whether
|
||||
* that reconstruction is honest, and neither is provable from a unit test
|
||||
* with a mocked Prisma:
|
||||
*
|
||||
* - the WINDOW. A reading taken after the session ended is not part of
|
||||
* the session. The query pads by five minutes so a boundary reading
|
||||
* still lands in the first / last bucket, and the fold then clamps to
|
||||
* the session itself — a pad reading must not extend the curve past
|
||||
* the finish.
|
||||
* - the OWNER. The measurement read is scoped to the calling account.
|
||||
* Another account's pulse at the same wall-clock minute must not
|
||||
* enter this curve.
|
||||
*
|
||||
* Both run through the real route against a real Postgres, and the
|
||||
* fixture places a reading on the far side of each boundary with a bpm
|
||||
* value no in-window reading has, so a leak is visible in the numbers
|
||||
* rather than only in a count.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
const TEST_USER_ID = "user-workout-hr-series";
|
||||
const OTHER_USER_ID = "user-workout-hr-series-other";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const { GET } = await import("@/app/api/workouts/[id]/route");
|
||||
|
||||
/** Session window: 07:00 → 07:30 on a fixed day. */
|
||||
const STARTED_AT = new Date("2026-05-15T07:00:00.000Z");
|
||||
const ENDED_AT = new Date("2026-05-15T07:30:00.000Z");
|
||||
const SPAN_SEC = 1800;
|
||||
|
||||
/**
|
||||
* In-window readings sit in this band; every out-of-window reading is
|
||||
* placed outside it, so an assertion on the curve's bpm bounds names any
|
||||
* leak directly.
|
||||
*/
|
||||
const IN_WINDOW_LOW = 120;
|
||||
const IN_WINDOW_HIGH = 168;
|
||||
/** After the finish but inside the query's five-minute pad. */
|
||||
const PAD_TAIL_BPM = 44;
|
||||
/** Well outside the padded window on either side. */
|
||||
const FAR_BEFORE_BPM = 201;
|
||||
const FAR_AFTER_BPM = 51;
|
||||
/** Another account's reading in the middle of this session. */
|
||||
const FOREIGN_BPM = 199;
|
||||
|
||||
interface HrSeriesPoint {
|
||||
tSec: number;
|
||||
mean: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface WorkoutDetailBody {
|
||||
data: {
|
||||
id: string;
|
||||
hrSeries: {
|
||||
source: string;
|
||||
bucketSec: number;
|
||||
envelope: boolean;
|
||||
points: HrSeriesPoint[];
|
||||
} | null;
|
||||
} | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
async function createUser(id: string, username: string) {
|
||||
await getPrismaClient().user.create({
|
||||
data: { id, username, email: `${username}@example.test` },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await createUser(TEST_USER_ID, "workout-hr-series");
|
||||
await createUser(OTHER_USER_ID, "workout-hr-series-other");
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
/**
|
||||
* The session under test, with no stored sample stream — so the route has
|
||||
* to reconstruct the curve from `PULSE` rows and the boundaries above are
|
||||
* the ones that decide what it sees.
|
||||
*/
|
||||
async function createWorkout(): Promise<string> {
|
||||
const row = await getPrismaClient().workout.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
sportType: "running",
|
||||
startedAt: STARTED_AT,
|
||||
endedAt: ENDED_AT,
|
||||
durationSec: SPAN_SEC,
|
||||
avgHeartRate: 144,
|
||||
maxHeartRate: IN_WINDOW_HIGH,
|
||||
source: "MANUAL",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function pulseRow(userId: string, offsetSec: number, value: number) {
|
||||
return {
|
||||
userId,
|
||||
type: "PULSE" as const,
|
||||
value,
|
||||
unit: "bpm",
|
||||
measuredAt: new Date(STARTED_AT.getTime() + offsetSec * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A dense in-window sweep plus one reading on the far side of each
|
||||
* boundary. The sweep alternates across the in-window band so the fold
|
||||
* has a real shape to carry.
|
||||
*/
|
||||
async function seedPulse() {
|
||||
const rows = [] as ReturnType<typeof pulseRow>[];
|
||||
for (let offset = 0; offset < SPAN_SEC; offset += 5) {
|
||||
const swing = Math.round(
|
||||
((IN_WINDOW_HIGH - IN_WINDOW_LOW) / 2) *
|
||||
(1 + Math.sin((offset / SPAN_SEC) * Math.PI * 4)),
|
||||
);
|
||||
rows.push(pulseRow(TEST_USER_ID, offset, IN_WINDOW_LOW + swing));
|
||||
}
|
||||
// Two minutes after the finish: inside the query's pad, outside the
|
||||
// session. The query returns it; the curve must not.
|
||||
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 120, PAD_TAIL_BPM));
|
||||
// Far outside the padded window on both sides.
|
||||
rows.push(pulseRow(TEST_USER_ID, -20 * 60, FAR_BEFORE_BPM));
|
||||
rows.push(pulseRow(TEST_USER_ID, SPAN_SEC + 15 * 60, FAR_AFTER_BPM));
|
||||
// Another account, mid-session.
|
||||
rows.push(pulseRow(OTHER_USER_ID, Math.floor(SPAN_SEC / 2), FOREIGN_BPM));
|
||||
await getPrismaClient().measurement.createMany({ data: rows });
|
||||
}
|
||||
|
||||
async function fetchDetail(id: string): Promise<WorkoutDetailBody> {
|
||||
const request = new NextRequest(
|
||||
`http://localhost/api/workouts/${id}?compact=1`,
|
||||
);
|
||||
const response = await GET(request, { params: Promise.resolve({ id }) });
|
||||
return (await response.json()) as WorkoutDetailBody;
|
||||
}
|
||||
|
||||
describe("GET /api/workouts/{id} — reconstructed heart-rate curve", () => {
|
||||
it("reconstructs the curve from the account's pulse readings", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const body = await fetchDetail(id);
|
||||
expect(body.error).toBeNull();
|
||||
const series = body.data?.hrSeries;
|
||||
expect(series).not.toBeNull();
|
||||
expect(series!.source).toBe("pulse_window");
|
||||
expect(series!.points.length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("stops the curve at the finish — a reading inside the query pad is not part of the session", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const series = (await fetchDetail(id)).data!.hrSeries!;
|
||||
const lastTSec = series.points[series.points.length - 1].tSec;
|
||||
expect(lastTSec).toBeLessThan(SPAN_SEC);
|
||||
// The pad reading is the only source of a sub-100 bpm value in the
|
||||
// fixture, so naming the value proves it stayed out rather than
|
||||
// merely proving a count.
|
||||
expect(series.points.map((p) => p.min)).not.toContain(PAD_TAIL_BPM);
|
||||
expect(Math.min(...series.points.map((p) => p.min))).toBeGreaterThanOrEqual(
|
||||
IN_WINDOW_LOW,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps readings from outside the window and from other accounts out of the curve", async () => {
|
||||
const id = await createWorkout();
|
||||
await seedPulse();
|
||||
|
||||
const series = (await fetchDetail(id)).data!.hrSeries!;
|
||||
const maxima = series.points.map((p) => p.max);
|
||||
expect(maxima).not.toContain(FAR_BEFORE_BPM);
|
||||
expect(maxima).not.toContain(FOREIGN_BPM);
|
||||
expect(series.points.map((p) => p.min)).not.toContain(FAR_AFTER_BPM);
|
||||
expect(Math.max(...maxima)).toBeLessThanOrEqual(IN_WINDOW_HIGH);
|
||||
});
|
||||
|
||||
it("serves no curve when the account's readings do not cover the session", async () => {
|
||||
const id = await createWorkout();
|
||||
// A handful of opportunistic readings in the first minute. Enough
|
||||
// rows to clear the sample floor, nowhere near enough to describe a
|
||||
// half-hour session — the route says nothing rather than drawing a
|
||||
// curve out of a corner of it.
|
||||
await getPrismaClient().measurement.createMany({
|
||||
data: Array.from({ length: 12 }, (_, i) =>
|
||||
pulseRow(TEST_USER_ID, i * 4, 130 + i),
|
||||
),
|
||||
});
|
||||
|
||||
const body = await fetchDetail(id);
|
||||
expect(body.error).toBeNull();
|
||||
expect(body.data?.hrSeries).toBeNull();
|
||||
});
|
||||
|
||||
it("does not reach the reconstruction at all for another account's workout", async () => {
|
||||
const foreign = await getPrismaClient().workout.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
sportType: "running",
|
||||
startedAt: STARTED_AT,
|
||||
endedAt: ENDED_AT,
|
||||
durationSec: SPAN_SEC,
|
||||
source: "MANUAL",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
await seedPulse();
|
||||
|
||||
const request = new NextRequest(
|
||||
`http://localhost/api/workouts/${foreign.id}?compact=1`,
|
||||
);
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({ id: foreign.id }),
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user