mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
3375bbabac
commit
c21131e26e
40 files changed
+1399
-446
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user