FlowBudget MVP: Chrome extension with SKU-level parsing, sinking funds, seasonal baselines, lifestyle creep heatmap, net worth dashboard

This commit is contained in:
Bun Bun
2026-06-19 12:31:22 +00:00
commit d6b9fe0ac6
24 changed files with 3465 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
dist-tests/
.venv/
*.log
.verdict
.env
.DS_Store
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

+15
View File
@@ -0,0 +1,15 @@
import { writeFileSync } from 'fs'
// Minimal valid 1x1 PNG with specific colors
function makePNG(r, g, b) {
const ihdr = Buffer.from([0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 1, 0, 0, 0, 1, 8, 2, 0, 0, 0, 0x90, 0x77, 0x53, 0xDE])
const idat = Buffer.from([0, 0, 0, 13, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x01, 0x01, 0x00, 0x05, 0x18, 0xD8, 0x53])
const iend = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
return Buffer.concat([sig, ihdr, idat, iend])
}
writeFileSync('icon16.png', makePNG(26, 35, 126))
writeFileSync('icon48.png', makePNG(26, 35, 126))
writeFileSync('icon128.png', makePNG(26, 35, 126))
console.log('Icons created')
+49
View File
@@ -0,0 +1,49 @@
{
"manifest_version": 3,
"name": "FlowBudget",
"version": "1.0.0",
"description": "Intelligent Cross-Platform Budgeting Layer",
"permissions": [
"storage",
"activeTab",
"alarms"
],
"host_permissions": [
"https://www.amazon.com/*",
"https://www.amazon.co.uk/*",
"https://www.instacart.com/*",
"https://*.amazon.com/*"
],
"action": {
"default_popup": "src/popup/popup.html",
"default_icon": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
},
"options_page": "src/options/options.html",
"background": {
"service_worker": "src/background/background.ts",
"type": "module"
},
"content_scripts": [
{
"matches": [
"https://www.amazon.com/gp/*/order-details*",
"https://www.amazon.com/your-orders/order-details*",
"https://www.amazon.co.uk/gp/*/order-details*",
"https://www.amazon.co.uk/your-orders/order-details*",
"https://www.instacart.com/store/checkout*",
"https://www.instacart.com/orders/*"
],
"js": ["src/content/content.ts"],
"run_at": "document_idle"
}
],
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}
+2185
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "flowbudget",
"version": "1.0.0",
"description": "Intelligent Cross-Platform Budgeting Layer",
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"test": "node tests/run-tests.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.28",
"@types/chrome": "^0.0.268",
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.5.2",
"vite": "^5.3.1"
}
}
+52
View File
@@ -0,0 +1,52 @@
import { loadState, saveState, getCurrentMonth } from '../lib/storage'
import { rollOverMonth, addTransaction } from '../lib/budget'
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name === 'monthly-rollover') {
const state = await loadState()
const currentMonth = getCurrentMonth()
if (state.monthlySnapshots.length === 0 || state.monthlySnapshots[state.monthlySnapshots.length - 1].month !== currentMonth) {
const snapshot = {
month: currentMonth,
categories: Object.fromEntries(state.categories.map(c => [c.id, { budget: c.budget + c.rolloverAmount, spent: c.spent }])),
netWorth: state.accounts.reduce((s, a) => s + a.balance, 0),
}
const newCats = rollOverMonth(state.categories)
await saveState({
...state,
categories: newCats,
monthlySnapshots: [...state.monthlySnapshots, snapshot],
})
}
}
})
chrome.runtime.onInstalled.addListener(() => {
chrome.alarms.create('monthly-rollover', { periodInMinutes: 60 })
})
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'PARSED_RECEIPT') {
(async () => {
const state = await loadState()
const { receipt } = message
const tx = {
id: crypto.randomUUID(),
date: receipt.date || new Date().toISOString().slice(0, 10),
amount: receipt.total,
merchant: receipt.merchant,
categoryId: receipt.items[0]?.categoryId || 'groceries',
description: `${receipt.merchant} order`,
isSplit: receipt.items.length > 1,
items: receipt.items,
originalAmount: receipt.total,
}
const result = addTransaction(state.categories, state.transactions, tx)
await saveState({ ...state, categories: result.categories, transactions: result.transactions })
sendResponse({ success: true, transaction: tx })
})()
return true
}
})
export {}
+43
View File
@@ -0,0 +1,43 @@
import { parseAmazonOrder, parseInstacartReceipt } from '../lib/parser'
function detectAndParse(): void {
const host = window.location.hostname
let receipt = null
if (host.includes('amazon')) {
receipt = parseAmazonOrder(document)
} else if (host.includes('instacart')) {
receipt = parseInstacartReceipt(document)
}
if (receipt) {
const banner = document.createElement('div')
banner.id = 'flowbudget-banner'
banner.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:99999;background:#1a237e;color:#fff;padding:12px 20px;font-family:sans-serif;font-size:14px;display:flex;justify-content:space-between;align-items:center;box-shadow:0 2px 8px rgba(0,0,0,0.3);'
banner.innerHTML = `
<div>
<strong>FlowBudget</strong> detected ${receipt.merchant} order — $${receipt.total.toFixed(2)} with ${receipt.items.length} items
</div>
<button id="flowbudget-import" style="padding:6px 14px;border:none;background:#4caf50;color:#fff;border-radius:4px;cursor:pointer;font-weight:600;">
Import to Budget
</button>
`
document.body.appendChild(banner)
document.getElementById('flowbudget-import')?.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'PARSED_RECEIPT', receipt }, (response) => {
if (response?.success) {
banner.innerHTML = '<div style="text-align:center;width:100%;"><strong>FlowBudget</strong> ✅ Imported successfully!</div>'
banner.style.background = '#2e7d32'
setTimeout(() => banner.remove(), 3000)
}
})
})
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', detectAndParse)
} else {
detectAndParse()
}
+119
View File
@@ -0,0 +1,119 @@
import type { Category, Transaction, MonthlySnapshot } from '../types'
export function getTotalBudget(categories: Category[]): number {
return categories.reduce((sum, c) => sum + getEffectiveBudget(c), 0)
}
export function getTotalSpent(categories: Category[]): number {
return categories.reduce((sum, c) => sum + c.spent, 0)
}
export function getEffectiveBudget(category: Category, month?: number): number {
const base = month !== undefined && category.seasonalBaseline
? getSeasonalValue(category.seasonalBaseline, month)
: category.budget
return base + (category.rolloverEnabled ? category.rolloverAmount : 0)
}
function getSeasonalValue(baseline: { jan: number; feb: number; mar: number; apr: number; may: number; jun: number; jul: number; aug: number; sep: number; oct: number; nov: number; dec: number }, month: number): number {
const map = [baseline.jan, baseline.feb, baseline.mar, baseline.apr, baseline.may, baseline.jun, baseline.jul, baseline.aug, baseline.sep, baseline.oct, baseline.nov, baseline.dec]
return map[month] ?? baseline.jan
}
export function getRemaining(category: Category): number {
return getEffectiveBudget(category) - category.spent
}
export function getProgressPercent(category: Category): number {
const budget = getEffectiveBudget(category)
if (budget <= 0) return 0
return Math.min(100, Math.round((category.spent / budget) * 100))
}
export function addTransaction(
categories: Category[],
transactions: Transaction[],
tx: Transaction
): { categories: Category[]; transactions: Transaction[] } {
const cats = categories.map(c => ({ ...c }))
const txs = [...transactions, tx]
if (tx.isSplit && tx.items) {
for (const item of tx.items) {
const cat = cats.find(c => c.id === item.categoryId)
if (cat) {
cat.spent += item.amount
}
}
} else {
const cat = cats.find(c => c.id === tx.categoryId)
if (cat) {
cat.spent += tx.amount
}
}
return { categories: cats, transactions: txs }
}
export function removeTransaction(
categories: Category[],
transactions: Transaction[],
txId: string
): { categories: Category[]; transactions: Transaction[] } {
const tx = transactions.find(t => t.id === txId)
if (!tx) return { categories, transactions }
const cats = categories.map(c => ({ ...c }))
const txs = transactions.filter(t => t.id !== txId)
if (tx.isSplit && tx.items) {
for (const item of tx.items) {
const cat = cats.find(c => c.id === item.categoryId)
if (cat) {
cat.spent = Math.max(0, cat.spent - item.amount)
}
}
} else {
const cat = cats.find(c => c.id === tx.categoryId)
if (cat) {
cat.spent = Math.max(0, cat.spent - tx.amount)
}
}
return { categories: cats, transactions: txs }
}
export function rollOverMonth(categories: Category[]): Category[] {
return categories.map(c => {
const remaining = getEffectiveBudget(c) - c.spent
return {
...c,
rolloverAmount: c.rolloverEnabled ? c.rolloverAmount + remaining : c.rolloverAmount,
spent: 0,
}
})
}
export function computeLifestyleCreep(
snapshots: MonthlySnapshot[],
currentMonth: string
): { categoryId: string; changePercent: number; isInflation: boolean }[] {
const current = snapshots.find(s => s.month === currentMonth)
const lastYear = currentMonth.slice(0, 4)
const prevYear = String(Number(lastYear) - 1)
const prevMonthKey = prevYear + currentMonth.slice(4)
const previous = snapshots.find(s => s.month === prevMonthKey)
if (!current || !previous) return []
return Object.entries(current.categories).map(([catId, curr]) => {
const prev = previous.categories[catId]
if (!prev || prev.budget <= 0) return { categoryId: catId, changePercent: 0, isInflation: false }
const change = ((curr.budget - prev.budget) / prev.budget) * 100
return { categoryId: catId, changePercent: Math.round(change * 10) / 10, isInflation: change > 5 }
})
}
export function getNetWorth(accounts: { balance: number }[]): number {
return accounts.reduce((sum, a) => sum + a.balance, 0)
}
+12
View File
@@ -0,0 +1,12 @@
export function getHeatmapColor(percentChange: number): string {
if (percentChange <= -10) return '#1B5E20'
if (percentChange <= -5) return '#4CAF50'
if (percentChange <= 5) return '#FFC107'
if (percentChange <= 10) return '#FF9800'
return '#F44336'
}
export function formatPercentChange(value: number): string {
const sign = value >= 0 ? '+' : ''
return `${sign}${value.toFixed(1)}%`
}
+65
View File
@@ -0,0 +1,65 @@
import type { PurchasedItem } from '../types'
export interface ParsedReceipt {
merchant: string
total: number
items: PurchasedItem[]
date?: string
}
export function parseAmazonOrder(doc: Document): ParsedReceipt | null {
const totalEl = doc.querySelector('[data-testid="order-summary-total"] span, .order-summary .grand-totals .grand-total-price, #orderTotal span')
const totalText = totalEl?.textContent?.replace(/[^0-9.]/g, '') ?? ''
const total = parseFloat(totalText)
const itemEls = doc.querySelectorAll('.a-fixed-left-grid .a-fixed-left-grid-inner, .yohtmlc-item')
const items: PurchasedItem[] = []
for (const el of Array.from(itemEls)) {
const nameEl = el.querySelector('.a-link-normal, .yohtmlc-product-title')
const priceEl = el.querySelector('.a-color-price, .a-text-price .a-offscreen')
const name = nameEl?.textContent?.trim() ?? ''
const priceText = priceEl?.textContent?.replace(/[^0-9.]/g, '') ?? ''
const price = parseFloat(priceText)
if (name && !isNaN(price) && price > 0) {
items.push({ name, amount: price, categoryId: guessCategory(name) })
}
}
if (!total || items.length === 0) return null
return { merchant: 'Amazon', total, items }
}
export function parseInstacartReceipt(doc: Document): ParsedReceipt | null {
const totalEl = doc.querySelector('[data-testid="receipt-total"], .receipt-total, .total-line')
const totalText = totalEl?.textContent?.replace(/[^0-9.]/g, '') ?? ''
const total = parseFloat(totalText)
const itemEls = doc.querySelectorAll('.item-row, .cart-item, [data-testid="receipt-item"]')
const items: PurchasedItem[] = []
for (const el of Array.from(itemEls)) {
const nameEl = el.querySelector('.item-name, .product-name, [data-testid="item-name"]')
const priceEl = el.querySelector('.item-price, .price, [data-testid="item-price"]')
const name = nameEl?.textContent?.trim() ?? ''
const priceText = priceEl?.textContent?.replace(/[^0-9.]/g, '') ?? ''
const price = parseFloat(priceText)
if (name && !isNaN(price) && price > 0) {
items.push({ name, amount: price, categoryId: guessCategory(name) })
}
}
if (!total || items.length === 0) return null
return { merchant: 'Instacart', total, items }
}
function guessCategory(itemName: string): string {
const name = itemName.toLowerCase()
if (/milk|egg|bread|cheese|meat|fruit|vegetable|produce|grocery|snack|cereal|rice|pasta/i.test(name)) return 'groceries'
if (/book|kindle|audiobook|magazine/i.test(name)) return 'entertainment'
if (/shampoo|soap|toothpaste|cleaner|paper towel|toilet paper|detergent/i.test(name)) return 'home'
if (/toy|game|puzzle|lego|doll/i.test(name)) return 'gifts'
return 'groceries'
}
+37
View File
@@ -0,0 +1,37 @@
import type { AppState } from '../types'
const DEFAULT_STATE: AppState = {
categories: [
{ id: 'groceries', name: 'Groceries', budget: 600, spent: 0, color: '#4CAF50', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
{ id: 'dining', name: 'Dining Out', budget: 300, spent: 0, color: '#FF9800', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
{ id: 'utilities', name: 'Utilities', budget: 200, spent: 0, color: '#2196F3', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0, seasonalBaseline: { jan: 220, feb: 210, mar: 190, apr: 170, may: 160, jun: 180, jul: 220, aug: 230, sep: 200, oct: 180, nov: 190, dec: 210 } },
{ id: 'transport', name: 'Transport', budget: 150, spent: 0, color: '#9C27B0', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
{ id: 'entertainment', name: 'Entertainment', budget: 100, spent: 0, color: '#E91E63', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
{ id: 'vacation', name: 'Vacation Fund', budget: 200, spent: 0, color: '#00BCD4', isSinkingFund: true, rolloverEnabled: true, rolloverAmount: 0 },
{ id: 'gifts', name: 'Gifts', budget: 50, spent: 0, color: '#795548', isSinkingFund: true, rolloverEnabled: true, rolloverAmount: 0 },
{ id: 'home', name: 'Home Maintenance', budget: 100, spent: 0, color: '#607D8B', isSinkingFund: true, rolloverEnabled: true, rolloverAmount: 0 },
],
transactions: [],
accounts: [
{ id: 'checking', name: 'Primary Checking', type: 'checking', balance: 3500 },
{ id: 'savings', name: 'Emergency Fund', type: 'savings', balance: 12000 },
],
monthlySnapshots: [],
settings: { currency: 'USD', monthStartDay: 1 },
}
export async function loadState(): Promise<AppState> {
const result = await chrome.storage.local.get('flowbudget_state')
if (result.flowbudget_state) {
return { ...DEFAULT_STATE, ...result.flowbudget_state }
}
return DEFAULT_STATE
}
export async function saveState(state: AppState): Promise<void> {
await chrome.storage.local.set({ flowbudget_state: state })
}
export function getCurrentMonth(): string {
return new Date().toISOString().slice(0, 7)
}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowBudget Settings</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./options.tsx"></script>
</body>
</html>
+207
View File
@@ -0,0 +1,207 @@
import { useState, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import type { AppState, Category, Account } from '../types'
import { loadState, saveState } from '../lib/storage'
function Options() {
const [state, setState] = useState<AppState | null>(null)
const [saved, setSaved] = useState(false)
useEffect(() => {
loadState().then(setState)
}, [])
if (!state) return <div style={{ padding: 40 }}>Loading...</div>
const updateCategory = (id: string, updates: Partial<Category>) => {
const cats = state.categories.map(c => c.id === id ? { ...c, ...updates } : c)
const newState = { ...state, categories: cats }
setState(newState)
saveState(newState)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const addCategory = () => {
const newCat: Category = {
id: 'cat_' + Date.now(),
name: 'New Category',
budget: 100,
spent: 0,
color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'),
isSinkingFund: false,
rolloverEnabled: false,
rolloverAmount: 0,
}
const newState = { ...state, categories: [...state.categories, newCat] }
setState(newState)
saveState(newState)
}
const removeCategory = (id: string) => {
const newState = { ...state, categories: state.categories.filter(c => c.id !== id) }
setState(newState)
saveState(newState)
}
const updateAccount = (id: string, updates: Partial<Account>) => {
const accs = state.accounts.map(a => a.id === id ? { ...a, ...updates } : a)
const newState = { ...state, accounts: accs }
setState(newState)
saveState(newState)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const addAccount = () => {
const newAcc: Account = {
id: 'acc_' + Date.now(),
name: 'New Account',
type: 'checking',
balance: 0,
}
const newState = { ...state, accounts: [...state.accounts, newAcc] }
setState(newState)
saveState(newState)
}
const removeAccount = (id: string) => {
const newState = { ...state, accounts: state.accounts.filter(a => a.id !== id) }
setState(newState)
saveState(newState)
}
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 8 }}>FlowBudget Settings</h1>
<p style={{ color: '#666', marginBottom: 24 }}>Manage your budget categories, sinking funds, accounts, and seasonal baselines.</p>
{saved && <div style={{ padding: '10px 16px', background: '#e8f5e9', color: '#2e7d32', borderRadius: 6, marginBottom: 20 }}>Saved!</div>}
<section style={{ background: '#fff', borderRadius: 8, padding: 24, marginBottom: 24, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Categories</h2>
<button onClick={addCategory} style={{ padding: '6px 14px', border: 'none', background: '#1a237e', color: '#fff', borderRadius: 4, cursor: 'pointer' }}>+ Add Category</button>
</div>
{state.categories.map(cat => (
<div key={cat.id} style={{ borderBottom: '1px solid #f0f0f0', padding: '16px 0' }}>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr auto', gap: 12, alignItems: 'center' }}>
<input
value={cat.name}
onChange={e => updateCategory(cat.id, { name: e.target.value })}
style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}
/>
<input
type="number"
value={cat.budget}
onChange={e => updateCategory(cat.id, { budget: parseFloat(e.target.value) || 0 })}
style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}
/>
<input
type="color"
value={cat.color}
onChange={e => updateCategory(cat.id, { color: e.target.value })}
style={{ padding: 4, border: '1px solid #ddd', borderRadius: 4, height: 36 }}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<input
type="checkbox"
checked={cat.isSinkingFund}
onChange={e => updateCategory(cat.id, { isSinkingFund: e.target.checked, rolloverEnabled: e.target.checked })}
/>
Sinking Fund
</label>
<button onClick={() => removeCategory(cat.id)} style={{ border: 'none', background: 'none', color: '#d32f2f', cursor: 'pointer', fontSize: 16 }}>🗑</button>
</div>
{cat.seasonalBaseline && (
<div style={{ marginTop: 12, padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Seasonal Baseline (monthly)</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 8 }}>
{(['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] as const).map(m => (
<div key={m}>
<label style={{ fontSize: 10, textTransform: 'uppercase', color: '#666' }}>{m}</label>
<input
type="number"
value={cat.seasonalBaseline[m]}
onChange={e => {
const val = parseFloat(e.target.value) || 0
const baseline = { ...cat.seasonalBaseline!, [m]: val }
updateCategory(cat.id, { seasonalBaseline: baseline })
}}
style={{ width: '100%', padding: 6, border: '1px solid #ddd', borderRadius: 4, fontSize: 12 }}
/>
</div>
))}
</div>
</div>
)}
{!cat.seasonalBaseline && (
<button
onClick={() => updateCategory(cat.id, { seasonalBaseline: { jan: cat.budget, feb: cat.budget, mar: cat.budget, apr: cat.budget, may: cat.budget, jun: cat.budget, jul: cat.budget, aug: cat.budget, sep: cat.budget, oct: cat.budget, nov: cat.budget, dec: cat.budget } })}
style={{ marginTop: 8, fontSize: 11, padding: '4px 10px', border: '1px solid #1a237e', background: '#fff', color: '#1a237e', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Seasonal Baseline
</button>
)}
</div>
))}
</section>
<section style={{ background: '#fff', borderRadius: 8, padding: 24, marginBottom: 24, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ margin: 0, fontSize: 18 }}>Accounts</h2>
<button onClick={addAccount} style={{ padding: '6px 14px', border: 'none', background: '#1a237e', color: '#fff', borderRadius: 4, cursor: 'pointer' }}>+ Add Account</button>
</div>
{state.accounts.map(acc => (
<div key={acc.id} style={{ display: 'grid', gridTemplateColumns: '2fr 1fr 1fr 1fr auto', gap: 12, alignItems: 'center', padding: '12px 0', borderBottom: '1px solid #f0f0f0' }}>
<input
value={acc.name}
onChange={e => updateAccount(acc.id, { name: e.target.value })}
style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}
/>
<select
value={acc.type}
onChange={e => updateAccount(acc.id, { type: e.target.value as Account['type'] })}
style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}
>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
<option value="investment">Investment</option>
<option value="credit">Credit</option>
<option value="loan">Loan</option>
</select>
<input
type="number"
value={acc.balance}
onChange={e => updateAccount(acc.id, { balance: parseFloat(e.target.value) || 0 })}
style={{ padding: 8, border: '1px solid #ddd', borderRadius: 4 }}
/>
<span></span>
<button onClick={() => removeAccount(acc.id)} style={{ border: 'none', background: 'none', color: '#d32f2f', cursor: 'pointer', fontSize: 16 }}>🗑</button>
</div>
))}
</section>
<section style={{ background: '#fff', borderRadius: 8, padding: 24, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<h2 style={{ margin: '0 0 16px', fontSize: 18 }}>Data</h2>
<button
onClick={async () => {
await chrome.storage.local.remove('flowbudget_state')
window.location.reload()
}}
style={{ padding: '8px 16px', border: '1px solid #d32f2f', background: '#fff', color: '#d32f2f', borderRadius: 4, cursor: 'pointer' }}
>
Reset All Data
</button>
<p style={{ fontSize: 12, color: '#666', marginTop: 8 }}>This will clear all budgets, transactions, and accounts. Cannot be undone.</p>
</section>
</div>
)
}
const root = document.getElementById('root')
if (root) createRoot(root).render(<Options />)
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowBudget</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./popup.tsx"></script>
</body>
</html>
+373
View File
@@ -0,0 +1,373 @@
import { useState, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import type { AppState, Category, Transaction } from '../types'
import { loadState, saveState, getCurrentMonth } from '../lib/storage'
import {
getTotalBudget,
getTotalSpent,
getRemaining,
getProgressPercent,
addTransaction,
removeTransaction,
computeLifestyleCreep,
getNetWorth,
rollOverMonth,
} from '../lib/budget'
import { getHeatmapColor, formatPercentChange } from '../lib/heatmap'
function Popup() {
const [state, setState] = useState<AppState | null>(null)
const [tab, setTab] = useState<'budget' | 'heatmap' | 'networth' | 'transactions'>('budget')
const [showAddTx, setShowAddTx] = useState(false)
useEffect(() => {
loadState().then(setState)
}, [])
if (!state) return <div style={{ padding: 20, width: 380 }}>Loading...</div>
const totalBudget = getTotalBudget(state.categories)
const totalSpent = getTotalSpent(state.categories)
const remaining = totalBudget - totalSpent
const percentUsed = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0
const handleAddTx = (tx: Transaction) => {
const result = addTransaction(state.categories, state.transactions, tx)
const newState = { ...state, categories: result.categories, transactions: result.transactions }
setState(newState)
saveState(newState)
setShowAddTx(false)
}
const handleDeleteTx = (id: string) => {
const result = removeTransaction(state.categories, state.transactions, id)
const newState = { ...state, categories: result.categories, transactions: result.transactions }
setState(newState)
saveState(newState)
}
const handleRollover = () => {
const newCats = rollOverMonth(state.categories)
const newState = { ...state, categories: newCats }
setState(newState)
saveState(newState)
}
return (
<div style={{ width: 400, minHeight: 500, background: '#fff' }}>
<header style={{ padding: '16px 20px', borderBottom: '1px solid #e0e0e0', background: '#1a237e', color: '#fff' }}>
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>FlowBudget</h1>
<div style={{ fontSize: 12, opacity: 0.8, marginTop: 4 }}>{getCurrentMonth()}</div>
</header>
<div style={{ padding: '12px 20px', background: '#f5f5f5', borderBottom: '1px solid #e0e0e0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 13, color: '#666' }}>Budget</span>
<span style={{ fontSize: 13, fontWeight: 600 }}>${totalBudget.toFixed(0)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 13, color: '#666' }}>Spent</span>
<span style={{ fontSize: 13, fontWeight: 600, color: '#d32f2f' }}>${totalSpent.toFixed(2)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: '#666' }}>Remaining</span>
<span style={{ fontSize: 13, fontWeight: 600, color: remaining >= 0 ? '#388e3c' : '#d32f2f' }}>${remaining.toFixed(2)}</span>
</div>
<div style={{ marginTop: 10, height: 8, background: '#e0e0e0', borderRadius: 4, overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, percentUsed)}%`, height: '100%', background: percentUsed > 90 ? '#d32f2f' : percentUsed > 75 ? '#ff9800' : '#4caf50', transition: 'width 0.3s' }} />
</div>
<div style={{ textAlign: 'center', fontSize: 11, color: '#999', marginTop: 4 }}>{percentUsed}% used</div>
</div>
<div style={{ display: 'flex', borderBottom: '1px solid #e0e0e0' }}>
{(['budget', 'heatmap', 'networth', 'transactions'] as const).map(t => (
<button
key={t}
onClick={() => setTab(t)}
style={{
flex: 1,
padding: '10px 0',
border: 'none',
background: tab === t ? '#fff' : '#fafafa',
borderBottom: tab === t ? '2px solid #1a237e' : '2px solid transparent',
fontSize: 11,
fontWeight: tab === t ? 600 : 400,
cursor: 'pointer',
color: tab === t ? '#1a237e' : '#666',
}}
>
{t === 'budget' ? 'Budget' : t === 'heatmap' ? 'Creep Map' : t === 'networth' ? 'Net Worth' : 'Transactions'}
</button>
))}
</div>
<div style={{ padding: '16px 20px', maxHeight: 360, overflowY: 'auto' }}>
{tab === 'budget' && <BudgetTab categories={state.categories} onRollover={handleRollover} />}
{tab === 'heatmap' && <HeatmapTab snapshots={state.monthlySnapshots} categories={state.categories} />}
{tab === 'networth' && <NetWorthTab accounts={state.accounts} categories={state.categories} />}
{tab === 'transactions' && (
<TransactionsTab
transactions={state.transactions}
categories={state.categories}
onAdd={() => setShowAddTx(true)}
onDelete={handleDeleteTx}
/>
)}
</div>
{showAddTx && <AddTxModal categories={state.categories} onAdd={handleAddTx} onClose={() => setShowAddTx(false)} />}
</div>
)
}
function BudgetTab({ categories, onRollover }: { categories: Category[]; onRollover: () => void }) {
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 14 }}>Categories</h3>
<button onClick={onRollover} style={{ fontSize: 11, padding: '4px 10px', border: '1px solid #1a237e', background: '#fff', color: '#1a237e', borderRadius: 4, cursor: 'pointer' }}>
Roll Over Month
</button>
</div>
{categories.map(cat => {
const pct = getProgressPercent(cat)
const rem = getRemaining(cat)
return (
<div key={cat.id} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 13, fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: cat.color, display: 'inline-block' }} />
{cat.name}
{cat.isSinkingFund && <span style={{ fontSize: 9, background: '#e3f2fd', color: '#1565c0', padding: '1px 5px', borderRadius: 8 }}>SF</span>}
</span>
<span style={{ fontSize: 12, color: '#666' }}>
${cat.spent.toFixed(0)} / ${getEffectiveBudget(cat).toFixed(0)}
</span>
</div>
<div style={{ height: 6, background: '#e0e0e0', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: `${pct}%`, height: '100%', background: cat.color, transition: 'width 0.3s' }} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#999' }}>{pct}%</span>
<span style={{ fontSize: 10, color: rem >= 0 ? '#388e3c' : '#d32f2f' }}>${rem.toFixed(0)} left</span>
</div>
{cat.rolloverEnabled && cat.rolloverAmount > 0 && (
<div style={{ fontSize: 10, color: '#1565c0', marginTop: 2 }}>Rollover: ${cat.rolloverAmount.toFixed(2)}</div>
)}
</div>
)
})}
</div>
)
}
function getEffectiveBudget(cat: Category): number {
const month = new Date().getMonth()
const base = cat.seasonalBaseline
? [cat.seasonalBaseline.jan, cat.seasonalBaseline.feb, cat.seasonalBaseline.mar, cat.seasonalBaseline.apr, cat.seasonalBaseline.may, cat.seasonalBaseline.jun, cat.seasonalBaseline.jul, cat.seasonalBaseline.aug, cat.seasonalBaseline.sep, cat.seasonalBaseline.oct, cat.seasonalBaseline.nov, cat.seasonalBaseline.dec][month]
: cat.budget
return base + (cat.rolloverEnabled ? cat.rolloverAmount : 0)
}
function HeatmapTab({ snapshots, categories }: { snapshots: import('../types').MonthlySnapshot[]; categories: Category[] }) {
const currentMonth = getCurrentMonth()
const creep = computeLifestyleCreep(snapshots, currentMonth)
if (creep.length === 0) {
return (
<div style={{ textAlign: 'center', padding: '30px 0' }}>
<div style={{ fontSize: 32, marginBottom: 10 }}>📊</div>
<div style={{ fontSize: 13, color: '#666' }}>No year-over-year data yet.</div>
<div style={{ fontSize: 11, color: '#999', marginTop: 6 }}>Add transactions and wait for next month to see lifestyle creep vs inflation.</div>
</div>
)
}
return (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: 14 }}>Lifestyle Creep vs Inflation</h3>
<div style={{ fontSize: 11, color: '#666', marginBottom: 12 }}>Comparing {currentMonth} to last year</div>
{creep.map(item => {
const cat = categories.find(c => c.id === item.categoryId)
if (!cat) return null
return (
<div key={item.categoryId} style={{ display: 'flex', alignItems: 'center', padding: '8px 0', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: cat.color, marginRight: 10, flexShrink: 0 }} />
<div style={{ flex: 1, fontSize: 12 }}>{cat.name}</div>
<div style={{
padding: '3px 10px',
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background: getHeatmapColor(item.changePercent),
color: item.changePercent > 5 || item.changePercent < -10 ? '#fff' : '#333',
}}>
{formatPercentChange(item.changePercent)}
</div>
</div>
)
})}
<div style={{ marginTop: 12, padding: 10, background: '#fff3e0', borderRadius: 6, fontSize: 11, color: '#e65100' }}>
🔥 Orange/Red = lifestyle creep above inflation. Green = spending under control.
</div>
</div>
)
}
function NetWorthTab({ accounts, categories }: { accounts: import('../types').Account[]; categories: Category[] }) {
const total = getNetWorth(accounts)
const assets = accounts.filter(a => a.type !== 'credit' && a.type !== 'loan').reduce((s, a) => s + a.balance, 0)
const debts = accounts.filter(a => a.type === 'credit' || a.type === 'loan').reduce((s, a) => s + a.balance, 0)
const liquid = accounts.filter(a => a.type === 'checking' || a.type === 'savings').reduce((s, a) => s + a.balance, 0)
return (
<div>
<h3 style={{ margin: '0 0 12px', fontSize: 14 }}>Net Worth</h3>
<div style={{ textAlign: 'center', padding: '20px 0', background: '#f5f5f5', borderRadius: 8, marginBottom: 16 }}>
<div style={{ fontSize: 28, fontWeight: 700, color: total >= 0 ? '#1a237e' : '#d32f2f' }}>${total.toLocaleString()}</div>
<div style={{ fontSize: 11, color: '#666', marginTop: 4 }}>Total Net Worth</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginBottom: 16 }}>
<div style={{ padding: 12, background: '#e8f5e9', borderRadius: 6, textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#2e7d32' }}>${assets.toLocaleString()}</div>
<div style={{ fontSize: 10, color: '#666' }}>Assets</div>
</div>
<div style={{ padding: 12, background: '#ffebee', borderRadius: 6, textAlign: 'center' }}>
<div style={{ fontSize: 16, fontWeight: 600, color: '#c62828' }}>${debts.toLocaleString()}</div>
<div style={{ fontSize: 10, color: '#666' }}>Debts</div>
</div>
</div>
<div style={{ marginBottom: 12, padding: 10, background: '#e3f2fd', borderRadius: 6 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>Liquid: ${liquid.toLocaleString()}</div>
<div style={{ fontSize: 10, color: '#666' }}>Checking + Savings</div>
</div>
<h4 style={{ fontSize: 12, margin: '12px 0 8px' }}>Accounts</h4>
{accounts.map(acc => (
<div key={acc.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #f0f0f0', fontSize: 12 }}>
<span>{acc.name} <span style={{ color: '#999', fontSize: 10 }}>({acc.type})</span></span>
<span style={{ fontWeight: 600 }}>${acc.balance.toLocaleString()}</span>
</div>
))}
<h4 style={{ fontSize: 12, margin: '16px 0 8px' }}>Sinking Funds</h4>
{categories.filter(c => c.isSinkingFund).map(cat => (
<div key={cat.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', fontSize: 12 }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: cat.color }} />
{cat.name}
</span>
<span>${(cat.rolloverAmount + getRemaining(cat)).toFixed(2)}</span>
</div>
))}
</div>
)
}
function TransactionsTab({
transactions,
categories,
onAdd,
onDelete,
}: {
transactions: Transaction[]
categories: Category[]
onAdd: () => void
onDelete: (id: string) => void
}) {
const sorted = [...transactions].sort((a, b) => b.date.localeCompare(a.date))
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 14 }}>Transactions ({transactions.length})</h3>
<button onClick={onAdd} style={{ fontSize: 11, padding: '4px 10px', border: 'none', background: '#1a237e', color: '#fff', borderRadius: 4, cursor: 'pointer' }}>
+ Add
</button>
</div>
{sorted.length === 0 ? (
<div style={{ textAlign: 'center', padding: '30px 0', color: '#999', fontSize: 13 }}>
No transactions yet.<br />Click + Add to log spending.
</div>
) : (
sorted.map(tx => {
const cat = categories.find(c => c.id === tx.categoryId)
return (
<div key={tx.id} style={{ padding: '8px 0', borderBottom: '1px solid #f0f0f0', fontSize: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 500 }}>{tx.merchant}</span>
<span style={{ fontWeight: 600, color: '#d32f2f' }}>-${tx.amount.toFixed(2)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ color: '#666' }}>
{tx.date} {cat && <span style={{ color: cat.color }}> {cat.name}</span>}
{tx.isSplit && <span style={{ marginLeft: 6, fontSize: 9, background: '#e0e0e0', padding: '1px 4px', borderRadius: 4 }}>split</span>}
</span>
<button onClick={() => onDelete(tx.id)} style={{ fontSize: 10, border: 'none', background: 'none', color: '#999', cursor: 'pointer' }}></button>
</div>
</div>
)
})
)}
</div>
)
}
function AddTxModal({ categories, onAdd, onClose }: { categories: Category[]; onAdd: (tx: Transaction) => void; onClose: () => void }) {
const [amount, setAmount] = useState('')
const [merchant, setMerchant] = useState('')
const [categoryId, setCategoryId] = useState(categories[0]?.id ?? '')
const [date, setDate] = useState(new Date().toISOString().slice(0, 10))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const num = parseFloat(amount)
if (!num || !merchant) return
onAdd({
id: crypto.randomUUID(),
date,
amount: num,
merchant,
categoryId,
description: merchant,
isSplit: false,
})
}
return (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }}>
<div style={{ background: '#fff', borderRadius: 8, padding: 20, width: 320 }}>
<h3 style={{ margin: '0 0 16px', fontSize: 16 }}>Add Transaction</h3>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', fontSize: 11, color: '#666', marginBottom: 4 }}>Merchant</label>
<input value={merchant} onChange={e => setMerchant(e.target.value)} style={{ width: '100%', padding: 8, border: '1px solid #ddd', borderRadius: 4, fontSize: 13 }} required />
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', fontSize: 11, color: '#666', marginBottom: 4 }}>Amount ($)</label>
<input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} style={{ width: '100%', padding: 8, border: '1px solid #ddd', borderRadius: 4, fontSize: 13 }} required />
</div>
<div style={{ marginBottom: 10 }}>
<label style={{ display: 'block', fontSize: 11, color: '#666', marginBottom: 4 }}>Category</label>
<select value={categoryId} onChange={e => setCategoryId(e.target.value)} style={{ width: '100%', padding: 8, border: '1px solid #ddd', borderRadius: 4, fontSize: 13 }}>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div style={{ marginBottom: 16 }}>
<label style={{ display: 'block', fontSize: 11, color: '#666', marginBottom: 4 }}>Date</label>
<input type="date" value={date} onChange={e => setDate(e.target.value)} style={{ width: '100%', padding: 8, border: '1px solid #ddd', borderRadius: 4, fontSize: 13 }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="button" onClick={onClose} style={{ flex: 1, padding: 8, border: '1px solid #ddd', background: '#fff', borderRadius: 4, cursor: 'pointer' }}>Cancel</button>
<button type="submit" style={{ flex: 1, padding: 8, border: 'none', background: '#1a237e', color: '#fff', borderRadius: 4, cursor: 'pointer' }}>Add</button>
</div>
</form>
</div>
</div>
)
}
const root = document.getElementById('root')
if (root) createRoot(root).render(<Popup />)
+68
View File
@@ -0,0 +1,68 @@
export interface Category {
id: string
name: string
budget: number
spent: number
color: string
isSinkingFund: boolean
rolloverEnabled: boolean
rolloverAmount: number
seasonalBaseline?: SeasonalBaseline
}
export interface SeasonalBaseline {
jan: number
feb: number
mar: number
apr: number
may: number
jun: number
jul: number
aug: number
sep: number
oct: number
nov: number
dec: number
}
export interface Transaction {
id: string
date: string
amount: number
merchant: string
categoryId: string
description: string
items?: PurchasedItem[]
isSplit: boolean
originalAmount?: number
}
export interface PurchasedItem {
name: string
amount: number
categoryId: string
}
export interface Account {
id: string
name: string
type: 'checking' | 'savings' | 'investment' | 'credit' | 'loan'
balance: number
}
export interface MonthlySnapshot {
month: string
categories: Record<string, { budget: number; spent: number }>
netWorth: number
}
export interface AppState {
categories: Category[]
transactions: Transaction[]
accounts: Account[]
monthlySnapshots: MonthlySnapshot[]
settings: {
currency: string
monthStartDay: number
}
}
+112
View File
@@ -0,0 +1,112 @@
import { describe, it } from 'node:test'
import assert from 'node:assert'
import {
getTotalBudget,
getTotalSpent,
getRemaining,
getProgressPercent,
addTransaction,
removeTransaction,
rollOverMonth,
computeLifestyleCreep,
getNetWorth,
} from '../src/lib/budget.js'
import type { Category, Transaction } from '../src/types.js'
const baseCats: Category[] = [
{ id: 'food', name: 'Food', budget: 500, spent: 100, color: '#4CAF50', isSinkingFund: false, rolloverEnabled: false, rolloverAmount: 0 },
{ id: 'vacation', name: 'Vacation', budget: 200, spent: 0, color: '#00BCD4', isSinkingFund: true, rolloverEnabled: true, rolloverAmount: 50 },
]
describe('getTotalBudget', () => {
it('sums category budgets including rollover', () => {
assert.strictEqual(getTotalBudget(baseCats), 750)
})
})
describe('getTotalSpent', () => {
it('sums category spending', () => {
assert.strictEqual(getTotalSpent(baseCats), 100)
})
})
describe('getRemaining', () => {
it('computes remaining for non-sinking fund', () => {
assert.strictEqual(getRemaining(baseCats[0]), 400)
})
it('includes rollover for sinking fund', () => {
assert.strictEqual(getRemaining(baseCats[1]), 250)
})
})
describe('getProgressPercent', () => {
it('returns correct percent', () => {
assert.strictEqual(getProgressPercent(baseCats[0]), 20)
})
it('caps at 100', () => {
const cat: Category = { ...baseCats[0], spent: 600 }
assert.strictEqual(getProgressPercent(cat), 100)
})
})
describe('addTransaction', () => {
it('adds a simple transaction', () => {
const tx: Transaction = { id: '1', date: '2024-01-01', amount: 50, merchant: 'Store', categoryId: 'food', description: '', isSplit: false }
const result = addTransaction(baseCats, [], tx)
assert.strictEqual(result.categories[0].spent, 150)
assert.strictEqual(result.transactions.length, 1)
})
it('adds a split transaction', () => {
const tx: Transaction = {
id: '2', date: '2024-01-01', amount: 100, merchant: 'Amazon', categoryId: 'food', description: '', isSplit: true,
items: [{ name: 'Food', amount: 60, categoryId: 'food' }, { name: 'Thing', amount: 40, categoryId: 'vacation' }],
originalAmount: 100,
}
const result = addTransaction(baseCats, [], tx)
assert.strictEqual(result.categories[0].spent, 160)
assert.strictEqual(result.categories[1].spent, 40)
})
})
describe('removeTransaction', () => {
it('removes a transaction and reverts spending', () => {
const tx: Transaction = { id: '1', date: '2024-01-01', amount: 50, merchant: 'Store', categoryId: 'food', description: '', isSplit: false }
const added = addTransaction(baseCats, [], tx)
const result = removeTransaction(added.categories, added.transactions, '1')
assert.strictEqual(result.categories[0].spent, 100)
assert.strictEqual(result.transactions.length, 0)
})
})
describe('rollOverMonth', () => {
it('rolls over remaining to next month for sinking funds', () => {
const cats = rollOverMonth(baseCats)
assert.strictEqual(cats[1].rolloverAmount, 300)
assert.strictEqual(cats[1].spent, 0)
})
it('does not roll over non-sinking funds', () => {
const cats = rollOverMonth(baseCats)
assert.strictEqual(cats[0].rolloverAmount, 0)
assert.strictEqual(cats[0].spent, 0)
})
})
describe('computeLifestyleCreep', () => {
it('computes year-over-year changes', () => {
const snapshots = [
{ month: '2023-06', categories: { food: { budget: 400, spent: 300 } }, netWorth: 10000 },
{ month: '2024-06', categories: { food: { budget: 500, spent: 350 } }, netWorth: 12000 },
]
const creep = computeLifestyleCreep(snapshots, '2024-06')
assert.strictEqual(creep.length, 1)
assert.strictEqual(creep[0].changePercent, 25)
assert.strictEqual(creep[0].isInflation, true)
})
})
describe('getNetWorth', () => {
it('sums all account balances', () => {
const accounts = [{ id: '1', name: 'A', type: 'checking' as const, balance: 1000 }, { id: '2', name: 'B', type: 'savings' as const, balance: 2000 }]
assert.strictEqual(getNetWorth(accounts), 3000)
})
})
+18
View File
@@ -0,0 +1,18 @@
import { execSync } from 'child_process'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = dirname(__dirname)
try {
execSync('npx tsc -p tsconfig.tests.json', { cwd: root, stdio: 'inherit' })
} catch (e) {
process.exit(1)
}
try {
execSync('node --test dist-tests/tests/budget.test.js', { cwd: root, stdio: 'inherit' })
} catch (e) {
process.exit(1)
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["chrome", "react", "react-dom"]
},
"include": ["src/**/*"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist-tests",
"rootDir": ".",
"types": ["node", "chrome"]
},
"include": ["tests/**/*.ts", "src/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { crx } from '@crxjs/vite-plugin'
import manifest from './manifest.json' with { type: 'json' }
export default defineConfig({
plugins: [react(), crx({ manifest })],
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
popup: 'src/popup/popup.html',
options: 'src/options/options.html',
},
},
},
})