commit e01cea882594c7d3166bdad9160e752581df0533 Author: Bun Bun Date: Sat Jun 20 12:28:17 2026 +0000 QuitFree MVP: subscription tracker, budget manager, cancel helper, debt calculator, CSV/JSON export. 13 tests passing. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be21be3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.venv/ +*.log +.verdict +.DS_Store +*.local diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4cdc35c --- /dev/null +++ b/manifest.json @@ -0,0 +1,40 @@ +{ + "manifest_version": 3, + "name": "QuitFree — Subscription & Budget Tracker", + "version": "1.0.0", + "description": "Track subscriptions, cancel in one tap, budget without bank linking. No subscription required.", + "permissions": [ + "storage", + "notifications", + "alarms", + "activeTab" + ], + "host_permissions": [ + "https://*/*" + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "background": { + "service_worker": "src/background/background.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://*/*"], + "js": ["src/content/cancel-helper.ts"], + "run_at": "document_idle" + } + ], + "options_page": "src/options/options.html", + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..48183b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1896 @@ +{ + "name": "quitfree", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "quitfree", + "version": "1.0.0", + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^2.1.5" + } + }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.7.0.tgz", + "integrity": "sha512-NN98fUgJOEiVBshELJnYCW54+K2Yy067++/K+RdxclHsyG08ea+Em3lJJTubtPTCl2bECyp4+K69oT+izilnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.3.5", + "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.1.0", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "rollup": "2.80.0", + "rxjs": "7.5.7", + "tinyglobby": "^0.2.17" + }, + "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.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "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/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "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/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "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/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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/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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "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/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": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "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.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..faf0536 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "quitfree", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "test": "vitest run" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "typescript": "^5.6.3", + "vite": "^5.4.11", + "vitest": "^2.1.5" + } +} diff --git a/public/icons/icon128.png b/public/icons/icon128.png new file mode 100644 index 0000000..5afe970 Binary files /dev/null and b/public/icons/icon128.png differ diff --git a/public/icons/icon16.png b/public/icons/icon16.png new file mode 100644 index 0000000..d23b017 Binary files /dev/null and b/public/icons/icon16.png differ diff --git a/public/icons/icon48.png b/public/icons/icon48.png new file mode 100644 index 0000000..9f7f28e Binary files /dev/null and b/public/icons/icon48.png differ diff --git a/scripts/gen-icons.cjs b/scripts/gen-icons.cjs new file mode 100644 index 0000000..83e5d0f --- /dev/null +++ b/scripts/gen-icons.cjs @@ -0,0 +1,43 @@ +const zlib = require('zlib'); +const fs = require('fs'); +const path = require('path'); +const outDir = process.argv[2] || path.join(__dirname, '..', 'public', 'icons'); +fs.mkdirSync(outDir, { recursive: true }); +function writeChunk(type, data) { + const chunk = Buffer.concat([Buffer.alloc(4), Buffer.from(type), data, Buffer.alloc(4)]); + chunk.writeUInt32BE(data.length, 0); + const crc = zlib.crc32(Buffer.concat([Buffer.from(type), data])); + chunk.writeUInt32BE(crc >>> 0, 4 + 4 + data.length); + return chunk; +} +function writePng(filename, width, height, r, g, b) { + const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr.writeUInt8(8, 8); + ihdr.writeUInt8(2, 9); + ihdr.writeUInt8(0, 10); + ihdr.writeUInt8(0, 11); + ihdr.writeUInt8(0, 12); + const rowLength = 1 + width * 3; + const imageData = Buffer.alloc(rowLength * height); + for (let y = 0; y < height; y++) { + const rowStart = y * rowLength; + imageData[rowStart] = 0; + for (let x = 0; x < width; x++) { + const offset = rowStart + 1 + x * 3; + imageData[offset] = r; + imageData[offset + 1] = g; + imageData[offset + 2] = b; + } + } + const compressed = zlib.deflateSync(imageData); + const chunks = [signature, writeChunk('IHDR', ihdr), writeChunk('IDAT', compressed), writeChunk('IEND', Buffer.alloc(0))]; + fs.writeFileSync(filename, Buffer.concat(chunks)); +} +const teal = [0x22, 0xD3, 0xEE]; +writePng(path.join(outDir, 'icon16.png'), 16, 16, ...teal); +writePng(path.join(outDir, 'icon48.png'), 48, 48, ...teal); +writePng(path.join(outDir, 'icon128.png'), 128, 128, ...teal); +console.log('Icons generated'); diff --git a/src/background/background.ts b/src/background/background.ts new file mode 100644 index 0000000..ba9bf50 --- /dev/null +++ b/src/background/background.ts @@ -0,0 +1,33 @@ +import { getData, setData, subscriptionsExpiringSoon } from '../shared/storage' + +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === 'trial-check') { + const data = await getData() + const soon = subscriptionsExpiringSoon(data.subscriptions, 3) + for (const s of soon) { + chrome.notifications.create(`trial-${s.id}`, { + type: 'basic', + iconUrl: '/icons/icon128.png', + title: 'QuitFree — Trial Expiring Soon', + message: `Your ${s.name} trial is ending soon. Cancel now to avoid charges!`, + priority: 2 + }) + } + } +}) + +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create('trial-check', { periodInMinutes: 60 }) +}) + +chrome.runtime.onStartup.addListener(() => { + chrome.alarms.create('trial-check', { periodInMinutes: 60 }) +}) + +chrome.notifications.onClicked.addListener((notificationId) => { + if (notificationId.startsWith('trial-')) { + chrome.tabs.create({ url: 'https://quitfree.bunbunlabs.com' }) + } +}) + +console.log('QuitFree background service worker started') diff --git a/src/content/cancel-helper.ts b/src/content/cancel-helper.ts new file mode 100644 index 0000000..30f23a2 --- /dev/null +++ b/src/content/cancel-helper.ts @@ -0,0 +1,85 @@ +import { CANCEL_HELPERS } from '../shared/storage' + +const hostname = window.location.hostname.toLowerCase() + +function findMatch(): { key: string; url: string; steps: string[] } | undefined { + for (const [key, helper] of Object.entries(CANCEL_HELPERS)) { + if (hostname.includes(key.replace(/\s+/g, ''))) { + return { key, url: helper.url, steps: helper.steps } + } + } + return undefined +} + +function injectBanner(match: { key: string; url: string; steps: string[] }) { + if (document.getElementById('quitfree-banner')) return + + const banner = document.createElement('div') + banner.id = 'quitfree-banner' + banner.innerHTML = ` +
+ 💸 +
+ QuitFree: + This is a subscription page. Want to cancel ${match.key}? +
+ Go to Cancel Page + +
+ ` + banner.style.cssText = ` + position: fixed; + top: 0; left: 0; right: 0; + z-index: 2147483647; + background: #0f172a; + color: #22d3ee; + padding: 10px 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + border-bottom: 2px solid #22d3ee; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + ` + + document.body.style.paddingTop = '54px' + + document.body.appendChild(banner) + + document.getElementById('quitfree-dismiss')!.addEventListener('click', () => { + banner.remove() + document.body.style.paddingTop = '' + }) + + // Auto-hide steps hint + const stepsEl = document.createElement('div') + stepsEl.id = 'quitfree-steps' + stepsEl.style.cssText = ` + position: fixed; + bottom: 16px; right: 16px; + z-index: 2147483647; + background: #1e293b; + color: #e2e8f0; + border: 1px solid #334155; + border-radius: 8px; + padding: 12px 16px; + max-width: 280px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + ` + stepsEl.innerHTML = ` +
🛠️ Cancel Steps
+
    ${match.steps.map(s => `
  1. ${s}
  2. `).join('')}
