feat(CC-075): add Life Reports page (30/90/180d personal report with export)

This commit is contained in:
admin-valentin 2026-08-02 12:41:13 +00:00
parent 7fab4a0527
commit 5f0e1db725

View file

@ -0,0 +1,229 @@
'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 Task { id: string; status: string; priority: string; createdAt: string; }
interface Goal { id: string; title: string; status: string; targetDate: string | null; progress: number | null; createdAt: string; }
interface Decision { id: string; title: string; status: string; createdAt: string; }
interface OutcomeReview { id: string; wasSuccessful: boolean | null; rating: number | null; createdAt: string; }
interface Observation { id: string; subjectType: string; metric: string; value: string; unit: string | null; createdAt: string; }
const PERSONAL_TYPES = ['self','person','health','energy','sleep','mood','fitness','nutrition','wellbeing','productivity'];
export default function LifeReportsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [period, setPeriod] = useState<30 | 90 | 180>(30);
const { data: tasks = [], isLoading: tL } = useQuery({
queryKey: ['lr-tasks', tenantId], queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: goals = [], isLoading: gL } = useQuery({
queryKey: ['lr-goals', tenantId], queryFn: () => apiFetch<Goal[]>('/v1/goals?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: decisions = [], isLoading: dL } = useQuery({
queryKey: ['lr-decisions', tenantId], queryFn: () => apiFetch<Decision[]>('/v1/decisions?limit=200', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: outcomes = [], isLoading: oRL } = useQuery({
queryKey: ['lr-outcomes', tenantId], queryFn: () => apiFetch<OutcomeReview[]>('/v1/outcome-reviews?limit=100', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: rawObs = [], isLoading: obsL } = useQuery({
queryKey: ['lr-obs', tenantId], queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const isLoading = tL || gL || dL || oRL || obsL;
const cutoff = Date.now() - period * 86400_000;
const report = useMemo(() => {
const inPeriod = (d: string) => new Date(d).getTime() >= cutoff;
const periodTasks = tasks.filter((t) => inPeriod(t.createdAt));
const completed = periodTasks.filter((t) => t.status === 'completed');
const completionRate = periodTasks.length > 0 ? Math.round((completed.length / periodTasks.length) * 100) : 0;
const periodGoals = goals.filter((g) => inPeriod(g.createdAt) || g.status === 'active');
const goalsDone = goals.filter((g) => g.status === 'completed' && inPeriod(g.createdAt));
const avgProgress = periodGoals.reduce((s, g) => s + (g.progress ?? 0), 0) / (periodGoals.length || 1);
const periodDecisions = decisions.filter((d) => inPeriod(d.createdAt));
const periodOutcomes = outcomes.filter((o) => inPeriod(o.createdAt));
const successfulOutcomes = periodOutcomes.filter((o) => o.wasSuccessful === true);
const avgRating = periodOutcomes.filter((o) => o.rating != null).reduce((s, o) => s + (o.rating ?? 0), 0)
/ (periodOutcomes.filter((o) => o.rating != null).length || 1);
const personalObs = rawObs.filter((o) => PERSONAL_TYPES.includes(o.subjectType) && inPeriod(o.createdAt));
const byMetric: Record<string, number[]> = {};
for (const o of personalObs) {
const v = parseFloat(o.value);
if (!isNaN(v)) byMetric[o.metric] = [...(byMetric[o.metric] ?? []), v];
}
const metricSummary = Object.entries(byMetric).map(([m, vals]) => ({
metric: m,
avg: vals.reduce((a, b) => a + b, 0) / vals.length,
latest: vals[vals.length - 1],
count: vals.length,
})).sort((a, b) => b.count - a.count);
return {
completionRate, completed: completed.length, total: periodTasks.length,
goalsDone: goalsDone.length, activeGoals: periodGoals.filter(g => g.status === 'active').length,
avgProgress: Math.round(avgProgress),
decisions: periodDecisions.length, outcomes: periodOutcomes.length,
successRate: periodOutcomes.length > 0 ? Math.round((successfulOutcomes.length / periodOutcomes.length) * 100) : null,
avgRating: periodOutcomes.length > 0 ? avgRating : null,
observations: personalObs.length, metricSummary,
};
}, [tasks, goals, decisions, outcomes, rawObs, cutoff]);
const periodLabel = { 30: '30 zile', 90: '90 zile', 180: '6 luni' }[period];
const generateText = () => {
const lines = [
`# Raport de viață — ultimele ${periodLabel}`,
`Data generării: ${new Date().toLocaleDateString('ro-RO')}`,
``,
`## Execuție & Productivitate`,
`- Taskuri finalizate: ${report.completed} din ${report.total} (${report.completionRate}%)`,
`- Obiective active: ${report.activeGoals} | Finalizate în perioadă: ${report.goalsDone}`,
`- Progres mediu obiective: ${report.avgProgress}%`,
``,
`## Decizii & Outcome-uri`,
`- Decizii luate: ${report.decisions}`,
`- Revizuiri outcome: ${report.outcomes}`,
report.successRate != null ? `- Rată de succes: ${report.successRate}%` : '',
report.avgRating != null ? `- Rating mediu: ${report.avgRating.toFixed(1)}/10` : '',
``,
`## Observații personale (${report.observations} înregistrări)`,
...report.metricSummary.slice(0, 8).map((m) => `- ${m.metric}: medie ${m.avg.toFixed(1)} (${m.count} măsurători)`),
].filter(Boolean).join('\n');
const blob = new Blob([lines], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `raport-viata-${periodLabel.replace(' ', '')}-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
};
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">Rapoarte de Viață</h1>
<p className="text-sm text-ink-faint mt-1">Analiză personală: obiective, timp, energie și decizii.</p>
</div>
<div className="flex items-center gap-2">
<div className="flex gap-1 border border-border rounded-lg overflow-hidden text-xs">
{([30, 90, 180] as const).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>
<button onClick={generateText}
className="rounded-lg border px-3 py-1.5 text-xs text-ink hover:bg-muted/50 transition-colors">
Export TXT
</button>
</div>
</div>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se generează raportul</div>
) : (
<>
{/* Execution */}
<div className="card p-6 space-y-4">
<h2 className="text-sm font-semibold text-ink">Execuție & Productivitate · {periodLabel}</h2>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<p className="font-display text-3xl font-bold text-ink">{report.completionRate}%</p>
<p className="text-xs text-ink-faint mt-1">rată completare taskuri</p>
<p className="text-[10px] text-ink-faint">{report.completed} din {report.total}</p>
</div>
<div className="text-center">
<p className="font-display text-3xl font-bold text-ink">{report.activeGoals}</p>
<p className="text-xs text-ink-faint mt-1">obiective active</p>
<p className="text-[10px] text-ink-faint">{report.avgProgress}% progres mediu</p>
</div>
<div className="text-center">
<p className="font-display text-3xl font-bold text-ink">{report.goalsDone}</p>
<p className="text-xs text-ink-faint mt-1">obiective finalizate</p>
<p className="text-[10px] text-ink-faint">în {periodLabel}</p>
</div>
</div>
{/* Completion bar */}
<div className="space-y-1">
<div className="flex justify-between text-[10px] text-ink-faint">
<span>Rată completare</span><span>{report.completionRate}%</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-primary rounded-full transition-all" style={{ width: `${report.completionRate}%` }} />
</div>
</div>
</div>
{/* Decision quality */}
<div className="card p-6 space-y-4">
<h2 className="text-sm font-semibold text-ink">Calitate decizii · {periodLabel}</h2>
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<p className="font-display text-3xl font-bold text-ink">{report.decisions}</p>
<p className="text-xs text-ink-faint mt-1">decizii luate</p>
</div>
<div className="text-center">
<p className="font-display text-3xl font-bold text-ink">{report.outcomes}</p>
<p className="text-xs text-ink-faint mt-1">outcome reviews</p>
</div>
<div className="text-center">
<p className={`font-display text-3xl font-bold ${report.successRate !== null ? (report.successRate >= 70 ? 'text-signal-ok' : report.successRate >= 50 ? 'text-warn' : 'text-signal-danger') : 'text-ink-faint'}`}>
{report.successRate !== null ? `${report.successRate}%` : '—'}
</p>
<p className="text-xs text-ink-faint mt-1">rată de succes</p>
</div>
<div className="text-center">
<p className={`font-display text-3xl font-bold ${report.avgRating !== null ? (report.avgRating >= 7 ? 'text-signal-ok' : report.avgRating >= 5 ? 'text-warn' : 'text-signal-danger') : 'text-ink-faint'}`}>
{report.avgRating !== null ? report.avgRating.toFixed(1) : '—'}
</p>
<p className="text-xs text-ink-faint mt-1">rating mediu /10</p>
</div>
</div>
</div>
{/* Personal metrics */}
<div className="card p-6 space-y-4">
<h2 className="text-sm font-semibold text-ink">Metrici personale · {periodLabel}</h2>
{report.metricSummary.length === 0 ? (
<p className="text-xs text-ink-faint">Nicio observație personală în această perioadă. Adaugă din Personal KPI sau Energy & Wellbeing.</p>
) : (
<div className="space-y-3">
<p className="text-[10px] text-ink-faint">{report.observations} înregistrări total</p>
{report.metricSummary.slice(0, 10).map((m) => (
<div key={m.metric} className="flex items-center gap-3">
<p className="text-xs text-ink capitalize w-36 truncate">{m.metric}</p>
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full bg-primary/50 rounded-full"
style={{ width: `${Math.min(100, (m.avg / 10) * 100)}%` }} />
</div>
<span className="text-xs font-mono font-semibold text-ink w-12 text-right">{m.avg.toFixed(1)}</span>
<span className="text-[9px] text-ink-faint w-10 text-right">({m.count}x)</span>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}