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:
@@ -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
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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'))
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -90,12 +90,14 @@ export const actions: Actions = {
|
||||
for (const field of healthTables[table].fields) row[field.key] = form.get(field.key) ?? '';
|
||||
const result = cleanRow(table, row);
|
||||
if (!result.ok) return fail(400, { message: result.error, table });
|
||||
let unchanged: boolean;
|
||||
try {
|
||||
await writeRows(db, { [table]: [result.row] });
|
||||
const counts = await writeRows(db, { [table]: [result.row] });
|
||||
unchanged = counts[table]?.unchanged === 1;
|
||||
} catch (e) {
|
||||
return fail(400, { message: e instanceof Error ? e.message : 'Could not save', table });
|
||||
}
|
||||
return { saved: table };
|
||||
return { saved: table, unchanged };
|
||||
},
|
||||
delete: async (event) => {
|
||||
requireOwner(event);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import {
|
||||
healthTables,
|
||||
healthTableNames,
|
||||
scaleFields,
|
||||
type Field,
|
||||
type HealthTable
|
||||
} from '#lib/health/tables.js';
|
||||
@@ -96,6 +97,38 @@
|
||||
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 =
|
||||
() =>
|
||||
async ({ result, update }) => {
|
||||
@@ -156,6 +189,80 @@
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<section class="panel lg:col-span-2">
|
||||
{@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}
|
||||
<div class="mb-3 flex flex-wrap gap-6">
|
||||
<p>
|
||||
@@ -169,6 +276,9 @@
|
||||
{#if latest.bodyFatPct !== null}<p>
|
||||
<span class="stat">{num(latest.bodyFatPct)}</span>% fat
|
||||
</p>{/if}
|
||||
{#if latest.muscleMassKg !== null}<p>
|
||||
<span class="stat">{num(latest.muscleMassKg, 1)}</span> kg muscle
|
||||
</p>{/if}
|
||||
</div>
|
||||
{#if data.measurements.length > 1}
|
||||
<BodyChart points={data.measurements} />
|
||||
@@ -186,6 +296,8 @@
|
||||
>{dayLabel(m.date)} · {num(m.weightKg, 2)} kg · {num(m.waistCm)} cm{m.bodyFatPct !==
|
||||
null
|
||||
? ` · ${num(m.bodyFatPct)}%`
|
||||
: ''}{scaleSummary(m) ? ` · ${scaleSummary(m)}` : ''}{m.source
|
||||
? ` · ${m.source}`
|
||||
: ''}{m.notes ? ` · ${m.notes}` : ''}</span
|
||||
>
|
||||
{@render rowActions('body_measurements', m)}
|
||||
|
||||
Reference in New Issue
Block a user