// Trends page — month-over-month with range selector + per-category time-series function TrendsPage() { const allMonths = D.months || []; const [range, setRange] = useState('12'); // '3' | '6' | '12' | 'all' const [selectedCat, setSelectedCat] = useState(null); const [a, setA] = useState(Math.max(0, allMonths.length - 2)); const [b, setB] = useState(Math.max(0, allMonths.length - 1)); // Filter months by selected range — always anchored to most recent const months = useMemo(() => { if (range === 'all') return allMonths; const n = parseInt(range, 10); return allMonths.slice(-n); }, [range, allMonths.length]); // Aggregate stats across the visible range const totalExp = months.reduce((s, m) => s + (m.summary?.expenses || 0), 0); const totalInc = months.reduce((s, m) => s + (m.summary?.income || 0), 0); const avgExp = months.length ? totalExp / months.length : 0; const maxMonth = months.reduce((max, m) => (!max || m.summary.expenses > max.summary.expenses ? m : max), null); const minMonth = months.reduce((min, m) => (!min || m.summary.expenses < min.summary.expenses ? m : min), null); // All categories that appear in the range, with totals const catTotals = useMemo(() => { const totals = {}; months.forEach(m => { Object.entries(m.summary?.byCat || {}).forEach(([c, v]) => { totals[c] = (totals[c] || 0) + v; }); }); return Object.entries(totals).sort((a, b) => b[1] - a[1]); }, [months]); // Per-month series for selected category (or first cat by default) const activeCat = selectedCat || catTotals[0]?.[0]; const catSeries = useMemo(() => { if (!activeCat) return []; return months.map(m => ({ month: m, value: m.summary?.byCat?.[activeCat] || 0, })); }, [months, activeCat]); const catMax = Math.max(...catSeries.map(p => p.value), 1); const catAvg = catSeries.length ? catSeries.reduce((s, p) => s + p.value, 0) / catSeries.length : 0; // Comparison const mA = allMonths[a], mB = allMonths[b]; const cats = useMemo(() => { if (!mA || !mB) return []; const all = new Set([...Object.keys(mA.summary?.byCat || {}), ...Object.keys(mB.summary?.byCat || {})]); return [...all].map(c => ({ cat: c, a: mA.summary.byCat[c] || 0, b: mB.summary.byCat[c] || 0, delta: ((mB.summary.byCat[c]||0) - (mA.summary.byCat[c]||0)), })).sort((x,y) => Math.abs(y.delta) - Math.abs(x.delta)); }, [a, b]); return (

מגמות

