feat(CC-092): add Personal KPI page (8 metrics, target comparison, 7-day sparklines)

This commit is contained in:
admin-valentin 2026-08-02 18:17:11 +00:00
parent 99bfb467c6
commit 1faab04670

View file

@ -0,0 +1,152 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Observation { id: string; metric: string; value: string; unit: string | null; subjectType: string; confidence: number | null; observedAt: string | null; createdAt: string; }
interface Task { id: string; status: string; updatedAt: string; }
interface Goal { id: string; status: string; progress: number | null; }
const KPI_DEFS: { id: string; label: string; icon: string; metric: string; unit: string; target: number; higher: boolean; }[] = [
{ id: 'energy', label: 'Energie', icon: '⚡', metric: 'energy-level', unit: '/5', target: 4, higher: true },
{ id: 'sleep', label: 'Somn', icon: '😴', metric: 'somn', unit: 'ore', target: 7.5, higher: true },
{ id: 'focus', label: 'Focus sessions', icon: '🍅', metric: 'focus-session', unit: '/zi', target: 2, higher: true },
{ id: 'exercise', label: 'Exerciții', icon: '🏃', metric: 'exercitiu', unit: 'min', target: 30, higher: true },
{ id: 'reading', label: 'Lectură', icon: '📚', metric: 'lectura', unit: 'pag', target: 20, higher: true },
{ id: 'water', label: 'Hidratare', icon: '💧', metric: 'apa', unit: 'ph', target: 8, higher: true },
{ id: 'tasks', label: 'Tasks/zi', icon: '✅', metric: '__tasks__', unit: 'tasks',target: 5, higher: true },
{ id: 'mood', label: 'Mood', icon: '😊', metric: 'standup-mood', unit: '/5', target: 4, higher: true },
];
const LAST_7 = Array.from({ length: 7 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - (6 - i)); return d.toISOString().slice(0, 10); });
const LAST_30 = Array.from({ length: 30 }, (_, i) => { const d = new Date(); d.setDate(d.getDate() - (29 - i)); return d.toISOString().slice(0, 10); });
export default function KPIPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: observations = [], isLoading: loadObs } = useQuery({
queryKey: ['kpi-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: tasks = [], isLoading: loadTasks } = useQuery({
queryKey: ['kpi-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=1000', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: goals = [] } = useQuery({
queryKey: ['kpi-goals', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const tasksByDay = useMemo(() => {
const map: Record<string, number> = {};
for (const t of tasks.filter((t) => t.status === 'completed')) {
const d = t.updatedAt.slice(0, 10);
map[d] = (map[d] ?? 0) + 1;
}
return map;
}, [tasks]);
function getValue(kpiId: string, metric: string, day: string): number | null {
if (kpiId === 'tasks') return tasksByDay[day] ?? null;
const obs = observations
.filter((o) => o.metric === metric && (o.observedAt ?? o.createdAt).slice(0, 10) === day)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
if (obs.length === 0) return null;
return parseFloat(obs[0].value) || null;
}
function avg7(kpiId: string, metric: string): number | null {
const vals = LAST_7.map((d) => getValue(kpiId, metric, d)).filter((v): v is number => v !== null);
return vals.length > 0 ? Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10 : null;
}
const activeGoalsProgress = useMemo(() => {
const ag = goals.filter((g) => g.status === 'active');
if (ag.length === 0) return null;
return Math.round(ag.reduce((s, g) => s + (g.progress ?? 0), 0) / ag.length);
}, [goals]);
const completedTasksThisWeek = LAST_7.reduce((s, d) => s + (tasksByDay[d] ?? 0), 0);
return (
<div className="max-w-4xl space-y-6 p-6">
<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 cheie · media ultimelor 7 zile</p>
</div>
{/* Summary row */}
<div className="grid gap-3 sm:grid-cols-3">
<div className="card p-4 space-y-1">
<p className="text-xs text-ink-faint">Tasks completate săptămâna asta</p>
<p className="text-2xl font-bold text-primary">{completedTasksThisWeek}</p>
</div>
<div className="card p-4 space-y-1">
<p className="text-xs text-ink-faint">Progres mediu goals active</p>
<p className="text-2xl font-bold text-ink">{activeGoalsProgress !== null ? `${activeGoalsProgress}%` : '—'}</p>
</div>
<div className="card p-4 space-y-1">
<p className="text-xs text-ink-faint">Observații logate (30z)</p>
<p className="text-2xl font-bold text-ink">
{observations.filter((o) => (o.observedAt ?? o.createdAt) >= LAST_30[0]).length}
</p>
</div>
</div>
{/* KPI grid */}
<div className="grid gap-4 sm:grid-cols-2">
{KPI_DEFS.map((kpi) => {
const avg = avg7(kpi.id, kpi.metric);
const pct = avg !== null ? Math.min((avg / kpi.target) * 100, 120) : 0;
const isGood = avg !== null && (kpi.higher ? avg >= kpi.target : avg <= kpi.target);
const isWarn = avg !== null && !isGood && (kpi.higher ? avg >= kpi.target * 0.7 : avg <= kpi.target * 1.3);
const colorBar = isGood ? 'bg-signal-ok' : isWarn ? 'bg-warn' : avg !== null ? 'bg-signal-danger/60' : 'bg-muted';
const colorVal = isGood ? 'text-signal-ok' : isWarn ? 'text-warn' : avg !== null ? 'text-signal-danger' : 'text-ink-faint';
return (
<div key={kpi.id} className="card p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xl">{kpi.icon}</span>
<div>
<p className="text-sm font-medium text-ink">{kpi.label}</p>
<p className="text-[10px] text-ink-faint">Target: {kpi.target} {kpi.unit}</p>
</div>
</div>
<p className={`text-xl font-bold ${colorVal}`}>
{avg !== null ? `${avg}` : '—'}
{avg !== null && <span className="text-xs font-normal ml-0.5">{kpi.unit}</span>}
</p>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full transition-all ${colorBar}`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
{/* 7-day sparkline dots */}
<div className="flex gap-1">
{LAST_7.map((day) => {
const val = getValue(kpi.id, kpi.metric, day);
const good = val !== null && (kpi.higher ? val >= kpi.target : val <= kpi.target);
return (
<div key={day} title={`${day}: ${val ?? '—'} ${kpi.unit}`}
className={`flex-1 h-2 rounded-full ${val === null ? 'bg-muted' : good ? 'bg-signal-ok/70' : 'bg-warn/70'}`} />
);
})}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
<span>7z în urmă</span><span>Azi</span>
</div>
</div>
);
})}
</div>
</div>
);
}