// Shared primitives, hooks, and util components for the CliBank Soft app. const { useState, useEffect, useMemo, useRef, useCallback, createContext, useContext } = React; // D is a lazy proxy: components access it during render (after data loads), // not at module load time. Direct assignment was the original mock-data path. const D = new Proxy({}, { get(_, prop) { if (!window.APP_DATA) return undefined; return window.APP_DATA[prop]; }, has(_, prop) { return window.APP_DATA != null && prop in window.APP_DATA; }, }); const fmtILS = (n, opts) => (window.APP_DATA?.fmtILS || ((x) => String(x ?? 0)))(n, opts); const fmtN = (n) => (window.APP_DATA?.fmtN || ((x) => String(x ?? 0)))(n); // ─── Theme + Tweaks context ─────────────────────────────────────── const AppCtx = createContext(null); const useApp = () => useContext(AppCtx); // Count-up hook function useCountUp(target, duration = 700) { const [v, setV] = useState(target); const startRef = useRef(target); const tRef = useRef(target); useEffect(() => { if (target === tRef.current) return; startRef.current = v; tRef.current = target; const t0 = performance.now(); let raf; const tick = (now) => { const p = Math.min(1, (now - t0) / duration); const eased = 1 - Math.pow(1 - p, 3); setV(Math.round(startRef.current + (target - startRef.current) * eased)); if (p < 1) raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [target, duration]); return v; } // Currency display with count-up function Money({ value, size = 16, weight = 600, color, sign = false, dec = 0 }) { const v = useCountUp(value); const isNeg = value < 0; return ( {sign && value > 0 ? '+' : ''} {fmtILS(v, { dec })} ); } // Hairline surface card function Surface({ children, style, padded = true, className = '', onClick }) { return (
{children}
); } // Segmented iOS-style function Segmented({ options, value, onChange }) { return (
{options.map((o, i) => ( onChange(o.value ?? o)} style={{ padding: '5px 14px', borderRadius: 8, background: (o.value ?? o) === value ? 'var(--surface)' : 'transparent', color: (o.value ?? o) === value ? 'var(--ink)' : 'var(--ink-soft)', boxShadow: (o.value ?? o) === value ? 'var(--shadow-sm)' : 'none', cursor: 'pointer', transition: 'background .15s', userSelect: 'none', }}>{o.label ?? o} ))}
); } // Sparkle / AI badge function SparkBadge({ size = 30 }) { return (
); } // Pace chart — signature visual function PaceChart({ daily, today, projected, height = 170 }) { const days = daily.length; const W = 540, H = height, padX = 4, padTop = 30, padBot = 22; const innerW = W - padX*2, innerH = H - padTop - padBot; const yMax = Math.max(daily[daily.length-1] || 0, projected) * 1.05 || 1; const xAt = (i) => padX + (i / (days-1)) * innerW; const yAt = (v) => padTop + innerH - (v / yMax) * innerH; const actualPts = daily.slice(0, today).map((v, i) => [xAt(i), yAt(v)]); const actualD = actualPts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const fillD = actualPts.length > 0 ? `${actualD} L${actualPts[actualPts.length-1][0]},${padTop+innerH} L${actualPts[0][0]},${padTop+innerH} Z` : ''; const paceD = `M${xAt(0)},${yAt(0)} L${xAt(days-1)},${yAt(projected)}`; const cur = actualPts[actualPts.length - 1] || [padX, padTop+innerH]; return ( {[1, 8, 15, 22, 31].filter(d => d <= days).map(d => ( {d} ))} ); } // 12-month area chart function MultiMonthChart({ months, height = 140, accent = 'var(--accent)' }) { const values = months.map(m => m.summary.expenses); const max = Math.max(...values) * 1.1; const min = Math.min(...values) * 0.85; const range = max - min || 1; const W = 600, H = height, padX = 8, padTop = 8, padBot = 22; const innerW = W - padX*2, innerH = H - padTop - padBot; const step = innerW / (values.length - 1 || 1); const pts = values.map((v, i) => [padX + i * step, padTop + innerH - ((v - min) / range) * innerH]); const d = pts.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(' '); const fillD = `${d} L${pts[pts.length-1][0]},${padTop+innerH} L${pts[0][0]},${padTop+innerH} Z`; return ( {[0, 0.5, 1].map((t, i) => ( ))} {pts.map((p, i) => ( ))} {months.map((m, i) => i % 2 === 0 && ( {m.shortLabel} ))} ); } // Daily-pace data helper function useDailyPace(month, today) { return useMemo(() => { const days = 31; const arr = new Array(days).fill(0); month.txns.filter(t => t.amount < 0).forEach(t => { const d = parseInt(t.date.slice(8), 10) - 1; if (d >= 0 && d < days) arr[d] += -t.amount; }); let acc = 0; return arr.map(v => acc += v); }, [month]); } // Skeleton helpers function SkeletonRow({ height = 14, width = '100%', mb = 8 }) { return
; } // Empty state function EmptyState({ icon = '◌', title, sub, action }) { return (
{icon}
{title}
{sub &&
{sub}
} {action}
); } // Hebrew month formatter const MONTH_NAMES = ['ינואר','פברואר','מרץ','אפריל','מאי','יוני','יולי','אוגוסט','ספטמבר','אוקטובר','נובמבר','דצמבר']; const fmtDate = (s, opts = {}) => { const d = new Date(s); const day = d.getDate(); const m = MONTH_NAMES[d.getMonth()]; if (opts.short) return `${day}.${String(d.getMonth()+1).padStart(2,'0')}`; return `${day} ב${m}`; }; Object.assign(window, { useApp, AppCtx, useCountUp, Money, Surface, Segmented, SparkBadge, PaceChart, MultiMonthChart, useDailyPace, SkeletonRow, EmptyState, fmtDate, MONTH_NAMES, D, fmtILS, fmtN, });