השווה בין חודשים, חפש דפוסים, זהה שינויים.
{/* Hero — overview of the selected range */}
סך הוצאות · {months.length} חודשים
ממוצע {fmtILS(avgExp)}/חודש
חודש הכי יקר
{maxMonth?.label || '—'}
{fmtILS(maxMonth?.summary?.expenses || 0)}
חודש הכי זול
{minMonth?.label || '—'}
{fmtILS(minMonth?.summary?.expenses || 0)}
הוצאות לאורך זמן
{months.length > 1 ? ( ) : (
צריך לפחות 2 חודשי נתונים כדי להציג מגמה.
)}
{/* Per-category time series */}
קטגוריה לאורך זמן
בחר קטגוריה כדי לראות את הוצאותיה בחודשים
{catTotals.slice(0, 8).map(([c]) => { const meta = D.categories[c] || {}; const active = c === activeCat; return ( ); })}
{/* Bar chart of selected category over months */} {activeCat && ( <>
{catSeries.map((p, i) => { const h = p.value > 0 ? Math.max(2, (p.value / catMax) * 130) : 0; const isMax = p.value === catMax && p.value > 0; const meta = D.categories[activeCat] || {}; return (
0 ? 1 : 0, }}>{p.value >= 1000 ? Math.round(p.value/100)/10+'k' : Math.round(p.value)}
0 ? 1 : 0.15, cursor: 'help', transition: 'background .2s', }} />
); })}
{catSeries.map((p, i) => (
{p.month.shortLabel}
))}
סך הכל
{fmtILS(catSeries.reduce((s,p) => s + p.value, 0))}
ממוצע חודשי
{fmtILS(catAvg)}
שיא
{fmtILS(catMax)}
)} {/* Month vs Month comparison */}
השוואה בין חודשים
{cats.length === 0 ? (
בחר שני חודשים עם נתונים כדי להשוות.
) : (
{cats.slice(0, 10).map(({ cat, a: av, b: bv, delta }) => { const max = Math.max(av, bv) || 1; const c = D.categories[cat] || {}; return (
{cat}
{fmtILS(av)}
{fmtILS(bv)}
0 ? 'var(--alert)' : 'var(--positive)' }}> {delta > 0 ? '↑' : '↓'} {fmtILS(Math.abs(delta))}
); })}
)}
); } // AI Report page function AIPage() { const { dataVersion } = useApp(); const [busy, setBusy] = useState(false); const aiConfigured = D.aiStatus?.configured; // Month selector — defaults to most recent month with transactions, falls // back to current month. Same pattern as Dashboard / TxnsPage. const monthsAll = D.months || []; 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); useEffect(() => { if (monthIdx >= monthsAll.length) setMonthIdx(Math.max(0, monthsAll.length - 1)); }, [dataVersion]); const activeMonth = monthsAll[monthIdx]; // Per-month insights cache. Initialized from D.insights for the current // month so the page doesn't double-fetch on first paint. const isCurrentMonth = activeMonth && D.current && activeMonth.year === D.current.year && activeMonth.month === D.current.month; const [byMonth, setByMonth] = useState(() => { const seed = {}; if (D.current && D.insights) { seed[`${D.current.year}-${D.current.month}`] = D.insights; } return seed; }); // Fetch insights for the selected month on demand. Backend serves from DB // cache when a report exists; this is a fast, non-AI call. useEffect(() => { if (!activeMonth) return; const key = `${activeMonth.year}-${activeMonth.month}`; if (byMonth[key] !== undefined) return; // cached let cancelled = false; (async () => { try { const r = await apiFetch(`/api/ai/insights?year=${activeMonth.year}&month=${activeMonth.month}`); if (!r.ok) throw new Error('fetch failed'); const data = await r.json(); if (cancelled) return; const insightObj = data.exists && data.content ? { summary: data.content.summary, forecast: data.content.forecast, bullets: data.content.bullets || [], model: data.model, createdAt: data.created_at, } : null; setByMonth(prev => ({ ...prev, [key]: insightObj })); } catch (e) { if (!cancelled) setByMonth(prev => ({ ...prev, [key]: null })); } })(); return () => { cancelled = true; }; }, [activeMonth?.year, activeMonth?.month]); const insights = activeMonth ? (byMonth[`${activeMonth.year}-${activeMonth.month}`] || null) : null; const hasReport = !!(insights && insights.summary && (insights.bullets?.length > 0 || insights.forecast)); const generate = async () => { if (!aiConfigured) { alert('AI לא מוגדר. הגדר ANTHROPIC_API_KEY ב-environment והפעל את השרת מחדש.'); return; } if (busy || !activeMonth) return; if (hasReport && !confirm(`דוח קיים עבור ${activeMonth.label} יוחלף בדוח חדש. להמשיך?`)) return; setBusy(true); try { const r = await apiFetch(`/api/ai/insights?year=${activeMonth.year}&month=${activeMonth.month}`, { method: 'POST' }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail?.message || 'יצירת הדוח נכשלה'); } const data = await r.json(); const key = `${activeMonth.year}-${activeMonth.month}`; setByMonth(prev => ({ ...prev, [key]: { summary: data.content.summary, forecast: data.content.forecast, bullets: data.content.bullets || [], model: data.model, createdAt: data.created_at, }})); // Also refresh top-level data so the dashboard's "featured insight" reflects new content if (isCurrentMonth) { await window.APP_DATA_REFRESH(); } } catch (err) { alert('שגיאה ביצירת דוח: ' + err.message); } finally { setBusy(false); } }; const updatedLabel = insights?.createdAt ? new Date(insights.createdAt).toLocaleString('he-IL', { dateStyle: 'short', timeStyle: 'short' }) : null; const modelLabel = insights?.model || D.aiStatus?.model || 'Cai'; // Empty-state summary text when no report exists for the selected month const placeholderSummary = aiConfigured ? `דוח Cai עוד לא נוצר עבור ${activeMonth?.label || 'חודש זה'}. לחץ "צור דוח" כדי לקבל סקירה אישית.` : 'AI לא מוגדר. הגדר ANTHROPIC_API_KEY כדי לקבל תובנות ודוחות חודשיים.'; return (

דוח Cai

