From d97e3c0976affdeb4d082d0a71788dd076ef7026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 14:30:10 +0200 Subject: [PATCH] fix(measurements): store a hand-entered night in the unit the field asks for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual sleep field is labelled in hours. The column stores minutes, because that is what every sync source delivers and because a single stage segment is only a few of them. The form sent the typed number straight through, so a night entered as 7.5 was filed as seven and a half minutes and the list showed eight minutes of sleep. Nothing rejected it. Seven and a half minutes is inside the column's plausibility band — a stage really can be that short — so the entry looked accepted and the loss was silent. Both ends were already right: the field says hours, the list formats minutes as hours and minutes. The assembly between them was where the factor went missing, which is why the round-trip test runs the whole of it — the form's conversion, the shipped POST, the stored row, and the formatter the list paints with. The storage unit stays minutes. The conversion sits at the entry boundary in one place with its inverse beside it, so a future editing surface cannot land on a different answer, and it rounds to whole minutes rather than storing 437.99999999999994 for 7.3 hours. The value field also lost "7,5" outright. `type="number"` sanitises a decimal comma away on the engines that do not localise the field, so the entry arrived as an empty string with no explanation, and `parseFloat` would have read it as 7 on the engines where it survived. The field now takes the same shape the labs and custom-metric fields use — a numeric keypad and a submit path that reads the comma — so every decimal metric on the form gains it, not only sleep. `scripts/diagnose-manual-sleep-hours.ts` lists the rows already stored this way. It reports and does not repair: an eight-minute stage 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. --- scripts/diagnose-manual-sleep-hours.ts | 103 ++++++++++++ .../measurements/measurement-form.tsx | 25 ++- src/lib/measurements/entry-units.ts | 73 +++++++++ .../sleep-entry-hours-round-trip.test.ts | 153 ++++++++++++++++++ 4 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 scripts/diagnose-manual-sleep-hours.ts create mode 100644 src/lib/measurements/entry-units.ts create mode 100644 tests/integration/sleep-entry-hours-round-trip.test.ts 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); + }); +});