From e334c2adfd43a885e897d0390dd5533d9088e92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 16:19:33 +0200 Subject: [PATCH 01/27] =?UTF-8?q?=E2=9E=95=20Install=20vitest=20and=20add?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 5 +- pnpm-lock.yaml | 250 ++++++++++++++++++ test/composables/statsAggregation.spec.js | 207 +++++++++++++++ test/composables/useActivityChartData.spec.js | 39 +++ test/composables/useLeaderChartData.spec.js | 39 +++ test/composables/useTagsChartData.spec.js | 25 ++ test/composables/useTotalChartData.spec.js | 64 +++++ test/utils.spec.js | 108 ++++++++ vitest.config.js | 13 + 9 files changed, 749 insertions(+), 1 deletion(-) create mode 100644 test/composables/statsAggregation.spec.js create mode 100644 test/composables/useActivityChartData.spec.js create mode 100644 test/composables/useLeaderChartData.spec.js create mode 100644 test/composables/useTagsChartData.spec.js create mode 100644 test/composables/useTotalChartData.spec.js create mode 100644 test/utils.spec.js create mode 100644 vitest.config.js diff --git a/package.json b/package.json index bc617a2..22ae9d0 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "build": "vite build", "serve": "vite preview", "lint": "oxlint", + "test": "vitest run", + "test:watch": "vitest", "getlang": "localazy download", "setlang": "localazy upload existing" }, @@ -22,7 +24,8 @@ "@localazy/cli": "^2.0.5", "@vitejs/plugin-vue": "^6.0.0", "oxlint": "^1.9.0", - "vite": "^8.0.16" + "vite": "^8.0.16", + "vitest": "^4.1.10" }, "browserslist": [ "Firefox ESR" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index baafe0c..4e76cb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: vite: specifier: ^8.0.16 version: 8.1.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(vite@8.1.3) packages: @@ -330,9 +333,21 @@ packages: Deprecated: no longer maintained and no longer used by Sinon packages. See https://github.com/sinonjs/nise/issues/243 for replacement details. + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@vitejs/plugin-vue@6.0.7': resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -340,6 +355,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vue/compiler-core@3.5.39': resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} @@ -372,6 +416,14 @@ packages: '@vue/shared@3.5.39': resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chart.js@4.5.1: resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} engines: {pnpm: '>=8'} @@ -387,6 +439,9 @@ packages: peerDependencies: chart.js: '>=3.0.0' + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -401,9 +456,19 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -500,6 +565,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + oxlint@1.72.0: resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -513,6 +582,9 @@ packages: vite-plus: optional: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -529,14 +601,34 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -583,6 +675,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vue-i18n@11.4.6: resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==} engines: {node: '>= 22'} @@ -597,6 +730,11 @@ packages: typescript: optional: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + ws@8.18.0: resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} engines: {node: '>=10.0.0'} @@ -789,17 +927,69 @@ snapshots: '@sinonjs/text-encoding@0.7.2': {} + '@standard-schema/spec@1.1.0': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@vitejs/plugin-vue@6.0.7(vite@8.1.3)(vue@3.5.39)': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3 vue: 3.5.39 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.3)': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3 + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vue/compiler-core@3.5.39': dependencies: '@babel/parser': 7.29.7 @@ -856,6 +1046,10 @@ snapshots: '@vue/shared@3.5.39': {} + assertion-error@2.0.1: {} + + chai@6.2.2: {} + chart.js@4.5.1: dependencies: '@kurkle/color': 0.3.4 @@ -869,6 +1063,8 @@ snapshots: dependencies: chart.js: 4.5.1 + convert-source-map@2.0.0: {} + csstype@3.2.3: {} date-fns@4.4.0: {} @@ -877,8 +1073,16 @@ snapshots: entities@7.0.1: {} + es-module-lexer@2.3.1: {} + estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -941,6 +1145,8 @@ snapshots: nanoid@3.3.15: {} + obug@2.1.4: {} + oxlint@1.72.0: optionalDependencies: '@oxlint/binding-android-arm-eabi': 1.72.0 @@ -963,6 +1169,8 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.72.0 '@oxlint/binding-win32-x64-msvc': 1.72.0 + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -994,13 +1202,25 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.4 '@rolldown/binding-win32-x64-msvc': 1.1.4 + siginfo@2.0.0: {} + source-map-js@1.2.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + tslib@2.8.1: optional: true @@ -1014,6 +1234,31 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + vitest@4.1.10(vite@8.1.3): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.3) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3 + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + vue-i18n@11.4.6(vue@3.5.39): dependencies: '@intlify/core-base': 11.4.6 @@ -1030,4 +1275,9 @@ snapshots: '@vue/server-renderer': 3.5.39(vue@3.5.39) '@vue/shared': 3.5.39 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + ws@8.18.0: {} diff --git a/test/composables/statsAggregation.spec.js b/test/composables/statsAggregation.spec.js new file mode 100644 index 0000000..467b07b --- /dev/null +++ b/test/composables/statsAggregation.spec.js @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; +import { + analyzeMessage, + buildComparisonData, + createComparisonData, + createStatsData, + sumAccountsData, +} from '@/composables/statsAggregation.js'; + +const baseMessage = (overrides = {}) => ({ + author: 'bob@example.com', + recipients: ['me@example.com'], + ccList: [], + bccList: [], + date: new Date(2023, 5, 15, 10, 30), + read: true, + junk: false, + junkScore: 0, + flagged: false, + tags: [], + folder: { name: 'Inbox' }, + ...overrides, +}); + +const noopContext = { activeContact: null, selfMessagesMode: 'none', allIdentities: [] }; + +describe('analyzeMessage', () => { + it('classifies a message from a non-identity author as received', () => { + const data = createStatsData(); + const type = analyzeMessage(data, baseMessage(), ['me@example.com'], noopContext); + expect(type).toBe('received'); + expect(data.numbers.received).toBe(1); + expect(data.numbers.sent).toBe(0); + expect(data.contacts.received['bob@example.com']).toBe(1); + }); + + it('classifies a message from an identity author as sent', () => { + const data = createStatsData(); + const message = baseMessage({ author: 'me@example.com', recipients: ['alice@example.com'] }); + const type = analyzeMessage(data, message, ['me@example.com'], noopContext); + expect(type).toBe('sent'); + expect(data.numbers.sent).toBe(1); + expect(data.contacts.sent['alice@example.com']).toBe(1); + }); + + it('skips a message that does not involve the active contact filter', () => { + const data = createStatsData(); + const context = { ...noopContext, activeContact: 'someone-else@example.com' }; + const type = analyzeMessage(data, baseMessage(), ['me@example.com'], context); + expect(type).toBeUndefined(); + expect(data.numbers.total).toBe(0); + }); + + it('processes a message that does involve the active contact filter', () => { + const data = createStatsData(); + const context = { ...noopContext, activeContact: 'bob@example.com' }; + const type = analyzeMessage(data, baseMessage(), ['me@example.com'], context); + expect(type).toBe('received'); + expect(data.numbers.total).toBe(1); + }); + + it('excludes a self message relative to the account identity list when selfMessagesMode is "sameAccount"', () => { + const data = createStatsData(); + const message = baseMessage({ author: 'me@example.com', recipients: ['me@example.com'] }); + const context = { activeContact: null, selfMessagesMode: 'sameAccount', allIdentities: ['someone-else@example.com'] }; + const type = analyzeMessage(data, message, ['me@example.com'], context); + expect(type).toBeUndefined(); + expect(data.numbers.total).toBe(0); + }); + + it('excludes a self message relative to allIdentities when selfMessagesMode is not "sameAccount"', () => { + const data = createStatsData(); + const message = baseMessage({ author: 'me@example.com', recipients: ['other-account@example.com'] }); + const context = { activeContact: null, selfMessagesMode: 'all', allIdentities: ['me@example.com', 'other-account@example.com'] }; + const type = analyzeMessage(data, message, ['me@example.com'], context); + expect(type).toBeUndefined(); + }); + + it('does not exclude self messages when selfMessagesMode is "none"', () => { + const data = createStatsData(); + const message = baseMessage({ author: 'me@example.com', recipients: ['me@example.com'] }); + const type = analyzeMessage(data, message, ['me@example.com'], noopContext); + expect(type).toBe('sent'); + }); + + it('counts unread, junk, starred and tagged messages', () => { + const data = createStatsData(); + const message = baseMessage({ read: false, junk: true, junkScore: 80, flagged: true, tags: ['important'] }); + analyzeMessage(data, message, ['me@example.com'], noopContext); + expect(data.numbers.unread).toBe(1); + expect(data.numbers.junk).toBe(1); + expect(data.numbers.junkScore).toBe(80); + expect(data.numbers.starred).toBe(1); + expect(data.numbers.tagged).toBe(1); + expect(data.tags.important).toBe(1); + expect(data.contacts.junk['bob@example.com']).toBe(1); + }); + + it('counts folders per message', () => { + const data = createStatsData(); + analyzeMessage(data, baseMessage({ folder: { name: 'Archive' } }), ['me@example.com'], noopContext); + expect(data.folders.received.Archive).toBe(1); + }); + + it('buckets a Jan-1 date with ISO week 53 into the previous year (week-boundary edge case)', () => { + const data = createStatsData(); + // Jan 1, 2010 has ISO week number 53 and belongs to week 53 of 2009 + analyzeMessage(data, baseMessage({ date: new Date(2010, 0, 1) }), ['me@example.com'], noopContext); + expect(data.weeksData.received[2009]).toEqual({ 53: 1 }); + }); + + it('assigns quarters correctly across the Mar/Apr boundary', () => { + const data = createStatsData(); + analyzeMessage(data, baseMessage({ date: new Date(2023, 2, 31) }), ['me@example.com'], noopContext); + analyzeMessage(data, baseMessage({ date: new Date(2023, 3, 1) }), ['me@example.com'], noopContext); + expect(data.quartersData.received[2023]).toEqual({ 1: 1, 2: 1 }); + }); + + it('tracks the earliest message date across repeated calls', () => { + const data = createStatsData(); + analyzeMessage(data, baseMessage({ date: new Date(2023, 5, 15) }), ['me@example.com'], noopContext); + analyzeMessage(data, baseMessage({ date: new Date(2020, 0, 1) }), ['me@example.com'], noopContext); + expect(data.meta.start.getTime()).toBe(new Date(2020, 0, 1).getTime()); + }); +}); + +describe('sumAccountsData', () => { + const accountA = () => ({ + ...createStatsData(), + numbers: { total: 10, unread: 2, received: 6, sent: 4, starred: 1, tagged: 2, junk: 1, junkScore: 20 }, + meta: { timestamp: 1000, start: 100, end: 200 }, + yearsData: { received: { 2023: 5 }, sent: { 2023: 3 } }, + contacts: { received: { alice: 3 }, sent: {}, junk: {} }, + tags: { work: 2 }, + folders: { received: { Inbox: 5 }, sent: {} }, + }); + const accountB = () => ({ + ...createStatsData(), + numbers: { total: 5, unread: 1, received: 3, sent: 2, starred: 0, tagged: 0, junk: 0, junkScore: 10 }, + meta: { timestamp: 2000, start: 50, end: 300 }, + yearsData: { received: { 2024: 2 }, sent: {} }, + contacts: { received: { bob: 1 }, sent: {}, junk: {} }, + tags: {}, + folders: { received: { Inbox: 1 }, sent: {} }, + }); + + it('sums numeric counters across accounts', () => { + const sum = sumAccountsData([accountA(), accountB()], 20); + expect(sum.numbers.total).toBe(15); + expect(sum.numbers.received).toBe(9); + expect(sum.numbers.sent).toBe(6); + expect(sum.numbers.starred).toBe(1); + }); + + it('averages junkScore rather than summing it', () => { + const sum = sumAccountsData([accountA(), accountB()], 20); + expect(sum.numbers.junkScore).toBe((20 + 10) / 2); + }); + + it('takes the minimum of per-account timestamps', () => { + const sum = sumAccountsData([accountA(), accountB()], 20); + expect(sum.meta.timestamp).toBe(1000); + }); + + it('unions keys across accounts with disjoint years', () => { + const sum = sumAccountsData([accountA(), accountB()], 20); + expect(sum.yearsData.received).toEqual({ 2023: 5, 2024: 2 }); + expect(sum.yearsData.sent).toEqual({ 2023: 3 }); + }); + + it('merges, sorts and limits contacts/tags/folders', () => { + const sum = sumAccountsData([accountA(), accountB()], 20); + expect(sum.contacts.received).toEqual({ alice: 3, bob: 1 }); + expect(sum.tags).toEqual({ work: 2 }); + expect(sum.folders.received).toEqual({ Inbox: 6 }); + }); + + it('does not throw for a single-account array', () => { + expect(() => sumAccountsData([accountA()], 20)).not.toThrow(); + }); +}); + +describe('buildComparisonData', () => { + it('creates one entry per active account, merging received and sent', () => { + const accountsData = [ + { + ...createStatsData(), + yearsData: { received: { 2023: 5 }, sent: { 2023: 2 } }, + quartersData: { received: { 2023: { 1: 5 } }, sent: { 2023: { 1: 2 } } }, + monthsData: { received: { 2023: { 5: 5 } }, sent: { 2023: { 5: 2 } } }, + weeksData: { received: { 2023: { 20: 5 } }, sent: { 2023: { 20: 2 } } }, + daytimeData: { received: { 10: 5 }, sent: { 10: 2 } }, + weekdayData: { received: { 1: 5 }, sent: { 1: 2 } }, + monthData: { received: { 5: 5 }, sent: { 5: 2 } }, + }, + ]; + const activeAccounts = [{ id: 'acct1' }]; + const result = buildComparisonData(accountsData, activeAccounts); + expect(result.yearsData.acct1).toEqual({ 2023: 7 }); + expect(result.quartersData.acct1).toEqual({ 2023: { 1: 7 } }); + expect(result.daytimeData.acct1).toEqual({ 10: 7 }); + }); + + it('starts from the createComparisonData shape for an empty account list', () => { + expect(buildComparisonData([], [])).toEqual(createComparisonData()); + }); +}); diff --git a/test/composables/useActivityChartData.spec.js b/test/composables/useActivityChartData.spec.js new file mode 100644 index 0000000..b03d146 --- /dev/null +++ b/test/composables/useActivityChartData.spec.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { reactive, ref } from 'vue'; +import { useActivityChartData } from '@/composables/useActivityChartData.js'; + +const stubT = (key) => key; + +describe('useActivityChartData', () => { + it('zero-fills every day of the selected year, even with no messages', () => { + const display = ref({ dateData: { received: {}, sent: {} } }); + const activityPrefs = reactive({ year: 2023 }); + const { dateChartData } = useActivityChartData({ display, activityPrefs, t: stubT }); + // 2023 is not a leap year -> 365 days + expect(dateChartData.value.received.data.length).toBe(365); + expect(dateChartData.value.sent.data.length).toBe(365); + expect(dateChartData.value.received.data.every(([, count]) => count === 0)).toBe(true); + }); + + it('excludes data belonging to a different year from the received counts', () => { + const display = ref({ + dateData: { + received: { '2022-12-31': 9 }, + sent: {}, + }, + }); + const activityPrefs = reactive({ year: 2023 }); + const { dateChartData } = useActivityChartData({ display, activityPrefs, t: stubT }); + // the 2022 entry must not leak into any day of the 2023 series + expect(dateChartData.value.received.data.every(([, count]) => count !== 9)).toBe(true); + }); + + it('reacts to a change in the selected year', () => { + const display = ref({ dateData: { received: { '2024-01-01': 7 }, sent: {} } }); + const activityPrefs = reactive({ year: 2023 }); + const { dateChartData } = useActivityChartData({ display, activityPrefs, t: stubT }); + expect(dateChartData.value.received.data.some(([key]) => key === '2024-01-01')).toBe(false); + activityPrefs.year = 2024; + expect(dateChartData.value.received.data.find(([key]) => key === '2024-01-01')[1]).toBe(7); + }); +}); diff --git a/test/composables/useLeaderChartData.spec.js b/test/composables/useLeaderChartData.spec.js new file mode 100644 index 0000000..86e43f9 --- /dev/null +++ b/test/composables/useLeaderChartData.spec.js @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useLeaderChartData } from '@/composables/useLeaderChartData.js'; + +const stubT = (key) => key; + +describe('useLeaderChartData', () => { + it('the *Exists guards are false when the underlying contact data is empty', () => { + const display = ref({ contacts: { received: {}, sent: {}, junk: {} } }); + const options = { dark: false }; + const { + sentContactLeadersChartDataExists, + receivedContactLeadersChartDataExists, + junkContactLeadersChartDataExists, + } = useLeaderChartData({ display, options, t: stubT }); + expect(sentContactLeadersChartDataExists.value).toBe(false); + expect(receivedContactLeadersChartDataExists.value).toBe(false); + expect(junkContactLeadersChartDataExists.value).toBe(false); + }); + + it('the *Exists guards are true when there is contact data', () => { + const display = ref({ + contacts: { + received: { 'alice@example.com': 3 }, + sent: { 'bob@example.com': 1 }, + junk: { 'spam@example.com': 5 }, + }, + }); + const options = { dark: false }; + const { + sentContactLeadersChartDataExists, + receivedContactLeadersChartDataExists, + junkContactLeadersChartDataExists, + } = useLeaderChartData({ display, options, t: stubT }); + expect(sentContactLeadersChartDataExists.value).toBe(true); + expect(receivedContactLeadersChartDataExists.value).toBe(true); + expect(junkContactLeadersChartDataExists.value).toBe(true); + }); +}); diff --git a/test/composables/useTagsChartData.spec.js b/test/composables/useTagsChartData.spec.js new file mode 100644 index 0000000..6f7a5ab --- /dev/null +++ b/test/composables/useTagsChartData.spec.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { ref } from 'vue'; +import { useTagsChartData } from '@/composables/useTagsChartData.js'; + +const stubT = (key) => key; + +describe('useTagsChartData', () => { + it('tagsChartDataExists is false when there are no tags', () => { + const display = ref({ tags: {} }); + const tags = ref([]); + const options = { dark: false, tagColors: false }; + const { tagsChartDataExists } = useTagsChartData({ display, tags, options, t: stubT }); + expect(tagsChartDataExists.value).toBe(false); + }); + + it('tagsChartDataExists is true and resolves tag names when there is tag data', () => { + const display = ref({ tags: { work: 5, personal: 2 } }); + const tags = ref([{ key: 'work', tag: 'Work', color: '#ff0000' }]); + const options = { dark: false, tagColors: false }; + const { tagsChartData, tagsChartDataExists } = useTagsChartData({ display, tags, options, t: stubT }); + expect(tagsChartDataExists.value).toBe(true); + expect(tagsChartData.value.labels).toContain('Work'); + expect(tagsChartData.value.labels).toContain('undefined'); + }); +}); diff --git a/test/composables/useTotalChartData.spec.js b/test/composables/useTotalChartData.spec.js new file mode 100644 index 0000000..57ccf2f --- /dev/null +++ b/test/composables/useTotalChartData.spec.js @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { computed, ref } from 'vue'; +import { useTotalChartData } from '@/composables/useTotalChartData.js'; + +const stubT = (key, args) => (args !== undefined ? `${key}:${args}` : key); + +describe('useTotalChartData', () => { + const buildContext = (overrides = {}) => { + const display = ref({ + yearsData: { received: { 2021: 5, 2023: 2 }, sent: { 2022: 1 } }, + quartersData: { + received: { 2023: { 1: 3, 2: 4 } }, + sent: { 2023: { 1: 1 } }, + }, + monthsData: { received: {}, sent: {} }, + weeksData: { received: {}, sent: {} }, + }); + const comparison = ref({ yearsData: {}, quartersData: {}, monthsData: {}, weeksData: {} }); + const accounts = ref([]); + const options = { accountColors: {} }; + const minYear = ref(2021); + const maxYear = ref(2023); + const minDate = computed(() => new Date(2023, 0, 15)); + const maxDate = computed(() => new Date(2023, 3, 15)); + const locale = ref('en'); + return { + display, comparison, accounts, options, minYear, maxYear, minDate, maxDate, + t: stubT, locale, + ...overrides, + }; + }; + + it('fills gaps between minYear and maxYear with 0, not just years that have data', () => { + const ctx = buildContext(); + const { yearsChartData } = useTotalChartData(ctx); + expect(yearsChartData.value.labels).toEqual([2021, 2022, 2023]); + // datasets[0] = sent, datasets[1] = received (per source order) + expect(yearsChartData.value.datasets[0].data).toEqual([0, 1, 0]); + expect(yearsChartData.value.datasets[1].data).toEqual([5, 0, 2]); + }); + + it('trims quarters to the range within minDate/maxDate for the first/last year', () => { + const ctx = buildContext(); + ctx.minYear.value = 2023; + ctx.maxYear.value = 2023; + const { quartersChartData } = useTotalChartData(ctx); + // minDate is Jan 15 2023 (Q1), maxDate is Apr 15 2023 (Q2) -> only Q1 and Q2 included + expect(quartersChartData.value.labels.length).toBe(2); + expect(quartersChartData.value.datasets[0].data).toEqual([1, 0]); // sent + expect(quartersChartData.value.datasets[1].data).toEqual([3, 4]); // received + }); + + it('produces one comparison dataset per account with 0-filled gaps and the account color', () => { + const ctx = buildContext(); + ctx.accounts.value = [{ id: 'acct1', name: 'Account 1' }]; + ctx.options.accountColors = { acct1: '#123456' }; + ctx.comparison.value.yearsData = { acct1: { 2021: 4 } }; + const { yearsComparedChartData } = useTotalChartData(ctx); + expect(yearsComparedChartData.value.labels).toEqual([2021, 2022, 2023]); + expect(yearsComparedChartData.value.datasets).toHaveLength(1); + expect(yearsComparedChartData.value.datasets[0].data).toEqual([4, 0, 0]); + expect(yearsComparedChartData.value.datasets[0].borderColor).toBe('#123456'); + }); +}); diff --git a/test/utils.spec.js b/test/utils.spec.js new file mode 100644 index 0000000..8aab26b --- /dev/null +++ b/test/utils.spec.js @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + contactInvolved, + isSelfMessage, + quarterNumber, + sumObjects, + sumObjectsObjects, + weekNumber, + weeksBetween, +} from '@/utils.js'; + +describe('weekNumber', () => { + it('assigns Dec 31 2018 to week 1 of 2019', () => { + expect(weekNumber(new Date(2018, 11, 31))).toBe(1); + }); + + it('assigns Jan 1 2017 to week 52 of the previous year', () => { + expect(weekNumber(new Date(2017, 0, 1))).toBe(52); + }); + + it('always assigns Jan 4 to week 1 (ISO 8601 invariant)', () => { + expect(weekNumber(new Date(2017, 0, 4))).toBe(1); + expect(weekNumber(new Date(2021, 0, 4))).toBe(1); + }); +}); + +describe('quarterNumber', () => { + it('assigns Mar 31 to Q1 and Apr 1 to Q2', () => { + expect(quarterNumber(new Date(2023, 2, 31))).toBe(1); + expect(quarterNumber(new Date(2023, 3, 1))).toBe(2); + }); +}); + +describe('weeksBetween', () => { + it('prefixes the last week of the previous year when the range starts in it', () => { + expect(weeksBetween(new Date(2017, 0, 1), new Date(2017, 0, 15))) + .toEqual([[2016, 52], [2017, 1], [2017, 2]]); + }); + + it('handles a range spanning a year boundary', () => { + expect(weeksBetween(new Date(2018, 11, 25), new Date(2019, 0, 10))) + .toEqual([[2018, 52], [2019, 1], [2019, 2]]); + }); +}); + +describe('sumObjects', () => { + it('sums overlapping keys and keeps disjoint ones', () => { + expect(sumObjects([{ a: 1, b: 2 }, { b: 3, c: 4 }])).toEqual({ a: 1, b: 5, c: 4 }); + }); + + it('returns an empty object for empty input', () => { + expect(sumObjects([])).toEqual({}); + }); +}); + +describe('sumObjectsObjects', () => { + it('sums nested objects key by key', () => { + expect(sumObjectsObjects([{ 2023: { 1: 1, 2: 2 } }, { 2023: { 2: 3, 3: 4 } }])) + .toEqual({ 2023: { 1: 1, 2: 5, 3: 4 } }); + }); +}); + +describe('contactInvolved', () => { + const message = { + author: 'Alice ', + recipients: ['Bob '], + ccList: ['carol@example.com'], + bccList: [], + }; + + it('matches the author', () => { + expect(contactInvolved('alice@example.com', message)).toBe(true); + }); + + it('matches a recipient', () => { + expect(contactInvolved('bob@example.com', message)).toBe(true); + }); + + it('matches a cc', () => { + expect(contactInvolved('carol@example.com', message)).toBe(true); + }); + + it('returns false when the contact is not involved', () => { + expect(contactInvolved('dave@example.com', message)).toBe(false); + }); +}); + +describe('isSelfMessage', () => { + it('is true when author and all recipients match the given identities', () => { + const message = { + author: 'Me ', + recipients: ['Me '], + ccList: [], + bccList: [], + }; + expect(isSelfMessage(message, ['me@example.com'])).toBe(true); + }); + + it('is false when the author is not in the given identities', () => { + const message = { + author: 'Alice ', + recipients: ['Bob '], + ccList: [], + bccList: [], + }; + expect(isSelfMessage(message, ['me@example.com'])).toBe(false); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..1d2a89e --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, + test: { + environment: 'node', + }, +}); From d39ec1c82ce32cfe55e29351c0d8f19d65dcdf6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 20:59:23 +0200 Subject: [PATCH 02/27] =?UTF-8?q?=F0=9F=94=A8=20Move=20sections=20out=20of?= =?UTF-8?q?=20Stats.vue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Stats.vue | 2119 +---------------------- src/composables/statsAggregation.js | 347 ++++ src/composables/useActivityChartData.js | 38 + src/composables/useFoldersChartData.js | 37 + src/composables/useLeaderChartData.js | 72 + src/composables/useOnedimChartData.js | 145 ++ src/composables/useStatsData.js | 737 ++++++++ src/composables/useTagsChartData.js | 36 + src/composables/useTotalChartData.js | 247 +++ src/composables/useTwodimChartData.js | 30 + src/parts/SectionTabHeader.vue | 118 ++ src/sections/ActivitySection.vue | 84 + src/sections/FoldersSection.vue | 41 + src/sections/LeaderSection.vue | 103 ++ src/sections/OnedimSection.vue | 96 + src/sections/TagsSection.vue | 55 + src/sections/TotalSection.vue | 138 ++ src/sections/TwodimSection.vue | 54 + 18 files changed, 2467 insertions(+), 2030 deletions(-) create mode 100644 src/composables/statsAggregation.js create mode 100644 src/composables/useActivityChartData.js create mode 100644 src/composables/useFoldersChartData.js create mode 100644 src/composables/useLeaderChartData.js create mode 100644 src/composables/useOnedimChartData.js create mode 100644 src/composables/useStatsData.js create mode 100644 src/composables/useTagsChartData.js create mode 100644 src/composables/useTotalChartData.js create mode 100644 src/composables/useTwodimChartData.js create mode 100644 src/parts/SectionTabHeader.vue create mode 100644 src/sections/ActivitySection.vue create mode 100644 src/sections/FoldersSection.vue create mode 100644 src/sections/LeaderSection.vue create mode 100644 src/sections/OnedimSection.vue create mode 100644 src/sections/TagsSection.vue create mode 100644 src/sections/TotalSection.vue create mode 100644 src/sections/TwodimSection.vue diff --git a/src/Stats.vue b/src/Stats.vue index 32c5f26..3717e52 100644 --- a/src/Stats.vue +++ b/src/Stats.vue @@ -357,491 +357,46 @@ class="chart-area" :class="{ 'first-column-only': preferences.sections.total.expand }" > - -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
  • - - - - - - - -
  • -
  • - - - - - - - - - - - - - - -
  • -
-
- - - - - - - - - - - - -
-
- -
-
-
- - - - -
- -
- - - - -
-
-
    -
  • - - {{ preferences.sections.activity.year == (new Date()).getFullYear() - ? t("stats.charts." + key + ".latestActivity") - : t("stats.charts." + key + ".title", [preferences.sections.activity.year]) }} - -
  • -
-
- - - - -
-
+ +
- -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
  • - - - - - - - -
  • -
-
- - - - - - - - - -
-
- -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
-
- - - - -
-
- -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
-
- - -
- - - - - - - -
-
- - -
- - - - - - - -
-
- - -
- - - - - - - -
-
-
-
- -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
-
- - -
-
- -
-
    -
  • - - {{ t("stats.charts." + key + ".title") }} - -
  • -
-
- - -
- - - - - - - -
-
-
-
+ + + + +
@@ -851,54 +406,31 @@ diff --git a/src/composables/statsAggregation.js b/src/composables/statsAggregation.js new file mode 100644 index 0000000..3c10944 --- /dev/null +++ b/src/composables/statsAggregation.js @@ -0,0 +1,347 @@ +// pure data-aggregation logic extracted from Stats.vue +// no Vue/messenger dependencies - safe to unit test in isolation +import { + contactInvolved, + extractEmailAddress, + isSelfMessage, + NumberedObject, + quarterNumber, + sortAndLimitObject, + sumObjects, + sumObjectsObjects, + weekNumber, +} from '@/utils.js'; + +// basic data structure to display stats numbers and charts +// used for single and multi-account display +const createStatsData = (periodStart, periodEnd) => ({ + meta: { + timestamp: null, + start: periodStart ? new Date(periodStart) : new Date(), + end: periodEnd ? new Date(periodEnd) : new Date(), + }, + numbers: { + total: 0, + unread: 0, + received: 0, + sent: 0, + starred: 0, + tagged: 0, + junk: 0, + junkScore: 0, + }, + yearsData: { + received: {}, + sent: {}, + }, + quartersData: { + received: {}, + sent: {}, + }, + monthsData: { + received: {}, + sent: {}, + }, + weeksData: { + received: {}, + sent: {}, + }, + dateData: { + received: {}, + sent: {}, + }, + daytimeData: { + received: new NumberedObject(24), + sent: new NumberedObject(24), + }, + weekdayData: { + received: new NumberedObject(7), + sent: new NumberedObject(7), + }, + monthData: { + received: new NumberedObject(12), + sent: new NumberedObject(12), + }, + weekdayPerHourData: { + received: new NumberedObject(7, 24), + sent: new NumberedObject(7, 24), + }, + contacts: { + received: {}, + sent: {}, + junk: {}, + }, + folders: { + received: {}, + sent: {}, + }, + tags: {}, +}); + +// basic data structure to display charts +// used for comparing account data if multiple accounts are active +const createComparisonData = () => ({ + yearsData: {}, + quartersData: {}, + monthsData: {}, + weeksData: {}, + daytimeData: {}, + weekdayData: {}, + monthData: {}, +}); + +// Extract information of a single message with accounts +// and update given object. +// = { activeContact, selfMessagesMode, allIdentities } +// Returns string type 'sent'|'received' +const analyzeMessage = (data, message, identityList, context) => { + // check filter:contact + if (context.activeContact && !contactInvolved(context.activeContact, message)) return; + // check for self messages, if exclusion is enabled + if (context.selfMessagesMode && context.selfMessagesMode != "none") { + const ids = context.selfMessagesMode == "sameAccount" ? identityList : context.allIdentities; + if (isSelfMessage(message, ids)) return; + } + // now start analyses + let type = ""; + const author = extractEmailAddress(message.author); + // numbers + data.numbers.total++; + if (message.read === false) data.numbers.unread++; + if (identityList.includes(author)) { + data.numbers.sent++; + type = "sent"; + } else { + data.numbers.received++; + type = "received"; + } + if (message.junk) data.numbers.junk++; + data.numbers.junkScore += message.junkScore; + // calculate starting date (= date of oldest email) + const start = new Date(data.meta.start); + if (message.date && message.date.getTime() > 0 && message.date.getTime() < start.getTime()) { + data.meta.start = message.date; + } + // years + const y = message.date.getFullYear(); + if (!(y in data.yearsData[type])) { + data.yearsData[type][y] = 1; + } else { + data.yearsData[type][y]++; + } + // quarters + const qn = quarterNumber(message.date); + if (!(y in data.quartersData[type])) { + data.quartersData[type][y] = {}; + data.quartersData[type][y][qn] = 1; + } else { + if (!(qn in data.quartersData[type][y])) { + data.quartersData[type][y][qn] = 1; + } else { + data.quartersData[type][y][qn]++; + } + } + // months + const mo = message.date.getMonth(); + if (!(y in data.monthsData[type])) { + data.monthsData[type][y] = {}; + data.monthsData[type][y][mo] = 1; + } else { + if (!(mo in data.monthsData[type][y])) { + data.monthsData[type][y][mo] = 1; + } else { + data.monthsData[type][y][mo]++; + } + } + // weeks + const wn = weekNumber(message.date); + const ywn = wn == 53 && mo == 0 ? y-1 : y; // adjust year for first days of January that are before week 1 + if (!(ywn in data.weeksData[type])) { + data.weeksData[type][ywn] = {}; + data.weeksData[type][ywn][wn] = 1; + } else { + if (!(wn in data.weeksData[type][ywn])) { + data.weeksData[type][ywn][wn] = 1; + } else { + data.weeksData[type][ywn][wn]++; + } + } + // daytime + const dt = message.date.getHours(); + data.daytimeData[type][dt]++; + // weekday + const wd = message.date.getDay(); + data.weekdayData[type][wd]++; + // month + data.monthData[type][mo]++; + // dates + const iso = message.date.toISOString().substr(0, 10); + if (!(iso in data.dateData[type])) { + data.dateData[type][iso] = 1; + } else { + data.dateData[type][iso]++; + } + // weekday per hour + data.weekdayPerHourData[type][wd][dt]++; + // contacts (leaderboards) + switch (type) { + case 'sent': + const recipients = message.recipients.map(r => extractEmailAddress(r).toLowerCase()); + recipients.forEach(r => { + if (!(r in data.contacts.sent)) { + data.contacts.sent[r] = 1; + } else { + data.contacts.sent[r]++; + } + }); + break; + case 'received': + if (!(author in data.contacts.received)) { + data.contacts.received[author] = 1; + } else { + data.contacts.received[author]++; + } + break; + default: + break; + } + if (message.junk) { + if (!(author in data.contacts.junk)) { + data.contacts.junk[author] = 1; + } else { + data.contacts.junk[author]++; + } + } + // folders + const f = message.folder.name; + if (!(f in data.folders[type])) { + data.folders[type][f] = 1; + } else { + data.folders[type][f]++; + } + // star + if (message.flagged === true) data.numbers.starred++; + // tags + if (message.tags.length > 0) { + data.numbers.tagged++; + message.tags.forEach(tag => { + if (!(tag in data.tags)) { + data.tags[tag] = 1; + } else { + data.tags[tag]++; + } + }); + } + + return type; +}; + +// sums an array of per-account data objects (shaped like createStatsData()) into one +const sumAccountsData = (accountsData, maxListCount) => { + let sum = createStatsData(); + // meta + accountsData.map(a => { + if (!a.hasOwnProperty("meta")) a.meta = { timestamp: 0 }; + }); + sum.meta.timestamp = accountsData.reduce((p,c) => p < c.meta.timestamp ? p : c.meta.timestamp, Date.now()); + // numbers + sum.numbers.total = accountsData.reduce((p,c) => p+c.numbers.total, 0); + sum.numbers.unread = accountsData.reduce((p,c) => p+c.numbers.unread, 0); + sum.numbers.received = accountsData.reduce((p,c) => p+c.numbers.received, 0); + sum.numbers.sent = accountsData.reduce((p,c) => p+c.numbers.sent, 0); + sum.numbers.starred = accountsData.reduce((p,c) => p+(c.numbers.starred ?? 0), 0); + sum.numbers.tagged = accountsData.reduce((p,c) => p+(c.numbers.tagged ?? 0), 0); + sum.numbers.junk = accountsData.reduce((p,c) => p+c.numbers.junk, 0); + sum.numbers.junkScore = accountsData.reduce((p,c) => p+c.numbers.junkScore, 0)/accountsData.length; + sum.meta.start = accountsData.reduce((p,c) => p < c.meta.start ? p : c.meta.start, 0); + sum.meta.end = accountsData.reduce((p,c) => p >= c.meta.end ? p : c.meta.end, 0); + // years + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.yearsData.received)])], []) + .map(y => { sum.yearsData.received[y] = accountsData.reduce((p,c) => c.yearsData.received[y] ? p+c.yearsData.received[y] : p, 0) }); + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.yearsData.sent)])], []) + .map(y => { sum.yearsData.sent[y] = accountsData.reduce((p,c) => c.yearsData.sent[y] ? p+c.yearsData.sent[y] : p, 0) }); + // quarters + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.quartersData.received)])], []) + .map(y => { sum.quartersData.received[y] = sumObjects(accountsData.reduce((p,c) => c.quartersData.received[y] ? p.concat(c.quartersData.received[y]) : p, [])) }); + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.quartersData.sent)])], []) + .map(y => { sum.quartersData.sent[y] = sumObjects(accountsData.reduce((p,c) => c.quartersData.sent[y] ? p.concat(c.quartersData.sent[y]) : p, [])) }); + // months + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.monthsData.received)])], []) + .map(y => { sum.monthsData.received[y] = sumObjects(accountsData.reduce((p,c) => c.monthsData.received[y] ? p.concat(c.monthsData.received[y]) : p, [])) }); + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.monthsData.sent)])], []) + .map(y => { sum.monthsData.sent[y] = sumObjects(accountsData.reduce((p,c) => c.monthsData.sent[y] ? p.concat(c.monthsData.sent[y]) : p, [])) }); + // weeks + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.weeksData.received)])], []) + .map(y => { sum.weeksData.received[y] = sumObjects(accountsData.reduce((p,c) => c.weeksData.received[y] ? p.concat(c.weeksData.received[y]) : p, [])) }); + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.weeksData.sent)])], []) + .map(y => { sum.weeksData.sent[y] = sumObjects(accountsData.reduce((p,c) => c.weeksData.sent[y] ? p.concat(c.weeksData.sent[y]) : p, [])) }); + // dates + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.dateData.received)])], []) + .map(d => { sum.dateData.received[d] = accountsData.reduce((p,c) => c.dateData.received[d] ? p+c.dateData.received[d] : p, 0) }) + accountsData.reduce((p,c) => [...new Set([...p ,...Object.keys(c.dateData.sent)])], []) + .map(d => { sum.dateData.sent[d] = accountsData.reduce((p,c) => c.dateData.sent[d] ? p+c.dateData.sent[d] : p, 0) }) + // daytime + for (let h = 0; h < 24; h++) { + sum.daytimeData.received[h] = accountsData.reduce((p,c) => p+c.daytimeData.received[h], 0); + sum.daytimeData.sent[h] = accountsData.reduce((p,c) => p+c.daytimeData.sent[h], 0); + } + // weekday + for (let d = 0; d < 7; d++) { + sum.weekdayData.received[d] = accountsData.reduce((p,c) => p+c.weekdayData.received[d], 0); + sum.weekdayData.sent[d] = accountsData.reduce((p,c) => p+c.weekdayData.sent[d], 0); + } + // month + for (let m = 0; m < 12; m++) { + sum.monthData.received[m] = accountsData.reduce((p,c) => p+c.monthData.received[m], 0); + sum.monthData.sent[m] = accountsData.reduce((p,c) => p+c.monthData.sent[m], 0); + } + // weekday per hour + for (let d = 0; d < 7; d++) { + for (let h = 0; h < 24; h++) { + sum.weekdayPerHourData.received[d][h] = accountsData.reduce((p,c) => p+c.weekdayPerHourData.received[d][h], 0); + sum.weekdayPerHourData.sent[d][h] = accountsData.reduce((p,c) => p+c.weekdayPerHourData.sent[d][h], 0); + } + } + // contacts + sum.contacts.received = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.contacts.received), [])), maxListCount); + sum.contacts.sent = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.contacts.sent), [])), maxListCount); + sum.contacts.junk = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.contacts.junk ?? []), [])), maxListCount); + // folders + sum.folders.received = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.folders?.received ?? []), []))); + sum.folders.sent = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.folders?.sent ?? []), []))); + // tags + sum.tags = sortAndLimitObject(sumObjects(accountsData.reduce((p,c) => p.concat(c.tags ?? []), [])), maxListCount); + + return sum; +}; + +// builds per-account comparison data (shaped like createComparisonData()) for the +// "sum of all accounts" view, one entry per account in +const buildComparisonData = (accountsData, activeAccounts) => { + const comparisonData = createComparisonData(); + activeAccounts.forEach((a, i) => { + // years + comparisonData.yearsData[a.id] = sumObjects([accountsData[i].yearsData.received, accountsData[i].yearsData.sent]); + // quarters + comparisonData.quartersData[a.id] = sumObjectsObjects([accountsData[i].quartersData.received, accountsData[i].quartersData.sent]); + // months + comparisonData.monthsData[a.id] = sumObjectsObjects([accountsData[i].monthsData.received, accountsData[i].monthsData.sent]); + // weeks + comparisonData.weeksData[a.id] = sumObjectsObjects([accountsData[i].weeksData.received, accountsData[i].weeksData.sent]); + // daytime + comparisonData.daytimeData[a.id] = sumObjects([accountsData[i].daytimeData.received, accountsData[i].daytimeData.sent]); + // weekday + comparisonData.weekdayData[a.id] = sumObjects([accountsData[i].weekdayData.received, accountsData[i].weekdayData.sent]); + // month + comparisonData.monthData[a.id] = sumObjects([accountsData[i].monthData.received, accountsData[i].monthData.sent]); + }); + return comparisonData; +}; + +export { + analyzeMessage, + buildComparisonData, + createComparisonData, + createStatsData, + sumAccountsData, +}; diff --git a/src/composables/useActivityChartData.js b/src/composables/useActivityChartData.js new file mode 100644 index 0000000..8df5aaf --- /dev/null +++ b/src/composables/useActivityChartData.js @@ -0,0 +1,38 @@ +// chart-data shaping for the "activity" section (single-date calendar matrix chart) +import { computed } from 'vue'; + +export function useActivityChartData({ display, activityPrefs, t }) { + // prepare data for single date matrix charts + const dateChartData = computed(() => { + const year = activityPrefs.year; + // Generate all dates for the full year (Jan 1 to Dec 31) + const startDate = new Date(year, 0, 1); + const endDate = new Date(year, 11, 31); + + // Build lookup maps from actual data + const receivedMap = Object.fromEntries( + Object.entries(display.value.dateData?.received || {}).filter(e => e[0].substring(0, 4) == year) + ); + const sentMap = Object.fromEntries( + Object.entries(display.value.dateData?.sent || {}).filter(e => e[0].substring(0, 4) == year) + ); + + // Generate full year data with 0 for empty days + let r = [], s = []; + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const key = d.toISOString().slice(0, 10); + r.push([key, receivedMap[key] || 0]); + s.push([key, sentMap[key] || 0]); + } + + // TODO: handle options.startOfWeek + return { + received: { label: t("stats.mailsReceived"), data: r }, + sent: { label: t("stats.mailsSent"), data: s }, + }; + }); + + return { + dateChartData, + }; +} diff --git a/src/composables/useFoldersChartData.js b/src/composables/useFoldersChartData.js new file mode 100644 index 0000000..0007867 --- /dev/null +++ b/src/composables/useFoldersChartData.js @@ -0,0 +1,37 @@ +// chart-data shaping for the "folders" section (emails per folder doughnut chart) +import { computed } from 'vue'; +import { accentColors } from '@/definitions.js'; + +export function useFoldersChartData({ display, locale, t }) { + // prepare data for emails per folder doughnut charts + const foldersChartData = computed(() => { + const r = display.value.folders.received, s = display.value.folders.sent; + let dr = [], ds = [], labels = []; + let all = Array.from(new Set([...Object.keys(r), ...Object.keys(s)])); + all.sort((a, b) => a.localeCompare(b, locale.value, { sensitivity: "base" })); + all.forEach(d => { + dr.push(r[d] ? r[d] : 0) + ds.push(s[d] ? s[d] : 0) + labels.push(d) + }); + return { + datasets: [ + { + label: t("stats.mailsReceived"), + data: dr, + color: accentColors[1], + }, + { + label: t("stats.mailsSent"), + data: ds, + color: accentColors[0], + }, + ], + labels: labels, + }; + }); + + return { + foldersChartData, + }; +} diff --git a/src/composables/useLeaderChartData.js b/src/composables/useLeaderChartData.js new file mode 100644 index 0000000..e521993 --- /dev/null +++ b/src/composables/useLeaderChartData.js @@ -0,0 +1,72 @@ +// chart-data shaping for the "leader" section (contact leaderboards horizontal bar charts) +import { computed } from 'vue'; +import { accentColors } from '@/definitions.js'; + +export function useLeaderChartData({ display, options, t }) { + // prepare data for sent emails leaderboard horizontal bar chart + const sentContactLeadersChartData = computed(() => { + const s = display.value.contacts.sent; + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: Object.values(s), + borderColor: accentColors[0], + }, + ], + labels: Object.keys(s), + }; + }); + // true if there are any receiver contacts to display + const sentContactLeadersChartDataExists = computed(() => { + return sentContactLeadersChartData.value.datasets[0].data?.length > 0; + }); + + // prepare data for received emails leaderboard horizontal bar chart + const receivedContactLeadersChartData = computed(() => { + const r = display.value.contacts.received; + return { + datasets: [ + { + label: t("stats.mailsReceived"), + data: Object.values(r), + borderColor: accentColors[1], + }, + ], + labels: Object.keys(r), + }; + }); + // true if there are any sender contacts to display + const receivedContactLeadersChartDataExists = computed(() => { + return receivedContactLeadersChartData.value.datasets[0].data?.length > 0; + }); + + // prepare data for junk emails leaderboard horizontal bar chart + const junkContactLeadersChartData = computed(() => { + const r = display.value.contacts.junk || {}; + const color = options.dark ? accentColors[2] : accentColors[3]; + return { + datasets: [ + { + label: t("stats.junkMails"), + data: Object.values(r), + borderColor: color, + }, + ], + labels: Object.keys(r), + }; + }); + // true if there are any junk contacts to display + const junkContactLeadersChartDataExists = computed(() => { + return junkContactLeadersChartData.value.datasets[0].data?.length > 0; + }); + + return { + sentContactLeadersChartData, + sentContactLeadersChartDataExists, + receivedContactLeadersChartData, + receivedContactLeadersChartDataExists, + junkContactLeadersChartData, + junkContactLeadersChartDataExists, + }; +} diff --git a/src/composables/useOnedimChartData.js b/src/composables/useOnedimChartData.js new file mode 100644 index 0000000..022de1b --- /dev/null +++ b/src/composables/useOnedimChartData.js @@ -0,0 +1,145 @@ +// chart-data shaping for the "onedim" section (daytime/weekday/month bar charts) +import { computed } from 'vue'; +import { accentColors } from '@/definitions.js'; +import { monthNames, weekdayNames } from '@/utils.js'; + +export function useOnedimChartData({ display, comparison, accounts, options, t, locale }) { + // prepare data for daytime bar chart + const daytimeChartData = computed(() => { + const r = display.value.daytimeData.received, s = display.value.daytimeData.sent; + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: Object.values(s), + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: Object.values(r), + borderColor: accentColors[1], + }, + ], + labels: Object.keys(r), + }; + }); + // prepare comparison data for daytime bar chart + const daytimeComparedChartData = computed(() => { + let datasets = []; + // compute dataset for each account + accounts.value.forEach((a) => { + const d = comparison.value.daytimeData[a.id]; + // add dataset for this account + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: Object.values(d), + borderColor: options.accountColors[a.id], + }); + }); + return { + datasets: datasets, + labels: Object.keys(Object.values(comparison.value.daytimeData)[0]), + }; + }); + + // prepare data for weekday bar chart + const weekdayChartData = computed(() => { + const r = Object.values(display.value.weekdayData.received); + const s = Object.values(display.value.weekdayData.sent); + let labels = [...weekdayNames(locale.value)]; + // TODO: start week with user defined day of week + for (let d = 0; d < 1/*options.startOfWeek*/; d++) { + r.push(r.shift()); + s.push(s.shift()); + labels.push(labels.shift()); + } + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: s, + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: r, + borderColor: accentColors[1], + }, + ], + labels: labels, + }; + }); + // prepare comparison data for weekday bar chart + const weekdayComparedChartData = computed(() => { + let datasets = []; + let labels = [...weekdayNames(locale.value)]; + // TODO: labels: start week with user defined day of week + for (let d = 0; d < 1/*options.startOfWeek*/; d++) { + labels.push(labels.shift()); + } + // compute dataset for each account + accounts.value.forEach((a) => { + const data = Object.values(comparison.value.weekdayData[a.id]); + // TODO: data: start week with user defined day of week + for (let d = 0; d < 1/*options.startOfWeek*/; d++) { + data.push(data.shift()); + } + // add dataset for this account + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: data, + borderColor: options.accountColors[a.id], + }); + }); + return { + datasets: datasets, + labels: labels, + }; + }); + + // prepare data for month bar chart + const monthChartData = computed(() => { + const r = display.value.monthData.received, s = display.value.monthData.sent; + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: Object.values(s), + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: Object.values(r), + borderColor: accentColors[1], + }, + ], + labels: monthNames(locale.value), + }; + }); + // prepare comparison data for month bar chart + const monthComparedChartData = computed(() => { + let datasets = []; + // compute dataset for each account + accounts.value.forEach((a) => { + // add dataset for this account + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: Object.values(comparison.value.monthData[a.id]), + borderColor: options.accountColors[a.id], + }); + }); + return { + datasets: datasets, + labels: monthNames(locale.value), + }; + }); + + return { + daytimeChartData, + daytimeComparedChartData, + weekdayChartData, + weekdayComparedChartData, + monthChartData, + monthComparedChartData, + }; +} diff --git a/src/composables/useStatsData.js b/src/composables/useStatsData.js new file mode 100644 index 0000000..79d5149 --- /dev/null +++ b/src/composables/useStatsData.js @@ -0,0 +1,737 @@ +// Thunderbird messenger.* data-fetch/aggregation engine for the stats page. +// Call exactly once, from Stats.vue - this composable owns all its state internally; +// invoking it a second time anywhere else would create an unsynced duplicate copy. +import { ref, reactive, computed, watch } from 'vue'; +import { useI18n } from 'vue-i18n'; + +import { accentColors, defaultColors, defaultOptions } from '@/definitions.js'; +import { queryMessages, setTheme, sortAndLimitObject, traverseAccount, yyyymmdd } from '@/utils.js'; +import { + analyzeMessage, + buildComparisonData, + createComparisonData, + createStatsData, + sumAccountsData, +} from '@/composables/statsAggregation.js'; + +export function useStatsData() { + const { t } = useI18n(); + + const now = new Date(); + + // list of all existing Thunderbird accounts + const accounts = ref([]); + + // list of all existing identities + const identities = ref([]); + + // list of all existing folders for the current account selection + const folders = ref([]); + + // list of all existing tags + const tags = ref([]); + + // selected filter field values and field errors + const active = reactive({ + account: null, // currently selected account + folder: null, // currently selected folder + contact: null, // currently selected contact + period: { + start: null, // currently configured start of period of time + end: null, // currently configured end of period of time + } + }); + const error = reactive({ + account: false, // truey if there where any errors on account message retrieval + period: { + start: [], // list of error messages if currently configured period start date is invalid + end: [], // list of error messages if currently configured period end date is invalid + } + }); + + // loading and process indication if data is processing + const isLoading = ref(false); + const progress = reactive({ + current: 0, // indicator for progress on refreshing data, fraction [0-1] + max: 0, // upper limit for progress indicator + }); + + // preferences for stats page configuration + const preferences = reactive({ + sections: { + total: { + expand: false, + comparison: false + }, + activity: { + year: now.getFullYear() + }, + onedim: { + comparison: false + } + } + }); + + // initially load default add-on options + const options = reactive({...defaultOptions}); + + // processed data to show; data structure see createStatsData + const display = ref(createStatsData(active.period.start, active.period.end)); + + // subset of processed data to show data for account comparison view; data structure see createComparisonData + const comparison = ref(createComparisonData()); + + // adds a listener for storage change events + // makes reactions on option changes possible + const addStorageListener = () => { + messenger.storage.onChanged.addListener((result, area) => { + if (area == "local" && result?.options?.newValue && result?.options?.oldValue) { + const n = result.options.newValue; + const o = result.options.oldValue; + // only update those options that changed + if (n.theme != o.theme) { + options.dark = setTheme(n.theme); + } + if (n.ordinate != o.ordinate) { + options.ordinate = n.ordinate; + } + if (n.tagColors != o.tagColors) { + options.tagColors = n.tagColors; + } + if (n.liveCountUp != o.liveCountUp) { + options.liveCountUp = n.liveCountUp; + } + if (n.autoRefresh != o.autoRefresh) { + options.autoRefresh = n.autoRefresh; + } + if (n.autoRefreshInterval != o.autoRefreshInterval) { + options.autoRefreshInterval = n.autoRefreshInterval; + } + if (n.startOfWeek != o.startOfWeek) { + options.startOfWeek = n.startOfWeek; + } + if (n.addresses != o.addresses) { + options.addresses = n.addresses.toLowerCase().split(",").map(x => x.trim()); + } + if (JSON.stringify(n.accounts) != JSON.stringify(o.accounts)) { + options.accounts = n.accounts; + } + if (JSON.stringify(n.accountColors) != JSON.stringify(o.accountColors)) { + options.accountColors = n.accountColors; + } + if (n.selfMessages != o.selfMessages) { + options.selfMessages = n.selfMessages; + } + if (n.maxListCount != o.maxListCount) { + options.maxListCount = n.maxListCount; + } + if (n.cache != o.cache) { + options.cache = n.cache; + } + if (n.debug != o.debug) { + options.debug = n.debug; + } + } + }); + }; + + // get all add-on settings from the options page + // for non existing options use default value + const getOptions = async () => { + const result = await messenger.storage.local.get("options"); + // only load options if they have been set, otherwise default settings will be kept + if (result && result.options) { + options.dark = setTheme( + result.options.theme ?? defaultOptions.theme, + document.body, + ['dark', 'background-normal'], + ['light', 'background-highlight-contrast'] + ); + options.ordinate = result.options.ordinate ?? defaultOptions.ordinate; + options.tagColors = result.options.tagColors ?? defaultOptions.tagColors; + options.liveCountUp = result.options.liveCountUp ?? defaultOptions.liveCountUp; + options.autoRefresh = result.options.autoRefresh ?? defaultOptions.autoRefresh; + options.autoRefreshInterval = result.options.autoRefreshInterval ?? defaultOptions.autoRefreshInterval; + // options.startOfWeek = result.options.startOfWeek ?? defaultOptions.startOfWeek; + options.addresses = result.options.addresses ? result.options.addresses.toLowerCase().split(",").map(x => x.trim()) : defaultOptions.addresses; + options.accounts = result.options.accounts ?? defaultOptions.accounts; + options.accountColors = result.options.accountColors ?? defaultOptions.accountColors; + options.selfMessages = result.options.selfMessages ?? defaultOptions.selfMessages; + options.maxListCount = result.options.maxListCount ?? defaultOptions.maxListCount; + options.cache = result.options.cache ?? defaultOptions.cache; + options.debug = result.options.debug ?? defaultOptions.debug; + } + }; + + // retrieve list of tags that can be set on messages + // their human-friendly name, color, and sort order + const getTags = async () => { + tags.value = await messenger.messages.tags.list(); + }; + + // retrieve accounts and identities list + // get active account from URL get parameter + const getAccounts = async () => { + let list = await messenger.accounts.list(); + // if account colors are not initialized yet, initialize them + if (Object.keys(options.accountColors).length == 0) { + list.forEach((a, i) => { + options.accountColors[a.id] = defaultColors[(i%defaultColors.length)] + }); + } + // filter list of accounts if user configured custom list + if (options.accounts.length > 0 && options.accounts.length < list.length) { + list = list.filter(a => options.accounts.includes(a.id)); + } + // store accounts + accounts.value = list; + // store identities of all activated accounts + let activeIdentities = list.reduce((p,c) => p.concat(c.identities.map(i => i.email.toLowerCase())), []); + // add local identities if any local account is active + if (options.addresses.length && list.some(a => ["none", "local"].includes(a.type))) { + options.addresses.forEach(l => activeIdentities.push(l.toLowerCase())); + } + identities.value = activeIdentities; + // extract account id from url GET parameter + const uri = window.location.search.substring(1); + let id = (new URLSearchParams(uri)).get("s"); + if (!id || (id == "sum" && !options.cache) || (id == "sum" && list.length <= 1)) id = list[0].id; + active.account = id; + }; + + // retrieve all messages of a given with accounts + // store results in object + const processMessages = async (data, folder, identityList) => { + // Only analyze existing, non-virtual folders + if (folder && !folder.isUnified && !folder.isVirtual) { + const context = { + activeContact: active.contact, + selfMessagesMode: options.selfMessages, + allIdentities: identities.value, + }; + let n = 0, s = 0, r = 0; + for await (let m of queryMessages(folder.id, active.period.start, active.period.end)) { + const type = analyzeMessage(data, m, identityList, context); + // live update numbers section if corresponding option is enabled + if (options.liveCountUp) display.value.numbers = data.numbers; + if (options.debug) { + n++; + s += type === 'sent' ? 1 : 0; + r += type === 'received' ? 1 : 0; + } + } + + // Handle debug output + if (options.debug) { + const totalOutput = String(n).padStart(6); + const receivedOutput = String(r).padStart(6); + const sentOutput = String(s).padStart(6); + console.debug( + `${totalOutput} %c${receivedOutput} %c${sentOutput} %c📁 ${folder.path}`, + `color:${accentColors[1]}`, + `color:${accentColors[0]}`, + 'color:inherit' + ); + } + } + }; + + // analyze folders of a given account + // return processed data object structured like createStatsData + const processAccount = async (a) => { + // get identities from account, or from preferences if it's a local account + const identities = !["none", "local"].includes(a.type) ? a.identities.map(i => i.email.toLowerCase()) : options.addresses; + // get all folders and subfolders from given account or selected folder of active account (filter field) + const foldersList = active.folder ? [JSON.parse(JSON.stringify(active.folder))] : await traverseAccount(a); + // build folder list for filter selection, if not already present + if (!folders.value.length) { + folders.value = foldersList; + } + const accountData = createStatsData(active.period.start, active.period.end); + await Promise.all(foldersList.map(async f => { + // analyze all messages in all folders + await processMessages(accountData, f, identities); + // increment current progress by 1 for each folder + progress.current++; + })); + // post processing: sort and reduce size of lists to configured limit + accountData.contacts.received = sortAndLimitObject(accountData.contacts.received, options.maxListCount); + accountData.contacts.sent = sortAndLimitObject(accountData.contacts.sent, options.maxListCount); + accountData.contacts.junk = sortAndLimitObject(accountData.contacts.junk, options.maxListCount); + accountData.tags = sortAndLimitObject(accountData.tags, options.maxListCount); + // post processing: sort folders + accountData.folders.received = sortAndLimitObject(accountData.folders.received); + accountData.folders.sent = sortAndLimitObject(accountData.folders.sent); + // post processing: add timestamp of finished processing + accountData.meta.timestamp = Date.now(); + + // Handle debug output + if (options.debug) { + const debugIdentities = identities.length ? identities.join(', ') : 'None'; + console.debug(`Detected identities: ${debugIdentities}`); + } + + // check if error occured during processing + // any error is saved to local storage during processing + const { err } = await messenger.storage.local.get("error"); + error.account = err; + + return accountData; + }; + + // true, if at least one filter is set + const filterIsActive = computed(() => active.folder || active.period.start || active.period.end || active.contact); + + // true, if just one single account is selected + const singleAccount = computed(() => active.account !== "sum"); + + // retrieve and process data of account with + // gets called multiple times if processing was invoked for all accounts + const reprocessData = async (id, auto=false) => { + // get currently selected account + const account = await messenger.accounts.get(id); + // process data of this account again + const accountData = await processAccount(account); + // directly display data if only one single account was manually processed + if (singleAccount.value && !auto) { + display.value = JSON.parse(JSON.stringify(accountData)); + } + // only store reprocessed data if cache is enabled and no filter is set + if (options.cache && !filterIsActive.value) { + const stats = {}; + stats["stats-" + id] = JSON.parse(JSON.stringify(accountData)); + await messenger.storage.local.set(stats); + } + // return processed account data + return accountData; + }; + + // corrects selected year, if it's out of the current date range + // called after data got reprocessed + const adjustSelectedYear = () => { + const min = new Date(display.value.meta.start).getFullYear() + const max = new Date(display.value.meta.end).getFullYear() + const current = preferences.sections.activity.year + if (current < min) preferences.sections.activity.year = min + if (current > max) preferences.sections.activity.year = max + }; + + // load data of given account or all accounts + // from cache or reprocess from scratch + // while reprocessing was invoked manually or automaticalle + const loadAccount = async (id, refresh, auto=false) => { + // start loading indication + isLoading.value = true; + // check id type + if (id === 'sum' && options.cache) { + // set tab title + document.title = "ThirdStats: " + t("stats.allAccounts"); + // deactivate list of folders + folders.value = []; + // iterate over all activated accounts + const activeAccounts = options.accounts.length > 0 && options.accounts.length < accounts.value.length + ? accounts.value.filter(a => options.accounts.includes(a.id)) + : accounts.value; + let accountsData = []; + // init progress indicator + progress.current = 1; + progress.max = activeAccounts.reduce(async (p,c) => p + (await traverseAccount(c).length), 0); + // when auto processing remember displayed account key and disable live counts + let displayedAccountKey = null; + let liveCountUpDisabled = false; + if (auto && options.liveCountUp) { + liveCountUpDisabled = true; + options.liveCountUp = false; + } + await Promise.all(activeAccounts.map(async a => { + // get data from storage + const result = await messenger.storage.local.get("stats-" + a.id); + if (!refresh && result && result["stats-" + a.id]) { + // if no refresh requested and this accounts data was cached before, take data from cache + accountsData.push(JSON.parse(JSON.stringify(result["stats-" + a.id]))); + progress.current += a.folderCount; + } else { + // otherwise (re)process account + await messenger.storage.local.set({ error: false }); + // Handle debug output + if (options.debug) { + console.debug(`Processing account ${a.name}`, a); + } + const data = await reprocessData(a.id, auto); + accountsData.push(JSON.parse(JSON.stringify(data))); + // remember key of currently displayed account if auto processed + if (auto && active.account == a.id) { + displayedAccountKey = accountsData.length - 1; + } + } + })); + // enable live counts again if set + if (auto && liveCountUpDisabled) { + options.liveCountUp = true; + } + // finish progress indicator + progress.current = 0; + progress.max = 0; + + // sum all values of all account objects + const sum = sumAccountsData(accountsData, options.maxListCount); + + // show summed stats or keep current view if processing was invoked automatically + display.value = auto && displayedAccountKey ? accountsData[displayedAccountKey] : sum; + + // retrieve all values of account objects for comparison views + comparison.value = buildComparisonData(accountsData, activeAccounts); + } else { + // load single account from id + const account = await messenger.accounts.get(id); + // set tab title + document.title = "ThirdStats: " + account.name; + // (re)calculate list of folders + folders.value = await traverseAccount(account); + // only check storage if no refresh was requested cache is enabled + const result = options.cache ? await messenger.storage.local.get("stats-" + id) : null; + if (!refresh && result && result["stats-" + id]) { + // if cache is enabled and data already exists in storage, display it directly + display.value = JSON.parse(JSON.stringify(result["stats-" + id])); + } else { + // otherwise retrieve it first/again and track progress by processed folder count + progress.current = 1; + progress.max = folders.value.length; + // Handle debug output + if (options.debug) { + console.debug(`Processing account ${account.name}`, account); + console.debug(` total %crecvd %csent %c📁 Folder path`, `color:${accentColors[1]}`, `color:${accentColors[0]}`, 'color:inherit'); + } + await messenger.storage.local.set({ error: false }); + await reprocessData(id); + progress.current = 0; + progress.max = 0; + } + } + // finally adjust displayed activity year + adjustSelectedYear(); + // finished - stop loading indication + isLoading.value = false; + }; + + // reset folder filter + // reload data if requested + const resetFolder = async (reload) => { + active.folder = null; + if (reload) { + // reprocess current data if another filter is set, otherwise just load account data + await loadAccount(active.account, (active.period.start && active.period.end) || active.contact) + } + }; + + // true if entered time period is valid + // fills error stack for affected fields when input is invalid + const validatePeriod = () => { + let valid = true; + const datex = RegExp(/^\d{4}-([0]\d|1[0-2])-([0-2]\d|3[01])$/); + error.period.start = []; + error.period.end = []; + // start time is not set + if (!active.period.start) { + valid = false; + error.period.start.push(t("stats.tooltips.error.empty")); + } + // start time is of wrong format + if (!datex.test(active.period.start)) { + valid = false; + error.period.start.push(t("stats.tooltips.error.dateFormat")); + } + // start time is no real date + if (isNaN(Date.parse(active.period.start))) { + valid = false; + error.period.start.push(t("stats.tooltips.error.dateUnreal")); + } + // end time is not set + if (!active.period.end) { + valid = false; + error.period.end.push(t("stats.tooltips.error.empty")); + } + // end time is of wrong format + if (!datex.test(active.period.end)) { + valid = false; + error.period.end.push(t("stats.tooltips.error.dateForma;t")) + } + // end time is no real date + if (isNaN(Date.parse(active.period.end))) { + valid = false; + error.period.end.push(t("stats.tooltips.error.dateUnreal;")) + } + // start date is before end date + if (Date.parse(active.period.start) > Date.parse(active.period.end)) { + valid = false; + error.period.start.push(t("stats.tooltips.error.dateOrderStart")); + error.period.end.push(t("stats.tooltips.error.dateOrderEnd")); + } + return valid; + }; + + // process data for current time period filter + // calls refresh if filter is valid + const updatePeriod = async () => { + if (validatePeriod()) { + await loadAccount(active.account, true); + display.value.meta.start = new Date(active.period.start); + display.value.meta.end = new Date(active.period.end); + adjustSelectedYear(); + } + }; + + // reset time period filter + // reload data if requested + const resetPeriod = async (reload) => { + active.period.start = null; + active.period.end = null; + error.period.start = []; + error.period.end = []; + adjustSelectedYear(); + if (reload) { + // reprocess current data if another filter is set, otherwise just load account data + await loadAccount(active.account, active.folder || active.contact); + } + }; + + // reset contact filter + // reload data if requested + const resetContact = async (reload) => { + active.contact = null; + if (reload) { + // reprocess current data if another filter is set, otherwise just load account data + await loadAccount(active.account, (active.period.start && active.period.end) || active.folder); + } + }; + + // format folder select options + // build name to match its hierarchy with preceding s + const formatFolder = (folder, indentSymbol = "—") => { + const level = (folder.path.match(/\//g) || []).length; + return level <= 1 ? folder.name : indentSymbol.repeat(level-1) + " " + folder.name; + }; + + // format period date input to match YYYY-MM-DD + // defines the input field, either 'start' or 'end' + const formatPeriod = (key) => { + if (active.period[key]) { + let s = active.period[key]; + // complete year + if (s.length == 6) { + s = String((new Date()).getFullYear()).slice(0, 2) + s; + } + // insert dashes + if (!s.includes("-")) { + s = s.slice(0, 4) + "-" + s.slice(4, 6) + "-" + s.slice(6); + } + // shorten to 10 characters + s = s.slice(0, 10); + // set lower limit + if (!isNaN(Date.parse(s)) && Date.parse(s) < 0) { + s = "1970-01-01"; + } + // set upper limit + if (!isNaN(Date.parse(s)) && Date.parse(s) > Date.now()) { + s = (new Date()).toISOString().slice(0, 10); + } + active.period[key] = s; + } + }; + + // first date in currently displayed data + const minDate = computed(() => { + return new Date(display.value.meta.start); + }); + // last date in currently displayed data + const maxDate = computed(() => { + return display.value.meta.end ? new Date(display.value.meta.end) : new Date(); + }); + // year minDate + const minYear = computed(() => { + return minDate.value.getFullYear(); + }); + // year of maxDate + const maxYear = computed(() => { + return maxDate.value.getFullYear(); + }); + + // increments selected year + // only up to the max existing year + const nextYear = () => { + if (preferences.sections.activity.year < maxYear.value) { + preferences.sections.activity.year++; + } + }; + // decrements selected year + // only down to the min existing year + const previousYear = () => { + if (preferences.sections.activity.year > minYear.value) { + preferences.sections.activity.year--; + } + }; + + // export displayed data + // provides a JSON file for download + const exportJson = () => { + const data = new Blob([JSON.stringify(display.value)], { type: "text/plain;charset=utf-8" }); + messenger.downloads.download({ + 'url': URL.createObjectURL(data), + 'filename': yyyymmdd(new Date()) + "_third-stats-export.json", + 'saveAs': true + }).then(() => {}, () => {}); // TODO: [alert] Successfully started download | Download aborted + }; + + // tooltip for account comparison button + // depends on active accounts, account selection and toggle of given section + const tooltipAccountComparison = (section) => { + if (options.accounts.length < 2) { + return t('stats.tooltips.comparisonWhenAccountsOption'); + } + if (singleAccount.value) { + return t('stats.tooltips.comparisonWhenFilter'); + } + return !preferences.sections[section].comparison + ? t('stats.tooltips.comparison') + : t('stats.tooltips.sum'); + }; + + // merges received and sent contacts to a distinct list for contacts filter + const contacts = computed(() => { + const r = display.value.contacts.received, s = display.value.contacts.sent; + return Array.from(new Set([...Object.keys(r), ...Object.keys(s)])).sort(); + }); + + // return account colors of all active accounts comma separated as single string + const accountsColorGradient = computed(() => { + return Object + .entries(options.accountColors) + .filter((a => options.accounts.includes(a[0]))) + .reduce((p,c) => p.concat(c[1]), []) + .join(","); + }); + + // array of years descending from last to first date + const yearsList = computed(() => { + let years = []; + for (let i = maxYear.value; i >= minYear.value; i--) { + years.push(i); + } + return years; + }); + + // compute current loading progress in percent + const processingState = computed(() => { + if (progress.max > 0) { + if (progress.current<=progress.max) { + return 100*progress.current/progress.max; + } else { + return 100; + } + } else { + return 0; + } + }); + + // on change of active account reset filter + // and load new accounts data accordingly + watch( + () => active.account, + async (id) => { + // default to all accounts page if no id given + if (!id) id = 'sum'; + // reset preferences + preferences.sections.total.comparison = false; + // reset folder filter + resetFolder(false); + // reset contact filter + resetContact(false); + // process data for given account, refresh if date range or contact filter is set + await loadAccount(id, (active.period.start && active.period.end) || active.contact); + } + ); + + // on change of active folder + // retrieve data again for current account selection + watch( + () => active.folder, + async (folder) => { + if (folder) { + // start processing for active folder only + await loadAccount(active.account, true); + } + } + ); + + // on change of active folder + // retrieve data again for current account selection + watch( + () => active.contact, + async (contact) => { + if (contact) { + // start processing for active contact only + await loadAccount(active.account, true); + } + } + ); + + // bootstraps the engine - call once from onMounted + const init = async () => { + // set initial tab title + document.title = "ThirdStats"; + // listen for option changes in local storage + addStorageListener(); + // get stored options + await getOptions(); + // retrieve all tags + await getTags(); + // retrieve all accounts + await getAccounts(); + // check if error occured during previous processing + const { err } = await messenger.storage.local.get("error"); + error.account = err; + // start auto-processing in intervals if activated + if (options.autoRefresh) { + setInterval(() => { + if (!isLoading.value) { + loadAccount('sum', true, true); + } + }, Number(options.autoRefreshInterval) * 60 * 1000); // convert minutes to seconds + } + }; + + return { + accounts, + folders, + tags, + active, + error, + isLoading, + progress, + preferences, + options, + display, + comparison, + singleAccount, + minDate, + maxDate, + minYear, + maxYear, + yearsList, + accountsColorGradient, + processingState, + contacts, + loadAccount, + resetFolder, + resetContact, + resetPeriod, + updatePeriod, + formatFolder, + formatPeriod, + nextYear, + previousYear, + exportJson, + tooltipAccountComparison, + init, + }; +} diff --git a/src/composables/useTagsChartData.js b/src/composables/useTagsChartData.js new file mode 100644 index 0000000..c6cf291 --- /dev/null +++ b/src/composables/useTagsChartData.js @@ -0,0 +1,36 @@ +// chart-data shaping for the "tags" section (email tags horizontal bar chart) +import { computed } from 'vue'; +import { accentColors } from '@/definitions.js'; +import { sortAndLimitObject } from '@/utils.js'; + +export function useTagsChartData({ display, tags, options, t }) { + // prepare data for email tags horizontal bar chart + const tagsChartData = computed(() => { + const r = sortAndLimitObject(display.value.tags) || {}; + const color = options.dark ? accentColors[2] : accentColors[3]; + const labels = [], colors = []; + Object.keys(r).forEach(key => { + labels.push(tags.value.find(tag => tag.key === key)?.tag || 'undefined'); + colors.push(tags.value.find(tag => tag.key === key)?.color || color); + }, this); + return { + datasets: [ + { + label: t("stats.mailsPerTag"), + data: Object.values(r), + borderColor: options.tagColors ? colors : color, + }, + ], + labels: labels, + }; + }); + // true if there is any tags data to display + const tagsChartDataExists = computed(() => { + return tagsChartData.value.datasets[0].data?.length > 0; + }); + + return { + tagsChartData, + tagsChartDataExists, + }; +} diff --git a/src/composables/useTotalChartData.js b/src/composables/useTotalChartData.js new file mode 100644 index 0000000..cf65a1a --- /dev/null +++ b/src/composables/useTotalChartData.js @@ -0,0 +1,247 @@ +// chart-data shaping for the "total" section (years/quarters/months/weeks line charts) +import { computed } from 'vue'; +import { accentColors } from '@/definitions.js'; +import { monthNames, quarterNumber, weeksBetween } from '@/utils.js'; + +export function useTotalChartData({ display, comparison, accounts, options, minYear, maxYear, minDate, maxDate, t, locale }) { + // prepare sum data for years line chart + const yearsChartData = computed(() => { + const r = display.value.yearsData.received; + const s = display.value.yearsData.sent; + let labels = [], dr = [], ds = []; + for (let y = minYear.value; y <= maxYear.value; ++y) { + labels.push(y); + dr.push(y in r ? r[y] : 0); + ds.push(y in s ? s[y] : 0); + } + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: ds, + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: dr, + borderColor: accentColors[1], + }, + ], + labels: labels, + }; + }); + // prepare comparison data for years line chart + const yearsComparedChartData = computed(() => { + // generate list of years between start and end date, e.g. [2018,2019,2020] + const labels = Array.from(Array(maxYear.value-minYear.value+1), (_, i) => i+minYear.value); + // compute dataset for each account + let datasets = []; + accounts.value.forEach(a => { + const d = comparison.value.yearsData[a.id]; + let data = []; + labels.map(y => data.push(y in d ? d[y] : 0)); + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: data, + borderColor: options.accountColors[a.id], + }); + }) + return { + datasets: datasets, + labels: labels, + }; + }); + + // prepare sum data for quarters line chart + const quartersChartData = computed(() => { + const r = display.value.quartersData.received; + const s = display.value.quartersData.sent; + let labels = [], dr = [], ds = []; + for (let y = minYear.value; y <= maxYear.value; ++y) { + for (let q = 1; q <= 4; ++q) { + // trim quarters before start date + if (y == minYear.value && q < quarterNumber(minDate.value)) continue; + // trim quarters after end date + if (y == maxYear.value && q > quarterNumber(maxDate.value)) break; + // organize labels and data + labels.push(y + " " + t("stats.abbreviations.quarter") + q); + dr.push(y in r && q in r[y] ? r[y][q] : 0); + ds.push(y in s && q in s[y] ? s[y][q] : 0); + } + } + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: ds, + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: dr, + borderColor: accentColors[1], + }, + ], + labels: labels, + }; + }); + // prepare comparison data for quarters line chart + const quartersComparedChartData = computed(() => { + let datasets = [], labels = []; + // compute dataset for each account + accounts.value.forEach((a, i) => { + const d = comparison.value.quartersData[a.id]; + let data = []; + for (let y = minYear.value; y <= maxYear.value; ++y) { + for (let q = 1; q <= 4; ++q) { + // trim quarters before start date + if (y == minYear.value && q < quarterNumber(minDate.value)) continue; + // trim quarters after end date + if (y == maxYear.value && q > quarterNumber(maxDate.value)) break; + // generate labels in first iteration + if (i == 0) labels.push(y + " " + t("stats.abbreviations.quarter") + q); + // fill all data values, default to 0 if not existing for this combination + data.push(y in d && q in d[y] ? d[y][q] : 0); + } + } + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: data, + borderColor: options.accountColors[a.id], + }); + }) + return { + datasets: datasets, + labels: labels, + }; + }); + + // prepare data for months line chart + const monthsChartData = computed(() => { + const r = display.value.monthsData.received; + const s = display.value.monthsData.sent; + let labels = [], dr = [], ds = []; + for (let y = minYear.value; y <= maxYear.value; ++y) { + for (let m = 0; m < 12; ++m) { + // trim months before start date + if (y == minYear.value && m < minDate.value.getMonth()) continue; + // trim months after end date + if (y == maxYear.value && m > maxDate.value.getMonth()) break; + // organize labels and data + labels.push(y + " " + monthNames(locale.value)[m]); + dr.push(y in r && m in r[y] ? r[y][m] : 0); + ds.push(y in s && m in s[y] ? s[y][m] : 0); + } + } + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: ds, + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: dr, + borderColor: accentColors[1], + }, + ], + labels: labels, + }; + }); + // prepare comparison data for months line chart + const monthsComparedChartData = computed(() => { + let datasets = [], labels = []; + // compute dataset for each account + accounts.value.map((a, i) => { + const d = comparison.value.monthsData[a.id]; + let data = []; + for (let y = minYear.value; y <= maxYear.value; ++y) { + for (let m = 0; m < 12; ++m) { + // trim months before start date + if (y == minYear.value && m < minDate.value.getMonth()) continue; + // trim months after end date + if (y == maxYear.value && m > maxDate.value.getMonth()) break; + // generate labels in first iteration + if (i == 0) labels.push(y + " " + monthNames(locale.value)[m]); + // fill all data values, default to 0 if not existing for this combination + data.push(y in d && m in d[y] ? d[y][m] : 0); + } + } + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: data, + borderColor: options.accountColors[a.id], + }); + }) + return { + datasets: datasets, + labels: labels, + }; + }); + + // prepare data for weeks line chart + const weeksChartData = computed(() => { + const r = display.value.weeksData.received; + const s = display.value.weeksData.sent; + let labels = [], dr = [], ds = []; + weeksBetween(minDate.value, maxDate.value).forEach(week => { + const [y, w] = week; + // organize labels and data + labels.push(y + " " + t("stats.abbreviations.calendarWeek") + w); + dr.push(y in r && w in r[y] ? r[y][w] : 0); + ds.push(y in s && w in s[y] ? s[y][w] : 0); + }) + return { + datasets: [ + { + label: t("stats.mailsSent"), + data: ds, + borderColor: accentColors[0], + }, + { + label: t("stats.mailsReceived"), + data: dr, + borderColor: accentColors[1], + }, + ], + labels: labels, + }; + }); + // prepare comparison data for weeks line chart + const weeksComparedChartData = computed(() => { + let datasets = [], labels = []; + // compute dataset for each account + accounts.value.forEach((a, i) => { + const d = comparison.value.weeksData[a.id]; + let data = []; + weeksBetween(minDate.value, maxDate.value).forEach(week => { + const [y, w] = week; + // generate labels in first iteration + if (i == 0) labels.push(y + " " + t("stats.abbreviations.calendarWeek") + w); + // fill all data values, default to 0 if not existing for this combination + data.push(y in d && w in d[y] ? d[y][w] : 0); + }); + datasets.push({ + label: t("popup.nMessages", 2) + " - " + a.name, + data: data, + borderColor: options.accountColors[a.id], + }); + }); + return { + datasets: datasets, + labels: labels, + }; + }); + + return { + yearsChartData, + yearsComparedChartData, + quartersChartData, + quartersComparedChartData, + monthsChartData, + monthsComparedChartData, + weeksChartData, + weeksComparedChartData, + }; +} diff --git a/src/composables/useTwodimChartData.js b/src/composables/useTwodimChartData.js new file mode 100644 index 0000000..bffaac4 --- /dev/null +++ b/src/composables/useTwodimChartData.js @@ -0,0 +1,30 @@ +// chart-data shaping for the "twodim" section (weekday/hour matrix chart) +import { computed } from 'vue'; + +export function useTwodimChartData({ display, t }) { + // prepare data for weekday/hour matrix charts + const weekdayPerHourChartData = computed(() => { + let rd = Object.values(display.value.weekdayPerHourData.received); + let sd = Object.values(display.value.weekdayPerHourData.sent); + let initDate = new Date(1970,0,4); + let r = rd.reduce((p,c,day) => [...p,...c.map((n,hour) => { + let d = new Date(initDate.setDate(4+day)); + d = new Date(d.setHours(hour, 0, 0)); + return [d.toISOString(), n]; + })], []); + let s = sd.reduce((p,c,day) => [...p,...c.map((n,hour) => { + let d = new Date(initDate.setDate(4+day)); + d = new Date(d.setHours(hour, 0, 0)); + return [d.toISOString(), n]; + })], []); + // TODO: handle options.startOfWeek + return { + received: { label: t("stats.mailsReceived"), data: r }, + sent: { label: t("stats.mailsSent"), data: s }, + }; + }); + + return { + weekdayPerHourChartData, + }; +} diff --git a/src/parts/SectionTabHeader.vue b/src/parts/SectionTabHeader.vue new file mode 100644 index 0000000..74bb416 --- /dev/null +++ b/src/parts/SectionTabHeader.vue @@ -0,0 +1,118 @@ + + + diff --git a/src/sections/ActivitySection.vue b/src/sections/ActivitySection.vue new file mode 100644 index 0000000..efbb4b1 --- /dev/null +++ b/src/sections/ActivitySection.vue @@ -0,0 +1,84 @@ + + + diff --git a/src/sections/FoldersSection.vue b/src/sections/FoldersSection.vue new file mode 100644 index 0000000..8c5aa71 --- /dev/null +++ b/src/sections/FoldersSection.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/sections/LeaderSection.vue b/src/sections/LeaderSection.vue new file mode 100644 index 0000000..3d6ada0 --- /dev/null +++ b/src/sections/LeaderSection.vue @@ -0,0 +1,103 @@ + + + diff --git a/src/sections/OnedimSection.vue b/src/sections/OnedimSection.vue new file mode 100644 index 0000000..ac077c3 --- /dev/null +++ b/src/sections/OnedimSection.vue @@ -0,0 +1,96 @@ + + + diff --git a/src/sections/TagsSection.vue b/src/sections/TagsSection.vue new file mode 100644 index 0000000..7cd3449 --- /dev/null +++ b/src/sections/TagsSection.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/sections/TotalSection.vue b/src/sections/TotalSection.vue new file mode 100644 index 0000000..cffb656 --- /dev/null +++ b/src/sections/TotalSection.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/sections/TwodimSection.vue b/src/sections/TwodimSection.vue new file mode 100644 index 0000000..3f4a59e --- /dev/null +++ b/src/sections/TwodimSection.vue @@ -0,0 +1,54 @@ + + + From b4a74a7566ec6cd2c6e1077a09eb156851fec0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:12:48 +0200 Subject: [PATCH 03/27] =?UTF-8?q?=F0=9F=94=A8=20Fix=20a=20timezone=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/statsAggregation.js | 3 ++- src/composables/useActivityChartData.js | 3 ++- src/utils.js | 10 ++++++++++ test/composables/statsAggregation.spec.js | 8 ++++++++ test/composables/useActivityChartData.spec.js | 12 ++++++++++++ test/utils.spec.js | 14 ++++++++++++++ 6 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/composables/statsAggregation.js b/src/composables/statsAggregation.js index 3c10944..b2aa518 100644 --- a/src/composables/statsAggregation.js +++ b/src/composables/statsAggregation.js @@ -4,6 +4,7 @@ import { contactInvolved, extractEmailAddress, isSelfMessage, + localDateKey, NumberedObject, quarterNumber, sortAndLimitObject, @@ -175,7 +176,7 @@ const analyzeMessage = (data, message, identityList, context) => { // month data.monthData[type][mo]++; // dates - const iso = message.date.toISOString().substr(0, 10); + const iso = localDateKey(message.date); if (!(iso in data.dateData[type])) { data.dateData[type][iso] = 1; } else { diff --git a/src/composables/useActivityChartData.js b/src/composables/useActivityChartData.js index 8df5aaf..bbaac06 100644 --- a/src/composables/useActivityChartData.js +++ b/src/composables/useActivityChartData.js @@ -1,5 +1,6 @@ // chart-data shaping for the "activity" section (single-date calendar matrix chart) import { computed } from 'vue'; +import { localDateKey } from '@/utils.js'; export function useActivityChartData({ display, activityPrefs, t }) { // prepare data for single date matrix charts @@ -20,7 +21,7 @@ export function useActivityChartData({ display, activityPrefs, t }) { // Generate full year data with 0 for empty days let r = [], s = []; for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { - const key = d.toISOString().slice(0, 10); + const key = localDateKey(d); r.push([key, receivedMap[key] || 0]); s.push([key, sentMap[key] || 0]); } diff --git a/src/utils.js b/src/utils.js index 9d070de..ec308f3 100644 --- a/src/utils.js +++ b/src/utils.js @@ -227,6 +227,15 @@ const yyyymmdd = (d) => { return d.toISOString().replace(/-/g, '').slice(0,8); }; +// format given date as YYYY-MM-DD using its local calendar date +// (unlike toISOString(), this doesn't shift the date across timezones) +const localDateKey = (d) => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +}; + // return day of week in iso format const isoDayOfWeek = d => { let wd = d.getDay(); // 0..6, from sunday @@ -335,6 +344,7 @@ export { formatDate, isoDayOfWeek, isSelfMessage, + localDateKey, localStartOfWeek, monthNames, NumberedObject, diff --git a/test/composables/statsAggregation.spec.js b/test/composables/statsAggregation.spec.js index 467b07b..2a7be5c 100644 --- a/test/composables/statsAggregation.spec.js +++ b/test/composables/statsAggregation.spec.js @@ -102,6 +102,14 @@ describe('analyzeMessage', () => { expect(data.folders.received.Archive).toBe(1); }); + it('buckets dateData by local calendar day, not the UTC-shifted day (timezone bug regression)', () => { + const data = createStatsData(); + // midnight local time - a UTC-based key would roll this back a day in any + // timezone ahead of UTC, splitting a single local day across two buckets + analyzeMessage(data, baseMessage({ date: new Date(2023, 5, 15, 0, 0, 0) }), ['me@example.com'], noopContext); + expect(data.dateData.received['2023-06-15']).toBe(1); + }); + it('buckets a Jan-1 date with ISO week 53 into the previous year (week-boundary edge case)', () => { const data = createStatsData(); // Jan 1, 2010 has ISO week number 53 and belongs to week 53 of 2009 diff --git a/test/composables/useActivityChartData.spec.js b/test/composables/useActivityChartData.spec.js index b03d146..8376148 100644 --- a/test/composables/useActivityChartData.spec.js +++ b/test/composables/useActivityChartData.spec.js @@ -36,4 +36,16 @@ describe('useActivityChartData', () => { activityPrefs.year = 2024; expect(dateChartData.value.received.data.find(([key]) => key === '2024-01-01')[1]).toBe(7); }); + + it('generates exactly the local calendar-date keys for the year, with no UTC day-shift (timezone bug regression)', () => { + const display = ref({ dateData: { received: {}, sent: {} } }); + const activityPrefs = reactive({ year: 2023 }); + const { dateChartData } = useActivityChartData({ display, activityPrefs, t: stubT }); + const keys = dateChartData.value.received.data.map(([key]) => key); + // the first and last day of the year must appear exactly as their own calendar date, + // not shifted to the adjacent year as a UTC-based toISOString() key would + expect(keys[0]).toBe('2023-01-01'); + expect(keys[keys.length - 1]).toBe('2023-12-31'); + expect(keys.every(k => k.startsWith('2023-'))).toBe(true); + }); }); diff --git a/test/utils.spec.js b/test/utils.spec.js index 8aab26b..ab28a8a 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { contactInvolved, isSelfMessage, + localDateKey, quarterNumber, sumObjects, sumObjectsObjects, @@ -85,6 +86,19 @@ describe('contactInvolved', () => { }); }); +describe('localDateKey', () => { + it('formats a local date as YYYY-MM-DD without shifting to UTC', () => { + // midnight local time - a UTC-based toISOString() would roll this back + // a day in any timezone ahead of UTC (the bug this helper fixes) + expect(localDateKey(new Date(2023, 5, 15, 0, 0, 0))).toBe('2023-06-15'); + expect(localDateKey(new Date(2023, 5, 15, 23, 59, 59))).toBe('2023-06-15'); + }); + + it('pads single-digit months and days', () => { + expect(localDateKey(new Date(2023, 0, 5))).toBe('2023-01-05'); + }); +}); + describe('isSelfMessage', () => { it('is true when author and all recipients match the given identities', () => { const message = { From 8cd2ebebecd046d70fa31301bc528ba988cc6d6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:15:41 +0200 Subject: [PATCH 04/27] =?UTF-8?q?=F0=9F=94=A8=20Fix=20leader=20tab=20borde?= =?UTF-8?q?r=20color?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sections/LeaderSection.vue | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/sections/LeaderSection.vue b/src/sections/LeaderSection.vue index 3d6ada0..b37cfce 100644 --- a/src/sections/LeaderSection.vue +++ b/src/sections/LeaderSection.vue @@ -79,13 +79,10 @@ const { t } = useI18n(); // tab navigation local to this section const tabLeader = ref(tabsLeader.contactsReceived); -// NOTE: preserves the original (always-false) comparison - `key` is the tab's string -// property name while tabsLeader.* are numeric ids, so none of these classes ever apply. -// Left as-is; not in scope for this refactor. const leaderTabColorClass = (key) => ({ - 'border-bottom-accent1': key === tabsLeader.contactsSent, - 'border-bottom-accent2': key === tabsLeader.contactsReceived, - 'border-bottom-accent3': key === tabsLeader.contactsJunk, + 'border-bottom-accent1': key === 'contactsSent', + 'border-bottom-accent2': key === 'contactsReceived', + 'border-bottom-accent3': key === 'contactsJunk', }); const { From d53c549f2cc1abd859af5fb0df388591d4faa243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:20:10 +0200 Subject: [PATCH 05/27] =?UTF-8?q?=E2=9E=95=20Add=20test=20to=20cover=20mor?= =?UTF-8?q?e=20of=20utils.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/utils.spec.js | 325 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 324 insertions(+), 1 deletion(-) diff --git a/test/utils.spec.js b/test/utils.spec.js index ab28a8a..9967bff 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -1,15 +1,40 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { + arrayContainsArray, contactInvolved, + extractEmailAddress, + formatBytes, + formatDate, + isoDayOfWeek, isSelfMessage, localDateKey, + localStartOfWeek, + monthNames, + NumberedObject, + oneDigit, + openTab, + pluralPolish, + pluralUkrainian, quarterNumber, + queryMessages, + setTheme, + sortAndLimitObject, + sortAndLimitObjectToArray, sumObjects, + sumObjectsArrays, sumObjectsObjects, + traverseAccount, + twoDigit, + weekdayNames, weekNumber, weeksBetween, + yyyymmdd, } from '@/utils.js'; +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('weekNumber', () => { it('assigns Dec 31 2018 to week 1 of 2019', () => { expect(weekNumber(new Date(2018, 11, 31))).toBe(1); @@ -120,3 +145,301 @@ describe('isSelfMessage', () => { expect(isSelfMessage(message, ['me@example.com'])).toBe(false); }); }); + +describe('arrayContainsArray', () => { + it('is true when every target element is present', () => { + expect(arrayContainsArray(['a', 'b', 'c'], ['a', 'b'])).toBe(true); + }); + + it('is false when a target element is missing', () => { + expect(arrayContainsArray(['a', 'b'], ['a', 'c'])).toBe(false); + }); + + it('is true for an empty target array (vacuous truth)', () => { + expect(arrayContainsArray(['a'], [])).toBe(true); + }); +}); + +describe('extractEmailAddress', () => { + it('extracts and lowercases the address from a "Name " string', () => { + expect(extractEmailAddress('Name ')).toBe('foo@example.com'); + }); + + it('extracts a bare email address', () => { + expect(extractEmailAddress('foo@example.com')).toBe('foo@example.com'); + }); + + it('returns an empty string when no email address is found', () => { + expect(extractEmailAddress('no email here')).toBe(''); + }); +}); + +describe('formatBytes', () => { + it('formats 0 bytes specially', () => { + expect(formatBytes(0)).toBe('0 Bytes'); + }); + + it('converts to KB and MB using 1024 as the base', () => { + expect(formatBytes(1024)).toBe('1 KB'); + expect(formatBytes(1048576)).toBe('1 MB'); + }); + + it('respects the decimals parameter', () => { + expect(formatBytes(1536, 1)).toBe('1.5 KB'); + }); +}); + +describe('formatDate', () => { + it('returns an empty string for a falsy date', () => { + expect(formatDate(null)).toBe(''); + expect(formatDate(undefined)).toBe(''); + }); + + it('formats a real date in the given locale', () => { + const result = formatDate(new Date(2023, 5, 15, 10, 30), 'en'); + expect(result).toContain('2023'); + expect(result).toContain('June'); + expect(result).toContain('15'); + }); +}); + +describe('isoDayOfWeek', () => { + it('maps Sunday to 7 and Monday to 1 (ISO 8601 numbering)', () => { + // Jan 1 2023 is a Sunday, Jan 2 2023 is a Monday + expect(isoDayOfWeek(new Date(2023, 0, 1))).toBe('7'); + expect(isoDayOfWeek(new Date(2023, 0, 2))).toBe('1'); + }); +}); + +describe('localStartOfWeek', () => { + it('always resolves to the Sunday of the current week (getDay() 0)', () => { + // by construction (today's date minus today's weekday number), this can + // only ever land on a Sunday, regardless of which day the test runs on + expect(localStartOfWeek()).toBe(0); + }); +}); + +describe('monthNames', () => { + it('returns 12 short, localized month names starting with January', () => { + const names = monthNames('en'); + expect(names).toHaveLength(12); + expect(names[0]).toBe('Jan'); + expect(names[11]).toBe('Dec'); + }); +}); + +describe('weekdayNames', () => { + it('returns 7 short, localized weekday names starting with Sunday', () => { + const names = weekdayNames('en'); + expect(names).toHaveLength(7); + expect(names[0]).toBe('Sun'); + expect(names[6]).toBe('Sat'); + }); +}); + +describe('NumberedObject', () => { + it('creates a flat zero-initialized object when no second dimension is given', () => { + expect({ ...new NumberedObject(3) }).toEqual({ 0: 0, 1: 0, 2: 0 }); + }); + + it('creates nested zero-filled arrays when a second dimension is given', () => { + expect({ ...new NumberedObject(2, 3) }).toEqual({ 0: [0, 0, 0], 1: [0, 0, 0] }); + }); +}); + +describe('oneDigit / twoDigit', () => { + it('rounds to one or two decimal places respectively', () => { + expect(oneDigit(3.14159)).toBe('3.1'); + expect(twoDigit(3.14159)).toBe('3.14'); + }); +}); + +describe('pluralPolish / pluralUkrainian', () => { + it('returns form 1 only for the number 1', () => { + expect(pluralPolish(1)).toBe(1); + }); + + it('never returns form 2 - the "few" branch is unreachable ' + + '(its `n < 12 && n > 14` guard can never be true)', () => { + expect(pluralPolish(2)).toBe(0); + expect(pluralPolish(22)).toBe(0); + }); + + it('pluralUkrainian returns form 1 for numbers ending in 1 (except 11), else 0', () => { + expect(pluralUkrainian(1)).toBe(1); + expect(pluralUkrainian(21)).toBe(1); + expect(pluralUkrainian(11)).toBe(0); + expect(pluralUkrainian(2)).toBe(0); + }); +}); + +describe('sortAndLimitObject', () => { + it('sorts entries by value descending', () => { + expect(sortAndLimitObject({ a: 1, b: 5, c: 3 })).toEqual({ b: 5, c: 3, a: 1 }); + }); + + it('limits to the given number of top entries', () => { + expect(sortAndLimitObject({ a: 1, b: 5, c: 3 }, 2)).toEqual({ b: 5, c: 3 }); + }); +}); + +describe('sortAndLimitObjectToArray', () => { + it('returns sorted [key, value] pairs, limited to the given count', () => { + expect(sortAndLimitObjectToArray({ a: 1, b: 5, c: 3 }, 2)).toEqual([['b', 5], ['c', 3]]); + }); +}); + +describe('sumObjectsArrays', () => { + it('sums arrays element-wise per key across objects', () => { + expect(sumObjectsArrays([{ a: [1, 2] }, { a: [3, 4], b: [1] }])) + .toEqual({ a: [4, 6], b: [1] }); + }); +}); + +describe('yyyymmdd', () => { + it('formats a date as YYYYMMDD in UTC', () => { + expect(yyyymmdd(new Date('2023-06-15T12:00:00Z'))).toBe('20230615'); + }); +}); + +describe('setTheme', () => { + const createFakeElement = () => { + const classes = new Set(); + return { + classList: { + add: (...names) => names.forEach(n => classes.add(n)), + remove: (...names) => names.forEach(n => classes.delete(n)), + contains: (n) => classes.has(n), + }, + }; + }; + + it('applies the dark classes and returns true for theme "dark"', () => { + const el = createFakeElement(); + expect(setTheme('dark', el)).toBe(true); + expect(el.classList.contains('dark')).toBe(true); + expect(el.classList.contains('light')).toBe(false); + }); + + it('applies the light classes and returns false for theme "light"', () => { + const el = createFakeElement(); + expect(setTheme('light', el)).toBe(false); + expect(el.classList.contains('light')).toBe(true); + expect(el.classList.contains('dark')).toBe(false); + }); + + it('follows the OS preference for theme "system"', () => { + vi.stubGlobal('window', { matchMedia: () => ({ matches: true }) }); + const el = createFakeElement(); + expect(setTheme('system', el)).toBe(true); + expect(el.classList.contains('dark')).toBe(true); + + vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) }); + const el2 = createFakeElement(); + expect(setTheme('system', el2)).toBe(false); + expect(el2.classList.contains('light')).toBe(true); + }); + + it('uses custom dark/light class names when given', () => { + const el = createFakeElement(); + setTheme('dark', el, ['custom-dark'], ['custom-light']); + expect(el.classList.contains('custom-dark')).toBe(true); + }); +}); + +describe('openTab', () => { + it('opens the given url with no query param when none is given', () => { + const create = vi.fn(); + vi.stubGlobal('messenger', { tabs: { create } }); + openTab('index.html'); + expect(create).toHaveBeenCalledWith({ active: true, url: 'index.html' }); + }); + + it('appends the "s" query param when given', () => { + const create = vi.fn(); + vi.stubGlobal('messenger', { tabs: { create } }); + openTab('index.html', 'abc'); + expect(create).toHaveBeenCalledWith({ active: true, url: 'index.html?s=abc' }); + }); +}); + +describe('queryMessages', () => { + const page = (messages, id) => ({ messages, id }); + + it('yields all messages from a single page', async () => { + const list = vi.fn().mockResolvedValue(page([{ id: 1 }, { id: 2 }], null)); + vi.stubGlobal('messenger', { messages: { list } }); + const result = []; + for await (const m of queryMessages('folder-1', null, null)) result.push(m); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + expect(list).toHaveBeenCalledWith('folder-1'); + }); + + it('paginates across multiple pages via continueList', async () => { + const list = vi.fn().mockResolvedValue(page([{ id: 1 }], 'page-2')); + const continueList = vi.fn().mockResolvedValue(page([{ id: 2 }], null)); + vi.stubGlobal('messenger', { messages: { list, continueList } }); + const result = []; + for await (const m of queryMessages('folder-1', null, null)) result.push(m); + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + expect(continueList).toHaveBeenCalledWith('page-2'); + }); + + it('filters out messages outside the date range when both bounds are given', async () => { + const inRange = { id: 1, date: new Date('2023-06-15').getTime() }; + const tooEarly = { id: 2, date: new Date('2022-01-01').getTime() }; + const tooLate = { id: 3, date: new Date('2024-01-01').getTime() }; + const list = vi.fn().mockResolvedValue(page([inRange, tooEarly, tooLate], null)); + vi.stubGlobal('messenger', { messages: { list } }); + const result = []; + for await (const m of queryMessages('folder-1', '2023-01-01', '2023-12-31')) result.push(m); + expect(result).toEqual([inRange]); + }); + + it('does not filter by date when only one bound is given', async () => { + const messages = [{ id: 1, date: new Date('2010-01-01').getTime() }]; + const list = vi.fn().mockResolvedValue(page(messages, null)); + vi.stubGlobal('messenger', { messages: { list } }); + const result = []; + for await (const m of queryMessages('folder-1', '2023-01-01', null)) result.push(m); + expect(result).toEqual(messages); + }); + + it('records an error and stops iterating when the underlying API throws', async () => { + const list = vi.fn().mockRejectedValue(new Error('boom')); + const set = vi.fn().mockResolvedValue(); + vi.stubGlobal('messenger', { messages: { list }, storage: { local: { set } } }); + vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = []; + for await (const m of queryMessages('folder-1', null, null)) result.push(m); + expect(result).toEqual([]); + expect(set).toHaveBeenCalledWith({ error: true }); + }); +}); + +describe('traverseAccount', () => { + it('flattens nested subfolders, excluding root folders', async () => { + const tree = { + isRoot: true, + subFolders: [ + { isRoot: false, path: '/Inbox', subFolders: [ + { isRoot: false, path: '/Inbox/Sub', subFolders: [] }, + ] }, + { isRoot: false, path: '/Sent', subFolders: [] }, + ], + }; + const get = vi.fn().mockResolvedValue(tree); + vi.stubGlobal('messenger', { folders: { get } }); + const account = { rootFolder: { id: 'root-1' } }; + const result = await traverseAccount(account); + expect(result.map(f => f.path)).toEqual(['/Inbox', '/Inbox/Sub', '/Sent']); + expect(get).toHaveBeenCalledWith('root-1', true); + }); + + it('returns an empty array when the account has no subfolders', async () => { + const get = vi.fn().mockResolvedValue({ isRoot: true, subFolders: [] }); + vi.stubGlobal('messenger', { folders: { get } }); + const result = await traverseAccount({ rootFolder: { id: 'root-1' } }); + expect(result).toEqual([]); + }); +}); From 4f564b394d2b09318feacfb9d5a6ad1df3968ab7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:22:55 +0200 Subject: [PATCH 06/27] =?UTF-8?q?=F0=9F=94=A8=20Fix=20plural=20calculation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils.js | 4 ++-- test/utils.spec.js | 25 ++++++++++++++++++++----- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/utils.js b/src/utils.js index ec308f3..2c279d2 100644 --- a/src/utils.js +++ b/src/utils.js @@ -295,13 +295,13 @@ const formatBytes = (bytes, decimals=2) => { const pluralPolish = (n) => { if (n === 1) return 1; const lastDigit = n % 10; - if (([2, 3, 4].indexOf(lastDigit) >= 0) && (n < 12) && (n > 14)) return 2; + if (([2, 3, 4].indexOf(lastDigit) >= 0) && (n < 12 || n > 14)) return 2; return 0; }; const pluralUkrainian = (n) => { const lastDigit = n % 10; if (lastDigit === 1 && n !== 11) return 1; - if (([2, 3, 4].indexOf(lastDigit) >= 0) && (n < 12) && (n > 14)) return 2; + if (([2, 3, 4].indexOf(lastDigit) >= 0) && (n < 12 || n > 14)) return 2; return 0; }; diff --git a/test/utils.spec.js b/test/utils.spec.js index 9967bff..5fa1b97 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -259,17 +259,32 @@ describe('pluralPolish / pluralUkrainian', () => { expect(pluralPolish(1)).toBe(1); }); - it('never returns form 2 - the "few" branch is unreachable ' + - '(its `n < 12 && n > 14` guard can never be true)', () => { - expect(pluralPolish(2)).toBe(0); - expect(pluralPolish(22)).toBe(0); + it('returns form 2 ("few") for numbers ending in 2-4, except the 12-14 teens', () => { + expect(pluralPolish(2)).toBe(2); + expect(pluralPolish(3)).toBe(2); + expect(pluralPolish(4)).toBe(2); + expect(pluralPolish(22)).toBe(2); + expect(pluralPolish(12)).toBe(0); + expect(pluralPolish(13)).toBe(0); + expect(pluralPolish(14)).toBe(0); + }); + + it('returns form 0 ("many") for everything else', () => { + expect(pluralPolish(5)).toBe(0); + expect(pluralPolish(11)).toBe(0); + expect(pluralPolish(25)).toBe(0); }); it('pluralUkrainian returns form 1 for numbers ending in 1 (except 11), else 0', () => { expect(pluralUkrainian(1)).toBe(1); expect(pluralUkrainian(21)).toBe(1); expect(pluralUkrainian(11)).toBe(0); - expect(pluralUkrainian(2)).toBe(0); + }); + + it('pluralUkrainian returns form 2 ("few") for numbers ending in 2-4, except the 12-14 teens', () => { + expect(pluralUkrainian(2)).toBe(2); + expect(pluralUkrainian(22)).toBe(2); + expect(pluralUkrainian(12)).toBe(0); }); }); From 24c0ee257c36c914f4cecb95ef83d8ccd9191d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:25:10 +0200 Subject: [PATCH 07/27] =?UTF-8?q?=F0=9F=93=A6=EF=B8=8F=20Package=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 16 +- pnpm-lock.yaml | 648 ++++++++++++++++++++++++------------------------- 2 files changed, 331 insertions(+), 333 deletions(-) diff --git a/package.json b/package.json index 22ae9d0..13c8e4f 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,18 @@ }, "packageManager": "pnpm@11.9.0", "dependencies": { - "chart.js": "^4.0.1", + "chart.js": "^4.5.1", "chartjs-adapter-date-fns": "^3.0.0", - "chartjs-chart-matrix": "^3.0.0", + "chartjs-chart-matrix": "^3.0.5", "date-fns": "^4.4.0", - "vue": "^3.2.38", - "vue-i18n": "^11.1.10" + "vue": "^3.5.40", + "vue-i18n": "^11.4.7" }, "devDependencies": { - "@localazy/cli": "^2.0.5", - "@vitejs/plugin-vue": "^6.0.0", - "oxlint": "^1.9.0", - "vite": "^8.0.16", + "@localazy/cli": "^2.0.11", + "@vitejs/plugin-vue": "^6.0.8", + "oxlint": "^1.74.0", + "vite": "^8.1.5", "vitest": "^4.1.10" }, "browserslist": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e76cb5..e69398d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,39 +9,39 @@ importers: .: dependencies: chart.js: - specifier: ^4.0.1 + specifier: ^4.5.1 version: 4.5.1 chartjs-adapter-date-fns: specifier: ^3.0.0 version: 3.0.0(chart.js@4.5.1)(date-fns@4.4.0) chartjs-chart-matrix: - specifier: ^3.0.0 - version: 3.0.4(chart.js@4.5.1) + specifier: ^3.0.5 + version: 3.0.5(chart.js@4.5.1) date-fns: specifier: ^4.4.0 version: 4.4.0 vue: - specifier: ^3.2.38 - version: 3.5.39 + specifier: ^3.5.40 + version: 3.5.40 vue-i18n: - specifier: ^11.1.10 - version: 11.4.6(vue@3.5.39) + specifier: ^11.4.7 + version: 11.4.7(vue@3.5.40) devDependencies: '@localazy/cli': - specifier: ^2.0.5 + specifier: ^2.0.11 version: 2.0.11 '@vitejs/plugin-vue': - specifier: ^6.0.0 - version: 6.0.7(vite@8.1.3)(vue@3.5.39) + specifier: ^6.0.8 + version: 6.0.8(vite@8.1.5)(vue@3.5.40) oxlint: - specifier: ^1.9.0 - version: 1.72.0 + specifier: ^1.74.0 + version: 1.74.0 vite: - specifier: ^8.0.16 - version: 8.1.3 + specifier: ^8.1.5 + version: 8.1.5 vitest: specifier: ^4.1.10 - version: 4.1.10(vite@8.1.3) + version: 4.1.10(vite@8.1.5) packages: @@ -71,20 +71,20 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@intlify/core-base@11.4.6': - resolution: {integrity: sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==} + '@intlify/core-base@11.4.7': + resolution: {integrity: sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==} engines: {node: '>= 22'} - '@intlify/devtools-types@11.4.6': - resolution: {integrity: sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==} + '@intlify/devtools-types@11.4.7': + resolution: {integrity: sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==} engines: {node: '>= 22'} - '@intlify/message-compiler@11.4.6': - resolution: {integrity: sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==} + '@intlify/message-compiler@11.4.7': + resolution: {integrity: sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==} engines: {node: '>= 22'} - '@intlify/shared@11.4.6': - resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==} + '@intlify/shared@11.4.7': + resolution: {integrity: sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==} engines: {node: '>= 22'} '@jridgewell/sourcemap-codec@1.5.5': @@ -104,222 +104,222 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@oxc-project/types@0.138.0': - resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@oxlint/binding-android-arm-eabi@1.72.0': - resolution: {integrity: sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==} + '@oxlint/binding-android-arm-eabi@1.74.0': + resolution: {integrity: sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.72.0': - resolution: {integrity: sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==} + '@oxlint/binding-android-arm64@1.74.0': + resolution: {integrity: sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.72.0': - resolution: {integrity: sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==} + '@oxlint/binding-darwin-arm64@1.74.0': + resolution: {integrity: sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.72.0': - resolution: {integrity: sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==} + '@oxlint/binding-darwin-x64@1.74.0': + resolution: {integrity: sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.72.0': - resolution: {integrity: sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==} + '@oxlint/binding-freebsd-x64@1.74.0': + resolution: {integrity: sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': - resolution: {integrity: sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==} + '@oxlint/binding-linux-arm-gnueabihf@1.74.0': + resolution: {integrity: sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.72.0': - resolution: {integrity: sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==} + '@oxlint/binding-linux-arm-musleabihf@1.74.0': + resolution: {integrity: sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.72.0': - resolution: {integrity: sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==} + '@oxlint/binding-linux-arm64-gnu@1.74.0': + resolution: {integrity: sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-arm64-musl@1.72.0': - resolution: {integrity: sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==} + '@oxlint/binding-linux-arm64-musl@1.74.0': + resolution: {integrity: sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxlint/binding-linux-ppc64-gnu@1.72.0': - resolution: {integrity: sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==} + '@oxlint/binding-linux-ppc64-gnu@1.74.0': + resolution: {integrity: sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-gnu@1.72.0': - resolution: {integrity: sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==} + '@oxlint/binding-linux-riscv64-gnu@1.74.0': + resolution: {integrity: sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-riscv64-musl@1.72.0': - resolution: {integrity: sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==} + '@oxlint/binding-linux-riscv64-musl@1.74.0': + resolution: {integrity: sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxlint/binding-linux-s390x-gnu@1.72.0': - resolution: {integrity: sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==} + '@oxlint/binding-linux-s390x-gnu@1.74.0': + resolution: {integrity: sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-gnu@1.72.0': - resolution: {integrity: sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==} + '@oxlint/binding-linux-x64-gnu@1.74.0': + resolution: {integrity: sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxlint/binding-linux-x64-musl@1.72.0': - resolution: {integrity: sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==} + '@oxlint/binding-linux-x64-musl@1.74.0': + resolution: {integrity: sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxlint/binding-openharmony-arm64@1.72.0': - resolution: {integrity: sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==} + '@oxlint/binding-openharmony-arm64@1.74.0': + resolution: {integrity: sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.72.0': - resolution: {integrity: sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==} + '@oxlint/binding-win32-arm64-msvc@1.74.0': + resolution: {integrity: sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.72.0': - resolution: {integrity: sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==} + '@oxlint/binding-win32-ia32-msvc@1.74.0': + resolution: {integrity: sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.72.0': - resolution: {integrity: sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==} + '@oxlint/binding-win32-x64-msvc@1.74.0': + resolution: {integrity: sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/binding-android-arm64@1.1.4': - resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.4': - resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.4': - resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.4': - resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': - resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.4': - resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.4': - resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.4': - resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.4': - resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.4': - resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.4': - resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.4': - resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.4': - resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.1.4': - resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.4': - resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -348,8 +348,8 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@vitejs/plugin-vue@6.0.7': - resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + '@vitejs/plugin-vue@6.0.8': + resolution: {integrity: sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -384,37 +384,35 @@ packages: '@vitest/utils@4.1.10': resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} - '@vue/compiler-core@3.5.39': - resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} - '@vue/compiler-dom@3.5.39': - resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} - '@vue/compiler-sfc@3.5.39': - resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} - '@vue/compiler-ssr@3.5.39': - resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} - '@vue/reactivity@3.5.39': - resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + '@vue/reactivity@3.5.40': + resolution: {integrity: sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==} - '@vue/runtime-core@3.5.39': - resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + '@vue/runtime-core@3.5.40': + resolution: {integrity: sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==} - '@vue/runtime-dom@3.5.39': - resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + '@vue/runtime-dom@3.5.40': + resolution: {integrity: sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==} - '@vue/server-renderer@3.5.39': - resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} - peerDependencies: - vue: 3.5.39 + '@vue/server-renderer@3.5.40': + resolution: {integrity: sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==} - '@vue/shared@3.5.39': - resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} @@ -434,8 +432,8 @@ packages: chart.js: '>=2.8.0' date-fns: '>=2.0.0' - chartjs-chart-matrix@3.0.4: - resolution: {integrity: sha512-thkswkjZEtmZph+JUU65GjSxfAIKkLedVAhKz6umIs8zO+y+gHIuzovEtS1FqRXzubMXCX2RcglbQjHsL8g0Xw==} + chartjs-chart-matrix@3.0.5: + resolution: {integrity: sha512-hVqXBEtLoYJk+iA9NajA/ArG7HlPZl3XXNsRDY3c5sCuqSrM6uTythSJh1jVR7UNVApdY9PStb84xSEjEceKaw==} peerDependencies: chart.js: '>=3.0.0' @@ -483,85 +481,85 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -569,12 +567,12 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} - oxlint@1.72.0: - resolution: {integrity: sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==} + oxlint@1.74.0: + resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.22.1' + oxlint-tsgolint: '>=0.24.0' vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: @@ -588,16 +586,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss@8.5.16: - resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} - rolldown@1.1.4: - resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -632,8 +630,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - vite@8.1.3: - resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -716,14 +714,14 @@ packages: jsdom: optional: true - vue-i18n@11.4.6: - resolution: {integrity: sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==} + vue-i18n@11.4.7: + resolution: {integrity: sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==} engines: {node: '>= 22'} peerDependencies: vue: ^3.0.0 - vue@3.5.39: - resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + vue@3.5.40: + resolution: {integrity: sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -778,23 +776,23 @@ snapshots: tslib: 2.8.1 optional: true - '@intlify/core-base@11.4.6': + '@intlify/core-base@11.4.7': dependencies: - '@intlify/devtools-types': 11.4.6 - '@intlify/message-compiler': 11.4.6 - '@intlify/shared': 11.4.6 + '@intlify/devtools-types': 11.4.7 + '@intlify/message-compiler': 11.4.7 + '@intlify/shared': 11.4.7 - '@intlify/devtools-types@11.4.6': + '@intlify/devtools-types@11.4.7': dependencies: - '@intlify/core-base': 11.4.6 - '@intlify/shared': 11.4.6 + '@intlify/core-base': 11.4.7 + '@intlify/shared': 11.4.7 - '@intlify/message-compiler@11.4.6': + '@intlify/message-compiler@11.4.7': dependencies: - '@intlify/shared': 11.4.6 + '@intlify/shared': 11.4.7 source-map-js: 1.2.1 - '@intlify/shared@11.4.6': {} + '@intlify/shared@11.4.7': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -815,112 +813,112 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@oxc-project/types@0.138.0': {} + '@oxc-project/types@0.139.0': {} - '@oxlint/binding-android-arm-eabi@1.72.0': + '@oxlint/binding-android-arm-eabi@1.74.0': optional: true - '@oxlint/binding-android-arm64@1.72.0': + '@oxlint/binding-android-arm64@1.74.0': optional: true - '@oxlint/binding-darwin-arm64@1.72.0': + '@oxlint/binding-darwin-arm64@1.74.0': optional: true - '@oxlint/binding-darwin-x64@1.72.0': + '@oxlint/binding-darwin-x64@1.74.0': optional: true - '@oxlint/binding-freebsd-x64@1.72.0': + '@oxlint/binding-freebsd-x64@1.74.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.72.0': + '@oxlint/binding-linux-arm-gnueabihf@1.74.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.72.0': + '@oxlint/binding-linux-arm-musleabihf@1.74.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.72.0': + '@oxlint/binding-linux-arm64-gnu@1.74.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.72.0': + '@oxlint/binding-linux-arm64-musl@1.74.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.72.0': + '@oxlint/binding-linux-ppc64-gnu@1.74.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.72.0': + '@oxlint/binding-linux-riscv64-gnu@1.74.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.72.0': + '@oxlint/binding-linux-riscv64-musl@1.74.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.72.0': + '@oxlint/binding-linux-s390x-gnu@1.74.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.72.0': + '@oxlint/binding-linux-x64-gnu@1.74.0': optional: true - '@oxlint/binding-linux-x64-musl@1.72.0': + '@oxlint/binding-linux-x64-musl@1.74.0': optional: true - '@oxlint/binding-openharmony-arm64@1.72.0': + '@oxlint/binding-openharmony-arm64@1.74.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.72.0': + '@oxlint/binding-win32-arm64-msvc@1.74.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.72.0': + '@oxlint/binding-win32-ia32-msvc@1.74.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.72.0': + '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true - '@rolldown/binding-android-arm64@1.1.4': + '@rolldown/binding-android-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-arm64@1.1.4': + '@rolldown/binding-darwin-arm64@1.1.5': optional: true - '@rolldown/binding-darwin-x64@1.1.4': + '@rolldown/binding-darwin-x64@1.1.5': optional: true - '@rolldown/binding-freebsd-x64@1.1.4': + '@rolldown/binding-freebsd-x64@1.1.5': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.4': + '@rolldown/binding-linux-arm64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.4': + '@rolldown/binding-linux-arm64-musl@1.1.5': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.4': + '@rolldown/binding-linux-ppc64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.4': + '@rolldown/binding-linux-s390x-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.4': + '@rolldown/binding-linux-x64-gnu@1.1.5': optional: true - '@rolldown/binding-linux-x64-musl@1.1.4': + '@rolldown/binding-linux-x64-musl@1.1.5': optional: true - '@rolldown/binding-openharmony-arm64@1.1.4': + '@rolldown/binding-openharmony-arm64@1.1.5': optional: true - '@rolldown/binding-wasm32-wasi@1.1.4': + '@rolldown/binding-wasm32-wasi@1.1.5': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.4': + '@rolldown/binding-win32-arm64-msvc@1.1.5': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.4': + '@rolldown/binding-win32-x64-msvc@1.1.5': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -943,11 +941,11 @@ snapshots: '@types/estree@1.0.9': {} - '@vitejs/plugin-vue@6.0.7(vite@8.1.3)(vue@3.5.39)': + '@vitejs/plugin-vue@6.0.8(vite@8.1.5)(vue@3.5.40)': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.3 - vue: 3.5.39 + vite: 8.1.5 + vue: 3.5.40 '@vitest/expect@4.1.10': dependencies: @@ -958,13 +956,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.10(vite@8.1.3)': + '@vitest/mocker@4.1.10(vite@8.1.5)': dependencies: '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.3 + vite: 8.1.5 '@vitest/pretty-format@4.1.10': dependencies: @@ -990,61 +988,61 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vue/compiler-core@3.5.39': + '@vue/compiler-core@3.5.40': dependencies: '@babel/parser': 7.29.7 - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.40 entities: 7.0.1 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-dom@3.5.39': + '@vue/compiler-dom@3.5.40': dependencies: - '@vue/compiler-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 - '@vue/compiler-sfc@3.5.39': + '@vue/compiler-sfc@3.5.40': dependencies: '@babel/parser': 7.29.7 - '@vue/compiler-core': 3.5.39 - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.16 + postcss: 8.5.20 source-map-js: 1.2.1 - '@vue/compiler-ssr@3.5.39': + '@vue/compiler-ssr@3.5.40': dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 '@vue/devtools-api@6.6.4': {} - '@vue/reactivity@3.5.39': + '@vue/reactivity@3.5.40': dependencies: - '@vue/shared': 3.5.39 + '@vue/shared': 3.5.40 - '@vue/runtime-core@3.5.39': + '@vue/runtime-core@3.5.40': dependencies: - '@vue/reactivity': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/reactivity': 3.5.40 + '@vue/shared': 3.5.40 - '@vue/runtime-dom@3.5.39': + '@vue/runtime-dom@3.5.40': dependencies: - '@vue/reactivity': 3.5.39 - '@vue/runtime-core': 3.5.39 - '@vue/shared': 3.5.39 + '@vue/reactivity': 3.5.40 + '@vue/runtime-core': 3.5.40 + '@vue/shared': 3.5.40 csstype: 3.2.3 - '@vue/server-renderer@3.5.39(vue@3.5.39)': + '@vue/server-renderer@3.5.40': dependencies: - '@vue/compiler-ssr': 3.5.39 - '@vue/shared': 3.5.39 - vue: 3.5.39 + '@vue/compiler-ssr': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/shared': 3.5.40 - '@vue/shared@3.5.39': {} + '@vue/shared@3.5.40': {} assertion-error@2.0.1: {} @@ -1059,7 +1057,7 @@ snapshots: chart.js: 4.5.1 date-fns: 4.4.0 - chartjs-chart-matrix@3.0.4(chart.js@4.5.1): + chartjs-chart-matrix@3.0.5(chart.js@4.5.1): dependencies: chart.js: 4.5.1 @@ -1083,124 +1081,124 @@ snapshots: expect-type@1.4.0: {} - fdir@6.5.0(picomatch@4.0.4): + fdir@6.5.0(picomatch@4.0.5): optionalDependencies: - picomatch: 4.0.4 + picomatch: 4.0.5 fsevents@2.3.3: optional: true - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - nanoid@3.3.15: {} + nanoid@3.3.16: {} obug@2.1.4: {} - oxlint@1.72.0: + oxlint@1.74.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.72.0 - '@oxlint/binding-android-arm64': 1.72.0 - '@oxlint/binding-darwin-arm64': 1.72.0 - '@oxlint/binding-darwin-x64': 1.72.0 - '@oxlint/binding-freebsd-x64': 1.72.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.72.0 - '@oxlint/binding-linux-arm-musleabihf': 1.72.0 - '@oxlint/binding-linux-arm64-gnu': 1.72.0 - '@oxlint/binding-linux-arm64-musl': 1.72.0 - '@oxlint/binding-linux-ppc64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-gnu': 1.72.0 - '@oxlint/binding-linux-riscv64-musl': 1.72.0 - '@oxlint/binding-linux-s390x-gnu': 1.72.0 - '@oxlint/binding-linux-x64-gnu': 1.72.0 - '@oxlint/binding-linux-x64-musl': 1.72.0 - '@oxlint/binding-openharmony-arm64': 1.72.0 - '@oxlint/binding-win32-arm64-msvc': 1.72.0 - '@oxlint/binding-win32-ia32-msvc': 1.72.0 - '@oxlint/binding-win32-x64-msvc': 1.72.0 + '@oxlint/binding-android-arm-eabi': 1.74.0 + '@oxlint/binding-android-arm64': 1.74.0 + '@oxlint/binding-darwin-arm64': 1.74.0 + '@oxlint/binding-darwin-x64': 1.74.0 + '@oxlint/binding-freebsd-x64': 1.74.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.74.0 + '@oxlint/binding-linux-arm-musleabihf': 1.74.0 + '@oxlint/binding-linux-arm64-gnu': 1.74.0 + '@oxlint/binding-linux-arm64-musl': 1.74.0 + '@oxlint/binding-linux-ppc64-gnu': 1.74.0 + '@oxlint/binding-linux-riscv64-gnu': 1.74.0 + '@oxlint/binding-linux-riscv64-musl': 1.74.0 + '@oxlint/binding-linux-s390x-gnu': 1.74.0 + '@oxlint/binding-linux-x64-gnu': 1.74.0 + '@oxlint/binding-linux-x64-musl': 1.74.0 + '@oxlint/binding-openharmony-arm64': 1.74.0 + '@oxlint/binding-win32-arm64-msvc': 1.74.0 + '@oxlint/binding-win32-ia32-msvc': 1.74.0 + '@oxlint/binding-win32-x64-msvc': 1.74.0 pathe@2.0.3: {} picocolors@1.1.1: {} - picomatch@4.0.4: {} + picomatch@4.0.5: {} - postcss@8.5.16: + postcss@8.5.20: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 - rolldown@1.1.4: + rolldown@1.1.5: dependencies: - '@oxc-project/types': 0.138.0 + '@oxc-project/types': 0.139.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.4 - '@rolldown/binding-darwin-arm64': 1.1.4 - '@rolldown/binding-darwin-x64': 1.1.4 - '@rolldown/binding-freebsd-x64': 1.1.4 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 - '@rolldown/binding-linux-arm64-gnu': 1.1.4 - '@rolldown/binding-linux-arm64-musl': 1.1.4 - '@rolldown/binding-linux-ppc64-gnu': 1.1.4 - '@rolldown/binding-linux-s390x-gnu': 1.1.4 - '@rolldown/binding-linux-x64-gnu': 1.1.4 - '@rolldown/binding-linux-x64-musl': 1.1.4 - '@rolldown/binding-openharmony-arm64': 1.1.4 - '@rolldown/binding-wasm32-wasi': 1.1.4 - '@rolldown/binding-win32-arm64-msvc': 1.1.4 - '@rolldown/binding-win32-x64-msvc': 1.1.4 + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 siginfo@2.0.0: {} @@ -1216,28 +1214,28 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {} tslib@2.8.1: optional: true - vite@8.1.3: + vite@8.1.5: dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.4 + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.20 + rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: fsevents: 2.3.3 - vitest@4.1.10(vite@8.1.3): + vitest@4.1.10(vite@8.1.5): dependencies: '@vitest/expect': 4.1.10 - '@vitest/mocker': 4.1.10(vite@8.1.3) + '@vitest/mocker': 4.1.10(vite@8.1.5) '@vitest/pretty-format': 4.1.10 '@vitest/runner': 4.1.10 '@vitest/snapshot': 4.1.10 @@ -1248,32 +1246,32 @@ snapshots: magic-string: 0.30.21 obug: 2.1.4 pathe: 2.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 std-env: 4.2.0 tinybench: 2.9.0 tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.3 + vite: 8.1.5 why-is-node-running: 2.3.0 transitivePeerDependencies: - msw - vue-i18n@11.4.6(vue@3.5.39): + vue-i18n@11.4.7(vue@3.5.40): dependencies: - '@intlify/core-base': 11.4.6 - '@intlify/devtools-types': 11.4.6 - '@intlify/shared': 11.4.6 + '@intlify/core-base': 11.4.7 + '@intlify/devtools-types': 11.4.7 + '@intlify/shared': 11.4.7 '@vue/devtools-api': 6.6.4 - vue: 3.5.39 + vue: 3.5.40 - vue@3.5.39: + vue@3.5.40: dependencies: - '@vue/compiler-dom': 3.5.39 - '@vue/compiler-sfc': 3.5.39 - '@vue/runtime-dom': 3.5.39 - '@vue/server-renderer': 3.5.39(vue@3.5.39) - '@vue/shared': 3.5.39 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-sfc': 3.5.40 + '@vue/runtime-dom': 3.5.40 + '@vue/server-renderer': 3.5.40 + '@vue/shared': 3.5.40 why-is-node-running@2.3.0: dependencies: From 10aa51fffe3b798d11958c8ac51a0b8f3fd5936f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Tue, 21 Jul 2026 21:45:42 +0200 Subject: [PATCH 08/27] =?UTF-8?q?=E2=9E=95=20Add=20prettier=20and=20reform?= =?UTF-8?q?at=20the=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierignore | 4 + .prettierrc.json | 8 + package.json | 3 + pnpm-lock.yaml | 10 + src/Options.vue | 858 +++++++++--------- src/Popup.vue | 114 +-- src/Stats.vue | 244 +++-- src/chart.config.js | 25 +- src/charts/BarChart.vue | 39 +- src/charts/DoughnutChart.vue | 44 +- src/charts/LineChart.vue | 72 +- src/charts/MatrixChart.vue | 102 ++- src/composables/statsAggregation.js | 204 +++-- src/composables/useActivityChartData.js | 11 +- src/composables/useFoldersChartData.js | 21 +- src/composables/useLeaderChartData.js | 6 +- src/composables/useOnedimChartData.js | 30 +- src/composables/useStatsData.js | 254 +++--- src/composables/useTagsChartData.js | 11 +- src/composables/useTotalChartData.js | 92 +- src/composables/useTwodimChartData.js | 38 +- src/definitions.js | 65 +- src/options.js | 4 +- src/parts/LiveAge.vue | 14 +- src/parts/ProjectMeta.vue | 47 +- src/parts/SectionTabHeader.vue | 53 +- src/popup.js | 4 +- src/sections/ActivitySection.vue | 42 +- src/sections/FoldersSection.vue | 11 +- src/sections/LeaderSection.vue | 51 +- src/sections/TagsSection.vue | 16 +- src/sections/TwodimSection.vue | 6 +- src/stats.js | 4 +- src/translations.js | 149 +-- src/utils.js | 158 ++-- test/composables/statsAggregation.spec.js | 12 +- test/composables/useActivityChartData.spec.js | 2 +- test/composables/useTotalChartData.spec.js | 12 +- test/utils.spec.js | 44 +- vite.config.js | 6 +- vitest.config.js | 2 +- 41 files changed, 1579 insertions(+), 1313 deletions(-) create mode 100644 .prettierignore create mode 100644 .prettierrc.json diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..fac63f2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +pnpm-lock.yaml +public/_locales diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..770f4ab --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "semi": true, + "trailingComma": "es5", + "printWidth": 120, + "arrowParens": "always" +} diff --git a/package.json b/package.json index 13c8e4f..e67d9ae 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "build": "vite build", "serve": "vite preview", "lint": "oxlint", + "format": "prettier --write \"src/**/*.{js,vue}\" \"test/**/*.js\" vite.config.js vitest.config.js", + "format:check": "prettier --check \"src/**/*.{js,vue}\" \"test/**/*.js\" vite.config.js vitest.config.js", "test": "vitest run", "test:watch": "vitest", "getlang": "localazy download", @@ -24,6 +26,7 @@ "@localazy/cli": "^2.0.11", "@vitejs/plugin-vue": "^6.0.8", "oxlint": "^1.74.0", + "prettier": "^3.9.5", "vite": "^8.1.5", "vitest": "^4.1.10" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e69398d..7f039f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,9 @@ importers: oxlint: specifier: ^1.74.0 version: 1.74.0 + prettier: + specifier: ^3.9.5 + version: 3.9.5 vite: specifier: ^8.1.5 version: 8.1.5 @@ -594,6 +597,11 @@ packages: resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} engines: {node: ^10 || ^12 || >=14} + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} + engines: {node: '>=14'} + hasBin: true + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1179,6 +1187,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prettier@3.9.5: {} + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 diff --git a/src/Options.vue b/src/Options.vue index 10ef548..bfc24b6 100644 --- a/src/Options.vue +++ b/src/Options.vue @@ -1,141 +1,138 @@ diff --git a/src/charts/MatrixChart.vue b/src/charts/MatrixChart.vue index e1a97d4..0d1574d 100644 --- a/src/charts/MatrixChart.vue +++ b/src/charts/MatrixChart.vue @@ -1,11 +1,11 @@ diff --git a/src/parts/ProjectMeta.vue b/src/parts/ProjectMeta.vue index 0bf385a..afd6539 100644 --- a/src/parts/ProjectMeta.vue +++ b/src/parts/ProjectMeta.vue @@ -14,7 +14,7 @@ :data-tooltip="t('cta.donate')" > - + {{ t('cta.donate') }} @@ -26,9 +26,13 @@ :data-tooltip="t('cta.share')" > - - - + + + {{ t('cta.share') }} @@ -39,8 +43,10 @@ :data-tooltip="t('cta.star')" > - - + + {{ t('cta.star') }} @@ -51,7 +57,7 @@ :data-tooltip="t('cta.review')" > - + @@ -64,7 +70,7 @@ :data-tooltip="t('cta.translate')" > - + @@ -78,31 +84,38 @@
diff --git a/src/parts/SectionTabHeader.vue b/src/parts/SectionTabHeader.vue index 74bb416..ce983c0 100644 --- a/src/parts/SectionTabHeader.vue +++ b/src/parts/SectionTabHeader.vue @@ -7,18 +7,21 @@ :class="[ tabItemClass, { - 'active': tab === id, - 'cursor-pointer text-hover-accent2': tab !== id - } + active: tab === id, + 'cursor-pointer text-hover-accent2': tab !== id, + }, ]" :data-tooltip="t('stats.charts.' + key + '.description')" @click="$emit('update:tab', id)" > - {{ t("stats.charts." + key + ".title") }} + {{ t('stats.charts.' + key + '.title') }}
  • - + @@ -50,26 +53,30 @@
  • - - - - - + + + + + + + + + - - - - - + + + + + + + + +
  • diff --git a/src/popup.js b/src/popup.js index 22e89ad..2fd661c 100644 --- a/src/popup.js +++ b/src/popup.js @@ -8,12 +8,12 @@ app.provide('version', APP_VERSION); // internationalization import { createI18n } from 'vue-i18n'; -import { messages, pluralRules } from "@/translations.js"; +import { messages, pluralRules } from '@/translations.js'; const i18n = createI18n({ legacy: false, globalInjection: true, locale: messenger.i18n.getUILanguage(), - fallbackLocale: "en", + fallbackLocale: 'en', messages, pluralRules, }); diff --git a/src/sections/ActivitySection.vue b/src/sections/ActivitySection.vue index efbb4b1..e74c76e 100644 --- a/src/sections/ActivitySection.vue +++ b/src/sections/ActivitySection.vue @@ -1,31 +1,45 @@ diff --git a/src/sections/TagsSection.vue b/src/sections/TagsSection.vue index 8b90241..91bb57d 100644 --- a/src/sections/TagsSection.vue +++ b/src/sections/TagsSection.vue @@ -28,28 +28,19 @@ diff --git a/src/sections/TotalSection.vue b/src/sections/TotalSection.vue index eee9ec7..01a27fc 100644 --- a/src/sections/TotalSection.vue +++ b/src/sections/TotalSection.vue @@ -86,34 +86,37 @@ diff --git a/src/sections/TwodimSection.vue b/src/sections/TwodimSection.vue index 3a2bb6f..43c8591 100644 --- a/src/sections/TwodimSection.vue +++ b/src/sections/TwodimSection.vue @@ -27,24 +27,19 @@ diff --git a/src/utils.js b/src/utils.js index 97b87aa..1c8a8bc 100644 --- a/src/utils.js +++ b/src/utils.js @@ -245,6 +245,13 @@ const startOfToday = () => { return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); }; +// format folder select options +// build name to match its hierarchy with preceding s +const formatFolder = (folder, indentSymbol = '—') => { + const level = (folder.path.match(/\//g) || []).length; + return level <= 1 ? folder.name : indentSymbol.repeat(level - 1) + ' ' + folder.name; +}; + // formats given date human readable, takes locale into account const formatDate = (d, locale = 'en') => { const options = { @@ -338,6 +345,7 @@ export { extractEmailAddress, formatBytes, formatDate, + formatFolder, isoDayOfWeek, isSelfMessage, localDateKey, diff --git a/test/utils.spec.js b/test/utils.spec.js index 7ffabfe..df158b4 100644 --- a/test/utils.spec.js +++ b/test/utils.spec.js @@ -5,6 +5,7 @@ import { extractEmailAddress, formatBytes, formatDate, + formatFolder, isoDayOfWeek, isSelfMessage, localDateKey, @@ -215,6 +216,22 @@ describe('formatDate', () => { }); }); +describe('formatFolder', () => { + it('does not indent top-level folders (path with 0 or 1 slash)', () => { + expect(formatFolder({ path: 'Inbox', name: 'Inbox' })).toBe('Inbox'); + expect(formatFolder({ path: 'Inbox/Sub', name: 'Sub' })).toBe('Sub'); + }); + + it('indents nested folders by one indentSymbol per level below the first', () => { + expect(formatFolder({ path: 'Inbox/Sub/SubSub', name: 'SubSub' })).toBe('— SubSub'); + expect(formatFolder({ path: 'Inbox/Sub/SubSub/Deep', name: 'Deep' })).toBe('—— Deep'); + }); + + it('uses a custom indent symbol when given', () => { + expect(formatFolder({ path: 'Inbox/Sub/SubSub', name: 'SubSub' }, '>')).toBe('> SubSub'); + }); +}); + describe('isoDayOfWeek', () => { it('maps Sunday to 7 and Monday to 1 (ISO 8601 numbering)', () => { // Jan 1 2023 is a Sunday, Jan 2 2023 is a Monday From d3f79ea47e6705887f5c699eea9a1ce90258cc2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Wed, 22 Jul 2026 00:40:32 +0200 Subject: [PATCH 13/27] =?UTF-8?q?=F0=9F=94=A8=20Move=20logic=20out=20of=20?= =?UTF-8?q?Popup.vue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Popup.vue | 89 ++-------------------- src/composables/usePopupData.js | 105 ++++++++++++++++++++++++++ src/composables/useStatsData.js | 33 ++++---- src/utils.js | 14 ++++ test/composables/usePopupData.spec.js | 66 ++++++++++++++++ test/utils.spec.js | 32 ++++++++ 6 files changed, 240 insertions(+), 99 deletions(-) create mode 100644 src/composables/usePopupData.js create mode 100644 test/composables/usePopupData.spec.js diff --git a/src/Popup.vue b/src/Popup.vue index 557e505..2272e58 100644 --- a/src/Popup.vue +++ b/src/Popup.vue @@ -74,97 +74,18 @@ diff --git a/src/filters/ContactFilter.vue b/src/filters/ContactFilter.vue index 8b82f0c..08e8cf3 100644 --- a/src/filters/ContactFilter.vue +++ b/src/filters/ContactFilter.vue @@ -1,8 +1,8 @@ diff --git a/src/filters/FolderFilter.vue b/src/filters/FolderFilter.vue index 4c9b74a..e5cbb0e 100644 --- a/src/filters/FolderFilter.vue +++ b/src/filters/FolderFilter.vue @@ -1,37 +1,36 @@ @@ -45,3 +44,11 @@ const { folders, active, isLoading, singleAccount, resetFolder } = inject('engin const { t } = useI18n(); + + diff --git a/src/filters/PeriodFilter.vue b/src/filters/PeriodFilter.vue index 8dcfd20..06a9492 100644 --- a/src/filters/PeriodFilter.vue +++ b/src/filters/PeriodFilter.vue @@ -1,23 +1,25 @@ @@ -71,3 +74,11 @@ const examplePeriodShort = now.toISOString().replace(/-/g, '').slice(2, 8); // returns the current date as example for formatted period input (YYYY-MM-DD) const examplePeriodFormatted = now.toISOString().slice(0, 10); + + diff --git a/src/options.js b/src/options.js index 86f20e5..5337698 100644 --- a/src/options.js +++ b/src/options.js @@ -6,6 +6,10 @@ const app = createApp(Options); // provide global properties app.provide('version', APP_VERSION); +// shared UI components and directives +import uiKit from '@/ui'; +app.use(uiKit); + // internationalization import { createI18n } from 'vue-i18n'; import { messages, pluralRules } from '@/translations.js'; diff --git a/src/options/AccountsOption.vue b/src/options/AccountsOption.vue index 4ed4ffa..c8098f8 100644 --- a/src/options/AccountsOption.vue +++ b/src/options/AccountsOption.vue @@ -3,14 +3,14 @@
    - +
    diff --git a/src/options/ClearCacheOption.vue b/src/options/ClearCacheOption.vue index c4d3375..3f3dce4 100644 --- a/src/options/ClearCacheOption.vue +++ b/src/options/ClearCacheOption.vue @@ -5,15 +5,15 @@ {{ t('options.clearCache.description') }}
    - + {{ t('options.clearCache.label') }}
    - + - +
    {{ t('options.clearCache.size', [formattedCacheSize]) }} {{ t('options.clearCache.empty') }} diff --git a/src/options/DebugOption.vue b/src/options/DebugOption.vue index 6bf1e41..4e6adf1 100644 --- a/src/options/DebugOption.vue +++ b/src/options/DebugOption.vue @@ -5,11 +5,7 @@ {{ t('options.debug.description') }}
    - +
    diff --git a/src/options/LiveCountUpOption.vue b/src/options/LiveCountUpOption.vue index 7688976..de9d23c 100644 --- a/src/options/LiveCountUpOption.vue +++ b/src/options/LiveCountUpOption.vue @@ -5,19 +5,15 @@ {{ t('options.liveCountUp.description') }}
    - +
    - + - +
    {{ t('options.liveCountUp.info') }}
    diff --git a/src/options/MaxListCountOption.vue b/src/options/MaxListCountOption.vue index c5d4e63..cc59aa5 100644 --- a/src/options/MaxListCountOption.vue +++ b/src/options/MaxListCountOption.vue @@ -3,8 +3,8 @@ -
    - +
    - - + +
    -
    +
    diff --git a/src/options/OrdinateOption.vue b/src/options/OrdinateOption.vue index 0682226..e3f1708 100644 --- a/src/options/OrdinateOption.vue +++ b/src/options/OrdinateOption.vue @@ -5,11 +5,7 @@ {{ t('options.ordinate.description') }}
    - +
    diff --git a/src/options/ResetOptionsOption.vue b/src/options/ResetOptionsOption.vue index c3ff9b4..9dc4d36 100644 --- a/src/options/ResetOptionsOption.vue +++ b/src/options/ResetOptionsOption.vue @@ -3,17 +3,17 @@
    - + {{ t('options.resetOptions.label') }}
    - + - +
    {{ t('options.resetOptions.removeIdentities') }}
    diff --git a/src/options/SelfMessagesOption.vue b/src/options/SelfMessagesOption.vue index cd57d5a..e02d471 100644 --- a/src/options/SelfMessagesOption.vue +++ b/src/options/SelfMessagesOption.vue @@ -3,8 +3,8 @@
    - +
    - + - +
    {{ t('options.selfMessages.info.' + options.selfMessages) }}
    diff --git a/src/options/TagColorsOption.vue b/src/options/TagColorsOption.vue index 1378021..a826601 100644 --- a/src/options/TagColorsOption.vue +++ b/src/options/TagColorsOption.vue @@ -5,11 +5,7 @@ {{ t('options.tagColors.description') }}
    - +
    diff --git a/src/options/ThemeOption.vue b/src/options/ThemeOption.vue index 6db2e39..b16be72 100644 --- a/src/options/ThemeOption.vue +++ b/src/options/ThemeOption.vue @@ -5,11 +5,11 @@ {{ t('options.theme.description') }}
    - +
    diff --git a/src/parts/ProjectMeta.vue b/src/parts/ProjectMeta.vue index afd6539..f531d14 100644 --- a/src/parts/ProjectMeta.vue +++ b/src/parts/ProjectMeta.vue @@ -7,25 +7,27 @@
    {{ t('cta.message') }}
  • - - +
  • - + @@ -66,8 +62,8 @@ - - + + @@ -77,7 +73,7 @@ - +
  • @@ -123,3 +119,78 @@ const spanStyle = (id) => { return ''; }; + + diff --git a/src/parts/popup/ActionBar.vue b/src/parts/popup/ActionBar.vue index aa566cf..1076d56 100644 --- a/src/parts/popup/ActionBar.vue +++ b/src/parts/popup/ActionBar.vue @@ -1,7 +1,11 @@ diff --git a/src/parts/stats/ActionBar.vue b/src/parts/stats/ActionBar.vue index 63b42b4..b3667cf 100644 --- a/src/parts/stats/ActionBar.vue +++ b/src/parts/stats/ActionBar.vue @@ -1,8 +1,9 @@