feat(CC-074): add Life Analytics page (weekly heatmap + metric averages + KPIs)
This commit is contained in:
parent
71a23cabcc
commit
5b6b012e53
1 changed files with 198 additions and 0 deletions
198
src/app/dashboard/life-analytics/page.tsx
Normal file
198
src/app/dashboard/life-analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue