// Dashboard — the hero page
function Dashboard() {
const { setPage, setDrawerTxn, dataVersion } = useApp();
const [scrapeOpen, setScrapeOpen] = useState(false);
const [scrapeProvider, setScrapeProvider] = useState('max');
const [aiBusy, setAiBusy] = useState(false);
const openConnect = (provider) => {
setScrapeProvider(provider);
setScrapeOpen(true);
};
const [range, setRange] = useState('1m'); // '1m' | '3m' | '1y'
const monthsAll = D.months || [];
// Default end-month = most recent month that actually has transactions
// (so the user lands on real data instead of an empty current month).
const initialIdx = useMemo(() => {
for (let i = monthsAll.length - 1; i >= 0; i--) {
if ((monthsAll[i].txns?.length || 0) > 0) return i;
}
return Math.max(0, monthsAll.length - 1);
}, [dataVersion]);
const [monthIdx, setMonthIdx] = useState(initialIdx);
// Re-clamp on data refresh (lengths may change after a scrape).
useEffect(() => {
if (monthIdx >= monthsAll.length) setMonthIdx(Math.max(0, monthsAll.length - 1));
}, [dataVersion]);
// Compute the list of months in the active range, ending at monthIdx.
const rangeLen = range === '1y' ? 12 : range === '3m' ? 3 : 1;
const activeMonths = useMemo(() => {
const end = monthIdx + 1;
const start = Math.max(0, end - rangeLen);
return monthsAll.slice(start, end);
}, [monthIdx, range, dataVersion]);
const endMonth = monthsAll[monthIdx] || D.current;
const startMonth = activeMonths[0] || endMonth;
// Aggregate stats across activeMonths.
const agg = useMemo(() => {
const expenses = activeMonths.reduce((s, m) => s + (m.summary?.expenses || 0), 0);
const income = activeMonths.reduce((s, m) => s + (m.summary?.income || 0), 0);
const txns = activeMonths.flatMap(m => m.txns || []);
const byCat = {};
txns.filter(t => t.amount < 0).forEach(t => {
byCat[t.category] = (byCat[t.category] || 0) + (-t.amount);
});
return { expenses, income, net: income - expenses, byCat, txns, count: txns.length };
}, [activeMonths]);
// Previous comparable period (same length, immediately before start).
const prevPeriod = useMemo(() => {
const endIdx = Math.max(0, monthIdx - rangeLen + 1);
const startIdx = Math.max(0, endIdx - rangeLen);
const prevMs = monthsAll.slice(startIdx, endIdx);
const expenses = prevMs.reduce((s, m) => s + (m.summary?.expenses || 0), 0);
return { expenses, months: prevMs };
}, [monthIdx, range, dataVersion]);
const now = new Date();
const isCurrentMonth = endMonth?.year === now.getFullYear() && endMonth?.month === now.getMonth() + 1;
const daysInMonth = endMonth ? new Date(endMonth.year, endMonth.month, 0).getDate() : 30;
const today = isCurrentMonth ? Math.min(now.getDate(), daysInMonth) : daysInMonth;
const daily = useDailyPace(endMonth || { txns: [] }, today);
// Projection only meaningful for current month, single-month view.
const showProjection = range === '1m' && isCurrentMonth && today > 0 && today < daysInMonth;
const projected = showProjection ? (daily[today-1] || 0) / today * daysInMonth : 0;
const projectedDelta = prevPeriod.expenses
? (((showProjection ? projected : agg.expenses) - prevPeriod.expenses) / prevPeriod.expenses) * 100
: 0;
const topCats = useMemo(
() => Object.entries(agg.byCat).sort((a,b) => b[1]-a[1]).slice(0, 5),
[agg]
);
const recent = useMemo(
() => agg.txns.slice().sort((a,b) => b.date.localeCompare(a.date)).slice(0, 6),
[agg]
);
const featuredInsight = D.insights?.bullets?.[0];
const hasSavings = (D.savings?.items?.length || 0) > 0;
const hasAccounts = (D.accounts?.length || 0) > 0;
// Title text depends on the active range.
const HEB_MONTHS = ['ינואר','פברואר','מרץ','אפריל','מאי','יוני','יולי','אוגוסט','ספטמבר','אוקטובר','נובמבר','דצמבר'];
const heroLabel = range === '1m'
? (endMonth ? `${HEB_MONTHS[endMonth.month-1]} ${endMonth.year}` : '')
: range === '3m'
? `3 חודשים אחרונים`
: `12 חודשים אחרונים`;
const titleText = range === '1m'
? (isCurrentMonth ? `הנה איך ${HEB_MONTHS[endMonth.month-1]} הולך` : `סקירת ${HEB_MONTHS[endMonth.month-1]} ${endMonth.year}`)
: range === '3m'
? 'סקירת 3 החודשים האחרונים'
: 'סקירת 12 החודשים האחרונים';
return (
{/* Page header */}
{(() => {
const h = new Date().getHours();
const greet = h < 5 ? 'לילה טוב' : h < 12 ? 'בוקר טוב' : h < 17 ? 'צהריים טובים' : h < 21 ? 'ערב טוב' : 'לילה טוב';
const name = (window.APP_USER?.name || '').split(' ')[0] || '';
return name ? `${greet}, ${name}` : greet;
})()}
{titleText}
{D.aiStatus?.configured && (
{
if (aiBusy) return;
if (!confirm("Cai יסווג מחדש את העסקאות שעדיין נמצאות ב-'אחר'. להמשיך?")) return;
setAiBusy(true);
try {
const r = await apiFetch('/api/ai/recategorize', { method: 'POST' });
if (!r.ok) {
const err = await r.json().catch(() => ({}));
throw new Error(err.detail?.message || 'הסיווג נכשל');
}
const d = await r.json();
await window.APP_DATA_REFRESH();
alert(`עודכנו ${d.updated} עסקאות (${d.rules_added} חוקים נשמרו).`);
} catch (err) {
alert('AI: ' + err.message);
} finally {
setAiBusy(false);
}
}} style={{
background: 'var(--surface)', color: 'var(--ink)', border: '0.5px solid var(--rule)',
padding: '8px 14px', borderRadius: 10, fontSize: 12.5, fontWeight: 500,
cursor: aiBusy ? 'wait' : 'pointer', boxShadow: 'var(--shadow-sm)',
display: 'flex', alignItems: 'center', gap: 6, opacity: aiBusy ? 0.6 : 1,
}}>
✦ {aiBusy ? 'מסווג…' : 'סווג עם AI'}
)}
setScrapeOpen(true)} style={{
background: 'var(--ink)', color: 'var(--bg)', border: 'none', padding: '8px 14px',
borderRadius: 10, fontSize: 12.5, fontWeight: 500, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6,
}}>
↻ סנכרון עכשיו
{scrapeOpen && setScrapeOpen(false)} />}
{/* Month picker — selects single month (1m) or end-month of range (3m/1y) */}
{monthsAll.map((m, i) => {
const active = i === monthIdx;
const isEmpty = (m.txns?.length || 0) === 0;
// Highlight months that are part of the current range (not just the end month).
const inRange = !active && i >= Math.max(0, monthIdx - rangeLen + 1) && i <= monthIdx;
return (
setMonthIdx(i)}
disabled={isEmpty && !active}
title={isEmpty ? 'אין עסקאות בחודש זה' : m.label}
style={{
padding: '6px 12px', borderRadius: 8,
background: active
? 'var(--ink)'
: inRange ? 'rgba(10,132,255,0.12)' : 'var(--surface)',
color: active ? 'var(--bg)' : (isEmpty ? 'var(--ink-soft)' : 'var(--ink)'),
border: '0.5px solid var(--rule)',
fontSize: 12, fontWeight: active ? 600 : 500,
cursor: (isEmpty && !active) ? 'not-allowed' : 'pointer',
opacity: (isEmpty && !active) ? 0.4 : 1,
whiteSpace: 'nowrap',
fontFeatureSettings: '"tnum"',
}}>
{m.shortLabel} {String(m.year).slice(-2)}
{!isEmpty && (
·{m.txns.length}
)}
);
})}
{/* Hero — chart adapts: pace (1m) or multi-month line (3m/1y) */}
{range === '1m' ? `הוצאות מצטברות · ${heroLabel}` : `סך הוצאות · ${heroLabel}`}
{showProjection ? (
<>
צפי לסוף החודש
{fmtILS(projected)}
>
) : prevPeriod.expenses > 0 ? (
<>
תקופה קודמת
{fmtILS(prevPeriod.expenses)}
>
) : (
אין השוואה זמינה
)}
{(showProjection || prevPeriod.expenses > 0) && (
0 ? 'rgba(216,58,58,0.1)' : 'rgba(48,164,108,0.1)',
color: projectedDelta > 0 ? 'var(--alert)' : 'var(--positive)',
}}>
{projectedDelta > 0 ? '↑' : '↓'} {Math.abs(projectedDelta).toFixed(1)}%
)}
{range === '1m' && (
{showProjection && }
)}
{range === '1m' ? (
) : (
)}
{range === '1m' ? (
{isCurrentMonth ? 'ימים שנותרו בחודש' : 'מספר עסקאות'}
{isCurrentMonth ? `${Math.max(0, daysInMonth - today)} ימים` : `${agg.count}`}
{endMonth?.label}
) : (
טווח
{startMonth?.label} – {endMonth?.label}
{agg.count} עסקאות
)}
{/* Savings opportunity teaser — only show if Cai found something */}
{hasSavings &&
setPage('savings')}
style={{
cursor: 'pointer', marginBottom: 16, padding: 0, overflow: 'hidden',
}}
padded={false}
>
↓
Cai מצא איפה אפשר לחסוך
{D.savings.items.length} הזדמנויות זוהו · עד {fmtILS(D.savings.annualPotential)} בשנה
{/* Quick wins highlight */}
{D.savings.items.slice(0, 3).map((s) => {
const cat = D.categories[s.category] || { icon: '·', color: 'var(--ink-soft)' };
return (
{cat.icon}
{s.title}
{fmtILS(s.annual)}/שנה
);
})}
ראה הכל ‹
}
{/* Featured AI insight + Accounts */}
setPage('ai')} style={{ cursor: 'pointer', position: 'relative', overflow: 'hidden' }}>
דוח Cai · {(() => {
const c = D.insights?.createdAt;
if (!c) return 'עוד לא נוצר';
const diff = Math.max(0, Date.now() - new Date(c).getTime());
const m = Math.round(diff / 60000);
if (m < 1) return 'עודכן זה עתה';
if (m < 60) return `עודכן לפני ${m} דקות`;
const h = Math.round(m / 60);
if (h < 24) return `עודכן לפני ${h} שעות`;
const d = Math.round(h / 24);
return d === 1 ? 'עודכן אתמול' : `עודכן לפני ${d} ימים`;
})()}
‹
{D.insights?.summary}
{featuredInsight && (
{featuredInsight.kind === 'warning' ? '!' : featuredInsight.kind === 'good' ? '✓' : 'i'}
{featuredInsight.title}
{featuredInsight.text}
)}
{/* Cats + Recent */}
איפה הכסף הולך
5 קטגוריות מובילות
setPage('budgets')} style={{ fontSize: 11, color: 'var(--accent)', cursor: 'pointer' }}>הצג הכל ‹
{topCats.map(([cat, val]) => {
const lim = D.budgets[cat];
const pct = lim ? val/lim : 0;
const c = D.categories[cat] || {};
const over = pct > 1;
return (
{c.icon}
{cat}
{fmtILS(val)}
{lim &&
{Math.round(pct*100)}% }
);
})}
פעילות אחרונה
{agg.count} עסקאות {range === '1m' ? 'החודש' : 'בתקופה'}
setPage('txns')} style={{ fontSize: 11, color: 'var(--accent)', cursor: 'pointer' }}>הצג הכל ‹
{recent.map((t, i) => {
const c = D.categories[t.category] || {};
return (
setDrawerTxn(t)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 22px',
cursor: 'pointer', borderTop: i === 0 ? '0.5px solid var(--rule)' : '0.5px solid var(--rule)',
transition: 'background .14s',
}} onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg-deep)'}
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}>
{c.icon}
{t.anomaly && ! }
{t.description}
{fmtDate(t.date)} · {t.category}
0 ? 'var(--positive)' : 'var(--ink)' }}>
{fmtILS(t.amount)}
);
})}
);
}
// Per-provider connection list with Sync/Connect buttons.
// All 4 supported providers always render — connected ones with their accounts,
// disconnected ones with a "התחבר" CTA that opens the modal pre-selected.
function ConnectionsCard({ accounts, onConnect }) {
const PROVIDERS = [
{ id: 'max', label: 'Max', color: '#0a84ff', sub: 'מקס' },
{ id: 'visaCal', label: 'Visa Cal', color: '#5856d6', sub: 'ויזה כאל' },
{ id: 'isracard', label: 'Isracard', color: '#ff375f', sub: 'ישראכרט (כולל Visa)' },
{ id: 'amex', label: 'American Express', color: '#1a73e8', sub: 'אמריקן אקספרס' },
];
// Group accounts by source
const byProv = {};
accounts.forEach(a => { (byProv[a.source] ||= []).push(a); });
const connectedCount = PROVIDERS.filter(p => byProv[p.id]).length;
return (
<>
חיבורים
{connectedCount} מתוך {PROVIDERS.length} מחוברים
{PROVIDERS.map(p => {
const accts = byProv[p.id] || [];
const isConnected = accts.length > 0;
const totalBalance = accts.reduce((s, a) => s + a.balance, 0);
return (
{p.label.slice(0,3).toUpperCase()}
{p.label}
{isConnected && ● }
{isConnected
? accts.map(a => a.holder).join(' · ')
: p.sub}
{isConnected ? (
<>
{fmtILS(totalBalance)}
onConnect(p.id)}
title="סנכרן עכשיו"
style={{
padding: '4px 8px', borderRadius: 7,
background: 'transparent', color: 'var(--ink-soft)',
border: '0.5px solid var(--rule)', cursor: 'pointer',
fontSize: 11, fontWeight: 500,
}}>↻
>
) : (
onConnect(p.id)}
style={{
padding: '5px 11px', borderRadius: 7,
background: p.color, color: 'white', border: 'none',
cursor: 'pointer', fontSize: 11.5, fontWeight: 600,
whiteSpace: 'nowrap',
}}>↗ התחבר
)}
);
})}
>
);
}
function LegendDot({ label, filled, dashed }) {
return (
{filled && }
{dashed && }
{label}
);
}
function SideStat({ label, value, positive, muted }) {
return (
);
}
window.Dashboard = Dashboard;