diff --git a/src/app/dashboard/coach/page.tsx b/src/app/dashboard/coach/page.tsx new file mode 100644 index 0000000..ea56157 --- /dev/null +++ b/src/app/dashboard/coach/page.tsx @@ -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('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: tasks = [] } = useQuery({ + queryKey: ['coach-tasks', tenantId], + queryFn: () => apiFetch('/v1/tasks?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: goals = [] } = useQuery({ + queryKey: ['coach-goals', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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 ( +
+
+
+

Performance Coach

+

+ Analiză de pattern-uri din observațiile și taskurile tale personale. +

+
+
+ {[14, 30, 90].map((p) => ( + + ))} +
+
+ +
+

+ Metodologie și limitări: 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ă. +

+
+ + {/* KPI cards */} +
+
+

= 70 ? 'text-signal-ok' : 'text-warn') : 'text-ink-faint'}`}> + {analytics.taskCompletionRate !== null ? `${analytics.taskCompletionRate}%` : '—'} +

+

rata finalizare taskuri

+
+
+

5 ? 'text-signal-danger' : analytics.overdueCount > 0 ? 'text-warn' : 'text-signal-ok'}`}> + {analytics.overdueCount} +

+

taskuri depășite

+
+
+

= 60 ? 'text-signal-ok' : 'text-warn') : 'text-ink-faint'}`}> + {analytics.avgGoalProgress !== null ? `${analytics.avgGoalProgress}%` : '—'} +

+

progres mediu obiective

+
+
+ + {/* Insights */} +
+

Insights personalizate

+ {analytics.insights.map((insight, i) => ( +
+
+ {INSIGHT_ICON[insight.type]} +

{insight.message}

+
+
+ ))} +
+ + {/* Metric trends */} + {analytics.metrics.length > 0 && ( +
+

Trendul metricilor ({period} zile)

+
+ {analytics.metrics.map((m) => { + const max = Math.max(...m.values, 0.1); + return ( +
+
+

{m.name}

+
+ {TREND_ICON[m.trend]} + {m.avg.toFixed(1)} avg +
+
+
+ {m.values.slice(-20).map((v, i) => ( +
= m.avg ? 'rgb(var(--primary) / 0.6)' : 'rgb(var(--muted-foreground) / 0.2)', + }} /> + ))} +
+
+ min: {Math.min(...m.values).toFixed(1)} + max: {Math.max(...m.values).toFixed(1)} + ultimul: {m.latest.toFixed(1)} +
+
+ ); + })} +
+
+ )} + + {oL && ( +
Se calculează…
+ )} +
+ ); +}