resworbDownload resworb
Back

spending.lmth

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

spending.lmth
<!doctype html>
<meta charset="utf-8">
<meta name="resworb-write" content="./rules.json">
<!--
  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>
  body { max-width: 760px; margin: 40px auto; padding: 0 20px; color: #111; font: 15px/1.4 'Helvetica Neue', sans-serif; }
  .bar, .row { display: grid; grid-template-columns: 8rem 1fr 6rem; gap: 1rem; padding: 0.4rem 0; }
  .bar { border-bottom: 1px solid #e3e3e8; }
  .bar span:nth-child(2) { background: #002fa7; }
  .bar span:last-child, .row span:last-child { text-align: right; }
  .card { margin-top: 1rem; padding: 0.8rem; border: 1px solid #111; }
  button { margin: 0.6rem 0.4rem 0 0; padding: 0.2rem 0.6rem; border: 1px solid #002fa7; background: none; color: #002fa7; font: inherit; }
</style>
<h1>Spending</h1>
<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 resworb.exists('rules.json')) ? JSON.parse(await resworb.readFile('rules.json')) : {};
  const payments = [];
  for (const name of await resworb.readdir('.')) {
    if (!name.endsWith('.csv')) continue;
    for (const line of (await resworb.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 resworb.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));
    bars.innerHTML = Object.entries(totals)
      .sort((a, b) => b[1] - a[1])
      .map(([category, total]) => `<div class="bar"><span>${category}</span><span style="width: ${(total / top) * 100}%"></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>
          ${categories.map(category => `<button data-payment="${index}">${category}</button>`).join('')}
        </div>`)
      .join('');
  }
</script>
Download spending.lmth