Build a Local AI Spending Coach

Build a local dashboard that classifies transactions and explains spending.

Introduction

30 Second Summary

A month of transactions can feel like a wall of unfamiliar charges. Useful patterns stay hidden until someone sorts them.

In this project, you will build a local AI dashboard that categorizes synthetic US transactions inside your browser. You will compare its keyword baseline with a local multinomial naive Bayes classifier that retrains from your corrections.

What You'll Build

Picture refreshing one local page to see an ambiguous merchant correctly classified beside a four-week plan that ends below zero.

By the end of this project, you'll have:

  • A local classifier comparison where you can switch from keyword rules to a learned model. Each prediction carries a confidence estimate.
  • A human-in-the-loop correction flow that lets you relabel an ambiguous merchant. The model retrains immediately from your choice.
  • An explainable spending view that surfaces exact recurring charges plus a transparent amount anomaly. Its weekly cash flow reveals a visible Week 4 shortfall while every calculation stays in your browser.
  • Secret Mission: Stress-test ambiguous merchants. Record two before-and-after corrections. Define when the model must defer to a human reviewer.

Are there any prerequisites?

You should be comfortable reading and editing HTML, CSS, and JavaScript. With Visual Studio Code plus Safari already installed, you need no package manager, cloud account, API key, or financial account connection.

Before We Start

Before the hands-on work begins, this is your moment to define the local Spending Coach you are building. You will connect its educational purpose to synthetic transactions, browser privacy, non-advisory results, and human review.

Set Up the Local App

A trustworthy AI feature needs a transparent shell that shows where its data lives. This local dashboard gives every later classification a visible place to appear.

You will create the project in Visual Studio Code. You will open the finished shell directly in Safari without installing a runtime or connecting a financial account.

In this step, get ready to:
  • Create the ai-spending-coach folder in Visual Studio Code.
  • Build the semantic dashboard shell in index.html.
  • Open the local dashboard in Safari.
Create the project workspace

A Visual Studio Code workspace keeps the project file inside one named folder. The Explorer view makes that file easy to find throughout the project.

  • Press Command-Space bar to open Spotlight.
  • Enter Visual Studio Code in Spotlight.
  • Open the Visual Studio Code result.
  • Select File from the menu bar.
  • Select Open Folder....
  • Select your Desktop in the folder dialog.
  • Select New Folder.
  • Name the folder ai-spending-coach.

Why Use a Workspace Folder?

Opening ai-spending-coach as a workspace places its files in the Explorer view. Visual Studio Code also restores the workspace layout when you return later.

  • Select Open to load the folder in Visual Studio Code.
  • Select Yes, I trust the authors if the Workspace Trust dialog appears.
  • Select the Explorer view in the left Activity Bar.
  • Select New File... at the top of the Explorer view.
  • Enter index.html as the file name.
  • Press Return to create the file.

You should see ai-spending-coach at the top of the Explorer view. The new index.html file should appear directly beneath it.

Build the semantic dashboard shell

Semantic HTML gives each dashboard area a clear purpose. Embedded CSS turns those areas into a readable interface while keeping the entire project in one file.

Why Keep Everything Local?

This shell loads from a file on your Mac. It needs no cloud account or financial account connection.

The finished app uses synthetic transactions inside the same browser session. That boundary keeps the classification lesson inspectable.

  • Replace the empty contents of index.html with the complete dashboard shell in the full-code tab below:

✔️ Awesome, I've got everything!

Your index.html file already contains the complete dashboard shell.

  • Save index.html.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Local AI Spending Coach</title>
  <style>
    :root {
      color-scheme: light;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f4f7fb;
      color: #172033;
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f4f7fb;
    }

    header {
      padding: 42px 24px 28px;
      color: white;
      background: linear-gradient(135deg, #173b68, #176b87);
    }

    header div,
    main,
    footer {
      width: min(1180px, calc(100% - 32px));
      margin: 0 auto;
    }

    h1,
    h2,
    p {
      margin-top: 0;
    }

    h1 {
      margin-bottom: 10px;
      font-size: clamp(2rem, 5vw, 3.2rem);
    }

    header p {
      max-width: 760px;
      margin-bottom: 14px;
      color: #d8eff7;
    }

    .badge {
      display: inline-block;
      padding: 7px 11px;
      border: 1px solid #7dd3fc;
      border-radius: 999px;
      color: #e0f7ff;
      font-size: 0.88rem;
    }

    main {
      display: grid;
      gap: 18px;
      padding: 24px 0;
    }

    .panel {
      padding: 20px;
      border: 1px solid #dfe7f1;
      border-radius: 16px;
      background: white;
      box-shadow: 0 8px 24px rgba(30, 64, 100, 0.06);
    }

    .controls {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: center;
    }

    button,
    select {
      border: 1px solid #b9c7d8;
      border-radius: 9px;
      background: white;
      color: #172033;
      font: inherit;
    }

    button {
      padding: 9px 12px;
      cursor: pointer;
    }

    button:hover {
      border-color: #176b87;
      background: #eefaff;
    }

    select {
      max-width: 150px;
      padding: 7px;
    }

    #modeStatus {
      margin: 12px 0 0;
      color: #35506e;
    }

    #feedback {
      min-height: 24px;
      margin: 8px 0 0;
      color: #176b50;
      font-weight: 700;
    }

    .table-wrap {
      overflow-x: auto;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    th,
    td {
      padding: 12px 10px;
      border-bottom: 1px solid #e6edf5;
      text-align: left;
      white-space: nowrap;
    }

    th {
      color: #52657c;
      font-size: 0.82rem;
      letter-spacing: 0.05em;
      text-transform: uppercase;
    }

    .amount {
      font-variant-numeric: tabular-nums;
      font-weight: 700;
    }

    .income {
      color: #08764d;
    }

    .expense {
      color: #9a3412;
    }

    .low-confidence {
      background: #fff8dd;
    }

    .insights-grid,
    .cashflow-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 14px;
    }

    .insight,
    .week-card {
      padding: 16px;
      border: 1px solid #dfe7f1;
      border-radius: 12px;
      background: #f9fbfd;
    }

    .insight ul {
      margin: 0;
      padding-left: 20px;
    }

    .insight li + li {
      margin-top: 8px;
    }

    .week-card strong,
    .week-card span {
      display: block;
    }

    .week-card strong {
      margin-bottom: 8px;
    }

    .week-card span {
      margin-top: 5px;
      color: #52657c;
    }

    .negative {
      border-color: #f3a18b;
      background: #fff1ed;
    }

    .warning {
      color: #b42318;
      font-weight: 800;
    }

    .notice {
      border-left: 4px solid #176b87;
      background: #edf9fc;
    }

    footer {
      padding: 0 0 32px;
      color: #607086;
      font-size: 0.9rem;
    }

    @media (max-width: 700px) {
      header {
        padding-top: 30px;
      }

      .panel {
        padding: 16px;
      }
    }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>Local AI Spending Coach</h1>
      <p>Compare a keyword baseline with a trainable transaction classifier, then inspect the financial signals it produces.</p>
      <span class="badge">Synthetic data only. Nothing leaves this browser.</span>
    </div>
  </header>

  <main>
    <section class="panel">
      <h2>Classifier controls</h2>
      <div class="controls">
        <button type="button" onclick="setMode('baseline')">Use keyword baseline</button>
        <button type="button" onclick="setMode('model')">Use learned model</button>
      </div>
      <p id="modeStatus" aria-live="polite"></p>
      <p id="feedback" aria-live="polite"></p>
    </section>

    <section class="panel">
      <h2>Synthetic transactions</h2>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              <th>Week</th>
              <th>Merchant</th>
              <th>Amount</th>
              <th>Prediction</th>
              <th>Confidence</th>
              <th>Human correction</th>
            </tr>
          </thead>
          <tbody id="transactionRows"></tbody>
        </table>
      </div>
    </section>

    <section class="insights-grid">
      <article class="panel insight">
        <h2>Exact recurring charges</h2>
        <div id="recurringList"></div>
      </article>
      <article class="panel insight">
        <h2>Transparent anomaly check</h2>
        <div id="anomalyList"></div>
      </article>
      <article class="panel insight notice">
        <h2>How to read this</h2>
        <p>Categories are model estimates. The anomaly is a visible rule, not a fraud verdict. Review the source transaction before making a decision.</p>
      </article>
    </section>

    <section class="panel">
      <h2>Four-week cash-flow plan</h2>
      <p>Each ending balance becomes the next week’s beginning balance.</p>
      <div id="cashflowGrid" class="cashflow-grid"></div>
    </section>
  </main>

  <footer>
    Educational demonstration only. This app does not provide investment, tax, credit, or legal advice. Do not replace the synthetic records with account numbers or other sensitive information.
  </footer>
</body>
</html>

What Does This Shell Contain?

  • The header names the Spending Coach and states that its data stays in the browser.
  • The classifier panel provides controls for the two classification modes used later.
  • The transaction table reserves transactionRows for the synthetic records.
  • The insight areas reserve recurringList, anomalyList, and cashflowGrid for later calculations.
  • Copy the complete file shown above.
  • Select all existing content in index.html.
  • Paste the complete shell into index.html.
  • Save index.html.

The page now has all of its visible regions. The empty containers give later JavaScript a precise destination for transactions and spending signals.

Seeing Red Underlines on the Buttons?

The two buttons already reference setMode() from the final app. That function arrives with the classification logic in a later step.

Keep both button attributes unchanged. Help me check whether my index.html shell matches the project code.

Open the dashboard in Safari

A local file gives you the shortest test loop for this project. Every saved visual change can be checked in Safari without a server.

  • Right-click index.html in the Visual Studio Code Explorer.
  • Select Reveal in Finder.
  • Press Command-Space bar to open Spotlight.
  • Enter Safari in Spotlight.
  • Open the Safari result.

Before you open the file, which empty dashboard regions do you expect Safari to display?

  • Drag index.html from Finder onto the Safari window.

You should see the Local AI Spending Coach title above a white dashboard. You should also see the classifier controls and the empty transaction table.

Below the table, you should see the recurring-charge panel and the anomaly panel. You should also see the educational notice and the empty four-week cash-flow panel.

Dashboard Not Opening Correctly?

  • Confirm that the file is named index.html without an extra file extension.
  • Confirm that index.html is inside the ai-spending-coach folder shown in Visual Studio Code.
  • Return to Visual Studio Code to save the file before reopening it in Safari.
  • Help me troubleshoot my local Spending Coach page in Safari.

Your local shell is ready. Next up, you will load synthetic transactions and reveal where keyword rules fall short.

Expose the Keyword Baseline

Your dashboard shell is open in Safari, but the table has no transactions to classify. A local finance demo needs transparent sample records before its results can mean anything.

A keyword baseline checks merchant text against a literal list of terms. This step gives you a visible first pass that you can inspect line by line.

In this step, get ready to:
  • Load 18 synthetic transactions into the local dashboard.
  • Classify expenses with transparent keyword rules.
  • Render every prediction to expose the baseline’s blind spot.
Add the synthetic transaction data

Synthetic transactions let you test financial logic without using names or account details from real records. The dataset covers four weeks of income plus expenses from recognizable merchant types.

  • Switch back to Visual Studio Code.
  • Find the empty <script> element below the footer in index.html.
  • Add the transaction dataset inside that element by pasting this code:
