feat(CC-074): add Life Analytics page (weekly heatmap + metric averages + KPIs)

This commit is contained in:
admin-valentin 2026-08-02 12:38:18 +00:00
parent 71a23cabcc
commit 5b6b012e53

View file

@ -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<string, number> {
const counts: Record<string, number> = {};
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<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: tasks = [], isLoading: tL } = useQuery({
queryKey: ['life-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: decisions = [], isLoading: dL } = useQuery({
queryKey: ['life-decisions', tenantId], queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: goals = [], isLoading: gL } = useQuery({
queryKey: ['life-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/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<string, number[]> = {};
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 (
<div className="max-w-4xl space-y-6 p-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Life Analytics</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `Analiză ${period} zile: ${analysis.obs.length} observații personale`}
</p>
</div>
<div className="flex gap-1 border border-border rounded-lg overflow-hidden text-xs">
{[30, 90, 180, 365].map((d) => (
<button key={d} onClick={() => setPeriod(d)}
className={`px-3 py-1.5 transition-colors ${period === d ? 'bg-primary/10 text-ink font-medium' : 'text-ink-faint hover:text-ink'}`}>
{d}z
</button>
))}
</div>
</div>
{/* KPI summary */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
{[
{ 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) => (
<div key={s.label} className="card p-4 text-center space-y-1">
<p className="text-2xl">{s.icon}</p>
<p className="font-display text-2xl font-bold text-ink">{s.value}</p>
<p className="text-[10px] text-ink-faint">{s.label}</p>
</div>
))}
</div>
{/* Weekly activity heatmap */}
{analysis.weeks.length > 0 && (
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Activitate săptămânală</p>
<div className="space-y-2">
{[
{ label: '📊 Observații', data: analysis.weeklyObs },
{ label: '✅ Taskuri finalizate', data: analysis.weeklyTasks },
].map(({ label, data }) => {
const maxVal = Math.max(1, ...Object.values(data));
return (
<div key={label} className="space-y-1">
<p className="text-[10px] text-ink-faint">{label}</p>
<div className="flex gap-1 flex-wrap">
{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 (
<div key={w} title={`${w}: ${count}`}
className={`w-5 h-5 rounded-sm ${cls}`} />
);
})}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Metric averages */}
{analysis.metricAvgs.length > 0 && (
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Medii per metrică</p>
<div className="space-y-2">
{analysis.metricAvgs.map(({ metric, avg, count, max, min }) => (
<div key={metric} className="flex items-center gap-3">
<p className="text-xs text-ink capitalize w-32 truncate">{metric}</p>
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary/50 rounded-full" style={{ width: `${Math.min(100, (avg / (max || 1)) * 100)}%` }} />
</div>
<div className="text-right shrink-0 w-20">
<span className="text-xs font-mono font-semibold text-ink">{avg.toFixed(2)}</span>
<span className="text-[9px] text-ink-faint ml-1">({count})</span>
</div>
<span className="text-[9px] text-ink-faint w-16 text-right">{min.toFixed(1)}{max.toFixed(1)}</span>
</div>
))}
</div>
</div>
)}
{analysis.obs.length === 0 && !isLoading && (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📈</p>
<p className="text-sm text-ink-faint">Nicio observație personală în această perioadă.</p>
<p className="text-xs text-ink-faint">Adaugă observații din Personal KPI sau Energy & Wellbeing.</p>
</div>
)}
</div>
);
}