diff --git a/scripts/diagnose-manual-sleep-hours.ts b/scripts/diagnose-manual-sleep-hours.ts new file mode 100644 index 000000000..38a4b51a9 --- /dev/null +++ b/scripts/diagnose-manual-sleep-hours.ts @@ -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 { + 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(); + 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(); + }); diff --git a/src/components/measurements/measurement-form.tsx b/src/components/measurements/measurement-form.tsx index 11e814478..93d1da4f5 100644 --- a/src/components/measurements/measurement-form.tsx +++ b/src/components/measurements/measurement-form.tsx @@ -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({ > setValue(e.target.value)} placeholder={ diff --git a/src/lib/measurements/entry-units.ts b/src/lib/measurements/entry-units.ts new file mode 100644 index 000000000..147886507 --- /dev/null +++ b/src/lib/measurements/entry-units.ts @@ -0,0 +1,73 @@ +/** + * 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, with the inverse beside it so a future editing + * surface cannot land on a different answer. + */ + +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> = { + SLEEP_DURATION: MINUTES_PER_HOUR, +}; + +/** Whether the type's entry unit differs from the unit its column stores. */ +export function hasEntryUnitConversion(type: string): boolean { + return type in ENTRY_TO_CANONICAL_FACTOR; +} + +/** + * 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); +} + +/** The inverse: a stored value expressed in the unit the entry form asks for. */ +export function canonicalValueToEntry(type: string, canonical: number): number { + const factor = ENTRY_TO_CANONICAL_FACTOR[type]; + if (factor === undefined || !Number.isFinite(canonical)) return canonical; + return canonical / 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; +} diff --git a/tests/integration/sleep-entry-hours-round-trip.test.ts b/tests/integration/sleep-entry-hours-round-trip.test.ts new file mode 100644 index 000000000..9389eb742 --- /dev/null +++ b/tests/integration/sleep-entry-hours-round-trip.test.ts @@ -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)( + 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 => + 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); + }); +});