- health schema: vaccinations, body_measurements, workouts, nutrition_daily, sleep (generated hours), medications, hair_progress, appointments - migration with updated_at triggers; grants to personal_app only - /trackers/health overview with add/edit/delete, owner-gated (404 otherwise), noindex + private no-store, hidden from listings for non-owners - owner-only JSON/CSV import endpoint and scripts/health-import.ts
119 lines
3.3 KiB
Svelte
119 lines
3.3 KiB
Svelte
<script lang="ts">
|
|
// Weight + waist trend. Same lazy-loaded ECharts setup as the CS prices chart.
|
|
import { onMount } from 'svelte';
|
|
import type { ECharts } from 'echarts/core';
|
|
|
|
export type BodyPoint = { date: string; weightKg: number | null; waistCm: number | null };
|
|
let { points }: { points: BodyPoint[] } = $props();
|
|
let element: HTMLDivElement;
|
|
let chart: ECharts | undefined;
|
|
let ready = $state(false);
|
|
|
|
const series = $derived([
|
|
{
|
|
name: 'Weight (kg)',
|
|
yAxisIndex: 0,
|
|
data: points.filter((p) => p.weightKg !== null).map((p) => [Date.parse(p.date), p.weightKg])
|
|
},
|
|
{
|
|
name: 'Waist (cm)',
|
|
yAxisIndex: 1,
|
|
data: points.filter((p) => p.waistCm !== null).map((p) => [Date.parse(p.date), p.waistCm])
|
|
}
|
|
]);
|
|
|
|
function update() {
|
|
if (!chart) return;
|
|
const style = getComputedStyle(document.documentElement);
|
|
const foreground = style.getPropertyValue('--site-text').trim();
|
|
const muted = style.getPropertyValue('--site-muted').trim();
|
|
const border = style.getPropertyValue('--site-border').trim();
|
|
const dark = style.getPropertyValue('--site-bg').trim() === '#000';
|
|
const axis = (name: string) => ({
|
|
type: 'value' as const,
|
|
name,
|
|
scale: true,
|
|
nameTextStyle: { color: muted },
|
|
axisLabel: { color: muted },
|
|
splitLine: { lineStyle: { color: border } }
|
|
});
|
|
chart.setOption(
|
|
{
|
|
animation: false,
|
|
backgroundColor: 'transparent',
|
|
grid: { left: 48, right: 48, top: 36, bottom: 28 },
|
|
legend: { top: 0, textStyle: { color: muted } },
|
|
xAxis: {
|
|
type: 'time',
|
|
axisLine: { lineStyle: { color: border } },
|
|
axisLabel: { color: muted },
|
|
splitLine: { show: false }
|
|
},
|
|
yAxis: [axis('kg'), { ...axis('cm'), splitLine: { show: false } }],
|
|
tooltip: {
|
|
trigger: 'axis',
|
|
backgroundColor: style.getPropertyValue('--site-surface').trim(),
|
|
borderColor: border,
|
|
textStyle: { color: foreground }
|
|
},
|
|
series: series.map((s, i) => ({
|
|
...s,
|
|
type: 'line',
|
|
showSymbol: s.data.length < 60,
|
|
symbolSize: 5,
|
|
itemStyle: { color: `hsl(${i ? 200 : 25} 70% ${dark ? '65%' : '40%'})` },
|
|
lineStyle: { width: 2 }
|
|
}))
|
|
},
|
|
true
|
|
);
|
|
}
|
|
|
|
onMount(() => {
|
|
let disposed = false;
|
|
let resize: ResizeObserver | undefined;
|
|
let theme: MutationObserver | undefined;
|
|
import('echarts/core').then(async (echarts) => {
|
|
const [
|
|
{ LineChart },
|
|
{ GridComponent, TooltipComponent, LegendComponent },
|
|
{ CanvasRenderer }
|
|
] = await Promise.all([
|
|
import('echarts/charts'),
|
|
import('echarts/components'),
|
|
import('echarts/renderers')
|
|
]);
|
|
if (disposed) return;
|
|
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer]);
|
|
chart = echarts.init(element, undefined, { renderer: 'canvas' });
|
|
ready = true;
|
|
update();
|
|
resize = new ResizeObserver(() => chart?.resize());
|
|
resize.observe(element);
|
|
theme = new MutationObserver(update);
|
|
theme.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ['data-theme']
|
|
});
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
resize?.disconnect();
|
|
theme?.disconnect();
|
|
chart?.dispose();
|
|
};
|
|
});
|
|
$effect(() => {
|
|
if (ready) update();
|
|
});
|
|
</script>
|
|
|
|
<div class="chart" bind:this={element} role="img" aria-label="Weight and waist trend"></div>
|
|
|
|
<style>
|
|
.chart {
|
|
width: 100%;
|
|
height: 240px;
|
|
}
|
|
</style>
|