main
oscar 2 months ago
parent 17bc7d8d17
commit dbd91e4e7f

@ -0,0 +1,301 @@
# SplitBuddy — tiny Flask app to track IOUs between two roommates
# ---------------------------------------------------------------
# Features
# - Add entries with + / - amount, reason, and method (cash/transfer/other)
# - Auto-summary: who owes whom and how much
# - List with delete (with confirm)
# - CSV export
# - Single-file app; uses SQLite (splitbuddy.db)
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, jsonify, send_file
app = Flask(__name__)
# ---------------------------- Config ---------------------------- #
DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db")
CURRENCY = "" # change if you want
PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me")
PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan")
# Convention: stored amount is signed
# > 0 => PERSON_A owes PERSON_B ("I owe Idan")
# < 0 => PERSON_B owes PERSON_A ("Idan owes me")
# ------------------------- DB helpers --------------------------- #
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
g.db.row_factory = sqlite3.Row
return g.db
@app.teardown_appcontext
def close_db(_=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
amount REAL NOT NULL, -- signed
method TEXT NOT NULL, -- cash | transfer | other
note TEXT -- reason / memo
)
"""
)
db.commit()
# --------------------------- Templates -------------------------- #
BASE = r"""
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SplitBuddy</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
<style>
:root{ --bg:#0f1115; --card:#141822; --muted:#8c93a6; --ok:#19c37d; --warn:#ffb224; --bad:#ef4444; --fg:#e6e7eb; --acc:#4ea1ff; }
*{ 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; letter-spacing:.2px }
.pill{ display:inline-flex; align-items:center; gap:8px; padding:8px 12px; border-radius:999px; background:var(--card); border:1px solid #202637; font-size:14px; }
.pill.ok{ border-color:#1f7a58; background:rgba(25,195,125,.12) }
.pill.bad{ border-color:#7a1f1f; background:rgba(239,68,68,.12) }
.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 #202637; border-radius:14px; padding:16px }
h2{ font-size:16px; margin:0 0 10px 0 }
form .row{ display:flex; gap:10px; flex-wrap:wrap }
input[type="number"], input[type="text"], select, input[type="datetime-local"]{
background:#0d1117; border:1px solid #263041; color:var(--fg); border-radius:10px; padding:10px; outline:none; width:100%;
}
input[type="number"]{ max-width:160px }
.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; }
.seg input:checked + label{ background:#182033; 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.amount{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } /* I owe (positive) */
.neg{ color:#9ae6b4 } /* they owe me (negative) */
.row-actions{ display:flex; gap:8px; justify-content:flex-end }
.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="h1">SplitBuddy</div>
<div>
<span class="pill {{ 'bad' if summary.total>0 else ('ok' if summary.total<0 else '') }}">
{% if summary.total > 0 %}
{{ A }} owes {{ B }} <strong>{{ currency }}{{ '%.2f'|format(summary.total) }}</strong>
{% elif summary.total < 0 %}
{{ B }} owes {{ A }} <strong>{{ currency }}{{ '%.2f'|format(-summary.total) }}</strong>
{% else %}
All settled
{% endif %}
</span>
<span class="pill muted">Balance: {{ currency }}{{ '%.2f'|format(summary.total) }}</span>
<a class="pill" href="{{ url_for('export_csv') }}">Export CSV</a>
</div>
</header>
<div class="grid">
<section class="card">
<h2>Add entry</h2>
<form method="post" action="{{ url_for('add') }}">
<div class="row">
<div class="seg">
<input type="radio" id="plus" name="sign" value="+" checked>
<label for="plus">+ ({{ A }} owes {{ B }})</label>
<input type="radio" id="minus" name="sign" value="-">
<label for="minus">- ({{ B }} owes {{ A }})</label>
</div>
<input type="number" name="amount" step="0.01" min="0" placeholder="Amount" required>
<input type="text" name="note" placeholder="Reason (e.g. groceries, rent)" maxlength="200">
</div>
<div class="row" style="margin-top:8px">
<select name="method" required>
<option value="cash">cash</option>
<option value="transfer">transfer</option>
<option value="other">other</option>
</select>
<input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button>
</div>
</form>
</section>
<section class="card">
<h2>Stats</h2>
<div class="muted">Total entries: {{ summary.count }}</div>
<div class="muted">Latest: {{ summary.latest or '' }}</div>
<div style="margin-top:8px">
<span class="tag">Cash: {{ currency }}{{ '%.2f'|format(summary.by_method.cash) }}</span>
<span class="tag">Transfer: {{ currency }}{{ '%.2f'|format(summary.by_method.transfer) }}</span>
<span class="tag">Other: {{ currency }}{{ '%.2f'|format(summary.by_method.other) }}</span>
</div>
</section>
</div>
<section class="card" style="margin-top:16px">
<h2>Ledger</h2>
<table>
<thead>
<tr>
<th style="width:160px">Time</th>
<th>Reason</th>
<th>Method</th>
<th class="amount">Amount</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>{{ e.created_at }}</td>
<td>{{ e.note or '' }}</td>
<td>{{ e.method }}</td>
<td class="amount {{ 'pos' if e.amount>0 else 'neg' if e.amount<0 else '' }}">{{ currency }}{{ '%.2f'|format(e.amount) }}</td>
<td class="row-actions">
<form method="post" action="{{ url_for('delete', entry_id=e.id) }}" onsubmit="return confirm('Delete this entry?');">
<button class="btn danger" type="submit">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</body>
</html>
"""
# --------------------------- Utilities -------------------------- #
def _now_local_iso_min() -> str:
# default to local time (no tz), minute precision for nicer input value
now = dt.datetime.now().replace(second=0, microsecond=0)
return now.isoformat(timespec="minutes")
class ByMethod:
def __init__(self, cash=0.0, transfer=0.0, other=0.0):
self.cash = cash
self.transfer = transfer
self.other = other
class Summary:
def __init__(self, total: float, count: int, latest: Optional[str], by_method: ByMethod):
self.total = total
self.count = count
self.latest = latest
self.by_method = by_method
# ----------------------------- Routes --------------------------- #
@app.before_request
def _ensure_db():
init_db()
@app.get("/")
def index():
db = get_db()
rows = db.execute("SELECT id, created_at, amount, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC").fetchall()
entries = [dict(r) for r in rows]
total = sum(e["amount"] for e in entries) if entries else 0.0
latest = entries[0]["created_at"] if entries else None
bm = ByMethod(
cash=sum(e["amount"] for e in entries if e["method"] == "cash"),
transfer=sum(e["amount"] for e in entries if e["method"] == "transfer"),
other=sum(e["amount"] for e in entries if e["method"] == "other"),
)
summary = Summary(total=total, count=len(entries), latest=latest, by_method=bm)
return render_template_string(
BASE,
entries=entries,
summary=summary,
A=PERSON_A, B=PERSON_B,
currency=CURRENCY,
now_local=_now_local_iso_min(),
)
@app.post("/add")
def add():
amount_raw = request.form.get("amount", type=float)
sign = request.form.get("sign", "+")
method = request.form.get("method", "cash").strip().lower()
note = request.form.get("note", "").strip()
created_at = request.form.get("created_at") or _now_local_iso_min()
if amount_raw is None:
return redirect(url_for("index"))
if sign not in (+1, -1, "+", "-"):
sign = "+"
signed = amount_raw * (1 if sign in (+1, "+") else -1)
db = get_db()
db.execute(
"INSERT INTO entries (created_at, amount, method, note) VALUES (?, ?, ?, ?)",
(created_at, signed, method, note)
)
db.commit()
return redirect(url_for("index"))
@app.post("/delete/<int:entry_id>")
def delete(entry_id: int):
db = get_db()
db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
db.commit()
return redirect(url_for("index"))
@app.get("/export.csv")
def export_csv():
db = get_db()
rows = db.execute("SELECT id, created_at, amount, method, note FROM entries ORDER BY datetime(created_at) DESC, id DESC").fetchall()
buff = io.StringIO()
writer = csv.writer(buff)
writer.writerow(["id", "created_at", "amount", "method", "note"]) # header
for r in rows:
writer.writerow([r["id"], r["created_at"], r["amount"], r["method"], r["note"]])
buff.seek(0)
return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name="splitbuddy_export.csv")
# Compatibility alias for templates
export_csv = 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)
Loading…
Cancel
Save