feat(CC-076): add Trends & Patterns page (observation trends + spending + task completion + hourly pattern)

This commit is contained in:
admin-valentin 2026-08-02 12:44:19 +00:00
parent 62ac5f270e
commit e18f751980

View file

@ -0,0 +1,261 @@
'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; metric: string; value: string;
unit: string | null; observedAt: string | null; createdAt: string;
}
interface Transaction {
id: string; amount: number; currency: string; type: string;
date: string; category: string | null;
}
interface Task { id: string; status: string; createdAt: string; }
function movingAvg(values: number[], window: number): number[] {
return values.map((_, i) => {
const start = Math.max(0, i - window + 1);
const slice = values.slice(start, i + 1);
return slice.reduce((a, b) => a + b, 0) / slice.length;
});
}
function groupByDay(items: { date: Date; value: number }[], days: number): { label: string; value: number }[] {
const result: { label: string; value: number }[] = [];
const cutoff = Date.now() - days * 86400_000;
for (let i = days - 1; i >= 0; i--) {
const d = new Date(Date.now() - i * 86400_000);
const label = d.toLocaleDateString('ro-RO', { day: '2-digit', month: 'short' });
const dayItems = items.filter((item) => {
const dd = item.date;
return dd.getFullYear() === d.getFullYear()
&& dd.getMonth() === d.getMonth()
&& dd.getDate() === d.getDate()
&& item.date.getTime() >= cutoff;
});
result.push({ label, value: dayItems.reduce((s, x) => s + x.value, 0) / (dayItems.length || 1) });
}
return result;
}
const COLORS = ['bg-primary', 'bg-signal-ok', 'bg-warn', 'bg-violet-500', 'bg-blue-500', 'bg-teal-500'];
export default function TrendsPage() {
const { activeTenant } = useSession();
const tenantId = activeTenant?.tenantId ?? '';
const [period, setPeriod] = useState(30);
const { data: rawObs = [], isLoading: oL } = useQuery({
queryKey: ['trends-obs', tenantId],
queryFn: () => apiFetch<Observation[]>('/v1/observations', { tenantId }),
enabled: Boolean(tenantId), staleTime: 60_000,
});
const { data: transactions = [], isLoading: tL } = useQuery({
queryKey: ['trends-tx', tenantId],
queryFn: () => apiFetch<Transaction[]>('/v1/transactions?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const { data: tasks = [], isLoading: tkL } = useQuery({
queryKey: ['trends-tasks', tenantId],
queryFn: () => apiFetch<Task[]>('/v1/tasks?limit=500', { tenantId }),
enabled: Boolean(tenantId), staleTime: 120_000,
});
const isLoading = oL || tL || tkL;
const cutoff = Date.now() - period * 86400_000;
const analysis = useMemo(() => {
// 1. Observations per metric trends
const obs = rawObs.filter((o) => new Date(o.observedAt ?? o.createdAt).getTime() >= cutoff);
const byMetric: Record<string, { date: Date; value: number }[]> = {};
for (const o of obs) {
const v = parseFloat(o.value);
if (!isNaN(v)) {
const key = `${o.subjectType}:${o.metric}`;
byMetric[key] = [...(byMetric[key] ?? []), { date: new Date(o.observedAt ?? o.createdAt), value: v }];
}
}
// Top 6 metrics by count
const topMetrics = Object.entries(byMetric)
.sort(([,a],[,b]) => b.length - a.length)
.slice(0, 6);
// 2. Spending trend by day
const txItems = transactions
.filter((t) => t.type === 'expense' && new Date(t.date).getTime() >= cutoff)
.map((t) => ({ date: new Date(t.date), value: Math.abs(t.amount) / 100 }));
const spendByDay = groupByDay(txItems, Math.min(period, 30));
// 3. Task completion rate by week
const completedTasks = tasks.filter((t) => t.status === 'completed' && new Date(t.createdAt).getTime() >= cutoff);
const weeklyCompleted: Record<string, number> = {};
for (const t of completedTasks) {
const d = new Date(t.createdAt);
const monday = new Date(d);
monday.setDate(d.getDate() - (d.getDay() === 0 ? 6 : d.getDay() - 1));
const key = monday.toISOString().slice(0, 10);
weeklyCompleted[key] = (weeklyCompleted[key] ?? 0) + 1;
}
// 4. Observation frequency pattern
const obsByHour: number[] = new Array(24).fill(0);
for (const o of obs) {
const h = new Date(o.observedAt ?? o.createdAt).getHours();
obsByHour[h]++;
}
return { topMetrics, spendByDay, weeklyCompleted, obsByHour };
}, [rawObs, transactions, tasks, 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">Tendințe & Tipare</h1>
<p className="text-sm text-ink-faint mt-1">
{isLoading ? 'Se analizează…' : `Analiză date pe ${period} zile — observații, cheltuieli, taskuri.`}
</p>
</div>
<div className="flex gap-1 border border-border rounded-lg overflow-hidden text-xs">
{[14, 30, 90].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>
{isLoading ? (
<div className="text-center text-sm text-ink-faint py-8">Se analizează tipare</div>
) : (
<>
{/* Observation trends */}
{analysis.topMetrics.length > 0 && (
<div className="card p-5 space-y-4">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Tendințe observații</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{analysis.topMetrics.map(([key, data], idx) => {
const [subjectType, ...metricParts] = key.split(':');
const metric = metricParts.join(':');
const sorted = [...data].sort((a, b) => a.date.getTime() - b.date.getTime());
const vals = sorted.map((d) => d.value);
const ma = movingAvg(vals, 3);
const maxV = Math.max(...vals, 0.01);
const minV = Math.min(...vals);
const range = maxV - minV || 1;
const trend = vals.length >= 2 ? vals[vals.length-1] - vals[0] : 0;
return (
<div key={key} className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] font-medium text-ink capitalize">{metric}</p>
<span className={`text-[10px] ${trend > 0 ? 'text-signal-ok' : trend < 0 ? 'text-signal-danger' : 'text-ink-faint'}`}>
{trend > 0 ? '↑' : trend < 0 ? '↓' : '→'} {Math.abs(trend).toFixed(1)}
</span>
</div>
<div className="flex items-end gap-px h-12">
{sorted.slice(-20).map((d, i) => {
const h = 4 + ((d.value - minV) / range) * 40;
const maVal = ma[Math.max(0, vals.length - sorted.slice(-20).length + i)];
const maH = 4 + ((maVal - minV) / range) * 40;
return (
<div key={i} className="flex-1 flex flex-col items-center justify-end gap-px" title={`${d.value.toFixed(1)}${d.date.toLocaleDateString('ro-RO')}`}>
<div className={`w-full ${COLORS[idx % COLORS.length]} opacity-30 rounded-sm`} style={{ height: `${maH}px` }} />
<div className={`w-full ${COLORS[idx % COLORS.length]} rounded-sm`} style={{ height: `${h}px` }} />
</div>
);
})}
</div>
<p className="text-[9px] text-ink-faint text-center">{data.length} măsurători</p>
</div>
);
})}
</div>
</div>
)}
{/* Spending trend */}
{analysis.spendByDay.some((d) => d.value > 0) && (
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Tendință cheltuieli zilnice (RON)</p>
<div className="flex items-end gap-px h-16">
{analysis.spendByDay.map((d, i) => {
const maxSpend = Math.max(...analysis.spendByDay.map((x) => x.value), 0.01);
const h = d.value > 0 ? 4 + (d.value / maxSpend) * 56 : 2;
const isLast = i === analysis.spendByDay.length - 1;
return (
<div key={i} title={`${d.label}: ${d.value.toFixed(0)} RON`}
className={`flex-1 rounded-sm transition-colors ${isLast ? 'bg-primary' : 'bg-primary/30'}`}
style={{ height: `${h}px` }} />
);
})}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
<span>{analysis.spendByDay[0]?.label}</span>
<span>Total: {analysis.spendByDay.reduce((s, d) => s + d.value, 0).toFixed(0)} RON</span>
<span>{analysis.spendByDay[analysis.spendByDay.length - 1]?.label}</span>
</div>
</div>
)}
{/* Weekly task completion */}
{Object.keys(analysis.weeklyCompleted).length > 0 && (
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Taskuri finalizate / săptămână</p>
<div className="flex items-end gap-2">
{Object.entries(analysis.weeklyCompleted).sort(([a],[b]) => a.localeCompare(b)).map(([week, count]) => {
const maxCount = Math.max(...Object.values(analysis.weeklyCompleted));
const h = 4 + (count / maxCount) * 56;
return (
<div key={week} className="flex-1 space-y-1" title={`${week}: ${count} taskuri`}>
<div className="flex justify-center">
<div className="rounded-sm bg-signal-ok/50" style={{ height: `${h}px`, width: '100%' }} />
</div>
<p className="text-[9px] text-ink-faint text-center">{count}</p>
<p className="text-[8px] text-ink-faint text-center">{week.slice(5)}</p>
</div>
);
})}
</div>
</div>
)}
{/* Activity pattern by hour */}
{analysis.obsByHour.some((c) => c > 0) && (
<div className="card p-5 space-y-3">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-faint">Pattern activitate (ora zilei)</p>
<div className="flex items-end gap-px h-10">
{analysis.obsByHour.map((count, h) => {
const maxC = Math.max(...analysis.obsByHour, 1);
const height = count > 0 ? 4 + (count / maxC) * 36 : 2;
return (
<div key={h} title={`${h}:00 — ${count} observații`}
className="flex-1 rounded-sm bg-violet-400/50 hover:bg-violet-400 transition-colors"
style={{ height: `${height}px` }} />
);
})}
</div>
<div className="flex justify-between text-[9px] text-ink-faint">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>23:00</span>
</div>
</div>
)}
{analysis.topMetrics.length === 0 && !analysis.spendByDay.some(d => d.value > 0) && (
<div className="card p-8 text-center space-y-2">
<p className="text-2xl">📈</p>
<p className="text-sm text-ink-faint">Date insuficiente pentru detectarea tiparelor.</p>
<p className="text-xs text-ink-faint">Adaugă observații și înregistrează tranzacții pentru a vedea tendințe.</p>
</div>
)}
</>
)}
</div>
);
}