Gramlot pre-alpha · First usable release planned for late 2026
gramlot.

A SMALL APP TO TAKE WITH YOU

Personal budget.

Make room for what matters. Explore a month of income and expenses.

JavaScript edition
Single-file experiment

Sample figures, ready to edit. Press Enter or leave a field to apply an amount. Changes last until you close or reload this page; export your data to keep them.

Keep the app. Make it yours.

Download this HTML from the online demo, then open it in your browser. Export and import a JSON file to carry your amounts between sessions.

Download HTML
Explore the Gramlot recipe

This class is the page: structure, formulas and styles. The HTML exporter supplies Gramlot and starts it. File import/export belongs to the surrounding shell; exported values are the last committed amounts.

class Page extends GramlotBuilder {
  main(root) {
    // The Page declares its own CSS through Gramlot; the HTML host only starts it.
    root.styleSheet(`
      .budget {display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,160px);
        background:#fff;color:#24334a;color-scheme:light;font-size:14px;
        font-variant-numeric:tabular-nums;border:1px solid #dce3ec}
      .budget-row {display:contents}
      .budget-row > * {min-width:0;padding:8px 12px;border-bottom:1px solid #dce3ec}
      .budget-row > :last-child {text-align:right;border-left:1px solid #dce3ec}
      .budget .heading,.budget .section-title {background:#eef2f7;font-weight:600}
      .budget .section-title {grid-column:1/-1;margin:0;padding:8px 12px;
        font-size:14px;border-bottom:1px solid #dce3ec}
      .budget gnr-numbertextbox {width:100%;color:#24334a;--field-bg:#fff;
        --field-border:transparent;--form-field-radius:0}
      .budget gnr-numbertextbox::part(content) {padding:0;border:0;min-height:28px}
      .budget .total > * {font-weight:600;background:#f4f7fb}
      .budget .balance > * {font-weight:700;background:#edf7f2;color:#176747}
      .budget .over > * {background:#fff0ef;color:#b83232}
    `);
    // Relative data paths below belong to the budget branch.
    const sheet = root.div({datapath:'budget', class:'budget'});
    sheet.data('.income', 2500);
    sheet.data('.home.rent', 800);
    sheet.data('.home.electricity', 65);
    sheet.data('.home.internet', 30);
    sheet.data('.home.insurance', 25);
    sheet.data('.work.transport', 80);
    sheet.data('.work.equipment', 40);
    sheet.data('.work.subscriptions', 25);
    sheet.data('.personal.food', 300);
    sheet.data('.personal.health', 45);
    sheet.data('.personal.leisure', 120);
    sheet.data('.personal.other', 50);

    const heading = sheet.div({class:'budget-row'});
    heading.div('Monthly budget', {class:'heading'});
    heading.div('Amount · EUR', {class:'heading'});
    this.amount(sheet, 'Monthly income (€)', '^.income');

    sheet.h2('Home', {class:'section-title'});
    this.amount(sheet, 'Rent (€)', '^.home.rent');
    this.amount(sheet, 'Electricity (€)', '^.home.electricity');
    this.amount(sheet, 'Internet (€)', '^.home.internet');
    this.amount(sheet, 'Insurance (€)', '^.home.insurance');
    // Bind the whole Bag: sum includes new entries, updates and removals.
    // Keep the result in totals, outside the Bag being summed.
    sheet.dataFormula('.totals.home', "Math.round(home.sum('#v') * 100) / 100", {
      home:'^.home', _on_start:true
    });
    this.total(sheet, 'Home total', '^.totals.home');

    sheet.h2('Work', {class:'section-title'});
    this.amount(sheet, 'Transport (€)', '^.work.transport');
    this.amount(sheet, 'Equipment (€)', '^.work.equipment');
    this.amount(sheet, 'Subscriptions (€)', '^.work.subscriptions');
    sheet.dataFormula('.totals.work', "Math.round(work.sum('#v') * 100) / 100", {
      work:'^.work', _on_start:true
    });
    this.total(sheet, 'Work total', '^.totals.work');

    sheet.h2('Personal', {class:'section-title'});
    this.amount(sheet, 'Food (€)', '^.personal.food');
    this.amount(sheet, 'Health (€)', '^.personal.health');
    this.amount(sheet, 'Leisure (€)', '^.personal.leisure');
    this.amount(sheet, 'Other (€)', '^.personal.other');
    sheet.dataFormula('.totals.personal', "Math.round(personal.sum('#v') * 100) / 100", {
      personal:'^.personal', _on_start:true
    });
    this.total(sheet, 'Personal total', '^.totals.personal');

    // Category totals form another Bag, summed with the same native API.
    // Rounding handles floating-point cents; places:2 only controls display.
    sheet.dataFormula('.expenses', "Math.round(totals.sum('#v') * 100) / 100", {
      totals:'^.totals', _on_start:true
    });
    sheet.dataFormula('.balance', 'Math.round((income - expenses) * 100) / 100', {
      income:'^.income', expenses:'^.expenses', _on_start:true
    });
    this.total(sheet, 'Total expenses', '^.expenses', 'expenses-value');
    const balance = sheet.div({class:'==amount < 0 ? "budget-row balance over" : "budget-row balance"', amount:'^.balance'});
    balance.div('Remaining this month');
    balance.div('^.balance', {id:'balance-value', format:'#,##0.00', mask:'€%s'});
  }

  // Reused layout only: value receives the literal ^ binding unchanged.
  // Gramlot owns formatting, validation and Enter/blur commits.
  amount(pane, label, binding) {
    const row = pane.div({class:'budget-row'});
    row.div(label);
    row.numberTextBox({value:binding, lbl:label, lbl_display:'none',
      min:0, max:100000000, step:0.01, places:2, format:'#,##0.00'});
  }

  total(pane, label, binding, id) {
    const row = pane.div({class:'budget-row total'});
    row.div(label);
    row.div(binding, {id:id, format:'#,##0.00', mask:'€%s'});
  }
}