feat(CC-075): add Life Reports page (30/90/180d personal report with export)
This commit is contained in:
parent
7fab4a0527
commit
5f0e1db725
1 changed files with 229 additions and 0 deletions
229
src/app/dashboard/reports/life/page.tsx
Normal file
229
src/app/dashboard/reports/life/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue