Skip to content

Instantly share code, notes, and snippets.

@dramaticlly
Created November 24, 2025 06:41
Show Gist options
  • Select an option

  • Save dramaticlly/8187f4fd739504f0af006cd36b710872 to your computer and use it in GitHub Desktop.

Select an option

Save dramaticlly/8187f4fd739504f0af006cd36b710872 to your computer and use it in GitHub Desktop.
Visualization of Costco Spend

Costco Receipt Tracker

This tool helps you download all your past Costco receipts and view them in a simple dashboard.

🛠 What You’ll Need

  • A computer with Google Chrome (recommended).
  • Your Costco.com account login.
  • The files provided:
    • download_costco_receipts.js
    • CostcoTracker.html

📋 Step-by-Step Instructions

  1. Log in to Costco.com

    • Go to Costco Order Status.
    • Sign in with your Costco account.
    • You should now see your list of past orders.
  2. Open the Developer Tools

    • On the orders page, right‑click anywhere on the screen.
    • From the menu, choose Inspect.
    • A panel will open (this is called Developer Tools).
  3. Run the Receipt Downloader

    • In the Developer Tools panel, click the Console tab.
    • Open the file download_costco_receipts.js (provided to you).
    • Copy all the text inside that file.
    • Paste it into the Console window.
    • Press Enter to run it.
    • Note: Chrome may ask you to allow pasting — Type "allow pasting" and press Enter if promotped
  4. Get Your Receipts

    • The script will automatically download all your past Costco receipts.
    • They will be saved together in one file ending with .json.
    • This file contains all the receipt information.
  5. Open Your Dashboard

    • Double‑click the file CostcoTracker.html to open it in your browser.
    • Inside the dashboard, click Upload Data (or similar button).
    • Select the .json file you just downloaded.
  6. 🎉 Enjoy Your Personal Costco Dashboard

    • You can now browse your purchase history in a clean, easy‑to‑read format.
    • Think of it as your own Costco spending tracker!

💡 Tips

  • If something doesn’t work, try refreshing the page and repeating the steps.
  • This process only works on a computer (not on a phone or tablet).
  • Don’t worry if the Developer Tools look complicated — you only need the Console tab.

(bonus: guess when I switched from a germany sedan to a hybrid japanese SUV) image


Credit

  1. https://www.reddit.com/r/Costco/comments/1p549mw/high_earner_single_household_enhanced_costco/
  2. https://www.reddit.com/r/Costco/comments/1p4w903/comment/nqf1r4c/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
