mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user