health: smart-scale columns, deduped body measurement imports, quick-add form
All checks were successful
Deploy production / deploy (push) Successful in 2m32s

This commit is contained in:
2026-10-24 14:15:59 +00:00
parent b51fef14fd
commit 07d2c70973
9 changed files with 17305 additions and 14 deletions

View File

@@ -28,6 +28,32 @@ export const appointmentTypes = [
'other'
] as const;
/** Optional smart-scale (BIA) readings on body_measurements; ranges mirror the DB check. */
export const scaleFields = [
{
key: 'muscle_mass_kg',
label: 'Muscle mass (kg)',
type: 'number',
min: 5,
max: 250,
step: 0.01
},
{
key: 'skeletal_muscle_kg',
label: 'Skeletal muscle (kg)',
type: 'number',
min: 5,
max: 200,
step: 0.01
},
{ key: 'bone_mass_kg', label: 'Bone mass (kg)', type: 'number', min: 0.5, max: 20, step: 0.01 },
{ key: 'visceral_fat', label: 'Visceral fat', type: 'number', min: 1, max: 60, step: 0.1 },
{ key: 'body_water_pct', label: 'Body water (%)', type: 'number', min: 20, max: 85, step: 0.1 },
{ key: 'protein_pct', label: 'Protein (%)', type: 'number', min: 5, max: 40, step: 0.1 },
{ key: 'bmr_kcal', label: 'BMR (kcal)', type: 'integer', min: 500, max: 6000 },
{ key: 'bmi', label: 'BMI', type: 'number', min: 10, max: 80, step: 0.1 }
] as const satisfies readonly Field[];
const notes: Field = { key: 'notes', label: 'Notes', type: 'textarea', maxLength: 4000 };
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
@@ -54,7 +80,8 @@ export const healthTables = {
body_measurements: {
label: 'Body measurements',
fields: [
date,
// Optional here: derived from measured_at when only that is given.
{ ...date, required: false },
{ key: 'weight_kg', label: 'Weight (kg)', type: 'number', min: 20, max: 400, step: 0.01 },
{
key: 'waist_cm',
@@ -65,6 +92,9 @@ export const healthTables = {
step: 0.1
},
{ key: 'body_fat_pct', label: 'Body fat (%)', type: 'number', min: 1, max: 75, step: 0.1 },
...scaleFields,
{ key: 'measured_at', label: 'Measured at', type: 'datetime' },
{ key: 'source', label: 'Source', type: 'text', maxLength: 100, placeholder: 'Renpho' },
notes
]
},

View File

@@ -1,4 +1,14 @@
import { check, date, index, integer, numeric, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import {
check,
date,
index,
integer,
numeric,
text,
timestamp,
uniqueIndex,
uuid
} from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { healthSchema } from './schemas';
import { timestamps, timestamptz } from './helpers';
@@ -42,11 +52,25 @@ export const healthBodyMeasurements = healthSchema.table(
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
// Optional smart-scale (BIA) readings, e.g. Renpho.
muscleMassKg: numeric('muscle_mass_kg', { precision: 5, scale: 2, mode: 'number' }),
skeletalMuscleKg: numeric('skeletal_muscle_kg', { precision: 5, scale: 2, mode: 'number' }),
boneMassKg: numeric('bone_mass_kg', { precision: 4, scale: 2, mode: 'number' }),
visceralFat: numeric('visceral_fat', { precision: 4, scale: 1, mode: 'number' }), // scale's index
bodyWaterPct: numeric('body_water_pct', { precision: 4, scale: 1, mode: 'number' }),
proteinPct: numeric('protein_pct', { precision: 4, scale: 1, mode: 'number' }),
bmrKcal: integer('bmr_kcal'),
bmi: numeric('bmi', { precision: 4, scale: 1, mode: 'number' }),
measuredAt: timestamptz('measured_at'), // exact moment of the reading, when known
source: text('source'), // e.g. "Renpho", "manual"
notes: text('notes'),
...timestamps
},
(t) => [
index('body_measurements_date_idx').on(t.date),
// Dedupe keys for repeat imports (see writeRows in #lib/server/health-import).
uniqueIndex('body_measurements_date_weight_key').on(t.date, t.weightKg),
uniqueIndex('body_measurements_measured_at_key').on(t.measuredAt),
check(
'body_measurements_any_check',
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
@@ -63,6 +87,17 @@ export const healthBodyMeasurements = healthSchema.table(
'body_measurements_fat_check',
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
),
check(
'body_measurements_scale_check',
sql`(${t.muscleMassKg} IS NULL OR ${t.muscleMassKg} BETWEEN 5 AND 250)
AND (${t.skeletalMuscleKg} IS NULL OR ${t.skeletalMuscleKg} BETWEEN 5 AND 200)
AND (${t.boneMassKg} IS NULL OR ${t.boneMassKg} BETWEEN 0.5 AND 20)
AND (${t.visceralFat} IS NULL OR ${t.visceralFat} BETWEEN 1 AND 60)
AND (${t.bodyWaterPct} IS NULL OR ${t.bodyWaterPct} BETWEEN 20 AND 85)
AND (${t.proteinPct} IS NULL OR ${t.proteinPct} BETWEEN 5 AND 40)
AND (${t.bmrKcal} IS NULL OR ${t.bmrKcal} BETWEEN 500 AND 6000)
AND (${t.bmi} IS NULL OR ${t.bmi} BETWEEN 10 AND 80)`
),
check('body_measurements_date_check', sane('date'))
]
);

View File

@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { amsterdamTime, cleanRow, parseCsv, toPayload, validatePayload } from './health-import';
import {
amsterdamTime,
cleanRow,
parseCsv,
scaleMetricsFromNotes,
toPayload,
validatePayload
} from './health-import';
describe('health import', () => {
it('parses CSV with quotes and semicolons', () => {
@@ -13,7 +20,7 @@ describe('health import', () => {
weight_kg: '80,5',
waist_cm: ''
});
expect(r).toEqual({
expect(r).toMatchObject({
ok: true,
row: {
id: undefined,
@@ -49,4 +56,48 @@ describe('health import', () => {
expect(() => toPayload([{}])).toThrow();
expect(() => toPayload({ nope: [] })).toThrow();
});
it('fills smart-scale metrics from notes and derives the date from measured_at', () => {
const notes =
'Renpho BIA scale, 16:08. Muscle mass 68.38 kg, skeletal muscle 46.34 kg, visceral fat 10, body water 53.3%, BMR 1928 kcal, BMI 27.0';
expect(scaleMetricsFromNotes(notes)).toEqual({
muscle_mass_kg: '68.38',
skeletal_muscle_kg: '46.34',
visceral_fat: '10',
body_water_pct: '53.3',
bmr_kcal: '1928',
bmi: '27.0'
});
const r = cleanRow('body_measurements', {
measured_at: '2026-10-24T16:08',
weight_kg: 97.55,
muscle_mass_kg: 70,
notes
});
expect(r).toMatchObject({
ok: true,
row: {
values: {
date: '2026-10-24',
muscleMassKg: 70,
skeletalMuscleKg: 46.34,
visceralFat: 10,
bodyWaterPct: 53.3,
bmrKcal: 1928,
bmi: 27
}
}
});
expect(r.ok && (r.row.values.measuredAt as Date).toISOString()).toBe(
'2026-10-24T14:08:00.000Z'
);
const t = cleanRow('body_measurements', {
date: '2026-10-24',
measured_at: '07:30',
weight_kg: 97
});
expect(t.ok && (t.row.values.measuredAt as Date).toISOString()).toBe(
'2026-10-24T05:30:00.000Z'
);
expect(cleanRow('body_measurements', { weight_kg: 97 }).ok).toBe(false);
});
});

View File

@@ -1,7 +1,7 @@
// Validation + import for the private health tables. Shared by the owner-only import
// endpoint (/trackers/health/import), the page's add/edit forms and
// scripts/health-import.ts, so it must not import anything from SvelteKit.
import { sql } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import {
healthAppointments,
@@ -12,7 +12,13 @@ import {
healthVaccinations,
healthWorkouts
} from './db/schema/health';
import { healthTables, isHealthTable, type Field, type HealthTable } from '../health/tables';
import {
healthTables,
isHealthTable,
scaleFields,
type Field,
type HealthTable
} from '../health/tables';
export const drizzleTables = {
vaccinations: healthVaccinations,
@@ -140,6 +146,29 @@ function cleanField(field: Field, value: unknown): { value: unknown } | { error:
}
}
// Smart-scale readings written in free text ("Muscle mass 68.38 kg, visceral fat 10, BMI 27.0").
const NUM = String.raw`\s*[:=]?\s*(\d+(?:[.,]\d+)?)`;
const notePatterns: Record<(typeof scaleFields)[number]['key'], RegExp> = {
skeletal_muscle_kg: new RegExp(String.raw`skeletal muscle(?: mass)?${NUM}\s*kg`, 'i'),
muscle_mass_kg: new RegExp(String.raw`(?<!skeletal )muscle mass${NUM}\s*kg`, 'i'),
bone_mass_kg: new RegExp(String.raw`bone mass${NUM}\s*kg`, 'i'),
visceral_fat: new RegExp(String.raw`visceral fat(?: level| index)?${NUM}(?!\s*%)`, 'i'),
body_water_pct: new RegExp(String.raw`(?:body )?water${NUM}\s*%`, 'i'),
protein_pct: new RegExp(String.raw`protein${NUM}\s*%`, 'i'),
bmr_kcal: new RegExp(String.raw`\bBMR${NUM}`, 'i'),
bmi: new RegExp(String.raw`\bBMI${NUM}`, 'i')
};
/** Scale metrics found in a notes string (raw strings, validated by the caller). */
export function scaleMetricsFromNotes(notes: string): Record<string, string> {
const found: Record<string, string> = {};
for (const [key, pattern] of Object.entries(notePatterns)) {
const m = pattern.exec(notes);
if (m) found[key] = m[1].replace(',', '.');
}
return found;
}
/** Validate one row (snake_case keys) for a table. Unknown keys are rejected. */
export function cleanRow(table: HealthTable, input: Row): RowResult {
if (!input || typeof input !== 'object' || Array.isArray(input))
@@ -169,6 +198,24 @@ export function cleanRow(table: HealthTable, input: Row): RowResult {
if (!/^[A-Z]{2}$/.test(country))
return { ok: false, error: 'country must be a 2-letter ISO code, e.g. NL' };
}
if (table === 'body_measurements') {
if (v.measured_at === 'time') {
if (!v.date) return { ok: false, error: 'measured_at as HH:MM needs a date' };
v.measured_at = amsterdamTime(String(v.date), String(input.measured_at).trim());
}
if (v.date === null && v.measured_at instanceof Date)
v.date = new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE }).format(v.measured_at);
if (v.date === null) return { ok: false, error: 'date is required (or give measured_at)' };
// Fill smart-scale columns that were left empty from readings written in the notes.
if (typeof v.notes === 'string') {
for (const [key, raw] of Object.entries(scaleMetricsFromNotes(v.notes))) {
if (v[key] !== null) continue;
const field = scaleFields.find((f) => f.key === key)!;
const parsed = cleanField(field, raw);
if ('value' in parsed) v[key] = parsed.value;
}
}
}
if (
table === 'body_measurements' &&
v.weight_kg === null &&
@@ -312,10 +359,43 @@ export function validatePayload(payload: Payload) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Db = PgAsyncDatabase<any, any>;
export type ImportCounts = Partial<Record<HealthTable, { inserted: number; updated: number }>>;
export type ImportCounts = Partial<
Record<HealthTable, { inserted: number; updated: number; unchanged: number }>
>;
const sameValue = (a: unknown, b: unknown) =>
a instanceof Date && b instanceof Date ? a.getTime() === b.getTime() : a === b;
/**
* Body measurements without an id dedupe on measured_at (when given) or on (date, weight_kg):
* a match is merged (only non-empty incoming values overwrite) and left alone when nothing
* differs, so re-importing the same file is a no-op.
*/
async function findMeasurement(tx: Db, values: Record<string, unknown>) {
const t = healthBodyMeasurements;
if (values.measuredAt instanceof Date) {
const [row] = await tx
.select()
.from(t)
.where(eq(t.measuredAt, values.measuredAt))
.limit(1)
.for('update');
if (row) return row;
}
if (typeof values.weightKg === 'number') {
const [row] = await tx
.select()
.from(t)
.where(and(eq(t.date, String(values.date)), eq(t.weightKg, values.weightKg)))
.limit(1)
.for('update');
if (row) return row;
}
return null;
}
/** Insert/update rows; all-or-nothing in one transaction. Rows with an id update that row;
* nutrition_daily and sleep upsert on date. */
* nutrition_daily and sleep upsert on date; body_measurements dedupe (see findMeasurement). */
export async function writeRows(
db: Db,
clean: Partial<Record<HealthTable, CleanRow[]>>
@@ -324,8 +404,27 @@ export async function writeRows(
const counts: ImportCounts = {};
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
const target = drizzleTables[table];
const count = { inserted: 0, updated: 0 };
const count = { inserted: 0, updated: 0, unchanged: 0 };
for (const { id, values } of rows) {
if (table === 'body_measurements' && !id) {
const existing = await findMeasurement(tx, values);
if (existing) {
const changes = Object.fromEntries(
Object.entries(values).filter(
([k, v]) => v !== null && !sameValue(v, (existing as Record<string, unknown>)[k])
)
);
if (!Object.keys(changes).length) count.unchanged++;
else {
await tx
.update(healthBodyMeasurements)
.set({ ...changes, updatedAt: new Date() } as never)
.where(eq(healthBodyMeasurements.id, existing.id));
count.updated++;
}
continue;
}
}
const data = { ...values, ...(id ? { id } : {}) };
const set = { ...values, updatedAt: new Date() };
const conflictTarget = id