QuitFree MVP: subscription tracker, budget manager, cancel helper, debt calculator, CSV/JSON export. 13 tests passing.
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "QuitFree — Subscription & Budget Tracker",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Track subscriptions, cancel in one tap, budget without bank linking. No subscription required.",
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"notifications",
|
||||||
|
"alarms",
|
||||||
|
"activeTab"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://*/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "src/popup/popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "src/background/background.ts",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://*/*"],
|
||||||
|
"js": ["src/content/cancel-helper.ts"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options_page": "src/options/options.html",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1896
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "quitfree",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^5.4.11",
|
||||||
|
"vitest": "^2.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 307 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
@@ -0,0 +1,43 @@
|
|||||||
|
const zlib = require('zlib');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const outDir = process.argv[2] || path.join(__dirname, '..', 'public', 'icons');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
function writeChunk(type, data) {
|
||||||
|
const chunk = Buffer.concat([Buffer.alloc(4), Buffer.from(type), data, Buffer.alloc(4)]);
|
||||||
|
chunk.writeUInt32BE(data.length, 0);
|
||||||
|
const crc = zlib.crc32(Buffer.concat([Buffer.from(type), data]));
|
||||||
|
chunk.writeUInt32BE(crc >>> 0, 4 + 4 + data.length);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
function writePng(filename, width, height, r, g, b) {
|
||||||
|
const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(width, 0);
|
||||||
|
ihdr.writeUInt32BE(height, 4);
|
||||||
|
ihdr.writeUInt8(8, 8);
|
||||||
|
ihdr.writeUInt8(2, 9);
|
||||||
|
ihdr.writeUInt8(0, 10);
|
||||||
|
ihdr.writeUInt8(0, 11);
|
||||||
|
ihdr.writeUInt8(0, 12);
|
||||||
|
const rowLength = 1 + width * 3;
|
||||||
|
const imageData = Buffer.alloc(rowLength * height);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const rowStart = y * rowLength;
|
||||||
|
imageData[rowStart] = 0;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const offset = rowStart + 1 + x * 3;
|
||||||
|
imageData[offset] = r;
|
||||||
|
imageData[offset + 1] = g;
|
||||||
|
imageData[offset + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const compressed = zlib.deflateSync(imageData);
|
||||||
|
const chunks = [signature, writeChunk('IHDR', ihdr), writeChunk('IDAT', compressed), writeChunk('IEND', Buffer.alloc(0))];
|
||||||
|
fs.writeFileSync(filename, Buffer.concat(chunks));
|
||||||
|
}
|
||||||
|
const teal = [0x22, 0xD3, 0xEE];
|
||||||
|
writePng(path.join(outDir, 'icon16.png'), 16, 16, ...teal);
|
||||||
|
writePng(path.join(outDir, 'icon48.png'), 48, 48, ...teal);
|
||||||
|
writePng(path.join(outDir, 'icon128.png'), 128, 128, ...teal);
|
||||||
|
console.log('Icons generated');
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { getData, setData, subscriptionsExpiringSoon } from '../shared/storage'
|
||||||
|
|
||||||
|
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||||
|
if (alarm.name === 'trial-check') {
|
||||||
|
const data = await getData()
|
||||||
|
const soon = subscriptionsExpiringSoon(data.subscriptions, 3)
|
||||||
|
for (const s of soon) {
|
||||||
|
chrome.notifications.create(`trial-${s.id}`, {
|
||||||
|
type: 'basic',
|
||||||
|
iconUrl: '/icons/icon128.png',
|
||||||
|
title: 'QuitFree — Trial Expiring Soon',
|
||||||
|
message: `Your ${s.name} trial is ending soon. Cancel now to avoid charges!`,
|
||||||
|
priority: 2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(() => {
|
||||||
|
chrome.alarms.create('trial-check', { periodInMinutes: 60 })
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.runtime.onStartup.addListener(() => {
|
||||||
|
chrome.alarms.create('trial-check', { periodInMinutes: 60 })
|
||||||
|
})
|
||||||
|
|
||||||
|
chrome.notifications.onClicked.addListener((notificationId) => {
|
||||||
|
if (notificationId.startsWith('trial-')) {
|
||||||
|
chrome.tabs.create({ url: 'https://quitfree.bunbunlabs.com' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('QuitFree background service worker started')
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { CANCEL_HELPERS } from '../shared/storage'
|
||||||
|
|
||||||
|
const hostname = window.location.hostname.toLowerCase()
|
||||||
|
|
||||||
|
function findMatch(): { key: string; url: string; steps: string[] } | undefined {
|
||||||
|
for (const [key, helper] of Object.entries(CANCEL_HELPERS)) {
|
||||||
|
if (hostname.includes(key.replace(/\s+/g, ''))) {
|
||||||
|
return { key, url: helper.url, steps: helper.steps }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectBanner(match: { key: string; url: string; steps: string[] }) {
|
||||||
|
if (document.getElementById('quitfree-banner')) return
|
||||||
|
|
||||||
|
const banner = document.createElement('div')
|
||||||
|
banner.id = 'quitfree-banner'
|
||||||
|
banner.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;max-width:1200px;margin:0 auto;padding:0 16px;">
|
||||||
|
<span style="font-size:20px;">💸</span>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<strong style="font-size:14px;">QuitFree:</strong>
|
||||||
|
<span style="font-size:13px;opacity:0.9;"> This is a subscription page. Want to cancel ${match.key}?</span>
|
||||||
|
</div>
|
||||||
|
<a href="${match.url}" target="_blank" style="background:#fff;color:#0f172a;padding:6px 14px;border-radius:6px;font-weight:600;text-decoration:none;font-size:13px;transition:opacity 0.15s;" onmouseover="this.style.opacity='0.9'" onmouseout="this.style.opacity='1'">Go to Cancel Page</a>
|
||||||
|
<button id="quitfree-dismiss" style="background:transparent;border:1px solid rgba(255,255,255,0.3);color:#fff;padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px;">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
banner.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #22d3ee;
|
||||||
|
padding: 10px 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
border-bottom: 2px solid #22d3ee;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
`
|
||||||
|
|
||||||
|
document.body.style.paddingTop = '54px'
|
||||||
|
|
||||||
|
document.body.appendChild(banner)
|
||||||
|
|
||||||
|
document.getElementById('quitfree-dismiss')!.addEventListener('click', () => {
|
||||||
|
banner.remove()
|
||||||
|
document.body.style.paddingTop = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-hide steps hint
|
||||||
|
const stepsEl = document.createElement('div')
|
||||||
|
stepsEl.id = 'quitfree-steps'
|
||||||
|
stepsEl.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px; right: 16px;
|
||||||
|
z-index: 2147483647;
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
max-width: 280px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
`
|
||||||
|
stepsEl.innerHTML = `
|
||||||
|
<div style="font-weight:700;margin-bottom:6px;color:#22d3ee;">🛠️ Cancel Steps</div>
|
||||||
|
<ol style="margin:0;padding-left:16px;">${match.steps.map(s => `<li style="margin-bottom:4px;">${s}</li>`).join('')}</ol>
|
||||||
|
<div style="margin-top:8px;text-align:right;">
|
||||||
|
<button id="quitfree-hide-steps" style="background:transparent;border:none;color:#94a3b8;cursor:pointer;font-size:11px;text-decoration:underline;">Hide</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
document.body.appendChild(stepsEl)
|
||||||
|
|
||||||
|
document.getElementById('quitfree-hide-steps')!.addEventListener('click', () => {
|
||||||
|
stepsEl.remove()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = findMatch()
|
||||||
|
if (match) {
|
||||||
|
injectBanner(match)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
||||||
|
|
||||||
|
.header { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
.header h1 { font-size: 28px; font-weight: 700; color: #22d3ee; }
|
||||||
|
|
||||||
|
.subtitle { color: #94a3b8; margin-top: 4px; }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 20px; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 { font-size: 16px; font-weight: 700; color: #e2e8f0; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.actions { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #0891b2;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #06b6d4; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #334155;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover { background: #475569; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover { background: #991b1b; }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.form input, .form select {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input:focus { outline: none; border-color: #22d3ee; }
|
||||||
|
|
||||||
|
.row { display: flex; gap: 8px; }
|
||||||
|
|
||||||
|
.row input { flex: 1; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
|
||||||
|
th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #334155; }
|
||||||
|
|
||||||
|
th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; }
|
||||||
|
|
||||||
|
.list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debt-toggle { display: flex; gap: 8px; margin: 12px 0; }
|
||||||
|
|
||||||
|
.debt-toggle .active { background: #0891b2; color: #fff; }
|
||||||
|
|
||||||
|
.results {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-row { display: flex; justify-content: space-between; padding: 4px 0; }
|
||||||
|
|
||||||
|
.results-total { border-top: 1px solid #334155; margin-top: 8px; padding-top: 8px; font-weight: 700; color: #22d3ee; }
|
||||||
|
|
||||||
|
.chart { display: flex; align-items: flex-end; gap: 4px; height: 160px; padding: 12px 0; }
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
flex: 1;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
min-height: 4px;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:hover { background: #06b6d4; }
|
||||||
|
|
||||||
|
.bar-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-value {
|
||||||
|
position: absolute;
|
||||||
|
top: -18px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: #22d3ee;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty { color: #64748b; text-align: center; padding: 20px; }
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="./options.css">
|
||||||
|
<title>QuitFree Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header class="header">
|
||||||
|
<h1>QuitFree Dashboard</h1>
|
||||||
|
<p class="subtitle">Anti-subscription finance. No bank links. No recurring fees.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h2>All Subscriptions</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="export-csv" class="btn-primary">Export CSV</button>
|
||||||
|
<button id="export-json" class="btn-secondary">Export JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="all-subs" class="table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Monthly Spending Trend</h2>
|
||||||
|
<div id="spending-chart" class="chart"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Debt Payoff Calculator</h2>
|
||||||
|
<form id="debt-form" class="form">
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="debt-name" placeholder="Debt name (e.g. Credit Card)" required>
|
||||||
|
<input type="number" id="debt-balance" placeholder="Balance" step="0.01" min="0" required>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<input type="number" id="debt-rate" placeholder="APR %" step="0.01" min="0" required>
|
||||||
|
<input type="number" id="debt-min" placeholder="Min payment" step="0.01" min="0" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Add Debt</button>
|
||||||
|
</form>
|
||||||
|
<div id="debt-list" class="list"></div>
|
||||||
|
<div class="debt-toggle">
|
||||||
|
<button id="calc-snowball" class="btn-primary active">Snowball</button>
|
||||||
|
<button id="calc-avalanche" class="btn-secondary">Avalanche</button>
|
||||||
|
</div>
|
||||||
|
<div id="debt-results" class="results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>Expense History</h2>
|
||||||
|
<div id="all-expenses" class="table-wrap"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="./options.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import {
|
||||||
|
getData, setData, generateId, formatCurrency, totalMonthlyCost, subscriptionsToCSV, expensesToCSV, exportToJSON, downloadFile, monthlyCost, annualCost
|
||||||
|
} from '../shared/storage'
|
||||||
|
import type { Subscription, Expense, DebtEntry } from '../shared/storage'
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const data = await getData()
|
||||||
|
renderAllSubs(data.subscriptions)
|
||||||
|
renderSpendingChart(data.subscriptions, data.expenses)
|
||||||
|
renderDebts(data.debts)
|
||||||
|
renderAllExpenses(data.expenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllSubs(subs: Subscription[]) {
|
||||||
|
const container = document.getElementById('all-subs')!
|
||||||
|
if (subs.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">No subscriptions yet.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Cost</th>
|
||||||
|
<th>Interval</th>
|
||||||
|
<th>Monthly</th>
|
||||||
|
<th>Annual</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${subs.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(s.name)}</td>
|
||||||
|
<td>${formatCurrency(s.cost)}</td>
|
||||||
|
<td>${s.interval}</td>
|
||||||
|
<td>${formatCurrency(monthlyCost(s))}</td>
|
||||||
|
<td>${formatCurrency(annualCost(s))}</td>
|
||||||
|
<td>${escapeHtml(s.category)}</td>
|
||||||
|
<td>${s.cancelled ? '<span style="color:#ef4444">Cancelled</span>' : '<span style="color:#22d3ee">Active</span>'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpendingChart(subs: Subscription[], expenses: Expense[]) {
|
||||||
|
const container = document.getElementById('spending-chart')!
|
||||||
|
const months = getLast12Months()
|
||||||
|
const data = months.map(m => {
|
||||||
|
const subTotal = totalMonthlyCost(subs) // rough estimate
|
||||||
|
const expTotal = expenses.filter((e: Expense) => e.date.startsWith(m)).reduce((s: number, e: Expense) => s + e.amount, 0)
|
||||||
|
return subTotal + expTotal
|
||||||
|
})
|
||||||
|
const max = Math.max(...data, 1)
|
||||||
|
|
||||||
|
if (data.every(v => v === 0)) {
|
||||||
|
container.innerHTML = '<div class="empty">Add subscriptions and expenses to see trends.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = months.map((m, i) => {
|
||||||
|
const h = (data[i] / max) * 100
|
||||||
|
return `
|
||||||
|
<div class="bar" style="height:${h}%">
|
||||||
|
<div class="bar-value">${formatCurrency(data[i])}</div>
|
||||||
|
<div class="bar-label">${m.slice(5)}</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLast12Months(): string[] {
|
||||||
|
const months = []
|
||||||
|
const now = new Date()
|
||||||
|
for (let i = 11; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||||
|
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
return months
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDebts(debts: DebtEntry[]) {
|
||||||
|
const container = document.getElementById('debt-list')!
|
||||||
|
if (debts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">No debts added yet.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
container.innerHTML = debts.map(d => `
|
||||||
|
<div class="list-item">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600">${escapeHtml(d.name)}</div>
|
||||||
|
<div style="font-size:12px;color:#94a3b8">${d.interestRate}% APR • Min: ${formatCurrency(d.minPayment)}/mo</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-weight:700;color:#22d3ee">${formatCurrency(d.balance)}</div>
|
||||||
|
<button class="btn-danger btn-delete-debt" data-id="${d.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
`).join('')
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete-debt').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = (btn as HTMLElement).dataset.id!
|
||||||
|
const data = await getData()
|
||||||
|
data.debts = data.debts.filter(d => d.id !== id)
|
||||||
|
await setData(data)
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
calculateDebt(debts, 'snowball')
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentDebtMode: 'snowball' | 'avalanche' = 'snowball'
|
||||||
|
|
||||||
|
function calculateDebt(debts: DebtEntry[], mode: 'snowball' | 'avalanche') {
|
||||||
|
currentDebtMode = mode
|
||||||
|
const container = document.getElementById('debt-results')!
|
||||||
|
if (debts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">Add debts to calculate payoff.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = mode === 'snowball'
|
||||||
|
? [...debts].sort((a, b) => a.balance - b.balance)
|
||||||
|
: [...debts].sort((a, b) => b.interestRate - a.interestRate)
|
||||||
|
|
||||||
|
let totalMonths = 0
|
||||||
|
let totalInterest = 0
|
||||||
|
const results: { name: string; months: number; interest: number }[] = []
|
||||||
|
|
||||||
|
for (const d of sorted) {
|
||||||
|
let balance = d.balance
|
||||||
|
let months = 0
|
||||||
|
let interest = 0
|
||||||
|
const monthlyRate = d.interestRate / 100 / 12
|
||||||
|
while (balance > 0 && months < 600) {
|
||||||
|
const int = balance * monthlyRate
|
||||||
|
interest += int
|
||||||
|
balance = balance + int - d.minPayment
|
||||||
|
if (balance < 0) balance = 0
|
||||||
|
months++
|
||||||
|
}
|
||||||
|
totalMonths = Math.max(totalMonths, months)
|
||||||
|
totalInterest += interest
|
||||||
|
results.push({ name: d.name, months, interest })
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="results-row"><span>Payoff strategy</span><span style="color:#22d3ee;font-weight:600">${mode === 'snowball' ? 'Snowball' : 'Avalanche'}</span></div>
|
||||||
|
<div class="results-row"><span>Total debts</span><span>${debts.length}</span></div>
|
||||||
|
<div class="results-row"><span>Payoff time</span><span>${totalMonths} months</span></div>
|
||||||
|
<div class="results-row"><span>Total interest paid</span><span>${formatCurrency(totalInterest)}</span></div>
|
||||||
|
<div class="results-total">
|
||||||
|
<div class="results-row"><span>Total cost</span><span>${formatCurrency(debts.reduce((s, d) => s + d.balance, 0) + totalInterest)}</span></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllExpenses(expenses: Expense[]) {
|
||||||
|
const container = document.getElementById('all-expenses')!
|
||||||
|
const sorted = [...expenses].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">No expenses yet.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
container.innerHTML = `
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Date</th><th>Description</th><th>Category</th><th>Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${sorted.slice(0, 50).map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${e.date}</td>
|
||||||
|
<td>${escapeHtml(e.description)}</td>
|
||||||
|
<td>${escapeHtml(e.category)}</td>
|
||||||
|
<td>${formatCurrency(e.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = str
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debt form
|
||||||
|
document.getElementById('debt-form')!.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = await getData()
|
||||||
|
const name = (document.getElementById('debt-name') as HTMLInputElement).value.trim()
|
||||||
|
const balance = parseFloat((document.getElementById('debt-balance') as HTMLInputElement).value)
|
||||||
|
const rate = parseFloat((document.getElementById('debt-rate') as HTMLInputElement).value)
|
||||||
|
const min = parseFloat((document.getElementById('debt-min') as HTMLInputElement).value)
|
||||||
|
|
||||||
|
if (!name || isNaN(balance) || isNaN(rate) || isNaN(min)) return
|
||||||
|
|
||||||
|
data.debts.push({ id: generateId(), name, balance, interestRate: rate, minPayment: min })
|
||||||
|
await setData(data)
|
||||||
|
;(e.target as HTMLFormElement).reset()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Toggle strategies
|
||||||
|
document.getElementById('calc-snowball')!.addEventListener('click', async () => {
|
||||||
|
document.getElementById('calc-snowball')!.classList.add('active')
|
||||||
|
document.getElementById('calc-avalanche')!.classList.remove('active')
|
||||||
|
const data = await getData()
|
||||||
|
calculateDebt(data.debts, 'snowball')
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('calc-avalanche')!.addEventListener('click', async () => {
|
||||||
|
document.getElementById('calc-avalanche')!.classList.add('active')
|
||||||
|
document.getElementById('calc-snowball')!.classList.remove('active')
|
||||||
|
const data = await getData()
|
||||||
|
calculateDebt(data.debts, 'avalanche')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Export
|
||||||
|
document.getElementById('export-csv')!.addEventListener('click', async () => {
|
||||||
|
const data = await getData()
|
||||||
|
const csv = subscriptionsToCSV(data.subscriptions) + '\n\n' + expensesToCSV(data.expenses)
|
||||||
|
downloadFile(`quitfree-${new Date().toISOString().slice(0,10)}.csv`, csv, 'text/csv')
|
||||||
|
})
|
||||||
|
|
||||||
|
document.getElementById('export-json')!.addEventListener('click', async () => {
|
||||||
|
const data = await getData()
|
||||||
|
const json = exportToJSON(data)
|
||||||
|
downloadFile(`quitfree-${new Date().toISOString().slice(0,10)}.json`, json, 'application/json')
|
||||||
|
})
|
||||||
|
|
||||||
|
render()
|
||||||
@@ -0,0 +1,289 @@
|
|||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
width: 360px;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22d3ee;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alerts {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
background: #451a03;
|
||||||
|
border: 1px solid #92400e;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fdba74;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background: #450a0a;
|
||||||
|
border-color: #991b1b;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
padding: 0 16px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: #22d3ee;
|
||||||
|
border-bottom-color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active { display: block; }
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input, .form select {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form input:focus, .form select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #22d3ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row input { flex: 1; }
|
||||||
|
.row select { width: 90px; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #0891b2;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover { background: #06b6d4; }
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #334155;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover { background: #475569; }
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #7f1d1d;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #fca5a5;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover { background: #991b1b; }
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #14532d;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
color: #86efac;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success:hover { background: #166534; }
|
||||||
|
|
||||||
|
.list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-info { flex: 1; }
|
||||||
|
|
||||||
|
.list-item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-cost {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22d3ee;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #64748b;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-status {
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar {
|
||||||
|
height: 8px;
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.budget-bar-fill.over { background: #ef4444; }
|
||||||
|
|
||||||
|
.budget-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 1px solid #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: #22d3ee;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.cancelled .list-item-name { text-decoration: line-through; color: #64748b; }
|
||||||
|
.cancelled .list-item-cost { color: #64748b; }
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="./popup.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header class="header">
|
||||||
|
<h1>QuitFree</h1>
|
||||||
|
<p class="subtitle">No subscriptions. No bank links. Just control.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="summary-cards">
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-label">Monthly Spend</span>
|
||||||
|
<span class="card-value" id="monthly-spend">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-label">Annual Spend</span>
|
||||||
|
<span class="card-value" id="annual-spend">$0.00</span>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<span class="card-label">Active Subs</span>
|
||||||
|
<span class="card-value" id="active-count">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alerts" class="alerts"></div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="subs">Subscriptions</button>
|
||||||
|
<button class="tab-btn" data-tab="expenses">Expenses</button>
|
||||||
|
<button class="tab-btn" data-tab="budget">Budget</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-subs" class="tab-content active">
|
||||||
|
<form id="sub-form" class="form">
|
||||||
|
<input type="text" id="sub-name" placeholder="Service name (e.g. Netflix)" required>
|
||||||
|
<div class="row">
|
||||||
|
<input type="number" id="sub-cost" placeholder="Cost" step="0.01" min="0" required>
|
||||||
|
<select id="sub-interval">
|
||||||
|
<option value="monthly">/month</option>
|
||||||
|
<option value="yearly">/year</option>
|
||||||
|
<option value="weekly">/week</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="sub-category" placeholder="Category (e.g. Entertainment)">
|
||||||
|
<input type="date" id="sub-started" placeholder="Started date">
|
||||||
|
<input type="date" id="sub-trial-ends" placeholder="Trial ends (optional)">
|
||||||
|
<button type="submit" class="btn-primary">Add Subscription</button>
|
||||||
|
</form>
|
||||||
|
<div id="sub-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-expenses" class="tab-content">
|
||||||
|
<form id="expense-form" class="form">
|
||||||
|
<input type="date" id="exp-date" required>
|
||||||
|
<input type="text" id="exp-desc" placeholder="Description" required>
|
||||||
|
<input type="text" id="exp-category" placeholder="Category">
|
||||||
|
<input type="number" id="exp-amount" placeholder="Amount" step="0.01" min="0" required>
|
||||||
|
<button type="submit" class="btn-primary">Add Expense</button>
|
||||||
|
</form>
|
||||||
|
<div id="expense-list" class="list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-budget" class="tab-content">
|
||||||
|
<form id="budget-form" class="form">
|
||||||
|
<input type="number" id="monthly-budget" placeholder="Monthly budget total" step="0.01" min="0">
|
||||||
|
<button type="submit" class="btn-primary">Set Budget</button>
|
||||||
|
</form>
|
||||||
|
<div id="budget-status" class="budget-status"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<a href="#" id="open-options">Open Full Dashboard</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="./popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import {
|
||||||
|
getData, setData, generateId, formatCurrency, totalMonthlyCost, totalAnnualCost,
|
||||||
|
monthlyCost, daysUntil, subscriptionsExpiringSoon, CANCEL_HELPERS
|
||||||
|
} from '../shared/storage'
|
||||||
|
import type { Subscription, Expense } from '../shared/storage'
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
const data = await getData()
|
||||||
|
const subs = data.subscriptions
|
||||||
|
const expenses = data.expenses
|
||||||
|
const budget = data.budgetSettings
|
||||||
|
|
||||||
|
document.getElementById('monthly-spend')!.textContent = formatCurrency(totalMonthlyCost(subs))
|
||||||
|
document.getElementById('annual-spend')!.textContent = formatCurrency(totalAnnualCost(subs))
|
||||||
|
document.getElementById('active-count')!.textContent = String(subs.filter(s => !s.cancelled).length)
|
||||||
|
|
||||||
|
renderAlerts(subs)
|
||||||
|
renderSubscriptions(subs)
|
||||||
|
renderExpenses(expenses)
|
||||||
|
renderBudget(budget, expenses)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAlerts(subs: Subscription[]) {
|
||||||
|
const container = document.getElementById('alerts')!
|
||||||
|
container.innerHTML = ''
|
||||||
|
const soon = subscriptionsExpiringSoon(subs, 3)
|
||||||
|
for (const s of soon) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.className = 'alert alert-danger'
|
||||||
|
div.textContent = `Trial for ${s.name} ends in ${daysUntil(s.trialEndsAt!)} day(s). Cancel now!`
|
||||||
|
container.appendChild(div)
|
||||||
|
}
|
||||||
|
const week = subscriptionsExpiringSoon(subs, 7).filter(s => !soon.includes(s))
|
||||||
|
for (const s of week) {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.className = 'alert'
|
||||||
|
div.textContent = `${s.name} trial ends in ${daysUntil(s.trialEndsAt!)} days.`
|
||||||
|
container.appendChild(div)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSubscriptions(subs: Subscription[]) {
|
||||||
|
const container = document.getElementById('sub-list')!
|
||||||
|
container.innerHTML = ''
|
||||||
|
const active = subs.filter(s => !s.cancelled)
|
||||||
|
const cancelled = subs.filter(s => s.cancelled)
|
||||||
|
|
||||||
|
if (active.length === 0 && cancelled.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">No subscriptions yet. Add one above.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const s of [...active, ...cancelled]) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = `list-item${s.cancelled ? ' cancelled' : ''}`
|
||||||
|
const helper = findHelper(s.name)
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="list-item-info">
|
||||||
|
<div class="list-item-name">${escapeHtml(s.name)}</div>
|
||||||
|
<div class="list-item-meta">${escapeHtml(s.category || 'Uncategorized')} • ${s.interval}</div>
|
||||||
|
${s.trialEndsAt ? `<div class="list-item-meta">Trial ends: ${s.trialEndsAt}</div>` : ''}
|
||||||
|
<div class="list-item-actions">
|
||||||
|
${helper ? `<button class="btn-success btn-cancel" data-id="${s.id}">Cancel</button>` : ''}
|
||||||
|
<button class="btn-secondary btn-mark" data-id="${s.id}">${s.cancelled ? 'Restore' : 'Mark Cancelled'}</button>
|
||||||
|
<button class="btn-danger btn-delete" data-id="${s.id}">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-item-cost">${formatCurrency(monthlyCost(s))}/mo</div>
|
||||||
|
`
|
||||||
|
container.appendChild(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-cancel').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = (btn as HTMLElement).dataset.id!
|
||||||
|
const sub = subs.find(s => s.id === id)
|
||||||
|
if (!sub) return
|
||||||
|
const helper = findHelper(sub.name) as { url: string; email?: string; steps: string[] } | undefined
|
||||||
|
if (!helper) return
|
||||||
|
chrome.tabs.create({ url: helper.url })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-mark').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = (btn as HTMLElement).dataset.id!
|
||||||
|
const data = await getData()
|
||||||
|
const s = data.subscriptions.find(x => x.id === id)
|
||||||
|
if (s) { s.cancelled = !s.cancelled; await setData(data); render() }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = (btn as HTMLElement).dataset.id!
|
||||||
|
const data = await getData()
|
||||||
|
data.subscriptions = data.subscriptions.filter(s => s.id !== id)
|
||||||
|
await setData(data)
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHelper(name: string): { url: string; email?: string; steps: string[] } | undefined {
|
||||||
|
const key = name.toLowerCase().trim()
|
||||||
|
return CANCEL_HELPERS[key] || Object.entries(CANCEL_HELPERS).find(([k]) => key.includes(k))?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExpenses(expenses: Expense[]) {
|
||||||
|
const container = document.getElementById('expense-list')!
|
||||||
|
container.innerHTML = ''
|
||||||
|
const sorted = [...expenses].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|
||||||
|
if (sorted.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty">No expenses yet. Add one above.</div>'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of sorted.slice(0, 20)) {
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.className = 'list-item'
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="list-item-info">
|
||||||
|
<div class="list-item-name">${escapeHtml(e.description)}</div>
|
||||||
|
<div class="list-item-meta">${e.date} • ${escapeHtml(e.category || 'Uncategorized')}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-item-cost">${formatCurrency(e.amount)}</div>
|
||||||
|
`
|
||||||
|
container.appendChild(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBudget(budget: { monthlyBudget: number; categories: Record<string, number> }, expenses: Expense[]) {
|
||||||
|
const container = document.getElementById('budget-status')!
|
||||||
|
const thisMonth = new Date().toISOString().slice(0, 7)
|
||||||
|
const spent = expenses.filter(e => e.date.startsWith(thisMonth)).reduce((s, e) => s + e.amount, 0)
|
||||||
|
const pct = budget.monthlyBudget > 0 ? Math.min((spent / budget.monthlyBudget) * 100, 100) : 0
|
||||||
|
const over = budget.monthlyBudget > 0 && spent > budget.monthlyBudget
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="budget-text">${formatCurrency(spent)} of ${formatCurrency(budget.monthlyBudget)} spent</div>
|
||||||
|
<div class="budget-bar"><div class="budget-bar-fill ${over ? 'over' : ''}" style="width:${pct}%"></div></div>
|
||||||
|
<div class="budget-text">${over ? 'Over budget!' : `${(100 - pct).toFixed(0)}% remaining`}</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str: string): string {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.textContent = str
|
||||||
|
return div.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'))
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'))
|
||||||
|
btn.classList.add('active')
|
||||||
|
document.getElementById(`tab-${(btn as HTMLElement).dataset.tab}`)!.classList.add('active')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sub form
|
||||||
|
document.getElementById('sub-form')!.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = await getData()
|
||||||
|
const name = (document.getElementById('sub-name') as HTMLInputElement).value.trim()
|
||||||
|
const cost = parseFloat((document.getElementById('sub-cost') as HTMLInputElement).value)
|
||||||
|
const interval = (document.getElementById('sub-interval') as HTMLSelectElement).value as Subscription['interval']
|
||||||
|
const category = (document.getElementById('sub-category') as HTMLInputElement).value.trim() || 'Other'
|
||||||
|
const started = (document.getElementById('sub-started') as HTMLInputElement).value || new Date().toISOString().slice(0, 10)
|
||||||
|
const trialEnds = (document.getElementById('sub-trial-ends') as HTMLInputElement).value || undefined
|
||||||
|
|
||||||
|
if (!name || isNaN(cost) || cost < 0) return
|
||||||
|
|
||||||
|
const helper = findHelper(name)
|
||||||
|
data.subscriptions.push({
|
||||||
|
id: generateId(), name, cost, interval, startedAt: started,
|
||||||
|
trialEndsAt: trialEnds || undefined, category, url: '',
|
||||||
|
cancelUrl: helper?.url, cancelEmail: helper?.email, cancelled: false
|
||||||
|
})
|
||||||
|
await setData(data)
|
||||||
|
;(e.target as HTMLFormElement).reset()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Expense form
|
||||||
|
document.getElementById('expense-form')!.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = await getData()
|
||||||
|
const date = (document.getElementById('exp-date') as HTMLInputElement).value
|
||||||
|
const desc = (document.getElementById('exp-desc') as HTMLInputElement).value.trim()
|
||||||
|
const category = (document.getElementById('exp-category') as HTMLInputElement).value.trim() || 'Other'
|
||||||
|
const amount = parseFloat((document.getElementById('exp-amount') as HTMLInputElement).value)
|
||||||
|
|
||||||
|
if (!date || !desc || isNaN(amount) || amount < 0) return
|
||||||
|
|
||||||
|
data.expenses.push({ id: generateId(), date, description: desc, category, amount })
|
||||||
|
await setData(data)
|
||||||
|
;(e.target as HTMLFormElement).reset()
|
||||||
|
render()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Budget form
|
||||||
|
document.getElementById('budget-form')!.addEventListener('submit', async e => {
|
||||||
|
e.preventDefault()
|
||||||
|
const data = await getData()
|
||||||
|
const val = parseFloat((document.getElementById('monthly-budget') as HTMLInputElement).value)
|
||||||
|
if (!isNaN(val) && val >= 0) {
|
||||||
|
data.budgetSettings.monthlyBudget = val
|
||||||
|
await setData(data)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Open options
|
||||||
|
document.getElementById('open-options')!.addEventListener('click', e => {
|
||||||
|
e.preventDefault()
|
||||||
|
chrome.runtime.openOptionsPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
render()
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
export interface Subscription {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
cost: number
|
||||||
|
interval: 'monthly' | 'yearly' | 'weekly'
|
||||||
|
startedAt: string
|
||||||
|
trialEndsAt?: string
|
||||||
|
category: string
|
||||||
|
url?: string
|
||||||
|
notes?: string
|
||||||
|
cancelled?: boolean
|
||||||
|
cancelUrl?: string
|
||||||
|
cancelEmail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Expense {
|
||||||
|
id: string
|
||||||
|
amount: number
|
||||||
|
description: string
|
||||||
|
category: string
|
||||||
|
date: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BudgetSettings {
|
||||||
|
monthlyBudget: number
|
||||||
|
categories: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DebtEntry {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
balance: number
|
||||||
|
interestRate: number
|
||||||
|
minPayment: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StoredData {
|
||||||
|
subscriptions: Subscription[]
|
||||||
|
expenses: Expense[]
|
||||||
|
budgetSettings: BudgetSettings
|
||||||
|
debts: DebtEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'quitfree_data'
|
||||||
|
|
||||||
|
export async function getData(): Promise<StoredData> {
|
||||||
|
try {
|
||||||
|
const result = await chrome.storage.local.get(STORAGE_KEY)
|
||||||
|
const raw = result[STORAGE_KEY] as StoredData | undefined
|
||||||
|
return raw ?? { subscriptions: [], expenses: [], budgetSettings: { monthlyBudget: 0, categories: {} }, debts: [] }
|
||||||
|
} catch {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (raw) {
|
||||||
|
try { return JSON.parse(raw) } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return { subscriptions: [], expenses: [], budgetSettings: { monthlyBudget: 0, categories: {} }, debts: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setData(data: StoredData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await chrome.storage.local.set({ [STORAGE_KEY]: data })
|
||||||
|
} catch {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthlyCost(sub: Subscription): number {
|
||||||
|
switch (sub.interval) {
|
||||||
|
case 'weekly': return sub.cost * 4.33
|
||||||
|
case 'yearly': return sub.cost / 12
|
||||||
|
case 'monthly':
|
||||||
|
default: return sub.cost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function annualCost(sub: Subscription): number {
|
||||||
|
switch (sub.interval) {
|
||||||
|
case 'weekly': return sub.cost * 52
|
||||||
|
case 'yearly': return sub.cost
|
||||||
|
case 'monthly':
|
||||||
|
default: return sub.cost * 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalMonthlyCost(subs: Subscription[]): number {
|
||||||
|
return subs.filter(s => !s.cancelled).reduce((sum, s) => sum + monthlyCost(s), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalAnnualCost(subs: Subscription[]): number {
|
||||||
|
return subs.filter(s => !s.cancelled).reduce((sum, s) => sum + annualCost(s), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(dateStr: string): number {
|
||||||
|
const diff = new Date(dateStr).getTime() - Date.now()
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
|
return days === -0 ? 0 : days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionsExpiringSoon(subs: Subscription[], days = 3): Subscription[] {
|
||||||
|
return subs.filter(s => s.trialEndsAt && !s.cancelled && daysUntil(s.trialEndsAt) <= days && daysUntil(s.trialEndsAt) >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscriptionsToCSV(subs: Subscription[]): string {
|
||||||
|
const headers = ['Name', 'Cost', 'Interval', 'Monthly Equiv', 'Annual Equiv', 'Started', 'Trial Ends', 'Category', 'URL', 'Cancelled']
|
||||||
|
const rows = subs.map(s => [
|
||||||
|
s.name, String(s.cost), s.interval, String(monthlyCost(s).toFixed(2)),
|
||||||
|
String(annualCost(s).toFixed(2)), s.startedAt, s.trialEndsAt || '', s.category, s.url || '', s.cancelled ? 'Yes' : 'No'
|
||||||
|
])
|
||||||
|
return [headers, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expensesToCSV(expenses: Expense[]): string {
|
||||||
|
const headers = ['Date', 'Description', 'Category', 'Amount']
|
||||||
|
const rows = expenses.map(e => [e.date, e.description, e.category, String(e.amount.toFixed(2))])
|
||||||
|
return [headers, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportToJSON(data: StoredData): string {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFile(filename: string, content: string, type = 'text/csv'): void {
|
||||||
|
const blob = new Blob([content], { type })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CANCEL_HELPERS: Record<string, { url: string; email?: string; steps: string[] }> = {
|
||||||
|
'netflix': { url: 'https://www.netflix.com/cancelplan', steps: ['Sign in', 'Go to Account', 'Click Cancel Membership'] },
|
||||||
|
'spotify': { url: 'https://www.spotify.com/account/cancel/', steps: ['Sign in', 'Go to Account Overview', 'Click Cancel Premium'] },
|
||||||
|
'adobe': { url: 'https://account.adobe.com/plans', steps: ['Sign in', 'Manage plan', 'Click Cancel plan'] },
|
||||||
|
'amazon prime': { url: 'https://www.amazon.com/primecentral', steps: ['Go to Account & Lists', 'Prime Membership', 'End Membership'] },
|
||||||
|
'hulu': { url: 'https://www.hulu.com/account/cancel', steps: ['Sign in', 'Account page', 'Cancel Subscription'] },
|
||||||
|
'disney+': { url: 'https://www.disneyplus.com/account', steps: ['Sign in', 'Billing Details', 'Cancel Subscription'] },
|
||||||
|
'apple music': { url: 'https://music.apple.com/us/account/settings', steps: ['Open Settings on device', 'Tap your name', 'Subscriptions', 'Cancel'] },
|
||||||
|
'youtube premium': { url: 'https://www.youtube.com/paid_memberships', steps: ['Sign in', 'Paid Memberships', 'Manage', 'Cancel'] },
|
||||||
|
'canva': { url: 'https://www.canva.com/teams/settings', steps: ['Sign in', 'Team Settings', 'Billing', 'Cancel'] },
|
||||||
|
'notion': { url: 'https://www.notion.so/settings', steps: ['Sign in', 'Settings', 'Billing', 'Cancel'] },
|
||||||
|
'figma': { url: 'https://www.figma.com/settings', steps: ['Sign in', 'Settings', 'Plan', 'Cancel'] },
|
||||||
|
'grammarly': { url: 'https://account.grammarly.com/subscription', steps: ['Sign in', 'Subscription', 'Cancel'] },
|
||||||
|
'chatgpt': { url: 'https://chat.openai.com/#settings', steps: ['Sign in', 'Settings', 'My plan', 'Cancel'] },
|
||||||
|
'midjourney': { url: 'https://www.midjourney.com/account/', steps: ['Sign in', 'Manage Plan', 'Cancel'] },
|
||||||
|
'duolingo': { url: 'https://www.duolingo.com/settings', steps: ['Sign in', 'Super settings', 'Manage Plan', 'Cancel'] },
|
||||||
|
'hellofresh': { url: 'https://www.hellofresh.com/account-settings', steps: ['Sign in', 'Account Settings', 'Cancel'] },
|
||||||
|
'audible': { url: 'https://www.audible.com/account/settings', steps: ['Sign in', 'Account Details', 'Cancel Membership'] },
|
||||||
|
'patreon': { url: 'https://www.patreon.com/settings', steps: ['Sign in', 'Settings', 'Memberships', 'Edit', 'Cancel'] },
|
||||||
|
'dropbox': { url: 'https://www.dropbox.com/account/plan', steps: ['Sign in', 'Plan', 'Cancel'] },
|
||||||
|
'google one': { url: 'https://one.google.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'todoist': { url: 'https://todoist.com/app/settings', steps: ['Sign in', 'Settings', 'Subscription', 'Cancel'] },
|
||||||
|
'nordvpn': { url: 'https://my.nordaccount.com/dashboard/nordvpn/', steps: ['Sign in', 'Dashboard', 'Cancel'] },
|
||||||
|
'1password': { url: 'https://my.1password.com/billing', steps: ['Sign in', 'Billing', 'Cancel'] },
|
||||||
|
'lastpass': { url: 'https://lastpass.com/my-lastpass', steps: ['Sign in', 'My Account', 'Cancel'] },
|
||||||
|
'bitdefender': { url: 'https://central.bitdefender.com', steps: ['Sign in', 'My Devices', 'Cancel'] },
|
||||||
|
'avast': { url: 'https://id.avast.com', steps: ['Sign in', 'Subscriptions', 'Cancel'] },
|
||||||
|
'malwarebytes': { url: 'https://my.malwarebytes.com', steps: ['Sign in', 'My Account', 'Cancel'] },
|
||||||
|
'norton': { url: 'https://my.norton.com', steps: ['Sign in', 'My Account', 'Auto-Renewal', 'Turn Off'] },
|
||||||
|
'ynab': { url: 'https://app.youneedabudget.com/settings', steps: ['Sign in', 'Settings', 'Subscription', 'Cancel'] },
|
||||||
|
'rocket money': { url: 'https://rocketmoney.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'albert': { url: 'https://albert.com/settings', steps: ['Open app', 'Profile', 'Settings', 'Cancel'] },
|
||||||
|
'acorns': { url: 'https://www.acorns.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'betterment': { url: 'https://www.betterment.com/app/account', steps: ['Sign in', 'Account', 'Cancel'] },
|
||||||
|
'wealthfront': { url: 'https://www.wealthfront.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'sofi': { url: 'https://www.sofi.com/my/dashboard', steps: ['Sign in', 'Dashboard', 'Cancel'] },
|
||||||
|
'klarna': { url: 'https://app.klarna.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'affirm': { url: 'https://www.affirm.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'credit karma': { url: 'https://www.creditkarma.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] },
|
||||||
|
'experian': { url: 'https://www.experian.com/account', steps: ['Sign in', 'Account', 'Cancel'] }
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
monthlyCost, annualCost, totalMonthlyCost, totalAnnualCost, daysUntil,
|
||||||
|
subscriptionsExpiringSoon, subscriptionsToCSV, expensesToCSV, generateId, formatCurrency
|
||||||
|
} from '../src/shared/storage'
|
||||||
|
import type { Subscription, Expense } from '../src/shared/storage'
|
||||||
|
|
||||||
|
describe('monthlyCost', () => {
|
||||||
|
it('returns monthly cost for monthly interval', () => {
|
||||||
|
const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
expect(monthlyCost(s)).toBe(10)
|
||||||
|
})
|
||||||
|
it('returns monthly cost for yearly interval', () => {
|
||||||
|
const s: Subscription = { id: '1', name: 'Test', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
expect(monthlyCost(s)).toBe(10)
|
||||||
|
})
|
||||||
|
it('returns monthly cost for weekly interval', () => {
|
||||||
|
const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'weekly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
expect(monthlyCost(s)).toBeCloseTo(43.3, 1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('annualCost', () => {
|
||||||
|
it('returns annual cost for monthly interval', () => {
|
||||||
|
const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
expect(annualCost(s)).toBe(120)
|
||||||
|
})
|
||||||
|
it('returns annual cost for yearly interval', () => {
|
||||||
|
const s: Subscription = { id: '1', name: 'Test', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
expect(annualCost(s)).toBe(120)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('totalMonthlyCost', () => {
|
||||||
|
it('sums only active subscriptions', () => {
|
||||||
|
const subs: Subscription[] = [
|
||||||
|
{ id: '1', name: 'A', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' },
|
||||||
|
{ id: '2', name: 'B', cost: 20, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', cancelled: true },
|
||||||
|
{ id: '3', name: 'C', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' }
|
||||||
|
]
|
||||||
|
expect(totalMonthlyCost(subs)).toBe(20)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('daysUntil', () => {
|
||||||
|
it('returns days until a future date', () => {
|
||||||
|
const future = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10)
|
||||||
|
expect(daysUntil(future)).toBe(3)
|
||||||
|
})
|
||||||
|
it('returns 0 for today', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
expect(daysUntil(today)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscriptionsExpiringSoon', () => {
|
||||||
|
it('returns trials expiring within given days', () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10)
|
||||||
|
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)
|
||||||
|
const subs: Subscription[] = [
|
||||||
|
{ id: '1', name: 'A', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: today },
|
||||||
|
{ id: '2', name: 'B', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: tomorrow },
|
||||||
|
{ id: '3', name: 'C', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: nextWeek }
|
||||||
|
]
|
||||||
|
expect(subscriptionsExpiringSoon(subs, 3).length).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatCurrency', () => {
|
||||||
|
it('formats dollars correctly', () => {
|
||||||
|
expect(formatCurrency(10)).toBe('$10.00')
|
||||||
|
expect(formatCurrency(10.5)).toBe('$10.50')
|
||||||
|
expect(formatCurrency(0)).toBe('$0.00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateId', () => {
|
||||||
|
it('generates unique ids', () => {
|
||||||
|
const id1 = generateId()
|
||||||
|
const id2 = generateId()
|
||||||
|
expect(id1).not.toBe(id2)
|
||||||
|
expect(id1.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('subscriptionsToCSV', () => {
|
||||||
|
it('produces valid CSV', () => {
|
||||||
|
const subs: Subscription[] = [
|
||||||
|
{ id: '1', name: 'Netflix', cost: 15.99, interval: 'monthly', startedAt: '2024-01-01', category: 'Entertainment' }
|
||||||
|
]
|
||||||
|
const csv = subscriptionsToCSV(subs)
|
||||||
|
expect(csv).toContain('Name')
|
||||||
|
expect(csv).toContain('Netflix')
|
||||||
|
expect(csv).toContain('15.99')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('expensesToCSV', () => {
|
||||||
|
it('produces valid CSV', () => {
|
||||||
|
const expenses: Expense[] = [
|
||||||
|
{ id: '1', amount: 25.50, description: 'Lunch', category: 'Food', date: '2024-01-01' }
|
||||||
|
]
|
||||||
|
const csv = expensesToCSV(expenses)
|
||||||
|
expect(csv).toContain('Description')
|
||||||
|
expect(csv).toContain('Lunch')
|
||||||
|
expect(csv).toContain('25.50')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["chrome", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"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({
|
||||||
|
plugins: [crx({ manifest })],
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user