// 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 (
);
}
// 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 (
);
}
// 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,
});