Initial build: SaaS Cost Predictor Chrome extension MVP
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.venv/
|
||||
.verdict
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 360 B |
Binary file not shown.
|
After Width: | Height: | Size: 82 B |
Binary file not shown.
|
After Width: | Height: | Size: 157 B |
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1511
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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 })],
|
||||
})
|
||||
Reference in New Issue
Block a user