From ca02f40841d180547c9be88d39b1ec741766a1bd Mon Sep 17 00:00:00 2001 From: cesnimda Date: Tue, 30 Jun 2026 19:40:54 +0200 Subject: [PATCH] feat: Smart Folders sidebar, category heatmap, sidebar counts API - Replace plain activity heatmap with CategoryHeatmapWidget on dashboard - Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections - Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage - Mailbox section: 11 Gmail mailbox items with icons and live count badges - Smart Folders section: 10 category folders sorted by count desc - Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint - Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping - Sidebar collapses to icon-only; section states persist to localStorage Co-Authored-By: Claude Sonnet 4.6 --- frontend/package-lock.json | 2153 +++++++++++++++++ frontend/src/api/client.js | 6 +- frontend/src/components/Layout.jsx | 258 +- frontend/src/components/widgets.jsx | 38 + frontend/src/main.jsx | 2 + frontend/src/pages/Dashboard.jsx | 26 +- frontend/src/pages/Senders.jsx | 69 + frontend/src/styles.css | 105 +- .../Controllers/AnalyticsController.cs | 6 + src/InboxIntel.Api/Program.cs | 8 + src/InboxIntel.Api/appsettings.json | 4 +- .../Abstractions/IServices.cs | 2 + .../DTOs/AnalyticsDtos.cs | 17 + .../Analytics/AnalyticsService.cs | 67 + .../Configuration/Options.cs | 6 +- .../Gmail/GmailService.cs | 39 +- .../Sync/SyncService.cs | 126 +- 17 files changed, 2866 insertions(+), 66 deletions(-) create mode 100644 frontend/package-lock.json create mode 100644 frontend/src/pages/Senders.jsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..d7c48be --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2153 @@ +{ + "name": "inboxintel-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inboxintel-frontend", + "version": "0.1.0", + "dependencies": { + "axios": "^1.7.2", + "chart.js": "^4.4.3", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", + "react-dom": "^18.3.1", + "react-grid-layout": "^1.4.4", + "react-router-dom": "^6.24.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.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/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "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/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.382", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.382.tgz", + "integrity": "sha512-8ETaWbV6SZOrno+G93Ffd9ENsMtetqdnqj4nlfxFW90Sm5GgnuV28Kf62hqQVD6VUgzm7qFQKsTsAPmeUiU3Ug==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "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/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "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-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "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/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-draggable": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.0.tgz", + "integrity": "sha512-kTpANmKWVnFXiZ76Ag2ZowiFStuBYnJ606PI1TbUsOg29/400/JNIxI9+CuenhiAqFuXWJffz6F4UI3R51kUug==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.3.tgz", + "integrity": "sha512-KaG6IbjD6fYhagUtIvOzhftXG+ViKZjCjADe86X1KHl7C/dsBN2z0mi14nbvZKTkp0RKiil9RPcJBgq3LnoA8g==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index e91bf88..4e64106 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -39,7 +39,11 @@ export const AnalyticsApi = { topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data), volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data), heatmap: () => api.get('/analytics/heatmap').then((r) => r.data), - attachments: () => api.get('/analytics/attachments').then((r) => r.data) + categoryHeatmap: () => api.get('/analytics/category-heatmap').then((r) => r.data), + attachments: () => api.get('/analytics/attachments').then((r) => r.data), + sidebarCounts: () => api.get('/analytics/sidebar-counts').then((r) => r.data), + // Large take to retrieve all senders for the volume-tiers page. + allSenders: () => api.get('/analytics/top-senders?take=5000').then((r) => r.data) }; export const CleanupApi = { diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index cf6b46e..6d2707f 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,31 +1,194 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { useEffect, useState } from 'react'; -import { AuthApi, SyncApi } from '../api/client.js'; +import { useEffect, useState, useCallback } from 'react'; +import { AuthApi, SyncApi, AnalyticsApi } from '../api/client.js'; import Logo from './Logo.jsx'; import DevBanner from './DevBanner.jsx'; +// ── Folder definitions ──────────────────────────────────────────────────────── + +const MAILBOX = [ + { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox' }, + { slug: 'allmail', icon: '📬', label: 'All Mail', countKey: 'allMail' }, + { slug: 'starred', icon: '⭐', label: 'Starred', countKey: 'starred' }, + { slug: 'sent', icon: '📤', label: 'Sent', countKey: 'sent' }, + { slug: 'drafts', icon: '✏️', label: 'Drafts', countKey: 'drafts' }, + { slug: 'archive', icon: '📦', label: 'Archive', countKey: null }, + { slug: 'spam', icon: '🚫', label: 'Spam', countKey: 'spam' }, + { slug: 'trash', icon: '🗑️', label: 'Trash', countKey: 'trash' }, + { slug: 'unlabeled', icon: '🏷️', label: 'Unlabeled', countKey: null }, + { slug: 'pinned', icon: '📌', label: 'Pinned', countKey: null }, + { slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null }, +]; + +// Special items always available as favorites (not smart folders) +const FAV_SPECIALS = { + inbox: { slug: 'inbox', icon: '📥', label: 'Inbox', countKey: 'inbox', type: 'mailbox' }, + unread: { slug: 'unread', icon: '🔵', label: 'Unread Mail', countKey: 'unread', type: 'filter' }, + large: { slug: 'large', icon: '📎', label: 'Large Mail', countKey: 'large', type: 'filter' }, + old: { slug: 'old', icon: '🕰️', label: 'Old Mail', countKey: 'old', type: 'filter' }, + readlater: { slug: 'readlater', icon: '🔖', label: 'Read Later', countKey: null, type: 'filter' }, +}; + +const DEFAULT_FAV_SLUGS = ['inbox', 'unread', 'large', 'old', 'readlater']; + +const SMART_FOLDERS = [ + { slug: 'automated', icon: '🤖', label: 'Automated', countKey: 'automated' }, + { slug: 'noreply', icon: '🔇', label: 'No-Reply', countKey: 'noreply' }, + { slug: 'shopping', icon: '🛍️', label: 'Online Shopping', countKey: 'shopping' }, + { slug: 'gaming', icon: '🎮', label: 'Gaming', countKey: 'gaming' }, + { slug: 'finance', icon: '💳', label: 'Finance & Insurance', countKey: 'finance' }, + { slug: 'sales', icon: '🏷️', label: 'Seasonal Sales', countKey: 'sales' }, + { slug: 'ridesharing', icon: '🚗', label: 'Ride Sharing', countKey: 'ridesharing' }, + { slug: 'food', icon: '🍕', label: 'Food Delivery', countKey: 'food' }, + { slug: 'social', icon: '📱', label: 'Social Notifications',countKey: 'social' }, + { slug: 'wellness', icon: '🏃', label: 'Wellness & Sport', countKey: 'wellness' }, +]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const fmtCount = (n) => { + if (!n) return null; + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +}; + +const LS_FAV = 'ii:fav-slugs'; +const LS_OPEN = 'ii:sidebar-open'; +const LS_SECTS = 'ii:sidebar-sections'; + +const loadFavs = () => { + try { return JSON.parse(localStorage.getItem(LS_FAV)) ?? DEFAULT_FAV_SLUGS; } + catch { return DEFAULT_FAV_SLUGS; } +}; +const saveFavs = (v) => localStorage.setItem(LS_FAV, JSON.stringify(v)); + +const loadSections = () => { + try { return JSON.parse(localStorage.getItem(LS_SECTS)) ?? { fav: true, mailbox: true, smart: true }; } + catch { return { fav: true, mailbox: true, smart: true }; } +}; +const saveSections = (v) => localStorage.setItem(LS_SECTS, JSON.stringify(v)); + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function SectionHead({ label, open, onToggle, collapsed }) { + return ( + + ); +} + +function FolderLink({ item, active, count, collapsed, extra }) { + return ( + + {item.icon} + {!collapsed && ( + <> + {item.label} + + {count && {count}} + {extra} + + )} + + ); +} + +// ── Main Layout ─────────────────────────────────────────────────────────────── + export default function Layout() { const [user, setUser] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(() => { + try { return localStorage.getItem(LS_OPEN) !== 'false'; } catch { return true; } + }); + const [sections, setSections] = useState(loadSections); + const [favSlugs, setFavSlugs] = useState(loadFavs); + const [counts, setCounts] = useState(null); + const loc = useLocation(); const navigate = useNavigate(); + useEffect(() => { AuthApi.me().then(setUser).catch(() => {}); }, []); + useEffect(() => { - AuthApi.me().then(setUser).catch(() => {}); + AnalyticsApi.sidebarCounts().then(setCounts).catch(() => {}); + }, []); + + const toggleSidebar = () => { + setSidebarOpen((o) => { + localStorage.setItem(LS_OPEN, String(!o)); + return !o; + }); + }; + + const toggleSection = (key) => { + setSections((s) => { + const next = { ...s, [key]: !s[key] }; + saveSections(next); + return next; + }); + }; + + const toggleFav = useCallback((slug) => { + setFavSlugs((prev) => { + const next = prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]; + saveFavs(next); + return next; + }); }, []); const nav = [ { to: '/app', label: 'Dashboard', end: true }, + { to: '/app/senders', label: 'Senders' }, { to: '/app/cleanup', label: 'Cleanup' }, { to: '/app/unsubscribe', label: 'Unsubscribe' } ]; - const isActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to)); + const isNavActive = (n) => (n.end ? loc.pathname === n.to : loc.pathname.startsWith(n.to)); + const activeSlug = loc.pathname.startsWith('/app/folder/') + ? loc.pathname.split('/app/folder/')[1] + : null; const logout = async () => { try { await AuthApi.logout(); } catch { /* ignore */ } navigate('/'); }; + const startSync = async () => { + try { await SyncApi.incremental(); } catch { /* ignore */ } + if (loc.pathname !== '/app') navigate('/app'); + window.dispatchEvent(new Event('inboxintel:sync-started')); + }; + + // Count getters + const mailboxCount = (item) => fmtCount(item.countKey ? counts?.[item.countKey] : null); + const smartCount = (item) => fmtCount(counts?.smartFolders?.[item.slug]); + + // Build favorites list: specials first (in default order), then pinned smart folders + const favItems = favSlugs.map((slug) => { + if (FAV_SPECIALS[slug]) return { ...FAV_SPECIALS[slug], source: 'special' }; + const sf = SMART_FOLDERS.find((f) => f.slug === slug); + return sf ? { ...sf, source: 'smart' } : null; + }).filter(Boolean); + + const favCount = (item) => { + if (item.source === 'smart') return smartCount(item); + return fmtCount(item.countKey ? counts?.[item.countKey] : null); + }; + + // Smart folders sorted by count desc + const sortedSmart = [...SMART_FOLDERS].sort((a, b) => { + const ca = counts?.smartFolders?.[a.slug] ?? 0; + const cb = counts?.smartFolders?.[b.slug] ?? 0; + return cb - ca; + }); + + const isPinnedSmart = (slug) => favSlugs.includes(slug); + return (
@@ -33,17 +196,94 @@ export default function Layout() {
- + {user?.email} -
- -
+ +
+ + +
+ +
+
); } diff --git a/frontend/src/components/widgets.jsx b/frontend/src/components/widgets.jsx index 0fb7de5..5c4e6ad 100644 --- a/frontend/src/components/widgets.jsx +++ b/frontend/src/components/widgets.jsx @@ -1,4 +1,6 @@ +import { useEffect, useState } from 'react'; import { Bar, Line, Doughnut } from 'react-chartjs-2'; +import { AnalyticsApi } from '../api/client.js'; import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend @@ -91,6 +93,42 @@ export function HeatmapWidget({ heatmap }) { ); } +const DOW = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +export function CategoryHeatmapWidget() { + const [cells, setCells] = useState(null); + useEffect(() => { AnalyticsApi.categoryHeatmap().then(setCells).catch(() => setCells([])); }, []); + + if (!cells) return
Loading…
; + if (cells.length === 0) return

Category Heatmap

No data yet — run a sync.
; + + const categories = [...new Set(cells.map((c) => c.category))].sort(); + const max = Math.max(1, ...cells.map((c) => c.count)); + const grid = {}; + cells.forEach((c) => { grid[`${c.category}-${c.dayOfWeek}`] = c.count; }); + + return ( +
+

Category Heatmap

+
+
+ + {DOW.map((d) => {d})} +
+ {categories.map((cat) => ( +
+ {cat} + {DOW.map((_, dow) => { + const v = grid[`${cat}-${dow}`] || 0; + return {v || ''}; + })} +
+ ))} +
+
+ ); +} + export function AttachmentsWidget({ attachments }) { if (!attachments) return ; const data = { diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 1bee4fe..2ecb8b3 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import Landing from './pages/Landing.jsx'; import Dashboard from './pages/Dashboard.jsx'; +import Senders from './pages/Senders.jsx'; import Cleanup from './pages/Cleanup.jsx'; import Unsubscribe from './pages/Unsubscribe.jsx'; import Layout from './components/Layout.jsx'; @@ -18,6 +19,7 @@ ReactDOM.createRoot(document.getElementById('root')).render( {/* Authenticated app */} }> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 5bbfddf..7d7aa09 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -6,7 +6,7 @@ import { AnalyticsApi, LayoutApi, ExportApi, SyncApi } from '../api/client.js'; import SyncSplash from '../components/SyncSplash.jsx'; import { HealthWidget, StatCard, TopSendersWidget, VolumeWidget, - HeatmapWidget, AttachmentsWidget, StorageWidget + CategoryHeatmapWidget, AttachmentsWidget, StorageWidget } from '../components/widgets.jsx'; // Default grid geometry; overridden by the user's saved layout. @@ -17,8 +17,8 @@ const DEFAULT_LAYOUT = [ { i: 'storage', x: 7, y: 0, w: 2, h: 2 }, { i: 'top-senders', x: 3, y: 2, w: 3, h: 5 }, { i: 'volume', x: 6, y: 2, w: 6, h: 4 }, - { i: 'heatmap', x: 0, y: 5, w: 6, h: 4 }, - { i: 'attachments', x: 6, y: 6, w: 4, h: 4 } + { i: 'category-heatmap', x: 0, y: 5, w: 6, h: 5 }, + { i: 'attachments', x: 6, y: 6, w: 4, h: 4 }, ]; const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i); @@ -36,7 +36,11 @@ export default function Dashboard() { useEffect(() => { LayoutApi.get().then((saved) => { if (saved?.length) { - setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h }))); + const savedLayout = saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })); + const savedKeys = new Set(savedLayout.map((l) => l.i)); + // Append any newer default widgets the saved layout doesn't know about yet. + const merged = [...savedLayout, ...DEFAULT_LAYOUT.filter((d) => !savedKeys.has(d.i))]; + setLayout(merged); setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey)); } }).catch(() => {}); @@ -54,6 +58,13 @@ export default function Dashboard() { }).catch(loadData); }, [loadData]); + // Show the splash when a sync is triggered from the header "Sync now" button. + useEffect(() => { + const onStart = () => setSyncing(true); + window.addEventListener('inboxintel:sync-started', onStart); + return () => window.removeEventListener('inboxintel:sync-started', onStart); + }, []); + const onSyncDone = useCallback(() => { setSyncing(false); loadData(); @@ -83,7 +94,7 @@ export default function Dashboard() { case 'storage': return ; case 'top-senders': return ; case 'volume': return ; - case 'heatmap': return ; + case 'category-heatmap': return ; case 'attachments': return ; default: return null; } @@ -112,6 +123,11 @@ export default function Dashboard() { cols={12} rowHeight={60} width={1200} + isResizable + isDraggable + resizeHandles={['se']} + compactType="vertical" + margin={[12, 12]} onLayoutChange={onLayoutChange} draggableHandle=".widget h3, .widget .stat-label" > diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx new file mode 100644 index 0000000..d7cdccf --- /dev/null +++ b/frontend/src/pages/Senders.jsx @@ -0,0 +1,69 @@ +import { useEffect, useMemo, useState } from 'react'; +import { AnalyticsApi } from '../api/client.js'; + +// Volume tiers, highest first. +const TIERS = [ + { key: '1000+', label: '1000+ emails', min: 1000, max: Infinity, accent: '#eb5757' }, + { key: '500-1000', label: '500 – 1000', min: 500, max: 999, accent: '#f2994a' }, + { key: '250-500', label: '250 – 500', min: 250, max: 499, accent: '#f2c94c' }, + { key: '100-250', label: '100 – 250', min: 100, max: 249, accent: '#56ccf2' }, + { key: '<100', label: 'Under 100', min: 0, max: 99, accent: '#6fcf97' } +]; + +const PER_TIER = 60; // cap visible blocks per tier to keep it light + +export default function Senders() { + const [senders, setSenders] = useState(null); + + useEffect(() => { AnalyticsApi.allSenders().then(setSenders).catch(() => setSenders([])); }, []); + + const buckets = useMemo(() => { + const map = Object.fromEntries(TIERS.map((t) => [t.key, []])); + (senders || []).forEach((s) => { + const tier = TIERS.find((t) => s.emailCount >= t.min && s.emailCount <= t.max); + if (tier) map[tier.key].push(s); + }); + Object.values(map).forEach((arr) => arr.sort((a, b) => b.emailCount - a.emailCount)); + return map; + }, [senders]); + + if (!senders) return

Senders by Volume

Loading…

; + + return ( +
+

Senders by Volume

+

Who fills your inbox, grouped by how many emails they've sent you.

+ + {TIERS.map((tier) => { + const list = buckets[tier.key]; + if (!list.length) return null; + const shown = list.slice(0, PER_TIER); + return ( +
+
+ +

{tier.label}

+ {list.length} sender{list.length === 1 ? '' : 's'} +
+
+ {shown.map((s) => ( +
+
{s.displayName || s.address}
+
{s.domain}
+
+ {s.emailCount.toLocaleString()} + {s.unreadCount > 0 && {s.unreadCount} unread} + {s.hasUnsubscribe && unsub} +
+
+ ))} +
+ {list.length > PER_TIER &&
+ {list.length - PER_TIER} more
} +
+ ); + })} + + {senders.length === 0 &&

No senders yet — run a sync first.

} +
+ ); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 86b6f5a..d242b2a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -19,7 +19,73 @@ body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans- .spacer { flex: 1; } .user { color: var(--muted); font-size: 13px; } -main { padding: 20px; } +/* ── App body (sidebar + main) ── */ +.app-body { display: flex; min-height: calc(100vh - 53px); } + +.sidebar { + width: 216px; flex-shrink: 0; + background: var(--panel); border-right: 1px solid #2c3550; + display: flex; flex-direction: column; overflow-y: auto; + transition: width 0.2s ease; +} +.sidebar--collapsed { width: 48px; } + +.sidebar-header { + display: flex; align-items: center; justify-content: space-between; + padding: 12px 8px 10px; + border-bottom: 1px solid #2c3550; + min-height: 42px; flex-shrink: 0; +} +.sidebar-brand { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted); padding-left: 6px; } +.sidebar-toggle { padding: 2px 6px; font-size: 17px; line-height: 1; color: var(--muted); flex-shrink: 0; } +.sidebar-toggle:hover { color: var(--text); } + +/* Section headings */ +.section-head { + display: flex; align-items: center; justify-content: space-between; + width: 100%; background: none; border: none; border-bottom: 1px solid #2c3550; + padding: 7px 10px 7px 12px; cursor: pointer; + color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: 0.07em; text-transform: uppercase; +} +.section-head:hover { color: var(--text); } +.section-title { flex: 1; text-align: left; white-space: nowrap; overflow: hidden; } +.section-arrow { font-size: 13px; transform: rotate(90deg); display: inline-block; transition: transform 0.15s; } +.section-arrow--closed { transform: rotate(0deg); } + +/* Folder items */ +.folder-list { list-style: none; margin: 0; padding: 2px 0; } +.folder-item { + display: flex; align-items: center; gap: 8px; + padding: 6px 10px 6px 12px; + color: var(--muted); text-decoration: none; font-size: 13px; + border-left: 3px solid transparent; + white-space: nowrap; overflow: hidden; + transition: background 0.1s, color 0.1s; +} +.folder-item:hover { background: var(--panel-2); color: var(--text); } +.folder-item:hover .fav-pin { opacity: 1; } +.folder-item--active { border-left-color: var(--accent); color: var(--text); background: var(--panel-2); } +.folder-icon { font-size: 14px; flex-shrink: 0; } +.folder-label { flex: 1; overflow: hidden; text-overflow: ellipsis; } +.folder-spacer { flex: 1; } +.folder-badge { + font-size: 10px; font-weight: 600; color: var(--muted); + background: var(--panel-2); border-radius: 8px; padding: 1px 5px; + flex-shrink: 0; +} +.folder-item--active .folder-badge { background: #2c3550; color: var(--accent); } + +/* Favorite pin/unpin button */ +.fav-pin { + background: none; border: none; padding: 0 2px; cursor: pointer; + font-size: 12px; color: var(--muted); opacity: 0; + transition: opacity 0.1s, color 0.1s; flex-shrink: 0; line-height: 1; +} +.fav-pin:hover { color: var(--accent); } +.fav-pin--remove { opacity: 1; color: var(--danger); } +.fav-pin--remove:hover { color: #ff7070; } + +main { padding: 20px; flex: 1; min-width: 0; } button, .btn { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 14px; } button.danger, .btn.danger { background: var(--danger); } @@ -54,6 +120,43 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t .hm-day { width: 30px; font-size: 10px; color: var(--muted); } .hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; } +/* Category heatmap */ +.cat-heatmap { display: flex; flex-direction: column; gap: 3px; overflow: auto; } +.chm-row { display: grid; grid-template-columns: 92px repeat(7, 1fr); gap: 3px; align-items: stretch; } +.chm-head .chm-col { font-size: 10px; color: var(--muted); text-align: center; } +.chm-label { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.chm-cell { background: var(--accent); border-radius: 3px; min-height: 22px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; } + +/* react-grid-layout resize handle — make it clearly visible on the dark theme */ +.react-resizable-handle { + opacity: 0; transition: opacity 0.15s; +} +.grid-item:hover .react-resizable-handle { opacity: 1; } +.react-resizable-handle::after { + content: ''; position: absolute; right: 4px; bottom: 4px; + width: 9px; height: 9px; + border-right: 2px solid var(--accent); border-bottom: 2px solid var(--accent); + border-bottom-right-radius: 2px; +} +.react-grid-item.react-grid-placeholder { background: var(--accent); opacity: 0.25; border-radius: 10px; } + +/* Senders by volume */ +.senders-page { max-width: 1100px; } +.tier { margin-top: 22px; } +.tier-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } +.tier-head h3 { margin: 0; font-size: 16px; } +.tier-dot { width: 12px; height: 12px; border-radius: 50%; } +.tier-count { color: var(--muted); font-size: 12px; } +.sender-blocks { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 10px; } +.sender-block { background: var(--panel); border: 1px solid #2c3550; border-top: 3px solid var(--accent); border-radius: 8px; padding: 10px 12px; } +.sb-name { font-weight: 600; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sb-domain { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 6px; } +.sb-stats { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.sb-count { font-size: 20px; font-weight: 700; } +.sb-unread { font-size: 11px; color: var(--accent); } +.sb-unsub { font-size: 10px; color: var(--muted); border: 1px solid #36405c; border-radius: 4px; padding: 0 4px; } +.tier-more { margin-top: 8px; } + .page { max-width: 900px; } .form-row { display: flex; gap: 10px; margin: 14px 0; } .form-row input { flex: 1; } diff --git a/src/InboxIntel.Api/Controllers/AnalyticsController.cs b/src/InboxIntel.Api/Controllers/AnalyticsController.cs index 4751e88..5487053 100644 --- a/src/InboxIntel.Api/Controllers/AnalyticsController.cs +++ b/src/InboxIntel.Api/Controllers/AnalyticsController.cs @@ -25,6 +25,12 @@ public class AnalyticsController : ApiControllerBase [HttpGet("heatmap")] public async Task Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct)); + [HttpGet("category-heatmap")] + public async Task CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct)); + [HttpGet("attachments")] public async Task Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct)); + + [HttpGet("sidebar-counts")] + public async Task SidebarCounts(CancellationToken ct) => Ok(await _analytics.GetSidebarCountsAsync(UserId, ct)); } diff --git a/src/InboxIntel.Api/Program.cs b/src/InboxIntel.Api/Program.cs index b5efc4b..5f90730 100644 --- a/src/InboxIntel.Api/Program.cs +++ b/src/InboxIntel.Api/Program.cs @@ -71,6 +71,14 @@ builder.Services.AddAuthentication(options => options.SaveTokens = true; foreach (var scope in google.Scopes) options.Scope.Add(scope); options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync; + // Force the consent screen so Google ALWAYS returns a refresh token. + // Without this, Google omits the refresh token on re-authorisation, + // leaving offline Gmail sync with no usable credential. + options.Events.OnRedirectToAuthorizationEndpoint = context => + { + context.Response.Redirect(context.RedirectUri + "&prompt=consent"); + return Task.CompletedTask; + }; }); builder.Services.AddAuthorization(); diff --git a/src/InboxIntel.Api/appsettings.json b/src/InboxIntel.Api/appsettings.json index c189c77..da36c5c 100644 --- a/src/InboxIntel.Api/appsettings.json +++ b/src/InboxIntel.Api/appsettings.json @@ -23,8 +23,8 @@ "DevMode": false }, "GmailSync": { - "PageSize": 100, - "MaxParallelism": 4, + "PageSize": 500, + "MaxParallelism": 8, "MaxRetries": 5, "BackoffBaseMs": 500, "DailySyncHourUtc": 3, diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs index 4538e44..86db84f 100644 --- a/src/InboxIntel.Application/Abstractions/IServices.cs +++ b/src/InboxIntel.Application/Abstractions/IServices.cs @@ -23,7 +23,9 @@ public interface IAnalyticsService Task> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default); Task> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default); Task> GetHeatmapAsync(Guid userId, CancellationToken ct = default); + Task> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default); Task> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default); + Task GetSidebarCountsAsync(Guid userId, CancellationToken ct = default); Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default); } diff --git a/src/InboxIntel.Application/DTOs/AnalyticsDtos.cs b/src/InboxIntel.Application/DTOs/AnalyticsDtos.cs index 3ddbfe6..da08f1c 100644 --- a/src/InboxIntel.Application/DTOs/AnalyticsDtos.cs +++ b/src/InboxIntel.Application/DTOs/AnalyticsDtos.cs @@ -14,6 +14,23 @@ public record TimeSeriesPointDto(DateOnly Day, int Count); public record HeatmapCellDto(int DayOfWeek, int Hour, int Count); +/// Email counts per category per day-of-week, for the category heatmap. +public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count); + +/// Counts for the sidebar: mailbox labels, special filters, and smart folders. +public record SidebarCountsDto( + int Inbox, + int AllMail, + int Unread, + int Starred, + int Sent, + int Drafts, + int Trash, + int Spam, + int Large, + int Old, + IReadOnlyDictionary SmartFolders); + public record AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count); public record DashboardSummaryDto( diff --git a/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs b/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs index f58fe02..017e6cb 100644 --- a/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs +++ b/src/InboxIntel.Infrastructure/Analytics/AnalyticsService.cs @@ -88,6 +88,19 @@ public class AnalyticsService : IAnalyticsService .ToList(); } + public async Task> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default) + { + var raw = await _db.Emails + .Where(e => e.UserId == userId) + .Select(e => new { e.SentAtUtc, e.Category }) + .ToListAsync(ct); + + return raw + .GroupBy(x => new { x.Category, Dow = (int)x.SentAtUtc.DayOfWeek }) + .Select(g => new CategoryHeatmapCellDto(g.Key.Category.ToString(), g.Key.Dow, g.Count())) + .ToList(); + } + public async Task> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default) { var raw = await _db.Attachments @@ -115,6 +128,60 @@ public class AnalyticsService : IAnalyticsService _ => "other" }; + public async Task GetSidebarCountsAsync(Guid userId, CancellationToken ct = default) + { + var emails = _db.Emails.Where(e => e.UserId == userId); + + var allMail = await emails.CountAsync(ct); + var inbox = await emails.CountAsync(e => e.IsInInbox, ct); + var unread = await emails.CountAsync(e => e.IsUnread, ct); + var starred = await emails.CountAsync(e => e.IsStarred, ct); + var trash = await emails.CountAsync(e => e.IsTrashed, ct); + var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct); + var cutoff = DateTimeOffset.UtcNow.AddYears(-1); + var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct); + + // Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels) + async Task LabelCount(string gmailId) + { + var labelId = await _db.Labels + .Where(l => l.UserId == userId && l.GmailLabelId == gmailId) + .Select(l => (Guid?)l.Id) + .FirstOrDefaultAsync(ct); + return labelId.HasValue + ? await _db.EmailLabels.CountAsync(el => el.LabelId == labelId.Value, ct) + : 0; + } + + var sent = await LabelCount("SENT"); + var drafts = await LabelCount("DRAFT"); + var spam = await LabelCount("SPAM"); + + // Category → smart-folder slug mapping + var catCounts = await emails + .GroupBy(e => e.Category) + .Select(g => new { Cat = g.Key, N = g.Count() }) + .ToListAsync(ct); + + int Cat(EmailCategory c) => catCounts.FirstOrDefault(x => x.Cat == c)?.N ?? 0; + + var smartFolders = new Dictionary + { + ["automated"] = Cat(EmailCategory.Notification), + ["finance"] = Cat(EmailCategory.Finance), + ["social"] = Cat(EmailCategory.Social), + ["shopping"] = Cat(EmailCategory.Promotional), + ["noreply"] = 0, + ["gaming"] = 0, + ["sales"] = 0, + ["ridesharing"] = 0, + ["food"] = 0, + ["wellness"] = 0, + }; + + return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders); + } + public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default) { var since = DateTimeOffset.UtcNow.AddDays(-365); diff --git a/src/InboxIntel.Infrastructure/Configuration/Options.cs b/src/InboxIntel.Infrastructure/Configuration/Options.cs index 808eac8..dd7b430 100644 --- a/src/InboxIntel.Infrastructure/Configuration/Options.cs +++ b/src/InboxIntel.Infrastructure/Configuration/Options.cs @@ -19,8 +19,10 @@ public class GoogleOAuthOptions public class GmailSyncOptions { public const string SectionName = "GmailSync"; - public int PageSize { get; set; } = 100; - public int MaxParallelism { get; set; } = 4; + /// Message-id list page size (Gmail max is 500). + public int PageSize { get; set; } = 500; + /// Concurrent Gmail message fetches during sync (kept well under quota). + public int MaxParallelism { get; set; } = 8; public int MaxRetries { get; set; } = 5; /// Base delay in ms for exponential backoff. public int BackoffBaseMs { get; set; } = 500; diff --git a/src/InboxIntel.Infrastructure/Gmail/GmailService.cs b/src/InboxIntel.Infrastructure/Gmail/GmailService.cs index 378b740..0aa0b50 100644 --- a/src/InboxIntel.Infrastructure/Gmail/GmailService.cs +++ b/src/InboxIntel.Infrastructure/Gmail/GmailService.cs @@ -24,6 +24,12 @@ public class GmailApiService : IGmailService private readonly ILogger _logger; private readonly ResiliencePipeline _pipeline; + // Cache the authenticated client per user so parallel message fetches don't + // each rebuild it (which would hit the shared DbContext concurrently). + private readonly SemaphoreSlim _clientLock = new(1, 1); + private Google.Apis.Gmail.v1.GmailService? _cachedClient; + private Guid _cachedUserId; + public GmailApiService(GmailClientFactory factory, IOptions options, ILogger logger) { _factory = factory; @@ -56,9 +62,26 @@ public class GmailApiService : IGmailService or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout; + /// Builds the Gmail client once per user and reuses it (thread-safe). + private async Task GetClientAsync(Guid userId, CancellationToken ct) + { + if (_cachedClient is not null && _cachedUserId == userId) return _cachedClient; + await _clientLock.WaitAsync(ct); + try + { + if (_cachedClient is null || _cachedUserId != userId) + { + _cachedClient = await _factory.CreateAsync(userId, ct); + _cachedUserId = userId; + } + return _cachedClient; + } + finally { _clientLock.Release(); } + } + public async Task GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var profile = await _pipeline.ExecuteAsync(async token => await client.Users.GetProfile("me").ExecuteAsync(token), ct); return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId."); @@ -66,7 +89,7 @@ public class GmailApiService : IGmailService public async Task ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var page = await _pipeline.ExecuteAsync(async token => { var req = client.Users.Messages.List("me"); @@ -82,7 +105,7 @@ public class GmailApiService : IGmailService public async Task GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var msg = await _pipeline.ExecuteAsync(async token => { var req = client.Users.Messages.Get("me", gmailMessageId); @@ -96,7 +119,7 @@ public class GmailApiService : IGmailService public async Task ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var history = await _pipeline.ExecuteAsync(async token => { var req = client.Users.History.List("me"); @@ -117,7 +140,7 @@ public class GmailApiService : IGmailService public async Task> ListLabelsAsync(Guid userId, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var resp = await _pipeline.ExecuteAsync(async token => await client.Users.Labels.List("me").ExecuteAsync(token), ct); @@ -132,7 +155,7 @@ public class GmailApiService : IGmailService public async Task BatchModifyAsync(Guid userId, IEnumerable messageIds, IEnumerable addLabelIds, IEnumerable removeLabelIds, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var body = new BatchModifyMessagesRequest { Ids = messageIds.ToList(), @@ -148,7 +171,7 @@ public class GmailApiService : IGmailService public async Task BatchTrashAsync(Guid userId, IEnumerable messageIds, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); foreach (var id in messageIds) { await _pipeline.ExecuteAsync(async token => @@ -161,7 +184,7 @@ public class GmailApiService : IGmailService public async Task BatchDeleteAsync(Guid userId, IEnumerable messageIds, CancellationToken ct = default) { - var client = await _factory.CreateAsync(userId, ct); + var client = await GetClientAsync(userId, ct); var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() }; await _pipeline.ExecuteAsync(async token => { diff --git a/src/InboxIntel.Infrastructure/Sync/SyncService.cs b/src/InboxIntel.Infrastructure/Sync/SyncService.cs index ed2f549..0e001ad 100644 --- a/src/InboxIntel.Infrastructure/Sync/SyncService.cs +++ b/src/InboxIntel.Infrastructure/Sync/SyncService.cs @@ -54,9 +54,11 @@ public class SyncService : ISyncService public async Task GetProgressAsync(Guid userId, CancellationToken ct = default) { var s = await GetOrCreateStateAsync(userId, ct); - var total = _options.MaxMessages > 0 - ? Math.Min(s.TotalMessagesEstimate == 0 ? _options.MaxMessages : s.TotalMessagesEstimate, _options.MaxMessages) - : s.TotalMessagesEstimate; + // Total is unknown (0 -> indeterminate bar) until Gmail returns the real + // count; then it's the mailbox total, capped by MaxMessages in dev. + var total = s.TotalMessagesEstimate; + if (_options.MaxMessages > 0 && total > 0) + total = Math.Min(total, _options.MaxMessages); return new SyncProgressDto( s.Status.ToString(), s.MessagesProcessed, total, s.Status == SyncStatus.Running, s.LastSuccessfulSyncUtc, s.LastError); @@ -77,34 +79,48 @@ public class SyncService : ISyncService // Dev cap: stop after MaxMessages (most recent first). 0 = unlimited. var maxMessages = _options.MaxMessages; - var capReached = false; - string? pageToken = state.ResumePageToken; // resume support + // Phase 1 — enumerate message ids (cheap, ids only) to get an ACCURATE + // total. Gmail's resultSizeEstimate is unreliable, so we count instead. + var ids = new List(); + string? listToken = null; do { - var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct); - foreach (var messageId in page.MessageIds) + var page = await _gmail.ListMessageIdsAsync(userId, listToken, ct); + foreach (var id in page.MessageIds) + { + ids.Add(id); + if (maxMessages > 0 && ids.Count >= maxMessages) break; + } + listToken = page.NextPageToken; + } + while (listToken is not null && (maxMessages == 0 || ids.Count < maxMessages) && !ct.IsCancellationRequested); + + state.TotalMessagesEstimate = ids.Count; // the true target (capped in dev) + await _db.SaveChangesAsync(ct); + + // Phase 2 — fetch bodies in chunks, in parallel, skipping ones we already + // have. The DbContext is not thread-safe, so upserts run sequentially. + const int chunkSize = 100; + for (var i = 0; i < ids.Count && !ct.IsCancellationRequested; i += chunkSize) + { + var chunk = ids.GetRange(i, Math.Min(chunkSize, ids.Count - i)); + var have = (await _db.Emails + .Where(e => e.UserId == userId && chunk.Contains(e.GmailMessageId)) + .Select(e => e.GmailMessageId).ToListAsync(ct)).ToHashSet(); + state.MessagesProcessed += have.Count; // already-synced count toward progress + + var toFetch = chunk.Where(id => !have.Contains(id)).ToList(); + var details = await FetchDetailsParallelAsync(userId, toFetch, ct); + foreach (var detail in details) { - if (maxMessages > 0 && state.MessagesProcessed >= maxMessages) - { - capReached = true; - break; - } - if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct)) - continue; - var detail = await _gmail.GetMessageAsync(userId, messageId, ct); await UpsertMessageAsync(userId, detail, ct); state.MessagesProcessed++; + if (state.MessagesProcessed % 25 == 0) + await _db.SaveChangesAsync(ct); // smooth progress for the splash } - - pageToken = page.NextPageToken; - state.ResumePageToken = pageToken; // checkpoint - state.TotalMessagesEstimate = maxMessages > 0 - ? Math.Min(page.ResultSizeEstimate, maxMessages) - : page.ResultSizeEstimate; await _db.SaveChangesAsync(ct); } - while (pageToken is not null && !capReached && !ct.IsCancellationRequested); state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct); state.ResumePageToken = null; @@ -175,9 +191,14 @@ public class SyncService : ISyncService private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct) { _logger.LogError(ex, "Sync failed for user {UserId}", state.UserId); - state.Status = SyncStatus.Failed; - state.ConsecutiveFailures++; - state.LastError = ex.Message; + // Discard the failed batch's pending changes; otherwise saving the + // failure status re-attempts the same bad inserts and throws again. + _db.ChangeTracker.Clear(); + var fresh = await _db.SyncStates.FirstOrDefaultAsync(s => s.UserId == state.UserId, ct); + if (fresh is null) return; + fresh.Status = SyncStatus.Failed; + fresh.ConsecutiveFailures++; + fresh.LastError = ex.Message; await _db.SaveChangesAsync(ct); } @@ -217,9 +238,9 @@ public class SyncService : ISyncService GmailMessageId = d.GmailMessageId, ThreadId = thread.Id, SenderId = sender.Id, - Subject = d.Subject, - Snippet = d.Snippet, - BodyText = d.BodyText, + Subject = Trunc(d.Subject, 1024), + Snippet = Trunc(d.Snippet, 2048), + BodyText = d.BodyText, // unlimited (text column) SentAtUtc = d.SentAtUtc, ReceivedAtUtc = d.SentAtUtc, SizeEstimateBytes = d.SizeEstimateBytes, @@ -229,7 +250,7 @@ public class SyncService : ISyncService IsImportant = d.LabelIds.Contains("IMPORTANT"), HasAttachments = d.HasAttachments, HasListUnsubscribe = d.HasListUnsubscribe, - ListUnsubscribeRaw = d.ListUnsubscribeRaw, + ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048), SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe, Category = HeuristicClassifier.Classify(d) }; @@ -239,8 +260,8 @@ public class SyncService : ISyncService { _db.Attachments.Add(new Attachment { - UserId = userId, EmailId = email.Id, FileName = fileName, - MimeType = mime, SizeBytes = size, GmailAttachmentId = attId + UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty, + MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId }); } @@ -255,13 +276,37 @@ public class SyncService : ISyncService thread.LastMessageUtc = d.SentAtUtc; } + /// + /// Fetches message bodies concurrently (bounded by MaxParallelism) to speed + /// up the sync. Results only — the caller persists them sequentially. + /// + private async Task> FetchDetailsParallelAsync(Guid userId, List ids, CancellationToken ct) + { + if (ids.Count == 0) return new List(); + using var sem = new SemaphoreSlim(Math.Max(1, _options.MaxParallelism)); + var tasks = ids.Select(async id => + { + await sem.WaitAsync(ct); + try { return await _gmail.GetMessageAsync(userId, id, ct); } + finally { sem.Release(); } + }); + var results = await Task.WhenAll(tasks); + return results.ToList(); + } + private async Task ResolveSenderAsync(Guid userId, string address, string? displayName, CancellationToken ct) { - var sender = await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct); + // Check the in-memory tracker first so senders/domains added earlier in + // this batch (not yet saved) are reused instead of duplicated. + var sender = _db.Senders.Local.FirstOrDefault(s => s.UserId == userId && s.Address == address) + ?? await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct); if (sender is not null) return sender; - var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown"; - var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct); + address = Trunc(address, 320)!; + var rawDomain = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown"; + var domainName = Trunc(rawDomain, 255)!; + var domain = _db.Domains.Local.FirstOrDefault(x => x.UserId == userId && x.Name == domainName) + ?? await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct); if (domain is null) { domain = new MailDomain { UserId = userId, Name = domainName }; @@ -269,19 +314,24 @@ public class SyncService : ISyncService } domain.EmailCount++; - sender = new Sender { UserId = userId, Address = address, DisplayName = displayName, Domain = domain, DomainId = domain.Id }; + sender = new Sender { UserId = userId, Address = address, DisplayName = Trunc(displayName, 255), Domain = domain, DomainId = domain.Id }; _db.Senders.Add(sender); return sender; } + /// Truncates a string to a column's max length so no email can overflow it. + private static string? Trunc(string? value, int max) + => value is null ? null : value.Length <= max ? value : value[..max]; + private async Task ResolveThreadAsync(Guid userId, string gmailThreadId, string? subject, string? snippet, DateTimeOffset sentAt, CancellationToken ct) { - var thread = await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct); + var thread = _db.Threads.Local.FirstOrDefault(t => t.UserId == userId && t.GmailThreadId == gmailThreadId) + ?? await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct); if (thread is not null) return thread; thread = new MailThread { - UserId = userId, GmailThreadId = gmailThreadId, Subject = subject, - Snippet = snippet, FirstMessageUtc = sentAt + UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024), + Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt }; _db.Threads.Add(thread); return thread;