+
+ +
+ ` + document.body.appendChild(stepsEl) + + document.getElementById('quitfree-hide-steps')!.addEventListener('click', () => { + stepsEl.remove() + }) +} + +const match = findMatch() +if (match) { + injectBanner(match) +} diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..1b6ab37 --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,159 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0f172a; + color: #e2e8f0; + font-size: 14px; + line-height: 1.5; +} + +.container { max-width: 1200px; margin: 0 auto; padding: 24px; } + +.header { margin-bottom: 24px; } + +.header h1 { font-size: 28px; font-weight: 700; color: #22d3ee; } + +.subtitle { color: #94a3b8; margin-top: 4px; } + +.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(380px, 1fr)); gap: 20px; } + +.panel { + background: #1e293b; + border-radius: 12px; + padding: 20px; + border: 1px solid #334155; +} + +.panel h2 { font-size: 16px; font-weight: 700; color: #e2e8f0; margin-bottom: 16px; } + +.actions { display: flex; gap: 8px; margin-bottom: 16px; } + +.btn-primary { + background: #0891b2; + border: none; + border-radius: 6px; + padding: 8px 14px; + color: #fff; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { background: #06b6d4; } + +.btn-secondary { + background: #334155; + border: none; + border-radius: 6px; + padding: 8px 14px; + color: #cbd5e1; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-secondary:hover { background: #475569; } + +.btn-danger { + background: #7f1d1d; + border: none; + border-radius: 4px; + padding: 4px 8px; + color: #fca5a5; + font-size: 11px; + cursor: pointer; +} + +.btn-danger:hover { background: #991b1b; } + +.form { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } + +.form input, .form select { + background: #0f172a; + border: 1px solid #334155; + border-radius: 6px; + padding: 8px 10px; + color: #e2e8f0; + font-size: 13px; + width: 100%; +} + +.form input:focus { outline: none; border-color: #22d3ee; } + +.row { display: flex; gap: 8px; } + +.row input { flex: 1; } + +.table-wrap { overflow-x: auto; } + +table { width: 100%; border-collapse: collapse; font-size: 13px; } + +th, td { padding: 8px 10px; text-align: left; border-bottom: 1px solid #334155; } + +th { color: #94a3b8; font-weight: 600; font-size: 11px; text-transform: uppercase; } + +.list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; } + +.list-item { + background: #0f172a; + border: 1px solid #334155; + border-radius: 6px; + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.debt-toggle { display: flex; gap: 8px; margin: 12px 0; } + +.debt-toggle .active { background: #0891b2; color: #fff; } + +.results { + background: #0f172a; + border: 1px solid #334155; + border-radius: 6px; + padding: 12px; + font-size: 13px; +} + +.results-row { display: flex; justify-content: space-between; padding: 4px 0; } + +.results-total { border-top: 1px solid #334155; margin-top: 8px; padding-top: 8px; font-weight: 700; color: #22d3ee; } + +.chart { display: flex; align-items: flex-end; gap: 4px; height: 160px; padding: 12px 0; } + +.bar { + flex: 1; + background: #22d3ee; + border-radius: 4px 4px 0 0; + min-height: 4px; + position: relative; + transition: all 0.3s; +} + +.bar:hover { background: #06b6d4; } + +.bar-label { + position: absolute; + bottom: -18px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: #94a3b8; + white-space: nowrap; +} + +.bar-value { + position: absolute; + top: -18px; + left: 50%; + transform: translateX(-50%); + font-size: 10px; + color: #22d3ee; + white-space: nowrap; +} + +.empty { color: #64748b; text-align: center; padding: 20px; } diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..a0da872 --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,59 @@ + + + + + + QuitFree Dashboard + + +
+
+

QuitFree Dashboard

+

Anti-subscription finance. No bank links. No recurring fees.

+
+ +
+
+

All Subscriptions

+
+ + +
+
+
+ +
+

Monthly Spending Trend

+
+
+ +
+

Debt Payoff Calculator

+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
+ +
+

Expense History

+
+
+
+
+ + + diff --git a/src/options/options.ts b/src/options/options.ts new file mode 100644 index 0000000..000acce --- /dev/null +++ b/src/options/options.ts @@ -0,0 +1,239 @@ +import { + getData, setData, generateId, formatCurrency, totalMonthlyCost, subscriptionsToCSV, expensesToCSV, exportToJSON, downloadFile, monthlyCost, annualCost +} from '../shared/storage' +import type { Subscription, Expense, DebtEntry } from '../shared/storage' + +async function render() { + const data = await getData() + renderAllSubs(data.subscriptions) + renderSpendingChart(data.subscriptions, data.expenses) + renderDebts(data.debts) + renderAllExpenses(data.expenses) +} + +function renderAllSubs(subs: Subscription[]) { + const container = document.getElementById('all-subs')! + if (subs.length === 0) { + container.innerHTML = '
No subscriptions yet.
' + return + } + container.innerHTML = ` + + + + + + + + + + + + + + ${subs.map(s => ` + + + + + + + + + + `).join('')} + +
NameCostIntervalMonthlyAnnualCategoryStatus
${escapeHtml(s.name)}${formatCurrency(s.cost)}${s.interval}${formatCurrency(monthlyCost(s))}${formatCurrency(annualCost(s))}${escapeHtml(s.category)}${s.cancelled ? 'Cancelled' : 'Active'}
+ ` +} + +function renderSpendingChart(subs: Subscription[], expenses: Expense[]) { + const container = document.getElementById('spending-chart')! + const months = getLast12Months() + const data = months.map(m => { + const subTotal = totalMonthlyCost(subs) // rough estimate + const expTotal = expenses.filter((e: Expense) => e.date.startsWith(m)).reduce((s: number, e: Expense) => s + e.amount, 0) + return subTotal + expTotal + }) + const max = Math.max(...data, 1) + + if (data.every(v => v === 0)) { + container.innerHTML = '
Add subscriptions and expenses to see trends.
' + return + } + + container.innerHTML = months.map((m, i) => { + const h = (data[i] / max) * 100 + return ` +
+
${formatCurrency(data[i])}
+
${m.slice(5)}
+
+ ` + }).join('') +} + +function getLast12Months(): string[] { + const months = [] + const now = new Date() + for (let i = 11; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`) + } + return months +} + +function renderDebts(debts: DebtEntry[]) { + const container = document.getElementById('debt-list')! + if (debts.length === 0) { + container.innerHTML = '
No debts added yet.
' + return + } + container.innerHTML = debts.map(d => ` +
+
+
${escapeHtml(d.name)}
+
${d.interestRate}% APR • Min: ${formatCurrency(d.minPayment)}/mo
+
+
${formatCurrency(d.balance)}
+ +
+ `).join('') + + container.querySelectorAll('.btn-delete-debt').forEach(btn => { + btn.addEventListener('click', async () => { + const id = (btn as HTMLElement).dataset.id! + const data = await getData() + data.debts = data.debts.filter(d => d.id !== id) + await setData(data) + render() + }) + }) + + calculateDebt(debts, 'snowball') +} + +let currentDebtMode: 'snowball' | 'avalanche' = 'snowball' + +function calculateDebt(debts: DebtEntry[], mode: 'snowball' | 'avalanche') { + currentDebtMode = mode + const container = document.getElementById('debt-results')! + if (debts.length === 0) { + container.innerHTML = '
Add debts to calculate payoff.
' + return + } + + const sorted = mode === 'snowball' + ? [...debts].sort((a, b) => a.balance - b.balance) + : [...debts].sort((a, b) => b.interestRate - a.interestRate) + + let totalMonths = 0 + let totalInterest = 0 + const results: { name: string; months: number; interest: number }[] = [] + + for (const d of sorted) { + let balance = d.balance + let months = 0 + let interest = 0 + const monthlyRate = d.interestRate / 100 / 12 + while (balance > 0 && months < 600) { + const int = balance * monthlyRate + interest += int + balance = balance + int - d.minPayment + if (balance < 0) balance = 0 + months++ + } + totalMonths = Math.max(totalMonths, months) + totalInterest += interest + results.push({ name: d.name, months, interest }) + } + + container.innerHTML = ` +
Payoff strategy${mode === 'snowball' ? 'Snowball' : 'Avalanche'}
+
Total debts${debts.length}
+
Payoff time${totalMonths} months
+
Total interest paid${formatCurrency(totalInterest)}
+
+
Total cost${formatCurrency(debts.reduce((s, d) => s + d.balance, 0) + totalInterest)}
+
+ ` +} + +function renderAllExpenses(expenses: Expense[]) { + const container = document.getElementById('all-expenses')! + const sorted = [...expenses].sort((a, b) => b.date.localeCompare(a.date)) + if (sorted.length === 0) { + container.innerHTML = '
No expenses yet.
' + return + } + container.innerHTML = ` + + + + + + ${sorted.slice(0, 50).map(e => ` + + + + + + + `).join('')} + +
DateDescriptionCategoryAmount
${e.date}${escapeHtml(e.description)}${escapeHtml(e.category)}${formatCurrency(e.amount)}
+ ` +} + +function escapeHtml(str: string): string { + const div = document.createElement('div') + div.textContent = str + return div.innerHTML +} + +// Debt form +document.getElementById('debt-form')!.addEventListener('submit', async e => { + e.preventDefault() + const data = await getData() + const name = (document.getElementById('debt-name') as HTMLInputElement).value.trim() + const balance = parseFloat((document.getElementById('debt-balance') as HTMLInputElement).value) + const rate = parseFloat((document.getElementById('debt-rate') as HTMLInputElement).value) + const min = parseFloat((document.getElementById('debt-min') as HTMLInputElement).value) + + if (!name || isNaN(balance) || isNaN(rate) || isNaN(min)) return + + data.debts.push({ id: generateId(), name, balance, interestRate: rate, minPayment: min }) + await setData(data) + ;(e.target as HTMLFormElement).reset() + render() +}) + +// Toggle strategies +document.getElementById('calc-snowball')!.addEventListener('click', async () => { + document.getElementById('calc-snowball')!.classList.add('active') + document.getElementById('calc-avalanche')!.classList.remove('active') + const data = await getData() + calculateDebt(data.debts, 'snowball') +}) + +document.getElementById('calc-avalanche')!.addEventListener('click', async () => { + document.getElementById('calc-avalanche')!.classList.add('active') + document.getElementById('calc-snowball')!.classList.remove('active') + const data = await getData() + calculateDebt(data.debts, 'avalanche') +}) + +// Export +document.getElementById('export-csv')!.addEventListener('click', async () => { + const data = await getData() + const csv = subscriptionsToCSV(data.subscriptions) + '\n\n' + expensesToCSV(data.expenses) + downloadFile(`quitfree-${new Date().toISOString().slice(0,10)}.csv`, csv, 'text/csv') +}) + +document.getElementById('export-json')!.addEventListener('click', async () => { + const data = await getData() + const json = exportToJSON(data) + downloadFile(`quitfree-${new Date().toISOString().slice(0,10)}.json`, json, 'application/json') +}) + +render() diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..1926ac6 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,289 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + width: 360px; + min-height: 400px; + background: #0f172a; + color: #e2e8f0; + font-size: 13px; +} + +.header { + padding: 16px; + border-bottom: 1px solid #1e293b; + text-align: center; +} + +.header h1 { + font-size: 18px; + font-weight: 700; + color: #22d3ee; + margin-bottom: 4px; +} + +.subtitle { + font-size: 11px; + color: #94a3b8; +} + +.summary-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + padding: 12px 16px; + border-bottom: 1px solid #1e293b; +} + +.card { + background: #1e293b; + border-radius: 8px; + padding: 10px; + text-align: center; +} + +.card-label { + display: block; + font-size: 10px; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.card-value { + display: block; + font-size: 14px; + font-weight: 700; + color: #22d3ee; +} + +.alerts { + padding: 0 16px; +} + +.alert { + background: #451a03; + border: 1px solid #92400e; + border-radius: 6px; + padding: 8px 12px; + margin-top: 8px; + font-size: 12px; + color: #fdba74; +} + +.alert-danger { + background: #450a0a; + border-color: #991b1b; + color: #fca5a5; +} + +.tabs { + display: flex; + padding: 0 16px; + margin-top: 12px; + border-bottom: 1px solid #1e293b; +} + +.tab-btn { + flex: 1; + background: none; + border: none; + color: #64748b; + padding: 8px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; +} + +.tab-btn.active { + color: #22d3ee; + border-bottom-color: #22d3ee; +} + +.tab-btn:hover { + color: #e2e8f0; +} + +.tab-content { + display: none; + padding: 12px 16px; + max-height: 320px; + overflow-y: auto; +} + +.tab-content.active { display: block; } + +.form { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; +} + +.form input, .form select { + background: #1e293b; + border: 1px solid #334155; + border-radius: 6px; + padding: 8px 10px; + color: #e2e8f0; + font-size: 13px; + width: 100%; +} + +.form input:focus, .form select:focus { + outline: none; + border-color: #22d3ee; +} + +.row { + display: flex; + gap: 8px; +} + +.row input { flex: 1; } +.row select { width: 90px; } + +.btn-primary { + background: #0891b2; + border: none; + border-radius: 6px; + padding: 8px; + color: #fff; + font-weight: 600; + font-size: 13px; + cursor: pointer; + transition: background 0.15s; +} + +.btn-primary:hover { background: #06b6d4; } + +.btn-secondary { + background: #334155; + border: none; + border-radius: 4px; + padding: 4px 8px; + color: #cbd5e1; + font-size: 11px; + cursor: pointer; +} + +.btn-secondary:hover { background: #475569; } + +.btn-danger { + background: #7f1d1d; + border: none; + border-radius: 4px; + padding: 4px 8px; + color: #fca5a5; + font-size: 11px; + cursor: pointer; +} + +.btn-danger:hover { background: #991b1b; } + +.btn-success { + background: #14532d; + border: none; + border-radius: 4px; + padding: 4px 8px; + color: #86efac; + font-size: 11px; + cursor: pointer; +} + +.btn-success:hover { background: #166534; } + +.list { display: flex; flex-direction: column; gap: 8px; } + +.list-item { + background: #1e293b; + border-radius: 6px; + padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.list-item-info { flex: 1; } + +.list-item-name { + font-weight: 600; + color: #e2e8f0; + font-size: 13px; +} + +.list-item-meta { + font-size: 11px; + color: #94a3b8; + margin-top: 2px; +} + +.list-item-cost { + font-weight: 700; + color: #22d3ee; + font-size: 13px; + text-align: right; +} + +.list-item-actions { + display: flex; + gap: 4px; + margin-top: 6px; +} + +.empty { + text-align: center; + color: #64748b; + padding: 20px; + font-size: 12px; +} + +.budget-status { + background: #1e293b; + border-radius: 6px; + padding: 12px; +} + +.budget-bar { + height: 8px; + background: #334155; + border-radius: 4px; + overflow: hidden; + margin: 8px 0; +} + +.budget-bar-fill { + height: 100%; + background: #22d3ee; + border-radius: 4px; + transition: width 0.3s; +} + +.budget-bar-fill.over { background: #ef4444; } + +.budget-text { + font-size: 12px; + color: #94a3b8; + text-align: center; +} + +.footer { + padding: 12px 16px; + text-align: center; + border-top: 1px solid #1e293b; +} + +.footer a { + color: #22d3ee; + text-decoration: none; + font-size: 12px; + font-weight: 600; +} + +.footer a:hover { text-decoration: underline; } + +.cancelled .list-item-name { text-decoration: line-through; color: #64748b; } +.cancelled .list-item-cost { color: #64748b; } diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..0535bae --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,81 @@ + + + + + + + +
+
+

QuitFree

+

No subscriptions. No bank links. Just control.

+
+ +
+
+ Monthly Spend + $0.00 +
+
+ Annual Spend + $0.00 +
+
+ Active Subs + 0 +
+
+ +
+ +
+ + + +
+ +
+
+ +
+ + +
+ + + + +
+
+
+ +
+
+ + + + + +
+
+
+ +
+
+ + +
+
+
+ + +
+ + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..1813003 --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,222 @@ +import { + getData, setData, generateId, formatCurrency, totalMonthlyCost, totalAnnualCost, + monthlyCost, daysUntil, subscriptionsExpiringSoon, CANCEL_HELPERS +} from '../shared/storage' +import type { Subscription, Expense } from '../shared/storage' + +async function render() { + const data = await getData() + const subs = data.subscriptions + const expenses = data.expenses + const budget = data.budgetSettings + + document.getElementById('monthly-spend')!.textContent = formatCurrency(totalMonthlyCost(subs)) + document.getElementById('annual-spend')!.textContent = formatCurrency(totalAnnualCost(subs)) + document.getElementById('active-count')!.textContent = String(subs.filter(s => !s.cancelled).length) + + renderAlerts(subs) + renderSubscriptions(subs) + renderExpenses(expenses) + renderBudget(budget, expenses) +} + +function renderAlerts(subs: Subscription[]) { + const container = document.getElementById('alerts')! + container.innerHTML = '' + const soon = subscriptionsExpiringSoon(subs, 3) + for (const s of soon) { + const div = document.createElement('div') + div.className = 'alert alert-danger' + div.textContent = `Trial for ${s.name} ends in ${daysUntil(s.trialEndsAt!)} day(s). Cancel now!` + container.appendChild(div) + } + const week = subscriptionsExpiringSoon(subs, 7).filter(s => !soon.includes(s)) + for (const s of week) { + const div = document.createElement('div') + div.className = 'alert' + div.textContent = `${s.name} trial ends in ${daysUntil(s.trialEndsAt!)} days.` + container.appendChild(div) + } +} + +function renderSubscriptions(subs: Subscription[]) { + const container = document.getElementById('sub-list')! + container.innerHTML = '' + const active = subs.filter(s => !s.cancelled) + const cancelled = subs.filter(s => s.cancelled) + + if (active.length === 0 && cancelled.length === 0) { + container.innerHTML = '
No subscriptions yet. Add one above.
' + return + } + + for (const s of [...active, ...cancelled]) { + const el = document.createElement('div') + el.className = `list-item${s.cancelled ? ' cancelled' : ''}` + const helper = findHelper(s.name) + el.innerHTML = ` +
+
${escapeHtml(s.name)}
+
${escapeHtml(s.category || 'Uncategorized')} • ${s.interval}
+ ${s.trialEndsAt ? `
Trial ends: ${s.trialEndsAt}
` : ''} +
+ ${helper ? `` : ''} + + +
+
+
${formatCurrency(monthlyCost(s))}/mo
+ ` + container.appendChild(el) + } + + container.querySelectorAll('.btn-cancel').forEach(btn => { + btn.addEventListener('click', async () => { + const id = (btn as HTMLElement).dataset.id! + const sub = subs.find(s => s.id === id) + if (!sub) return + const helper = findHelper(sub.name) as { url: string; email?: string; steps: string[] } | undefined + if (!helper) return + chrome.tabs.create({ url: helper.url }) + }) + }) + + container.querySelectorAll('.btn-mark').forEach(btn => { + btn.addEventListener('click', async () => { + const id = (btn as HTMLElement).dataset.id! + const data = await getData() + const s = data.subscriptions.find(x => x.id === id) + if (s) { s.cancelled = !s.cancelled; await setData(data); render() } + }) + }) + + container.querySelectorAll('.btn-delete').forEach(btn => { + btn.addEventListener('click', async () => { + const id = (btn as HTMLElement).dataset.id! + const data = await getData() + data.subscriptions = data.subscriptions.filter(s => s.id !== id) + await setData(data) + render() + }) + }) +} + +function findHelper(name: string): { url: string; email?: string; steps: string[] } | undefined { + const key = name.toLowerCase().trim() + return CANCEL_HELPERS[key] || Object.entries(CANCEL_HELPERS).find(([k]) => key.includes(k))?.[1] +} + +function renderExpenses(expenses: Expense[]) { + const container = document.getElementById('expense-list')! + container.innerHTML = '' + const sorted = [...expenses].sort((a, b) => b.date.localeCompare(a.date)) + + if (sorted.length === 0) { + container.innerHTML = '
No expenses yet. Add one above.
' + return + } + + for (const e of sorted.slice(0, 20)) { + const el = document.createElement('div') + el.className = 'list-item' + el.innerHTML = ` +
+
${escapeHtml(e.description)}
+
${e.date} • ${escapeHtml(e.category || 'Uncategorized')}
+
+
${formatCurrency(e.amount)}
+ ` + container.appendChild(el) + } +} + +function renderBudget(budget: { monthlyBudget: number; categories: Record }, expenses: Expense[]) { + const container = document.getElementById('budget-status')! + const thisMonth = new Date().toISOString().slice(0, 7) + const spent = expenses.filter(e => e.date.startsWith(thisMonth)).reduce((s, e) => s + e.amount, 0) + const pct = budget.monthlyBudget > 0 ? Math.min((spent / budget.monthlyBudget) * 100, 100) : 0 + const over = budget.monthlyBudget > 0 && spent > budget.monthlyBudget + + container.innerHTML = ` +
${formatCurrency(spent)} of ${formatCurrency(budget.monthlyBudget)} spent
+
+
${over ? 'Over budget!' : `${(100 - pct).toFixed(0)}% remaining`}
+ ` +} + +function escapeHtml(str: string): string { + const div = document.createElement('div') + div.textContent = str + return div.innerHTML +} + +// Tabs +document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')) + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')) + btn.classList.add('active') + document.getElementById(`tab-${(btn as HTMLElement).dataset.tab}`)!.classList.add('active') + }) +}) + +// Sub form +document.getElementById('sub-form')!.addEventListener('submit', async e => { + e.preventDefault() + const data = await getData() + const name = (document.getElementById('sub-name') as HTMLInputElement).value.trim() + const cost = parseFloat((document.getElementById('sub-cost') as HTMLInputElement).value) + const interval = (document.getElementById('sub-interval') as HTMLSelectElement).value as Subscription['interval'] + const category = (document.getElementById('sub-category') as HTMLInputElement).value.trim() || 'Other' + const started = (document.getElementById('sub-started') as HTMLInputElement).value || new Date().toISOString().slice(0, 10) + const trialEnds = (document.getElementById('sub-trial-ends') as HTMLInputElement).value || undefined + + if (!name || isNaN(cost) || cost < 0) return + + const helper = findHelper(name) + data.subscriptions.push({ + id: generateId(), name, cost, interval, startedAt: started, + trialEndsAt: trialEnds || undefined, category, url: '', + cancelUrl: helper?.url, cancelEmail: helper?.email, cancelled: false + }) + await setData(data) + ;(e.target as HTMLFormElement).reset() + render() +}) + +// Expense form +document.getElementById('expense-form')!.addEventListener('submit', async e => { + e.preventDefault() + const data = await getData() + const date = (document.getElementById('exp-date') as HTMLInputElement).value + const desc = (document.getElementById('exp-desc') as HTMLInputElement).value.trim() + const category = (document.getElementById('exp-category') as HTMLInputElement).value.trim() || 'Other' + const amount = parseFloat((document.getElementById('exp-amount') as HTMLInputElement).value) + + if (!date || !desc || isNaN(amount) || amount < 0) return + + data.expenses.push({ id: generateId(), date, description: desc, category, amount }) + await setData(data) + ;(e.target as HTMLFormElement).reset() + render() +}) + +// Budget form +document.getElementById('budget-form')!.addEventListener('submit', async e => { + e.preventDefault() + const data = await getData() + const val = parseFloat((document.getElementById('monthly-budget') as HTMLInputElement).value) + if (!isNaN(val) && val >= 0) { + data.budgetSettings.monthlyBudget = val + await setData(data) + render() + } +}) + +// Open options +document.getElementById('open-options')!.addEventListener('click', e => { + e.preventDefault() + chrome.runtime.openOptionsPage() +}) + +render() diff --git a/src/shared/storage.ts b/src/shared/storage.ts new file mode 100644 index 0000000..e43d4fd --- /dev/null +++ b/src/shared/storage.ts @@ -0,0 +1,181 @@ +export interface Subscription { + id: string + name: string + cost: number + interval: 'monthly' | 'yearly' | 'weekly' + startedAt: string + trialEndsAt?: string + category: string + url?: string + notes?: string + cancelled?: boolean + cancelUrl?: string + cancelEmail?: string +} + +export interface Expense { + id: string + amount: number + description: string + category: string + date: string +} + +export interface BudgetSettings { + monthlyBudget: number + categories: Record +} + +export interface DebtEntry { + id: string + name: string + balance: number + interestRate: number + minPayment: number +} + +export interface StoredData { + subscriptions: Subscription[] + expenses: Expense[] + budgetSettings: BudgetSettings + debts: DebtEntry[] +} + +const STORAGE_KEY = 'quitfree_data' + +export async function getData(): Promise { + try { + const result = await chrome.storage.local.get(STORAGE_KEY) + const raw = result[STORAGE_KEY] as StoredData | undefined + return raw ?? { subscriptions: [], expenses: [], budgetSettings: { monthlyBudget: 0, categories: {} }, debts: [] } + } catch { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) { + try { return JSON.parse(raw) } catch { /* fall through */ } + } + return { subscriptions: [], expenses: [], budgetSettings: { monthlyBudget: 0, categories: {} }, debts: [] } + } +} + +export async function setData(data: StoredData): Promise { + try { + await chrome.storage.local.set({ [STORAGE_KEY]: data }) + } catch { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) + } +} + +export function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount) +} + +export function monthlyCost(sub: Subscription): number { + switch (sub.interval) { + case 'weekly': return sub.cost * 4.33 + case 'yearly': return sub.cost / 12 + case 'monthly': + default: return sub.cost + } +} + +export function annualCost(sub: Subscription): number { + switch (sub.interval) { + case 'weekly': return sub.cost * 52 + case 'yearly': return sub.cost + case 'monthly': + default: return sub.cost * 12 + } +} + +export function totalMonthlyCost(subs: Subscription[]): number { + return subs.filter(s => !s.cancelled).reduce((sum, s) => sum + monthlyCost(s), 0) +} + +export function totalAnnualCost(subs: Subscription[]): number { + return subs.filter(s => !s.cancelled).reduce((sum, s) => sum + annualCost(s), 0) +} + +export function daysUntil(dateStr: string): number { + const diff = new Date(dateStr).getTime() - Date.now() + const days = Math.ceil(diff / (1000 * 60 * 60 * 24)) + return days === -0 ? 0 : days +} + +export function subscriptionsExpiringSoon(subs: Subscription[], days = 3): Subscription[] { + return subs.filter(s => s.trialEndsAt && !s.cancelled && daysUntil(s.trialEndsAt) <= days && daysUntil(s.trialEndsAt) >= 0) +} + +export function subscriptionsToCSV(subs: Subscription[]): string { + const headers = ['Name', 'Cost', 'Interval', 'Monthly Equiv', 'Annual Equiv', 'Started', 'Trial Ends', 'Category', 'URL', 'Cancelled'] + const rows = subs.map(s => [ + s.name, String(s.cost), s.interval, String(monthlyCost(s).toFixed(2)), + String(annualCost(s).toFixed(2)), s.startedAt, s.trialEndsAt || '', s.category, s.url || '', s.cancelled ? 'Yes' : 'No' + ]) + return [headers, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n') +} + +export function expensesToCSV(expenses: Expense[]): string { + const headers = ['Date', 'Description', 'Category', 'Amount'] + const rows = expenses.map(e => [e.date, e.description, e.category, String(e.amount.toFixed(2))]) + return [headers, ...rows].map(r => r.map(c => `"${c.replace(/"/g, '""')}"`).join(',')).join('\n') +} + +export function exportToJSON(data: StoredData): string { + return JSON.stringify(data, null, 2) +} + +export function downloadFile(filename: string, content: string, type = 'text/csv'): void { + const blob = new Blob([content], { type }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export const CANCEL_HELPERS: Record = { + 'netflix': { url: 'https://www.netflix.com/cancelplan', steps: ['Sign in', 'Go to Account', 'Click Cancel Membership'] }, + 'spotify': { url: 'https://www.spotify.com/account/cancel/', steps: ['Sign in', 'Go to Account Overview', 'Click Cancel Premium'] }, + 'adobe': { url: 'https://account.adobe.com/plans', steps: ['Sign in', 'Manage plan', 'Click Cancel plan'] }, + 'amazon prime': { url: 'https://www.amazon.com/primecentral', steps: ['Go to Account & Lists', 'Prime Membership', 'End Membership'] }, + 'hulu': { url: 'https://www.hulu.com/account/cancel', steps: ['Sign in', 'Account page', 'Cancel Subscription'] }, + 'disney+': { url: 'https://www.disneyplus.com/account', steps: ['Sign in', 'Billing Details', 'Cancel Subscription'] }, + 'apple music': { url: 'https://music.apple.com/us/account/settings', steps: ['Open Settings on device', 'Tap your name', 'Subscriptions', 'Cancel'] }, + 'youtube premium': { url: 'https://www.youtube.com/paid_memberships', steps: ['Sign in', 'Paid Memberships', 'Manage', 'Cancel'] }, + 'canva': { url: 'https://www.canva.com/teams/settings', steps: ['Sign in', 'Team Settings', 'Billing', 'Cancel'] }, + 'notion': { url: 'https://www.notion.so/settings', steps: ['Sign in', 'Settings', 'Billing', 'Cancel'] }, + 'figma': { url: 'https://www.figma.com/settings', steps: ['Sign in', 'Settings', 'Plan', 'Cancel'] }, + 'grammarly': { url: 'https://account.grammarly.com/subscription', steps: ['Sign in', 'Subscription', 'Cancel'] }, + 'chatgpt': { url: 'https://chat.openai.com/#settings', steps: ['Sign in', 'Settings', 'My plan', 'Cancel'] }, + 'midjourney': { url: 'https://www.midjourney.com/account/', steps: ['Sign in', 'Manage Plan', 'Cancel'] }, + 'duolingo': { url: 'https://www.duolingo.com/settings', steps: ['Sign in', 'Super settings', 'Manage Plan', 'Cancel'] }, + 'hellofresh': { url: 'https://www.hellofresh.com/account-settings', steps: ['Sign in', 'Account Settings', 'Cancel'] }, + 'audible': { url: 'https://www.audible.com/account/settings', steps: ['Sign in', 'Account Details', 'Cancel Membership'] }, + 'patreon': { url: 'https://www.patreon.com/settings', steps: ['Sign in', 'Settings', 'Memberships', 'Edit', 'Cancel'] }, + 'dropbox': { url: 'https://www.dropbox.com/account/plan', steps: ['Sign in', 'Plan', 'Cancel'] }, + 'google one': { url: 'https://one.google.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'todoist': { url: 'https://todoist.com/app/settings', steps: ['Sign in', 'Settings', 'Subscription', 'Cancel'] }, + 'nordvpn': { url: 'https://my.nordaccount.com/dashboard/nordvpn/', steps: ['Sign in', 'Dashboard', 'Cancel'] }, + '1password': { url: 'https://my.1password.com/billing', steps: ['Sign in', 'Billing', 'Cancel'] }, + 'lastpass': { url: 'https://lastpass.com/my-lastpass', steps: ['Sign in', 'My Account', 'Cancel'] }, + 'bitdefender': { url: 'https://central.bitdefender.com', steps: ['Sign in', 'My Devices', 'Cancel'] }, + 'avast': { url: 'https://id.avast.com', steps: ['Sign in', 'Subscriptions', 'Cancel'] }, + 'malwarebytes': { url: 'https://my.malwarebytes.com', steps: ['Sign in', 'My Account', 'Cancel'] }, + 'norton': { url: 'https://my.norton.com', steps: ['Sign in', 'My Account', 'Auto-Renewal', 'Turn Off'] }, + 'ynab': { url: 'https://app.youneedabudget.com/settings', steps: ['Sign in', 'Settings', 'Subscription', 'Cancel'] }, + 'rocket money': { url: 'https://rocketmoney.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'albert': { url: 'https://albert.com/settings', steps: ['Open app', 'Profile', 'Settings', 'Cancel'] }, + 'acorns': { url: 'https://www.acorns.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'betterment': { url: 'https://www.betterment.com/app/account', steps: ['Sign in', 'Account', 'Cancel'] }, + 'wealthfront': { url: 'https://www.wealthfront.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'sofi': { url: 'https://www.sofi.com/my/dashboard', steps: ['Sign in', 'Dashboard', 'Cancel'] }, + 'klarna': { url: 'https://app.klarna.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'affirm': { url: 'https://www.affirm.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'credit karma': { url: 'https://www.creditkarma.com/settings', steps: ['Sign in', 'Settings', 'Cancel'] }, + 'experian': { url: 'https://www.experian.com/account', steps: ['Sign in', 'Account', 'Cancel'] } +} diff --git a/test/storage.test.ts b/test/storage.test.ts new file mode 100644 index 0000000..80f8ef4 --- /dev/null +++ b/test/storage.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from 'vitest' +import { + monthlyCost, annualCost, totalMonthlyCost, totalAnnualCost, daysUntil, + subscriptionsExpiringSoon, subscriptionsToCSV, expensesToCSV, generateId, formatCurrency +} from '../src/shared/storage' +import type { Subscription, Expense } from '../src/shared/storage' + +describe('monthlyCost', () => { + it('returns monthly cost for monthly interval', () => { + const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' } + expect(monthlyCost(s)).toBe(10) + }) + it('returns monthly cost for yearly interval', () => { + const s: Subscription = { id: '1', name: 'Test', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' } + expect(monthlyCost(s)).toBe(10) + }) + it('returns monthly cost for weekly interval', () => { + const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'weekly', startedAt: '2024-01-01', category: 'Other' } + expect(monthlyCost(s)).toBeCloseTo(43.3, 1) + }) +}) + +describe('annualCost', () => { + it('returns annual cost for monthly interval', () => { + const s: Subscription = { id: '1', name: 'Test', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' } + expect(annualCost(s)).toBe(120) + }) + it('returns annual cost for yearly interval', () => { + const s: Subscription = { id: '1', name: 'Test', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' } + expect(annualCost(s)).toBe(120) + }) +}) + +describe('totalMonthlyCost', () => { + it('sums only active subscriptions', () => { + const subs: Subscription[] = [ + { id: '1', name: 'A', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other' }, + { id: '2', name: 'B', cost: 20, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', cancelled: true }, + { id: '3', name: 'C', cost: 120, interval: 'yearly', startedAt: '2024-01-01', category: 'Other' } + ] + expect(totalMonthlyCost(subs)).toBe(20) + }) +}) + +describe('daysUntil', () => { + it('returns days until a future date', () => { + const future = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10) + expect(daysUntil(future)).toBe(3) + }) + it('returns 0 for today', () => { + const today = new Date().toISOString().slice(0, 10) + expect(daysUntil(today)).toBe(0) + }) +}) + +describe('subscriptionsExpiringSoon', () => { + it('returns trials expiring within given days', () => { + const today = new Date().toISOString().slice(0, 10) + const tomorrow = new Date(Date.now() + 86400000).toISOString().slice(0, 10) + const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10) + const subs: Subscription[] = [ + { id: '1', name: 'A', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: today }, + { id: '2', name: 'B', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: tomorrow }, + { id: '3', name: 'C', cost: 10, interval: 'monthly', startedAt: '2024-01-01', category: 'Other', trialEndsAt: nextWeek } + ] + expect(subscriptionsExpiringSoon(subs, 3).length).toBe(2) + }) +}) + +describe('formatCurrency', () => { + it('formats dollars correctly', () => { + expect(formatCurrency(10)).toBe('$10.00') + expect(formatCurrency(10.5)).toBe('$10.50') + expect(formatCurrency(0)).toBe('$0.00') + }) +}) + +describe('generateId', () => { + it('generates unique ids', () => { + const id1 = generateId() + const id2 = generateId() + expect(id1).not.toBe(id2) + expect(id1.length).toBeGreaterThan(0) + }) +}) + +describe('subscriptionsToCSV', () => { + it('produces valid CSV', () => { + const subs: Subscription[] = [ + { id: '1', name: 'Netflix', cost: 15.99, interval: 'monthly', startedAt: '2024-01-01', category: 'Entertainment' } + ] + const csv = subscriptionsToCSV(subs) + expect(csv).toContain('Name') + expect(csv).toContain('Netflix') + expect(csv).toContain('15.99') + }) +}) + +describe('expensesToCSV', () => { + it('produces valid CSV', () => { + const expenses: Expense[] = [ + { id: '1', amount: 25.50, description: 'Lunch', category: 'Food', date: '2024-01-01' } + ] + const csv = expensesToCSV(expenses) + expect(csv).toContain('Description') + expect(csv).toContain('Lunch') + expect(csv).toContain('25.50') + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b0a5d99 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["chrome", "vitest/globals"] + }, + "include": ["src/**/*"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5893fb0 --- /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({ + plugins: [crx({ manifest })], + build: { + outDir: 'dist', + emptyOutDir: true, + }, +})