feat(cycle): second-audit feature, QoL, and wiring close

Close the second-round audit UI cluster:

- Cycle reminders: add the missing notification-event labels so the
  period-soon / period-confirm toggles read correctly on the
  notifications page, and point to them from cycle settings.
- Per-cycle history: render a recent-cycles list (range, length, period
  length, confirmed-ovulation chip) — the cycles were fetched but only
  the aggregate stats showed.
- Confirmed ovulation: carry it on the prediction DTO and surface it as a
  solid BBT-chart marker + a predictions row + a history chip.
- Log-day sheet: deselecting an enum now clears it (edits route through
  the nullable PATCH); period start/end work on any selected date for
  retroactive correction; "end period" shows only while one is open.
  Extract the payload builders as pure, unit-tested functions.
- Cycle-tracking enable toggle in account settings so any account can opt
  in before the gated page is reachable; wire the cycle data-purge button
  to the hard-delete route and document the route in the OpenAPI spec.
- Calendar legend: add the symptom dot, render fertile as a ring to match
  the grid; tighten the luteal prior bound to the engine clamp; mute the
  BBT ovulation-label colour for contrast.
- Share the cycle-start backward-walk between the wheel and the chart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 13:32:25 +02:00
1 parent 3375bbabac
commit c21131e26e
40 files changed
+1399 -446

No files matched your search

+3 -1
View File
@@ -75,7 +75,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
regularity = "LEARNING";
} else {
regularity =
lengthVariabilityDays <= REGULAR_MAD_THRESHOLD ? "REGULAR" : "IRREGULAR";
lengthVariabilityDays <= REGULAR_MAD_THRESHOLD
? "REGULAR"
: "IRREGULAR";
}
}
if (periodLengths.length > 0) {
+6 -2
View File
@@ -122,11 +122,15 @@ export const PATCH = apiHandler(
}),
sexualActivity: encryptSensitive ? false : merged.sexualActivity,
protectedSex: encryptSensitive ? null : merged.protectedSex,
pregnancyTest: (encryptSensitive ? null : merged.pregnancyTest) as never,
pregnancyTest: (encryptSensitive
? null
: merged.pregnancyTest) as never,
progesteroneTest: (encryptSensitive
? null
: merged.progesteroneTest) as never,
contraceptive: (encryptSensitive ? null : merged.contraceptive) as never,
contraceptive: (encryptSensitive
? null
: merged.contraceptive) as never,
sensitiveEncrypted: encryptSensitive
? encrypt(JSON.stringify(merged))
: null,
+4 -1
View File
@@ -28,7 +28,10 @@ import { prisma } from "@/lib/db";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { cycleBulkSchema, MAX_CYCLE_BULK_ENTRIES } from "@/lib/validations/cycle";
import {
cycleBulkSchema,
MAX_CYCLE_BULK_ENTRIES,
} from "@/lib/validations/cycle";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
+70 -73
View File
@@ -19,10 +19,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
predictCycle,
type NightlyTempInput,
} from "@/lib/cycle";
import { predictCycle, type NightlyTempInput } from "@/lib/cycle";
import { LUTEAL_DEFAULT } from "@/lib/cycle/types";
import {
buildPhaseDayMap,
@@ -65,77 +62,77 @@ export const GET = apiHandler(async () => {
prefsRow,
moodRows,
] = await Promise.all([
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered window (`from` = today − 365d),
// which is already earlier than the symptothermal lookback. Cycle-length
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
// perf — unbounded full-history read).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
prisma.menstrualCycle.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { startDate: "asc" },
}),
// Bound the day-log read to the rendered window (`from` = today − 365d),
// which is already earlier than the symptothermal lookback. Cycle-length
// stats run off MenstrualCycle rows so the day-logs can be windowed (QA:
// perf — unbounded full-history read).
prisma.cycleDayLog.findMany({
where: { userId: user.id, deletedAt: null, date: { gte: from } },
orderBy: { date: "asc" },
select: {
date: true,
flow: true,
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
},
}),
// Passive wrist temperature feeds the temperature-trend ovulation layer
// (used only to sharpen the prediction's next-start the phase-day map
// extends to). Not a crosstab input.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
},
}),
// Passive wrist temperature feeds the temperature-trend ovulation layer
// (used only to sharpen the prediction's next-start the phase-day map
// extends to). Not a crosstab input.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: "WRIST_TEMPERATURE",
measuredAt: {
gte: new Date(Date.parse(`${addDays(today, -90)}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
// The outcome metrics the phase contrast compares — soft-delete-scoped,
// canonical-source deduped per day inside `metricDayMap`.
prisma.measurement.findMany({
where: {
userId: user.id,
deletedAt: null,
type: { in: PHASE_CROSSTAB_METRIC_TYPES },
measuredAt: {
gte: new Date(Date.parse(`${from}T00:00:00Z`)),
},
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
]);
},
orderBy: { measuredAt: "asc" },
select: {
type: true,
value: true,
measuredAt: true,
source: true,
deviceType: true,
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
}),
// MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a
// Measurement row, so read it here and inject it into the crosstab as a
// synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards).
prisma.moodEntry.findMany({
where: {
userId: user.id,
deletedAt: null,
moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) },
},
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
}),
]);
// The latest open cycle runs to the predicted next-period start so the
// trailing window is phase-labelled even before the next period is logged.