Back

spending.papp

All 85 lines. No libraries, nothing hidden. Read it before you run it.

spending.papp
<!doctype html>
<meta charset="utf-8">
<meta name="papp-write" content="./rules.json">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 2 64 64'><g fill='%23111114'><rect x='19' y='30' width='11' height='28' rx='2'/><rect x='34' y='22' width='11' height='36' rx='2'/><rect x='49' y='44' width='11' height='14' rx='2'/></g><rect x='4' y='10' width='11' height='48' rx='2' fill='%23ff4f00'/></svg>">
<!--
  Spending: where did the money go?

  Put this page next to your bank's CSV exports: a header row, then one payment per row, with no commas inside a value.

    date,description,amount
    2026-10-03,Corner grocery,-54.20

  Money out is negative; money in is left out. rules.json maps part of a description to a category.
  Click a category under a row it can't place, and it saves that row's description as a rule.
-->
<title>Spending</title>
<style>
  :root { color-scheme: light dark; --paper: light-dark(#fff, #0f0f11); --well: light-dark(#f4f4f5, #1c1c20); --ink: light-dark(#0f0f11, #f2f2f3); --soft: light-dark(#6b6b72, #9b9ba4); --rule: light-dark(#e4e4e7, #2b2b31); --accent: #ff4f00; }
  body { max-width: 760px; margin: 0 auto; padding: 40px 28px; background: var(--paper); color: var(--ink); font: 14px/1.45 system-ui, sans-serif; font-variant-numeric: tabular-nums; }
  header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24px; }
  h1 { margin: 0; font-size: 22px; font-weight: 600; letter-spacing: -0.01em; }
  header p { margin: 0; color: var(--soft); }
  #spent { color: var(--ink); font-size: 22px; }
  .bar { display: grid; grid-template-columns: 7rem 1fr 5.5rem; gap: 14px; align-items: center; padding: 6px 0; }
  .track { height: 10px; border-radius: 99px; background: var(--well); }
  .fill { display: block; height: 100%; border-radius: inherit; background: var(--ink); }
  .bar:first-child .fill { background: var(--accent); }
  .bar span:last-child, .row span:last-child { text-align: right; }
  h2 { margin: 36px 0 12px; color: var(--soft); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
  .card { margin-bottom: 10px; padding: 14px 16px; border: 1px solid var(--rule); border-radius: 12px; }
  .row { display: grid; grid-template-columns: 6rem 1fr 5.5rem; gap: 14px; font-weight: 500; }
  .row span:first-child { color: var(--soft); font-weight: 400; }
  .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
  button { padding: 5px 12px; border: 1px solid var(--rule); border-radius: 99px; background: none; color: var(--ink); font: inherit; font-size: 13px; cursor: pointer; }
  button:hover { border-color: var(--accent); background: var(--accent); color: #0f0f11; }
</style>
<header><h1>Spending</h1><p>Spent <strong id="spent"></strong></p></header>
<div id="bars"></div>
<h2 id="heading"></h2>
<div id="cards"></div>
<script type="module">
  const categories = ['Groceries', 'Rent', 'Bills', 'Transport', 'Eating out', 'Coffee', 'Other'];
  const rules = (await papp.exists('rules.json')) ? JSON.parse(await papp.readFile('rules.json')) : {};
  const payments = [];
  for (const name of await papp.readdir('.')) {
    if (!name.endsWith('.csv')) continue;
    for (const line of (await papp.readFile(name)).trim().split('\n').slice(1)) {
      const [date, description, amount] = line.split(',');
      if (amount < 0) payments.push({ date, description, amount: -amount });
    }
  }
  let unsorted = [];
  draw();

  cards.onclick = async ({ target }) => {
    if (target.tagName !== 'BUTTON') return;
    rules[unsorted[target.dataset.payment].description] = target.textContent;
    await papp.writeFile('rules.json', JSON.stringify(rules, null, 2) + '\n');
    draw();
  };

  function draw() {
    const totals = {};
    unsorted = [];
    for (const payment of payments) {
      const rule = Object.keys(rules).find(part => payment.description.toLowerCase().includes(part.toLowerCase()));
      if (rule) totals[rules[rule]] = (totals[rules[rule]] ?? 0) + payment.amount;
      else unsorted.push(payment);
    }
    const top = Math.max(...Object.values(totals));
    spent.textContent = payments.reduce((sum, payment) => sum + payment.amount, 0).toFixed(2);
    bars.innerHTML = Object.entries(totals)
      .sort((a, b) => b[1] - a[1])
      .map(([category, total]) => `<div class="bar"><span>${category}</span><span class="track"><span class="fill" style="width: ${(total / top) * 100}%"></span></span><span>${total.toFixed(2)}</span></div>`)
      .join('');
    heading.textContent = unsorted.length ? `${unsorted.length} to sort` : 'All sorted';
    cards.innerHTML = unsorted
      .map(({ date, description, amount }, index) => `
        <div class="card">
          <div class="row"><span>${date}</span><span>${description}</span><span>${amount.toFixed(2)}</span></div>
          <div class="chips">${categories.map(category => `<button data-payment="${index}">${category}</button>`).join('')}</div>
        </div>`)
      .join('');
  }
</script>
Download spending.papp