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