From 1faab04670a564358dd1219b98cf8443d4f4de26 Mon Sep 17 00:00:00 2001 From: admin-valentin Date: Sun, 2 Aug 2026 18:17:11 +0000 Subject: [PATCH] feat(CC-092): add Personal KPI page (8 metrics, target comparison, 7-day sparklines) --- src/app/dashboard/kpi/page.tsx | 152 +++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 src/app/dashboard/kpi/page.tsx diff --git a/src/app/dashboard/kpi/page.tsx b/src/app/dashboard/kpi/page.tsx new file mode 100644 index 0000000..a98541f --- /dev/null +++ b/src/app/dashboard/kpi/page.tsx @@ -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('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: tasks = [], isLoading: loadTasks } = useQuery({ + queryKey: ['kpi-tasks', tenantId], + queryFn: () => apiFetch('/v1/tasks?limit=1000', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const { data: goals = [] } = useQuery({ + queryKey: ['kpi-goals', tenantId], + queryFn: () => apiFetch('/v1/goals?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + + const tasksByDay = useMemo(() => { + const map: Record = {}; + 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 ( +
+
+

Personal KPI

+

Metrici personale cheie · media ultimelor 7 zile

+
+ + {/* Summary row */} +
+
+

Tasks completate săptămâna asta

+

{completedTasksThisWeek}

+
+
+

Progres mediu goals active

+

{activeGoalsProgress !== null ? `${activeGoalsProgress}%` : '—'}

+
+
+

Observații logate (30z)

+

+ {observations.filter((o) => (o.observedAt ?? o.createdAt) >= LAST_30[0]).length} +

+
+
+ + {/* KPI grid */} +
+ {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 ( +
+
+
+ {kpi.icon} +
+

{kpi.label}

+

Target: {kpi.target} {kpi.unit}

+
+
+

+ {avg !== null ? `${avg}` : '—'} + {avg !== null && {kpi.unit}} +

+
+
+
+
+ {/* 7-day sparkline dots */} +
+ {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 ( +
+ ); + })} +
+
+ 7z în urmăAzi +
+
+ ); + })} +
+
+ ); +}