Initial MVP: Cost.dev AI Coding Cost Optimizer Chrome extension

This commit is contained in:
Bun Bun
2026-06-14 10:48:26 +00:00
commit 866bea3d2b
18 changed files with 2436 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
+51
View File
@@ -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 🐰
+70
View File
@@ -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')
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

+1493
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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"
}
}
+62
View File
@@ -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
})
+39
View File
@@ -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
})
+42
View File
@@ -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"
}
]
}
+176
View File
@@ -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; }
+100
View File
@@ -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>
+210
View File
@@ -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()
+90
View File
@@ -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)
}
+56
View File
@@ -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 // 01 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')}`
}
+15
View File
@@ -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"]
}
+11
View File
@@ -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 })],
})