Initial build: SaaS Cost Predictor Chrome extension MVP

This commit is contained in:
Bun Bun
2026-06-14 18:26:44 +00:00
commit 9082038e29
21 changed files with 2653 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.log
.venv/
.verdict
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
function walk(d) {
for (const f of fs.readdirSync(d)) {
const p = d + '/' + f;
const s = fs.statSync(p);
if (s.isDirectory()) walk(p);
else console.log(p);
}
}
walk('dist');
+10
View File
@@ -0,0 +1,10 @@
const fs = require('fs');
function walk(d) {
for (const f of fs.readdirSync(d)) {
const p = d + '/' + f;
const s = fs.statSync(p);
if (s.isDirectory()) walk(p);
else console.log(p);
}
}
walk('dist');
+56
View File
@@ -0,0 +1,56 @@
import { writeFileSync } from 'fs';
import { deflateSync } from 'zlib';
function makePng(size, r, g, b) {
const crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
crcTable[n] = c;
}
function crc32(buf) {
let c = ~0;
for (let i = 0; i < buf.length; i++) {
c = crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
}
return ~c >>> 0;
}
function writeChunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crc]);
}
const ihdrData = Buffer.from([
0x00, 0x00, 0x00, size, 0x00, 0x00, 0x00, size,
0x08, 0x02, 0x00, 0x00, 0x00,
]);
const ihdrChunk = writeChunk('IHDR', ihdrData);
const raw = [];
for (let y = 0; y < size; y++) {
raw.push(0);
for (let x = 0; x < size; x++) {
raw.push(r, g, b, 255);
}
}
const rawBuf = Buffer.from(raw);
const compressed = deflateSync(rawBuf);
const idatChunk = writeChunk('IDAT', compressed);
const iendChunk = writeChunk('IEND', Buffer.alloc(0));
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
return Buffer.concat([sig, ihdrChunk, idatChunk, iendChunk]);
}
writeFileSync('icons/icon16.png', makePng(16, 99, 102, 241));
writeFileSync('icons/icon48.png', makePng(48, 99, 102, 241));
writeFileSync('icons/icon128.png', makePng(128, 99, 102, 241));
console.log('Icons created');
+45
View File
@@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "SaaS Cost Predictor",
"version": "1.0.0",
"description": "Forecast and budget your AI-powered subscription spend",
"permissions": [
"storage",
"alarms",
"notifications",
"activeTab"
],
"host_permissions": [
"https://dashboard.stripe.com/*",
"https://console.aws.amazon.com/*",
"https://*.vercel.com/*"
],
"background": {
"service_worker": "src/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": [
"https://dashboard.stripe.com/*",
"https://console.aws.amazon.com/*",
"https://*.vercel.com/*"
],
"js": ["src/content.ts"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}
+1511
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "saas-cost-predictor",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "node --test dist/__tests__/forecast.test.js"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"typescript": "^5.4.0",
"vite": "^5.2.0"
}
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, it } from 'node:test';
import assert from 'node:assert';
import { normalizeToMonthly, calculateForecast } from '../forecast.js';
import type { Subscription } from '../types.js';
describe('normalizeToMonthly', () => {
it('returns same for monthly', () => {
assert.strictEqual(normalizeToMonthly(100, 'monthly'), 100);
});
it('divides yearly by 12', () => {
assert.strictEqual(normalizeToMonthly(1200, 'yearly'), 100);
});
it('multiplies weekly by 4.33', () => {
assert.strictEqual(normalizeToMonthly(100, 'weekly'), 433);
});
});
describe('calculateForecast', () => {
it('returns zero for empty subscriptions', () => {
const result = calculateForecast([], { monthlyLimit: 1000, alertThreshold: 80 });
assert.strictEqual(result.nextMonthForecast, 0);
assert.strictEqual(result.confidence, 'low');
assert.strictEqual(result.alertTriggered, false);
});
it('predicts stable when no history', () => {
const subs: Subscription[] = [
{ id: '1', name: 'A', cost: 100, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01', history: [] },
];
const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 });
assert.strictEqual(result.nextMonthForecast, 100);
assert.strictEqual(result.trendDirection, 'stable');
assert.strictEqual(result.confidence, 'low');
});
it('predicts upward trend from history', () => {
const subs: Subscription[] = [
{
id: '1', name: 'A', cost: 120, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01',
history: [
{ date: '2024-01-01', amount: 100 },
{ date: '2024-02-01', amount: 110 },
{ date: '2024-03-01', amount: 120 },
],
},
];
const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 });
assert.strictEqual(result.trendDirection, 'up');
assert.ok(result.nextMonthForecast > 120);
assert.strictEqual(result.confidence, 'high');
});
it('triggers alert when forecast exceeds threshold', () => {
const subs: Subscription[] = [
{ id: '1', name: 'A', cost: 900, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01', history: [] },
];
const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 });
assert.strictEqual(result.alertTriggered, true);
assert.ok(result.alertMessage?.includes('100'));
});
it('groups by category in breakdown', () => {
const subs: Subscription[] = [
{ id: '1', name: 'A', cost: 100, billingCycle: 'monthly', category: 'AI', createdAt: '2024-01-01', history: [] },
{ id: '2', name: 'B', cost: 200, billingCycle: 'monthly', category: 'AI', createdAt: '2024-01-01', history: [] },
{ id: '3', name: 'C', cost: 50, billingCycle: 'monthly', category: 'Hosting', createdAt: '2024-01-01', history: [] },
];
const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 });
assert.strictEqual(result.breakdown.length, 2);
const ai = result.breakdown.find(b => b.category === 'AI');
assert.ok(ai);
assert.strictEqual(ai!.currentSpend, 300);
assert.strictEqual(ai!.forecastSpend, 300);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { getState } from './storage.js';
import { calculateForecast } from './forecast.js';
const ALARM_NAME = 'saas-cost-check';
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create(ALARM_NAME, { periodInMinutes: 60 });
});
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === ALARM_NAME) {
await runCostCheck();
}
});
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'RUN_CHECK') {
runCostCheck().then((result) => sendResponse(result));
return true;
}
if (message.type === 'IMPORT_COST') {
handleImportCost(message.payload).then(() => sendResponse({ ok: true }));
return true;
}
});
async function runCostCheck() {
try {
const state = await getState();
const forecast = calculateForecast(state.subscriptions, state.budget);
if (forecast.alertTriggered) {
await chrome.notifications.create(`alert-${Date.now()}`, {
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'SaaS Cost Alert',
message: forecast.alertMessage || 'Your forecasted spend is approaching the budget limit.',
priority: 2,
});
}
return { ok: true, forecast };
} catch (err) {
console.error('Cost check failed:', err);
return { ok: false, error: String(err) };
}
}
async function handleImportCost(payload: { name: string; cost: number; url: string }) {
const { addSubscription, generateId } = await import('./storage.js');
const { normalizeToMonthly } = await import('./forecast.js');
await addSubscription({
id: generateId(),
name: payload.name,
cost: payload.cost,
billingCycle: 'monthly',
category: 'Imported',
url: payload.url,
createdAt: new Date().toISOString(),
history: [
{ date: new Date().toISOString().slice(0, 10), amount: payload.cost },
],
});
}
+68
View File
@@ -0,0 +1,68 @@
(function () {
'use strict';
const host = window.location.hostname;
if (host.includes('stripe.com')) {
detectStripeCost();
} else if (host.includes('amazon.com')) {
detectAWSCost();
}
function detectStripeCost() {
const observer = new MutationObserver(() => {
const els = document.querySelectorAll('[data-testid="balance-summary"] .Text, .DashboardBalanceSummary-amount');
els.forEach(el => {
const text = el.textContent || '';
const match = text.match(/\$?([\d,]+\.?\d*)/);
if (match && !el.hasAttribute('data-saas-cost-detected')) {
el.setAttribute('data-saas-cost-detected', 'true');
showImportButton(el, 'Stripe Dashboard', parseFloat(match[1].replace(/,/g, '')));
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
function detectAWSCost() {
const observer = new MutationObserver(() => {
const els = document.querySelectorAll('.awsui-util-font-size-display-l, [data-testid="total-cost"]');
els.forEach(el => {
const text = el.textContent || '';
const match = text.match(/\$?([\d,]+\.?\d*)/);
if (match && !el.hasAttribute('data-saas-cost-detected')) {
el.setAttribute('data-saas-cost-detected', 'true');
showImportButton(el, 'AWS Console', parseFloat(match[1].replace(/,/g, '')));
}
});
});
observer.observe(document.body, { childList: true, subtree: true });
}
function showImportButton(targetEl: Element, name: string, cost: number) {
const btn = document.createElement('button');
btn.textContent = ' Import to SaaS Cost Predictor';
btn.style.cssText = `
position: absolute;
z-index: 99999;
background: #4f46e5;
color: white;
border: none;
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
margin-top: 4px;
`;
btn.addEventListener('click', () => {
chrome.runtime.sendMessage({
type: 'IMPORT_COST',
payload: { name, cost, url: window.location.href },
});
btn.textContent = '✅ Imported';
btn.style.background = '#16a34a';
setTimeout(() => btn.remove(), 2000);
});
targetEl.parentElement?.appendChild(btn);
}
})();
+136
View File
@@ -0,0 +1,136 @@
import type { Subscription, ForecastResult, Budget, CategoryBreakdown } from './types.js';
export function normalizeToMonthly(cost: number, cycle: Subscription['billingCycle']): number {
switch (cycle) {
case 'weekly': return cost * 4.33;
case 'yearly': return cost / 12;
case 'monthly':
default:
return cost;
}
}
export function calculateForecast(
subscriptions: Subscription[],
budget: Budget
): ForecastResult {
if (subscriptions.length === 0) {
return {
nextMonthForecast: 0,
trendDirection: 'stable',
trendPercent: 0,
confidence: 'low',
breakdown: [],
alertTriggered: false,
};
}
let totalCurrentMonthly = 0;
let totalForecastMonthly = 0;
const categoryMap = new Map<string, { current: number; forecast: number }>();
for (const sub of subscriptions) {
const monthlyCost = normalizeToMonthly(sub.cost, sub.billingCycle);
totalCurrentMonthly += monthlyCost;
const predicted = predictNextCost(sub);
const predictedMonthly = normalizeToMonthly(predicted, sub.billingCycle);
totalForecastMonthly += predictedMonthly;
const cat = sub.category || 'Uncategorized';
const existing = categoryMap.get(cat) || { current: 0, forecast: 0 };
existing.current += monthlyCost;
existing.forecast += predictedMonthly;
categoryMap.set(cat, existing);
}
const trendPercent = totalCurrentMonthly > 0
? ((totalForecastMonthly - totalCurrentMonthly) / totalCurrentMonthly) * 100
: 0;
const trendDirection: ForecastResult['trendDirection'] =
Math.abs(trendPercent) < 1 ? 'stable' : trendPercent > 0 ? 'up' : 'down';
const breakdown: CategoryBreakdown[] = [];
for (const [category, vals] of categoryMap) {
breakdown.push({
category,
currentSpend: Math.round(vals.current * 100) / 100,
forecastSpend: Math.round(vals.forecast * 100) / 100,
percentOfTotal: totalForecastMonthly > 0
? Math.round((vals.forecast / totalForecastMonthly) * 1000) / 10
: 0,
});
}
breakdown.sort((a, b) => b.forecastSpend - a.forecastSpend);
const thresholdAmount = budget.monthlyLimit * (budget.alertThreshold / 100);
const alertTriggered = totalForecastMonthly >= thresholdAmount;
let confidence: ForecastResult['confidence'] = 'low';
const subsWithHistory = subscriptions.filter(s => s.history && s.history.length >= 3);
if (subsWithHistory.length / subscriptions.length >= 0.5) {
confidence = 'high';
} else if (subsWithHistory.length > 0) {
confidence = 'medium';
}
let alertMessage: string | undefined;
if (alertTriggered) {
const overBy = totalForecastMonthly - budget.monthlyLimit;
if (overBy > 0) {
alertMessage = `Forecast $${overBy.toFixed(2)} over $${budget.monthlyLimit.toFixed(2)} budget`;
} else {
alertMessage = `Forecast within $${(budget.monthlyLimit - totalForecastMonthly).toFixed(2)} of $${budget.monthlyLimit.toFixed(2)} budget`;
}
}
return {
nextMonthForecast: Math.round(totalForecastMonthly * 100) / 100,
trendDirection,
trendPercent: Math.round(trendPercent * 10) / 10,
confidence,
breakdown,
alertTriggered,
alertMessage,
};
}
function predictNextCost(sub: Subscription): number {
if (!sub.history || sub.history.length < 2) {
return sub.cost;
}
const sorted = [...sub.history].sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
);
const n = sorted.length;
if (n === 2) {
const diff = sorted[1].amount - sorted[0].amount;
return Math.max(0, sorted[n - 1].amount + diff);
}
let sumX = 0;
let sumY = 0;
let sumXY = 0;
let sumXX = 0;
for (let i = 0; i < n; i++) {
sumX += i;
sumY += sorted[i].amount;
sumXY += i * sorted[i].amount;
sumXX += i * i;
}
const denom = n * sumXX - sumX * sumX;
if (denom === 0) {
return sorted[n - 1].amount;
}
const slope = (n * sumXY - sumX * sumY) / denom;
const intercept = (sumY - slope * sumX) / n;
const predicted = slope * n + intercept;
return Math.max(0, Math.round(predicted * 100) / 100);
}
+282
View File
@@ -0,0 +1,282 @@
:root {
--bg: #0f172a;
--surface: #1e293b;
--text: #f1f5f9;
--muted: #94a3b8;
--accent: #6366f1;
--accent-hover: #4f46e5;
--danger: #ef4444;
--success: #22c55e;
--warning: #f59e0b;
--radius: 8px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
width: 380px;
min-height: 400px;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
}
#app {
padding: 16px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
header h1 {
font-size: 18px;
font-weight: 700;
}
#settings-btn {
background: transparent;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--text);
}
section {
background: var(--surface);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 12px;
}
section h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.forecast-main {
text-align: center;
padding: 12px 0;
}
.forecast-number {
font-size: 36px;
font-weight: 800;
color: var(--accent);
}
.forecast-label {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
.forecast-trend {
margin-top: 8px;
font-weight: 600;
font-size: 13px;
}
.forecast-trend.up { color: var(--danger); }
.forecast-trend.down { color: var(--success); }
.forecast-trend.stable { color: var(--muted); }
.forecast-confidence {
margin-top: 4px;
font-size: 12px;
color: var(--muted);
}
#alert-box {
display: flex;
align-items: center;
gap: 8px;
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--danger);
border-radius: var(--radius);
padding: 10px;
margin-top: 12px;
font-size: 13px;
}
#alert-icon {
font-size: 16px;
}
#breakdown-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.breakdown-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
}
.breakdown-item .cat-name {
font-weight: 500;
}
.breakdown-item .cat-bar-wrap {
flex: 1;
margin: 0 10px;
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
overflow: hidden;
}
.breakdown-item .cat-bar {
height: 100%;
background: var(--accent);
border-radius: 3px;
}
.breakdown-item .cat-amount {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
}
#subscription-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sub-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
}
.sub-item .sub-info {
flex: 1;
}
.sub-item .sub-name {
font-weight: 600;
font-size: 13px;
}
.sub-item .sub-meta {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
}
.sub-item .sub-cost {
font-weight: 700;
font-size: 14px;
margin-right: 8px;
}
.sub-item button {
background: rgba(239, 68, 68, 0.2);
border: none;
color: var(--danger);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.sub-item button:hover {
background: rgba(239, 68, 68, 0.35);
}
form {
display: flex;
flex-direction: column;
gap: 8px;
}
input, select {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
padding: 8px 10px;
color: var(--text);
font-size: 13px;
}
input::placeholder {
color: var(--muted);
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
}
button[type="submit"] {
background: var(--accent);
border: none;
color: white;
padding: 10px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
margin-top: 4px;
}
button[type="submit"]:hover {
background: var(--accent-hover);
}
#settings-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--muted);
}
#close-settings {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
color: var(--text);
padding: 10px;
border-radius: 6px;
cursor: pointer;
margin-top: 4px;
}
#close-settings:hover {
background: rgba(255,255,255,0.1);
}
.hidden {
display: none !important;
}
#forecast-empty, #subscription-empty {
text-align: center;
color: var(--muted);
padding: 16px;
}
#forecast-empty p, #subscription-empty p {
margin-bottom: 6px;
}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SaaS Cost Predictor</title>
<link rel="stylesheet" href="./popup.css" />
</head>
<body>
<div id="app">
<header>
<h1>SaaS Cost Predictor</h1>
<button id="settings-btn" title="Settings">⚙️</button>
</header>
<section id="forecast-section">
<div id="forecast-loading">Loading forecast…</div>
<div id="forecast-content" class="hidden">
<div class="forecast-main">
<div class="forecast-number" id="forecast-amount">$0</div>
<div class="forecast-label">Next month forecast</div>
<div class="forecast-trend" id="forecast-trend"></div>
<div class="forecast-confidence" id="forecast-confidence">Confidence: low</div>
</div>
<div id="alert-box" class="hidden">
<span id="alert-icon">⚠️</span>
<span id="alert-text"></span>
</div>
</div>
<div id="forecast-empty" class="hidden">
<p>No subscriptions yet.</p>
<p>Add your first subscription below to get a forecast.</p>
</div>
</section>
<section id="breakdown-section" class="hidden">
<h2>By Category</h2>
<div id="breakdown-list"></div>
</section>
<section id="subscriptions-section">
<h2>Subscriptions</h2>
<div id="subscription-list"></div>
<div id="subscription-empty" class="hidden">
<p>No subscriptions tracked.</p>
</div>
</section>
<section id="add-section">
<h2>Add Subscription</h2>
<form id="add-form">
<input type="text" id="sub-name" placeholder="Name (e.g., OpenAI)" required />
<input type="number" id="sub-cost" placeholder="Cost (e.g., 20.00)" step="0.01" min="0" required />
<select id="sub-cycle">
<option value="monthly">Monthly</option>
<option value="yearly">Yearly</option>
<option value="weekly">Weekly</option>
</select>
<input type="text" id="sub-category" placeholder="Category (e.g., AI, Hosting)" required />
<button type="submit">Add Subscription</button>
</form>
</section>
<section id="settings-section" class="hidden">
<h2>Budget Settings</h2>
<form id="settings-form">
<label>
Monthly Budget Limit ($)
<input type="number" id="budget-limit" step="1" min="0" required />
</label>
<label>
Alert Threshold (%)
<input type="number" id="alert-threshold" step="1" min="1" max="100" required />
</label>
<button type="submit">Save Settings</button>
<button type="button" id="close-settings">Close</button>
</form>
</section>
</div>
<script type="module" src="./popup.ts"></script>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
import { getState, addSubscription, deleteSubscription, updateBudget, generateId } from '../storage.js';
import { calculateForecast, normalizeToMonthly } from '../forecast.js';
import type { Subscription } from '../types.js';
async function render() {
const state = await getState();
const forecast = calculateForecast(state.subscriptions, state.budget);
renderForecast(forecast, state.subscriptions.length);
renderBreakdown(forecast);
renderSubscriptions(state.subscriptions);
renderSettings(state.budget);
}
function renderForecast(forecast: ReturnType<typeof calculateForecast>, subCount: number) {
const loading = document.getElementById('forecast-loading')!;
const content = document.getElementById('forecast-content')!;
const empty = document.getElementById('forecast-empty')!;
loading.classList.add('hidden');
if (subCount === 0) {
empty.classList.remove('hidden');
content.classList.add('hidden');
return;
}
empty.classList.add('hidden');
content.classList.remove('hidden');
document.getElementById('forecast-amount')!.textContent = `$${forecast.nextMonthForecast.toFixed(2)}`;
const trendEl = document.getElementById('forecast-trend')!;
trendEl.className = `forecast-trend ${forecast.trendDirection}`;
const arrow = forecast.trendDirection === 'up' ? '▲' : forecast.trendDirection === 'down' ? '▼' : '—';
trendEl.textContent = `${arrow} ${Math.abs(forecast.trendPercent).toFixed(1)}% vs current`;
document.getElementById('forecast-confidence')!.textContent = `Confidence: ${forecast.confidence}`;
const alertBox = document.getElementById('alert-box')!;
if (forecast.alertTriggered) {
alertBox.classList.remove('hidden');
document.getElementById('alert-text')!.textContent = forecast.alertMessage || 'Budget alert';
} else {
alertBox.classList.add('hidden');
}
}
function renderBreakdown(forecast: ReturnType<typeof calculateForecast>) {
const section = document.getElementById('breakdown-section')!;
const list = document.getElementById('breakdown-list')!;
if (forecast.breakdown.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
list.innerHTML = '';
const maxVal = Math.max(...forecast.breakdown.map(b => b.forecastSpend));
for (const item of forecast.breakdown) {
const row = document.createElement('div');
row.className = 'breakdown-item';
const barWidth = maxVal > 0 ? (item.forecastSpend / maxVal) * 100 : 0;
row.innerHTML = `
<span class="cat-name">${escapeHtml(item.category)}</span>
<div class="cat-bar-wrap"><div class="cat-bar" style="width:${barWidth}%"></div></div>
<span class="cat-amount">$${item.forecastSpend.toFixed(2)}</span>
`;
list.appendChild(row);
}
}
function renderSubscriptions(subs: Subscription[]) {
const list = document.getElementById('subscription-list')!;
const empty = document.getElementById('subscription-empty')!;
if (subs.length === 0) {
list.innerHTML = '';
empty.classList.remove('hidden');
return;
}
empty.classList.add('hidden');
list.innerHTML = '';
for (const sub of subs) {
const monthly = normalizeToMonthly(sub.cost, sub.billingCycle);
const row = document.createElement('div');
row.className = 'sub-item';
row.innerHTML = `
<div class="sub-info">
<div class="sub-name">${escapeHtml(sub.name)}</div>
<div class="sub-meta">${escapeHtml(sub.category)}${sub.billingCycle} • ~$${monthly.toFixed(2)}/mo</div>
</div>
<div class="sub-cost">$${sub.cost.toFixed(2)}</div>
<button data-id="${sub.id}">Delete</button>
`;
row.querySelector('button')!.addEventListener('click', async () => {
await deleteSubscription(sub.id);
await render();
});
list.appendChild(row);
}
}
function renderSettings(budget: { monthlyLimit: number; alertThreshold: number }) {
(document.getElementById('budget-limit') as HTMLInputElement).value = String(budget.monthlyLimit);
(document.getElementById('alert-threshold') as HTMLInputElement).value = String(budget.alertThreshold);
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
document.getElementById('add-form')!.addEventListener('submit', async (e) => {
e.preventDefault();
const name = (document.getElementById('sub-name') as HTMLInputElement).value.trim();
const cost = parseFloat((document.getElementById('sub-cost') as HTMLInputElement).value);
const cycle = (document.getElementById('sub-cycle') as HTMLSelectElement).value as Subscription['billingCycle'];
const category = (document.getElementById('sub-category') as HTMLInputElement).value.trim();
if (!name || isNaN(cost) || cost < 0 || !category) return;
await addSubscription({
id: generateId(),
name,
cost,
billingCycle: cycle,
category,
createdAt: new Date().toISOString(),
history: [{ date: new Date().toISOString().slice(0, 10), amount: cost }],
});
(e.target as HTMLFormElement).reset();
await render();
});
document.getElementById('settings-form')!.addEventListener('submit', async (e) => {
e.preventDefault();
const limit = parseFloat((document.getElementById('budget-limit') as HTMLInputElement).value);
const threshold = parseFloat((document.getElementById('alert-threshold') as HTMLInputElement).value);
if (isNaN(limit) || limit < 0 || isNaN(threshold) || threshold < 1 || threshold > 100) return;
await updateBudget({ monthlyLimit: limit, alertThreshold: threshold });
toggleSettings(false);
await render();
});
document.getElementById('settings-btn')!.addEventListener('click', () => toggleSettings(true));
document.getElementById('close-settings')!.addEventListener('click', () => toggleSettings(false));
function toggleSettings(show: boolean) {
document.getElementById('settings-section')!.classList.toggle('hidden', !show);
document.getElementById('forecast-section')!.classList.toggle('hidden', show);
document.getElementById('breakdown-section')!.classList.toggle('hidden', show);
document.getElementById('subscriptions-section')!.classList.toggle('hidden', show);
document.getElementById('add-section')!.classList.toggle('hidden', show);
}
render().catch(console.error);
+52
View File
@@ -0,0 +1,52 @@
import type { AppState, Subscription, Budget } from './types.js';
const DEFAULT_STATE: AppState = {
subscriptions: [],
budget: {
monthlyLimit: 1000,
alertThreshold: 80,
},
};
export async function getState(): Promise<AppState> {
if (typeof chrome !== 'undefined' && chrome.storage) {
const result = await chrome.storage.local.get(['saasCostState']);
if (result.saasCostState) {
return result.saasCostState as AppState;
}
}
return structuredClone(DEFAULT_STATE);
}
export async function saveState(state: AppState): Promise<void> {
if (typeof chrome !== 'undefined' && chrome.storage) {
await chrome.storage.local.set({ saasCostState: state });
}
}
export async function addSubscription(sub: Subscription): Promise<void> {
const state = await getState();
const existing = state.subscriptions.findIndex(s => s.id === sub.id);
if (existing >= 0) {
state.subscriptions[existing] = sub;
} else {
state.subscriptions.push(sub);
}
await saveState(state);
}
export async function deleteSubscription(id: string): Promise<void> {
const state = await getState();
state.subscriptions = state.subscriptions.filter(s => s.id !== id);
await saveState(state);
}
export async function updateBudget(budget: Budget): Promise<void> {
const state = await getState();
state.budget = budget;
await saveState(state);
}
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
+42
View File
@@ -0,0 +1,42 @@
export interface Subscription {
id: string;
name: string;
cost: number;
billingCycle: 'monthly' | 'yearly' | 'weekly';
category: string;
url?: string;
createdAt: string;
history: CostHistoryPoint[];
}
export interface CostHistoryPoint {
date: string;
amount: number;
}
export interface Budget {
monthlyLimit: number;
alertThreshold: number;
}
export interface ForecastResult {
nextMonthForecast: number;
trendDirection: 'up' | 'down' | 'stable';
trendPercent: number;
confidence: 'high' | 'medium' | 'low';
breakdown: CategoryBreakdown[];
alertTriggered: boolean;
alertMessage?: string;
}
export interface CategoryBreakdown {
category: string;
currentSpend: number;
forecastSpend: number;
percentOfTotal: number;
}
export interface AppState {
subscriptions: Subscription[];
budget: Budget;
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"types": ["chrome", "node"]
},
"include": ["src/**/*"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json' assert { type: 'json' }
export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
},
plugins: [crx({ manifest })],
})