{activeMonth?.label || 'סקירה חודשית'} · {updatedLabel ? `עודכן ${updatedLabel}` : 'לא נוצר עדיין'} · {modelLabel}
{/* Month picker — pick which month to view/generate the report for */}
{monthsAll.map((m, i) => { const active = i === monthIdx; const isEmpty = (m.txns?.length || 0) === 0; const key = `${m.year}-${m.month}`; const reportEntry = byMonth[key]; const hasReportHere = !!(reportEntry && reportEntry.summary); return ( ); })}
תקציר
{insights?.summary || placeholderSummary}
{insights?.forecast && (
צפי: {insights.forecast}
)}
{(insights?.bullets || []).map((b, i) => { const colors = { good: 'var(--positive)', warning: 'var(--alert)', info: 'var(--accent)' }; const labels = { good: 'חיובי', warning: 'התראה', info: 'מידע' }; return (
{b.kind === 'warning' ? '!' : b.kind === 'good' ? '✓' : 'i'}
{b.title} {labels[b.kind]}
{b.text}
); })}
); } // Budgets, Subs, Goals, Settings — compact function BudgetsPage() { const [editing, setEditing] = useState(false); const budgetEntries = Object.entries(D.budgets || {}).filter(([, v]) => v > 0); const isEmpty = budgetEntries.length === 0; return (

תקציבים

