feat(CC-074): add Energy & Wellbeing page (quick log + bar charts per metric)

This commit is contained in:
admin-valentin 2026-08-02 12:38:18 +00:00
parent aab44f71b3
commit 71a23cabcc

View file

@ -0,0 +1,198 @@
'use client';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Observation {
id: string; subjectType: string; subjectId: string; metric: string;
value: string; unit: string | null; source: string;
confidence: number | null; observedAt: string | null; createdAt: string;
}
const HEALTH_TYPES = ['health', 'energy', 'wellbeing', 'sleep', 'fitness', 'mood', 'nutrition'];
const QUICK_METRICS = [
{ metric: 'energie', unit: '/10', placeholder: '7.5', icon: '⚡', subjectType: 'energy' },
{ metric: 'somn', unit: 'ore', placeholder: '7.5', icon: '😴', subjectType: 'sleep' },
{ metric: 'stare generala', unit: '/10', placeholder: '7', icon: '😊', subjectType: 'mood' },
{ metric: 'activitate fizica', unit: 'min', placeholder: '30', icon: '🏃', subjectType: 'fitness' },
{ metric: 'hidratare', unit: 'L', placeholder: '2.0', icon: '💧', subjectType: 'health' },
{ metric: 'focus', unit: '/10', placeholder: '8', icon: '🎯', subjectType: 'energy' },
];
function avg(vals: number[]): number {
if (!vals.length) return 0;
return vals.reduce((a, b) => a + b, 0) / vals.length;
}
export default function EnergyWellbeingPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const qc = useQueryClient();
const [period, setPeriod] = useState(14);
const [quickVals, setQuickVals] = useState<Record<string, string>>({});
const { data: rawObs = [], isLoading } = useQuery({
queryKey: ['energy-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 30_000,
});
const observations = useMemo(() => {
const cutoff = Date.now() - period * 86400_000;
return rawObs.filter((o) =>
HEALTH_TYPES.includes(o.subjectType) &&
new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff
);
}, [rawObs, period]);
const byMetric = useMemo(() => {
const m: Record<string, Observation[]> = {};
for (const o of observations) {
m[o.metric] = [...(m[o.metric] ?? []), o];
}
for (const k of Object.keys(m)) {
m[k].sort((a, b) => new Date(a.observedAt ?? a.createdAt).getTime() - new Date(b.observedAt ?? b.createdAt).getTime());
}
return m;
}, [observations]);
const logMut = useMutation({
mutationFn: ({ metric, value, unit, subjectType }: { metric: string; value: string; unit: string; subjectType: string }) =>
apiFetch<Observation>('/v1/observations', {
tenantId, method: 'POST',
body: { subjectType, subjectId: 'me', metric, value, unit, source: 'self', confidence: '0.95' },
}),
onSuccess: (_, vars) => {
qc.invalidateQueries({ queryKey: ['energy-obs', tenantId] });
setQuickVals((prev) => ({ ...prev, [vars.metric]: '' }));
},
});
const today = new Date().toLocaleDateString('ro-RO');
const todayObs = observations.filter((o) =>
new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO') === today
);
return (
<div className="max-w-4xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Energie & Wellbeing</h1>
<p className="text-sm text-ink-faint mt-1">
{todayObs.length} înregistrări azi · {observations.length} în {period} zile
</p>
</div>
{/* Quick log */}
<div className="card p-5 space-y-4">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Înregistrare rapidă {today}</p>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{QUICK_METRICS.map((qm) => (
<div key={qm.metric} className="flex items-center gap-2">
<span className="text-lg">{qm.icon}</span>
<div className="flex-1 min-w-0">
<p className="text-[10px] text-ink-faint capitalize">{qm.metric} ({qm.unit})</p>
<div className="flex gap-1">
<input
type="number" step="0.1" placeholder={qm.placeholder}
value={quickVals[qm.metric] ?? ''}
onChange={(e) => setQuickVals({ ...quickVals, [qm.metric]: e.target.value })}
className="flex-1 min-w-0 rounded border bg-card px-2 py-1 text-sm text-ink focus:outline-none focus:ring-1 focus:ring-ring"
/>
<button
onClick={() => logMut.mutate({ metric: qm.metric, value: quickVals[qm.metric] ?? '', unit: qm.unit, subjectType: qm.subjectType })}
disabled={!quickVals[qm.metric] || logMut.isPending}
className="rounded border px-2 text-xs text-primary hover:bg-primary/5 disabled:opacity-40">
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Period selector */}
<div className="flex gap-1 border border-border rounded-lg overflow-hidden w-fit text-xs">
{[7, 14, 30, 90].map((d) => (
<button key={d} onClick={() => setPeriod(d)}
className={`px-3 py-1.5 transition-colors ${period === d ? 'bg-primary/10 text-ink font-medium' : 'text-ink-faint hover:text-ink'}`}>
{d}z
</button>
))}
</div>
{/* Metric cards */}
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-6">Se încarcă</div>
) : Object.keys(byMetric).length === 0 ? (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">💚</p>
<p className="text-sm text-ink-faint">Nicio observație de wellbeing. Înregistrează prima lectură mai sus.</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(byMetric).map(([metric, obs]) => {
const nums = obs.map((o) => parseFloat(o.value)).filter((v) => !isNaN(v));
const avgVal = avg(nums);
const latest = obs[obs.length - 1];
const latestNum = parseFloat(latest.value);
const unit = latest.unit ?? '';
const icon = QUICK_METRICS.find((q) => q.metric === metric)?.icon ?? '📊';
// Simple color based on avg (if has /10 unit)
const score10 = unit === '/10' && !isNaN(avgVal);
const scoreCls = score10
? avgVal >= 7.5 ? 'text-signal-ok' : avgVal >= 5 ? 'text-warn' : 'text-signal-danger'
: 'text-ink';
return (
<div key={metric} className="card p-5 space-y-3">
<div className="flex items-center gap-2">
<span className="text-xl">{icon}</span>
<p className="text-sm font-semibold text-ink capitalize">{metric}</p>
</div>
<div>
<p className={`font-display text-2xl font-bold ${scoreCls}`}>
{isNaN(latestNum) ? latest.value : latestNum.toFixed(1)}
<span className="text-sm font-normal text-ink-faint ml-1">{unit}</span>
</p>
{nums.length > 1 && (
<p className="text-[10px] text-ink-faint">
media {period}z: {avgVal.toFixed(1)}{unit} · {obs.length} măsurători
</p>
)}
</div>
{/* Bar chart */}
{nums.length > 1 && (
<div className="flex items-end gap-0.5 h-10">
{obs.slice(-14).map((o, i) => {
const v = parseFloat(o.value);
const min = Math.min(...nums);
const max = Math.max(...nums);
const range = max - min || 1;
const h = isNaN(v) ? 3 : Math.round(4 + ((v - min) / range) * 36);
const isLast = i === obs.slice(-14).length - 1;
return (
<div key={i} title={`${o.value}${unit}${new Date(o.observedAt ?? o.createdAt).toLocaleDateString('ro-RO')}`}
className={`flex-1 rounded-sm transition-colors ${isLast ? 'bg-primary' : 'bg-primary/25'} hover:bg-primary/60`}
style={{ height: `${h}px` }} />
);
})}
</div>
)}
<p className="text-[10px] text-ink-faint">
{new Date(latest.observedAt ?? latest.createdAt).toLocaleDateString('ro-RO')} · {latest.source}
</p>
</div>
);
})}
</div>
)}
</div>
);
}