async function listGasReceipts(startDate, endDate) {
return await new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.responseType = 'json';
xhr.open('POST', 'https://ecom-api.costco.com/ebusiness/order/v1/orders/graphql');
xhr.setRequestHeader('Content-Type', 'application/json-patch+json');
xhr.setRequestHeader('Costco.Env', 'ecom');
xhr.setRequestHeader('Costco.Service', 'restOrders');
xhr.setRequestHeader('Costco-X-Wcs-Clientid', localStorage.getItem('clientID'));
xhr.setRequestHeader('Client-Identifier', '481b1aec-aa3b-454b-b81b-48187e28f205');
xhr.setRequestHeader('Costco-X-Authorization', 'Bearer ' + localStorage.getItem('idToken'));
const gasReceiptsQuery = {
"query": `
query receiptsWithCounts($startDate: String!, $endDate: String!) {
receiptsWithCounts(startDate: $startDate, endDate: $endDate) {
receipts {
warehouseName
receiptType
documentType
transactionDateTime
transactionDate
companyNumber
warehouseNumber
operatorNumber
warehouseShortName
registerNumber
transactionNumber
transactionType
transactionBarcode
total
warehouseAddress1
warehouseAddress2
warehouseCity
warehouseState
warehouseCountry
warehousePostalCode
totalItemCount
subTotal
taxes
invoiceNumber
sequenceNumber
itemArray {
itemNumber
itemDescription01
frenchItemDescription1
itemDescription02
frenchItemDescription2
itemIdentifier
itemDepartmentNumber
unit
amount
taxFlag
merchantID
entryMethod
transDepartmentNumber
fuelUnitQuantity
fuelGradeCode
itemUnitPriceAmount
fuelUomCode
fuelUomDescription
fuelUomDescriptionFr
fuelGradeDescription
fuelGradeDescriptionFr
}
tenderArray {
tenderTypeCode
tenderSubTypeCode
tenderDescription
amountTender
displayAccountNumber
sequenceNumber
approvalNumber
responseCode
tenderTypeName
transactionID
merchantID
entryMethod
tenderAcctTxnNumber
tenderAuthorizationCode
tenderTypeNameFr
tenderEntryMethodDescription
walletType
walletId
storedValueBucket
}
subTaxes {
tax1
tax2
tax3
tax4
aTaxPercent
aTaxLegend
aTaxAmount
bTaxPercent
bTaxLegend
bTaxAmount
cTaxPercent
cTaxLegend
cTaxAmount
dTaxAmount
}
instantSavings
membershipNumber
}
}
}`.replace(/\s+/g,' '),
"variables": {
"startDate": startDate,
"endDate": endDate
}
};
xhr.onload = function() {
if (xhr.status === 200) {
resolve(xhr.response.data.receiptsWithCounts.receipts);
} else {
reject(xhr.status);
}
};
xhr.onerror = function() {
reject('Network Error');
};
xhr.send(JSON.stringify(gasReceiptsQuery));
});
}
async function downloadGasReceipts() {
var startDateStr = '01/01/2020';
var endDate = new Date();
var endDateStr = endDate.toLocaleDateString('en-US', {
year: "numeric",
month: "2-digit",
day: "2-digit"
});
var receipts = await listGasReceipts(startDateStr, endDateStr);
console.log(`Got ${receipts.length} gas receipts, saving.`);
var a = document.createElement('a');
a.download = `costco-gas-${endDate.toISOString()}.json`;
a.href = window.URL.createObjectURL(new Blob([JSON.stringify(receipts, null, 2)], {type: 'text/plain'}));
a.target = '_blank';
document.body.appendChild(a);
a.click();
}
await downloadGasReceipts();
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Costco Receipt Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Chart.js Library -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
:root {
--bg: #f5f7fb;
--card-bg: #ffffff;
--accent: #2563eb;
--accent-soft: #e0edff;
--gas-accent: #dc2626;
--gas-soft: #fee2e2;
--gold: #d97706;
--gold-soft: #fef3c7;
--refund-accent: #7c3aed;
--refund-soft: #f3e8ff;
--text-main: #111827;
--text-muted: #6b7280;
--border: #e5e7eb;
--success: #16a34a;
--danger: #ef4444;
--radius-lg: 18px;
--shadow-soft: 0 10px 25px rgba(15, 23, 42, 0.05);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text-main);
padding-bottom: 60px;
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 24px 16px;
}
/* HEADER */
header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 30px;
}
header h1 {
margin: 0;
font-size: 1.8rem;
display: flex;
align-items: center;
gap: 10px;
}
.date-badge {
background: #e5e7eb;
color: var(--text-main);
font-size: 0.9rem;
padding: 4px 12px;
border-radius: 12px;
font-weight: 500;
}
/* UPLOADER */
.uploader {
padding: 10px 16px;
background: var(--card-bg);
border-radius: 999px;
border: 1px solid var(--border);
box-shadow: var(--shadow-soft);
display: flex;
align-items: center;
gap: 12px;
font-size: 0.9rem;
}
.status {
font-size: 0.85rem;
color: var(--text-muted);
margin-top: 6px;
text-align: right;
}
/* SECTIONS */
.section-header {
font-size: 1.4rem;
font-weight: 700;
margin: 40px 0 16px;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 8px;
border-bottom: 2px solid var(--border);
}
/* GRIDS */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
/* CARDS */
.summary-card {
background: var(--card-bg);
border-radius: var(--radius-lg);
padding: 16px;
box-shadow: var(--shadow-soft);
border: 1px solid var(--border);
}
.summary-card.gold { border-left: 4px solid var(--gold); }
.summary-card.gas { border-left: 4px solid var(--gas-accent); }
.summary-card.refund { border-left: 4px solid var(--refund-accent); }
.summary-card .label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
font-weight: 600;
}
.summary-card .value {
margin-top: 6px;
font-size: 1.4rem;
font-weight: 700;
}
.summary-card .sub {
margin-top: 4px;
font-size: 0.8rem;
color: var(--text-muted);
}
.card {
background: var(--card-bg);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow-soft);
border: 1px solid var(--border);
}
.card.gas-container { border-top: 4px solid var(--gas-accent); }
.card h2 {
margin: 0 0 4px;
font-size: 1.1rem;
display: flex;
align-items: center;
gap: 8px;
}
.card .subtitle {
margin: 0 0 16px;
font-size: 0.85rem;
color: var(--text-muted);
}
/* TABLES */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
thead { background: var(--accent-soft); }
.gold-table thead { background: var(--gold-soft); color: #92400e; }
th, td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--border);
}
th:first-child, td:first-child { width: 1%; white-space: nowrap; }
th:last-child, td:last-child { text-align: right; }
tbody tr:nth-child(even) { background: #f9fafb; }
.money { font-variant-numeric: tabular-nums; font-weight: 600; }
.pos { color: var(--success); }
.neg { color: var(--danger); }
.chart-wrapper {
position: relative;
height: 300px;
width: 100%;
}
@media (max-width: 640px) {
.uploader { width: 100%; flex-wrap: wrap; justify-content: center; }
.date-badge { margin-top: 5px; }
}
</style>
</head>
<body>
<div class="page">
<header>
<div>
<h1>
<span class="icon">🧾</span>Enhanced Costco Analyzer
<span id="dateRangeTitle" class="date-badge">Waiting for file...</span>
</h1>
<p>Upload your JSON receipts to see spending insights.</p>
</div>
<div>
<div class="uploader">
<label for="fileInput">Select JSON:</label>
<input id="fileInput" type="file" accept="application/json,.json" />
</div>
<div id="status" class="status"></div>
</div>
</header>
<!-- MERCHANDISE SECTION -->
<div class="section-header"><span class="icon">🛒</span> Warehouse Merchandise</div>
<section class="summary-grid">
<div class="summary-card">
<div class="label">Net Spent (Merch)</div>
<div class="value" id="totalSpent">–</div>
<div class="sub">Purchases - Refunds</div>
</div>
<div class="summary-card">
<div class="label">Net Items</div>
<div class="value" id="totalPurchases">–</div>
<div class="sub">Unique Codes: <span id="uniqueItems">0</span></div>
</div>
<div class="summary-card">
<div class="label">Merch Visits</div>
<div class="value" id="receiptCount">–</div>
<div class="sub">Avg <span id="avgPerReceipt"></span> / trip</div>
</div>
<div class="summary-card refund">
<div class="label" style="color:var(--refund-accent)">Total Refunded</div>
<div class="value" id="refundTotal" style="color:var(--refund-accent)">–</div>
<div class="sub">Returns Count: <span id="refundCount">0</span></div>
</div>
<div class="summary-card">
<div class="label">Merch Locations</div>
<div class="value" id="uniqueWarehouses">–</div>
<div class="sub">Unique Warehouses</div>
</div>
<div class="summary-card gold">
<div class="label" style="color:#b45309">Est. Executive Reward</div>
<div class="value" id="totalRewards" style="color:#b45309">–</div>
<div class="sub">2% of Annual Subtotals</div>
</div>
</section>
<section class="grid-2">
<div class="card">
<h2><span class="icon">🏆</span> 2% Reward Tracker</h2>
<p class="subtitle">Estimated cashback based on annual pre-tax merchandise subtotals.</p>
<table class="gold-table">
<thead>
<tr>
<th>Year</th>
<th>Qualifying Spend</th>
<th>2% Cashback</th>
</tr>
</thead>
<tbody id="rewardsBody"></tbody>
</table>
</div>
<div class="card">
<h2><span class="icon">📊</span> Merchandise Spending Trend</h2>
<p class="subtitle">Total spent per month (Tax included).</p>
<div class="chart-wrapper">
<canvas id="merchChart"></canvas>
</div>
</div>
</section>
<!-- NEW ITEM LISTS -->
<section class="grid-3">
<div class="card">
<h2><span class="icon">💎</span> Most Expensive</h2>
<p class="subtitle">Items with highest recorded price.</p>
<table>
<thead>
<tr><th>Item</th><th>Max Price</th><th>Avg</th></tr>
</thead>
<tbody id="mostExpensiveBody"></tbody>
</table>
</div>
<div class="card">
<h2><span class="icon">📈</span> Price Increases</h2>
<p class="subtitle">Latest price > First price recorded.</p>
<table>
<thead>
<tr><th>Item</th><th>Old &rarr; New</th><th>Diff</th></tr>
</thead>
<tbody id="priceIncreaseBody"></tbody>
</table>
</div>
<div class="card">
<h2><span class="icon">📉</span> Price Drops</h2>
<p class="subtitle">Latest price < First price recorded.</p>
<table>
<thead>
<tr><th>Item</th><th>Old &rarr; New</th><th>Diff</th></tr>
</thead>
<tbody id="priceDecreaseBody"></tbody>
</table>
</div>
</section>
<section class="grid-2">
<div class="card">
<h2><span class="icon">💰</span> Most Total Spent</h2>
<p class="subtitle">Highest cumulative spend items.</p>
<table>
<thead>
<tr>
<th>#</th><th>Item</th><th>Total</th><th>Cnt</th><th>Avg</th>
</tr>
</thead>
<tbody id="mostTotalSpentBody"></tbody>
</table>
</div>
<div class="card">
<h2><span class="icon">🔥</span> Most Frequently Bought</h2>
<p class="subtitle">Items purchased most often.</p>
<table>
<thead>
<tr>
<th>#</th><th>Item</th><th>Cnt</th><th>Avg</th><th>Change</th>
</tr>
</thead>
<tbody id="mostPurchasedBody"></tbody>
</table>
</div>
</section>
<!-- GAS SECTION -->
<div class="section-header" style="color:#b91c1c; border-color:#fecaca;">
<span class="icon">⛽</span> Gas Station Stats
</div>
<section class="summary-grid">
<div class="summary-card gas">
<div class="label">Total Gas Spent</div>
<div class="value" id="gasTotalSpent">–</div>
</div>
<div class="summary-card">
<div class="label">Total Gallons</div>
<div class="value" id="gasTotalGallons">–</div>
</div>
<div class="summary-card">
<div class="label">Avg Price/Gal</div>
<div class="value" id="gasAvgPrice">–</div>
<div class="sub">Weighted Average</div>
</div>
<div class="summary-card">
<div class="label">Gas Visits</div>
<div class="value" id="gasVisits">–</div>
</div>
<div class="summary-card">
<div class="label">Gas Locations</div>
<div class="value" id="gasLocationsCount">–</div>
<div class="sub">Unique Stations</div>
</div>
</section>
<section class="grid-2">
<div class="card gas-container">
<h2><span class="icon">📉</span> Price Per Gallon History</h2>
<p class="subtitle">Average price paid per month by grade.</p>
<div class="chart-wrapper">
<canvas id="gasPriceHistoryChart"></canvas>
</div>
</div>
<div class="card gas-container">
<h2><span class="icon">💳</span> Gas Spend by Grade</h2>
<p class="subtitle">Total monthly spend split by fuel type.</p>
<div class="chart-wrapper">
<canvas id="gasSpendBreakdownChart"></canvas>
</div>
</div>
</section>
<section class="card gas-container" style="margin-top:20px;">
<h2><span class="icon">📊</span> Total Monthly Gas Spending</h2>
<p class="subtitle">Combined fuel expenses over time.</p>
<div class="chart-wrapper">
<canvas id="gasChart"></canvas>
</div>
</section>
<section class="summary-grid" style="margin-top: 40px; opacity: 0.9;">
<div class="summary-card">
<div class="label">Total Unique Locations</div>
<div class="value" id="totalAllLocations">–</div>
<div class="sub">Gas + Merchandise combined</div>
</div>
</section>
</div>
<script>
// --- 1. CONFIGURATION & FORMATTERS ---
const currencyFormatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const numFormatter = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 });
function formatMoney(v) { return currencyFormatter.format(v || 0); }
function formatNum(v) { return numFormatter.format(v || 0); }
function getMonthAbbr(dateObj) {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const y = dateObj.getFullYear().toString().slice(-2);
return `${months[dateObj.getMonth()]} '${y}`;
}
// Helper to generate "YYYY-MM" keys for sorting
function getSortableMonthKey(dateObj) {
const y = dateObj.getFullYear();
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
return `${y}-${m}`;
}
// Global chart instances
let merchChartInstance = null;
let gasChartInstance = null;
let gasPriceChartInstance = null;
let gasSpendBreakdownInstance = null;
// --- 2. DATA PROCESSING ---
function processData(receipts) {
// Sort receipts by date ascending
receipts.sort((a, b) => new Date(a.transactionDate) - new Date(b.transactionDate));
// Determine Date Range
const dates = receipts.map(r => new Date(r.transactionDate));
const minDate = new Date(Math.min(...dates));
const maxDate = new Date(Math.max(...dates));
const dateRangeStr = `${minDate.getFullYear()} - ${maxDate.getFullYear()}`;
// Data Containers
const merch = {
receipts: [],
itemStats: new Map(),
monthly: new Map(),
subtotalByYear: new Map(),
totalSpent: 0,
totalUnits: 0,
refundCount: 0,
refundTotal: 0,
locations: new Set()
};
const gas = {
receipts: [],
monthly: new Map(),
totalSpent: 0,
totalGallons: 0,
priceSum: 0,
count: 0,
locations: new Set()
};
const allLocations = new Set();
receipts.forEach(r => {
const date = new Date(r.transactionDate);
const monthKey = getSortableMonthKey(date);
const year = date.getFullYear();
const locName = (r.warehouseName || "Unknown").trim();
const total = Number(r.total) || 0;
allLocations.add(locName);
// Identify Gas Receipts (Type or Item Codes 800877/800599)
const isGas = r.receiptType === "Gas Station" ||
r.documentType === "FuelReceipts" ||
(r.itemArray && r.itemArray.some(i => ["800877", "800599"].includes(i.itemNumber)));
if (isGas) {
// GAS LOGIC
gas.receipts.push(r);
gas.locations.add(locName);
gas.totalSpent += total;
gas.count++;
// Initialize monthly map if needed
if (!gas.monthly.has(monthKey)) gas.monthly.set(monthKey, { spent: 0 });
gas.monthly.get(monthKey).spent += total;
// Process Gas Items (Gallons, Price)
if (r.itemArray) {
r.itemArray.forEach(item => {
const gals = Number(item.fuelUnitQuantity) || 0;
const price = Number(item.itemUnitPriceAmount) || 0;
if (gals > 0) {
gas.totalGallons += gals;
gas.priceSum += (price * gals); // Weighted sum for avg calc
}
});
}
} else {
// MERCH LOGIC
merch.receipts.push(r);
merch.locations.add(locName);
// Net Spend Logic
merch.totalSpent += total;
// Refund Tracking
if (total < 0 || r.transactionType === "Refund") {
merch.refundCount++;
merch.refundTotal += Math.abs(total);
}
// Subtotal Logic (Pre-tax, for rewards)
const subTotal = Number(r.subTotal) || 0;
merch.subtotalByYear.set(year, (merch.subtotalByYear.get(year) || 0) + subTotal);
if (!merch.monthly.has(monthKey)) merch.monthly.set(monthKey, { spent: 0 });
merch.monthly.get(monthKey).spent += total;
// Item Stats
if (r.itemArray) {
r.itemArray.forEach(item => {
const unit = Number(item.unit) || 0;
const amount = Number(item.amount) || 0;
if (!item.itemNumber || unit <= 0 || amount <= 0) return;
// Item Key
const key = item.itemNumber;
const name = (item.itemDescription01 || "").trim();
const unitPrice = amount / unit;
if (!merch.itemStats.has(key)) {
merch.itemStats.set(key, {
name, itemNumber: key,
totalSpent: 0,
totalUnits: 0,
purchases: 0,
priceHistory: [] // Array of {date, price}
});
}
const stat = merch.itemStats.get(key);
stat.totalSpent += amount;
stat.totalUnits += unit;
merch.totalUnits += unit;
// Only track positive purchases as "Visits" and "Price History"
// We exclude returns from price history to avoid skewed min/max stats
if (unit > 0 && amount > 0) {
stat.purchases += 1;
stat.priceHistory.push({ date: date, price: unitPrice });
}
});
}
}
});
// Post-process itemStats to sort price history
merch.itemStats.forEach(stat => {
stat.priceHistory.sort((a, b) => a.date - b.date);
if (stat.priceHistory.length > 0) {
stat.firstPrice = stat.priceHistory[0].price;
stat.lastPrice = stat.priceHistory[stat.priceHistory.length - 1].price;
stat.maxPrice = Math.max(...stat.priceHistory.map(p => p.price));
stat.minPrice = Math.min(...stat.priceHistory.map(p => p.price));
} else {
stat.firstPrice = 0; stat.lastPrice = 0; stat.maxPrice = 0; stat.minPrice = 0;
}
});
return { merch, gas, allLocations, dateRangeStr };
}
// --- 3. RENDERING DASHBOARD ---
function renderDashboard(data) {
const { merch, gas, allLocations, dateRangeStr } = data;
// Update Header
document.getElementById("dateRangeTitle").textContent = dateRangeStr;
// --- MERCH STATS ---
document.getElementById("totalSpent").textContent = formatMoney(merch.totalSpent);
document.getElementById("totalPurchases").textContent = formatNum(merch.totalUnits);
document.getElementById("uniqueItems").textContent = formatNum(merch.itemStats.size);
document.getElementById("receiptCount").textContent = formatNum(merch.receipts.length);
document.getElementById("uniqueWarehouses").textContent = merch.locations.size;
document.getElementById("refundCount").textContent = formatNum(merch.refundCount);
document.getElementById("refundTotal").textContent = formatMoney(merch.refundTotal);
const avgPerReceipt = merch.receipts.length ? merch.totalSpent / merch.receipts.length : 0;
document.getElementById("avgPerReceipt").textContent = formatMoney(avgPerReceipt);
// --- EXECUTIVE REWARDS (2%) ---
const rewardsBody = document.getElementById("rewardsBody");
rewardsBody.innerHTML = "";
let grandTotalRewards = 0;
const years = Array.from(merch.subtotalByYear.keys()).sort((a,b) => b - a);
years.forEach(year => {
const sub = merch.subtotalByYear.get(year);
const reward = sub * 0.02;
grandTotalRewards += reward;
rewardsBody.innerHTML += `
<tr>
<td><strong>${year}</strong></td>
<td>${formatMoney(sub)}</td>
<td class="money pos">+${formatMoney(reward)}</td>
</tr>
`;
});
document.getElementById("totalRewards").textContent = formatMoney(grandTotalRewards);
// --- TABLES ---
renderMerchTables(merch.itemStats);
// --- GAS STATS ---
document.getElementById("gasTotalSpent").textContent = formatMoney(gas.totalSpent);
document.getElementById("gasTotalGallons").textContent = formatNum(gas.totalGallons) + " gal";
const weightedAvgPrice = gas.totalGallons > 0 ? (gas.priceSum / gas.totalGallons) : 0;
document.getElementById("gasAvgPrice").textContent = formatMoney(weightedAvgPrice);
document.getElementById("gasVisits").textContent = gas.count;
document.getElementById("gasLocationsCount").textContent = gas.locations.size;
document.getElementById("totalAllLocations").textContent = allLocations.size;
// --- CHART 1: MERCH SPEND ---
renderSimpleBarChart("merchChart", merch.monthly, "Merchandise Spend ($)", "#2563eb", merchChartInstance, (i)=>merchChartInstance=i);
// --- CHART 2: TOTAL GAS SPEND ---
renderSimpleBarChart("gasChart", gas.monthly, "Total Gas Spend ($)", "#dc2626", gasChartInstance, (i)=>gasChartInstance=i);
// --- GAS SPECIFIC LOGIC FOR GRADE SPLIT ---
const gasGrades = new Map(); // Key: "YYYY-MM" -> { pSpend:0, pGal:0, rSpend:0, rGal:0 }
gas.receipts.forEach(r => {
const monthKey = getSortableMonthKey(new Date(r.transactionDate));
if (!gasGrades.has(monthKey)) {
gasGrades.set(monthKey, { pSpend: 0, pGal: 0, rSpend: 0, rGal: 0 });
}
const entry = gasGrades.get(monthKey);
if(r.itemArray) {
r.itemArray.forEach(item => {
const amt = Number(item.amount) || 0;
const gal = Number(item.fuelUnitQuantity) || 0;
if (item.itemNumber === "800877") { // Premium
entry.pSpend += amt;
entry.pGal += gal;
} else if (item.itemNumber === "800599") { // Regular
entry.rSpend += amt;
entry.rGal += gal;
}
});
}
});
// Prepare Data for Gas Breakdown Charts
const sortedGasKeys = Array.from(gasGrades.keys()).sort();
const gasLabels = sortedGasKeys.map(k => {
const [y, m] = k.split('-');
return getMonthAbbr(new Date(y, m - 1));
});
const pPrices = [];
const rPrices = [];
const pSpends = [];
const rSpends = [];
sortedGasKeys.forEach(k => {
const d = gasGrades.get(k);
pPrices.push(d.pGal > 0 ? d.pSpend / d.pGal : null);
rPrices.push(d.rGal > 0 ? d.rSpend / d.rGal : null);
pSpends.push(d.pSpend);
rSpends.push(d.rSpend);
});
// --- CHART 3: GAS PRICE HISTORY (LINE) ---
const ctxPrice = document.getElementById("gasPriceHistoryChart").getContext("2d");
if (gasPriceChartInstance) gasPriceChartInstance.destroy();
gasPriceChartInstance = new Chart(ctxPrice, {
type: 'line',
data: {
labels: gasLabels,
datasets: [
{
label: 'Premium ($/gal)',
data: pPrices,
borderColor: '#dc2626',
backgroundColor: '#dc2626',
tension: 0.3,
spanGaps: true
},
{
label: 'Regular ($/gal)',
data: rPrices,
borderColor: '#4b5563',
backgroundColor: '#4b5563',
tension: 0.3,
spanGaps: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { y: { beginAtZero: false } }
}
});
// --- CHART 4: GAS SPEND BREAKDOWN (STACKED BAR) ---
const ctxBreakdown = document.getElementById("gasSpendBreakdownChart").getContext("2d");
if (gasSpendBreakdownInstance) gasSpendBreakdownInstance.destroy();
gasSpendBreakdownInstance = new Chart(ctxBreakdown, {
type: 'bar',
data: {
labels: gasLabels,
datasets: [
{
label: 'Premium Spend',
data: pSpends,
backgroundColor: '#dc2626',
},
{
label: 'Regular Spend',
data: rSpends,
backgroundColor: '#9ca3af',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
x: { stacked: true },
y: { stacked: true, beginAtZero: true }
}
}
});
}
// --- 4. HELPER RENDER FUNCTIONS ---
function renderMerchTables(itemStats) {
const items = Array.from(itemStats.values());
// 1. Most Expensive (Max Price) - Filter out "0" prices from erroneous data
const byMaxPrice = [...items]
.filter(i => i.maxPrice > 0)
.sort((a,b) => b.maxPrice - a.maxPrice)
.slice(0, 10);
const expBody = document.getElementById("mostExpensiveBody");
expBody.innerHTML = "";
byMaxPrice.forEach(row => {
expBody.innerHTML += `<tr><td>${row.name}</td><td class="money">${formatMoney(row.maxPrice)}</td><td class="money">${formatMoney(row.totalSpent/row.totalUnits)}</td></tr>`;
});
// 2. Price Increases (Latest > First)
const byIncrease = [...items]
.filter(i => i.lastPrice > i.firstPrice)
.sort((a,b) => (b.lastPrice - b.firstPrice) - (a.lastPrice - a.firstPrice))
.slice(0, 10);
const incBody = document.getElementById("priceIncreaseBody");
incBody.innerHTML = "";
byIncrease.forEach(row => {
const diff = row.lastPrice - row.firstPrice;
incBody.innerHTML += `<tr><td>${row.name}</td><td>${formatMoney(row.firstPrice)} &rarr; ${formatMoney(row.lastPrice)}</td><td class="money pos">+${formatMoney(diff)}</td></tr>`;
});
// 3. Price Decreases (Latest < First)
const byDecrease = [...items]
.filter(i => i.lastPrice < i.firstPrice)
.sort((a,b) => (a.lastPrice - a.firstPrice) - (b.lastPrice - b.firstPrice))
.slice(0, 10);
const decBody = document.getElementById("priceDecreaseBody");
decBody.innerHTML = "";
byDecrease.forEach(row => {
const diff = row.lastPrice - row.firstPrice;
decBody.innerHTML += `<tr><td>${row.name}</td><td>${formatMoney(row.firstPrice)} &rarr; ${formatMoney(row.lastPrice)}</td><td class="money neg">${formatMoney(diff)}</td></tr>`;
});
// 4. Top Spend
const bySpend = [...items].sort((a,b) => b.totalSpent - a.totalSpent).slice(0, 10);
const spendBody = document.getElementById("mostTotalSpentBody");
spendBody.innerHTML = "";
bySpend.forEach((row, i) => {
spendBody.innerHTML += `
<tr>
<td>${i+1}</td>
<td><strong>${row.name}</strong></td>
<td class="money">${formatMoney(row.totalSpent)}</td>
<td>${row.totalUnits}</td>
<td class="money">${formatMoney(row.totalSpent/row.totalUnits)}</td>
</tr>
`;
});
// 5. Most Frequent
const byCount = [...items].sort((a,b) => b.totalUnits - a.totalUnits).slice(0, 10);
const countBody = document.getElementById("mostPurchasedBody");
countBody.innerHTML = "";
byCount.forEach((row, i) => {
const change = row.maxPrice - row.minPrice;
const changeStr = change > 0.01 ? `<span class="pos">+${formatMoney(change)}</span>` : "-";
countBody.innerHTML += `
<tr>
<td>${i+1}</td>
<td><strong>${row.name}</strong></td>
<td>${row.totalUnits}</td>
<td class="money">${formatMoney(row.totalSpent/row.totalUnits)}</td>
<td>${changeStr}</td>
</tr>
`;
});
}
function renderSimpleBarChart(canvasId, monthlyMap, label, color, instance, setInstance) {
const ctx = document.getElementById(canvasId).getContext('2d');
const sortedKeys = Array.from(monthlyMap.keys()).sort();
const labels = sortedKeys.map(k => {
const [y, m] = k.split('-');
return getMonthAbbr(new Date(y, m - 1));
});
const data = sortedKeys.map(k => monthlyMap.get(k).spent);
if (instance) instance.destroy();
const newChart = new Chart(ctx, {
type: 'bar',
data: {
labels,
datasets: [{
label: label,
data: data,
backgroundColor: color,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true } }
}
});
setInstance(newChart);
}
// --- 5. EVENT LISTENER ---
document.getElementById("fileInput").addEventListener("change", (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
const json = JSON.parse(ev.target.result);
if (!Array.isArray(json)) throw new Error("File must be a JSON array of receipts.");
document.getElementById("status").textContent = `Success! Loaded ${json.length} receipts.`;
const processed = processData(json);
renderDashboard(processed);
} catch (err) {
console.error(err);
document.getElementById("status").textContent = "Error: " + err.message;
}
};
reader.readAsText(file);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment