feat(CC-077): add Life OS page (aggregated personal dashboard with all domain links)
This commit is contained in:
parent
2440494df0
commit
a11d5421db
1 changed files with 199 additions and 0 deletions
199
src/app/dashboard/life/page.tsx
Normal file
199
src/app/dashboard/life/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue