KrishnaGarg commited on
Commit
0fe2ea2
·
verified ·
1 Parent(s): 30708e0

Deploy BudgetBuddy update

Browse files
Files changed (3) hide show
  1. app.py +27 -16
  2. frontend/assets/app.js +38 -9
  3. frontend/index.html +28 -14
app.py CHANGED
@@ -126,9 +126,25 @@ def api_save(token: str, record_json: str) -> dict:
126
  return {"ok": False, "message": f"Save failed: {e}"}
127
 
128
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  @app.api(name="dashboard")
130
- def api_dashboard(token: str, start: str = "", end: str = "",
131
- category: str = "All", granularity: str = "Monthly") -> dict:
132
  user = auth.verify_token(token)
133
  if not user:
134
  return {"ok": False, "error": "auth"}
@@ -138,18 +154,11 @@ def api_dashboard(token: str, start: str = "", end: str = "",
138
  cat = None if category in ("", "All", None) else category
139
  filt = analytics.filter_records(
140
  records, analytics.parse_date(start), analytics.parse_date(end), cat)
141
- txns = [{"date": r.get("date", ""), "vendor": r.get("vendor", ""),
142
- "total": analytics._num(r.get("total")), "category": analytics._category(r),
143
- "source": r.get("source", "receipt"),
144
- "understanding": r.get("understanding", ""),
145
- "currency": r.get("currency", ""),
146
- "items": [{"name": str(it.get("name", "")), "amount": analytics._num(it.get("amount")),
147
- "qty": analytics._num(it.get("qty", 1)) or 1,
148
- "category": str(it.get("category") or analytics._category(r))}
149
- for it in (r.get("line_items") or [])]}
150
- for r in sorted(filt, key=lambda r: (analytics.parse_date(r.get("date")) or date.min,
151
- str(r.get("saved_at", ""))), reverse=True)]
152
  today = date.today()
 
 
 
 
153
  return {
154
  "ok": True,
155
  "summary": summ,
@@ -157,9 +166,11 @@ def api_dashboard(token: str, start: str = "", end: str = "",
157
  "by_category": analytics.spend_by_category(filt),
158
  "over_time": analytics.spend_over_time(filt, granularity or "Monthly"),
159
  "comparison": analytics.category_comparison(records),
160
- "calendar": analytics.calendar_data(records, today.year, today.month),
161
- "calendar_month": f"{today.year}-{today.month:02d}",
162
- "transactions": txns,
 
 
163
  }
164
  except Exception as e:
165
  import traceback
 
126
  return {"ok": False, "message": f"Save failed: {e}"}
127
 
128
 
129
+ def _txn_dict(r: dict) -> dict:
130
+ return {"date": r.get("date", ""), "vendor": r.get("vendor", ""),
131
+ "total": analytics._num(r.get("total")), "category": analytics._category(r),
132
+ "source": r.get("source", "receipt"), "understanding": r.get("understanding", ""),
133
+ "currency": r.get("currency", ""),
134
+ "items": [{"name": str(it.get("name", "")), "amount": analytics._num(it.get("amount")),
135
+ "qty": analytics._num(it.get("qty", 1)) or 1,
136
+ "category": str(it.get("category") or analytics._category(r))}
137
+ for it in (r.get("line_items") or [])]}
138
+
139
+
140
+ def _sort_recent(records):
141
+ return sorted(records, key=lambda r: (analytics.parse_date(r.get("date")) or date.min,
142
+ str(r.get("saved_at", ""))), reverse=True)
143
+
144
+
145
  @app.api(name="dashboard")
146
+ def api_dashboard(token: str, start: str = "", end: str = "", category: str = "All",
147
+ granularity: str = "Monthly", cal_month: str = "") -> dict:
148
  user = auth.verify_token(token)
149
  if not user:
150
  return {"ok": False, "error": "auth"}
 
154
  cat = None if category in ("", "All", None) else category
155
  filt = analytics.filter_records(
156
  records, analytics.parse_date(start), analytics.parse_date(end), cat)
 
 
 
 
 
 
 
 
 
 
 
157
  today = date.today()
158
+ try:
159
+ cy, cm = (int(x) for x in cal_month.split("-")) if cal_month else (today.year, today.month)
160
+ except Exception:
161
+ cy, cm = today.year, today.month
162
  return {
163
  "ok": True,
164
  "summary": summ,
 
166
  "by_category": analytics.spend_by_category(filt),
167
  "over_time": analytics.spend_over_time(filt, granularity or "Monthly"),
168
  "comparison": analytics.category_comparison(records),
169
+ "calendar": analytics.calendar_data(records, cy, cm),
170
+ "calendar_month": f"{cy}-{cm:02d}",
171
+ "transactions": [_txn_dict(r) for r in _sort_recent(filt)],
172
+ "recent": [_txn_dict(r) for r in _sort_recent(records)[:8]],
173
+ "has_any": len(records) > 0,
174
  }
175
  except Exception as e:
176
  import traceback
frontend/assets/app.js CHANGED
@@ -72,6 +72,9 @@ async function loadDashboard() {
72
  budgetRing(s.this_month_total, d.budget, cur);
73
  $("#budget-sub").textContent = d.budget > 0 ? `${money(s.this_month_total, cur)} of ${money(d.budget, cur)} used` : "No budget set yet";
74
  donut(d.by_category, cur); $("#ot-sub").textContent = S.gran; timeChart(d.over_time); comparison(d.comparison, cur);
 
 
 
75
  }
76
  function budgetRing(spent, budget, cur) {
77
  const ctx = $("#ring-budget"); if (S.charts.b) S.charts.b.destroy();
@@ -81,14 +84,14 @@ function budgetRing(spent, budget, cur) {
81
  }
82
  function donut(rows, cur) {
83
  const ctx = $("#chart-cat"); if (S.charts.c) S.charts.c.destroy();
84
- if (!rows.length) { ctx.getContext("2d").clearRect(0, 0, ctx.width, ctx.height); return; }
85
- S.charts.c = new Chart(ctx, { type: "doughnut", data: { labels: rows.map(r => r.category), datasets: [{ data: rows.map(r => r.amount), backgroundColor: rows.map(r => catColor(r.category)), borderWidth: 0, hoverOffset: 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "64%", plugins: { legend: { position: "right", labels: { color: "#cfd6e4", boxWidth: 10, font: { size: 11 }, padding: 9, usePointStyle: true } }, tooltip: { callbacks: { label: c => ` ${c.label}: ${money(c.parsed, cur)}` } } } } });
86
  }
87
  function timeChart(rows) {
88
  const ctx = $("#chart-time"); if (S.charts.t) S.charts.t.destroy();
89
- if (!rows.length) { ctx.getContext("2d").clearRect(0, 0, ctx.width, ctx.height); return; }
90
  const g = ctx.getContext("2d").createLinearGradient(0, 0, 0, 230); g.addColorStop(0, "rgba(215,242,90,.32)"); g.addColorStop(1, "rgba(215,242,90,0)");
91
- S.charts.t = new Chart(ctx, { type: "line", data: { labels: rows.map(r => r.period), datasets: [{ data: rows.map(r => r.amount), borderColor: "#d7f25a", backgroundColor: g, fill: true, tension: .4, pointRadius: 3, pointBackgroundColor: "#d7f25a", borderWidth: 2.5 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { display: false } }, y: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { color: "#ffffff0c" } } } } });
92
  }
93
  function comparison(rows, cur) {
94
  if (!rows || !rows.length) { $("#comparison").innerHTML = `<div class="muted" style="font-size:.84rem">No data yet.</div>`; return; }
@@ -101,9 +104,15 @@ async function saveBudget() { const v = num($("#budget-in").value); if (v <= 0)
101
  /* calendar */
102
  async function loadCalendar() {
103
  if (!S.token) return;
104
- const t = new Date(), y = t.getFullYear(), m = t.getMonth(), iso = d => d.toISOString().slice(0, 10);
105
- let d; try { d = await api("dashboard", { token: S.token, start: iso(new Date(y, m, 1)), end: iso(new Date(y, m + 1, 0)), category: "All", granularity: "Daily" }); } catch (e) { return; }
106
- if (!d.ok) return; S.cal = d; renderCalendar();
 
 
 
 
 
 
107
  }
108
  function renderCalendar() {
109
  const d = S.cal; if (!d) return;
@@ -190,7 +199,20 @@ async function understandReceipt() {
190
  try { const r = await api("understand", { record_json: JSON.stringify(readReceipt()) }); fillReceipt(r.record); const u = $("#rc-understand"); u.classList.remove("hidden"); u.textContent = r.understanding || "Categorized."; const unc = r.uncertain || []; $("#rc-savemsg").textContent = unc.length ? "Double-check: " + unc.join(", ") : "Looks good — Save when ready."; $("#rc-savemsg").className = "msg " + (unc.length ? "warn" : "good"); $("#rc-save").disabled = false; }
191
  catch (e) { $("#rc-savemsg").textContent = "Error: " + e; } busy($("#rc-confirm"), false);
192
  }
193
- async function saveReceipt() { busy($("#rc-save"), true, "Saving…"); try { const r = await api("save", { token: S.token, record_json: JSON.stringify(readReceipt()) }); $("#rc-savemsg").textContent = r.ok ? "Saved." : r.message; $("#rc-savemsg").className = "msg " + (r.ok ? "good" : "warn"); if (r.ok) loadDashboard(); } catch (e) { $("#rc-savemsg").textContent = "Error: " + e; } busy($("#rc-save"), false); }
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  /* payment */
196
  async function extractPayment() {
@@ -201,7 +223,12 @@ async function extractPayment() {
201
  }
202
  async function savePayment() {
203
  const rec = { vendor: $("#pm-vendor").value.trim(), date: $("#pm-date").value.trim(), currency: $("#pm-currency").value.trim(), line_items: [], charges: [], total: num($("#pm-amount").value), category: $("#pm-cat").value, note: $("#pm-note").value.trim(), source: "payment" };
204
- busy($("#pm-save"), true, "Saving…"); try { const r = await api("save", { token: S.token, record_json: JSON.stringify(rec) }); $("#pm-savemsg").textContent = r.ok ? "Saved." : r.message; $("#pm-savemsg").className = "msg " + (r.ok ? "good" : "warn"); if (r.ok) loadDashboard(); } catch (e) { $("#pm-savemsg").textContent = "Error: " + e; } busy($("#pm-save"), false);
 
 
 
 
 
205
  }
206
 
207
  /* manual (item-wise) */
@@ -260,6 +287,8 @@ async function boot() {
260
  wireFile("#pm-file", "#pm-cam", "#pm-prev", f => S.pmFile = f);
261
  $("#mn-additem").onclick = () => $("#mn-items tbody").appendChild(mnItemRow());
262
  $("#mn-save").onclick = saveManual;
 
 
263
  ["#tx-q", "#tx-cat", "#tx-from", "#tx-to"].forEach(s => $(s).addEventListener("input", () => { if (s === "#tx-q") renderTxnList(); else loadTxns(); }));
264
  $("#chat-send").onclick = chat; $("#chat-in").addEventListener("keydown", e => { if (e.key === "Enter") chat(); });
265
  $("#chat-chips").innerHTML = ["How much did I spend this month?", "What's my top category?", "How much on Dining?"].map(q => `<button class="chip">${q}</button>`).join("");
 
72
  budgetRing(s.this_month_total, d.budget, cur);
73
  $("#budget-sub").textContent = d.budget > 0 ? `${money(s.this_month_total, cur)} of ${money(d.budget, cur)} used` : "No budget set yet";
74
  donut(d.by_category, cur); $("#ot-sub").textContent = S.gran; timeChart(d.over_time); comparison(d.comparison, cur);
75
+ const rec = d.recent || [];
76
+ $("#recent").innerHTML = rec.length ? rec.map(t => txnCard(t, t.currency || cur)).join("") : `<div class="muted" style="font-size:.85rem">No transactions yet — add your first spend.</div>`;
77
+ wireTxnExpand();
78
  }
79
  function budgetRing(spent, budget, cur) {
80
  const ctx = $("#ring-budget"); if (S.charts.b) S.charts.b.destroy();
 
84
  }
85
  function donut(rows, cur) {
86
  const ctx = $("#chart-cat"); if (S.charts.c) S.charts.c.destroy();
87
+ const empty = !rows.length;
88
+ S.charts.c = new Chart(ctx, { type: "doughnut", data: { labels: empty ? ["No spending yet"] : rows.map(r => r.category), datasets: [{ data: empty ? [1] : rows.map(r => r.amount), backgroundColor: empty ? ["#242936"] : rows.map(r => catColor(r.category)), borderWidth: 0, hoverOffset: empty ? 0 : 6 }] }, options: { responsive: true, maintainAspectRatio: false, cutout: "64%", plugins: { legend: { display: !empty, position: "right", labels: { color: "#cfd6e4", boxWidth: 10, font: { size: 11 }, padding: 9, usePointStyle: true } }, tooltip: { enabled: !empty, callbacks: { label: c => ` ${c.label}: ${money(c.parsed, cur)}` } } } } });
89
  }
90
  function timeChart(rows) {
91
  const ctx = $("#chart-time"); if (S.charts.t) S.charts.t.destroy();
92
+ const empty = !rows.length;
93
  const g = ctx.getContext("2d").createLinearGradient(0, 0, 0, 230); g.addColorStop(0, "rgba(215,242,90,.32)"); g.addColorStop(1, "rgba(215,242,90,0)");
94
+ S.charts.t = new Chart(ctx, { type: "line", data: { labels: empty ? ["", ""] : rows.map(r => r.period), datasets: [{ data: empty ? [0, 0] : rows.map(r => r.amount), borderColor: "#d7f25a", backgroundColor: g, fill: true, tension: .4, pointRadius: empty ? 0 : 3, pointBackgroundColor: "#d7f25a", borderWidth: 2.5 }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { display: false } }, y: { beginAtZero: true, ticks: { color: "#8b94a6", font: { size: 10 } }, grid: { color: "#ffffff0c" } } } } });
95
  }
96
  function comparison(rows, cur) {
97
  if (!rows || !rows.length) { $("#comparison").innerHTML = `<div class="muted" style="font-size:.84rem">No data yet.</div>`; return; }
 
104
  /* calendar */
105
  async function loadCalendar() {
106
  if (!S.token) return;
107
+ if (!S.calYM) { const t = new Date(); S.calYM = [t.getFullYear(), t.getMonth() + 1]; }
108
+ const [y, m] = S.calYM, iso = d => d.toISOString().slice(0, 10);
109
+ let d; try { d = await api("dashboard", { token: S.token, start: iso(new Date(y, m - 1, 1)), end: iso(new Date(y, m, 0)), category: "All", granularity: "Daily", cal_month: `${y}-${String(m).padStart(2, "0")}` }); } catch (e) { return; }
110
+ if (!d.ok) return; S.cal = d; S.selDay = null; renderCalendar();
111
+ $("#cal-day-title").textContent = "Pick a day"; $("#cal-day").innerHTML = "";
112
+ }
113
+ function calNav(delta) {
114
+ if (!S.calYM) { const t = new Date(); S.calYM = [t.getFullYear(), t.getMonth() + 1]; }
115
+ let [y, m] = S.calYM; m += delta; if (m < 1) { m = 12; y--; } if (m > 12) { m = 1; y++; } S.calYM = [y, m]; loadCalendar();
116
  }
117
  function renderCalendar() {
118
  const d = S.cal; if (!d) return;
 
199
  try { const r = await api("understand", { record_json: JSON.stringify(readReceipt()) }); fillReceipt(r.record); const u = $("#rc-understand"); u.classList.remove("hidden"); u.textContent = r.understanding || "Categorized."; const unc = r.uncertain || []; $("#rc-savemsg").textContent = unc.length ? "Double-check: " + unc.join(", ") : "Looks good — Save when ready."; $("#rc-savemsg").className = "msg " + (unc.length ? "warn" : "good"); $("#rc-save").disabled = false; }
200
  catch (e) { $("#rc-savemsg").textContent = "Error: " + e; } busy($("#rc-confirm"), false);
201
  }
202
+ function resetReceipt() {
203
+ fillReceipt({ line_items: [], charges: [] });
204
+ $("#rc-prev").innerHTML = ""; $("#rc-status").textContent = ""; $("#rc-understand").classList.add("hidden");
205
+ $("#rc-save").disabled = true; S.rcFile = null; $("#rc-file").value = ""; $("#rc-cam").value = "";
206
+ }
207
+ async function saveReceipt() {
208
+ busy($("#rc-save"), true, "Saving…");
209
+ try {
210
+ const r = await api("save", { token: S.token, record_json: JSON.stringify(readReceipt()) });
211
+ if (r.ok) { resetReceipt(); $("#rc-savemsg").textContent = "Saved ✓ — see it on your Dashboard."; $("#rc-savemsg").className = "msg good"; loadDashboard(); }
212
+ else { $("#rc-savemsg").textContent = r.message; $("#rc-savemsg").className = "msg warn"; }
213
+ } catch (e) { $("#rc-savemsg").textContent = "Error: " + e; }
214
+ busy($("#rc-save"), false);
215
+ }
216
 
217
  /* payment */
218
  async function extractPayment() {
 
223
  }
224
  async function savePayment() {
225
  const rec = { vendor: $("#pm-vendor").value.trim(), date: $("#pm-date").value.trim(), currency: $("#pm-currency").value.trim(), line_items: [], charges: [], total: num($("#pm-amount").value), category: $("#pm-cat").value, note: $("#pm-note").value.trim(), source: "payment" };
226
+ busy($("#pm-save"), true, "Saving…");
227
+ try {
228
+ const r = await api("save", { token: S.token, record_json: JSON.stringify(rec) });
229
+ if (r.ok) { ["pm-vendor", "pm-amount", "pm-date", "pm-currency", "pm-note"].forEach(i => $("#" + i).value = ""); $("#pm-prev").innerHTML = ""; $("#pm-status").textContent = ""; $("#pm-save").disabled = true; S.pmFile = null; $("#pm-file").value = ""; $("#pm-cam").value = ""; $("#pm-savemsg").textContent = "Saved ✓ — see it on your Dashboard."; $("#pm-savemsg").className = "msg good"; loadDashboard(); }
230
+ else { $("#pm-savemsg").textContent = r.message; $("#pm-savemsg").className = "msg warn"; }
231
+ } catch (e) { $("#pm-savemsg").textContent = "Error: " + e; } busy($("#pm-save"), false);
232
  }
233
 
234
  /* manual (item-wise) */
 
287
  wireFile("#pm-file", "#pm-cam", "#pm-prev", f => S.pmFile = f);
288
  $("#mn-additem").onclick = () => $("#mn-items tbody").appendChild(mnItemRow());
289
  $("#mn-save").onclick = saveManual;
290
+ $("#cal-prev").onclick = () => calNav(-1); $("#cal-next").onclick = () => calNav(1);
291
+ $("#see-all").onclick = () => view("txns");
292
  ["#tx-q", "#tx-cat", "#tx-from", "#tx-to"].forEach(s => $(s).addEventListener("input", () => { if (s === "#tx-q") renderTxnList(); else loadTxns(); }));
293
  $("#chat-send").onclick = chat; $("#chat-in").addEventListener("keydown", e => { if (e.key === "Enter") chat(); });
294
  $("#chat-chips").innerHTML = ["How much did I spend this month?", "What's my top category?", "How much on Dining?"].map(q => `<button class="chip">${q}</button>`).join("");
frontend/index.html CHANGED
@@ -27,6 +27,9 @@ svg{stroke-width:1.9;fill:none;stroke:currentColor;stroke-linecap:round;stroke-l
27
  input,select,textarea{width:100%;background:var(--surface2);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:11px 13px;font-size:.9rem;outline:none;transition:border-color .15s}
28
  input:focus,select:focus,textarea:focus{border-color:var(--lav)}
29
  input[type=date]::-webkit-calendar-picker-indicator{filter:invert(.7)}
 
 
 
30
  label{display:block;font-size:.76rem;color:var(--muted);margin-bottom:6px;font-weight:600}
31
  .field{margin:13px 0} .row{display:flex;gap:11px}.row>*{flex:1} .msg{margin-top:11px;font-size:.84rem;min-height:1em}
32
  .btn{background:var(--lime);color:var(--lime-ink);font-weight:800;border-radius:13px;padding:11px 17px;font-size:.88rem;transition:filter .12s,transform .05s}
@@ -104,6 +107,9 @@ td input,td select{padding:8px 9px;border-radius:9px}
104
  .drop{border:1.5px dashed var(--line);border-radius:15px;padding:18px;text-align:center;color:var(--muted);font-size:.85rem;display:block;cursor:pointer}
105
  .drop:hover{border-color:var(--lav);color:var(--ink)} .drop img{max-height:280px;max-width:100%;border-radius:12px;margin-top:10px}
106
  .cap-row{display:flex;gap:9px;margin-top:10px}
 
 
 
107
  .banner{border-radius:12px;padding:11px 14px;font-size:.84rem;margin:12px 0;border:1px solid transparent}
108
  .banner.ok{background:#10271d;color:var(--good);border-color:#1d3a2c} .banner.warn{background:#2c2611;color:var(--warn);border-color:#3c331a}
109
  .banner.lav{background:#211a37;color:var(--lav);border-color:#352c57}
@@ -203,6 +209,7 @@ td input,td select{padding:8px 9px;border-radius:9px}
203
  <div class="panel"><h3>Spend over time</h3><div class="ph-sub" id="ot-sub">Monthly</div><div class="chart-box"><canvas id="chart-time"></canvas></div></div>
204
  <div class="panel"><h3>This month vs last</h3><div class="ph-sub">By category</div><div class="cmp" id="comparison"></div></div>
205
  </div>
 
206
  </section>
207
 
208
  <!-- Add -->
@@ -214,28 +221,33 @@ td input,td select{padding:8px 9px;border-radius:9px}
214
  <button class="mode" data-mode="manual"><svg viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>Manual</button>
215
  </div>
216
 
217
- <div id="mode-receipt" class="grid2b">
218
- <div class="panel">
219
  <h3>Bill photo</h3><div class="ph-sub">Snap or upload — items, taxes, round-off and all</div>
220
- <label class="drop" for="rc-file">Tap to choose a photo<div id="rc-prev"></div></label>
 
 
 
 
 
 
 
221
  <input id="rc-file" type="file" accept="image/*" class="vh"><input id="rc-cam" type="file" accept="image/*" capture="environment" class="vh">
222
- <div class="cap-row"><label class="btn alt sm" for="rc-file" style="flex:1;display:inline-flex;align-items:center;justify-content:center">Choose file</label><label class="btn alt sm" for="rc-cam" style="flex:1;display:inline-flex;align-items:center;justify-content:center">Take photo</label></div>
223
- <button class="btn" id="rc-extract" style="margin-top:11px;width:100%">Extract</button><div class="msg" id="rc-status"></div>
224
  </div>
225
  <div class="panel">
226
- <h3>Review &amp; understand</h3>
227
- <div class="row"><div class="field"><label>Vendor</label><input id="rc-vendor"></div><div class="field"><label>Currency</label><input id="rc-currency"></div></div>
228
- <div class="field"><label>Date</label><input id="rc-date" type="date"></div>
229
  <label>Line items</label>
230
  <table id="rc-items"><thead><tr><th>Item</th><th class="col-qty">Qty</th><th class="col-amt">Amount</th><th class="col-cat">Category</th><th class="col-x"></th></tr></thead><tbody></tbody></table>
231
  <button class="btn sm alt" id="rc-additem" style="margin-top:8px">+ Add item</button>
232
- <label style="margin-top:15px">Charges &amp; taxes</label>
233
- <table id="rc-charges"><thead><tr><th>Label</th><th class="col-amt">Amount</th><th class="col-x"></th></tr></thead><tbody></tbody></table>
234
- <button class="btn sm alt" id="rc-addcharge" style="margin-top:8px">+ Add charge</button>
235
- <div class="row" style="margin-top:15px"><div class="field"><label>Total</label><input id="rc-total" type="number" inputmode="decimal"></div><div class="field"><label>Overall category</label><select id="rc-cat"></select></div></div>
236
  <div id="rc-recon" class="banner ok">Totals reconcile.</div>
237
  <div id="rc-understand" class="banner lav hidden"></div>
238
- <div class="row"><button class="btn lav" id="rc-confirm">Understand &amp; categorize</button><button class="btn" id="rc-save" disabled>Save</button></div>
239
  <div class="msg" id="rc-savemsg"></div>
240
  </div>
241
  </div>
@@ -268,7 +280,9 @@ td input,td select{padding:8px 9px;border-radius:9px}
268
 
269
  <!-- Calendar -->
270
  <section id="view-calendar" class="hidden">
271
- <div class="topbar"><div class="title">Calendar</div><div class="sub" id="cal-month"></div></div>
 
 
272
  <div class="grid2b">
273
  <div class="panel"><div class="cal-grid" id="cal"></div></div>
274
  <div class="panel"><h3 id="cal-day-title">Pick a day</h3><div class="ph-sub">Spending on the selected day</div><div id="cal-day"></div></div>
 
27
  input,select,textarea{width:100%;background:var(--surface2);border:1px solid var(--line);color:var(--ink);border-radius:12px;padding:11px 13px;font-size:.9rem;outline:none;transition:border-color .15s}
28
  input:focus,select:focus,textarea:focus{border-color:var(--lav)}
29
  input[type=date]::-webkit-calendar-picker-indicator{filter:invert(.7)}
30
+ input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
31
+ input[type=number]{-moz-appearance:textfield;appearance:textfield}
32
+ .empty-chart{height:230px;display:flex;align-items:center;justify-content:center;color:var(--faint);font-size:.86rem;border:1px dashed var(--line);border-radius:14px}
33
  label{display:block;font-size:.76rem;color:var(--muted);margin-bottom:6px;font-weight:600}
34
  .field{margin:13px 0} .row{display:flex;gap:11px}.row>*{flex:1} .msg{margin-top:11px;font-size:.84rem;min-height:1em}
35
  .btn{background:var(--lime);color:var(--lime-ink);font-weight:800;border-radius:13px;padding:11px 17px;font-size:.88rem;transition:filter .12s,transform .05s}
 
107
  .drop{border:1.5px dashed var(--line);border-radius:15px;padding:18px;text-align:center;color:var(--muted);font-size:.85rem;display:block;cursor:pointer}
108
  .drop:hover{border-color:var(--lav);color:var(--ink)} .drop img{max-height:280px;max-width:100%;border-radius:12px;margin-top:10px}
109
  .cap-row{display:flex;gap:9px;margin-top:10px}
110
+ .upload-row{display:flex;gap:14px;align-items:stretch}
111
+ .upload-actions{display:flex;flex-direction:column;gap:9px;justify-content:center;min-width:160px}
112
+ @media(max-width:720px){.upload-row{flex-direction:column}.upload-actions{min-width:0}}
113
  .banner{border-radius:12px;padding:11px 14px;font-size:.84rem;margin:12px 0;border:1px solid transparent}
114
  .banner.ok{background:#10271d;color:var(--good);border-color:#1d3a2c} .banner.warn{background:#2c2611;color:var(--warn);border-color:#3c331a}
115
  .banner.lav{background:#211a37;color:var(--lav);border-color:#352c57}
 
209
  <div class="panel"><h3>Spend over time</h3><div class="ph-sub" id="ot-sub">Monthly</div><div class="chart-box"><canvas id="chart-time"></canvas></div></div>
210
  <div class="panel"><h3>This month vs last</h3><div class="ph-sub">By category</div><div class="cmp" id="comparison"></div></div>
211
  </div>
212
+ <div class="panel"><div style="display:flex;justify-content:space-between;align-items:center"><h3>Recent transactions</h3><button class="btn sm alt" id="see-all">See all</button></div><div class="ph-sub">Your latest spends</div><div id="recent"></div></div>
213
  </section>
214
 
215
  <!-- Add -->
 
221
  <button class="mode" data-mode="manual"><svg viewBox="0 0 24 24"><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>Manual</button>
222
  </div>
223
 
224
+ <div id="mode-receipt">
225
+ <div class="panel" style="margin-bottom:16px">
226
  <h3>Bill photo</h3><div class="ph-sub">Snap or upload — items, taxes, round-off and all</div>
227
+ <div class="upload-row">
228
+ <label class="drop" for="rc-file" style="flex:1;margin:0">Tap to choose a photo<div id="rc-prev"></div></label>
229
+ <div class="upload-actions">
230
+ <label class="btn alt sm" for="rc-file" style="text-align:center">Choose file</label>
231
+ <label class="btn alt sm" for="rc-cam" style="text-align:center">Take photo</label>
232
+ <button class="btn" id="rc-extract">Extract</button>
233
+ </div>
234
+ </div>
235
  <input id="rc-file" type="file" accept="image/*" class="vh"><input id="rc-cam" type="file" accept="image/*" capture="environment" class="vh">
236
+ <div class="msg" id="rc-status"></div>
 
237
  </div>
238
  <div class="panel">
239
+ <h3>Review &amp; understand</h3><div class="ph-sub">Fix anything wrong, then let the AI categorize it</div>
240
+ <div class="row"><div class="field"><label>Vendor</label><input id="rc-vendor"></div><div class="field"><label>Date</label><input id="rc-date" type="date"></div><div class="field"><label>Currency</label><input id="rc-currency"></div></div>
 
241
  <label>Line items</label>
242
  <table id="rc-items"><thead><tr><th>Item</th><th class="col-qty">Qty</th><th class="col-amt">Amount</th><th class="col-cat">Category</th><th class="col-x"></th></tr></thead><tbody></tbody></table>
243
  <button class="btn sm alt" id="rc-additem" style="margin-top:8px">+ Add item</button>
244
+ <div class="row" style="margin-top:16px;align-items:flex-start">
245
+ <div style="flex:1"><label>Charges &amp; taxes</label><table id="rc-charges"><thead><tr><th>Label</th><th class="col-amt">Amount</th><th class="col-x"></th></tr></thead><tbody></tbody></table><button class="btn sm alt" id="rc-addcharge" style="margin-top:8px">+ Add charge</button></div>
246
+ <div style="flex:1"><div class="field" style="margin:0 0 13px"><label>Total</label><input id="rc-total" type="number" inputmode="decimal"></div><div class="field" style="margin:0"><label>Overall category</label><select id="rc-cat"></select></div></div>
247
+ </div>
248
  <div id="rc-recon" class="banner ok">Totals reconcile.</div>
249
  <div id="rc-understand" class="banner lav hidden"></div>
250
+ <div class="row" style="margin-top:4px"><button class="btn lav" id="rc-confirm">Understand &amp; categorize</button><button class="btn" id="rc-save" disabled>Save</button></div>
251
  <div class="msg" id="rc-savemsg"></div>
252
  </div>
253
  </div>
 
280
 
281
  <!-- Calendar -->
282
  <section id="view-calendar" class="hidden">
283
+ <div class="topbar"><div class="title">Calendar</div>
284
+ <div class="controls"><button class="btn sm alt" id="cal-prev">‹ Prev</button><div class="sub" id="cal-month" style="min-width:120px;text-align:center"></div><button class="btn sm alt" id="cal-next">Next ›</button></div>
285
+ </div>
286
  <div class="grid2b">
287
  <div class="panel"><div class="cal-grid" id="cal"></div></div>
288
  <div class="panel"><h3 id="cal-day-title">Pick a day</h3><div class="ph-sub">Spending on the selected day</div><div id="cal-day"></div></div>