Add private health tracker (owner-only)
- 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
This commit is contained in:
118
src/lib/health/BodyChart.svelte
Normal file
118
src/lib/health/BodyChart.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user