{isEmpty && } {budgetEntries.map(([cat, lim], i) => { const spent = D.current.summary.byCat[cat] || 0; const pct = lim > 0 ? spent/lim : 0; const c = D.categories[cat] || {}; const over = pct > 1; return (
{c.icon} {cat} {fmtILS(spent)} / {fmtILS(lim)}
); })} {editing && setEditing(false)} />}
); } function BudgetsEditModal({ onClose }) { const cats = Object.keys(D.categories).filter(c => c !== 'הכנסה'); const [values, setValues] = useState(() => { const obj = {}; cats.forEach(c => { obj[c] = D.budgets[c] || ''; }); return obj; }); const [saving, setSaving] = useState(false); const save = async () => { setSaving(true); try { await Promise.all(cats.map(c => { const amt = parseFloat(values[c]) || 0; return apiFetch('/api/budgets', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ category: c, amount: amt }), }); })); await window.APP_DATA_REFRESH(); onClose(); } catch (err) { alert('שמירת התקציבים נכשלה: ' + err.message); } finally { setSaving(false); } }; return (
e.stopPropagation()} className="slide-up" style={{ width: 480, maxWidth: '90%', maxHeight: '80vh', background: 'var(--surface)', borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden', display: 'flex', flexDirection: 'column', }}>
תקציבים חודשיים
השאר ריק או 0 כדי שלא יהיה תקציב לקטגוריה. הסכומים בש"ח לחודש.
{cats.map(c => { const meta = D.categories[c] || {}; return (
{meta.icon} {c} setValues(v => ({ ...v, [c]: e.target.value }))} placeholder="ללא" style={{ width: 110, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none', textAlign: 'right' }} />
); })}
); } // Per-provider metadata — UI labels, login URLs, brand colors const PROVIDER_INFO = { max: { label: 'Max', sub: 'מקס (לשעבר לאומי קארד)', url: 'https://www.max.co.il', color: '#0a84ff' }, visaCal: { label: 'Visa Cal', sub: 'ויזה כאל', url: 'https://www.cal-online.co.il', color: '#5856d6' }, isracard: { label: 'Isracard', sub: 'ישראכרט (כולל Isracard Visa)', url: 'https://www.isracard.co.il', color: '#ff375f' }, amex: { label: 'American Express', sub: 'אמריקן אקספרס (דרך ישראכרט)', url: 'https://www.americanexpress.co.il', color: '#1a73e8' }, }; // Scrape modal — triggered from Dashboard "סנכרון עכשיו" / per-provider Connect cards function ScrapeModal({ onClose, defaultProvider = 'max' }) { const [provider, setProvider] = useState(defaultProvider); // Restore last-used username per provider (NOT password — password never persists) const [username, setUsername] = useState(() => { try { return localStorage.getItem(`clibank.user.${defaultProvider}`) || ''; } catch { return ''; } }); const [password, setPassword] = useState(''); const [months, setMonths] = useState(3); const [rememberUser, setRememberUser] = useState(true); const [status, setStatus] = useState(null); // null | 'running' | 'done' | 'error' const [message, setMessage] = useState(''); // When user switches provider in the dropdown, swap the saved username for it const onProviderChange = (e) => { const p = e.target.value; setProvider(p); try { setUsername(localStorage.getItem(`clibank.user.${p}`) || ''); } catch { setUsername(''); } setPassword(''); }; const info = PROVIDER_INFO[provider] || PROVIDER_INFO.max; const submit = async (e) => { e.preventDefault(); if (rememberUser) { try { localStorage.setItem(`clibank.user.${provider}`, username); } catch {} } else { try { localStorage.removeItem(`clibank.user.${provider}`); } catch {} } setStatus('running'); setMessage(`מתחבר ל-${info.label} דרך Cli-Bank… זה יכול לקחת דקה-שתיים.`); try { // show_browser=false because the scraper runs on Railway with no // display server. CAPTCHA/2FA prompts (if the issuer requires them) // would have to be handled by the scraper itself, not the user. const r = await apiFetch('/api/scrape', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ provider, username, password, months: +months, show_browser: false }), }); setPassword(''); if (!r.ok) { const err = await r.json().catch(() => ({})); const t = err.detail?.errorType || 'UNKNOWN'; const m = err.detail?.message || 'שגיאה במשיכה'; throw new Error(`[${t}] ${m}`); } const data = await r.json(); setStatus('done'); setMessage(`נמשכו ${data.new_count} עסקאות חדשות מ-${info.label} (סך הכל ${data.total_seen}).`); await window.APP_DATA_REFRESH(); } catch (err) { setStatus('error'); setMessage(err.message || 'שגיאה'); } }; return (
e.stopPropagation()} onSubmit={submit} className="slide-up" style={{ width: 460, maxWidth: '90%', background: 'var(--surface)', borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden', }}>
חיבור {info.label}
{!status && ( <> {/* Brand strip */}
{info.label.slice(0, 3).toUpperCase()}
{info.label}
{info.sub}
פתח באתר ↗
{/* Explanation banner */}
איך זה עובד: כשתלחץ "התחבר", חלון Chrome אמיתי של אתר {info.label} ייפתח. הסקרייפר ינסה להיכנס אוטומטית; אם יש CAPTCHA או OTP — תשלים אותם בחלון בעצמך. הנתונים יישלפו ישירות מהאתר הרשמי.
🔒 הסיסמה לא נשמרת אצלנו אף פעם — היא עוברת רק לחלון הדפדפן ונמחקת מיד.
setUsername(e.target.value)} required autoComplete="off" style={inpStyle} placeholder="לרוב מספר ת.ז." /> setPassword(e.target.value)} required autoComplete="off" style={inpStyle} placeholder="הסיסמה לאתר חברת האשראי" /> setMonths(e.target.value)} style={inpStyle} /> )} {status === 'running' && (
{message}
)} {status === 'done' && (
{message}
)} {status === 'error' && (
{message}
)}
{(status === 'done' || status === 'error') && ( )} {status === 'error' && ( )} {!status && ( <> )}
); } const inpStyle = { width: '100%', padding: '8px 10px', borderRadius: 9, background: 'var(--bg-deep)', border: '0.5px solid var(--rule)', fontSize: 13, outline: 'none', boxSizing: 'border-box' }; function Field({ label, children }) { return (
{label}
{children}
); } function SubsPage() { const { dataVersion } = useApp(); const [filter, setFilter] = useState('all'); // all | used | unused | high const [subs, setSubs] = useState(D.subscriptions || []); const [loading, setLoading] = useState(false); const [editing, setEditing] = useState(null); // currently-edited subscription object // Refetch when data refreshes — hidden items always filtered out useEffect(() => { let cancelled = false; setLoading(true); apiFetch(`/api/subscriptions?months_back=12&min_months=3&include_hidden=false`) .then(r => r.json()) .then(data => { if (!cancelled) setSubs(data); }) .catch(() => { if (!cancelled) setSubs(D.subscriptions || []); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [dataVersion]); const visibleSubs = subs; const filtered = useMemo(() => { let arr = visibleSubs.slice(); if (filter === 'used') arr = arr.filter(s => s.used); if (filter === 'unused') arr = arr.filter(s => !s.used); if (filter === 'high') arr = arr.filter(s => s.amount >= 100); return arr; }, [filter, visibleSubs.length]); const total = filtered.reduce((s,x) => s + x.amount, 0); const totalUsed = visibleSubs.filter(s => s.used).reduce((s,x) => s + x.amount, 0); const totalUnused = visibleSubs.filter(s => !s.used).reduce((s,x) => s + x.amount, 0); // Hard delete — sub disappears from the page; data is preserved server-side // as a hidden override so it never auto-detects again on future scrapes. const removeSub = async (sub) => { if (!confirm(`למחוק את "${sub.name}" מרשימת המנויים?\n\nהעסקאות עצמן לא יימחקו, אבל הספק לא יזוהה כמנוי שוב.`)) return; try { await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden: true }), }); // Remove from local state immediately setSubs(prev => prev.filter(s => s.key !== sub.key)); } catch (e) { alert('מחיקה נכשלה: ' + e.message); } }; return (

מנויים

זוהו אוטומטית מהיסטוריית העסקאות · 12 חודשים אחרונים · גם כל מה שסיווגת ידנית כ-"חשבונות ומנויים"
{subs.length === 0 ? ( ) : ( <> {/* Hero stats */}
סך כל החיובים
s + x.amount, 0)} size={26} weight={700} />
{subs.length} ספקים · לחודש
חיובים פעילים
{subs.filter(s => s.used).length} ב-45 ימים אחרונים
חיובים רדומים
0 ? 'var(--alert)' : 'var(--ink-soft)'} />
{subs.filter(s => !s.used).length} שלא חויבו לאחרונה
{/* Filters */}
setFilter('all')} /> s.used).length}`} on={filter==='used'} onClick={() => setFilter('used')} /> !s.used).length}`} on={filter==='unused'} onClick={() => setFilter('unused')} /> setFilter('high')} /> סינון: {fmtILS(total)}/חודש · {fmtILS(total*12)}/שנה
{filtered.map((s, i) => { const cat = D.categories[s.category] || { color: 'var(--ink-soft)', icon: '·' }; const lastSeen = new Date(s.last_seen); const daysAgo = Math.round((Date.now() - lastSeen.getTime()) / (1000 * 60 * 60 * 24)); const variance = s.variance_pct; const isStrict = variance < 5; // very tight = recurring fixed bill const isLoose = variance > 25; // wider = could be just frequent merchant return (
{cat.icon}
{s.name} {s.edited && } {s.hidden && מוסתר} {!s.hidden && isStrict && קבוע} {!s.hidden && isLoose && משתנה}
{s.category} · {s.months} חודשים · {s.occurrences} חיובים {' · '} {daysAgo === 0 ? 'חויב היום' : `לפני ${daysAgo} ימים`}
{!s.used && !s.hidden && ( רדום )}
{fmtILS(-s.amount)}
{fmtILS(-s.annual_estimate)}/שנה
{/* Action buttons */}
); })} {filtered.length === 0 && ( )}
איך הזיהוי עובד:{' '} סורק את 12 החודשים האחרונים. כל ספק שמחויב ב-3+ חודשים שונים נחשב חיוב חוזר. קבוע = פחות מ-5% שינוי בסכום (כמו Netflix). משתנה = יותר מ-25% (כמו תחנת דלק שאתה מבקר בה לעיתים קרובות). רדום = לא היה חיוב ב-45 הימים האחרונים — שווה לבדוק שאין תשלום ששכחת.
)} {editing && setEditing(null)} onSaved={(updated) => { setSubs(prev => prev.map(s => s.key === updated.key ? updated : s)); setEditing(null); }} onReset={(key) => { // Reset = removed all overrides; refetch to get clean detection apiFetch(`/api/subscriptions?months_back=12&min_months=3&include_hidden=false`) .then(r => r.json()) .then(setSubs) .catch(() => {}); setEditing(null); }} />}
); } function SubscriptionEditModal({ sub, onClose, onSaved, onReset }) { const [name, setName] = useState(sub.name); const [category, setCategory] = useState(sub.category); const [hidden, setHidden] = useState(!!sub.hidden); const [notes, setNotes] = useState(sub.notes || ''); const [saving, setSaving] = useState(false); const categories = Object.keys(D.categories || {}); const hasOverrides = sub.edited; const save = async (e) => { e?.preventDefault(); setSaving(true); try { // Only send changed fields; null leaves them unchanged const body = { display_name: name === sub.original_name ? '' : name, category: category === sub.original_category ? '' : category, hidden, notes, }; const r = await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || 'שמירה נכשלה'); } onSaved({ ...sub, name, category, hidden, notes, edited: true, }); } catch (err) { alert('שגיאה: ' + err.message); } finally { setSaving(false); } }; const resetOverrides = async () => { if (!confirm('לבטל את כל העריכות ולחזור לזיהוי האוטומטי המקורי?')) return; setSaving(true); try { await apiFetch(`/api/subscriptions/${encodeURIComponent(sub.key)}/override`, { method: 'DELETE' }); onReset(sub.key); } catch (err) { alert('שגיאה: ' + err.message); } finally { setSaving(false); } }; return (
e.stopPropagation()} onSubmit={save} className="slide-up" style={{ width: 480, maxWidth: '90%', background: 'var(--surface)', borderRadius: 16, boxShadow: 'var(--shadow-lg)', overflow: 'hidden', }}>
עריכת מנוי
{sub.key}
{/* Read-only metadata */}
סכום ממוצע
{fmtILS(sub.amount)}
חיובים
{sub.occurrences} ב-{sub.months} חודשים
שונות
{sub.variance_pct}%
setName(e.target.value)} style={inpStyle} /> {sub.original_name !== name && (
מקורי: {sub.original_name}
)}