feat(CC-081): add Performance Coach page (trend analysis + personalized insights with methodological disclaimer)

This commit is contained in:
admin-valentin 2026-08-02 13:05:43 +00:00
parent c466deedab
commit b6589a7091

View file

@ -0,0 +1,251 @@
'use client';
import { useMemo, useState } 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; createdAt: string; observedAt: string | null; }
interface Task { id: string; status: string; priority: string | null; dueDate: string | null; tags: string[]; createdAt: string; }
interface Goal { id: string; title: string; status: string; progress: number | null; targetDate: string | null; }
const NUMERIC_OBS = ['energie', 'energy', 'somn', 'sleep', 'focus', 'stare', 'activitate', 'hidratare', 'mood', 'stress', 'productivitate'];
function parseNum(v: string): number | null {
const n = parseFloat(v.replace(',', '.'));
return isNaN(n) ? null : n;
}
function movingAvg(values: number[], window: number): number[] {
return values.map((_, i) => {
const slice = values.slice(Math.max(0, i - window + 1), i + 1);
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
}
function trend(values: number[]): 'up' | 'down' | 'flat' {
if (values.length < 3) return 'flat';
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
const earlier = values.slice(0, Math.max(1, values.length - 3)).reduce((a, b) => a + b, 0) / Math.max(1, values.length - 3);
if (recent > earlier * 1.05) return 'up';
if (recent < earlier * 0.95) return 'down';
return 'flat';
}
interface CoachInsight { type: 'strength' | 'warning' | 'suggestion'; message: string; metric?: string; }
export default function PerformanceCoachPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [period, setPeriod] = useState(30);
const { data: observations = [], isLoading: oL } = useQuery({
queryKey: ['coach-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: tasks = [] } = useQuery({
queryKey: ['coach-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: goals = [] } = useQuery({
queryKey: ['coach-goals', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const cutoff = useMemo(() => new Date(Date.now() - period * 86400_000), [period]);
const analytics = useMemo(() => {
const recentObs = observations.filter((o) => new Date(o.observedAt ?? o.createdAt) >= cutoff);
// Group numeric metrics
const metricMap: Record<string, { date: Date; value: number }[]> = {};
for (const o of recentObs) {
const val = parseNum(o.value);
if (val === null) continue;
const isNumericMetric = NUMERIC_OBS.some((k) => o.metric.toLowerCase().includes(k));
if (isNumericMetric || o.unit === '/10' || o.unit === 'ore' || o.unit === 'min') {
const date = new Date(o.observedAt ?? o.createdAt);
metricMap[o.metric] = [...(metricMap[o.metric] ?? []), { date, value: val }];
}
}
// Sort each metric by date
const metrics = Object.entries(metricMap)
.map(([name, entries]) => {
const sorted = [...entries].sort((a, b) => a.date.getTime() - b.date.getTime());
const values = sorted.map((e) => e.value);
const avg = values.reduce((a, b) => a + b, 0) / values.length;
const latest = values[values.length - 1];
const t = trend(values);
const ma = movingAvg(values, 3);
return { name, values, avg, latest, trend: t, ma, dates: sorted.map((e) => e.date) };
})
.sort((a, b) => b.values.length - a.values.length)
.slice(0, 6);
// Task completion rate
const recentTasks = tasks.filter((t) => new Date(t.createdAt) >= cutoff);
const completedTasks = recentTasks.filter((t) => t.status === 'completed');
const taskCompletionRate = recentTasks.length > 0 ? Math.round((completedTasks.length / recentTasks.length) * 100) : null;
// Overdue rate
const overdueCount = tasks.filter((t) =>
t.status !== 'completed' && t.dueDate && new Date(t.dueDate) < new Date()
).length;
// Active goals progress
const activeGoals = goals.filter((g) => g.status === 'active');
const avgGoalProgress = activeGoals.length > 0
? Math.round(activeGoals.reduce((s, g) => s + (g.progress ?? 0), 0) / activeGoals.length)
: null;
// Generate insights
const insights: CoachInsight[] = [];
for (const m of metrics) {
if (m.trend === 'up' && m.latest > m.avg) {
insights.push({ type: 'strength', message: `${m.name} este în creștere — medie ${m.avg.toFixed(1)}, recent ${m.latest.toFixed(1)}.`, metric: m.name });
} else if (m.trend === 'down' && m.values.length >= 3) {
insights.push({ type: 'warning', message: `${m.name} scade — ultimele valori arată un trend negativ.`, metric: m.name });
}
}
if (taskCompletionRate !== null) {
if (taskCompletionRate < 50) {
insights.push({ type: 'warning', message: `Rata de finalizare taskuri este ${taskCompletionRate}% — sub jumătate. Prioritizează sau redimensionează.` });
} else if (taskCompletionRate >= 80) {
insights.push({ type: 'strength', message: `Excelent! ${taskCompletionRate}% din taskuri finalizate în ultimele ${period} zile.` });
}
}
if (overdueCount >= 5) {
insights.push({ type: 'warning', message: `${overdueCount} taskuri depășite acumulat. Revizuiește și reprogramează sau marchează ca neaplicabile.` });
}
if (avgGoalProgress !== null && avgGoalProgress < 30) {
insights.push({ type: 'suggestion', message: `Progres mediu obiective: ${avgGoalProgress}%. Consideră descompunerea obiectivelor în sub-taskuri mai mici.` });
}
if (recentObs.length < 5) {
insights.push({ type: 'suggestion', message: `Numai ${recentObs.length} observații în ultimele ${period} zile. Logarea zilnică a metricilor crește acuratețea analizei.` });
}
if (insights.length === 0) {
insights.push({ type: 'strength', message: 'Date insuficiente pentru insight-uri personalizate. Continuă să loghezi observații zilnic.' });
}
return { metrics, taskCompletionRate, overdueCount, avgGoalProgress, insights, totalObs: recentObs.length };
}, [observations, tasks, goals, cutoff]);
const TREND_ICON = { up: '↑', down: '↓', flat: '→' };
const TREND_CLS = { up: 'text-signal-ok', down: 'text-signal-danger', flat: 'text-ink-faint' };
const INSIGHT_CLS = { strength: 'border-signal-ok/30 bg-signal-ok/5', warning: 'border-signal-danger/30 bg-signal-danger/5', suggestion: 'border-primary/20 bg-primary/5' };
const INSIGHT_ICON = { strength: '💪', warning: '⚠', suggestion: '💡' };
return (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-start justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Performance Coach</h1>
<p className="text-sm text-ink-faint mt-1">
Analiză de pattern-uri din observațiile și taskurile tale personale.
</p>
</div>
<div className="flex gap-1">
{[14, 30, 90].map((p) => (
<button key={p} onClick={() => setPeriod(p)}
className={`rounded border px-3 py-1.5 text-xs ${period === p ? 'bg-primary/10 border-primary text-ink' : 'bg-card border-border text-ink-faint'}`}>
{p}z
</button>
))}
</div>
</div>
<div className="card p-4 bg-warn/5 border-warn/30">
<p className="text-xs text-ink-faint">
<strong>Metodologie și limitări:</strong> Analiză bazată exclusiv pe date auto-raportate în CEO OS ({analytics.totalObs} observații).
Nu înlocuiește evaluarea profesională medicală, psihologică sau de coaching. Tendințele reflectă datele disponibile, nu realitatea completă.
</p>
</div>
{/* KPI cards */}
<div className="grid grid-cols-3 gap-3">
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${analytics.taskCompletionRate !== null ? (analytics.taskCompletionRate >= 70 ? 'text-signal-ok' : 'text-warn') : 'text-ink-faint'}`}>
{analytics.taskCompletionRate !== null ? `${analytics.taskCompletionRate}%` : '—'}
</p>
<p className="text-[10px] text-ink-faint">rata finalizare taskuri</p>
</div>
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${analytics.overdueCount > 5 ? 'text-signal-danger' : analytics.overdueCount > 0 ? 'text-warn' : 'text-signal-ok'}`}>
{analytics.overdueCount}
</p>
<p className="text-[10px] text-ink-faint">taskuri depășite</p>
</div>
<div className="card p-4 text-center">
<p className={`text-2xl font-bold ${analytics.avgGoalProgress !== null ? (analytics.avgGoalProgress >= 60 ? 'text-signal-ok' : 'text-warn') : 'text-ink-faint'}`}>
{analytics.avgGoalProgress !== null ? `${analytics.avgGoalProgress}%` : '—'}
</p>
<p className="text-[10px] text-ink-faint">progres mediu obiective</p>
</div>
</div>
{/* Insights */}
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Insights personalizate</p>
{analytics.insights.map((insight, i) => (
<div key={i} className={`card p-4 border ${INSIGHT_CLS[insight.type]}`}>
<div className="flex items-start gap-2">
<span>{INSIGHT_ICON[insight.type]}</span>
<p className="text-xs text-ink">{insight.message}</p>
</div>
</div>
))}
</div>
{/* Metric trends */}
{analytics.metrics.length > 0 && (
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Trendul metricilor ({period} zile)</p>
<div className="grid gap-4 sm:grid-cols-2">
{analytics.metrics.map((m) => {
const max = Math.max(...m.values, 0.1);
return (
<div key={m.name} className="card p-4 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-ink capitalize">{m.name}</p>
<div className="flex items-center gap-1">
<span className={`text-xs font-bold ${TREND_CLS[m.trend]}`}>{TREND_ICON[m.trend]}</span>
<span className="text-xs text-ink-faint">{m.avg.toFixed(1)} avg</span>
</div>
</div>
<div className="flex items-end gap-0.5 h-8">
{m.values.slice(-20).map((v, i) => (
<div key={i} className="flex-1 rounded-sm transition-all"
style={{
height: `${(v / max) * 100}%`,
backgroundColor: v >= m.avg ? 'rgb(var(--primary) / 0.6)' : 'rgb(var(--muted-foreground) / 0.2)',
}} />
))}
</div>
<div className="flex justify-between text-[10px] text-ink-faint">
<span>min: {Math.min(...m.values).toFixed(1)}</span>
<span>max: {Math.max(...m.values).toFixed(1)}</span>
<span>ultimul: {m.latest.toFixed(1)}</span>
</div>
</div>
);
})}
</div>
</div>
)}
{oL && (
<div className="text-center text-sm text-ink-faint py-4">Se calculează</div>
)}
</div>
);
}