<?php
// --- API handler (runs before any HTML output) ---
$db = new SQLite3(__DIR__ . '/kids_money.db');
$db->exec("PRAGMA journal_mode=WAL");
$db->exec("CREATE TABLE IF NOT EXISTS kids (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)");
$db->exec("CREATE TABLE IF NOT EXISTS transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kid_id INTEGER NOT NULL,
amount REAL NOT NULL,
description TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (kid_id) REFERENCES kids(id) ON DELETE CASCADE
)");
header('X-Content-Type-Options: nosniff');
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json');
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
if ($action === 'add_kid') {
$name = trim($input['name'] ?? '');
$color = intval($input['color'] ?? 0);
if ($name === '') { echo json_encode(['error' => 'Name required']); exit; }
$stmt = $db->prepare("INSERT INTO kids (name, color) VALUES (:n, :c)");
$stmt->bindValue(':n', $name);
$stmt->bindValue(':c', $color);
$stmt->execute();
echo json_encode(['id' => $db->lastInsertRowID(), 'name' => $name, 'color' => $color]);
exit;
}
if ($action === 'remove_kid') {
$id = intval($input['id'] ?? 0);
$db->exec("DELETE FROM transactions WHERE kid_id = $id");
$db->exec("DELETE FROM kids WHERE id = $id");
echo json_encode(['ok' => true]);
exit;
}
if ($action === 'add_transaction') {
$kid_id = intval($input['kid_id'] ?? 0);
$amount = floatval($input['amount'] ?? 0);
$desc = trim($input['description'] ?? '');
if ($kid_id === 0 || $amount == 0) { echo json_encode(['error' => 'Invalid']); exit; }
$stmt = $db->prepare("INSERT INTO transactions (kid_id, amount, description) VALUES (:k, :a, :d)");
$stmt->bindValue(':k', $kid_id);
$stmt->bindValue(':a', $amount);
$stmt->bindValue(':d', $desc);
$stmt->execute();
$txid = $db->lastInsertRowID();
// return updated balance
$bal = $db->querySingle("SELECT SUM(amount) FROM transactions WHERE kid_id = $kid_id");
echo json_encode(['id' => $txid, 'balance' => round((float)$bal, 2)]);
exit;
}
if ($action === 'get_data') {
$kids = [];
$res = $db->query("SELECT * FROM kids ORDER BY id ASC");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$kid_id = $row['id'];
$bal = $db->querySingle("SELECT SUM(amount) FROM transactions WHERE kid_id = $kid_id");
$row['balance'] = round((float)$bal, 2);
$txRes = $db->query("SELECT * FROM transactions WHERE kid_id = $kid_id ORDER BY created_at DESC LIMIT 50");
$txns = [];
while ($tx = $txRes->fetchArray(SQLITE3_ASSOC)) { $txns[] = $tx; }
$row['history'] = $txns;
$kids[] = $row;
}
echo json_encode(['kids' => $kids]);
exit;
}
echo json_encode(['error' => 'Unknown action']);
exit;
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Kids Money" />
<title>Kids Money Tracker</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #f5f5f3;
--surface: #ffffff;
--border: rgba(0,0,0,0.12);
--border-md: rgba(0,0,0,0.2);
--text: #1a1a18;
--text-muted: #6b6b66;
--text-hint: #9a9a94;
--green-bg: #eaf3de; --green-text: #27500a; --green-border: rgba(59,109,17,0.3);
--red-bg: #fcebeb; --red-text: #791f1f; --red-border: rgba(163,45,45,0.3);
--radius: 12px; --radius-sm: 8px;
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1c1a; --surface: #2a2a28; --border: rgba(255,255,255,0.1); --border-md: rgba(255,255,255,0.2);
--text: #f0efe8; --text-muted: #a0a09a; --text-hint: #6a6a64;
--green-bg: #173404; --green-text: #c0dd97; --green-border: rgba(96,155,34,0.35);
--red-bg: #501313; --red-text: #f09595; --red-border: rgba(240,149,149,0.3);
}
}
body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100vh; padding-bottom: env(safe-area-inset-bottom, 20px); }
.topbar { background: var(--surface); border-bottom: 0.5px solid var(--border); padding: 16px 20px 14px; position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; }
.topbar h1 { font-size: 20px; font-weight: 600; }
.topbar span { font-size: 12px; color: var(--text-muted); }
.container { max-width: 480px; margin: 0 auto; padding: 16px; }
.add-kid-row { display: flex; gap: 8px; margin-bottom: 16px; }
.add-kid-row input { flex: 1; padding: 10px 12px; font-size: 15px; border: 0.5px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); outline: none; }
.add-kid-row input:focus { border-color: var(--border-md); }
.add-kid-btn { padding: 10px 16px; font-size: 14px; font-weight: 500; border-radius: var(--radius-sm); border: 0.5px solid var(--border-md); background: var(--surface); color: var(--text); cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 6px; }
.add-kid-btn:active { opacity: 0.7; }
.kid-card { background: var(--surface); border: 0.5px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 14px; }
.kid-header { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.avatar { width: 44px; height: 44px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 16px; flex-shrink: 0; }
.kid-meta { flex: 1; }
.kid-name { font-size: 16px; font-weight: 600; }
.kid-txcount { font-size: 12px; color: var(--text-muted); }
.remove-btn { background: none; border: none; cursor: pointer; color: var(--text-hint); font-size: 20px; padding: 4px; line-height: 1; }
.balance-row { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 0.5px solid var(--border); }
.balance-label { font-size: 13px; color: var(--text-muted); }
.balance-val { font-size: 30px; font-weight: 600; transition: color 0.3s; }
.balance-val.negative { color: var(--red-text); }
.section-label { font-size: 11px; font-weight: 600; color: var(--text-hint); letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 8px; }
.quick-btns { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.quick-btn { padding: 10px 0; font-size: 15px; font-weight: 600; border-radius: var(--radius-sm); border: 0.5px solid var(--green-border); background: var(--green-bg); color: var(--green-text); cursor: pointer; }
.quick-btn:active { transform: scale(0.96); }
.note-input { width: 100%; padding: 9px 12px; font-size: 13px; border: 0.5px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); margin-bottom: 10px; outline: none; }
.note-input:focus { border-color: var(--border-md); }
.custom-row { display: flex; gap: 8px; align-items: center; }
.dollar-wrap { position: relative; display: flex; align-items: center; }
.dollar-sign { position: absolute; left: 10px; font-size: 14px; color: var(--text-muted); pointer-events: none; }
.amt-input { width: 88px; padding: 9px 10px 9px 22px; font-size: 14px; border: 0.5px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); outline: none; }
.amt-input:focus { border-color: var(--border-md); }
.action-btn { flex: 1; padding: 9px 0; font-size: 13px; font-weight: 600; border-radius: var(--radius-sm); cursor: pointer; border: 0.5px solid; display: flex; align-items: center; justify-content: center; gap: 4px; }
.action-btn.plus { background: var(--green-bg); color: var(--green-text); border-color: var(--green-border); }
.action-btn.minus { background: var(--red-bg); color: var(--red-text); border-color: var(--red-border); }
.action-btn:active { transform: scale(0.97); }
.hist-toggle { margin-top: 12px; background: none; border: none; cursor: pointer; font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; padding: 0; }
.history { display: none; margin-top: 10px; border-top: 0.5px solid var(--border); padding-top: 10px; max-height: 220px; overflow-y: auto; }
.history.open { display: block; }
.hist-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 7px 0; border-bottom: 0.5px solid var(--border); gap: 10px; }
.hist-item:last-child { border-bottom: none; }
.hist-left { flex: 1; }
.hist-desc { font-size: 13px; color: var(--text); }
.hist-desc.empty { font-style: italic; color: var(--text-hint); }
.hist-date { font-size: 11px; color: var(--text-hint); margin-top: 2px; }
.hist-amt { font-size: 14px; font-weight: 600; }
.hist-amt.pos { color: var(--green-text); }
.hist-amt.neg { color: var(--red-text); }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-muted); font-size: 15px; }
.flash { animation: pop 0.35s ease; }
@keyframes pop { 0%,100%{transform:scale(1)} 50%{transform:scale(1.08)} }
.toast { position: fixed; bottom: calc(20px + env(safe-area-inset-bottom,0px)); left: 50%; transform: translateX(-50%) translateY(60px); background: var(--text); color: var(--bg); font-size: 13px; padding: 8px 18px; border-radius: 20px; opacity: 0; transition: opacity 0.2s, transform 0.2s; pointer-events: none; z-index: 999; white-space: nowrap; }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
#loading { text-align: center; padding: 3rem; color: var(--text-muted); }
</style>
</head>
<body>
<div class="topbar">
<h1>💰 Money I Owe</h1>
<span id="total-label"></span>
</div>
<div class="container">
<div class="add-kid-row">
<input id="newKidName" placeholder="Child's name…" maxlength="24" />
<button class="add-kid-btn" onclick="addKid()">
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="8" y1="2" x2="8" y2="14"/><line x1="2" y1="8" x2="14" y2="8"/></svg>
Add
</button>
</div>
<div id="loading">Loading…</div>
<div id="kids-list"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const COLORS = [
{bg:'#B5D4F4',text:'#0C447C'},{bg:'#9FE1CB',text:'#085041'},
{bg:'#F5C4B3',text:'#712B13'},{bg:'#FAC775',text:'#633806'},
{bg:'#CECBF6',text:'#3C3489'},{bg:'#F4C0D1',text:'#72243E'}
];
let kids = [];
async function api(payload) {
const r = await fetch('', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
return r.json();
}
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2000);
}
async function loadData() {
const data = await api({ action: 'get_data' });
kids = data.kids || [];
document.getElementById('loading').style.display = 'none';
render();
}
async function addKid() {
const inp = document.getElementById('newKidName');
const name = inp.value.trim();
if (!name) { inp.focus(); return; }
const color = kids.length % COLORS.length;
const res = await api({ action: 'add_kid', name, color });
if (res.error) { toast(res.error); return; }
kids.push({ id: res.id, name: res.name, color: res.color, balance: 0, history: [] });
inp.value = '';
render();
toast(`Added ${res.name}`);
}
async function removeKid(id) {
const kid = kids.find(k => k.id == id);
if (!confirm(`Remove ${kid?.name || 'this child'} and all their history?`)) return;
await api({ action: 'remove_kid', id });
kids = kids.filter(k => k.id != id);
render();
toast('Removed');
}
async function applyAmount(kid_id, amount) {
const descEl = document.getElementById('desc_' + kid_id);
const description = descEl ? descEl.value.trim() : '';
if (amount === 0) { toast('Enter an amount'); return; }
const res = await api({ action: 'add_transaction', kid_id, amount, description });
if (res.error) { toast(res.error); return; }
const kid = kids.find(k => k.id == kid_id);
if (kid) {
kid.balance = res.balance;
const now = new Date();
const label = now.toLocaleDateString('en-US', { month:'short', day:'numeric' });
kid.history.unshift({ id: res.id, amount, description, created_at: label });
if (kid.history.length > 50) kid.history = kid.history.slice(0, 50);
}
if (descEl) descEl.value = '';
const customEl = document.getElementById('custom_' + kid_id);
if (customEl) customEl.value = '';
render();
setTimeout(() => {
const el = document.getElementById('bal_' + kid_id);
if (el) { el.classList.add('flash'); setTimeout(() => el.classList.remove('flash'), 400); }
}, 10);
toast(amount > 0 ? `+$${amount.toFixed(2)} added` : `-$${Math.abs(amount).toFixed(2)} subtracted`);
}
function getCustomAmt(kid_id) {
const v = parseFloat((document.getElementById('custom_' + kid_id) || {}).value || 0);
return isNaN(v) ? 0 : Math.abs(Math.round(v * 100) / 100);
}
function toggleHistory(kid_id) {
const el = document.getElementById('hist_' + kid_id);
const btn = document.getElementById('histbtn_' + kid_id);
if (!el) return;
el.classList.toggle('open');
btn.textContent = el.classList.contains('open') ? 'â–² hide history' : 'â–¼ show history';
}
function formatDate(str) {
if (!str) return '';
const d = new Date(str);
if (isNaN(d)) return str;
return d.toLocaleDateString('en-US', { month:'short', day:'numeric', year:'numeric' });
}
function render() {
const list = document.getElementById('kids-list');
const totalLabel = document.getElementById('total-label');
if (!list) return;
const total = kids.reduce((s, k) => s + k.balance, 0);
totalLabel.textContent = kids.length ? `Total: $${total.toFixed(2)}` : '';
if (kids.length === 0) {
list.innerHTML = '<div class="empty-state">Add a child above to start tracking.</div>';
return;
}
list.innerHTML = kids.map(kid => {
const c = COLORS[kid.color % COLORS.length];
const initials = kid.name.split(' ').map(w => w[0]).join('').slice(0,2).toUpperCase();
const histEmpty = kid.history.length === 0;
const histHTML = histEmpty
? '<p style="font-size:13px;color:var(--text-hint);padding:6px 0;">No transactions yet.</p>'
: kid.history.map(h => `
<div class="hist-item">
<div class="hist-left">
<div class="hist-desc ${h.description ? '' : 'empty'}">${h.description || 'no note'}</div>
<div class="hist-date">${formatDate(h.created_at)}</div>
</div>
<span class="hist-amt ${h.amount >= 0 ? 'pos' : 'neg'}">${h.amount >= 0 ? '+' : ''}$${Math.abs(h.amount).toFixed(2)}</span>
</div>`).join('');
return `
<div class="kid-card">
<div class="kid-header">
<div class="avatar" style="background:${c.bg};color:${c.text};">${initials}</div>
<div class="kid-meta">
<div class="kid-name">${kid.name}</div>
<div class="kid-txcount">${kid.history.length} transaction${kid.history.length !== 1 ? 's' : ''}</div>
</div>
<button class="remove-btn" onclick="removeKid(${kid.id})" aria-label="Remove ${kid.name}">×</button>
</div>
<div class="balance-row">
<span class="balance-label">I owe</span>
<span class="balance-val ${kid.balance < 0 ? 'negative' : ''}" id="bal_${kid.id}">$${kid.balance.toFixed(2)}</span>
</div>
<div class="section-label">Quick add</div>
<div class="quick-btns">
<button class="quick-btn" onclick="applyAmount(${kid.id}, 1)">+$1</button>
<button class="quick-btn" onclick="applyAmount(${kid.id}, 2)">+$2</button>
<button class="quick-btn" onclick="applyAmount(${kid.id}, 5)">+$5</button>
</div>
<input class="note-input" id="desc_${kid.id}" placeholder="Note (optional — e.g. cleaned room)…" />
<div class="custom-row">
<div class="dollar-wrap">
<span class="dollar-sign">$</span>
<input class="amt-input" id="custom_${kid.id}" type="number" min="0" step="0.25" placeholder="0.00" />
</div>
<button class="action-btn plus" onclick="applyAmount(${kid.id}, getCustomAmt(${kid.id}))">+ Add</button>
<button class="action-btn minus" onclick="applyAmount(${kid.id}, -getCustomAmt(${kid.id}))">− Subtract</button>
</div>
<button class="hist-toggle" id="histbtn_${kid.id}" onclick="toggleHistory(${kid.id})">â–¼ show history</button>
<div class="history" id="hist_${kid.id}">${histHTML}</div>
</div>`;
}).join('');
}
document.getElementById('newKidName').addEventListener('keydown', e => { if (e.key === 'Enter') addKid(); });
loadData();
</script>
</body>
</html>