diff --git a/src/app/dashboard/life-analytics/page.tsx b/src/app/dashboard/life-analytics/page.tsx new file mode 100644 index 0000000..ae3d2f8 --- /dev/null +++ b/src/app/dashboard/life-analytics/page.tsx @@ -0,0 +1,198 @@ +'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; subjectType: string; subjectId: string; metric: string; + value: string; unit: string | null; source: string; + confidence: number | null; observedAt: string | null; createdAt: string; +} +interface Task { id: string; status: string; createdAt: string; } +interface Decision { id: string; createdAt: string; } +interface Goal { id: string; status: string; createdAt: string; targetDate: string | null; } + +const PERSONAL_TYPES = ['self', 'person', 'health', 'energy', 'sleep', 'mood', 'fitness', 'nutrition', 'wellbeing', 'productivity']; + +function groupByWeek(items: { date: Date }[]): Record { + const counts: Record = {}; + for (const item of items) { + const d = item.date; + const day = d.getDay(); + const monday = new Date(d); + monday.setDate(d.getDate() - (day === 0 ? 6 : day - 1)); + const key = monday.toISOString().slice(0, 10); + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +export default function LifeAnalyticsPage() { + const { activeTenant } = useSession(); + const tenantId = activeTenant?.tenantId ?? ''; + const [period, setPeriod] = useState(90); + + const { data: rawObs = [], isLoading: oL } = useQuery({ + queryKey: ['life-obs', tenantId], queryFn: () => apiFetch('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: tasks = [], isLoading: tL } = useQuery({ + queryKey: ['life-tasks', tenantId], queryFn: () => apiFetch('/v1/tasks?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: decisions = [], isLoading: dL } = useQuery({ + queryKey: ['life-decisions', tenantId], queryFn: () => apiFetch('/v1/decisions?limit=200', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: goals = [], isLoading: gL } = useQuery({ + queryKey: ['life-goals', tenantId], queryFn: () => apiFetch('/v1/goals?limit=100', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + + const isLoading = oL || tL || dL || gL; + + const cutoff = Date.now() - period * 86400_000; + + const analysis = useMemo(() => { + const obs = rawObs.filter((o) => + PERSONAL_TYPES.includes(o.subjectType) && + new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff + ); + + const tasksDone = tasks.filter((t) => t.status === 'completed' && new Date(t.createdAt).getTime() >= cutoff); + const tasksCreated = tasks.filter((t) => new Date(t.createdAt).getTime() >= cutoff); + const decs = decisions.filter((d) => new Date(d.createdAt).getTime() >= cutoff); + const goalsActive = goals.filter((g) => g.status === 'active'); + const goalsCompleted = goals.filter((g) => g.status === 'completed' && new Date(g.createdAt).getTime() >= cutoff); + + // Metrics by name + const byMetric: Record = {}; + for (const o of obs) { + const v = parseFloat(o.value); + if (!isNaN(v)) byMetric[o.metric] = [...(byMetric[o.metric] ?? []), v]; + } + const metricAvgs = Object.entries(byMetric).map(([metric, vals]) => ({ + metric, avg: vals.reduce((a, b) => a + b, 0) / vals.length, count: vals.length, + max: Math.max(...vals), min: Math.min(...vals), + })).sort((a, b) => b.count - a.count); + + // Weekly obs counts + const weeklyObs = groupByWeek(obs.map((o) => ({ date: new Date(o.observedAt ?? o.createdAt) }))); + const weeklyTasks = groupByWeek(tasksDone.map((t) => ({ date: new Date(t.createdAt) }))); + + // All weeks in period + const weeks: string[] = []; + const start = new Date(cutoff); + const day = start.getDay(); + start.setDate(start.getDate() - (day === 0 ? 6 : day - 1)); + while (start <= new Date()) { + weeks.push(start.toISOString().slice(0, 10)); + start.setDate(start.getDate() + 7); + } + + return { obs, tasksDone, tasksCreated, decs, goalsActive, goalsCompleted, metricAvgs, weeklyObs, weeklyTasks, weeks }; + }, [rawObs, tasks, decisions, goals, cutoff]); + + return ( +
+
+
+

Life Analytics

+

+ {isLoading ? 'Se încarcă…' : `Analiză ${period} zile: ${analysis.obs.length} observații personale`} +

+
+
+ {[30, 90, 180, 365].map((d) => ( + + ))} +
+
+ + {/* KPI summary */} +
+ {[ + { label: 'Observații personale', value: analysis.obs.length, icon: '📊' }, + { label: 'Taskuri finalizate', value: analysis.tasksDone.length, icon: '✅' }, + { label: 'Decizii luate', value: analysis.decs.length, icon: '🧠' }, + { label: 'Obiective active', value: analysis.goalsActive.length, icon: '🎯' }, + ].map((s) => ( +
+

{s.icon}

+

{s.value}

+

{s.label}

+
+ ))} +
+ + {/* Weekly activity heatmap */} + {analysis.weeks.length > 0 && ( +
+

Activitate săptămânală

+
+ {[ + { label: '📊 Observații', data: analysis.weeklyObs }, + { label: '✅ Taskuri finalizate', data: analysis.weeklyTasks }, + ].map(({ label, data }) => { + const maxVal = Math.max(1, ...Object.values(data)); + return ( +
+

{label}

+
+ {analysis.weeks.map((w) => { + const count = data[w] ?? 0; + const intensity = Math.round((count / maxVal) * 4); + const cls = intensity === 0 ? 'bg-muted' : + intensity === 1 ? 'bg-primary/20' : + intensity === 2 ? 'bg-primary/40' : + intensity === 3 ? 'bg-primary/70' : 'bg-primary'; + return ( +
+ ); + })} +
+
+ ); + })} +
+
+ )} + + {/* Metric averages */} + {analysis.metricAvgs.length > 0 && ( +
+

Medii per metrică

+
+ {analysis.metricAvgs.map(({ metric, avg, count, max, min }) => ( +
+

{metric}

+
+
+
+
+ {avg.toFixed(2)} + ({count}) +
+ {min.toFixed(1)}–{max.toFixed(1)} +
+ ))} +
+
+ )} + + {analysis.obs.length === 0 && !isLoading && ( +
+

📈

+

Nicio observație personală în această perioadă.

+

Adaugă observații din Personal KPI sau Energy & Wellbeing.

+
+ )} +
+ ); +}