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

@@ -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));

File diff suppressed because it is too large Load Diff

View File

@@ -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();
} }

View File

@@ -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
] ]
}, },

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 { 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'))
] ]
); );

View File

@@ -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);
});
}); });

View File

@@ -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

View File

@@ -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);

View File

@@ -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)}