diff --git a/src/app/dashboard/trends/page.tsx b/src/app/dashboard/trends/page.tsx new file mode 100644 index 0000000..16e9ce8 --- /dev/null +++ b/src/app/dashboard/trends/page.tsx @@ -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('/v1/observations', { tenantId }), + enabled: Boolean(tenantId), staleTime: 60_000, + }); + const { data: transactions = [], isLoading: tL } = useQuery({ + queryKey: ['trends-tx', tenantId], + queryFn: () => apiFetch('/v1/transactions?limit=500', { tenantId }), + enabled: Boolean(tenantId), staleTime: 120_000, + }); + const { data: tasks = [], isLoading: tkL } = useQuery({ + queryKey: ['trends-tasks', tenantId], + queryFn: () => apiFetch('/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 = {}; + 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 = {}; + 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 ( +
+
+
+

Tendințe & Tipare

+

+ {isLoading ? 'Se analizează…' : `Analiză date pe ${period} zile — observații, cheltuieli, taskuri.`} +

+
+
+ {[14, 30, 90].map((d) => ( + + ))} +
+
+ + {isLoading ? ( +
Se analizează tipare…
+ ) : ( + <> + {/* Observation trends */} + {analysis.topMetrics.length > 0 && ( +
+

Tendințe observații

+
+ {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 ( +
+
+

{metric}

+ 0 ? 'text-signal-ok' : trend < 0 ? 'text-signal-danger' : 'text-ink-faint'}`}> + {trend > 0 ? '↑' : trend < 0 ? '↓' : '→'} {Math.abs(trend).toFixed(1)} + +
+
+ {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 ( +
+
+
+
+ ); + })} +
+

{data.length} măsurători

+
+ ); + })} +
+
+ )} + + {/* Spending trend */} + {analysis.spendByDay.some((d) => d.value > 0) && ( +
+

Tendință cheltuieli zilnice (RON)

+
+ {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 ( +
+ ); + })} +
+
+ {analysis.spendByDay[0]?.label} + Total: {analysis.spendByDay.reduce((s, d) => s + d.value, 0).toFixed(0)} RON + {analysis.spendByDay[analysis.spendByDay.length - 1]?.label} +
+
+ )} + + {/* Weekly task completion */} + {Object.keys(analysis.weeklyCompleted).length > 0 && ( +
+

Taskuri finalizate / săptămână

+
+ {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 ( +
+
+
+
+

{count}

+

{week.slice(5)}

+
+ ); + })} +
+
+ )} + + {/* Activity pattern by hour */} + {analysis.obsByHour.some((c) => c > 0) && ( +
+

Pattern activitate (ora zilei)

+
+ {analysis.obsByHour.map((count, h) => { + const maxC = Math.max(...analysis.obsByHour, 1); + const height = count > 0 ? 4 + (count / maxC) * 36 : 2; + return ( +
+ ); + })} +
+
+ 00:0006:0012:0018:0023:00 +
+
+ )} + + {analysis.topMetrics.length === 0 && !analysis.spendByDay.some(d => d.value > 0) && ( +
+

📈

+

Date insuficiente pentru detectarea tiparelor.

+

Adaugă observații și înregistrează tranzacții pentru a vedea tendințe.

+
+ )} + + )} +
+ ); +}