feat(CC-077): add Life OS page (aggregated personal dashboard with all domain links)

This commit is contained in:
admin-valentin 2026-08-02 12:50:19 +00:00
parent 2440494df0
commit a11d5421db

View file

@ -0,0 +1,199 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import Link from 'next/link';
import { apiFetch } from '../../../lib/api';
import { useSession } from '../../../components/session-provider';
interface Task { id: string; status: string; priority: string; dueDate: string | null; }
interface Goal { id: string; title: string; status: string; progress: number | null; targetDate: string | null; }
interface Observation { id: string; subjectType: string; metric: string; value: string; unit: string | null; createdAt: string; observedAt: string | null; }
interface Decision { id: string; title: string; status: string; createdAt: string; }
const PERSONAL_TYPES = ['self','person','health','energy','sleep','mood','fitness','nutrition','wellbeing','productivity','education','course','certification','skill'];
const DOMAIN_LINKS = [
{ label: 'Rutine & Taskuri', href: '/dashboard/routines', icon: '✅', desc: 'Taskuri zilnice și rutine recurente' },
{ label: 'Obiective', href: '/dashboard/goals', icon: '🎯', desc: 'Obiective pe termen mediu și lung' },
{ label: 'Energie & Wellbeing', href: '/dashboard/energy', icon: '⚡', desc: 'Stare fizică și mentală' },
{ label: 'Educație', href: '/dashboard/education', icon: '🎓', desc: 'Cursuri, certificări, ore studiu' },
{ label: 'Personal KPI', href: '/dashboard/personal-kpi', icon: '📊', desc: 'Metrici personale cu trend' },
{ label: 'Interacțiuni', href: '/dashboard/interactions', icon: '📅', desc: 'Timeline completă de activitate' },
{ label: 'Follow-ups', href: '/dashboard/follow-ups', icon: '📞', desc: 'Urmăriri și callback-uri pendinte' },
{ label: 'Decizii', href: '/dashboard/decisions', icon: '🧠', desc: 'Log de decizii luate' },
{ label: 'Calendar', href: '/dashboard/calendar', icon: '🗓️', desc: 'Termene și scadențe viitoare' },
{ label: 'Life Analytics', href: '/dashboard/life-analytics', icon: '📈', desc: 'Tendințe și tipare personale' },
{ label: 'Rapoarte Viață', href: '/dashboard/reports/life', icon: '📋', desc: 'Rapoarte 30/90/180 zile' },
{ label: 'Tendințe', href: '/dashboard/trends', icon: '🔍', desc: 'Analiză tipare din toate datele' },
];
function daysUntil(d: Date) { return Math.ceil((d.getTime() - Date.now()) / 86400_000); }
export default function LifeOSPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const { data: tasks = [], isLoading: tL } = useQuery({
queryKey: ['life-os-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: goals = [], isLoading: gL } = useQuery({
queryKey: ['life-os-goals', tenantId],
queryFn: () => apiFetch<Goal[]>('/v1/goals?status=active&limit=20', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: rawObs = [], isLoading: oL } = useQuery({
queryKey: ['life-os-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: decisions = [], isLoading: dL } = useQuery({
queryKey: ['life-os-decisions', tenantId],
queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const isLoading = tL || gL || oL || dL;
const summary = useMemo(() => {
const active = tasks.filter((t) => t.status !== 'completed' && t.status !== 'cancelled');
const overdue = active.filter((t) => t.dueDate && daysUntil(new Date(t.dueDate)) < 0);
const dueToday = active.filter((t) => t.dueDate && daysUntil(new Date(t.dueDate)) === 0);
const cutoff7 = Date.now() - 7 * 86400_000;
const personalObs = rawObs.filter((o) => PERSONAL_TYPES.includes(o.subjectType) && new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff7);
const byMetric: Record<string, Observation[]> = {};
for (const o of personalObs) {
byMetric[o.metric] = [...(byMetric[o.metric] ?? []), o];
}
const latestObs = Object.entries(byMetric).map(([m, obs]) => {
const latest = [...obs].sort((a, b) => new Date(b.observedAt ?? b.createdAt).getTime() - new Date(a.observedAt ?? a.createdAt).getTime())[0];
return { metric: m, latest };
}).slice(0, 4);
const avgProgress = goals.length > 0
? Math.round(goals.reduce((s, g) => s + (g.progress ?? 0), 0) / goals.length)
: 0;
const recentDecisions = [...decisions]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
return { active, overdue, dueToday, latestObs, avgProgress, recentDecisions };
}, [tasks, goals, rawObs, decisions]);
return (
<div className="max-w-5xl space-y-6 p-6">
<div>
<h1 className="font-display text-2xl font-semibold text-ink">Life OS</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se încarcă…' : `${new Date().toLocaleDateString('ro-RO', { weekday: 'long', day: 'numeric', month: 'long' })} · ${summary.active.length} taskuri active · ${goals.length} obiective`}
</p>
</div>
{/* Status cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<Link href="/dashboard/routines" className="card p-4 hover:border-primary/40 transition-colors">
<p className="font-display text-3xl font-bold text-ink">{summary.active.length}</p>
<p className="text-xs text-ink-faint mt-1">taskuri active</p>
{summary.overdue.length > 0 && <p className="text-[10px] text-signal-danger font-semibold mt-0.5">{summary.overdue.length} depășite</p>}
{summary.dueToday.length > 0 && <p className="text-[10px] text-warn mt-0.5">{summary.dueToday.length} azi</p>}
</Link>
<Link href="/dashboard/goals" className="card p-4 hover:border-primary/40 transition-colors">
<p className="font-display text-3xl font-bold text-ink">{goals.length}</p>
<p className="text-xs text-ink-faint mt-1">obiective active</p>
<p className="text-[10px] text-ink-faint mt-0.5">{summary.avgProgress}% progres mediu</p>
</Link>
<Link href="/dashboard/personal-kpi" className="card p-4 hover:border-primary/40 transition-colors">
<p className="font-display text-3xl font-bold text-ink">{summary.latestObs.length}</p>
<p className="text-xs text-ink-faint mt-1">metrici personale active</p>
<p className="text-[10px] text-ink-faint mt-0.5">ultimele 7 zile</p>
</Link>
<Link href="/dashboard/decisions" className="card p-4 hover:border-primary/40 transition-colors">
<p className="font-display text-3xl font-bold text-ink">{decisions.length}</p>
<p className="text-xs text-ink-faint mt-1">decizii logate</p>
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{/* Active goals */}
<div className="card p-4 space-y-3 sm:col-span-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Obiective active</p>
<Link href="/dashboard/goals" className="text-[10px] text-primary hover:underline">Toate </Link>
</div>
{goals.length === 0 ? (
<p className="text-xs text-ink-faint text-center py-2">Niciun obiectiv activ.</p>
) : goals.slice(0, 5).map((g) => (
<div key={g.id} className="space-y-1">
<div className="flex justify-between text-xs">
<span className="text-ink truncate max-w-[70%]">{g.title}</span>
<span className="text-ink-faint shrink-0">{g.progress ?? 0}%</span>
</div>
<div className="h-1 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary/60 rounded-full" style={{ width: `${g.progress ?? 0}%` }} />
</div>
</div>
))}
</div>
{/* Latest personal obs */}
<div className="card p-4 space-y-3 sm:col-span-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Metrici recente</p>
<Link href="/dashboard/energy" className="text-[10px] text-primary hover:underline">Wellbeing </Link>
</div>
{summary.latestObs.length === 0 ? (
<p className="text-xs text-ink-faint text-center py-2">Nicio observație recentă.</p>
) : summary.latestObs.map(({ metric, latest }) => (
<div key={metric} className="flex items-center justify-between">
<span className="text-xs text-ink-faint capitalize">{metric}</span>
<span className="text-sm font-semibold text-ink">
{latest.value}{latest.unit ? ` ${latest.unit}` : ''}
</span>
</div>
))}
</div>
{/* Recent decisions */}
<div className="card p-4 space-y-3 sm:col-span-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Decizii recente</p>
<Link href="/dashboard/decisions" className="text-[10px] text-primary hover:underline">Toate </Link>
</div>
{summary.recentDecisions.length === 0 ? (
<p className="text-xs text-ink-faint text-center py-2">Nicio decizie.</p>
) : summary.recentDecisions.map((d) => (
<div key={d.id} className="py-1 border-b border-border/30 last:border-0">
<p className="text-xs text-ink line-clamp-1">{d.title}</p>
<p className="text-[10px] text-ink-faint">{new Date(d.createdAt).toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' })} · {d.status}</p>
</div>
))}
</div>
</div>
{/* Domain navigation */}
<div className="space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Module Life OS</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{DOMAIN_LINKS.map((link) => (
<Link key={link.href} href={link.href}
className="card p-4 hover:border-primary/40 transition-colors group">
<div className="flex items-center gap-2 mb-1">
<span className="text-lg">{link.icon}</span>
<span className="text-sm font-medium text-ink group-hover:text-primary transition-colors">{link.label}</span>
</div>
<p className="text-[10px] text-ink-faint">{link.desc}</p>
</Link>
))}
</div>
</div>
</div>
);
}