feat(CC-092): add Personal KPI page (8 metrics, target comparison, 7-day sparklines)
This commit is contained in:
parent
99bfb467c6
commit
1faab04670
1 changed files with 152 additions and 0 deletions
152
src/app/dashboard/kpi/page.tsx
Normal file
152
src/app/dashboard/kpi/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue