feat(CC-081): add Performance Coach page (trend analysis + personalized insights with methodological disclaimer)
This commit is contained in:
parent
c466deedab
commit
b6589a7091
1 changed files with 251 additions and 0 deletions
251
src/app/dashboard/coach/page.tsx
Normal file
251
src/app/dashboard/coach/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue