QuitFree MVP: subscription tracker, budget manager, cancel helper, debt calculator, CSV/JSON export. 13 tests passing.

This commit is contained in:
Bun Bun
2026-06-20 12:28:17 +00:00
commit e01cea8825
20 changed files with 3488 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.venv/
*.log
.verdict
.DS_Store
*.local
+40
View File
@@ -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"
}
}
+1896
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -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

+43
View File
@@ -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');
+33
View File
@@ -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')
+85
View File
@@ -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)
}
+159
View File
@@ -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; }
+59
View File
@@ -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>
+239
View File
@@ -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()
+289
View File
@@ -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; }
+81
View File
@@ -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>
+222
View File
@@ -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()
+181
View File
@@ -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'] }
}
+109
View File
@@ -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')
})
})
+16
View File
@@ -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/**/*"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json' assert { type: 'json' }
export default defineConfig({
plugins: [crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})