Initial MVP: Cost.dev AI Coding Cost Optimizer Chrome extension
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,51 @@
|
||||
# Cost.dev — AI Coding Cost Optimizer
|
||||
|
||||
**v1 MVP** — A Chrome extension that helps developers track, budget, and control spending on AI coding tools.
|
||||
|
||||
## What it does (real features)
|
||||
|
||||
- **Manual usage logging**: Log spend per tool per day with notes.
|
||||
- **Monthly dashboard**: See total spend, per-tool breakdown, daily trend list, and projected month-end bill.
|
||||
- **Budget & alerts**: Set a monthly budget and alert threshold. Over-budget notifications via `chrome.alarms` + `chrome.notifications` (checked hourly).
|
||||
- **Tool registry**: Add your AI tools (Claude Code, GitHub Copilot, Cursor, etc.) with estimated monthly cost and billing model (fixed / usage / hybrid).
|
||||
- **Local-only storage**: All data lives in `chrome.storage.local` — no backend, no cloud, no accounts.
|
||||
- **Empty-state handling**: Clean UI when no tools or data exist yet.
|
||||
|
||||
## What is real vs. manual / deferred
|
||||
|
||||
- **Real**: Manual entry, local storage, dashboard math, alarms/notifications, popup UI.
|
||||
- **Manual**: All usage numbers must be entered by you. We do **not** auto-scrape billing pages because auth/DOM varies and breaks — the content script is a scaffold that detects you're on a supported page and may support quick-capture in a future version.
|
||||
- **Deferred**: Chrome Web Store publishing, auto-import from APIs, paid tiers, backend sync, cross-device sync.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Vite + `@crxjs/vite-plugin` + TypeScript
|
||||
- Manifest V3
|
||||
- Permissions: `storage`, `alarms`, `notifications`
|
||||
- Host permissions (content script): `cursor.com`, `github.com/copilot` (scaffold only, no auto-read in v1)
|
||||
|
||||
## Build
|
||||
|
||||
Requires `npm install --include=dev` because this environment runs with `NODE_ENV=production`.
|
||||
|
||||
```bash
|
||||
npm install --include=dev
|
||||
npm run build
|
||||
```
|
||||
|
||||
The build output goes to `dist/`. Confirm `dist/manifest.json` exists.
|
||||
|
||||
## Load unpacked
|
||||
|
||||
1. Open `chrome://extensions`
|
||||
2. Enable **Developer mode**
|
||||
3. Click **Load unpacked**
|
||||
4. Select the `dist/` folder
|
||||
|
||||
## Gitea
|
||||
|
||||
```bash
|
||||
git push https://git.bunbunlabs.com/bunbun/cost-dev.git HEAD:refs/heads/main
|
||||
```
|
||||
|
||||
Built by Bun Bun for BunBun Labs 🐰
|
||||
@@ -0,0 +1,70 @@
|
||||
// Minimal PNG generator — run before build to create icon files
|
||||
const { writeFileSync, mkdirSync } = require('fs')
|
||||
|
||||
function crc32(buf) {
|
||||
const table = new Int32Array(256)
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let c = i
|
||||
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
|
||||
table[i] = c
|
||||
}
|
||||
let crc = -1
|
||||
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)
|
||||
return crc ^ -1
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const len = data.length
|
||||
const chunk = new Uint8Array(4 + 4 + len + 4)
|
||||
const view = new DataView(chunk.buffer)
|
||||
view.setUint32(0, len, false)
|
||||
chunk.set(new TextEncoder().encode(type), 4)
|
||||
chunk.set(data, 8)
|
||||
const crc = crc32(chunk.slice(4, 8 + len))
|
||||
view.setUint32(8 + len, crc, false)
|
||||
return chunk
|
||||
}
|
||||
|
||||
function generatePNG(size, color) {
|
||||
const header = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const ihdr = new Uint8Array(13)
|
||||
const v = new DataView(ihdr.buffer)
|
||||
v.setUint32(0, size, false)
|
||||
v.setUint32(4, size, false)
|
||||
v.setUint8(8, 8)
|
||||
v.setUint8(9, 2)
|
||||
v.setUint8(10, 0)
|
||||
v.setUint8(11, 0)
|
||||
v.setUint8(12, 0)
|
||||
|
||||
const rowLen = 1 + size * 3
|
||||
const raw = new Uint8Array(size * rowLen)
|
||||
for (let y = 0; y < size; y++) {
|
||||
raw[y * rowLen] = 0
|
||||
for (let x = 0; x < size; x++) {
|
||||
const off = y * rowLen + 1 + x * 3
|
||||
raw[off] = color[0]
|
||||
raw[off + 1] = color[1]
|
||||
raw[off + 2] = color[2]
|
||||
}
|
||||
}
|
||||
|
||||
const compressed = new Uint8Array(require('zlib').deflateSync(raw))
|
||||
const idat = pngChunk('IDAT', compressed)
|
||||
const iend = pngChunk('IEND', new Uint8Array(0))
|
||||
|
||||
const out = new Uint8Array(header.length + 4 + 4 + 13 + 4 + idat.length + iend.length)
|
||||
let p = 0
|
||||
out.set(header, p); p += header.length
|
||||
out.set(pngChunk('IHDR', ihdr), p); p += 4 + 4 + 13 + 4
|
||||
out.set(idat, p); p += idat.length
|
||||
out.set(iend, p)
|
||||
return out
|
||||
}
|
||||
|
||||
mkdirSync('icons', { recursive: true })
|
||||
const teal = [0, 180, 170]
|
||||
writeFileSync('icons/icon16.png', generatePNG(16, teal))
|
||||
writeFileSync('icons/icon48.png', generatePNG(48, teal))
|
||||
writeFileSync('icons/icon128.png', generatePNG(128, teal))
|
||||
console.log('Icons generated')
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 405 B |
Binary file not shown.
|
After Width: | Height: | Size: 78 B |
Binary file not shown.
|
After Width: | Height: | Size: 122 B |
Generated
+1493
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "cost-dev",
|
||||
"version": "1.0.0",
|
||||
"description": "AI Coding Cost Optimizer — track and control AI tool spending",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"dev": "vite",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||
"@types/chrome": "^0.0.268",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { loadState, saveState, sumMonthUsages, projectedMonthEnd } from './storage'
|
||||
|
||||
const ALARM_NAME = 'costdev_budget_check'
|
||||
|
||||
chrome.runtime.onInstalled.addListener(async () => {
|
||||
await chrome.alarms.create(ALARM_NAME, { periodInMinutes: 60 })
|
||||
})
|
||||
|
||||
chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
if (alarm.name !== ALARM_NAME) return
|
||||
const state = await loadState()
|
||||
if (!state.budget.enabled || state.budget.monthlyLimit <= 0) return
|
||||
|
||||
const spent = sumMonthUsages(state.usages)
|
||||
const limit = state.budget.monthlyLimit
|
||||
const threshold = limit * state.budget.alertThreshold
|
||||
|
||||
if (spent >= threshold && spent < limit) {
|
||||
const last = state.lastAlertAt ?? 0
|
||||
if (Date.now() - last > 24 * 60 * 60 * 1000) {
|
||||
chrome.notifications.create('costdev-warning', {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'Cost.dev — Budget Alert',
|
||||
message: `You've spent $${spent.toFixed(2)} of your $${limit.toFixed(2)} monthly budget.`,
|
||||
priority: 1,
|
||||
})
|
||||
state.lastAlertAt = Date.now()
|
||||
await saveState(state)
|
||||
}
|
||||
}
|
||||
|
||||
if (spent >= limit) {
|
||||
const last = state.lastAlertAt ?? 0
|
||||
if (Date.now() - last > 24 * 60 * 60 * 1000) {
|
||||
chrome.notifications.create('costdev-over', {
|
||||
type: 'basic',
|
||||
iconUrl: 'icons/icon128.png',
|
||||
title: 'Cost.dev — Budget Overrun',
|
||||
message: `You've exceeded your $${limit.toFixed(2)} monthly budget! Current spend: $${spent.toFixed(2)}.`,
|
||||
priority: 2,
|
||||
})
|
||||
state.lastAlertAt = Date.now()
|
||||
await saveState(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||
;(async () => {
|
||||
if (message.type === 'budget-check') {
|
||||
const state = await loadState()
|
||||
const spent = sumMonthUsages(state.usages)
|
||||
const limit = state.budget.monthlyLimit
|
||||
const projected = projectedMonthEnd(spent)
|
||||
sendResponse({ spent, limit, projected, enabled: state.budget.enabled })
|
||||
} else {
|
||||
sendResponse({ ok: false })
|
||||
}
|
||||
})()
|
||||
return true
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
// Content script — honest about what we can and cannot read client-side
|
||||
// v1: we do NOT auto-scrape private billing pages because authentication
|
||||
// and DOM structures vary and break. Instead, we watch for visible plan/usage
|
||||
// text on dashboard pages and offer a "Quick Capture" to the user via a
|
||||
// floating badge (not implemented in v1 to keep scope tight).
|
||||
// In v1, this script is a lightweight scaffold that sends a heartbeat
|
||||
// so the popup knows the user is on a supported tool page.
|
||||
|
||||
interface PageInfo {
|
||||
url: string
|
||||
title: string
|
||||
hasBillingText: boolean
|
||||
}
|
||||
|
||||
function detectBillingIndicators(): boolean {
|
||||
const text = document.body.innerText.toLowerCase()
|
||||
const indicators = [
|
||||
'usage',
|
||||
'tokens',
|
||||
'requests',
|
||||
'credits',
|
||||
'billing',
|
||||
'subscription',
|
||||
'plan',
|
||||
'limit',
|
||||
'quota',
|
||||
]
|
||||
return indicators.some((w) => text.includes(w))
|
||||
}
|
||||
|
||||
const pageInfo: PageInfo = {
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
hasBillingText: detectBillingIndicators(),
|
||||
}
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'page-info', data: pageInfo }).catch(() => {
|
||||
// no-op if popup closed
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Cost.dev — AI Coding Cost Optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "Track, budget, and control your AI coding tool spend. Manual entry + dashboard.",
|
||||
"permissions": ["storage", "alarms", "notifications"],
|
||||
"host_permissions": [
|
||||
"https://cursor.com/*",
|
||||
"https://www.cursor.com/*",
|
||||
"https://github.com/copilot/*",
|
||||
"https://copilot.github.com/*"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "src/background.ts",
|
||||
"type": "module"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
}
|
||||
},
|
||||
"icons": {
|
||||
"16": "icons/icon16.png",
|
||||
"48": "icons/icon48.png",
|
||||
"128": "icons/icon128.png"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://cursor.com/*",
|
||||
"https://www.cursor.com/*",
|
||||
"https://github.com/copilot/*",
|
||||
"https://copilot.github.com/*"
|
||||
],
|
||||
"js": ["src/content.ts"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0f172a;
|
||||
--card: #1e293b;
|
||||
--text: #f1f5f9;
|
||||
--muted: #94a3b8;
|
||||
--accent: #2dd4bf;
|
||||
--accent-dim: #0f766e;
|
||||
--danger: #f87171;
|
||||
--warning: #fbbf24;
|
||||
--border: #334155;
|
||||
--radius: 8px;
|
||||
--gap: 12px;
|
||||
}
|
||||
body {
|
||||
width: 380px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
#app { padding: 16px; }
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.logo-icon { font-size: 22px; }
|
||||
.brand h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.brand p { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.icon-btn:hover { background: var(--card); }
|
||||
|
||||
.overview .card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.overview .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.overview .big-number { font-size: 32px; font-weight: 800; margin: 6px 0; color: var(--accent); }
|
||||
.overview .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.progress-wrap { margin-top: 10px; height: 8px; background: #0f172a; border-radius: 4px; overflow: hidden; }
|
||||
.progress-bar { height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||
|
||||
h2 { font-size: 14px; font-weight: 600; margin: 14px 0 8px; }
|
||||
|
||||
.tools-section, .add-usage-section, .trend-section { margin-bottom: 14px; }
|
||||
|
||||
.tools-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.tool-item {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tool-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tool-name { font-weight: 600; font-size: 13px; }
|
||||
.tool-meta { color: var(--muted); font-size: 11px; }
|
||||
.tool-right { text-align: right; }
|
||||
.tool-spend { font-weight: 700; font-size: 14px; }
|
||||
.tool-budget { font-size: 11px; color: var(--muted); }
|
||||
.tool-actions { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.btn-small {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.btn-small:hover { background: var(--border); }
|
||||
|
||||
.form { display: flex; flex-direction: column; gap: 8px; }
|
||||
.form.compact { flex-direction: row; flex-wrap: wrap; gap: 6px; }
|
||||
.form.compact input, .form.compact select { flex: 1 1 120px; }
|
||||
.form.compact button { flex: 0 0 auto; }
|
||||
input, select, textarea {
|
||||
background: #0f172a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); }
|
||||
.btn-primary {
|
||||
background: var(--accent-dim);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent); color: #000; }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trend-list { display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; }
|
||||
.trend-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
background: var(--card);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.trend-row .day { color: var(--muted); }
|
||||
.trend-row .val { font-weight: 600; }
|
||||
|
||||
.empty-state { text-align: center; padding: 30px 16px; color: var(--muted); }
|
||||
.empty-icon { font-size: 36px; margin-bottom: 8px; }
|
||||
.empty-state p { font-size: 13px; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-content {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
width: 340px;
|
||||
}
|
||||
.modal-content h2 { margin-bottom: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
|
||||
.danger-zone { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 12px; }
|
||||
.row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Cost.dev</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header class="header">
|
||||
<div class="brand">
|
||||
<span class="logo-icon">💰</span>
|
||||
<div>
|
||||
<h1>Cost.dev</h1>
|
||||
<p>AI Coding Cost Optimizer</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="btn-settings" class="icon-btn" title="Budget & Settings">⚙️</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="overview" class="overview">
|
||||
<div class="card">
|
||||
<div class="label">This Month</div>
|
||||
<div class="big-number" id="total-spend">$0.00</div>
|
||||
<div class="sub" id="budget-sub">Budget: $50.00</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" id="budget-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<div class="sub" id="projected-sub">Projected: $0.00</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tools-section" class="tools-section">
|
||||
<h2>Your Tools</h2>
|
||||
<div id="tools-list" class="tools-list"></div>
|
||||
<form id="add-tool-form" class="form compact">
|
||||
<input type="text" id="tool-name" placeholder="Tool name (e.g. Claude Code)" required>
|
||||
<input type="number" id="tool-cost" placeholder="Monthly cost $" min="0" step="0.01" required>
|
||||
<select id="tool-model">
|
||||
<option value="fixed">Fixed plan</option>
|
||||
<option value="usage">Usage-based</option>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
</select>
|
||||
<button type="submit" class="btn-primary">Add Tool</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="add-usage-section" class="add-usage-section">
|
||||
<h2>Log Usage</h2>
|
||||
<form id="add-usage-form" class="form compact">
|
||||
<select id="usage-tool" required>
|
||||
<option value="" disabled selected>Select tool</option>
|
||||
</select>
|
||||
<input type="date" id="usage-date" required>
|
||||
<input type="number" id="usage-amount" placeholder="Amount $" min="0" step="0.01" required>
|
||||
<input type="text" id="usage-note" placeholder="Note (optional)">
|
||||
<button type="submit" class="btn-primary">Log</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="trend-section" class="trend-section">
|
||||
<h2>Daily Breakdown</h2>
|
||||
<div id="trend-list" class="trend-list"></div>
|
||||
</section>
|
||||
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<div class="empty-icon">📭</div>
|
||||
<p>No tools yet. Add one above to start tracking.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings modal -->
|
||||
<div id="settings-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h2>Budget & Settings</h2>
|
||||
<form id="budget-form" class="form">
|
||||
<label>Monthly Budget ($)</label>
|
||||
<input type="number" id="budget-limit" min="0" step="1" required>
|
||||
<label>Alert Threshold (%)</label>
|
||||
<input type="number" id="budget-threshold" min="1" max="100" step="1" required>
|
||||
<div class="row">
|
||||
<input type="checkbox" id="budget-enabled" checked>
|
||||
<label for="budget-enabled">Enable alerts</label>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="btn-close-settings" class="btn-secondary">Close</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="danger-zone">
|
||||
<button id="btn-reset" class="btn-danger">Reset All Data</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="popup.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { AppState, Budget, ToolEntry, UsageRecord } from '../types'
|
||||
import {
|
||||
loadState,
|
||||
saveState,
|
||||
addTool,
|
||||
addUsage,
|
||||
removeTool,
|
||||
removeUsage,
|
||||
setBudget,
|
||||
sumMonthUsages,
|
||||
sumToolMonthUsages,
|
||||
dailyTotals,
|
||||
projectedMonthEnd,
|
||||
clearAll,
|
||||
} from '../storage'
|
||||
import { newId, DEFAULT_BUDGET, getMonthKey, getTodayKey } from '../types'
|
||||
|
||||
// --- DOM refs ---
|
||||
const el = (id: string) => document.getElementById(id)!
|
||||
|
||||
let state: AppState = { version: 1, tools: [], usages: [], budget: { ...DEFAULT_BUDGET } }
|
||||
|
||||
async function init() {
|
||||
state = await loadState()
|
||||
render()
|
||||
wireEvents()
|
||||
}
|
||||
|
||||
function render() {
|
||||
const month = getMonthKey()
|
||||
const total = sumMonthUsages(state.usages, month)
|
||||
const limit = state.budget.monthlyLimit
|
||||
const projected = projectedMonthEnd(total)
|
||||
|
||||
// Overview
|
||||
el('total-spend').textContent = `$${total.toFixed(2)}`
|
||||
el('budget-sub').textContent = `Budget: $${limit.toFixed(2)}`
|
||||
el('projected-sub').textContent = `Projected: $${projected.toFixed(2)}`
|
||||
const pct = limit > 0 ? Math.min((total / limit) * 100, 100) : 0
|
||||
el('budget-bar').style.width = `${pct}%`
|
||||
el('budget-bar').style.background = pct > 100 ? 'var(--danger)' : pct > 80 ? 'var(--warning)' : 'var(--accent)'
|
||||
|
||||
// Tools list
|
||||
const toolsList = el('tools-list')
|
||||
toolsList.innerHTML = ''
|
||||
if (state.tools.length === 0) {
|
||||
el('empty-state').classList.remove('hidden')
|
||||
el('tools-section').classList.add('hidden')
|
||||
el('add-usage-section').classList.add('hidden')
|
||||
el('trend-section').classList.add('hidden')
|
||||
} else {
|
||||
el('empty-state').classList.add('hidden')
|
||||
el('tools-section').classList.remove('hidden')
|
||||
el('add-usage-section').classList.remove('hidden')
|
||||
el('trend-section').classList.remove('hidden')
|
||||
|
||||
for (const t of state.tools) {
|
||||
const toolSpent = sumToolMonthUsages(state.usages, t.id, month)
|
||||
const div = document.createElement('div')
|
||||
div.className = 'tool-item'
|
||||
div.innerHTML = `
|
||||
<div class="tool-left">
|
||||
<div class="tool-name">${escapeHtml(t.name)}</div>
|
||||
<div class="tool-meta">${escapeHtml(t.billingModel)}${t.planName ? ' · ' + escapeHtml(t.planName) : ''}</div>
|
||||
<div class="tool-actions">
|
||||
<button class="btn-small" data-action="add-usage" data-tool="${t.id}">+ Usage</button>
|
||||
<button class="btn-small" data-action="delete-tool" data-tool="${t.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-right">
|
||||
<div class="tool-spend">$${toolSpent.toFixed(2)}</div>
|
||||
<div class="tool-budget">${t.monthlyCost > 0 ? 'Est: $' + t.monthlyCost.toFixed(2) + '/mo' : 'No estimate'}</div>
|
||||
</div>
|
||||
`
|
||||
toolsList.appendChild(div)
|
||||
}
|
||||
}
|
||||
|
||||
// Usage tool selector
|
||||
const usageTool = el('usage-tool') as HTMLSelectElement
|
||||
const prevVal = usageTool.value
|
||||
usageTool.innerHTML = '<option value="" disabled selected>Select tool</option>'
|
||||
for (const t of state.tools) {
|
||||
const opt = document.createElement('option')
|
||||
opt.value = t.id
|
||||
opt.textContent = t.name
|
||||
usageTool.appendChild(opt)
|
||||
}
|
||||
if (prevVal) usageTool.value = prevVal
|
||||
|
||||
// Daily trend
|
||||
const trendList = el('trend-list')
|
||||
trendList.innerHTML = ''
|
||||
const days = dailyTotals(state.usages, month)
|
||||
if (days.length === 0) {
|
||||
const empty = document.createElement('div')
|
||||
empty.className = 'trend-row'
|
||||
empty.innerHTML = '<span class="day">No data yet</span><span class="val">—</span>'
|
||||
trendList.appendChild(empty)
|
||||
} else {
|
||||
for (const d of days) {
|
||||
const row = document.createElement('div')
|
||||
row.className = 'trend-row'
|
||||
row.innerHTML = `<span class="day">${d.day}</span><span class="val">$${d.total.toFixed(2)}</span>`
|
||||
trendList.appendChild(row)
|
||||
}
|
||||
}
|
||||
|
||||
// Settings form values
|
||||
;(el('budget-limit') as HTMLInputElement).value = String(state.budget.monthlyLimit)
|
||||
;(el('budget-threshold') as HTMLInputElement).value = String(Math.round(state.budget.alertThreshold * 100))
|
||||
;(el('budget-enabled') as HTMLInputElement).checked = state.budget.enabled
|
||||
}
|
||||
|
||||
function wireEvents() {
|
||||
el('add-tool-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const name = (el('tool-name') as HTMLInputElement).value.trim()
|
||||
const cost = parseFloat((el('tool-cost') as HTMLInputElement).value)
|
||||
const model = (el('tool-model') as HTMLSelectElement).value as ToolEntry['billingModel']
|
||||
if (!name || isNaN(cost) || cost < 0) return
|
||||
const tool: ToolEntry = {
|
||||
id: newId(),
|
||||
name,
|
||||
monthlyCost: cost,
|
||||
currency: 'USD',
|
||||
billingModel: model,
|
||||
lastUpdated: Date.now(),
|
||||
}
|
||||
await addTool(tool)
|
||||
state = await loadState()
|
||||
;(el('add-tool-form') as HTMLFormElement).reset()
|
||||
render()
|
||||
})
|
||||
|
||||
el('add-usage-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const toolId = (el('usage-tool') as HTMLSelectElement).value
|
||||
const date = (el('usage-date') as HTMLInputElement).value
|
||||
const amount = parseFloat((el('usage-amount') as HTMLInputElement).value)
|
||||
const note = (el('usage-note') as HTMLInputElement).value.trim()
|
||||
if (!toolId || !date || isNaN(amount) || amount < 0) return
|
||||
const record: UsageRecord = {
|
||||
id: newId(),
|
||||
toolId,
|
||||
date,
|
||||
amount,
|
||||
note: note || undefined,
|
||||
source: 'manual',
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
await addUsage(record)
|
||||
state = await loadState()
|
||||
;(el('add-usage-form') as HTMLFormElement).reset()
|
||||
;(el('usage-date') as HTMLInputElement).value = getTodayKey()
|
||||
render()
|
||||
})
|
||||
|
||||
el('tools-list').addEventListener('click', async (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('button') as HTMLButtonElement | null
|
||||
if (!btn) return
|
||||
const action = btn.dataset.action
|
||||
const toolId = btn.dataset.tool
|
||||
if (!action || !toolId) return
|
||||
if (action === 'delete-tool') {
|
||||
await removeTool(toolId)
|
||||
state = await loadState()
|
||||
render()
|
||||
} else if (action === 'add-usage') {
|
||||
;(el('usage-tool') as HTMLSelectElement).value = toolId
|
||||
el('add-usage-section').scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
})
|
||||
|
||||
el('btn-settings').addEventListener('click', () => {
|
||||
el('settings-modal').classList.remove('hidden')
|
||||
})
|
||||
el('btn-close-settings').addEventListener('click', () => {
|
||||
el('settings-modal').classList.add('hidden')
|
||||
})
|
||||
el('budget-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault()
|
||||
const limit = parseFloat((el('budget-limit') as HTMLInputElement).value)
|
||||
const threshold = parseInt((el('budget-threshold') as HTMLInputElement).value, 10) / 100
|
||||
const enabled = (el('budget-enabled') as HTMLInputElement).checked
|
||||
if (isNaN(limit) || isNaN(threshold)) return
|
||||
const budget: Budget = { monthlyLimit: limit, currency: 'USD', alertThreshold: threshold, enabled }
|
||||
await setBudget(budget)
|
||||
state = await loadState()
|
||||
el('settings-modal').classList.add('hidden')
|
||||
render()
|
||||
})
|
||||
el('btn-reset').addEventListener('click', async () => {
|
||||
if (!confirm('Delete ALL data? This cannot be undone.')) return
|
||||
await clearAll()
|
||||
state = await loadState()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement('div')
|
||||
d.textContent = s
|
||||
return d.innerHTML
|
||||
}
|
||||
|
||||
// Init default date
|
||||
;(el('usage-date') as HTMLInputElement).value = getTodayKey()
|
||||
|
||||
init()
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { AppState, Budget, ToolEntry, UsageRecord } from './types'
|
||||
import { DEFAULT_BUDGET, getMonthKey } from './types'
|
||||
|
||||
const STORAGE_KEY = 'costdev_state_v1'
|
||||
|
||||
export async function loadState(): Promise<AppState> {
|
||||
const res = await chrome.storage.local.get(STORAGE_KEY)
|
||||
const stored = res[STORAGE_KEY] as Partial<AppState> | undefined
|
||||
return {
|
||||
version: 1,
|
||||
tools: stored?.tools ?? [],
|
||||
usages: stored?.usages ?? [],
|
||||
budget: stored?.budget ?? { ...DEFAULT_BUDGET },
|
||||
lastAlertAt: stored?.lastAlertAt,
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveState(state: AppState): Promise<void> {
|
||||
await chrome.storage.local.set({ [STORAGE_KEY]: state })
|
||||
}
|
||||
|
||||
export async function addTool(tool: ToolEntry): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.tools = [...s.tools, tool]
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export async function updateTool(id: string, patch: Partial<ToolEntry>): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.tools = s.tools.map((t) => (t.id === id ? { ...t, ...patch, lastUpdated: Date.now() } : t))
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export async function removeTool(id: string): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.tools = s.tools.filter((t) => t.id !== id)
|
||||
s.usages = s.usages.filter((u) => u.toolId !== id)
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export async function addUsage(record: UsageRecord): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.usages = [...s.usages, record]
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export async function removeUsage(id: string): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.usages = s.usages.filter((u) => u.id !== id)
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export async function setBudget(budget: Budget): Promise<void> {
|
||||
const s = await loadState()
|
||||
s.budget = budget
|
||||
await saveState(s)
|
||||
}
|
||||
|
||||
export function sumMonthUsages(usages: UsageRecord[], month = getMonthKey()): number {
|
||||
return usages
|
||||
.filter((u) => u.date.startsWith(month))
|
||||
.reduce((sum, u) => sum + u.amount, 0)
|
||||
}
|
||||
|
||||
export function sumToolMonthUsages(usages: UsageRecord[], toolId: string, month = getMonthKey()): number {
|
||||
return usages
|
||||
.filter((u) => u.toolId === toolId && u.date.startsWith(month))
|
||||
.reduce((sum, u) => sum + u.amount, 0)
|
||||
}
|
||||
|
||||
export function dailyTotals(usages: UsageRecord[], month = getMonthKey()): { day: string; total: number }[] {
|
||||
const map = new Map<string, number>()
|
||||
usages
|
||||
.filter((u) => u.date.startsWith(month))
|
||||
.forEach((u) => map.set(u.date, (map.get(u.date) ?? 0) + u.amount))
|
||||
return Array.from(map.entries())
|
||||
.map(([day, total]) => ({ day, total }))
|
||||
.sort((a, b) => a.day.localeCompare(b.day))
|
||||
}
|
||||
|
||||
export function projectedMonthEnd(currentTotal: number, today: Date = new Date()): number {
|
||||
const day = today.getDate()
|
||||
const daysInMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
|
||||
if (day <= 0 || daysInMonth <= 0) return currentTotal
|
||||
return (currentTotal / day) * daysInMonth
|
||||
}
|
||||
|
||||
export async function clearAll(): Promise<void> {
|
||||
await chrome.storage.local.remove(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Core types for Cost.dev
|
||||
|
||||
export interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
monthlyCost: number // in dollars, cents implied by decimals
|
||||
currency: string
|
||||
planName?: string
|
||||
billingModel: 'fixed' | 'usage' | 'hybrid'
|
||||
lastUpdated: number // timestamp
|
||||
}
|
||||
|
||||
export interface UsageRecord {
|
||||
id: string
|
||||
toolId: string
|
||||
date: string // YYYY-MM-DD
|
||||
amount: number // dollars
|
||||
note?: string
|
||||
source: 'manual' | 'auto' | 'import'
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
monthlyLimit: number // dollars
|
||||
currency: string
|
||||
alertThreshold: number // 0–1 fraction of monthlyLimit
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
tools: ToolEntry[]
|
||||
usages: UsageRecord[]
|
||||
budget: Budget
|
||||
lastAlertAt?: number
|
||||
version: 1
|
||||
}
|
||||
|
||||
export const DEFAULT_BUDGET: Budget = {
|
||||
monthlyLimit: 50,
|
||||
currency: 'USD',
|
||||
alertThreshold: 0.8,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
export function getMonthKey(date = new Date()): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function getTodayKey(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"types": ["chrome"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { crx } from '@crxjs/vite-plugin'
|
||||
import manifest from './src/manifest.json' with { type: 'json' }
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [crx({ manifest })],
|
||||
})
|
||||
Reference in New Issue
Block a user