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
-
-
-
-
-
-
-
- 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
-
-
-
- | Time |
- Type |
- Payer/Sender |
- Reason |
- Method |
- Your share % |
- Amount |
- Δ Balance |
- |
-
-
-
- {% for e in entries %}
-
- | {{ 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) }}
- |
-
-
- |
-
- {% endfor %}
-
-
-
-
-
-
-"""
-
-# --------------------------- 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
+
+
+
+
+
+
+
+ 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
+
+
+
+ | Time |
+ Type |
+ Payer/Sender |
+ Reason |
+ Method |
+ Your share % |
+ Amount |
+ Δ Balance |
+ |
+
+
+
+ {% for e in entries %}
+
+ | {{ 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) }}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+
\ No newline at end of file