const transactions = [
  { id: 1, week: 1, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
  { id: 2, week: 1, description: "CITY APARTMENT", amount: 1100, type: "expense" },
  { id: 3, week: 1, description: "GREENWAY GROCERS", amount: 95, type: "expense" },
  { id: 4, week: 1, description: "METRO PASS", amount: 50, type: "expense" },
  { id: 5, week: 2, description: "CITY ENERGY", amount: 82, type: "expense" },
  { id: 6, week: 2, description: "STREAMBOX", amount: 15, type: "expense" },
  { id: 7, week: 2, description: "CEDAR BISTRO", amount: 38, type: "expense" },
  { id: 8, week: 2, description: "MIDTOWN HUB", amount: 28, type: "expense" },
  { id: 9, week: 3, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
  { id: 10, week: 3, description: "GREENWAY GROCERS", amount: 115, type: "expense" },
  { id: 11, week: 3, description: "METRO PASS", amount: 50, type: "expense" },
  { id: 12, week: 3, description: "STREAMBOX", amount: 15, type: "expense" },
  { id: 13, week: 3, description: "PINE CLUB", amount: 22, type: "expense" },
  { id: 14, week: 4, description: "CITY APARTMENT", amount: 1100, type: "expense" },
  { id: 15, week: 4, description: "CEDAR BISTRO", amount: 42, type: "expense" },
  { id: 16, week: 4, description: "CITY ENERGY", amount: 260, type: "expense" },
  { id: 17, week: 4, description: "STREAMBOX", amount: 15, type: "expense" },
  { id: 18, week: 4, description: "HOME REPAIR", amount: 1200, type: "expense" }
];

What does this data represent?

  • Each object represents one synthetic transaction with a unique id.
  • The week value places the transaction into one of four cash-flow periods.
  • The description value contains the merchant text that the baseline reads.
  • The type value separates income from expenses.
  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Use Safari’s developer tools to open its JavaScript console.
  • Inspect the loaded dataset by evaluating this expression:
transactions

The console shows an array containing 18 transaction objects. This confirms that the browser loaded the synthetic records from your page.

Transactions missing from the console?

  • Confirm that the array sits between the opening and closing script tags in index.html.
  • Check that the final transaction is followed by the array’s closing bracket.
  • Refresh Safari after saving the file.
  • Help me find why the transactions array is unavailable in Safari.

The expense categories give every baseline result a consistent label. Income stays separate because it does not need merchant classification.

  • Switch back to Visual Studio Code.
  • Add the fixed category list below the transactions array by pasting this code:
const categories = [
  "Housing",
  "Groceries",
  "Utilities",
  "Transport",
  "Dining",
  "Entertainment",
  "Other"
];

Why use a fixed category list?

A fixed list keeps the baseline output predictable. The same labels also populate the correction controls that you use later.

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm the category list through the JavaScript console by evaluating:
categories

The console shows seven expense labels. The last fallback label is Other.

The keyword map makes the baseline’s reasoning visible. Each category owns a small list of merchant terms that trigger that label.

  • Switch back to Visual Studio Code.
  • Add the keyword rules below the categories array by pasting this code:
const keywordMap = {
  Housing: ["rent", "apartment", "mortgage"],
  Groceries: ["grocery", "grocers", "market"],
  Utilities: ["energy", "electric", "water", "internet"],
  Transport: ["metro", "transit", "fuel"],
  Dining: ["cafe", "coffee", "restaurant", "pizza"],
  Entertainment: ["stream", "cinema", "music"]
};

How do the rules work?

  • Each property name is an expense category.
  • Each property value is a list of lowercase terms associated with that category.
  • Merchants without a matching term eventually fall back to Other.
  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Inspect the literal rules through the JavaScript console by evaluating:
keywordMap

The console shows six named categories with their keyword arrays. The fallback category has no keyword list because it catches every unmatched expense.

Keyword map unavailable?

  • Check that each category entry ends with a comma except the final entry.
  • Confirm that the map closes with a brace followed by a full stop character in the code.
  • Refresh Safari after saving index.html.
  • Help me debug the keyword map in my local dashboard.
Build the baseline classifier

The baseline normalizes each merchant name to lowercase. It returns the first category whose keyword appears anywhere in that text.

  • Switch back to Visual Studio Code.
  • Add baselineCategory() below keywordMap by pasting this code:
function baselineCategory(text) {
  const normalized = text.toLowerCase();

  for (const [category, keywords] of Object.entries(keywordMap)) {
    if (keywords.some((keyword) => normalized.includes(keyword))) {
      return category;
    }
  }

  return "Other";
}

What does this function do?

  • The normalized value makes keyword matching case-insensitive.
  • The loop checks each category’s keyword list in order.
  • The function returns Other when every keyword check fails.
  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm that the classifier function loaded by evaluating:
baselineCategory

The console displays the function definition. This proves that Safari parsed the baseline logic without stopping at a syntax problem.

The transaction classifier adds one finance-specific rule around the baseline. Income receives a fixed category because merchant keywords only classify expenses.

  • Switch back to Visual Studio Code.
  • Add the mode value plus classifyTransaction() below baselineCategory() by pasting this code:
let mode = "model";

function classifyTransaction(transaction) {
  if (transaction.type === "income") {
    return { category: "Income", confidence: 1 };
  }

  if (mode === "baseline") {
    return { category: baselineCategory(transaction.description), confidence: null };
  }

  return predictCategory(transaction.description);
}

Why handle income first?

The transaction’s type already identifies income. Returning Income directly avoids forcing deposits through expense rules.

Baseline expenses receive a null confidence value. Their results come from literal rules instead of a statistical estimate.

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm that the transaction classifier loaded by evaluating:
classifyTransaction

The console displays the classifier function. The learned branch remains dormant while the baseline mode renders the table.

Classifier function missing?

  • Check that baselineCategory() closes before classifyTransaction() begins.
  • Confirm that transaction.type uses the same lowercase spelling as the transaction objects.
  • Refresh Safari after saving the script.
  • Help me debug the baseline transaction classifier.
Render the baseline predictions

The final layer turns each classifier result into a table row. Currency formatting keeps amounts consistent while the confidence column distinguishes fixed income from rule-based expenses.

  • Switch back to Visual Studio Code.
  • Add formatMoney() below classifyTransaction() by pasting this code:
function formatMoney(amount) {
  const sign = amount < 0 ? "-" : "";
  return `${sign}$${Math.abs(amount).toFixed(2)}`;
}

What does the formatter do?

The formatter keeps every amount at two decimal places. It also places the minus sign before the dollar sign for negative balances.

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm that the formatter loaded by evaluating:
formatMoney

The console displays the formatter function. The table renderer can now present every amount with consistent currency formatting.

Each expense row also needs the same category choices. The option generator creates that repeated markup from the fixed category list.

  • Switch back to Visual Studio Code.
  • Add categoryOptions() below formatMoney() by pasting this code:
function categoryOptions(selectedCategory) {
  return categories
    .map((category) => `<option value="${category}" ${category === selectedCategory ? "selected" : ""}>${category}</option>`)
    .join("");
}

What do the options represent?

The helper turns each category into a selectable option. It marks the current prediction as the selected value for that transaction.

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm that the option generator loaded by evaluating:
categoryOptions

The console displays the option generator function. The renderer now has every helper it needs to build complete rows.

The table renderer is one long function with two logical halves. The first half classifies each transaction plus prepares its row markup.

  • Switch back to Visual Studio Code.
  • Start renderTransactions() below categoryOptions() by pasting this first half:
function renderTransactions() {
  const rows = transactions.map((transaction) => {
    const prediction = classifyTransaction(transaction);
    const isLowConfidence = mode === "model"
      && transaction.type === "expense"
      && prediction.confidence < 0.35;
    const confidenceLabel = transaction.type === "income"
      ? "Fixed"
      : mode === "baseline"
        ? "Rule"
        : `${Math.round(prediction.confidence * 100)}%`;
    const correction = transaction.type === "income"
      ? "Fixed category"
      : `<select id="category-${transaction.id}" aria-label="Correct ${transaction.description}">
          ${categoryOptions(prediction.category)}
        </select>
        <button type="button" onclick="learnCorrection(${transaction.id})">Learn correction</button>`;
    const amountLabel = transaction.type === "income"
      ? `+${formatMoney(transaction.amount)}`
      : formatMoney(transaction.amount);

    return `<tr class="${isLowConfidence ? "low-confidence" : ""}">
      <td>Week ${transaction.week}</td>
      <td>${transaction.description}</td>
      <td class="amount ${transaction.type}">${amountLabel}</td>
      <td>${prediction.category}</td>
      <td>${confidenceLabel}</td>
      <td>${correction}</td>
    </tr>`;
  }).join("");

How is each row prepared?

  • The renderer classifies one transaction at a time.
  • Income displays Fixed in the confidence column.
  • Baseline expenses display Rule because no probability calculation is involved.
  • The row markup preserves the transaction’s week plus merchant plus amount plus prediction.

The second half inserts those rows into the existing table body. It also explains which classification mode is active.

  • Complete renderTransactions() by adding this second half immediately below the first:
  document.getElementById("transactionRows").innerHTML = rows;
  document.getElementById("modeStatus").textContent = mode === "baseline"
    ? "Keyword baseline active. CEDAR BISTRO should expose the missing rule."
    : "Learned model active. Percentages are relative confidence estimates, not guarantees.";
}

Where do the rows appear?

The renderer places its generated markup inside #transactionRows. It writes the active-mode explanation inside #modeStatus.

Mode switching gives the existing baseline button a working behavior. The initial call activates that mode as soon as the local page loads.

  • Add setMode() below renderTransactions() by pasting this code:
function setMode(nextMode) {
  mode = nextMode;
  document.getElementById("feedback").textContent = "";
  renderTransactions();
}

setMode('baseline')

What completes the baseline?

The mode function records the selected classifier. It clears old feedback before refreshing every transaction row.

The final call activates the baseline during this step. The dashboard can now reveal exactly what the literal keyword list recognizes.

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.

Pause to predict which merchant names the literal keyword list recognizes.

  • Select Use keyword baseline.
  • Count the rendered transaction rows.
  • Find both CEDAR BISTRO entries in the table.

You’ll see all 18 transactions. Income rows show Income with Fixed confidence.

Expense rows show Rule confidence. Both CEDAR BISTRO entries receive Other because bistro is absent from keywordMap.

Why is this shortfall useful?

The baseline can explain every result by pointing to a literal rule. Its coverage depends completely on the keywords that you wrote in advance.

The unseen word bistro exposes that limit in a concrete row. A recognizable dining merchant still falls into the fallback category.

Table still empty?

  • Confirm that setMode('baseline') appears after the completed setMode() function.
  • Check that the table body still uses the transactionRows identifier.
  • Refresh Safari after saving every script change.
  • Help me debug an empty transaction table in my local dashboard.

✔️ Awesome, I've got everything!

Great work. Your keyword baseline now renders all 18 transactions plus makes its missing merchant coverage visible.

  • Confirm that you saved index.html.
  • Confirm that CEDAR BISTRO appears as Other.

ⓧ I'd like to double check the full code

Compare your cumulative index.html file with this reference:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Local AI Spending Coach</title>
  <style>
    :root {
      color-scheme: light;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f4f7fb;
      color: #172033;
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f4f7fb;
    }

    header {
      padding: 42px 24px 28px;
      color: white;
      background: linear-gradient(135deg, #173b68, #176b87);
    }

    header div,
    main,
    footer {
      width: min(1180px, calc(100% - 32px));
      margin: 0 auto;
    }

    h1,
    h2,
    p {
      margin-top: 0;
    }

    h1 {
      margin-bottom: 10px;
      font-size: clamp(2rem, 5vw, 3.2rem);
    }

    header p {
      max-width: 760px;
      margin-bottom: 14px;
      color: #d8eff7;
    }

    .badge {
      display: inline-block;
      padding: 7px 11px;
      border: 1px solid #7dd3fc;
      border-radius: 999px;
      color: #e0f7ff;
      font-size: 0.88rem;
    }

    main {
      display: grid;
      gap: 18px;
      padding: 24px 0;
    }

    .panel {
      padding: 20px;
      border: 1px solid #dfe7f1;
      border-radius: 16px;
      background: white;
      box-shadow: 0 8px 24px rgba(30, 64, 100, 0.06);
    }

    .controls {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: center;
    }

    button,
    select {
      border: 1px solid #b9c7d8;
      border-radius: 9px;
      background: white;
      color: #172033;
      font: inherit;
    }

    button {
      padding: 9px 12px;
      cursor: pointer;
    }

    button:hover {
      border-color: #176b87;
      background: #eefaff;
    }

    select {
      max-width: 150px;
      padding: 7px;
    }

    #modeStatus {
      margin: 12px 0 0;
      color: #35506e;
    }

    #feedback {
      min-height: 24px;
      margin: 8px 0 0;
      color: #176b50;
      font-weight: 700;
    }

    .table-wrap {
      overflow-x: auto;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    th,
    td {
      padding: 12px 10px;
      border-bottom: 1px solid #e6edf5;
      text-align: left;
      white-space: nowrap;
    }

    th {
      color: #52657c;
      font-size: 0.82rem;
      letter-spacing: 0.05em;
      text-transform: uppercase;
    }

    .amount {
      font-variant-numeric: tabular-nums;
      font-weight: 700;
    }

    .income {
      color: #08764d;
    }

    .expense {
      color: #9a3412;
    }

    .low-confidence {
      background: #fff8dd;
    }

    .insights-grid,
    .cashflow-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 14px;
    }

    .insight,
    .week-card {
      padding: 16px;
      border: 1px solid #dfe7f1;
      border-radius: 12px;
      background: #f9fbfd;
    }

    .insight ul {
      margin: 0;
      padding-left: 20px;
    }

    .insight li + li {
      margin-top: 8px;
    }

    .week-card strong,
    .week-card span {
      display: block;
    }

    .week-card strong {
      margin-bottom: 8px;
    }

    .week-card span {
      margin-top: 5px;
      color: #52657c;
    }

    .negative {
      border-color: #f3a18b;
      background: #fff1ed;
    }

    .warning {
      color: #b42318;
      font-weight: 800;
    }

    .notice {
      border-left: 4px solid #176b87;
      background: #edf9fc;
    }

    footer {
      padding: 0 0 32px;
      color: #607086;
      font-size: 0.9rem;
    }

    @media (max-width: 700px) {
      header {
        padding-top: 30px;
      }

      .panel {
        padding: 16px;
      }
    }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>Local AI Spending Coach</h1>
      <p>Compare a keyword baseline with a trainable transaction classifier, then inspect the financial signals it produces.</p>
      <span class="badge">Synthetic data only. Nothing leaves this browser.</span>
    </div>
  </header>

  <main>
    <section class="panel">
      <h2>Classifier controls</h2>
      <div class="controls">
        <button type="button" onclick="setMode('baseline')">Use keyword baseline</button>
        <button type="button" onclick="setMode('model')">Use learned model</button>
      </div>
      <p id="modeStatus" aria-live="polite"></p>
      <p id="feedback" aria-live="polite"></p>
    </section>

    <section class="panel">
      <h2>Synthetic transactions</h2>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              <th>Week</th>
              <th>Merchant</th>
              <th>Amount</th>
              <th>Prediction</th>
              <th>Confidence</th>
              <th>Human correction</th>
            </tr>
          </thead>
          <tbody id="transactionRows"></tbody>
        </table>
      </div>
    </section>

    <section class="insights-grid">
      <article class="panel insight">
        <h2>Exact recurring charges</h2>
        <div id="recurringList"></div>
      </article>
      <article class="panel insight">
        <h2>Transparent anomaly check</h2>
        <div id="anomalyList"></div>
      </article>
      <article class="panel insight notice">
        <h2>How to read this</h2>
        <p>Categories are model estimates. The anomaly is a visible rule, not a fraud verdict. Review the source transaction before making a decision.</p>
      </article>
    </section>

    <section class="panel">
      <h2>Four-week cash-flow plan</h2>
      <p>Each ending balance becomes the next week’s beginning balance.</p>
      <div id="cashflowGrid" class="cashflow-grid"></div>
    </section>
  </main>

  <footer>
    Educational demonstration only. This app does not provide investment, tax, credit, or legal advice. Do not replace the synthetic records with account numbers or other sensitive information.
  </footer>

  <script>
    const transactions = [
      { id: 1, week: 1, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
      { id: 2, week: 1, description: "CITY APARTMENT", amount: 1100, type: "expense" },
      { id: 3, week: 1, description: "GREENWAY GROCERS", amount: 95, type: "expense" },
      { id: 4, week: 1, description: "METRO PASS", amount: 50, type: "expense" },
      { id: 5, week: 2, description: "CITY ENERGY", amount: 82, type: "expense" },
      { id: 6, week: 2, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 7, week: 2, description: "CEDAR BISTRO", amount: 38, type: "expense" },
      { id: 8, week: 2, description: "MIDTOWN HUB", amount: 28, type: "expense" },
      { id: 9, week: 3, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
      { id: 10, week: 3, description: "GREENWAY GROCERS", amount: 115, type: "expense" },
      { id: 11, week: 3, description: "METRO PASS", amount: 50, type: "expense" },
      { id: 12, week: 3, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 13, week: 3, description: "PINE CLUB", amount: 22, type: "expense" },
      { id: 14, week: 4, description: "CITY APARTMENT", amount: 1100, type: "expense" },
      { id: 15, week: 4, description: "CEDAR BISTRO", amount: 42, type: "expense" },
      { id: 16, week: 4, description: "CITY ENERGY", amount: 260, type: "expense" },
      { id: 17, week: 4, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 18, week: 4, description: "HOME REPAIR", amount: 1200, type: "expense" }
    ];

    const categories = [
      "Housing",
      "Groceries",
      "Utilities",
      "Transport",
      "Dining",
      "Entertainment",
      "Other"
    ];

    const keywordMap = {
      Housing: ["rent", "apartment", "mortgage"],
      Groceries: ["grocery", "grocers", "market"],
      Utilities: ["energy", "electric", "water", "internet"],
      Transport: ["metro", "transit", "fuel"],
      Dining: ["cafe", "coffee", "restaurant", "pizza"],
      Entertainment: ["stream", "cinema", "music"]
    };

    let mode = "model";

    function baselineCategory(text) {
      const normalized = text.toLowerCase();

      for (const [category, keywords] of Object.entries(keywordMap)) {
        if (keywords.some((keyword) => normalized.includes(keyword))) {
          return category;
        }
      }

      return "Other";
    }

    function classifyTransaction(transaction) {
      if (transaction.type === "income") {
        return { category: "Income", confidence: 1 };
      }

      if (mode === "baseline") {
        return { category: baselineCategory(transaction.description), confidence: null };
      }

      return predictCategory(transaction.description);
    }

    function formatMoney(amount) {
      const sign = amount < 0 ? "-" : "";
      return `${sign}$${Math.abs(amount).toFixed(2)}`;
    }

    function categoryOptions(selectedCategory) {
      return categories
        .map((category) => `<option value="${category}" ${category === selectedCategory ? "selected" : ""}>${category}</option>`)
        .join("");
    }

    function renderTransactions() {
      const rows = transactions.map((transaction) => {
        const prediction = classifyTransaction(transaction);
        const isLowConfidence = mode === "model"
          && transaction.type === "expense"
          && prediction.confidence < 0.35;
        const confidenceLabel = transaction.type === "income"
          ? "Fixed"
          : mode === "baseline"
            ? "Rule"
            : `${Math.round(prediction.confidence * 100)}%`;
        const correction = transaction.type === "income"
          ? "Fixed category"
          : `<select id="category-${transaction.id}" aria-label="Correct ${transaction.description}">
              ${categoryOptions(prediction.category)}
            </select>
            <button type="button" onclick="learnCorrection(${transaction.id})">Learn correction</button>`;
        const amountLabel = transaction.type === "income"
          ? `+${formatMoney(transaction.amount)}`
          : formatMoney(transaction.amount);

        return `<tr class="${isLowConfidence ? "low-confidence" : ""}">
          <td>Week ${transaction.week}</td>
          <td>${transaction.description}</td>
          <td class="amount ${transaction.type}">${amountLabel}</td>
          <td>${prediction.category}</td>
          <td>${confidenceLabel}</td>
          <td>${correction}</td>
        </tr>`;
      }).join("");

      document.getElementById("transactionRows").innerHTML = rows;
      document.getElementById("modeStatus").textContent = mode === "baseline"
        ? "Keyword baseline active. CEDAR BISTRO should expose the missing rule."
        : "Learned model active. Percentages are relative confidence estimates, not guarantees.";
    }

    function setMode(nextMode) {
      mode = nextMode;
      document.getElementById("feedback").textContent = "";
      renderTransactions();
    }

    setMode('baseline')
  </script>
</body>
</html>

Use this full-file block for comparison. Keep your existing document structure plus styles exactly aligned with the reference.

That’s the limitation exposed: your baseline is transparent, but an unseen merchant term defeats its literal rules. Next, you’ll train a word-count classifier that can generalize beyond exact keyword coverage.

Train the Transaction Classifier

Your keyword baseline now exposes its blind spot. CEDAR BISTRO has no listed keyword. It falls back to Other.

A supervised text classifier learns category patterns from labeled merchant examples. In this step, you will build that model inside the browser.

In this step, get ready to:
  • Add labeled merchant examples across all seven expense categories.
  • Turn the labeled examples into a smoothed word-count model.
  • Display learned categories with relative confidence estimates.
Add labeled merchant examples

Each labeled example pairs merchant text with the category the model should learn. Examples from every expense category give the classifier patterns it can compare with new merchant names.

  • Switch back to index.html in Visual Studio Code.
  • Find the const keywordMap = { block.
  • Add the labeled examples immediately after the closing }; by pasting this code:
const trainingSamples = [
  { text: "city apartment rent", category: "Housing" },
  { text: "oak street mortgage", category: "Housing" },
  { text: "downtown loft lease", category: "Housing" },
  { text: "greenway grocers", category: "Groceries" },
  { text: "fresh market foods", category: "Groceries" },
  { text: "neighborhood supermarket", category: "Groceries" },
  { text: "city energy electric", category: "Utilities" },
  { text: "clear water utility", category: "Utilities" },
  { text: "home internet service", category: "Utilities" },
  { text: "metro transit pass", category: "Transport" },
  { text: "quick fuel station", category: "Transport" },
  { text: "city rideshare", category: "Transport" },
  { text: "harbor bistro dinner", category: "Dining" },
  { text: "corner cafe coffee", category: "Dining" },
  { text: "pizza restaurant", category: "Dining" },
  { text: "streambox subscription", category: "Entertainment" },
  { text: "cinema movie tickets", category: "Entertainment" },
  { text: "music club", category: "Entertainment" },
  { text: "home repair service", category: "Other" },
  { text: "pharmacy purchase", category: "Other" },
  { text: "bank fee", category: "Other" }
];

What do these examples teach?

  • Each object stores merchant words in text.
  • The category value supplies the correct label for those words.
  • Three examples per category give the model 21 labeled records to learn from.
  • The Dining examples include bistro. That word gives the model evidence for classifying the unseen merchant.
  • Save index.html.
  • Refresh the dashboard in Safari.

You should still see all 18 transactions. This confirms the new dataset loads without breaking the existing baseline.

Did the table disappear?

  • Check that every training object has matching braces.
  • Check that the array ends with ];.
  • Compare the commas between the 21 objects with the code above.
  • Help me find the syntax error in my trainingSamples array.
Build the word-count model

A multinomial naive Bayes classifier compares word counts across labeled categories. Laplace smoothing gives unseen words a small nonzero probability.

  • Find function baselineCategory(text).
  • Add the tokenizer immediately above that function by pasting this code:
function tokenize(text) {
  return text.toLowerCase().match(/[a-z]+/g) || [];
}

What does the tokenizer do?

  • The lowercase conversion makes BISTRO match the training word bistro.
  • The letter pattern extracts words from each merchant description.
  • The empty array keeps the classifier safe when a description contains no matching letters.
  • Save index.html.
  • Refresh Safari to confirm the 18 transaction rows still render.

The visible table confirms the browser parsed the tokenizer successfully.

Did the refresh stop rendering rows?

  • Check that the regular expression contains both forward slashes.
  • Check that toLowerCase() includes both parentheses.
  • Help me debug my tokenize function.
  • Find the closing brace of baselineCategory().
  • Add the training function immediately below it by pasting this code:
function trainNaiveBayes(samples) {
  const labels = {};
  const vocabulary = new Set();

  for (const sample of samples) {
    if (!labels[sample.category]) {
      labels[sample.category] = { documents: 0, totalWords: 0, words: {} };
    }

    labels[sample.category].documents += 1;

    for (const token of tokenize(sample.text)) {
      vocabulary.add(token);
      labels[sample.category].words[token] = (labels[sample.category].words[token] || 0) + 1;
      labels[sample.category].totalWords += 1;
    }
  }

  return {
    labels,
    vocabulary,
    sampleCount: samples.length
  };
}

How does training work?

  • The labels object tracks document counts and word counts for each category.
  • The vocabulary set records every distinct training word.
  • The nested loops tokenize each example before adding its counts to the correct category.
  • The returned sampleCount supports the class-prior calculation during prediction.
  • Save index.html.
  • Refresh Safari to confirm the transaction table still contains 18 rows.

The table remains available because the training function is defined without changing the active predictions.

Seeing an empty dashboard?

  • Check that each loop and conditional has a closing brace.
  • Check that labels[sample.category] uses square brackets in every occurrence.
  • Help me debug trainNaiveBayes().

The training function now exists. The next two lines run it once when the page loads and store the resulting word-count model.

  • Find the line that defines mode.
  • Replace that definition with these two lines:
let mode = "model";
let model = trainNaiveBayes(trainingSamples);

What initializes the model?

The first line makes the learned model the initial mode. The second line trains the model from the 21 labeled examples when the page loads.

  • Save index.html.
  • Refresh Safari to confirm the 18 transaction rows still appear.

Your training data has now become a model in browser memory. The existing table confirms that initialization completed without a syntax error.

Did model initialization fail?

  • Check that trainingSamples uses the same capitalization in the array and the training call.
  • Check that the function name is exactly trainNaiveBayes.
  • Help me fix model initialization.
Predict categories and confidence

Prediction scores each category using its class prior and smoothed word probabilities. Logarithms keep the repeated probability calculations numerically manageable.

  • Find the closing brace of trainNaiveBayes().
  • Add the prediction function immediately below it by pasting this code:
function predictCategory(text) {
  const knownTokens = tokenize(text).filter((token) => model.vocabulary.has(token));
  const scores = [];

  for (const [category, data] of Object.entries(model.labels)) {
    let score = Math.log(data.documents / model.sampleCount);

    for (const token of knownTokens) {
      const wordCount = data.words[token] || 0;
      const probability = (wordCount + 1) / (data.totalWords + model.vocabulary.size);
      score += Math.log(probability);
    }

    scores.push({ category, score });
  }

  scores.sort((a, b) => b.score - a.score);
  const largestScore = scores[0].score;
  const relativeScores = scores.map((item) => Math.exp(item.score - largestScore));
  const relativeTotal = relativeScores.reduce((sum, value) => sum + value, 0);

  return {
    category: scores[0].category,
    confidence: relativeScores[0] / relativeTotal
  };
}

How does prediction work?

  • The knownTokens filter keeps words that appear in the training vocabulary.
  • Each category starts with a class-prior score based on its share of the training examples.
  • The + 1 applies Laplace smoothing so a missing category word does not erase the entire score.
  • The final normalization converts the winning score into a relative confidence estimate.

How should you read confidence?

The displayed percentage compares category scores within this small training set. It is an estimate rather than a calibrated probability or financial risk score.

  • Find the existing classifyTransaction() function.
  • Replace the entire function with this learned-mode version:
function classifyTransaction(transaction) {
  if (transaction.type === "income") {
    return { category: "Income", confidence: 1 };
  }

  if (mode === "baseline") {
    return { category: baselineCategory(transaction.description), confidence: null };
  }

  return predictCategory(transaction.description);
}

How do the two modes connect?

  • Income remains a fixed category in both modes.
  • Baseline mode continues to use the transparent keyword rules.
  • Model mode sends each expense description to predictCategory().
  • The existing table renderer turns the model confidence value into a percentage.
  • Save index.html.

Before you refresh, which category do you think the learned model assigns to CEDAR BISTRO?

  • Refresh index.html in Safari.
  • Select Use learned model.

You should see CEDAR BISTRO categorized as Dining. Its confidence cell should contain a percentage.

  • Select Use keyword baseline.

You should see CEDAR BISTRO return to Other. Its confidence cell should return to Rule.

Does the learned mode still show Other?

  • Check that the Dining training example contains the lowercase word bistro.
  • Check that the final line of classifyTransaction() returns predictCategory(transaction.description).
  • Check that setMode() still calls renderTransactions().
  • Help me debug the learned prediction for CEDAR BISTRO.

✔️ Awesome, I've got everything!

Great. Double-check that you saved index.html and can switch between both classifier modes.

ⓧ I'd like to double check the full code

Compare the classifier section of your script with this cumulative reference.

const trainingSamples = [
  { text: "city apartment rent", category: "Housing" },
  { text: "oak street mortgage", category: "Housing" },
  { text: "downtown loft lease", category: "Housing" },
  { text: "greenway grocers", category: "Groceries" },
  { text: "fresh market foods", category: "Groceries" },
  { text: "neighborhood supermarket", category: "Groceries" },
  { text: "city energy electric", category: "Utilities" },
  { text: "clear water utility", category: "Utilities" },
  { text: "home internet service", category: "Utilities" },
  { text: "metro transit pass", category: "Transport" },
  { text: "quick fuel station", category: "Transport" },
  { text: "city rideshare", category: "Transport" },
  { text: "harbor bistro dinner", category: "Dining" },
  { text: "corner cafe coffee", category: "Dining" },
  { text: "pizza restaurant", category: "Dining" },
  { text: "streambox subscription", category: "Entertainment" },
  { text: "cinema movie tickets", category: "Entertainment" },
  { text: "music club", category: "Entertainment" },
  { text: "home repair service", category: "Other" },
  { text: "pharmacy purchase", category: "Other" },
  { text: "bank fee", category: "Other" }
];

let mode = "model";
let model = trainNaiveBayes(trainingSamples);

function tokenize(text) {
  return text.toLowerCase().match(/[a-z]+/g) || [];
}

function baselineCategory(text) {
  const normalized = text.toLowerCase();

  for (const [category, keywords] of Object.entries(keywordMap)) {
    if (keywords.some((keyword) => normalized.includes(keyword))) {
      return category;
    }
  }

  return "Other";
}

function trainNaiveBayes(samples) {
  const labels = {};
  const vocabulary = new Set();

  for (const sample of samples) {
    if (!labels[sample.category]) {
      labels[sample.category] = { documents: 0, totalWords: 0, words: {} };
    }

    labels[sample.category].documents += 1;

    for (const token of tokenize(sample.text)) {
      vocabulary.add(token);
      labels[sample.category].words[token] = (labels[sample.category].words[token] || 0) + 1;
      labels[sample.category].totalWords += 1;
    }
  }

  return {
    labels,
    vocabulary,
    sampleCount: samples.length
  };
}

function predictCategory(text) {
  const knownTokens = tokenize(text).filter((token) => model.vocabulary.has(token));
  const scores = [];

  for (const [category, data] of Object.entries(model.labels)) {
    let score = Math.log(data.documents / model.sampleCount);

    for (const token of knownTokens) {
      const wordCount = data.words[token] || 0;
      const probability = (wordCount + 1) / (data.totalWords + model.vocabulary.size);
      score += Math.log(probability);
    }

    scores.push({ category, score });
  }

  scores.sort((a, b) => b.score - a.score);
  const largestScore = scores[0].score;
  const relativeScores = scores.map((item) => Math.exp(item.score - largestScore));
  const relativeTotal = relativeScores.reduce((sum, value) => sum + value, 0);

  return {
    category: scores[0].category,
    confidence: relativeScores[0] / relativeTotal
  };
}

function classifyTransaction(transaction) {
  if (transaction.type === "income") {
    return { category: "Income", confidence: 1 };
  }

  if (mode === "baseline") {
    return { category: baselineCategory(transaction.description), confidence: null };
  }

  return predictCategory(transaction.description);
}

How to use this reference

Check the order of the classifier declarations and functions. Keep your existing transaction data and rendering code around this section.

That is a meaningful upgrade. Your local dashboard now learns word patterns and exposes the uncertainty behind each expense prediction.

Next, you will give the human reviewer a way to correct weak predictions and retrain the model immediately.

Add Human Corrections

Your learned classifier now recognizes CEDAR BISTRO as Dining in Safari. The dashboard can compare that result with the keyword baseline.

Ambiguous names like MIDTOWN HUB still need human judgment. In this step, you will add a human-in-the-loop correction path that retrains the local model from a selected label.

In this step, get ready to:
  • Add a category selector plus a correction button to each expense row.
  • Retrain the classifier whenever a human submits a corrected label.
  • Highlight learned predictions below the project-defined confidence threshold.
Add correction controls to each expense

Each selector needs the same seven expense categories. A helper can generate those options while preserving the model's current prediction as the selected value.

  • Switch back to the ai-spending-coach folder in Visual Studio Code.
  • In index.html, find the renderTransactions() function.
  • Add the category-options helper immediately above renderTransactions() by pasting this code:
function categoryOptions(selectedCategory) {
      return categories
        .map((category) => `<option value="${category}" ${category === selectedCategory ? "selected" : ""}>${category}</option>`)
        .join("");
    }

What Does This Helper Do?

  • The categoryOptions(selectedCategory) helper turns every entry in categories into an HTML option.
  • The selected attribute keeps the classifier's current category visible when the table rerenders.
  • Save index.html.
  • Switch back to Safari.
  • Refresh the local dashboard.

You should still see all 18 transactions with their learned categories and confidence estimates. This confirms that the helper did not interrupt the existing render.

Did the Transaction Rows Disappear?

  • Check that categoryOptions() closes before renderTransactions() begins.
  • Check that the template literal around each option uses matching backticks.
  • Ask for help with the exact helper you added: Help me find the syntax error in my categoryOptions function.

The row renderer can now build a selector for each expense. Income remains fixed because the correction path only applies to expense categories.

  • Switch back to index.html in Visual Studio Code.
  • Inside renderTransactions(), find the confidenceLabel calculation.
  • Add the correction markup below that calculation by pasting this code:
const correction = transaction.type === "income"
          ? "Fixed category"
          : `<select id="category-${transaction.id}" aria-label="Correct ${transaction.description}">
              ${categoryOptions(prediction.category)}
            </select>
            <button type="button" onclick="learnCorrection(${transaction.id})">Learn correction</button>`;

How Are the Controls Connected?

  • Each selector receives an ID based on transaction.id. The correction function can use that ID to read the selected category.
  • Each button passes the matching transaction ID to learnCorrection().
  • Income rows display Fixed category because their category does not come from the expense classifier.
  • Find the <td>${confidenceLabel}</td> line in the row template.
  • Add the correction cell directly below it so those two lines look like this:
<td>${confidenceLabel}</td>
          <td>${correction}</td>

What Changes in the Table?

The final cell now renders the selector and button markup for expenses. The same cell shows a fixed-category message for income.

  • Save index.html.
  • Switch back to Safari.
  • Refresh the local dashboard.

You should see a category selector and a Learn correction button in every expense row. The two payroll rows should show Fixed category.

Missing the Correction Controls?

  • Check that ${correction} sits inside the final table cell.
  • Check that each selector ID includes category-${transaction.id} exactly.
  • Use this prompt if the table renders without controls: Help me debug the correction cell in renderTransactions.
Teach the model from a correction

A submitted correction becomes one more labeled training example. Retraining immediately lets you observe how a single human label changes the local classifier.

  • In index.html, find the closing brace of setMode().
  • Add the correction function immediately below setMode() by pasting this code:
function learnCorrection(transactionId) {
      const transaction = transactions.find((item) => item.id === transactionId);
      const selectedCategory = document.getElementById(`category-${transactionId}`).value;

      trainingSamples.push({
        text: transaction.description,
        category: selectedCategory
      });

      model = trainNaiveBayes(trainingSamples);
      mode = "model";
      document.getElementById("feedback").textContent = `Learned ${transaction.description} as ${selectedCategory}.`;
      renderTransactions();
    }

What Does This Function Do?

  • The transaction ID locates the matching transaction plus its category selector.
  • The selected merchant and category become a new object in trainingSamples.
  • The call to trainNaiveBayes(trainingSamples) rebuilds the model with the correction included.
  • The feedback message records what the model learned before renderTransactions() refreshes the table.
  • Save index.html.
  • Switch back to Safari.
  • Refresh the local dashboard.
  • Choose Transport from the selector in the MIDTOWN HUB row.

Before you select Learn correction, do you expect the model to keep its previous label or learn from the new Transport example?

  • Select Learn correction in the MIDTOWN HUB row.

You should see Learned MIDTOWN HUB as Transport. above the table. The refreshed row should predict Transport for MIDTOWN HUB.

Correction Button Not Responding?

  • Check that the button calls learnCorrection(${transaction.id}) with the current transaction ID.
  • Check that getElementById() uses the same category-${transactionId} pattern as the selector.
  • Ask for targeted help if the feedback stays empty: Help me debug learnCorrection in my local spending coach.
Highlight uncertain model results

The displayed percentages are relative confidence estimates from this small training set. The project uses 0.35 as a teaching threshold so uncertain learned results remain visible for human review.

  • Inside renderTransactions(), find the line that assigns prediction.
  • Add the low-confidence check directly below that line by pasting this code:
const isLowConfidence = mode === "model"
          && transaction.type === "expense"
          && prediction.confidence < 0.35;

When Does the Highlight Apply?

  • The check only applies while the learned model is active.
  • Income stays outside the check because its category is fixed.
  • An expense qualifies when its confidence estimate falls below 0.35.
  • Find the opening row template inside renderTransactions().
  • Replace its opening tag with this line:
return `<tr class="${isLowConfidence ? "low-confidence" : ""}">

How Does the Highlight Appear?

A qualifying row receives the existing low-confidence class. The embedded stylesheet gives that row a pale yellow background.

  • Save index.html.
  • Switch back to Safari.
  • Refresh the local dashboard.

You should see learned expense rows below the threshold highlighted in pale yellow. Baseline mode should leave every row unhighlighted because its results come from rules.

  • Choose Transport from the selector in the MIDTOWN HUB row.

Before you select Learn correction, do you expect the refreshed prediction to follow your label?

  • Select Learn correction in the MIDTOWN HUB row.

You should see the confirmation message above the table. You should also see MIDTOWN HUB predicted as Transport in the refreshed learned-model table.

That is the correction loop working. Your local classifier can now surface uncertainty while learning from human judgment.

Rows Not Highlighting?

  • Check that the comparison uses prediction.confidence < 0.35.
  • Check that the row template adds low-confidence when isLowConfidence is true.
  • Use this prompt if confidence values appear without highlighting: Help me debug the low-confidence row class.

✔️ Awesome, I've got everything!

Great. Double-check that you saved index.html after testing the correction path.

ⓧ I'd like to double check the full code

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Local AI Spending Coach</title>
  <style>
    :root {
      color-scheme: light;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f4f7fb;
      color: #172033;
    }

    * {
      box-sizing: border-box;
    }

    body {
      margin: 0;
      background: #f4f7fb;
    }

    header {
      padding: 42px 24px 28px;
      color: white;
      background: linear-gradient(135deg, #173b68, #176b87);
    }

    header div,
    main,
    footer {
      width: min(1180px, calc(100% - 32px));
      margin: 0 auto;
    }

    h1,
    h2,
    p {
      margin-top: 0;
    }

    h1 {
      margin-bottom: 10px;
      font-size: clamp(2rem, 5vw, 3.2rem);
    }

    header p {
      max-width: 760px;
      margin-bottom: 14px;
      color: #d8eff7;
    }

    .badge {
      display: inline-block;
      padding: 7px 11px;
      border: 1px solid #7dd3fc;
      border-radius: 999px;
      color: #e0f7ff;
      font-size: 0.88rem;
    }

    main {
      display: grid;
      gap: 18px;
      padding: 24px 0;
    }

    .panel {
      padding: 20px;
      border: 1px solid #dfe7f1;
      border-radius: 16px;
      background: white;
      box-shadow: 0 8px 24px rgba(30, 64, 100, 0.06);
    }

    .controls {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      align-items: center;
    }

    button,
    select {
      border: 1px solid #b9c7d8;
      border-radius: 9px;
      background: white;
      color: #172033;
      font: inherit;
    }

    button {
      padding: 9px 12px;
      cursor: pointer;
    }

    button:hover {
      border-color: #176b87;
      background: #eefaff;
    }

    select {
      max-width: 150px;
      padding: 7px;
    }

    #modeStatus {
      margin: 12px 0 0;
      color: #35506e;
    }

    #feedback {
      min-height: 24px;
      margin: 8px 0 0;
      color: #176b50;
      font-weight: 700;
    }

    .table-wrap {
      overflow-x: auto;
    }

    table {
      width: 100%;
      border-collapse: collapse;
    }

    th,
    td {
      padding: 12px 10px;
      border-bottom: 1px solid #e6edf5;
      text-align: left;
      white-space: nowrap;
    }

    th {
      color: #52657c;
      font-size: 0.82rem;
      letter-spacing: 0.05em;
      text-transform: uppercase;
    }

    .amount {
      font-variant-numeric: tabular-nums;
      font-weight: 700;
    }

    .income {
      color: #08764d;
    }

    .expense {
      color: #9a3412;
    }

    .low-confidence {
      background: #fff8dd;
    }

    .insights-grid,
    .cashflow-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
      gap: 14px;
    }

    .insight,
    .week-card {
      padding: 16px;
      border: 1px solid #dfe7f1;
      border-radius: 12px;
      background: #f9fbfd;
    }

    .insight ul {
      margin: 0;
      padding-left: 20px;
    }

    .insight li + li {
      margin-top: 8px;
    }

    .week-card strong,
    .week-card span {
      display: block;
    }

    .week-card strong {
      margin-bottom: 8px;
    }

    .week-card span {
      margin-top: 5px;
      color: #52657c;
    }

    .negative {
      border-color: #f3a18b;
      background: #fff1ed;
    }

    .warning {
      color: #b42318;
      font-weight: 800;
    }

    .notice {
      border-left: 4px solid #176b87;
      background: #edf9fc;
    }

    footer {
      padding: 0 0 32px;
      color: #607086;
      font-size: 0.9rem;
    }

    @media (max-width: 700px) {
      header {
        padding-top: 30px;
      }

      .panel {
        padding: 16px;
      }
    }
  </style>
</head>
<body>
  <header>
    <div>
      <h1>Local AI Spending Coach</h1>
      <p>Compare a keyword baseline with a trainable transaction classifier, then inspect the financial signals it produces.</p>
      <span class="badge">Synthetic data only. Nothing leaves this browser.</span>
    </div>
  </header>

  <main>
    <section class="panel">
      <h2>Classifier controls</h2>
      <div class="controls">
        <button type="button" onclick="setMode('baseline')">Use keyword baseline</button>
        <button type="button" onclick="setMode('model')">Use learned model</button>
      </div>
      <p id="modeStatus" aria-live="polite"></p>
      <p id="feedback" aria-live="polite"></p>
    </section>

    <section class="panel">
      <h2>Synthetic transactions</h2>
      <div class="table-wrap">
        <table>
          <thead>
            <tr>
              <th>Week</th>
              <th>Merchant</th>
              <th>Amount</th>
              <th>Prediction</th>
              <th>Confidence</th>
              <th>Human correction</th>
            </tr>
          </thead>
          <tbody id="transactionRows"></tbody>
        </table>
      </div>
    </section>

    <section class="insights-grid">
      <article class="panel insight">
        <h2>Exact recurring charges</h2>
        <div id="recurringList"></div>
      </article>
      <article class="panel insight">
        <h2>Transparent anomaly check</h2>
        <div id="anomalyList"></div>
      </article>
      <article class="panel insight notice">
        <h2>How to read this</h2>
        <p>Categories are model estimates. The anomaly is a visible rule, not a fraud verdict. Review the source transaction before making a decision.</p>
      </article>
    </section>

    <section class="panel">
      <h2>Four-week cash-flow plan</h2>
      <p>Each ending balance becomes the next week’s beginning balance.</p>
      <div id="cashflowGrid" class="cashflow-grid"></div>
    </section>
  </main>

  <footer>
    Educational demonstration only. This app does not provide investment, tax, credit, or legal advice. Do not replace the synthetic records with account numbers or other sensitive information.
  </footer>

  <script>
    const transactions = [
      { id: 1, week: 1, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
      { id: 2, week: 1, description: "CITY APARTMENT", amount: 1100, type: "expense" },
      { id: 3, week: 1, description: "GREENWAY GROCERS", amount: 95, type: "expense" },
      { id: 4, week: 1, description: "METRO PASS", amount: 50, type: "expense" },
      { id: 5, week: 2, description: "CITY ENERGY", amount: 82, type: "expense" },
      { id: 6, week: 2, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 7, week: 2, description: "CEDAR BISTRO", amount: 38, type: "expense" },
      { id: 8, week: 2, description: "MIDTOWN HUB", amount: 28, type: "expense" },
      { id: 9, week: 3, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },
      { id: 10, week: 3, description: "GREENWAY GROCERS", amount: 115, type: "expense" },
      { id: 11, week: 3, description: "METRO PASS", amount: 50, type: "expense" },
      { id: 12, week: 3, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 13, week: 3, description: "PINE CLUB", amount: 22, type: "expense" },
      { id: 14, week: 4, description: "CITY APARTMENT", amount: 1100, type: "expense" },
      { id: 15, week: 4, description: "CEDAR BISTRO", amount: 42, type: "expense" },
      { id: 16, week: 4, description: "CITY ENERGY", amount: 260, type: "expense" },
      { id: 17, week: 4, description: "STREAMBOX", amount: 15, type: "expense" },
      { id: 18, week: 4, description: "HOME REPAIR", amount: 1200, type: "expense" }
    ];

    const categories = [
      "Housing",
      "Groceries",
      "Utilities",
      "Transport",
      "Dining",
      "Entertainment",
      "Other"
    ];

    const keywordMap = {
      Housing: ["rent", "apartment", "mortgage"],
      Groceries: ["grocery", "grocers", "market"],
      Utilities: ["energy", "electric", "water", "internet"],
      Transport: ["metro", "transit", "fuel"],
      Dining: ["cafe", "coffee", "restaurant", "pizza"],
      Entertainment: ["stream", "cinema", "music"]
    };

    const trainingSamples = [
      { text: "city apartment rent", category: "Housing" },
      { text: "oak street mortgage", category: "Housing" },
      { text: "downtown loft lease", category: "Housing" },
      { text: "greenway grocers", category: "Groceries" },
      { text: "fresh market foods", category: "Groceries" },
      { text: "neighborhood supermarket", category: "Groceries" },
      { text: "city energy electric", category: "Utilities" },
      { text: "clear water utility", category: "Utilities" },
      { text: "home internet service", category: "Utilities" },
      { text: "metro transit pass", category: "Transport" },
      { text: "quick fuel station", category: "Transport" },
      { text: "city rideshare", category: "Transport" },
      { text: "harbor bistro dinner", category: "Dining" },
      { text: "corner cafe coffee", category: "Dining" },
      { text: "pizza restaurant", category: "Dining" },
      { text: "streambox subscription", category: "Entertainment" },
      { text: "cinema movie tickets", category: "Entertainment" },
      { text: "music club", category: "Entertainment" },
      { text: "home repair service", category: "Other" },
      { text: "pharmacy purchase", category: "Other" },
      { text: "bank fee", category: "Other" }
    ];

    let mode = "model";
    let model = trainNaiveBayes(trainingSamples);

    function tokenize(text) {
      return text.toLowerCase().match(/[a-z]+/g) || [];
    }

    function baselineCategory(text) {
      const normalized = text.toLowerCase();

      for (const [category, keywords] of Object.entries(keywordMap)) {
        if (keywords.some((keyword) => normalized.includes(keyword))) {
          return category;
        }
      }

      return "Other";
    }

    function trainNaiveBayes(samples) {
      const labels = {};
      const vocabulary = new Set();

      for (const sample of samples) {
        if (!labels[sample.category]) {
          labels[sample.category] = { documents: 0, totalWords: 0, words: {} };
        }

        labels[sample.category].documents += 1;

        for (const token of tokenize(sample.text)) {
          vocabulary.add(token);
          labels[sample.category].words[token] = (labels[sample.category].words[token] || 0) + 1;
          labels[sample.category].totalWords += 1;
        }
      }

      return {
        labels,
        vocabulary,
        sampleCount: samples.length
      };
    }

    function predictCategory(text) {
      const knownTokens = tokenize(text).filter((token) => model.vocabulary.has(token));
      const scores = [];

      for (const [category, data] of Object.entries(model.labels)) {
        let score = Math.log(data.documents / model.sampleCount);

        for (const token of knownTokens) {
          const wordCount = data.words[token] || 0;
          const probability = (wordCount + 1) / (data.totalWords + model.vocabulary.size);
          score += Math.log(probability);
        }

        scores.push({ category, score });
      }

      scores.sort((a, b) => b.score - a.score);
      const largestScore = scores[0].score;
      const relativeScores = scores.map((item) => Math.exp(item.score - largestScore));
      const relativeTotal = relativeScores.reduce((sum, value) => sum + value, 0);

      return {
        category: scores[0].category,
        confidence: relativeScores[0] / relativeTotal
      };
    }

    function classifyTransaction(transaction) {
      if (transaction.type === "income") {
        return { category: "Income", confidence: 1 };
      }

      if (mode === "baseline") {
        return { category: baselineCategory(transaction.description), confidence: null };
      }

      return predictCategory(transaction.description);
    }

    function formatMoney(amount) {
      const sign = amount < 0 ? "-" : "";
      return `${sign}$${Math.abs(amount).toFixed(2)}`;
    }

    function categoryOptions(selectedCategory) {
      return categories
        .map((category) => `<option value="${category}" ${category === selectedCategory ? "selected" : ""}>${category}</option>`)
        .join("");
    }

    function renderTransactions() {
      const rows = transactions.map((transaction) => {
        const prediction = classifyTransaction(transaction);
        const isLowConfidence = mode === "model"
          && transaction.type === "expense"
          && prediction.confidence < 0.35;
        const confidenceLabel = transaction.type === "income"
          ? "Fixed"
          : mode === "baseline"
            ? "Rule"
            : `${Math.round(prediction.confidence * 100)}%`;
        const correction = transaction.type === "income"
          ? "Fixed category"
          : `<select id="category-${transaction.id}" aria-label="Correct ${transaction.description}">
              ${categoryOptions(prediction.category)}
            </select>
            <button type="button" onclick="learnCorrection(${transaction.id})">Learn correction</button>`;
        const amountLabel = transaction.type === "income"
          ? `+${formatMoney(transaction.amount)}`
          : formatMoney(transaction.amount);

        return `<tr class="${isLowConfidence ? "low-confidence" : ""}">
          <td>Week ${transaction.week}</td>
          <td>${transaction.description}</td>
          <td class="amount ${transaction.type}">${amountLabel}</td>
          <td>${prediction.category}</td>
          <td>${confidenceLabel}</td>
          <td>${correction}</td>
        </tr>`;
      }).join("");

      document.getElementById("transactionRows").innerHTML = rows;
      document.getElementById("modeStatus").textContent = mode === "baseline"
        ? "Keyword baseline active. CEDAR BISTRO should expose the missing rule."
        : "Learned model active. Percentages are relative confidence estimates, not guarantees.";
    }

    function setMode(nextMode) {
      mode = nextMode;
      document.getElementById("feedback").textContent = "";
      renderTransactions();
    }

    function learnCorrection(transactionId) {
      const transaction = transactions.find((item) => item.id === transactionId);
      const selectedCategory = document.getElementById(`category-${transactionId}`).value;

      trainingSamples.push({
        text: transaction.description,
        category: selectedCategory
      });

      model = trainNaiveBayes(trainingSamples);
      mode = "model";
      document.getElementById("feedback").textContent = `Learned ${transaction.description} as ${selectedCategory}.`;
      renderTransactions();
    }

    renderTransactions();
  </script>
</body>
</html>

Your classifier can now learn from a human correction without sending transaction data anywhere. Next up, you will turn its categorized records into recurring-charge, anomaly, and weekly cash-flow signals.

Surface Spending Signals

Your dashboard can now learn from a correction in Safari. That gives you a classifier with a visible human review path.

Categories alone do not answer a money question. This final layer uses transparent patterns to reveal recurring charges, an unusual expense, and a projected weekly shortfall.

In this step, get ready to:
  • Detect exact recurring charges and unusual merchant amounts.
  • Calculate four weeks of cash flow from a starting balance.
  • Render every spending signal when the local dashboard loads.
Detect recurring charges and anomalies

Recurring-charge detection groups expenses with the same merchant and amount. A transparent anomaly rule compares each charge with that merchant's earlier average.

  • Switch back to Visual Studio Code.
  • In index.html, find the closing brace of learnCorrection(transactionId).
  • Add the signal detectors directly below that function by pasting this code:
function findRecurringCharges() { const groups = {}; for (const transaction of transactions.filter((item) => item.type === "expense")) { const key = `${transaction.description}|${transaction.amount}`; if (!groups[key]) groups[key] = { description: transaction.description, amount: transaction.amount, count: 0 }; groups[key].count += 1; } return Object.values(groups).filter((group) => group.count >= 2); }
function findAmountAnomalies() { const history = {}; const anomalies = []; for (const transaction of transactions.filter((item) => item.type === "expense")) { const previousAmounts = history[transaction.description] || []; if (previousAmounts.length > 0) { const previousAverage = previousAmounts.reduce((sum, amount) => sum + amount, 0) / previousAmounts.length; if (transaction.amount > previousAverage * 2) anomalies.push({ transaction, previousAverage }); } previousAmounts.push(transaction.amount); history[transaction.description] = previousAmounts; } return anomalies; }
function renderInsights() { const recurring = findRecurringCharges(); const anomalies = findAmountAnomalies(); document.getElementById("recurringList").innerHTML = `<ul>${recurring.map((item) => `<li>${item.description}: ${formatMoney(item.amount)} repeated ${item.count} times</li>`).join("")}</ul>`; document.getElementById("anomalyList").innerHTML = anomalies.length ? `<ul>${anomalies.map(({ transaction, previousAverage }) => `<li>${transaction.description} reached ${formatMoney(transaction.amount)} in Week ${transaction.week}, more than twice its earlier average of ${formatMoney(previousAverage)}.</li>`).join("")}</ul>` : "<p>No amount anomalies found.</p>"; }

What does this code do?

  • The findRecurringCharges() function builds a group for every exact merchant-and-amount pair. It returns groups that occur at least twice.
  • The findAmountAnomalies() function keeps each merchant's earlier amounts. It flags a later amount when it exceeds twice the earlier average.
  • The renderInsights() function turns both results into lists inside the existing insight panels.
  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Press Option+Command+I to open Web Inspector.
  • Select the Console tab.

Before you run the renderer, which merchants do you expect the exact-match rule to identify?

  • Populate both insight panels by running this in the Console:
renderInsights()

What should you see?

The recurring list shows CITY APARTMENT at $1100.00, METRO PASS at $50.00, and STREAMBOX at $15.00.

The anomaly panel shows CITY ENERGY reaching $260.00 in Week 4. It explains that the amount is more than twice the earlier $82.00 average.

Insight panels still empty?

  • Check that renderInsights() appears below both detector functions in index.html.
  • If Web Inspector does not open, select Settings from Safari. Select Advanced. Enable Show features for web developers.
  • Use the Console to check for a missing brace or parenthesis.
  • Help me debug my spending insight functions.
Build the weekly cash-flow plan

Weekly cash flow carries one week's ending balance into the next week's beginning balance. This makes a future shortfall visible before it arrives.

  • Return to index.html in Visual Studio Code.
  • Add the cash-flow calculation directly below renderInsights() by pasting this code:
function buildCashFlow() { let balance = 300; const weeks = []; for (let week = 1; week <= 4; week += 1) { const weeklyTransactions = transactions.filter((transaction) => transaction.week === week); const income = weeklyTransactions.filter((transaction) => transaction.type === "income").reduce((sum, transaction) => sum + transaction.amount, 0); const expenses = weeklyTransactions.filter((transaction) => transaction.type === "expense").reduce((sum, transaction) => sum + transaction.amount, 0); const beginningBalance = balance; balance = beginningBalance + income - expenses; weeks.push({ week, beginningBalance, income, expenses, endingBalance: balance }); } return weeks; }
function renderCashFlow() { document.getElementById("cashflowGrid").innerHTML = buildCashFlow().map((week) => `<article class="week-card ${week.endingBalance < 0 ? "negative" : ""}"><strong>Week ${week.week}</strong><span>Beginning: ${formatMoney(week.beginningBalance)}</span><span>Income: ${formatMoney(week.income)}</span><span>Expenses: ${formatMoney(week.expenses)}</span><span>Ending: ${formatMoney(week.endingBalance)}</span>${week.endingBalance < 0 ? '<span class="warning">Shortfall</span>' : ""}</article>`).join(""); }

How does the balance move?

  • The buildCashFlow() function begins with $300.00.
  • Each loop adds that week's income. It subtracts that week's expenses.
  • The resulting ending balance becomes the next loop's beginning balance.
  • The renderCashFlow() function creates one card per week. A negative ending balance receives the shortfall styling.
  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Return to the Web Inspector Console.

Before you run the renderer, do you expect the starting $300.00 to remain positive through all four weeks?

  • Populate the weekly cards by running this in the Console:
renderCashFlow()

What should you see?

You should see four cards with ending balances of $855.00, $692.00, $2290.00, and -$327.00.

Week 4 has a red-tinted card with the Shortfall label. The carried balance exposes the final deficit.

Cash-flow cards missing?

  • Check that buildCashFlow() starts its balance at 300.
  • Check that renderCashFlow() targets cashflowGrid with matching capitalization.
  • Help me debug my weekly cash-flow cards.
Render every signal on page load

The Console calls prove each renderer works. A single startup function now makes the completed dashboard populate automatically after every refresh.

  • Return to the bottom of the script element in index.html.
  • Find the current startup call:
renderTransactions();

What does this line do?

This startup call renders the transaction table by itself. The insight panels and cash-flow cards currently depend on the manual Console calls.

  • Replace that startup call with this combined renderer:
function renderApp() { renderTransactions(); renderInsights(); renderCashFlow(); }
renderApp();

Why use one startup function?

The renderApp() function provides one entry point for the completed dashboard. Every refresh now rebuilds the table and both spending-signal views.

Before you refresh, which exact recurring charges and final balance do you expect the completed dashboard to show?

  • Save index.html.
  • Return to Safari.
  • Refresh the local dashboard.
  • Confirm that the exact recurring charges are CITY APARTMENT, METRO PASS, and STREAMBOX.
  • Confirm that the anomaly panel identifies the Week 4 CITY ENERGY charge.
  • Confirm that the Week 4 card ends at -$327.00 with a Shortfall label.

Why keep this local?

Privacy by design keeps the synthetic transactions and calculations inside the browser. The dashboard does not send records to an API or financial account.

The educational notice also limits how you interpret the results. Categories remain estimates, while the anomaly remains a visible teaching rule.

Dashboard not filling automatically?

  • Check that the final line calls renderApp().
  • Check that renderApp() calls all three renderer functions with matching capitalization.
  • Use the Web Inspector Console to locate the first function named in any error.
  • Help me debug my dashboard startup function.

✔️ Awesome, I've got everything!

Great work. Save index.html once more so your completed local Spending Coach is ready to reopen.

ⓧ I'd like to double check the full code

Compare your index.html with the completed file below.

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Local AI Spending Coach</title>
  <style>
    :root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f4f7fb; color: #172033; }
    * { box-sizing: border-box; }
    body { margin: 0; background: #f4f7fb; }
    header { padding: 42px 24px 28px; color: white; background: linear-gradient(135deg, #173b68, #176b87); }
    header div, main, footer { width: min(1180px, calc(100% - 32px)); margin: 0 auto; }
    h1, h2, p { margin-top: 0; }
    h1 { margin-bottom: 10px; font-size: clamp(2rem, 5vw, 3.2rem); }
    header p { max-width: 760px; margin-bottom: 14px; color: #d8eff7; }
    .badge { display: inline-block; padding: 7px 11px; border: 1px solid #7dd3fc; border-radius: 999px; color: #e0f7ff; font-size: 0.88rem; }
    main { display: grid; gap: 18px; padding: 24px 0; }
    .panel { padding: 20px; border: 1px solid #dfe7f1; border-radius: 16px; background: white; box-shadow: 0 8px 24px rgba(30, 64, 100, 0.06); }
    .controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
    button, select { border: 1px solid #b9c7d8; border-radius: 9px; background: white; color: #172033; font: inherit; }
    button { padding: 9px 12px; cursor: pointer; }
    button:hover { border-color: #176b87; background: #eefaff; }
    select { max-width: 150px; padding: 7px; }
    #modeStatus { margin: 12px 0 0; color: #35506e; }
    #feedback { min-height: 24px; margin: 8px 0 0; color: #176b50; font-weight: 700; }
    .table-wrap { overflow-x: auto; }
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 12px 10px; border-bottom: 1px solid #e6edf5; text-align: left; white-space: nowrap; }
    th { color: #52657c; font-size: 0.82rem; letter-spacing: 0.05em; text-transform: uppercase; }
    .amount { font-variant-numeric: tabular-nums; font-weight: 700; }
    .income { color: #08764d; }
    .expense { color: #9a3412; }
    .low-confidence { background: #fff8dd; }
    .insights-grid, .cashflow-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
    .insight, .week-card { padding: 16px; border: 1px solid #dfe7f1; border-radius: 12px; background: #f9fbfd; }
    .insight ul { margin: 0; padding-left: 20px; }
    .insight li + li { margin-top: 8px; }
    .week-card strong, .week-card span { display: block; }
    .week-card strong { margin-bottom: 8px; }
    .week-card span { margin-top: 5px; color: #52657c; }
    .negative { border-color: #f3a18b; background: #fff1ed; }
    .warning { color: #b42318; font-weight: 800; }
    .notice { border-left: 4px solid #176b87; background: #edf9fc; }
    footer { padding: 0 0 32px; color: #607086; font-size: 0.9rem; }
    @media (max-width: 700px) { header { padding-top: 30px; } .panel { padding: 16px; } }
  </style>
</head>
<body>
  <header><div><h1>Local AI Spending Coach</h1><p>Compare a keyword baseline with a trainable transaction classifier, then inspect the financial signals it produces.</p><span class="badge">Synthetic data only. Nothing leaves this browser.</span></div></header>
  <main>
    <section class="panel"><h2>Classifier controls</h2><div class="controls"><button type="button" onclick="setMode('baseline')">Use keyword baseline</button><button type="button" onclick="setMode('model')">Use learned model</button></div><p id="modeStatus" aria-live="polite"></p><p id="feedback" aria-live="polite"></p></section>
    <section class="panel"><h2>Synthetic transactions</h2><div class="table-wrap"><table><thead><tr><th>Week</th><th>Merchant</th><th>Amount</th><th>Prediction</th><th>Confidence</th><th>Human correction</th></tr></thead><tbody id="transactionRows"></tbody></table></div></section>
    <section class="insights-grid"><article class="panel insight"><h2>Exact recurring charges</h2><div id="recurringList"></div></article><article class="panel insight"><h2>Transparent anomaly check</h2><div id="anomalyList"></div></article><article class="panel insight notice"><h2>How to read this</h2><p>Categories are model estimates. The anomaly is a visible rule, not a fraud verdict. Review the source transaction before making a decision.</p></article></section>
    <section class="panel"><h2>Four-week cash-flow plan</h2><p>Each ending balance becomes the next week’s beginning balance.</p><div id="cashflowGrid" class="cashflow-grid"></div></section>
  </main>
  <footer>Educational demonstration only. This app does not provide investment, tax, credit, or legal advice. Do not replace the synthetic records with account numbers or other sensitive information.</footer>
  <script>
    const transactions = [{ id: 1, week: 1, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },{ id: 2, week: 1, description: "CITY APARTMENT", amount: 1100, type: "expense" },{ id: 3, week: 1, description: "GREENWAY GROCERS", amount: 95, type: "expense" },{ id: 4, week: 1, description: "METRO PASS", amount: 50, type: "expense" },{ id: 5, week: 2, description: "CITY ENERGY", amount: 82, type: "expense" },{ id: 6, week: 2, description: "STREAMBOX", amount: 15, type: "expense" },{ id: 7, week: 2, description: "CEDAR BISTRO", amount: 38, type: "expense" },{ id: 8, week: 2, description: "MIDTOWN HUB", amount: 28, type: "expense" },{ id: 9, week: 3, description: "PAYROLL DEPOSIT", amount: 1800, type: "income" },{ id: 10, week: 3, description: "GREENWAY GROCERS", amount: 115, type: "expense" },{ id: 11, week: 3, description: "METRO PASS", amount: 50, type: "expense" },{ id: 12, week: 3, description: "STREAMBOX", amount: 15, type: "expense" },{ id: 13, week: 3, description: "PINE CLUB", amount: 22, type: "expense" },{ id: 14, week: 4, description: "CITY APARTMENT", amount: 1100, type: "expense" },{ id: 15, week: 4, description: "CEDAR BISTRO", amount: 42, type: "expense" },{ id: 16, week: 4, description: "CITY ENERGY", amount: 260, type: "expense" },{ id: 17, week: 4, description: "STREAMBOX", amount: 15, type: "expense" },{ id: 18, week: 4, description: "HOME REPAIR", amount: 1200, type: "expense" }];
    const categories = ["Housing", "Groceries", "Utilities", "Transport", "Dining", "Entertainment", "Other"];
    const keywordMap = { Housing: ["rent", "apartment", "mortgage"], Groceries: ["grocery", "grocers", "market"], Utilities: ["energy", "electric", "water", "internet"], Transport: ["metro", "transit", "fuel"], Dining: ["cafe", "coffee", "restaurant", "pizza"], Entertainment: ["stream", "cinema", "music"] };
    const trainingSamples = [{ text: "city apartment rent", category: "Housing" },{ text: "oak street mortgage", category: "Housing" },{ text: "downtown loft lease", category: "Housing" },{ text: "greenway grocers", category: "Groceries" },{ text: "fresh market foods", category: "Groceries" },{ text: "neighborhood supermarket", category: "Groceries" },{ text: "city energy electric", category: "Utilities" },{ text: "clear water utility", category: "Utilities" },{ text: "home internet service", category: "Utilities" },{ text: "metro transit pass", category: "Transport" },{ text: "quick fuel station", category: "Transport" },{ text: "city rideshare", category: "Transport" },{ text: "harbor bistro dinner", category: "Dining" },{ text: "corner cafe coffee", category: "Dining" },{ text: "pizza restaurant", category: "Dining" },{ text: "streambox subscription", category: "Entertainment" },{ text: "cinema movie tickets", category: "Entertainment" },{ text: "music club", category: "Entertainment" },{ text: "home repair service", category: "Other" },{ text: "pharmacy purchase", category: "Other" },{ text: "bank fee", category: "Other" }];
    let mode = "model";
    let model = trainNaiveBayes(trainingSamples);
    function tokenize(text) { return text.toLowerCase().match(/[a-z]+/g) || []; }
    function baselineCategory(text) { const normalized = text.toLowerCase(); for (const [category, keywords] of Object.entries(keywordMap)) { if (keywords.some((keyword) => normalized.includes(keyword))) return category; } return "Other"; }
    function trainNaiveBayes(samples) { const labels = {}; const vocabulary = new Set(); for (const sample of samples) { if (!labels[sample.category]) labels[sample.category] = { documents: 0, totalWords: 0, words: {} }; labels[sample.category].documents += 1; for (const token of tokenize(sample.text)) { vocabulary.add(token); labels[sample.category].words[token] = (labels[sample.category].words[token] || 0) + 1; labels[sample.category].totalWords += 1; } } return { labels, vocabulary, sampleCount: samples.length }; }
    function predictCategory(text) { const knownTokens = tokenize(text).filter((token) => model.vocabulary.has(token)); const scores = []; for (const [category, data] of Object.entries(model.labels)) { let score = Math.log(data.documents / model.sampleCount); for (const token of knownTokens) { const wordCount = data.words[token] || 0; score += Math.log((wordCount + 1) / (data.totalWords + model.vocabulary.size)); } scores.push({ category, score }); } scores.sort((a, b) => b.score - a.score); const largestScore = scores[0].score; const relativeScores = scores.map((item) => Math.exp(item.score - largestScore)); const relativeTotal = relativeScores.reduce((sum, value) => sum + value, 0); return { category: scores[0].category, confidence: relativeScores[0] / relativeTotal }; }
    function classifyTransaction(transaction) { if (transaction.type === "income") return { category: "Income", confidence: 1 }; return mode === "baseline" ? { category: baselineCategory(transaction.description), confidence: null } : predictCategory(transaction.description); }
    function formatMoney(amount) { const sign = amount < 0 ? "-" : ""; return `${sign}$${Math.abs(amount).toFixed(2)}`; }
    function categoryOptions(selectedCategory) { return categories.map((category) => `<option value="${category}" ${category === selectedCategory ? "selected" : ""}>${category}</option>`).join(""); }
    function renderTransactions() { const rows = transactions.map((transaction) => { const prediction = classifyTransaction(transaction); const isLowConfidence = mode === "model" && transaction.type === "expense" && prediction.confidence < 0.35; const confidenceLabel = transaction.type === "income" ? "Fixed" : mode === "baseline" ? "Rule" : `${Math.round(prediction.confidence * 100)}%`; const correction = transaction.type === "income" ? "Fixed category" : `<select id="category-${transaction.id}" aria-label="Correct ${transaction.description}">${categoryOptions(prediction.category)}</select><button type="button" onclick="learnCorrection(${transaction.id})">Learn correction</button>`; const amountLabel = transaction.type === "income" ? `+${formatMoney(transaction.amount)}` : formatMoney(transaction.amount); return `<tr class="${isLowConfidence ? "low-confidence" : ""}"><td>Week ${transaction.week}</td><td>${transaction.description}</td><td class="amount ${transaction.type}">${amountLabel}</td><td>${prediction.category}</td><td>${confidenceLabel}</td><td>${correction}</td></tr>`; }).join(""); document.getElementById("transactionRows").innerHTML = rows; document.getElementById("modeStatus").textContent = mode === "baseline" ? "Keyword baseline active. CEDAR BISTRO should expose the missing rule." : "Learned model active. Percentages are relative confidence estimates, not guarantees."; }
    function setMode(nextMode) { mode = nextMode; document.getElementById("feedback").textContent = ""; renderTransactions(); }
    function learnCorrection(transactionId) { const transaction = transactions.find((item) => item.id === transactionId); const selectedCategory = document.getElementById(`category-${transactionId}`).value; trainingSamples.push({ text: transaction.description, category: selectedCategory }); model = trainNaiveBayes(trainingSamples); mode = "model"; document.getElementById("feedback").textContent = `Learned ${transaction.description} as ${selectedCategory}.`; renderTransactions(); }
    function findRecurringCharges() { const groups = {}; for (const transaction of transactions.filter((item) => item.type === "expense")) { const key = `${transaction.description}|${transaction.amount}`; if (!groups[key]) groups[key] = { description: transaction.description, amount: transaction.amount, count: 0 }; groups[key].count += 1; } return Object.values(groups).filter((group) => group.count >= 2); }
    function findAmountAnomalies() { const history = {}; const anomalies = []; for (const transaction of transactions.filter((item) => item.type === "expense")) { const previousAmounts = history[transaction.description] || []; if (previousAmounts.length > 0) { const previousAverage = previousAmounts.reduce((sum, amount) => sum + amount, 0) / previousAmounts.length; if (transaction.amount > previousAverage * 2) anomalies.push({ transaction, previousAverage }); } previousAmounts.push(transaction.amount); history[transaction.description] = previousAmounts; } return anomalies; }
    function buildCashFlow() { let balance = 300; const weeks = []; for (let week = 1; week <= 4; week += 1) { const weeklyTransactions = transactions.filter((transaction) => transaction.week === week); const income = weeklyTransactions.filter((transaction) => transaction.type === "income").reduce((sum, transaction) => sum + transaction.amount, 0); const expenses = weeklyTransactions.filter((transaction) => transaction.type === "expense").reduce((sum, transaction) => sum + transaction.amount, 0); const beginningBalance = balance; balance = beginningBalance + income - expenses; weeks.push({ week, beginningBalance, income, expenses, endingBalance: balance }); } return weeks; }
    function renderInsights() { const recurring = findRecurringCharges(); const anomalies = findAmountAnomalies(); document.getElementById("recurringList").innerHTML = `<ul>${recurring.map((item) => `<li>${item.description}: ${formatMoney(item.amount)} repeated ${item.count} times</li>`).join("")}</ul>`; document.getElementById("anomalyList").innerHTML = anomalies.length ? `<ul>${anomalies.map(({ transaction, previousAverage }) => `<li>${transaction.description} reached ${formatMoney(transaction.amount)} in Week ${transaction.week}, more than twice its earlier average of ${formatMoney(previousAverage)}.</li>`).join("")}</ul>` : "<p>No amount anomalies found.</p>"; }
    function renderCashFlow() { document.getElementById("cashflowGrid").innerHTML = buildCashFlow().map((week) => `<article class="week-card ${week.endingBalance < 0 ? "negative" : ""}"><strong>Week ${week.week}</strong><span>Beginning: ${formatMoney(week.beginningBalance)}</span><span>Income: ${formatMoney(week.income)}</span><span>Expenses: ${formatMoney(week.expenses)}</span><span>Ending: ${formatMoney(week.endingBalance)}</span>${week.endingBalance < 0 ? '<span class="warning">Shortfall</span>' : ""}</article>`).join(""); }
    function renderApp() { renderTransactions(); renderInsights(); renderCashFlow(); }
    renderApp();
  </script>
</body>
</html>

You have turned local transaction records into explainable financial signals. Your dashboard now reveals repeated charges, a transparent anomaly, and a cash-flow shortfall without sharing financial data.

Secret mission

Audit the Model Like a Financial AI Reviewer

Stress-test three ambiguous merchants across both classifier modes. Record two retraining results before defining when this financial AI must defer to human review.

Clean Up Your Resources

Clean Up Your Resources

Your local dashboard has no ongoing costs. Decide whether to keep the project files, set them aside for later, or delete them.

Resources you used:

  • The ai-spending-coach folder containing the completed index.html dashboard.
  • The written model audit record containing prediction comparisons, correction results, and your mandatory human-review rule.

Keep everything running

No action is needed. Choose this option if you want to keep testing predictions or reviewing your financial AI audit.

  • Keep the dashboard available in Safari for more classification experiments.
  • Keep the ai-spending-coach folder open in Visual Studio Code for future changes.
  • Keep your written model audit record as evidence of the threshold decisions you made.

Pause - I'll come back to this later

Close the local project while preserving every file for later. No background process or cloud resource needs to be stopped.

  • Close the Safari tab that displays index.html.
  • Close the ai-spending-coach project window in Visual Studio Code.
  • Leave the ai-spending-coach folder on your Mac.
  • Retain your written model audit record for your next review session.

Delete - I don't want to use this again

Deleting the folder removes the working dashboard. Clearing the deleted files makes that removal permanent.

  • Close the Safari tab that displays index.html.
  • Close the ai-spending-coach project window in Visual Studio Code.
  • Delete the ai-spending-coach folder from the location where you created it.
  • Delete the written model audit record from the location where you saved it.
  • Clear the deleted project items permanently from your Mac.

Nice Work!

Nice Work!

You did it! Your local AI Spending Coach turns synthetic transactions into an explainable view of spending.

You've learned how to:

  • Build a local Spending Coach that compares a transparent keyword baseline with learned categories. Keep every synthetic transaction inside the browser through privacy by design.
  • Implement a multinomial naive Bayes classifier from first principles. Use word counts with Laplace smoothing to produce relative confidence estimates. Add human-in-the-loop correction so each new label retrains the model immediately.
  • Surface recurring charges from exact merchant-amount matches. Explain the unusual CITY ENERGY charge with a visible rule. Carry the balance through a four-week cash-flow plan until Week 4 shows -$327.00 with Shortfall.
  • Complete a Secret Mission by auditing the model across both classifier modes. Record two before-and-after corrections. Define mandatory human review for confidence below 35%. Require the same review when merchant text lacks enough context.

Ready to quiz yourself?