From 9082038e29f34ab54499d892e606b815eb67b07d Mon Sep 17 00:00:00 2001 From: Bun Bun Date: Sun, 14 Jun 2026 18:26:44 +0000 Subject: [PATCH] Initial build: SaaS Cost Predictor Chrome extension MVP --- .gitignore | 5 + icons/icon128.png | Bin 0 -> 360 bytes icons/icon16.png | Bin 0 -> 82 bytes icons/icon48.png | Bin 0 -> 157 bytes list-dist.cjs | 10 + list-dist.js | 10 + make-icons.js | 56 ++ manifest.json | 45 + package-lock.json | 1511 ++++++++++++++++++++++++++++++++ package.json | 17 + src/__tests__/forecast.test.ts | 77 ++ src/background.ts | 65 ++ src/content.ts | 68 ++ src/forecast.ts | 136 +++ src/popup/popup.css | 282 ++++++ src/popup/popup.html | 83 ++ src/popup/popup.ts | 166 ++++ src/storage.ts | 52 ++ src/types.ts | 42 + tsconfig.json | 17 + vite.config.ts | 11 + 21 files changed, 2653 insertions(+) create mode 100644 .gitignore create mode 100644 icons/icon128.png create mode 100644 icons/icon16.png create mode 100644 icons/icon48.png create mode 100644 list-dist.cjs create mode 100644 list-dist.js create mode 100644 make-icons.js create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/__tests__/forecast.test.ts create mode 100644 src/background.ts create mode 100644 src/content.ts create mode 100644 src/forecast.ts create mode 100644 src/popup/popup.css create mode 100644 src/popup/popup.html create mode 100644 src/popup/popup.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..edf243d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +.venv/ +.verdict diff --git a/icons/icon128.png b/icons/icon128.png new file mode 100644 index 0000000000000000000000000000000000000000..f0760b4a079e8e4fbedd792c235d3b7e13e04d6c GIT binary patch literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1SEZ8zRh7^VAS_?aSW-L^Y)UVAcKOyfeqhJ z$^HvuFp`+1Slxc^*7HlI`6ax@=y85}Sb4q9e08P|o-2eap literal 0 HcmV?d00001 diff --git a/icons/icon16.png b/icons/icon16.png new file mode 100644 index 0000000000000000000000000000000000000000..03f4867259b21eb4ad0f2abc656cc3f017c227ca GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61SBU+%rFB|lAbP(Ar*6yJ^X(Em@jn3AnU*i f#>H$r;>-;1vv$hQDPax;Dq`?-^>bP0l+XkK(y|oX literal 0 HcmV?d00001 diff --git a/icons/icon48.png b/icons/icon48.png new file mode 100644 index 0000000000000000000000000000000000000000..a65a36059bd7ed10f125e0aa72b7b252aa04f61e GIT binary patch literal 157 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1SD@H=ACtJew0~1GJC9)78&qol`;+ E0NEWc8~^|S literal 0 HcmV?d00001 diff --git a/list-dist.cjs b/list-dist.cjs new file mode 100644 index 0000000..4105d42 --- /dev/null +++ b/list-dist.cjs @@ -0,0 +1,10 @@ +const fs = require('fs'); +function walk(d) { + for (const f of fs.readdirSync(d)) { + const p = d + '/' + f; + const s = fs.statSync(p); + if (s.isDirectory()) walk(p); + else console.log(p); + } +} +walk('dist'); diff --git a/list-dist.js b/list-dist.js new file mode 100644 index 0000000..4105d42 --- /dev/null +++ b/list-dist.js @@ -0,0 +1,10 @@ +const fs = require('fs'); +function walk(d) { + for (const f of fs.readdirSync(d)) { + const p = d + '/' + f; + const s = fs.statSync(p); + if (s.isDirectory()) walk(p); + else console.log(p); + } +} +walk('dist'); diff --git a/make-icons.js b/make-icons.js new file mode 100644 index 0000000..0efdb92 --- /dev/null +++ b/make-icons.js @@ -0,0 +1,56 @@ +import { writeFileSync } from 'fs'; +import { deflateSync } from 'zlib'; + +function makePng(size, r, g, b) { + const crcTable = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) { + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + } + crcTable[n] = c; + } + + function crc32(buf) { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c = crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); + } + return ~c >>> 0; + } + + function writeChunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); + return Buffer.concat([len, typeBuf, data, crc]); + } + + const ihdrData = Buffer.from([ + 0x00, 0x00, 0x00, size, 0x00, 0x00, 0x00, size, + 0x08, 0x02, 0x00, 0x00, 0x00, + ]); + const ihdrChunk = writeChunk('IHDR', ihdrData); + + const raw = []; + for (let y = 0; y < size; y++) { + raw.push(0); + for (let x = 0; x < size; x++) { + raw.push(r, g, b, 255); + } + } + const rawBuf = Buffer.from(raw); + const compressed = deflateSync(rawBuf); + const idatChunk = writeChunk('IDAT', compressed); + const iendChunk = writeChunk('IEND', Buffer.alloc(0)); + + const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + return Buffer.concat([sig, ihdrChunk, idatChunk, iendChunk]); +} + +writeFileSync('icons/icon16.png', makePng(16, 99, 102, 241)); +writeFileSync('icons/icon48.png', makePng(48, 99, 102, 241)); +writeFileSync('icons/icon128.png', makePng(128, 99, 102, 241)); +console.log('Icons created'); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5a1ecd5 --- /dev/null +++ b/manifest.json @@ -0,0 +1,45 @@ +{ + "manifest_version": 3, + "name": "SaaS Cost Predictor", + "version": "1.0.0", + "description": "Forecast and budget your AI-powered subscription spend", + "permissions": [ + "storage", + "alarms", + "notifications", + "activeTab" + ], + "host_permissions": [ + "https://dashboard.stripe.com/*", + "https://console.aws.amazon.com/*", + "https://*.vercel.com/*" + ], + "background": { + "service_worker": "src/background.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": [ + "https://dashboard.stripe.com/*", + "https://console.aws.amazon.com/*", + "https://*.vercel.com/*" + ], + "js": ["src/content.ts"], + "run_at": "document_idle" + } + ], + "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" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7c3d356 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1511 @@ +{ + "name": "saas-cost-predictor", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "saas-cost-predictor", + "version": "1.0.0", + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "@types/node": "^20.0.0", + "typescript": "^5.4.0", + "vite": "^5.2.0" + } + }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.6.1.tgz", + "integrity": "sha512-0RmYlUtQGvHXCz3B/FW7P5j+RBZUc4mk3GIRPXCx5a52N8AQ0e35GvE+Pcap/TKuI09PJgcRTdXeBOavtLVyYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.2.0", + "convert-source-map": "^1.7.0", + "debug": "^4.3.3", + "es-module-lexer": "^0.10.0", + "fs-extra": "^10.0.1", + "jsesc": "^3.0.2", + "magic-string": "^0.30.12", + "node-html-parser": "^7.0.2", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "react-refresh": "^0.13.0", + "rollup": "2.80.0", + "rxjs": "7.5.7", + "tinyglobby": "^0.2.15" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.268", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz", + "integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react-refresh": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz", + "integrity": "sha512-XP8A9BT0CpRBD+NYLLeIhld/RqG9+gktUjW1FkE+Vm7OCinbG1SshcK5tb9ls4kzvjZr9mOQc7HYgBngEyPAXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..45c319c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "saas-cost-predictor", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "test": "node --test dist/__tests__/forecast.test.js" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "typescript": "^5.4.0", + "vite": "^5.2.0" + } +} diff --git a/src/__tests__/forecast.test.ts b/src/__tests__/forecast.test.ts new file mode 100644 index 0000000..b6e2117 --- /dev/null +++ b/src/__tests__/forecast.test.ts @@ -0,0 +1,77 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { normalizeToMonthly, calculateForecast } from '../forecast.js'; +import type { Subscription } from '../types.js'; + +describe('normalizeToMonthly', () => { + it('returns same for monthly', () => { + assert.strictEqual(normalizeToMonthly(100, 'monthly'), 100); + }); + + it('divides yearly by 12', () => { + assert.strictEqual(normalizeToMonthly(1200, 'yearly'), 100); + }); + + it('multiplies weekly by 4.33', () => { + assert.strictEqual(normalizeToMonthly(100, 'weekly'), 433); + }); +}); + +describe('calculateForecast', () => { + it('returns zero for empty subscriptions', () => { + const result = calculateForecast([], { monthlyLimit: 1000, alertThreshold: 80 }); + assert.strictEqual(result.nextMonthForecast, 0); + assert.strictEqual(result.confidence, 'low'); + assert.strictEqual(result.alertTriggered, false); + }); + + it('predicts stable when no history', () => { + const subs: Subscription[] = [ + { id: '1', name: 'A', cost: 100, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01', history: [] }, + ]; + const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 }); + assert.strictEqual(result.nextMonthForecast, 100); + assert.strictEqual(result.trendDirection, 'stable'); + assert.strictEqual(result.confidence, 'low'); + }); + + it('predicts upward trend from history', () => { + const subs: Subscription[] = [ + { + id: '1', name: 'A', cost: 120, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01', + history: [ + { date: '2024-01-01', amount: 100 }, + { date: '2024-02-01', amount: 110 }, + { date: '2024-03-01', amount: 120 }, + ], + }, + ]; + const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 }); + assert.strictEqual(result.trendDirection, 'up'); + assert.ok(result.nextMonthForecast > 120); + assert.strictEqual(result.confidence, 'high'); + }); + + it('triggers alert when forecast exceeds threshold', () => { + const subs: Subscription[] = [ + { id: '1', name: 'A', cost: 900, billingCycle: 'monthly', category: 'Test', createdAt: '2024-01-01', history: [] }, + ]; + const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 }); + assert.strictEqual(result.alertTriggered, true); + assert.ok(result.alertMessage?.includes('100')); + }); + + it('groups by category in breakdown', () => { + const subs: Subscription[] = [ + { id: '1', name: 'A', cost: 100, billingCycle: 'monthly', category: 'AI', createdAt: '2024-01-01', history: [] }, + { id: '2', name: 'B', cost: 200, billingCycle: 'monthly', category: 'AI', createdAt: '2024-01-01', history: [] }, + { id: '3', name: 'C', cost: 50, billingCycle: 'monthly', category: 'Hosting', createdAt: '2024-01-01', history: [] }, + ]; + const result = calculateForecast(subs, { monthlyLimit: 1000, alertThreshold: 80 }); + assert.strictEqual(result.breakdown.length, 2); + const ai = result.breakdown.find(b => b.category === 'AI'); + assert.ok(ai); + assert.strictEqual(ai!.currentSpend, 300); + assert.strictEqual(ai!.forecastSpend, 300); + }); +}); diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..6e6ca0b --- /dev/null +++ b/src/background.ts @@ -0,0 +1,65 @@ +import { getState } from './storage.js'; +import { calculateForecast } from './forecast.js'; + +const ALARM_NAME = 'saas-cost-check'; + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create(ALARM_NAME, { periodInMinutes: 60 }); +}); + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === ALARM_NAME) { + await runCostCheck(); + } +}); + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'RUN_CHECK') { + runCostCheck().then((result) => sendResponse(result)); + return true; + } + if (message.type === 'IMPORT_COST') { + handleImportCost(message.payload).then(() => sendResponse({ ok: true })); + return true; + } +}); + +async function runCostCheck() { + try { + const state = await getState(); + const forecast = calculateForecast(state.subscriptions, state.budget); + + if (forecast.alertTriggered) { + await chrome.notifications.create(`alert-${Date.now()}`, { + type: 'basic', + iconUrl: 'icons/icon128.png', + title: 'SaaS Cost Alert', + message: forecast.alertMessage || 'Your forecasted spend is approaching the budget limit.', + priority: 2, + }); + } + + return { ok: true, forecast }; + } catch (err) { + console.error('Cost check failed:', err); + return { ok: false, error: String(err) }; + } +} + +async function handleImportCost(payload: { name: string; cost: number; url: string }) { + const { addSubscription, generateId } = await import('./storage.js'); + const { normalizeToMonthly } = await import('./forecast.js'); + + await addSubscription({ + id: generateId(), + name: payload.name, + cost: payload.cost, + billingCycle: 'monthly', + category: 'Imported', + url: payload.url, + createdAt: new Date().toISOString(), + history: [ + { date: new Date().toISOString().slice(0, 10), amount: payload.cost }, + ], + }); +} diff --git a/src/content.ts b/src/content.ts new file mode 100644 index 0000000..cd5188d --- /dev/null +++ b/src/content.ts @@ -0,0 +1,68 @@ +(function () { + 'use strict'; + + const host = window.location.hostname; + + if (host.includes('stripe.com')) { + detectStripeCost(); + } else if (host.includes('amazon.com')) { + detectAWSCost(); + } + + function detectStripeCost() { + const observer = new MutationObserver(() => { + const els = document.querySelectorAll('[data-testid="balance-summary"] .Text, .DashboardBalanceSummary-amount'); + els.forEach(el => { + const text = el.textContent || ''; + const match = text.match(/\$?([\d,]+\.?\d*)/); + if (match && !el.hasAttribute('data-saas-cost-detected')) { + el.setAttribute('data-saas-cost-detected', 'true'); + showImportButton(el, 'Stripe Dashboard', parseFloat(match[1].replace(/,/g, ''))); + } + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function detectAWSCost() { + const observer = new MutationObserver(() => { + const els = document.querySelectorAll('.awsui-util-font-size-display-l, [data-testid="total-cost"]'); + els.forEach(el => { + const text = el.textContent || ''; + const match = text.match(/\$?([\d,]+\.?\d*)/); + if (match && !el.hasAttribute('data-saas-cost-detected')) { + el.setAttribute('data-saas-cost-detected', 'true'); + showImportButton(el, 'AWS Console', parseFloat(match[1].replace(/,/g, ''))); + } + }); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function showImportButton(targetEl: Element, name: string, cost: number) { + const btn = document.createElement('button'); + btn.textContent = '➕ Import to SaaS Cost Predictor'; + btn.style.cssText = ` + position: absolute; + z-index: 99999; + background: #4f46e5; + color: white; + border: none; + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + margin-top: 4px; + `; + btn.addEventListener('click', () => { + chrome.runtime.sendMessage({ + type: 'IMPORT_COST', + payload: { name, cost, url: window.location.href }, + }); + btn.textContent = '✅ Imported'; + btn.style.background = '#16a34a'; + setTimeout(() => btn.remove(), 2000); + }); + targetEl.parentElement?.appendChild(btn); + } +})(); diff --git a/src/forecast.ts b/src/forecast.ts new file mode 100644 index 0000000..96a8f33 --- /dev/null +++ b/src/forecast.ts @@ -0,0 +1,136 @@ +import type { Subscription, ForecastResult, Budget, CategoryBreakdown } from './types.js'; + +export function normalizeToMonthly(cost: number, cycle: Subscription['billingCycle']): number { + switch (cycle) { + case 'weekly': return cost * 4.33; + case 'yearly': return cost / 12; + case 'monthly': + default: + return cost; + } +} + +export function calculateForecast( + subscriptions: Subscription[], + budget: Budget +): ForecastResult { + if (subscriptions.length === 0) { + return { + nextMonthForecast: 0, + trendDirection: 'stable', + trendPercent: 0, + confidence: 'low', + breakdown: [], + alertTriggered: false, + }; + } + + let totalCurrentMonthly = 0; + let totalForecastMonthly = 0; + const categoryMap = new Map(); + + for (const sub of subscriptions) { + const monthlyCost = normalizeToMonthly(sub.cost, sub.billingCycle); + totalCurrentMonthly += monthlyCost; + + const predicted = predictNextCost(sub); + const predictedMonthly = normalizeToMonthly(predicted, sub.billingCycle); + totalForecastMonthly += predictedMonthly; + + const cat = sub.category || 'Uncategorized'; + const existing = categoryMap.get(cat) || { current: 0, forecast: 0 }; + existing.current += monthlyCost; + existing.forecast += predictedMonthly; + categoryMap.set(cat, existing); + } + + const trendPercent = totalCurrentMonthly > 0 + ? ((totalForecastMonthly - totalCurrentMonthly) / totalCurrentMonthly) * 100 + : 0; + + const trendDirection: ForecastResult['trendDirection'] = + Math.abs(trendPercent) < 1 ? 'stable' : trendPercent > 0 ? 'up' : 'down'; + + const breakdown: CategoryBreakdown[] = []; + for (const [category, vals] of categoryMap) { + breakdown.push({ + category, + currentSpend: Math.round(vals.current * 100) / 100, + forecastSpend: Math.round(vals.forecast * 100) / 100, + percentOfTotal: totalForecastMonthly > 0 + ? Math.round((vals.forecast / totalForecastMonthly) * 1000) / 10 + : 0, + }); + } + breakdown.sort((a, b) => b.forecastSpend - a.forecastSpend); + + const thresholdAmount = budget.monthlyLimit * (budget.alertThreshold / 100); + const alertTriggered = totalForecastMonthly >= thresholdAmount; + + let confidence: ForecastResult['confidence'] = 'low'; + const subsWithHistory = subscriptions.filter(s => s.history && s.history.length >= 3); + if (subsWithHistory.length / subscriptions.length >= 0.5) { + confidence = 'high'; + } else if (subsWithHistory.length > 0) { + confidence = 'medium'; + } + + let alertMessage: string | undefined; + if (alertTriggered) { + const overBy = totalForecastMonthly - budget.monthlyLimit; + if (overBy > 0) { + alertMessage = `Forecast $${overBy.toFixed(2)} over $${budget.monthlyLimit.toFixed(2)} budget`; + } else { + alertMessage = `Forecast within $${(budget.monthlyLimit - totalForecastMonthly).toFixed(2)} of $${budget.monthlyLimit.toFixed(2)} budget`; + } + } + + return { + nextMonthForecast: Math.round(totalForecastMonthly * 100) / 100, + trendDirection, + trendPercent: Math.round(trendPercent * 10) / 10, + confidence, + breakdown, + alertTriggered, + alertMessage, + }; +} + +function predictNextCost(sub: Subscription): number { + if (!sub.history || sub.history.length < 2) { + return sub.cost; + } + + const sorted = [...sub.history].sort((a, b) => + new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + const n = sorted.length; + if (n === 2) { + const diff = sorted[1].amount - sorted[0].amount; + return Math.max(0, sorted[n - 1].amount + diff); + } + + let sumX = 0; + let sumY = 0; + let sumXY = 0; + let sumXX = 0; + + for (let i = 0; i < n; i++) { + sumX += i; + sumY += sorted[i].amount; + sumXY += i * sorted[i].amount; + sumXX += i * i; + } + + const denom = n * sumXX - sumX * sumX; + if (denom === 0) { + return sorted[n - 1].amount; + } + + const slope = (n * sumXY - sumX * sumY) / denom; + const intercept = (sumY - slope * sumX) / n; + const predicted = slope * n + intercept; + + return Math.max(0, Math.round(predicted * 100) / 100); +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..712df9b --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,282 @@ +:root { + --bg: #0f172a; + --surface: #1e293b; + --text: #f1f5f9; + --muted: #94a3b8; + --accent: #6366f1; + --accent-hover: #4f46e5; + --danger: #ef4444; + --success: #22c55e; + --warning: #f59e0b; + --radius: 8px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + width: 380px; + min-height: 400px; + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +#app { + padding: 16px; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +header h1 { + font-size: 18px; + font-weight: 700; +} + +#settings-btn { + background: transparent; + border: none; + font-size: 18px; + cursor: pointer; + color: var(--text); +} + +section { + background: var(--surface); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 12px; +} + +section h2 { + font-size: 14px; + font-weight: 600; + margin-bottom: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.forecast-main { + text-align: center; + padding: 12px 0; +} + +.forecast-number { + font-size: 36px; + font-weight: 800; + color: var(--accent); +} + +.forecast-label { + color: var(--muted); + font-size: 12px; + margin-top: 4px; +} + +.forecast-trend { + margin-top: 8px; + font-weight: 600; + font-size: 13px; +} + +.forecast-trend.up { color: var(--danger); } +.forecast-trend.down { color: var(--success); } +.forecast-trend.stable { color: var(--muted); } + +.forecast-confidence { + margin-top: 4px; + font-size: 12px; + color: var(--muted); +} + +#alert-box { + display: flex; + align-items: center; + gap: 8px; + background: rgba(239, 68, 68, 0.15); + border: 1px solid var(--danger); + border-radius: var(--radius); + padding: 10px; + margin-top: 12px; + font-size: 13px; +} + +#alert-icon { + font-size: 16px; +} + +#breakdown-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.breakdown-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px; + background: rgba(255,255,255,0.03); + border-radius: 6px; +} + +.breakdown-item .cat-name { + font-weight: 500; +} + +.breakdown-item .cat-bar-wrap { + flex: 1; + margin: 0 10px; + height: 6px; + background: rgba(255,255,255,0.1); + border-radius: 3px; + overflow: hidden; +} + +.breakdown-item .cat-bar { + height: 100%; + background: var(--accent); + border-radius: 3px; +} + +.breakdown-item .cat-amount { + font-size: 12px; + color: var(--muted); + white-space: nowrap; +} + +#subscription-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.sub-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background: rgba(255,255,255,0.03); + border-radius: 6px; +} + +.sub-item .sub-info { + flex: 1; +} + +.sub-item .sub-name { + font-weight: 600; + font-size: 13px; +} + +.sub-item .sub-meta { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +.sub-item .sub-cost { + font-weight: 700; + font-size: 14px; + margin-right: 8px; +} + +.sub-item button { + background: rgba(239, 68, 68, 0.2); + border: none; + color: var(--danger); + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + cursor: pointer; +} + +.sub-item button:hover { + background: rgba(239, 68, 68, 0.35); +} + +form { + display: flex; + flex-direction: column; + gap: 8px; +} + +input, select { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; + padding: 8px 10px; + color: var(--text); + font-size: 13px; +} + +input::placeholder { + color: var(--muted); +} + +input:focus, select:focus { + outline: none; + border-color: var(--accent); +} + +button[type="submit"] { + background: var(--accent); + border: none; + color: white; + padding: 10px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + margin-top: 4px; +} + +button[type="submit"]:hover { + background: var(--accent-hover); +} + +#settings-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--muted); +} + +#close-settings { + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.1); + color: var(--text); + padding: 10px; + border-radius: 6px; + cursor: pointer; + margin-top: 4px; +} + +#close-settings:hover { + background: rgba(255,255,255,0.1); +} + +.hidden { + display: none !important; +} + +#forecast-empty, #subscription-empty { + text-align: center; + color: var(--muted); + padding: 16px; +} + +#forecast-empty p, #subscription-empty p { + margin-bottom: 6px; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..b354a81 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,83 @@ + + + + + + SaaS Cost Predictor + + + +
+
+

SaaS Cost Predictor

+ +
+ +
+
Loading forecast…
+ + +
+ + + +
+

Subscriptions

+
+ +
+ +
+

Add Subscription

+
+ + + + + +
+
+ + +
+ + + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..c97c694 --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,166 @@ +import { getState, addSubscription, deleteSubscription, updateBudget, generateId } from '../storage.js'; +import { calculateForecast, normalizeToMonthly } from '../forecast.js'; +import type { Subscription } from '../types.js'; + +async function render() { + const state = await getState(); + const forecast = calculateForecast(state.subscriptions, state.budget); + + renderForecast(forecast, state.subscriptions.length); + renderBreakdown(forecast); + renderSubscriptions(state.subscriptions); + renderSettings(state.budget); +} + +function renderForecast(forecast: ReturnType, subCount: number) { + const loading = document.getElementById('forecast-loading')!; + const content = document.getElementById('forecast-content')!; + const empty = document.getElementById('forecast-empty')!; + + loading.classList.add('hidden'); + + if (subCount === 0) { + empty.classList.remove('hidden'); + content.classList.add('hidden'); + return; + } + + empty.classList.add('hidden'); + content.classList.remove('hidden'); + + document.getElementById('forecast-amount')!.textContent = `$${forecast.nextMonthForecast.toFixed(2)}`; + + const trendEl = document.getElementById('forecast-trend')!; + trendEl.className = `forecast-trend ${forecast.trendDirection}`; + const arrow = forecast.trendDirection === 'up' ? '▲' : forecast.trendDirection === 'down' ? '▼' : '—'; + trendEl.textContent = `${arrow} ${Math.abs(forecast.trendPercent).toFixed(1)}% vs current`; + + document.getElementById('forecast-confidence')!.textContent = `Confidence: ${forecast.confidence}`; + + const alertBox = document.getElementById('alert-box')!; + if (forecast.alertTriggered) { + alertBox.classList.remove('hidden'); + document.getElementById('alert-text')!.textContent = forecast.alertMessage || 'Budget alert'; + } else { + alertBox.classList.add('hidden'); + } +} + +function renderBreakdown(forecast: ReturnType) { + const section = document.getElementById('breakdown-section')!; + const list = document.getElementById('breakdown-list')!; + + if (forecast.breakdown.length === 0) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + list.innerHTML = ''; + + const maxVal = Math.max(...forecast.breakdown.map(b => b.forecastSpend)); + + for (const item of forecast.breakdown) { + const row = document.createElement('div'); + row.className = 'breakdown-item'; + const barWidth = maxVal > 0 ? (item.forecastSpend / maxVal) * 100 : 0; + row.innerHTML = ` + ${escapeHtml(item.category)} +
+ $${item.forecastSpend.toFixed(2)} + `; + list.appendChild(row); + } +} + +function renderSubscriptions(subs: Subscription[]) { + const list = document.getElementById('subscription-list')!; + const empty = document.getElementById('subscription-empty')!; + + if (subs.length === 0) { + list.innerHTML = ''; + empty.classList.remove('hidden'); + return; + } + + empty.classList.add('hidden'); + list.innerHTML = ''; + + for (const sub of subs) { + const monthly = normalizeToMonthly(sub.cost, sub.billingCycle); + const row = document.createElement('div'); + row.className = 'sub-item'; + row.innerHTML = ` +
+
${escapeHtml(sub.name)}
+
${escapeHtml(sub.category)} • ${sub.billingCycle} • ~$${monthly.toFixed(2)}/mo
+
+
$${sub.cost.toFixed(2)}
+ + `; + row.querySelector('button')!.addEventListener('click', async () => { + await deleteSubscription(sub.id); + await render(); + }); + list.appendChild(row); + } +} + +function renderSettings(budget: { monthlyLimit: number; alertThreshold: number }) { + (document.getElementById('budget-limit') as HTMLInputElement).value = String(budget.monthlyLimit); + (document.getElementById('alert-threshold') as HTMLInputElement).value = String(budget.alertThreshold); +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +document.getElementById('add-form')!.addEventListener('submit', async (e) => { + e.preventDefault(); + const name = (document.getElementById('sub-name') as HTMLInputElement).value.trim(); + const cost = parseFloat((document.getElementById('sub-cost') as HTMLInputElement).value); + const cycle = (document.getElementById('sub-cycle') as HTMLSelectElement).value as Subscription['billingCycle']; + const category = (document.getElementById('sub-category') as HTMLInputElement).value.trim(); + + if (!name || isNaN(cost) || cost < 0 || !category) return; + + await addSubscription({ + id: generateId(), + name, + cost, + billingCycle: cycle, + category, + createdAt: new Date().toISOString(), + history: [{ date: new Date().toISOString().slice(0, 10), amount: cost }], + }); + + (e.target as HTMLFormElement).reset(); + await render(); +}); + +document.getElementById('settings-form')!.addEventListener('submit', async (e) => { + e.preventDefault(); + const limit = parseFloat((document.getElementById('budget-limit') as HTMLInputElement).value); + const threshold = parseFloat((document.getElementById('alert-threshold') as HTMLInputElement).value); + + if (isNaN(limit) || limit < 0 || isNaN(threshold) || threshold < 1 || threshold > 100) return; + + await updateBudget({ monthlyLimit: limit, alertThreshold: threshold }); + toggleSettings(false); + await render(); +}); + +document.getElementById('settings-btn')!.addEventListener('click', () => toggleSettings(true)); +document.getElementById('close-settings')!.addEventListener('click', () => toggleSettings(false)); + +function toggleSettings(show: boolean) { + document.getElementById('settings-section')!.classList.toggle('hidden', !show); + document.getElementById('forecast-section')!.classList.toggle('hidden', show); + document.getElementById('breakdown-section')!.classList.toggle('hidden', show); + document.getElementById('subscriptions-section')!.classList.toggle('hidden', show); + document.getElementById('add-section')!.classList.toggle('hidden', show); +} + +render().catch(console.error); diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..e6584b3 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,52 @@ +import type { AppState, Subscription, Budget } from './types.js'; + +const DEFAULT_STATE: AppState = { + subscriptions: [], + budget: { + monthlyLimit: 1000, + alertThreshold: 80, + }, +}; + +export async function getState(): Promise { + if (typeof chrome !== 'undefined' && chrome.storage) { + const result = await chrome.storage.local.get(['saasCostState']); + if (result.saasCostState) { + return result.saasCostState as AppState; + } + } + return structuredClone(DEFAULT_STATE); +} + +export async function saveState(state: AppState): Promise { + if (typeof chrome !== 'undefined' && chrome.storage) { + await chrome.storage.local.set({ saasCostState: state }); + } +} + +export async function addSubscription(sub: Subscription): Promise { + const state = await getState(); + const existing = state.subscriptions.findIndex(s => s.id === sub.id); + if (existing >= 0) { + state.subscriptions[existing] = sub; + } else { + state.subscriptions.push(sub); + } + await saveState(state); +} + +export async function deleteSubscription(id: string): Promise { + const state = await getState(); + state.subscriptions = state.subscriptions.filter(s => s.id !== id); + await saveState(state); +} + +export async function updateBudget(budget: Budget): Promise { + const state = await getState(); + state.budget = budget; + await saveState(state); +} + +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..87265f4 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,42 @@ +export interface Subscription { + id: string; + name: string; + cost: number; + billingCycle: 'monthly' | 'yearly' | 'weekly'; + category: string; + url?: string; + createdAt: string; + history: CostHistoryPoint[]; +} + +export interface CostHistoryPoint { + date: string; + amount: number; +} + +export interface Budget { + monthlyLimit: number; + alertThreshold: number; +} + +export interface ForecastResult { + nextMonthForecast: number; + trendDirection: 'up' | 'down' | 'stable'; + trendPercent: number; + confidence: 'high' | 'medium' | 'low'; + breakdown: CategoryBreakdown[]; + alertTriggered: boolean; + alertMessage?: string; +} + +export interface CategoryBreakdown { + category: string; + currentSpend: number; + forecastSpend: number; + percentOfTotal: number; +} + +export interface AppState { + subscriptions: Subscription[]; + budget: Budget; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a01bb6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["chrome", "node"] + }, + "include": ["src/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..7e052f2 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import { crx } from '@crxjs/vite-plugin' +import manifest from './manifest.json' assert { type: 'json' } + +export default defineConfig({ + build: { + outDir: 'dist', + emptyOutDir: true, + }, + plugins: [crx({ manifest })], +})