First-run setup: upload Bills_Tracker.xlsx and loader_rules.json from the web page when the data folder is empty
build-container / build (push) Successful in 34s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 13:01:46 -04:00
1 parent 3dba054dca
commit 6f4ecd42f7
3 files changed
+16 -3

No files matched your search

+12 -2
View File
@@ -55,6 +55,9 @@ def coverage(sts):
return dict(months=months, accounts=out) return dict(months=months, accounts=out)
def state(): def state():
if not L.WORKBOOK.exists():
return dict(setup=True, data_dir=str(DATA), rules=(DATA / "loader_rules.json").exists(),
files=sorted(p.name for p in DATA.glob("*") if p.is_file())[:50])
res = L.run_preview(DATA) res = L.run_preview(DATA)
new = res["new"] new = res["new"]
rows = [dict(date=f"{r.txn.date:%Y-%m-%d}", account=r.txn.account, desc=r.txn.desc[:60], amount=r.txn.amount, kind=r.kind, rows = [dict(date=f"{r.txn.date:%Y-%m-%d}", account=r.txn.account, desc=r.txn.desc[:60], amount=r.txn.amount, kind=r.kind,
@@ -71,6 +74,7 @@ _DATA = {}
def workbook_data(): def workbook_data():
"""Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas).""" """Read-only snapshot of the Bill Log for the web dashboard (computed from raw cells, not cached formulas)."""
from openpyxl import load_workbook from openpyxl import load_workbook
if not L.WORKBOOK.exists(): return dict(tx=[], categories=[], excluded=0, updated="", setup=True, data_dir=str(DATA))
key = L.WORKBOOK.stat().st_mtime_ns key = L.WORKBOOK.stat().st_mtime_ns
if _DATA.get("key") == key: return _DATA["val"] if _DATA.get("key") == key: return _DATA["val"]
wb = load_workbook(L.WORKBOOK, read_only=True) wb = load_workbook(L.WORKBOOK, read_only=True)
@@ -90,7 +94,11 @@ def workbook_data():
def safe_name(name): def safe_name(name):
name = re.sub(r"[^\w.\- ]", "_", Path(name).name) name = re.sub(r"[^\w.\- ]", "_", Path(name).name)
if Path(name).suffix.lower() not in (".pdf", ".csv"): raise ValueError("Only PDF or CSV files can be added.") if name == "Bills_Tracker.xlsx":
if L.WORKBOOK.exists(): raise ValueError("Bills_Tracker.xlsx already exists in the data folder; it won't be overwritten.")
return L.WORKBOOK
if name == "loader_rules.json": return DATA / name
if Path(name).suffix.lower() not in (".pdf", ".csv"): raise ValueError("Only PDF or CSV statements, Bills_Tracker.xlsx (first time only) or loader_rules.json can be added.")
p = DATA / name; i = 1 p = DATA / name; i = 1
while p.exists(): while p.exists():
p = DATA / f"{Path(name).stem}_{i}{Path(name).suffix}"; i += 1 p = DATA / f"{Path(name).stem}_{i}{Path(name).suffix}"; i += 1
@@ -128,7 +136,9 @@ class H(BaseHTTPRequestHandler):
if u.path == "/api/upload": if u.path == "/api/upload":
name = parse_qs(u.query).get("name", [""])[0]; data = self.rfile.read(n) name = parse_qs(u.query).get("name", [""])[0]; data = self.rfile.read(n)
with LOCK: with LOCK:
p = safe_name(name); p.write_bytes(data) p = safe_name(name)
if p.name == "loader_rules.json": json.loads(data) # must be valid JSON
DATA.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
return self._send(200, {"saved": p.name}) return self._send(200, {"saved": p.name})
self.rfile.read(n) self.rfile.read(n)
if u.path == "/api/import": if u.path == "/api/import":
+1
View File
@@ -155,6 +155,7 @@ function renderTx(all){
} }
async function init(){ async function init(){
const j=await (await fetch("/api/data")).json();if(j.error){document.body.innerHTML="<main>"+esc(j.error)+"</main>";return} const j=await (await fetch("/api/data")).json();if(j.error){document.body.innerHTML="<main>"+esc(j.error)+"</main>";return}
if(j.setup){document.body.innerHTML=`<main><h1>Bills Dashboard</h1><p>Bills_Tracker.xlsx isn’t in the data folder (<code>${esc(j.data_dir)}</code>) yet. <a href="/">Upload it on the Statement Loader page.</a></p></main>`;return}
D=j;$("#updated").textContent="Workbook saved "+j.updated; D=j;$("#updated").textContent="Workbook saved "+j.updated;
const yrs=[...new Set(j.tx.map(t=>+t[0].slice(0,4)))].sort().reverse(); const yrs=[...new Set(j.tx.map(t=>+t[0].slice(0,4)))].sort().reverse();
$("#year").innerHTML=yrs.map(y=>`<option>${y}</option>`).join(""); $("#year").innerHTML=yrs.map(y=>`<option>${y}</option>`).join("");
+3 -1
View File
@@ -35,7 +35,7 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
<div class="card"> <div class="card">
<h2>1 · Add statements</h2> <h2>1 · Add statements</h2>
<div id="drop">Drop PDF or CSV statements here, or click to choose files<input id="file" type="file" multiple accept=".pdf,.csv" hidden></div> <div id="drop">Drop PDF or CSV statements here, or click to choose files<input id="file" type="file" multiple accept=".pdf,.csv,.xlsx,.json" hidden></div>
<div class="mute" style="margin-top:8px" id="uploadmsg">Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download. <div class="mute" style="margin-top:8px" id="uploadmsg">Supported: Chase Visa PDF · Ally Bank PDF · CVFCU PDF · Capital One CSV download.
<a href="#" id="helplink">Adding a different account?</a></div> <a href="#" id="helplink">Adding a different account?</a></div>
<div id="help" class="mute">Open <code>statement_loader.py</code>, add one line to <code>ACCOUNTS</code> (a name, a word printed on page 1 of its statement, and the day it closes), then ask Claude to write the parser for that bank’s layout. Any file the app can’t read is listed under “Needs attention”.</div> <div id="help" class="mute">Open <code>statement_loader.py</code>, add one line to <code>ACCOUNTS</code> (a name, a word printed on page 1 of its statement, and the day it closes), then ask Claude to write the parser for that bank’s layout. Any file the app can’t read is listed under “Needs attention”.</div>
@@ -67,6 +67,8 @@ async function refresh(msg){
$("#coverage").innerHTML="Reading statements…"; $("#coverage").innerHTML="Reading statements…";
const r=await fetch("/api/state");const s=await r.json(); const r=await fetch("/api/state");const s=await r.json();
if(s.error){alertBox("bad",esc(s.error));return} if(s.error){alertBox("bad",esc(s.error));return}
if(s.setup){$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true;
$("#summary").innerHTML="";alertBox("warn",`<b>First-time setup:</b> <code>Bills_Tracker.xlsx</code> isn’t in the data folder (<code>${esc(s.data_dir)}</code>). Drop it into the box above (or copy it into that folder), then reload.`+(s.rules?"":" Also add <code>loader_rules.json</code> if you have one (your name, so transfers between your accounts are recognized)."));return}
s.bad.forEach(b=>alertBox("bad",`<b>${esc(b.file)}</b>: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will <b>not</b> be imported until that matches.`)); s.bad.forEach(b=>alertBox("bad",`<b>${esc(b.file)}</b>: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will <b>not</b> be imported until that matches.`));
s.skipped.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b>: ${esc(b.why)}`)); s.skipped.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b>: ${esc(b.why)}`));
s.duplicates.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b> is a duplicate of ${esc(b.same_as)}; counted once.`)); s.duplicates.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b> is a duplicate of ${esc(b.same_as)}; counted once.`));