health: smart-scale columns, deduped body measurement imports, quick-add form
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
This commit is contained in:
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
20
drizzle/20261024141609_health_scale_metrics/migration.sql
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "muscle_mass_kg" numeric(5,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "skeletal_muscle_kg" numeric(5,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bone_mass_kg" numeric(4,2);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "visceral_fat" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "body_water_pct" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "protein_pct" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmr_kcal" integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "bmi" numeric(4,1);--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "measured_at" timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD COLUMN "source" text;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "body_measurements_date_weight_key" ON "health"."body_measurements" ("date","weight_kg");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "body_measurements_measured_at_key" ON "health"."body_measurements" ("measured_at");--> statement-breakpoint
|
||||||
|
ALTER TABLE "health"."body_measurements" ADD CONSTRAINT "body_measurements_scale_check" CHECK (("muscle_mass_kg" IS NULL OR "muscle_mass_kg" BETWEEN 5 AND 250)
|
||||||
|
AND ("skeletal_muscle_kg" IS NULL OR "skeletal_muscle_kg" BETWEEN 5 AND 200)
|
||||||
|
AND ("bone_mass_kg" IS NULL OR "bone_mass_kg" BETWEEN 0.5 AND 20)
|
||||||
|
AND ("visceral_fat" IS NULL OR "visceral_fat" BETWEEN 1 AND 60)
|
||||||
|
AND ("body_water_pct" IS NULL OR "body_water_pct" BETWEEN 20 AND 85)
|
||||||
|
AND ("protein_pct" IS NULL OR "protein_pct" BETWEEN 5 AND 40)
|
||||||
|
AND ("bmr_kcal" IS NULL OR "bmr_kcal" BETWEEN 500 AND 6000)
|
||||||
|
AND ("bmi" IS NULL OR "bmi" BETWEEN 10 AND 80));
|
||||||
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
16941
drizzle/20261024141609_health_scale_metrics/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,9 @@
|
|||||||
* add --dry-run to validate without writing
|
* add --dry-run to validate without writing
|
||||||
*
|
*
|
||||||
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
|
* Column names are the snake_case DB columns (see src/lib/health/tables.ts). Rows with an
|
||||||
* `id` update that row; nutrition_daily and sleep upsert on `date`; everything else is
|
* `id` update that row; nutrition_daily and sleep upsert on `date`; body_measurements dedupe
|
||||||
* inserted (importing the same file twice duplicates those rows). All-or-nothing: if any
|
* on measured_at or (date, weight_kg) and fill smart-scale columns from the notes, so
|
||||||
|
* re-importing is a no-op; everything else is inserted (importing twice duplicates those). All-or-nothing: if any
|
||||||
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
|
* row is invalid, nothing is written. Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||||
*/
|
*/
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
@@ -66,7 +67,7 @@ const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
|||||||
try {
|
try {
|
||||||
const counts = await writeRows(drizzle({ client }), clean);
|
const counts = await writeRows(drizzle({ client }), clean);
|
||||||
for (const [t, c] of Object.entries(counts))
|
for (const [t, c] of Object.entries(counts))
|
||||||
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated`);
|
console.log(`${t}: ${c.inserted} inserted, ${c.updated} updated, ${c.unchanged} unchanged`);
|
||||||
} finally {
|
} finally {
|
||||||
await client.end();
|
await client.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,32 @@ export const appointmentTypes = [
|
|||||||
'other'
|
'other'
|
||||||
] as const;
|
] 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 notes: Field = { key: 'notes', label: 'Notes', type: 'textarea', maxLength: 4000 };
|
||||||
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
|
const date: Field = { key: 'date', label: 'Date', type: 'date', required: true };
|
||||||
|
|
||||||
@@ -54,7 +80,8 @@ export const healthTables = {
|
|||||||
body_measurements: {
|
body_measurements: {
|
||||||
label: 'Body measurements',
|
label: 'Body measurements',
|
||||||
fields: [
|
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: 'weight_kg', label: 'Weight (kg)', type: 'number', min: 20, max: 400, step: 0.01 },
|
||||||
{
|
{
|
||||||
key: 'waist_cm',
|
key: 'waist_cm',
|
||||||
@@ -65,6 +92,9 @@ export const healthTables = {
|
|||||||
step: 0.1
|
step: 0.1
|
||||||
},
|
},
|
||||||
{ key: 'body_fat_pct', label: 'Body fat (%)', type: 'number', min: 1, max: 75, 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
|
notes
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { sql } from 'drizzle-orm';
|
||||||
import { healthSchema } from './schemas';
|
import { healthSchema } from './schemas';
|
||||||
import { timestamps, timestamptz } from './helpers';
|
import { timestamps, timestamptz } from './helpers';
|
||||||
@@ -42,11 +52,25 @@ export const healthBodyMeasurements = healthSchema.table(
|
|||||||
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
|
weightKg: numeric('weight_kg', { precision: 5, scale: 2, mode: 'number' }),
|
||||||
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
|
waistCm: numeric('waist_cm', { precision: 5, scale: 1, mode: 'number' }), // at the navel
|
||||||
bodyFatPct: numeric('body_fat_pct', { precision: 4, scale: 1, mode: 'number' }),
|
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'),
|
notes: text('notes'),
|
||||||
...timestamps
|
...timestamps
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index('body_measurements_date_idx').on(t.date),
|
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(
|
check(
|
||||||
'body_measurements_any_check',
|
'body_measurements_any_check',
|
||||||
sql`${t.weightKg} IS NOT NULL OR ${t.waistCm} IS NOT NULL OR ${t.bodyFatPct} IS NOT NULL`
|
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',
|
'body_measurements_fat_check',
|
||||||
sql`${t.bodyFatPct} IS NULL OR ${t.bodyFatPct} BETWEEN 1 AND 75`
|
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'))
|
check('body_measurements_date_check', sane('date'))
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
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', () => {
|
describe('health import', () => {
|
||||||
it('parses CSV with quotes and semicolons', () => {
|
it('parses CSV with quotes and semicolons', () => {
|
||||||
@@ -13,7 +20,7 @@ describe('health import', () => {
|
|||||||
weight_kg: '80,5',
|
weight_kg: '80,5',
|
||||||
waist_cm: ''
|
waist_cm: ''
|
||||||
});
|
});
|
||||||
expect(r).toEqual({
|
expect(r).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
row: {
|
row: {
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -49,4 +56,48 @@ describe('health import', () => {
|
|||||||
expect(() => toPayload([{}])).toThrow();
|
expect(() => toPayload([{}])).toThrow();
|
||||||
expect(() => toPayload({ nope: [] })).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Validation + import for the private health tables. Shared by the owner-only import
|
// Validation + import for the private health tables. Shared by the owner-only import
|
||||||
// endpoint (/trackers/health/import), the page's add/edit forms and
|
// endpoint (/trackers/health/import), the page's add/edit forms and
|
||||||
// scripts/health-import.ts, so it must not import anything from SvelteKit.
|
// 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 type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
|
||||||
import {
|
import {
|
||||||
healthAppointments,
|
healthAppointments,
|
||||||
@@ -12,7 +12,13 @@ import {
|
|||||||
healthVaccinations,
|
healthVaccinations,
|
||||||
healthWorkouts
|
healthWorkouts
|
||||||
} from './db/schema/health';
|
} 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 = {
|
export const drizzleTables = {
|
||||||
vaccinations: healthVaccinations,
|
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. */
|
/** Validate one row (snake_case keys) for a table. Unknown keys are rejected. */
|
||||||
export function cleanRow(table: HealthTable, input: Row): RowResult {
|
export function cleanRow(table: HealthTable, input: Row): RowResult {
|
||||||
if (!input || typeof input !== 'object' || Array.isArray(input))
|
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))
|
if (!/^[A-Z]{2}$/.test(country))
|
||||||
return { ok: false, error: 'country must be a 2-letter ISO code, e.g. NL' };
|
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 (
|
if (
|
||||||
table === 'body_measurements' &&
|
table === 'body_measurements' &&
|
||||||
v.weight_kg === null &&
|
v.weight_kg === null &&
|
||||||
@@ -312,10 +359,43 @@ export function validatePayload(payload: Payload) {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
type Db = PgAsyncDatabase<any, 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;
|
/** 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(
|
export async function writeRows(
|
||||||
db: Db,
|
db: Db,
|
||||||
clean: Partial<Record<HealthTable, CleanRow[]>>
|
clean: Partial<Record<HealthTable, CleanRow[]>>
|
||||||
@@ -324,8 +404,27 @@ export async function writeRows(
|
|||||||
const counts: ImportCounts = {};
|
const counts: ImportCounts = {};
|
||||||
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
|
for (const [table, rows] of Object.entries(clean) as [HealthTable, CleanRow[]][]) {
|
||||||
const target = drizzleTables[table];
|
const target = drizzleTables[table];
|
||||||
const count = { inserted: 0, updated: 0 };
|
const count = { inserted: 0, updated: 0, unchanged: 0 };
|
||||||
for (const { id, values } of rows) {
|
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 data = { ...values, ...(id ? { id } : {}) };
|
||||||
const set = { ...values, updatedAt: new Date() };
|
const set = { ...values, updatedAt: new Date() };
|
||||||
const conflictTarget = id
|
const conflictTarget = id
|
||||||
|
|||||||
@@ -90,12 +90,14 @@ export const actions: Actions = {
|
|||||||
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
|
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
|
||||||
const result = cleanRow(table, row);
|
const result = cleanRow(table, row);
|
||||||
if (!result.ok) return fail(400, { message: result.error, table });
|
if (!result.ok) return fail(400, { message: result.error, table });
|
||||||
|
let unchanged: boolean;
|
||||||
try {
|
try {
|
||||||
await writeRows(db, { [table]: [result.row] });
|
const counts = await writeRows(db, { [table]: [result.row] });
|
||||||
|
unchanged = counts[table]?.unchanged === 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
|
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
|
||||||
}
|
}
|
||||||
return { saved: table };
|
return { saved: table, unchanged };
|
||||||
},
|
},
|
||||||
delete: async (event) => {
|
delete: async (event) => {
|
||||||
requireOwner(event);
|
requireOwner(event);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import {
|
import {
|
||||||
healthTables,
|
healthTables,
|
||||||
healthTableNames,
|
healthTableNames,
|
||||||
|
scaleFields,
|
||||||
type Field,
|
type Field,
|
||||||
type HealthTable
|
type HealthTable
|
||||||
} from '#lib/health/tables.js';
|
} from '#lib/health/tables.js';
|
||||||
@@ -96,6 +97,38 @@
|
|||||||
dialogOpen = true;
|
dialogOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick-add for a scale reading: same ?/save action and dedupe as imports.
|
||||||
|
const nowLocal = () => localTime.format(new Date()).replace(' ', 'T');
|
||||||
|
let quickNow = $state(nowLocal());
|
||||||
|
let quickMessage = $state<{ text: string; error: boolean } | null>(null);
|
||||||
|
const quickSubmit: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
quickNow = nowLocal();
|
||||||
|
await update();
|
||||||
|
quickMessage = {
|
||||||
|
text: result.data?.unchanged ? 'Already logged, nothing changed.' : 'Saved.',
|
||||||
|
error: false
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
await update({ reset: false });
|
||||||
|
quickMessage = {
|
||||||
|
text: String((result.type === 'failure' && result.data?.message) || 'Could not save'),
|
||||||
|
error: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scaleSummary = (m: (typeof data.measurements)[number]) =>
|
||||||
|
[
|
||||||
|
m.muscleMassKg !== null ? `muscle ${num(m.muscleMassKg, 2)} kg` : '',
|
||||||
|
m.visceralFat !== null ? `visceral ${num(m.visceralFat)}` : '',
|
||||||
|
m.bodyWaterPct !== null ? `water ${num(m.bodyWaterPct)}%` : '',
|
||||||
|
m.bmi !== null ? `BMI ${num(m.bmi)}` : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
|
||||||
const submit: SubmitFunction =
|
const submit: SubmitFunction =
|
||||||
() =>
|
() =>
|
||||||
async ({ result, update }) => {
|
async ({ result, update }) => {
|
||||||
@@ -156,6 +189,80 @@
|
|||||||
<div class="grid gap-4 lg:grid-cols-3">
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
<section class="panel lg:col-span-2">
|
<section class="panel lg:col-span-2">
|
||||||
{@render sectionHeader('Body', 'body_measurements')}
|
{@render sectionHeader('Body', 'body_measurements')}
|
||||||
|
<form method="POST" action="?/save" use:enhance={quickSubmit} class="quick mb-4 grid gap-2">
|
||||||
|
<input type="hidden" name="table" value="body_measurements" />
|
||||||
|
<div class="flex flex-wrap items-end gap-2">
|
||||||
|
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
When
|
||||||
|
<Input
|
||||||
|
type="datetime-local"
|
||||||
|
name="measured_at"
|
||||||
|
value={quickNow}
|
||||||
|
required
|
||||||
|
class="w-52"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
Weight (kg)
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="weight_kg"
|
||||||
|
min={20}
|
||||||
|
max={400}
|
||||||
|
step={0.01}
|
||||||
|
inputmode="decimal"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
Body fat (%)
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name="body_fat_pct"
|
||||||
|
min={1}
|
||||||
|
max={75}
|
||||||
|
step={0.1}
|
||||||
|
inputmode="decimal"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
Source
|
||||||
|
<Input name="source" value="Renpho" maxlength={100} class="w-28" />
|
||||||
|
</label>
|
||||||
|
<Button type="submit">Add</Button>
|
||||||
|
</div>
|
||||||
|
<details class="text-sm">
|
||||||
|
<summary class="cursor-pointer text-[var(--site-muted)]">More readings & notes</summary>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||||
|
{#each scaleFields as field (field.key)}
|
||||||
|
<label class="grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
{field.label}
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
name={field.key}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
step={field.type === 'integer' ? 1 : field.step}
|
||||||
|
inputmode="decimal"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<label class="mt-2 grid gap-1 text-xs text-[var(--site-muted)]">
|
||||||
|
Notes
|
||||||
|
<Textarea name="notes" rows={2} maxlength={4000} />
|
||||||
|
</label>
|
||||||
|
</details>
|
||||||
|
{#if quickMessage}
|
||||||
|
<p
|
||||||
|
class={['text-sm', quickMessage.error ? 'text-red-500' : 'text-[var(--site-muted)]']}
|
||||||
|
role={quickMessage.error ? 'alert' : 'status'}
|
||||||
|
>
|
||||||
|
{quickMessage.text}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
{#if latest}
|
{#if latest}
|
||||||
<div class="mb-3 flex flex-wrap gap-6">
|
<div class="mb-3 flex flex-wrap gap-6">
|
||||||
<p>
|
<p>
|
||||||
@@ -169,6 +276,9 @@
|
|||||||
{#if latest.bodyFatPct !== null}<p>
|
{#if latest.bodyFatPct !== null}<p>
|
||||||
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
|
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
|
{#if latest.muscleMassKg !== null}<p>
|
||||||
|
<span class="stat">{num(latest.muscleMassKg, 1)}</span> kg muscle
|
||||||
|
</p>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if data.measurements.length > 1}
|
{#if data.measurements.length > 1}
|
||||||
<BodyChart points={data.measurements} />
|
<BodyChart points={data.measurements} />
|
||||||
@@ -186,6 +296,8 @@
|
|||||||
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
|
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
|
||||||
null
|
null
|
||||||
? ` · ${num(m.bodyFatPct)}%`
|
? ` · ${num(m.bodyFatPct)}%`
|
||||||
|
: ''}{scaleSummary(m) ? ` · ${scaleSummary(m)}` : ''}{m.source
|
||||||
|
? ` · ${m.source}`
|
||||||
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
|
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
|
||||||
>
|
>
|
||||||
{@render rowActions('body_measurements', m)}
|
{@render rowActions('body_measurements', m)}
|
||||||
|
|||||||
Reference in New Issue
Block a user