Compare commits

...

10 Commits

Author SHA1 Message Date
oscar 14f79cf4ff Initial commit 1 week ago
oscar 8f4bc8f11c editable date and amount 1 month ago
oscar 235870676f fixed human readable date 1 month ago
oscar 90f4eb7796 small config clean up 2 months ago
oscar 6e5321593c updated packages 2 months ago
oscar 6e9aa44dad audit UI update 2 months ago
oscar 903341f901 audiot update 2 months ago
oscar 6392139b0c step fix 2 months ago
oscar 8b09250f6d ignore update 2 months ago
oscar d4686ddfae organized structure 2 months ago

1
.gitignore vendored

@ -180,3 +180,4 @@ cython_debug/
.cursorignore
.cursorindexingignore
*.db
.DS_Store

@ -1,17 +1,41 @@
# 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
import os, sqlite3, csv, io, json, datetime as dt
from typing import Optional, Dict, Any
from flask import Flask, g, request, redirect, url_for, render_template, send_file, jsonify
app = Flask(__name__)
# ----- Config -----
DB_PATH = os.environ.get("SPLITBUDDY_DB", "splitbuddy.db")
CURRENCY = ""
CURRENCY = os.environ.get("SPLITBUDDY_CURRENCY", "")
PERSON_A = os.environ.get("SPLITBUDDY_ME", "Me") # you
PERSON_B = os.environ.get("SPLITBUDDY_ROOMIE", "Idan") # roommate
DEFAULT_ACTOR = os.environ.get("SPLITBUDDY_ACTOR", "anon") # who is using this device (optional)
DEFAULT_A_SHARE_PCT = os.environ.get("SPLITBUDDY_DEFAULT_A_SHARE_PCT", 66.6667) # <-- you pay 2/3 by default
WEBAPP_PORT = os.environ.get("SPLITBUDDY_WEBAPP_PORT", 42069)
# ------------------------- DB helpers --------------------------- #
# ----- Template filters -----
@app.template_filter('human_time')
def human_time(value: Any) -> str:
"""Format ISO-ish timestamps into a concise human-readable string.
Accepts values like '2025-09-14T18:37' or '2025-09-14T18:37:00Z'.
"""
if not value:
return ""
try:
if isinstance(value, dt.datetime):
d = value
else:
s = str(value).strip()
# fromisoformat doesn't accept trailing 'Z' (UTC); strip it if present
if s.endswith('Z'):
s = s[:-1]
d = dt.datetime.fromisoformat(s)
return d.strftime("%b %d, %Y %H:%M")
except Exception:
return str(value)
# ----- DB helpers -----
def get_db() -> sqlite3.Connection:
if "db" not in g:
g.db = sqlite3.connect(DB_PATH)
@ -26,205 +50,79 @@ def close_db(_=None):
def init_db():
db = get_db()
# entries
db.execute("""
CREATE TABLE IF NOT EXISTS entries (
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
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
total REAL NOT NULL DEFAULT 0,
payer TEXT NOT NULL DEFAULT 'A', -- 'A' or 'B'
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"),
# audits
db.execute("""
CREATE TABLE IF NOT EXISTS audits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- UTC ISO timestamp
action TEXT NOT NULL, -- 'add' | 'edit' | 'delete'
entry_id INTEGER, -- affected entry id (may be null for failures)
actor TEXT, -- who (env/config or future auth)
ip TEXT,
user_agent TEXT,
device TEXT, -- parsed (macOS, Windows, iPhone, Android, etc.)
old_row TEXT, -- JSON of previous row (if any)
new_row TEXT -- JSON of new row (if any)
)
""")
# (best-effort) migrations, ignore if exists
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"""
<!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; --bad:#ef4444; --fg:#e6e7eb; --edge:#202637;}
*{ box-sizing:border-box } body{ margin:0; font-family:Inter,system-ui; 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 } td.num{ font-variant-numeric:tabular-nums; text-align:right }
.pos{ color:#ef9a9a } .neg{ color:#9ae6b4 }
.tag{ font-size:12px; padding:2px 8px; border-radius:999px; background:#1a2332; border:1px solid #243046 }
</style>
<script>
function setShare(pct) {
const el = document.getElementById('a_share');
el.value = pct.toFixed(2);
}
function onKindChange(kind) {
const shareWrap = document.getElementById('share-wrap');
const presets = document.getElementById('presets');
if (kind === 'transfer') {
shareWrap.style.display = 'none';
presets.style.display = 'none';
} else {
shareWrap.style.display = '';
presets.style.display = '';
}
}
document.addEventListener('DOMContentLoaded', () => {
const kindRadios = document.querySelectorAll('input[name="kind"]');
kindRadios.forEach(r => r.addEventListener('change', () => onKindChange(r.value)));
onKindChange(document.querySelector('input[name="kind"]:checked').value);
});
</script>
</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" title="Entry type">
<input type="radio" id="k_bill" name="kind" value="bill" checked><label for="k_bill">Bill</label>
<input type="radio" id="k_xfer" name="kind" value="transfer"><label for="k_xfer">Transfer</label>
</div>
<div class="toggle" title="Who paid / who sent">
<input type="radio" id="payer_a" name="payer" value="A" checked><label for="payer_a">{{ A }}</label>
<input type="radio" id="payer_b" name="payer" value="B"><label for="payer_b">{{ B }}</label>
</div>
<input type="number" step="0.01" min="0" name="total" placeholder="Amount" required>
<select name="method">
<option>cash</option><option>transfer</option><option>other</option>
</select>
</div>
<div class="row" id="share-wrap" style="margin-top:8px">
<input id="a_share" type="number" step="0.01" min="0" max="100" name="a_share_pct" placeholder="{{ A }} share %" value="50.00" title="{{ A }}'s share (%)">
<input type="text" name="note" placeholder="Reason (e.g. rent, groceries, washer)" style="flex:1">
<input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button>
</div>
<div class="row" id="presets" style="margin-top:8px">
<div class="seg" title="Quick presets">
<input type="radio" id="p50" name="preset" onclick="setShare(50)" checked><label for="p50">50/50</label>
<input type="radio" id="p66" name="preset" onclick="setShare(66.6667)"><label for="p66">{{ A }} 2/3</label>
<input type="radio" id="p33" name="preset" onclick="setShare(33.3333)"><label for="p33">{{ A }} 1/3</label>
</div>
</div>
</form>
</section>
<section class="card">
<h2>Stats</h2>
<div class="muted">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>Type</th>
<th>Payer/Sender</th>
<th>Reason</th>
<th>Method</th>
<th class="num">Your share %</th>
<th class="num">Amount</th>
<th class="num">Δ Balance</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr>
<td>{{ e.created_at }}</td>
<td>{{ e.kind }}</td>
<td>{{ A if e.payer=='A' else B }}</td>
<td>{{ e.note or '' }}</td>
<td>{{ e.method }}</td>
<td class="num">{{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '' }}</td>
<td class="num">{{ currency }}{{ '%.2f'|format(e.total) }}</td>
<td class="num {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}">
{{ currency }}{{ '%.2f'|format(e.delta) }}
</td>
<td>
<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 -------------------------- #
# ----- Utility & models -----
def _now_local_iso_min() -> str:
return dt.datetime.now().replace(second=0, microsecond=0).isoformat(timespec="minutes")
def _now_utc_iso() -> str:
return dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def _client_ip() -> str:
# works behind simple proxy too
xff = request.headers.get("X-Forwarded-For")
return xff.split(",")[0].strip() if xff else (request.remote_addr or "")
def _guess_device(ua: str) -> str:
u = (ua or "").lower()
if "iphone" in u: return "iPhone"
if "ipad" in u: return "iPad"
if "android" in u: return "Android"
if "mac os x" in u or "macintosh" in u: return "macOS"
if "windows" in u: return "Windows"
if "linux" in u and "android" not in u: return "Linux"
return "Unknown"
class ByMethod:
def __init__(self, cash=0.0, transfer=0.0, other=0.0):
self.cash = cash; self.transfer = transfer; self.other = other
@ -241,11 +139,33 @@ def _delta_for_entry(kind: str, total: float, payer: str, a_share: float) -> flo
"""
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 --------------------------- #
def _row_to_dict(row: sqlite3.Row | Dict[str, Any] | None) -> Dict[str, Any] | None:
if row is None: return None
return dict(row)
def _audit(action: str, entry_id: Optional[int], old_row: Optional[dict], new_row: Optional[dict]):
db = get_db()
ua = request.headers.get("User-Agent", "")
db.execute("""
INSERT INTO audits (ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
_now_utc_iso(),
action,
entry_id,
DEFAULT_ACTOR,
_client_ip(),
ua,
_guess_device(ua),
json.dumps(old_row, ensure_ascii=False) if old_row is not None else None,
json.dumps(new_row, ensure_ascii=False) if new_row is not None else None,
))
db.commit()
# ----- Routes -----
@app.before_request
def _ensure_db():
init_db()
@ -258,6 +178,7 @@ def index():
FROM entries
ORDER BY datetime(created_at) DESC, id DESC
""").fetchall()
entries = []
for r in rows:
e = dict(r)
@ -273,13 +194,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")
@ -292,28 +214,79 @@ def add():
note = (request.form.get("note") or "").strip()
created_at = request.form.get("created_at") or _now_local_iso_min()
if total is None or total < 0: return redirect(url_for("index"))
if total is None or total < 0:
return redirect(url_for("index"))
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(
cur = db.execute(
"INSERT INTO entries (created_at, kind, total, payer, a_share, method, note) VALUES (?, ?, ?, ?, ?, ?, ?)",
(created_at, kind, total, payer, a_share, method, note),
)
db.commit()
new_id = cur.lastrowid
new_row = _row_to_dict(db.execute("SELECT * FROM entries WHERE id = ?", (new_id,)).fetchone())
_audit("add", new_id, old_row=None, new_row=new_row)
return redirect(url_for("index"))
@app.post("/delete/<int:entry_id>")
def delete(entry_id: int):
db = get_db()
old = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
db.execute("DELETE FROM entries WHERE id = ?", (entry_id,))
db.commit()
_audit("delete", entry_id, old_row=_row_to_dict(old), new_row=None)
return redirect(url_for("index"))
# Minimal edit endpoint (POST-only). Accepts same fields as /add.
@app.post("/edit/<int:entry_id>")
def edit(entry_id: int):
db = get_db()
old = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
if not old:
_audit("edit", entry_id, old_row=None, new_row=None)
return redirect(url_for("index"))
kind = (request.form.get("kind") or old["kind"]).strip().lower()
payer = (request.form.get("payer") or old["payer"]).strip().upper()
total = request.form.get("total", type=float)
a_share_pct = request.form.get("a_share_pct", type=float)
method = (request.form.get("method") or old["method"]).strip().lower()
note = (request.form.get("note") or old["note"] or "").strip()
created_at = request.form.get("created_at") or old["created_at"]
if total is None:
total = float(old["total"])
if payer not in ("A","B"):
payer = old["payer"]
if kind not in ("bill","transfer"):
kind = old["kind"]
if kind == "bill":
if a_share_pct is None:
a_share = float(old["a_share"])
else:
a_share = max(0.0, min(100.0, a_share_pct)) / 100.0
else:
a_share = float(old["a_share"]) # ignored but stored
db.execute("""
UPDATE entries
SET created_at = ?, kind = ?, total = ?, payer = ?, a_share = ?, method = ?, note = ?
WHERE id = ?
""", (created_at, kind, total, payer, a_share, method, note, entry_id))
db.commit()
new = db.execute("SELECT * FROM entries WHERE id = ?", (entry_id,)).fetchone()
_audit("edit", entry_id, old_row=_row_to_dict(old), new_row=_row_to_dict(new))
return redirect(url_for("index"))
@app.get("/export.csv")
@ -336,9 +309,113 @@ 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 -------------------------- #
# ----- Audit viewers -----
@app.get("/audit")
def audit_html():
db = get_db()
# --- filters from query params ---
entry_id = request.args.get("entry_id", type=int)
action = request.args.get("action", type=str) # add|edit|delete
actor = request.args.get("actor", type=str)
device = request.args.get("device", type=str)
q = request.args.get("q", type=str) # substring search in old/new JSON
page = max(1, request.args.get("page", default=1, type=int))
per_page = min(200, request.args.get("per_page", default=50, type=int))
offset = (page - 1) * per_page
# --- build WHERE clause dynamically ---
wh, params = [], []
if entry_id is not None:
wh.append("entry_id = ?"); params.append(entry_id)
if action:
wh.append("action = ?"); params.append(action)
if actor:
wh.append("actor LIKE ?"); params.append(f"%{actor}%")
if device:
wh.append("device = ?"); params.append(device)
if q:
wh.append("(COALESCE(old_row,'') LIKE ? OR COALESCE(new_row,'') LIKE ? OR COALESCE(user_agent,'') LIKE ?)")
params.extend([f"%{q}%", f"%{q}%", f"%{q}%"])
where_sql = ("WHERE " + " AND ".join(wh)) if wh else ""
# total count for pagination
total = db.execute(f"SELECT COUNT(*) FROM audits {where_sql}", params).fetchone()[0]
rows = db.execute(f"""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
{where_sql}
ORDER BY datetime(ts) DESC, id DESC
LIMIT ? OFFSET ?
""", (*params, per_page, offset)).fetchall()
# pretty JSON (server-side) to avoid giant lines
def pretty(s):
if not s: return ""
try:
obj = json.loads(s)
return json.dumps(obj, ensure_ascii=False, indent=2)
except Exception:
return s
# enrich rows for template
data = []
for r in rows:
d = dict(r)
d["old_pretty"] = pretty(d.get("old_row"))
d["new_pretty"] = pretty(d.get("new_row"))
data.append(d)
# distinct devices/actions for filter dropdowns
devices = [r[0] for r in db.execute("SELECT DISTINCT device FROM audits WHERE device IS NOT NULL ORDER BY device").fetchall()]
actions = ["add", "edit", "delete"]
return render_template(
"audit.html",
rows=data,
total=total,
page=page,
per_page=per_page,
pages=max(1, (total + per_page - 1) // per_page),
# current filters
f_entry_id=entry_id, f_action=action, f_actor=actor, f_device=device, f_q=q,
devices=devices, actions=actions
)
@app.get("/audit.csv")
def audit_csv():
db = get_db()
rows = db.execute("""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
ORDER BY datetime(ts) DESC, id DESC
""").fetchall()
buff = io.StringIO()
w = csv.writer(buff)
w.writerow(["id","ts","action","entry_id","actor","ip","device","user_agent","old_row","new_row"])
for r in rows:
w.writerow([r["id"], r["ts"], r["action"], r["entry_id"], r["actor"], r["ip"], r["device"], r["user_agent"], r["old_row"] or "", r["new_row"] or ""])
buff.seek(0)
return send_file(io.BytesIO(buff.read().encode("utf-8")), mimetype="text/csv",
as_attachment=True, download_name="splitbuddy_audit.csv")
@app.get("/api/audit")
def audit_api():
db = get_db()
rows = db.execute("""
SELECT id, ts, action, entry_id, actor, ip, user_agent, device, old_row, new_row
FROM audits
ORDER BY datetime(ts) DESC, id DESC
LIMIT 200
""").fetchall()
data = [dict(r) for r in rows]
return jsonify(data)
# ----- Main -----
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, port=WEBAPP_PORT)

@ -0,0 +1,53 @@
function setShare(pct) {
const el = document.getElementById('a_share');
if (el) el.value = Number(pct).toFixed(4);
}
function onKindChange(kind) {
const a_share = document.getElementById('a_share');
const presets = document.getElementById('presets');
if (!a_share || !presets) return;
if (kind === 'transfer') {
a_share.style.display = 'none';
presets.style.display = 'none';
} else {
a_share.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');
// inline edit toggles
document.querySelectorAll('[data-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-edit');
const row = document.getElementById(`edit-row-${id}`);
if (row) row.style.display = row.style.display === 'none' ? '' : 'none';
});
});
document.querySelectorAll('[data-cancel-edit]').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.getAttribute('data-cancel-edit');
const row = document.getElementById(`edit-row-${id}`);
if (row) row.style.display = 'none';
});
});
});

@ -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 }

@ -0,0 +1,142 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Audit Log</title>
<style>
:root { color-scheme: dark; }
body { margin:0; font-family:system-ui, Arial; background:#0e0f12; color:#e8e8ea; }
header { padding:16px 20px; border-bottom:1px solid #23242a; display:flex; align-items:center; gap:12px; }
h1 { margin:0; font-size:20px; }
.filters { display:flex; gap:10px; flex-wrap:wrap; margin-left:auto; }
.filters input, .filters select {
background:#14151a; color:#e8e8ea; border:1px solid #2a2c33; border-radius:8px; padding:8px 10px; font-size:14px;
}
.filters button {
background:#2b6efe; color:white; border:none; border-radius:8px; padding:8px 12px; cursor:pointer;
}
.filters a { color:#9aa0a6; text-decoration:none; padding-left:6px; }
.wrap { padding:16px 20px; }
table { width:100%; border-collapse:separate; border-spacing:0 10px; }
thead th { font-weight:600; font-size:12px; color:#a9acb3; text-align:left; padding:0 12px 6px; }
tbody tr { background:#14151a; border:1px solid #23242a; }
tbody td { padding:12px; vertical-align:top; }
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; font-weight:600; }
.ad { background:#153e25; color:#67d48a; border:1px solid #1f6b3b; }
.ed { background:#332a16; color:#f0c56a; border:1px solid #6d5a24; }
.del{ background:#3a1e22; color:#f08a94; border:1px solid #6d2b33; }
.meta { color:#a9acb3; font-size:12px; }
.json { background:#0c0d10; border:1px solid #23242a; border-radius:8px; padding:10px; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; white-space:pre-wrap; max-height:260px; overflow:auto; }
details { margin-top:8px; }
details > summary { cursor:pointer; color:#bfc3cb; }
.pill { display:inline-block; background:#1b1d23; border:1px solid #2a2c33; border-radius:999px; padding:2px 8px; font-size:12px; margin-right:6px; color:#cfd2d8; }
.rowhead { display:flex; align-items:center; gap:10px; }
.pagination { display:flex; gap:8px; align-items:center; margin-top:10px; }
.pagination a, .pagination span {
background:#14151a; border:1px solid #23242a; color:#e8e8ea; padding:6px 10px; border-radius:8px; text-decoration:none; font-size:14px;
}
.pagination .current { background:#1b1d23; }
</style>
</head>
<body>
<header>
<h1>Audit Log</h1>
<form class="filters" method="get">
<input type="number" name="entry_id" placeholder="Entry ID" value="{{ f_entry_id or '' }}">
<select name="action">
<option value="">Any action</option>
{% for a in actions %}
<option value="{{a}}" {% if f_action==a %}selected{% endif %}>{{a}}</option>
{% endfor %}
</select>
<input type="text" name="actor" placeholder="Actor" value="{{ f_actor or '' }}">
<select name="device">
<option value="">Any device</option>
{% for d in devices %}
<option value="{{d}}" {% if f_device==d %}selected{% endif %}>{{d}}</option>
{% endfor %}
</select>
<input type="text" name="q" placeholder="Search old/new/UA" value="{{ f_q or '' }}">
<input type="number" name="per_page" min="10" max="200" value="{{ per_page }}">
<button type="submit">Filter</button>
<a href="{{ url_for('audit_html') }}">Clear</a>
</form>
</header>
<div class="wrap">
<div class="meta">{{ total }} events • page {{ page }} / {{ pages }}</div>
<table>
<thead>
<tr>
<th style="width:180px">Time (UTC)</th>
<th style="width:100px">Action</th>
<th style="width:90px">Entry</th>
<th>Who / Where</th>
<th>Old → New</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td>{{ r.ts }}</td>
<td>
{% if r.action=='add' %}
<span class="badge ad">add</span>
{% elif r.action=='edit' %}
<span class="badge ed">edit</span>
{% else %}
<span class="badge del">delete</span>
{% endif %}
</td>
<td>
{% if r.entry_id %}
<span class="pill">#{{ r.entry_id }}</span>
<a class="pill" href="{{ url_for('audit_html', entry_id=r.entry_id) }}">history</a>
{% else %}
<span class="pill"></span>
{% endif %}
</td>
<td>
<div class="rowhead">
<span class="pill">{{ r.actor or 'anon' }}</span>
<span class="pill">{{ r.device or 'Unknown' }}</span>
<span class="pill">{{ r.ip or '' }}</span>
</div>
<div class="meta" title="{{ r.user_agent }}">{{ r.user_agent[:80] }}{% if r.user_agent and r.user_agent|length>80 %}…{% endif %}</div>
</td>
<td>
<details>
<summary>Old</summary>
<pre class="json">{{ r.old_pretty }}</pre>
</details>
<details>
<summary>New</summary>
<pre class="json">{{ r.new_pretty }}</pre>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
{% if page > 1 %}
<a href="{{ url_for('audit_html', entry_id=f_entry_id, action=f_action, actor=f_actor, device=f_device, q=f_q, per_page=per_page, page=page-1) }}">← Prev</a>
{% else %}
<span>← Prev</span>
{% endif %}
<span class="current">Page {{ page }}</span>
{% if page < pages %}
<a href="{{ url_for('audit_html', entry_id=f_entry_id, action=f_action, actor=f_actor, device=f_device, q=f_q, per_page=per_page, page=page+1) }}">Next →</a>
{% else %}
<span>Next →</span>
{% endif %}
</div>
</div>
</body>
</html>

@ -0,0 +1,144 @@
<!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">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<script defer src="{{ url_for('static', filename='app.js') }}"></script>
</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" title="Entry type">
<input type="radio" id="k_bill" name="kind" value="bill" checked><label for="k_bill">Bill</label>
<input type="radio" id="k_xfer" name="kind" value="transfer"><label for="k_xfer">Transfer</label>
</div>
<div class="toggle" title="Who paid / who sent">
<input type="radio" id="payer_a" name="payer" value="A" checked><label for="payer_a">{{ A }}</label>
<input type="radio" id="payer_b" name="payer" value="B"><label for="payer_b">{{ B }}</label>
</div>
<input type="number" step="0.0001" min="0" name="total" placeholder="Amount" required>
</div>
<div class="row" id="share-wrap" style="margin-top:8px">
<input id="a_share" type="number" step="0.0001" min="0" max="100" name="a_share_pct"
placeholder="{{ A }} share %" value="{{ '%.4f'|format(default_a_share_pct) }}"
title="{{ A }}'s share (%)">
<input type="text" name="note" placeholder="Reason (e.g. rent, groceries, washer)" style="flex:1">
<input type="datetime-local" name="created_at" value="{{ now_local }}">
<button class="btn" type="submit">Add</button>
</div>
<div class="row" id="presets" style="margin-top:8px">
<div class="seg" title="Quick presets">
<input type="radio" id="p50" name="preset"><label for="p50">50/50</label>
<input type="radio" id="p66" name="preset" checked><label for="p66">{{ A }} 2/3</label>
<input type="radio" id="p33" name="preset"><label for="p33">{{ A }} 1/3</label>
</div>
</div>
</form>
</section>
<section class="card">
<h2>Stats</h2>
<div class="muted">Entries: {{ summary.count }}</div>
<div class="muted">Latest: {{ summary.latest|human_time if summary.latest else '—' }}</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>Type</th>
<th>Payer/Sender</th>
<th>Reason</th>
<th>Method</th>
<th class="num">Your share %</th>
<th class="num">Amount</th>
<th class="num">Δ Balance</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for e in entries %}
<tr data-entry-row="{{ e.id }}">
<td>{{ e.created_at|human_time }}</td>
<td>{{ e.kind }}</td>
<td>{{ A if e.payer=='A' else B }}</td>
<td>{{ e.note or '—' }}</td>
<td>{{ e.method }}</td>
<td class="num">{{ e.kind == 'bill' and ('%.2f'|format(e.a_share*100) ~ '%') or '—' }}</td>
<td class="num">{{ currency }}{{ '%.2f'|format(e.total) }}</td>
<td class="num {{ 'pos' if e.delta>0 else 'neg' if e.delta<0 else '' }}">
{{ currency }}{{ '%.2f'|format(e.delta) }}
</td>
<td>
<div style="display:flex; gap:6px; justify-content:flex-end">
<button class="btn" type="button" data-edit="{{ e.id }}">Edit</button>
<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>
</div>
</td>
</tr>
<tr id="edit-row-{{ e.id }}" class="edit-row" style="display:none">
<td colspan="9">
<form class="edit-form" method="post" action="{{ url_for('edit', entry_id=e.id) }}" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap">
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Date</label>
<input type="datetime-local" name="created_at" value="{{ e.created_at }}">
</div>
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Amount</label>
<input type="number" step="0.0001" min="0" name="total" value="{{ '%.4f'|format(e.total) }}">
</div>
{% if e.kind == 'bill' %}
<div>
<label style="display:block; font-size:12px; color:#9aa0a6">Your share %</label>
<input type="number" step="0.0001" min="0" max="100" name="a_share_pct" value="{{ '%.4f'|format(e.a_share * 100) }}">
</div>
{% endif %}
<div style="margin-left:auto; display:flex; gap:8px">
<button class="btn" type="submit">Save</button>
<button class="btn" type="button" data-cancel-edit="{{ e.id }}">Cancel</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
</div>
</body>
</html>
Loading…
Cancel
Save