251 lines
12 KiB
TypeScript
251 lines
12 KiB
TypeScript
'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>
|
|
);
|
|
}
|