216 lines
10 KiB
TypeScript
216 lines
10 KiB
TypeScript
'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 PERIODS = [
|
|
{ label: '7 zile', days: 7 },
|
|
{ label: '30 zile', days: 30 },
|
|
{ label: '90 zile', days: 90 },
|
|
{ label: 'Toate', days: 0 },
|
|
];
|
|
|
|
const PERSONAL_SUBJECT_TYPES = ['person', 'self', 'health', 'energy', 'productivity', 'custom'];
|
|
|
|
export default function PersonalKPIPage() {
|
|
const { activeTenant } = useSession();
|
|
const tenantId = activeTenant?.tenantId ?? '';
|
|
const qc = useQueryClient();
|
|
|
|
const [period, setPeriod] = useState(30);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [form, setForm] = useState({
|
|
subjectType: 'self', subjectId: 'me', metric: '', value: '', unit: '', source: 'self', confidence: '0.9',
|
|
});
|
|
|
|
const { data: rawObs = [], isLoading } = useQuery({
|
|
queryKey: ['personal-kpi', tenantId],
|
|
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
|
|
enabled: Boolean(tenantId), staleTime: 30_000,
|
|
});
|
|
|
|
// Filter to personal subject types only
|
|
const observations = useMemo(() => {
|
|
const cutoff = period > 0 ? Date.now() - period * 86400_000 : 0;
|
|
return rawObs.filter((o) =>
|
|
PERSONAL_SUBJECT_TYPES.includes(o.subjectType) &&
|
|
(cutoff === 0 || new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff)
|
|
);
|
|
}, [rawObs, period]);
|
|
|
|
// Group by metric
|
|
const byMetric = useMemo(() => {
|
|
const m: Record<string, Observation[]> = {};
|
|
for (const o of observations) {
|
|
m[o.metric] = [...(m[o.metric] ?? []), o];
|
|
}
|
|
// Sort each group by date
|
|
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 createMut = useMutation({
|
|
mutationFn: (body: Record<string, string>) =>
|
|
apiFetch<Observation>('/v1/observations', { tenantId, method: 'POST', body }),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['personal-kpi', tenantId] });
|
|
setShowCreate(false);
|
|
setForm({ subjectType: 'self', subjectId: 'me', metric: '', value: '', unit: '', source: 'self', confidence: '0.9' });
|
|
},
|
|
});
|
|
|
|
// Trend: last vs first value (numeric)
|
|
function trend(obs: Observation[]): { dir: 'up' | 'down' | 'flat'; pct: number } {
|
|
if (obs.length < 2) return { dir: 'flat', pct: 0 };
|
|
const first = parseFloat(obs[0].value);
|
|
const last = parseFloat(obs[obs.length - 1].value);
|
|
if (isNaN(first) || isNaN(last) || first === 0) return { dir: 'flat', pct: 0 };
|
|
const pct = Math.round(((last - first) / Math.abs(first)) * 100);
|
|
return { dir: pct > 0 ? 'up' : pct < 0 ? 'down' : 'flat', pct: Math.abs(pct) };
|
|
}
|
|
|
|
const TREND_CLS = { up: 'text-signal-ok', down: 'text-signal-danger', flat: 'text-ink-faint' };
|
|
const TREND_ICON = { up: '↑', down: '↓', flat: '→' };
|
|
|
|
return (
|
|
<div className="max-w-4xl space-y-6 p-6">
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div>
|
|
<h1 className="font-display text-2xl font-semibold text-ink">Personal KPI</h1>
|
|
<p className="text-sm text-ink-faint mt-1">
|
|
Metrici personale cu trend, perioadă și sursă.
|
|
{observations.length > 0 && ` · ${observations.length} observații`}
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowCreate(!showCreate)}
|
|
className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
|
+ Observație
|
|
</button>
|
|
</div>
|
|
|
|
{/* Period selector */}
|
|
<div className="flex gap-1 border border-border rounded-lg overflow-hidden w-fit text-xs">
|
|
{PERIODS.map((p) => (
|
|
<button key={p.days} onClick={() => setPeriod(p.days)}
|
|
className={`px-3 py-1.5 transition-colors ${period === p.days ? 'bg-primary/10 text-ink font-medium' : 'text-ink-faint hover:text-ink'}`}>
|
|
{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Create form */}
|
|
{showCreate && (
|
|
<div className="card p-5 space-y-4">
|
|
<h2 className="text-sm font-semibold text-ink">Observație personală</h2>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<select value={form.subjectType} onChange={(e) => setForm({ ...form, subjectType: e.target.value })}
|
|
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring">
|
|
{PERSONAL_SUBJECT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
</select>
|
|
<input placeholder="Subject ID (ex: me, health-2024)" value={form.subjectId}
|
|
onChange={(e) => setForm({ ...form, subjectId: e.target.value })}
|
|
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
<input placeholder="Metric * (ex: energie, somn, focus)" value={form.metric}
|
|
onChange={(e) => setForm({ ...form, metric: e.target.value })}
|
|
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
<input placeholder="Valoare * (ex: 7.5, 8h, 85%)" value={form.value}
|
|
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
|
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
<input placeholder="Unitate (ex: /10, ore, %)" value={form.unit}
|
|
onChange={(e) => setForm({ ...form, unit: e.target.value })}
|
|
className="rounded-lg border bg-card px-3 py-2 text-sm text-ink focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs text-ink-faint shrink-0">Confidence</label>
|
|
<input type="range" min="0" max="1" step="0.05" value={form.confidence}
|
|
onChange={(e) => setForm({ ...form, confidence: e.target.value })}
|
|
className="flex-1" />
|
|
<span className="text-xs font-mono w-8 text-right">{Math.round(parseFloat(form.confidence) * 100)}%</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => createMut.mutate({ subjectType: form.subjectType, subjectId: form.subjectId, metric: form.metric, value: form.value, unit: form.unit, source: form.source, confidence: form.confidence })}
|
|
disabled={!form.metric || !form.value || createMut.isPending}
|
|
className="btn btn-primary px-4 py-1.5 text-sm rounded-lg disabled:opacity-50">
|
|
{createMut.isPending ? 'Se salvează…' : 'Salvează'}
|
|
</button>
|
|
<button onClick={() => setShowCreate(false)} className="text-sm text-ink-faint hover:text-ink px-2">Anulează</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* KPI cards */}
|
|
{isLoading ? (
|
|
<div className="text-center text-sm text-ink-faint py-8">Se încarcă…</div>
|
|
) : Object.keys(byMetric).length === 0 ? (
|
|
<div className="card p-10 text-center space-y-3">
|
|
<p className="text-3xl">📈</p>
|
|
<p className="text-sm font-medium text-ink">Niciun KPI personal înregistrat</p>
|
|
<p className="text-xs text-ink-faint">
|
|
Adaugă prima observație personală: energie, somn, focus, activitate fizică, sau orice metrică proprie.
|
|
</p>
|
|
<button onClick={() => setShowCreate(true)} className="btn btn-primary px-4 py-2 text-sm rounded-lg">
|
|
+ Prima observație
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{Object.entries(byMetric).map(([metric, obs]) => {
|
|
const latest = obs[obs.length - 1];
|
|
const t = trend(obs);
|
|
return (
|
|
<div key={metric} className="card p-5 space-y-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<p className="text-sm font-semibold text-ink">{metric}</p>
|
|
<span className={`text-xs font-medium ${TREND_CLS[t.dir]}`}>
|
|
{TREND_ICON[t.dir]}{t.pct > 0 ? ` ${t.pct}%` : ''}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<p className="font-display text-2xl font-bold text-ink">
|
|
{latest.value}{latest.unit ? <span className="text-base text-ink-faint ml-1">{latest.unit}</span> : ''}
|
|
</p>
|
|
<p className="text-[10px] text-ink-faint mt-0.5">
|
|
{new Date(latest.observedAt ?? latest.createdAt).toLocaleDateString('ro-RO')} · {latest.source}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sparkline as text dots */}
|
|
{obs.length > 1 && (
|
|
<div className="flex items-end gap-0.5 h-8">
|
|
{obs.map((o, i) => {
|
|
const v = parseFloat(o.value);
|
|
const allNums = obs.map((x) => parseFloat(x.value)).filter((x) => !isNaN(x));
|
|
const min = Math.min(...allNums);
|
|
const max = Math.max(...allNums);
|
|
const range = max - min || 1;
|
|
const h = isNaN(v) ? 4 : Math.round(4 + ((v - min) / range) * 28);
|
|
return (
|
|
<div key={i} title={o.value}
|
|
className="flex-1 bg-primary/30 rounded-sm hover:bg-primary/60 transition-colors"
|
|
style={{ height: `${h}px` }} />
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<p className="text-[10px] text-ink-faint">
|
|
{obs.length} puncte · confidence {Math.round((parseFloat(latest.confidence?.toString() ?? '1')) * 100)}%
|
|
</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|