From 71a23cabcc3c91f08db81c1ac4b808bffe48a073 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 12:38:18 +0000 Subject: [PATCH] feat(CC-074): add Energy & Wellbeing page (quick log + bar charts per metric) --- src/app/dashboard/energy/page.tsx | 198 ++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 src/app/dashboard/energy/page.tsx diff --git a/src/app/dashboard/energy/page.tsx b/src/app/dashboard/energy/page.tsx new file mode 100644 index 0000000..12bfde5 --- /dev/null +++ b/src/app/dashboard/energy/page.tsx @@ -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>({}); + + const { data: rawObs = [], isLoading } = useQuery({ + queryKey: ['energy-obs', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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('/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 ( +
+
+

Energie & Wellbeing

+

+ {todayObs.length} înregistrări azi · {observations.length} în {period} zile +

+
+ + {/* Quick log */} +
+

Înregistrare rapidă — {today}

+
+ {QUICK_METRICS.map((qm) => ( +
+ {qm.icon} +
+

{qm.metric} ({qm.unit})

+
+ 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" + /> + +
+
+
+ ))} +
+
+ + {/* Period selector */} +
+ {[7, 14, 30, 90].map((d) => ( + + ))} +
+ + {/* Metric cards */} + {isLoading ? ( +
Se încarcă…
+ ) : Object.keys(byMetric).length === 0 ? ( +
+

💚

+

Nicio observație de wellbeing. Înregistrează prima lectură mai sus.

+
+ ) : ( +
+ {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 ( +
+
+ {icon} +

{metric}

+
+
+

+ {isNaN(latestNum) ? latest.value : latestNum.toFixed(1)} + {unit} +

+ {nums.length > 1 && ( +

+ media {period}z: {avgVal.toFixed(1)}{unit} · {obs.length} măsurători +

+ )} +
+ + {/* Bar chart */} + {nums.length > 1 && ( +
+ {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 ( +
+ ); + })} +
+ )} + +

+ {new Date(latest.observedAt ?? latest.createdAt).toLocaleDateString('ro-RO')} · {latest.source} +

+
+ ); + })} +
+ )} +
+ ); +}