feat(CC-074): add Energy & Wellbeing page (quick log + bar charts per metric)
This commit is contained in:
parent
aab44f71b3
commit
71a23cabcc
1 changed files with 198 additions and 0 deletions
198
src/app/dashboard/energy/page.tsx
Normal file
198
src/app/dashboard/energy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue