diff --git a/main.py b/main.py index 3b6e2c3..6c43fe5 100644 --- a/main.py +++ b/main.py @@ -1,17 +1,18 @@ -# SplitBuddy — Flask app with bills/transfer + payer toggle from __future__ import annotations import os, sqlite3, csv, io, datetime as dt from typing import Optional -from flask import Flask, g, request, redirect, url_for, render_template_string, send_file +from flask import Flask, g, request, redirect, url_for, render_template, send_file app = Flask(__name__) +# ----- Config ----- DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db") CURRENCY = "₪" PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate +DEFAULT_A_SHARE_PCT = 66.6667 # <-- you pay 2/3 by default -# ------------------------- DB helpers --------------------------- # +# ----- DB helpers ----- def get_db() -> sqlite3.Connection: if "db" not in g: g.db = sqlite3.connect(DB_PATH) @@ -31,197 +32,34 @@ def init_db(): id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'bill', -- 'bill' | 'transfer' - total REAL NOT NULL DEFAULT 0, -- total amount + total REAL NOT NULL DEFAULT 0, -- amount payer TEXT NOT NULL DEFAULT 'A', -- 'A' (you) or 'B' (Idan) - a_share REAL NOT NULL DEFAULT 0.5, -- your share (0..1), only for bills + a_share REAL NOT NULL DEFAULT 0.666667, -- your share (0..1) for bills method TEXT NOT NULL DEFAULT 'cash', note TEXT ) """) - # Migrations for older versions - for col, ddl in [ - ("kind", "ALTER TABLE entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'bill'"), - ("total", "ALTER TABLE entries ADD COLUMN total REAL"), - ("payer", "ALTER TABLE entries ADD COLUMN payer TEXT"), - ("a_share","ALTER TABLE entries ADD COLUMN a_share REAL"), + # Migrations (no-op if columns already exist) + for ddl in [ + "ALTER TABLE entries ADD COLUMN kind TEXT NOT NULL DEFAULT 'bill'", + "ALTER TABLE entries ADD COLUMN total REAL", + "ALTER TABLE entries ADD COLUMN payer TEXT", + "ALTER TABLE entries ADD COLUMN a_share REAL", ]: try: db.execute(ddl) except sqlite3.OperationalError: pass - # Normalize NULLs from legacy rows db.execute(""" UPDATE entries SET kind = COALESCE(kind, 'bill'), total = COALESCE(total, 0), payer = COALESCE(payer, 'A'), - a_share= COALESCE(a_share, 0.5) + a_share= COALESCE(a_share, 0.666667) """) db.commit() -# --------------------------- Template --------------------------- # -BASE = r""" - - - - - SplitBuddy - - - - - - -
-
-
SplitBuddy
-
- - {% if summary.total > 0 %} - {{ A }} owes {{ B }} {{ currency }}{{ '%.2f'|format(summary.total) }} - {% elif summary.total < 0 %} - {{ B }} owes {{ A }} {{ currency }}{{ '%.2f'|format(-summary.total) }} - {% else %}All settled ✨{% endif %} - - Balance: {{ currency }}{{ '%.2f'|format(summary.total) }} - Export CSV -
-
- -
-
-

Add entry

-
-
-
- - -
-
- - -
- - -
-
- - - - -
-
-
- - - -
-
-
-
- -
-

Stats

-
Entries: {{ summary.count }}
-
Latest: {{ summary.latest or '—' }}
-
- Cash Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }} - Transfer Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }} - Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }} -
-
-
- -
-

Ledger

- - - - - - - - - - - - - - - - {% for e in entries %} - - - - - - - - - - - - {% endfor %} - -
TimeTypePayer/SenderReasonMethodYour share %AmountΔ Balance
{{ e.created_at }}{{ e.kind }}{{ A if e.payer=='A' else B }}{{ e.note or '—' }}{{ e.method }}{{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '—' }}{{ currency }}{{ '%.2f'|format(e.total) }} - {{ currency }}{{ '%.2f'|format(e.delta) }} - -
- -
-
-
-
- - -""" - -# --------------------------- Utilities -------------------------- # +# ----- Utility & models ----- def _now_local_iso_min() -> str: return dt.datetime.now().replace(second=0, microsecond=0).isoformat(timespec="minutes") @@ -236,16 +74,15 @@ class Summary: def _delta_for_entry(kind: str, total: float, payer: str, a_share: float) -> float: """ Positive => YOU owe Idan. Negative => Idan owes YOU. - - bill: delta = (your_share * total) - (total if YOU paid else 0) + - bill: delta = (your_share * total) - (total if YOU paid else 0) - transfer: delta = -total if YOU sent; +total if Idan sent """ if kind == "transfer": return -total if payer == "A" else total - # bill paid_by_a = total if payer == "A" else 0.0 return a_share * total - paid_by_a -# ----------------------------- Routes --------------------------- # +# ----- Routes ----- @app.before_request def _ensure_db(): init_db() @@ -258,6 +95,7 @@ def index(): FROM entries ORDER BY datetime(created_at) DESC, id DESC """).fetchall() + entries = [] for r in rows: e = dict(r) @@ -273,13 +111,14 @@ def index(): ) summary = Summary(total=total_balance, count=len(entries), latest=latest, by_method=bm) - return render_template_string( - BASE, + return render_template( + "index.html", entries=entries, summary=summary, A=PERSON_A, B=PERSON_B, currency=CURRENCY, now_local=_now_local_iso_min(), + default_a_share_pct=DEFAULT_A_SHARE_PCT, ) @app.post("/add") @@ -296,10 +135,12 @@ def add(): if payer not in ("A","B"): payer = "A" if kind not in ("bill","transfer"): kind = "bill" - a_share = 0.5 if kind == "bill": - if a_share_pct is None: a_share_pct = 50.0 + if a_share_pct is None: + a_share_pct = DEFAULT_A_SHARE_PCT a_share = max(0.0, min(100.0, a_share_pct)) / 100.0 + else: + a_share = DEFAULT_A_SHARE_PCT / 100.0 # stored but ignored for transfers db = get_db() db.execute( @@ -336,9 +177,8 @@ def export_csv(): return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name="splitbuddy_export.csv") -# --------------------------- Entrypoint -------------------------- # if __name__ == "__main__": os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) with app.app_context(): init_db() - app.run(debug=True) + app.run(debug=True) \ No newline at end of file diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..b67d64b --- /dev/null +++ b/static/app.js @@ -0,0 +1,36 @@ +function setShare(pct) { + const el = document.getElementById('a_share'); + if (el) el.value = Number(pct).toFixed(4); +} + +function onKindChange(kind) { + const shareWrap = document.getElementById('share-wrap'); + const presets = document.getElementById('presets'); + if (!shareWrap || !presets) return; + if (kind === 'transfer') { + shareWrap.style.display = 'none'; + presets.style.display = 'none'; + } else { + shareWrap.style.display = ''; + presets.style.display = ''; + } +} + +document.addEventListener('DOMContentLoaded', () => { + // default your share to 2/3 + setShare(66.6667); + // wire presets + const p50 = document.getElementById('p50'); + const p66 = document.getElementById('p66'); + const p33 = document.getElementById('p33'); + if (p50) p50.addEventListener('click', () => setShare(50)); + if (p66) p66.addEventListener('click', () => setShare(66.6667)); + if (p33) p33.addEventListener('click', () => setShare(33.3333)); + + // wire kind radios + const bill = document.getElementById('k_bill'); + const xfer = document.getElementById('k_xfer'); + if (bill) bill.addEventListener('change', () => onKindChange('bill')); + if (xfer) xfer.addEventListener('change', () => onKindChange('transfer')); + onKindChange((xfer && xfer.checked) ? 'transfer' : 'bill'); +}); \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..aa16e07 --- /dev/null +++ b/static/style.css @@ -0,0 +1,45 @@ +:root{ --bg:#0f1115; --card:#141822; --muted:#8c93a6; --ok:#19c37d; --bad:#ef4444; --fg:#e6e7eb; --edge:#202637;} +*{ box-sizing:border-box } +body{ margin:0; font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial; background:var(--bg); color:var(--fg) } +.wrap{ max-width:980px; margin:24px auto; padding:0 16px } +header{ display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:16px } +.h1{ font-size:24px; font-weight:700 } +.pill{ display:inline-flex; gap:8px; padding:8px 12px; border-radius:999px; background:var(--card); border:1px solid var(--edge); font-size:14px } +.pill.ok{ background:rgba(25,195,125,.12); border-color:#1f7a58 } +.pill.bad{ background:rgba(239,68,68,.12); border-color:#7a1f1f } +.muted{ color:var(--muted) } + +.grid{ display:grid; grid-template-columns:1.2fr .8fr; gap:16px } +@media (max-width:900px){ .grid{ grid-template-columns:1fr } } + +.card{ background:var(--card); border:1px solid var(--edge); border-radius:14px; padding:16px } +h2{ font-size:16px; margin:0 0 10px } + +form .row{ display:flex; gap:10px; flex-wrap:wrap } +input, select{ background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none } +input[type="number"]{ max-width:160px } +input[type="text"]{ flex:1 } + +.seg{ display:inline-flex; border:1px solid #263041; border-radius:10px; overflow:hidden } +.seg input{ display:none } +.seg label{ padding:8px 10px; cursor:pointer; background:#0d1117; user-select:none } +.seg input:checked + label{ background:#182033; color:#b6d1ff } + +.toggle{ display:inline-flex; border:1px solid #263041; border-radius:10px; overflow:hidden } +.toggle input{ display:none } +.toggle label{ padding:8px 12px; cursor:pointer; background:#0d1117; user-select:none; min-width:100px; text-align:center } +.toggle input:checked + label{ background:#183033; color:#b6d1ff } + +button.btn{ background:#1f6fe8; border:none; color:#fff; padding:10px 14px; border-radius:10px; cursor:pointer } +button.btn.secondary{ background:#273244 } +button.btn.danger{ background:#c92a2a } + +table{ width:100%; border-collapse:collapse } +th, td{ border-bottom:1px solid #222b3b; padding:10px; text-align:left; font-size:14px } +th{ color:#aab3c4; font-weight:600 } +td.num{ font-variant-numeric:tabular-nums; text-align:right } + +.pos{ color:#ef9a9a } /* you owe */ +.neg{ color:#9ae6b4 } /* they owe */ + +.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 } \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..14cdc2b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,120 @@ + + + + + SplitBuddy + + + + + + + +
+
+
SplitBuddy
+
+ + {% if summary.total > 0 %} + {{ A }} owes {{ B }} {{ currency }}{{ '%.2f'|format(summary.total) }} + {% elif summary.total < 0 %} + {{ B }} owes {{ A }} {{ currency }}{{ '%.2f'|format(-summary.total) }} + {% else %}All settled ✨{% endif %} + + Balance: {{ currency }}{{ '%.2f'|format(summary.total) }} + Export CSV +
+
+ +
+
+

Add entry

+
+
+
+ + +
+
+ + +
+ + +
+ +
+ + + + +
+ +
+
+ + + +
+
+
+
+ +
+

Stats

+
Entries: {{ summary.count }}
+
Latest: {{ summary.latest or '—' }}
+
+ Cash Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }} + Transfer Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }} + Other Δ: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }} +
+
+
+ +
+

Ledger

+ + + + + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + + + + {% endfor %} + +
TimeTypePayer/SenderReasonMethodYour share %AmountΔ Balance
{{ e.created_at }}{{ e.kind }}{{ A if e.payer=='A' else B }}{{ e.note or '—' }}{{ e.method }}{{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '—' }}{{ currency }}{{ '%.2f'|format(e.total) }} + {{ currency }}{{ '%.2f'|format(e.delta) }} + +
+ +
+
+
+
+ + \ No newline at end of file