mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): build anime-centric stats dashboard frontend
5-tab React dashboard with Catppuccin Mocha theme: - Overview: hero stats, streak calendar, watch time chart, recent sessions - Anime: grid with cover art, episode list with completion %, detail view - Trends: 15 charts across Activity, Efficiency, Anime, and Patterns - Vocabulary: POS-filtered word/kanji lists with detail panels - Sessions: expandable session history with event timeline Features: - Cross-tab navigation (anime <-> vocabulary) - Global word detail panel overlay - Expandable episode detail with Anki card links (Expression field) - Per-anime multi-line trend charts - Watch time by day-of-week and hour-of-day - Collapsible sections with accessibility (aria-expanded) - Card size selector for anime grid - Cover art caching via AniList - HTTP API client with file:// protocol fallback for Electron overlay
This commit is contained in:
418
stats/bun.lock
Normal file
418
stats/bun.lock
Normal file
@@ -0,0 +1,418 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@subminer/stats-ui",
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^6.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
|
||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.7", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
|
||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||
|
||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
|
||||
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
|
||||
"fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
|
||||
|
||||
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
|
||||
|
||||
"react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
|
||||
"react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
|
||||
|
||||
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
|
||||
|
||||
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="],
|
||||
|
||||
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
}
|
||||
}
|
||||
13
stats/index.html
Normal file
13
stats/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>SubMiner Stats</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
24
stats/package.json
Normal file
24
stats/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@subminer/stats-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^6.3.0"
|
||||
}
|
||||
}
|
||||
BIN
stats/public/favicon.png
Normal file
BIN
stats/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
93
stats/src/App.tsx
Normal file
93
stats/src/App.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { TabBar } from './components/layout/TabBar';
|
||||
import { OverviewTab } from './components/overview/OverviewTab';
|
||||
import { TrendsTab } from './components/trends/TrendsTab';
|
||||
import { AnimeTab } from './components/anime/AnimeTab';
|
||||
import { VocabularyTab } from './components/vocabulary/VocabularyTab';
|
||||
import { SessionsTab } from './components/sessions/SessionsTab';
|
||||
import { WordDetailPanel } from './components/vocabulary/WordDetailPanel';
|
||||
import type { TabId } from './components/layout/TabBar';
|
||||
|
||||
export function App() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
const [globalWordId, setGlobalWordId] = useState<number | null>(null);
|
||||
|
||||
const navigateToAnime = useCallback((animeId: number) => {
|
||||
setActiveTab('anime');
|
||||
setSelectedAnimeId(animeId);
|
||||
}, []);
|
||||
|
||||
const openWordDetail = useCallback((wordId: number) => {
|
||||
setGlobalWordId(wordId);
|
||||
}, []);
|
||||
|
||||
const handleTabChange = useCallback((tabId: TabId) => {
|
||||
setActiveTab(tabId);
|
||||
setSelectedAnimeId(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-ctp-base">
|
||||
<header className="px-4 pt-3 pb-0">
|
||||
<h1 className="text-lg font-semibold text-ctp-text mb-2">SubMiner Stats</h1>
|
||||
<TabBar activeTab={activeTab} onTabChange={handleTabChange} />
|
||||
</header>
|
||||
<main className="flex-1 overflow-y-auto p-4">
|
||||
<section
|
||||
id="panel-overview"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-overview"
|
||||
hidden={activeTab !== 'overview'}
|
||||
>
|
||||
<OverviewTab />
|
||||
</section>
|
||||
<section
|
||||
id="panel-anime"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-anime"
|
||||
hidden={activeTab !== 'anime'}
|
||||
>
|
||||
<AnimeTab
|
||||
initialAnimeId={selectedAnimeId}
|
||||
onClearInitialAnime={() => setSelectedAnimeId(null)}
|
||||
onNavigateToWord={openWordDetail}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
id="panel-trends"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-trends"
|
||||
hidden={activeTab !== 'trends'}
|
||||
>
|
||||
<TrendsTab />
|
||||
</section>
|
||||
<section
|
||||
id="panel-vocabulary"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-vocabulary"
|
||||
hidden={activeTab !== 'vocabulary'}
|
||||
>
|
||||
<VocabularyTab
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
onOpenWordDetail={openWordDetail}
|
||||
/>
|
||||
</section>
|
||||
<section
|
||||
id="panel-sessions"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-sessions"
|
||||
hidden={activeTab !== 'sessions'}
|
||||
>
|
||||
<SessionsTab />
|
||||
</section>
|
||||
</main>
|
||||
<WordDetailPanel
|
||||
wordId={globalWordId}
|
||||
onClose={() => setGlobalWordId(null)}
|
||||
onSelectWord={openWordDetail}
|
||||
onNavigateToAnime={navigateToAnime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
stats/src/components/anime/AnimeCard.tsx
Normal file
35
stats/src/components/anime/AnimeCard.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { AnimeLibraryItem } from '../../types/stats';
|
||||
|
||||
interface AnimeCardProps {
|
||||
anime: AnimeLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function AnimeCard({ anime, onClick }: AnimeCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-blue/50 hover:shadow-lg hover:shadow-ctp-blue/10 transition-all duration-200 hover:-translate-y-1 text-left w-full"
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<AnimeCoverImage
|
||||
animeId={anime.animeId}
|
||||
title={anime.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{anime.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{anime.episodeCount} episode{anime.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatDuration(anime.totalActiveMs)} · {formatNumber(anime.totalCards)} cards
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
72
stats/src/components/anime/AnimeCardsList.tsx
Normal file
72
stats/src/components/anime/AnimeCardsList.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface AnimeCardsListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
totalCards: number;
|
||||
}
|
||||
|
||||
export function AnimeCardsList({ episodes, totalCards }: AnimeCardsListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
|
||||
if (totalCards === 0) {
|
||||
return (
|
||||
<CollapsibleSection title="Cards Mined (0)" defaultOpen={false}>
|
||||
<p className="text-sm text-ctp-overlay2">No cards mined from this anime yet.</p>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
|
||||
const withCards = episodes.filter((ep) => ep.totalCards > 0);
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Cards Mined (${formatNumber(totalCards)})`} defaultOpen={false}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">Episode</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 font-medium">Last Watched</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withCards.map((ep) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '▼' : '▶'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[300px]">
|
||||
<span className="text-ctp-subtext0 mr-2">
|
||||
{ep.episode != null ? `#${ep.episode}` : ''}
|
||||
</span>
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-green">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
33
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
33
stats/src/components/anime/AnimeCoverImage.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
|
||||
interface AnimeCoverImageProps {
|
||||
animeId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AnimeCoverImage({ animeId, title, className = '' }: AnimeCoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const src = getStatsClient().getAnimeCoverUrl(animeId);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
131
stats/src/components/anime/AnimeDetailView.tsx
Normal file
131
stats/src/components/anime/AnimeDetailView.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAnimeDetail } from '../../hooks/useAnimeDetail';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { formatDuration, formatNumber, epochDayToDate } from '../../lib/formatters';
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { AnimeHeader } from './AnimeHeader';
|
||||
import { EpisodeList } from './EpisodeList';
|
||||
import { AnimeWordList } from './AnimeWordList';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface AnimeDetailViewProps {
|
||||
animeId: number;
|
||||
onBack: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
function AnimeWatchChart({ animeId }: { animeId: number }) {
|
||||
const [rollups, setRollups] = useState<DailyRollup[]>([]);
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeRollups(animeId, 90)
|
||||
.then((data) => { if (!cancelled) setRollups(data); })
|
||||
.catch(() => { if (!cancelled) setRollups([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [animeId]);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) {
|
||||
const { data, loading, error } = useAnimeDetail(animeId);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Anime not found</div>;
|
||||
|
||||
const { detail, episodes, anilistEntries } = data;
|
||||
const avgSessionMs = detail.totalSessions > 0
|
||||
? Math.round(detail.totalActiveMs / detail.totalSessions)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← Back to Anime
|
||||
</button>
|
||||
<AnimeHeader detail={detail} anilistEntries={anilistEntries ?? []} />
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<StatCard label="Watch Time" value={formatDuration(detail.totalActiveMs)} color="text-ctp-blue" />
|
||||
<StatCard label="Cards" value={formatNumber(detail.totalCards)} color="text-ctp-green" />
|
||||
<StatCard label="Words" value={formatNumber(detail.totalWordsSeen)} color="text-ctp-mauve" />
|
||||
<StatCard label="Sessions" value={String(detail.totalSessions)} color="text-ctp-peach" />
|
||||
<StatCard label="Avg Session" value={formatDuration(avgSessionMs)} />
|
||||
</div>
|
||||
<EpisodeList episodes={episodes} />
|
||||
<AnimeWatchChart animeId={animeId} />
|
||||
<AnimeWordList animeId={animeId} onNavigateToWord={onNavigateToWord} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
stats/src/components/anime/AnimeHeader.tsx
Normal file
81
stats/src/components/anime/AnimeHeader.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { AnimeCoverImage } from './AnimeCoverImage';
|
||||
import type { AnimeDetailData, AnilistEntry } from '../../types/stats';
|
||||
|
||||
interface AnimeHeaderProps {
|
||||
detail: AnimeDetailData['detail'];
|
||||
anilistEntries: AnilistEntry[];
|
||||
}
|
||||
|
||||
function AnilistButton({ entry }: { entry: AnilistEntry }) {
|
||||
const label = entry.season != null
|
||||
? `Season ${entry.season}`
|
||||
: entry.titleEnglish ?? entry.titleRomaji ?? 'AniList';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${entry.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
{label}
|
||||
<span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeHeader({ detail, anilistEntries }: AnimeHeaderProps) {
|
||||
const altTitles = [detail.titleRomaji, detail.titleEnglish, detail.titleNative]
|
||||
.filter((t): t is string => t != null && t !== detail.canonicalTitle);
|
||||
const uniqueAltTitles = [...new Set(altTitles)];
|
||||
|
||||
const hasMultipleEntries = anilistEntries.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<AnimeCoverImage
|
||||
animeId={detail.animeId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
{uniqueAltTitles.length > 0 && (
|
||||
<div className="text-xs text-ctp-overlay2 mt-0.5 truncate">
|
||||
{uniqueAltTitles.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm text-ctp-subtext0 mt-2">
|
||||
{detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{anilistEntries.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{hasMultipleEntries ? (
|
||||
anilistEntries.map((entry) => (
|
||||
<AnilistButton key={entry.anilistId} entry={entry} />
|
||||
))
|
||||
) : (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${anilistEntries[0]!.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : detail.anilistId ? (
|
||||
<a
|
||||
href={`https://anilist.co/anime/${detail.anilistId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 px-2 py-1 text-xs rounded bg-ctp-surface1 text-ctp-blue hover:bg-ctp-surface2 hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
View on AniList <span className="text-[10px]">{'\u2197'}</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
stats/src/components/anime/AnimeTab.tsx
Normal file
130
stats/src/components/anime/AnimeTab.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useAnimeLibrary } from '../../hooks/useAnimeLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { AnimeCard } from './AnimeCard';
|
||||
import { AnimeDetailView } from './AnimeDetailView';
|
||||
|
||||
type SortKey = 'lastWatched' | 'watchTime' | 'cards' | 'episodes';
|
||||
type CardSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const GRID_CLASSES: Record<CardSize, string> = {
|
||||
sm: 'grid-cols-5 sm:grid-cols-7 md:grid-cols-9 lg:grid-cols-11',
|
||||
md: 'grid-cols-4 sm:grid-cols-5 md:grid-cols-7 lg:grid-cols-9',
|
||||
lg: 'grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-7',
|
||||
};
|
||||
|
||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
||||
{ key: 'lastWatched', label: 'Last Watched' },
|
||||
{ key: 'watchTime', label: 'Watch Time' },
|
||||
{ key: 'cards', label: 'Cards' },
|
||||
{ key: 'episodes', label: 'Episodes' },
|
||||
];
|
||||
|
||||
function sortAnime(list: ReturnType<typeof useAnimeLibrary>['anime'], key: SortKey) {
|
||||
return [...list].sort((a, b) => {
|
||||
switch (key) {
|
||||
case 'lastWatched': return b.lastWatchedMs - a.lastWatchedMs;
|
||||
case 'watchTime': return b.totalActiveMs - a.totalActiveMs;
|
||||
case 'cards': return b.totalCards - a.totalCards;
|
||||
case 'episodes': return b.episodeCount - a.episodeCount;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface AnimeTabProps {
|
||||
initialAnimeId?: number | null;
|
||||
onClearInitialAnime?: () => void;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeTab({ initialAnimeId, onClearInitialAnime, onNavigateToWord }: AnimeTabProps) {
|
||||
const { anime, loading, error } = useAnimeLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('lastWatched');
|
||||
const [cardSize, setCardSize] = useState<CardSize>('md');
|
||||
const [selectedAnimeId, setSelectedAnimeId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAnimeId != null) {
|
||||
setSelectedAnimeId(initialAnimeId);
|
||||
onClearInitialAnime?.();
|
||||
}
|
||||
}, [initialAnimeId, onClearInitialAnime]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = search.trim()
|
||||
? anime.filter((a) => a.canonicalTitle.toLowerCase().includes(search.toLowerCase()))
|
||||
: anime;
|
||||
return sortAnime(base, sortKey);
|
||||
}, [anime, search, sortKey]);
|
||||
|
||||
const totalMs = anime.reduce((sum, a) => sum + a.totalActiveMs, 0);
|
||||
|
||||
if (selectedAnimeId !== null) {
|
||||
return (
|
||||
<AnimeDetailView
|
||||
animeId={selectedAnimeId}
|
||||
onBack={() => setSelectedAnimeId(null)}
|
||||
onNavigateToWord={onNavigateToWord}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search anime..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(e) => setSortKey(e.target.value as SortKey)}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-2 py-2 text-sm text-ctp-text focus:outline-none focus:border-ctp-blue"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<option key={opt.key} value={opt.key}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
{(['sm', 'md', 'lg'] as const).map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setCardSize(size)}
|
||||
className={`px-2 py-1 rounded text-xs ${
|
||||
cardSize === size
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{size === 'sm' ? '▪' : size === 'md' ? '◼' : '⬛'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} anime · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No anime found</div>
|
||||
) : (
|
||||
<div className={`grid ${GRID_CLASSES[cardSize]} gap-4`}>
|
||||
{filtered.map((item) => (
|
||||
<AnimeCard
|
||||
key={item.animeId}
|
||||
anime={item}
|
||||
onClick={() => setSelectedAnimeId(item.animeId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
stats/src/components/anime/AnimeWordList.tsx
Normal file
57
stats/src/components/anime/AnimeWordList.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { CollapsibleSection } from './CollapsibleSection';
|
||||
import type { AnimeWord } from '../../types/stats';
|
||||
|
||||
interface AnimeWordListProps {
|
||||
animeId: number;
|
||||
onNavigateToWord?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function AnimeWordList({ animeId, onNavigateToWord }: AnimeWordListProps) {
|
||||
const [words, setWords] = useState<AnimeWord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getAnimeWords(animeId, 50)
|
||||
.then((data) => { if (!cancelled) setWords(data); })
|
||||
.catch(() => { if (!cancelled) setWords([]); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [animeId]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-sm p-4">Loading words...</div>;
|
||||
if (words.length === 0) return null;
|
||||
|
||||
return (
|
||||
<CollapsibleSection title={`Top Words (${words.length})`} defaultOpen={false}>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{words.map((w) => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
onClick={() => onNavigateToWord?.(w.wordId)}
|
||||
className="bg-ctp-base border border-ctp-surface1 rounded-md p-2 hover:border-ctp-blue transition-colors cursor-pointer text-left"
|
||||
>
|
||||
<div className="text-sm font-medium text-ctp-text">{w.headword}</div>
|
||||
{w.reading && w.reading !== w.headword && (
|
||||
<div className="text-xs text-ctp-overlay2">{w.reading}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{w.partOfSpeech && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-ctp-surface1 text-ctp-subtext0 rounded">
|
||||
{w.partOfSpeech}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-ctp-mauve ml-auto">{formatNumber(w.frequency)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
}
|
||||
28
stats/src/components/anime/CollapsibleSection.tsx
Normal file
28
stats/src/components/anime/CollapsibleSection.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useId, useState } from 'react';
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ title, defaultOpen = true, children }: CollapsibleSectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const contentId = useId();
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={contentId}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-ctp-text">{title}</h3>
|
||||
<span className="text-ctp-overlay2 text-xs" aria-hidden="true">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
{open && <div id={contentId} className="px-4 pb-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
stats/src/components/anime/EpisodeDetail.tsx
Normal file
125
stats/src/components/anime/EpisodeDetail.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from '../../hooks/useStatsApi';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import type { EpisodeDetailData } from '../../types/stats';
|
||||
|
||||
interface EpisodeDetailProps {
|
||||
videoId: number;
|
||||
}
|
||||
|
||||
interface NoteInfo {
|
||||
noteId: number;
|
||||
expression: string;
|
||||
}
|
||||
|
||||
export function EpisodeDetail({ videoId }: EpisodeDetailProps) {
|
||||
const [data, setData] = useState<EpisodeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [noteInfos, setNoteInfos] = useState<Map<number, NoteInfo>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
getStatsClient()
|
||||
.getEpisodeDetail(videoId)
|
||||
.then((d) => {
|
||||
if (cancelled) return;
|
||||
setData(d);
|
||||
const allNoteIds = d.cardEvents.flatMap((ev) => ev.noteIds);
|
||||
if (allNoteIds.length > 0) {
|
||||
getStatsClient()
|
||||
.ankiNotesInfo(allNoteIds)
|
||||
.then((notes) => {
|
||||
if (cancelled) return;
|
||||
const map = new Map<number, NoteInfo>();
|
||||
for (const note of notes) {
|
||||
const expr =
|
||||
note.fields?.Expression?.value ??
|
||||
note.fields?.expression?.value ??
|
||||
note.fields?.Word?.value ??
|
||||
note.fields?.word?.value ??
|
||||
'';
|
||||
map.set(note.noteId, { noteId: note.noteId, expression: expr });
|
||||
}
|
||||
setNoteInfos(map);
|
||||
})
|
||||
.catch((err) => console.warn('Failed to fetch Anki note info:', err));
|
||||
}
|
||||
})
|
||||
.catch(() => { if (!cancelled) setData(null); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [videoId]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-3">Loading...</div>;
|
||||
if (!data) return <div className="text-ctp-overlay2 text-xs p-3">Failed to load episode details.</div>;
|
||||
|
||||
const { sessions, cardEvents } = data;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg">
|
||||
{sessions.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Sessions</h4>
|
||||
<div className="space-y-1">
|
||||
{sessions.map((s) => (
|
||||
<div key={s.sessionId} className="flex items-center gap-3 text-xs">
|
||||
<span className="text-ctp-overlay2">
|
||||
{s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'}
|
||||
</span>
|
||||
<span className="text-ctp-blue">{formatDuration(s.activeWatchedMs)}</span>
|
||||
<span className="text-ctp-green">{formatNumber(s.cardsMined)} cards</span>
|
||||
<span className="text-ctp-peach">{formatNumber(s.wordsSeen)} words</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cardEvents.length > 0 && (
|
||||
<div className="p-3 border-b border-ctp-surface1">
|
||||
<h4 className="text-xs font-semibold text-ctp-subtext0 mb-2">Cards Mined</h4>
|
||||
<div className="space-y-1.5">
|
||||
{cardEvents.map((ev) => (
|
||||
<div key={ev.eventId} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-ctp-overlay2 shrink-0">
|
||||
{formatRelativeDate(ev.tsMs)}
|
||||
</span>
|
||||
{ev.noteIds.length > 0 ? (
|
||||
ev.noteIds.map((noteId) => {
|
||||
const info = noteInfos.get(noteId);
|
||||
return (
|
||||
<div key={noteId} className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{info?.expression && (
|
||||
<span className="text-ctp-text font-medium truncate">{info.expression}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
getStatsClient().ankiBrowse(noteId);
|
||||
}}
|
||||
className="px-1.5 py-0.5 bg-ctp-surface1 text-ctp-blue rounded text-[10px] hover:bg-ctp-surface2 transition-colors cursor-pointer shrink-0 ml-auto"
|
||||
>
|
||||
Open in Anki
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span className="text-ctp-green">
|
||||
+{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sessions.length === 0 && cardEvents.length === 0 && (
|
||||
<div className="p-3 text-xs text-ctp-overlay2">No detailed data available.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
stats/src/components/anime/EpisodeList.tsx
Normal file
134
stats/src/components/anime/EpisodeList.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { formatDuration, formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { EpisodeDetail } from './EpisodeDetail';
|
||||
import type { AnimeEpisode } from '../../types/stats';
|
||||
|
||||
interface EpisodeListProps {
|
||||
episodes: AnimeEpisode[];
|
||||
}
|
||||
|
||||
export function EpisodeList({ episodes: initialEpisodes }: EpisodeListProps) {
|
||||
const [expandedVideoId, setExpandedVideoId] = useState<number | null>(null);
|
||||
const [episodes, setEpisodes] = useState(initialEpisodes);
|
||||
|
||||
if (episodes.length === 0) return null;
|
||||
|
||||
const sorted = [...episodes].sort((a, b) => {
|
||||
if (a.episode != null && b.episode != null) return a.episode - b.episode;
|
||||
if (a.episode != null) return -1;
|
||||
if (b.episode != null) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const toggleWatched = async (videoId: number, currentWatched: number) => {
|
||||
const newWatched = currentWatched ? 0 : 1;
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: newWatched } : ep)),
|
||||
);
|
||||
try {
|
||||
await apiClient.setVideoWatched(videoId, newWatched === 1);
|
||||
} catch {
|
||||
setEpisodes((prev) =>
|
||||
prev.map((ep) => (ep.videoId === videoId ? { ...ep, watched: currentWatched } : ep)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const watchedCount = episodes.filter((ep) => ep.watched).length;
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Episodes</h3>
|
||||
<span className="text-xs text-ctp-overlay2">
|
||||
{watchedCount}/{episodes.length} watched
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-ctp-overlay2 border-b border-ctp-surface1">
|
||||
<th className="w-6 py-2 pr-1 font-medium" />
|
||||
<th className="text-left py-2 pr-3 font-medium">#</th>
|
||||
<th className="text-left py-2 pr-3 font-medium">Title</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Progress</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Watch Time</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Cards</th>
|
||||
<th className="text-right py-2 pr-3 font-medium">Last Watched</th>
|
||||
<th className="w-8 py-2 font-medium" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((ep, idx) => (
|
||||
<Fragment key={ep.videoId}>
|
||||
<tr
|
||||
onClick={() => setExpandedVideoId(expandedVideoId === ep.videoId ? null : ep.videoId)}
|
||||
className="border-b border-ctp-surface1 last:border-0 cursor-pointer hover:bg-ctp-surface1/50 transition-colors"
|
||||
>
|
||||
<td className="py-2 pr-1 text-ctp-overlay2 text-xs w-6">
|
||||
{expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-subtext0">
|
||||
{ep.episode ?? idx + 1}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ctp-text truncate max-w-[200px]">
|
||||
{ep.canonicalTitle}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right">
|
||||
{ep.durationMs > 0 ? (
|
||||
<span className={
|
||||
ep.totalActiveMs >= ep.durationMs * 0.85
|
||||
? 'text-ctp-green'
|
||||
: ep.totalActiveMs >= ep.durationMs * 0.5
|
||||
? 'text-ctp-peach'
|
||||
: 'text-ctp-overlay2'
|
||||
}>
|
||||
{Math.min(100, Math.round((ep.totalActiveMs / ep.durationMs) * 100))}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-ctp-overlay2">{'\u2014'}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-blue">
|
||||
{formatDuration(ep.totalActiveMs)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-green">
|
||||
{formatNumber(ep.totalCards)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ctp-overlay2">
|
||||
{ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'}
|
||||
</td>
|
||||
<td className="py-2 text-center w-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void toggleWatched(ep.videoId, ep.watched);
|
||||
}}
|
||||
className={`w-5 h-5 rounded border transition-colors ${
|
||||
ep.watched
|
||||
? 'bg-ctp-green border-ctp-green text-ctp-base'
|
||||
: 'border-ctp-surface2 hover:border-ctp-overlay0 text-transparent hover:text-ctp-overlay0'
|
||||
}`}
|
||||
title={ep.watched ? 'Mark as unwatched' : 'Mark as watched'}
|
||||
>
|
||||
{'\u2713'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expandedVideoId === ep.videoId && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-2">
|
||||
<EpisodeDetail videoId={ep.videoId} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
stats/src/components/layout/StatCard.tsx
Normal file
24
stats/src/components/layout/StatCard.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
subValue?: string;
|
||||
color?: string;
|
||||
trend?: { direction: 'up' | 'down' | 'flat'; text: string };
|
||||
}
|
||||
|
||||
export function StatCard({ label, value, subValue, color = 'text-ctp-text', trend }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4 text-center">
|
||||
<div className={`text-2xl font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-ctp-subtext0 mt-1 uppercase tracking-wide">{label}</div>
|
||||
{subValue && (
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">{subValue}</div>
|
||||
)}
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.direction === 'up' ? 'text-ctp-green' : trend.direction === 'down' ? 'text-ctp-red' : 'text-ctp-overlay2'}`}>
|
||||
{trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
stats/src/components/layout/TabBar.tsx
Normal file
46
stats/src/components/layout/TabBar.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
export type TabId = 'overview' | 'anime' | 'trends' | 'vocabulary' | 'sessions';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TABS: Tab[] = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'anime', label: 'Anime' },
|
||||
{ id: 'trends', label: 'Trends' },
|
||||
{ id: 'vocabulary', label: 'Vocabulary' },
|
||||
{ id: 'sessions', label: 'Sessions' },
|
||||
];
|
||||
|
||||
interface TabBarProps {
|
||||
activeTab: TabId;
|
||||
onTabChange: (tabId: TabId) => void;
|
||||
}
|
||||
|
||||
export function TabBar({ activeTab, onTabChange }: TabBarProps) {
|
||||
return (
|
||||
<nav className="flex border-b border-ctp-surface1" role="tablist" aria-label="Stats tabs">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
aria-selected={activeTab === tab.id}
|
||||
tabIndex={activeTab === tab.id ? 0 : -1}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`px-4 py-2.5 text-sm font-medium transition-colors
|
||||
${
|
||||
activeTab === tab.id
|
||||
? 'text-ctp-text border-b-2 border-ctp-lavender'
|
||||
: 'text-ctp-subtext0 hover:text-ctp-subtext1'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
30
stats/src/components/library/CoverImage.tsx
Normal file
30
stats/src/components/library/CoverImage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
|
||||
interface CoverImageProps {
|
||||
videoId: number;
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CoverImage({ videoId, title, className = '' }: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className={`bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-2xl font-bold ${className}`}>
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
alt={title}
|
||||
className={`object-cover bg-ctp-surface2 ${className}`}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
stats/src/components/library/LibraryTab.tsx
Normal file
57
stats/src/components/library/LibraryTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMediaLibrary } from '../../hooks/useMediaLibrary';
|
||||
import { formatDuration } from '../../lib/formatters';
|
||||
import { MediaCard } from './MediaCard';
|
||||
import { MediaDetailView } from './MediaDetailView';
|
||||
|
||||
export function LibraryTab() {
|
||||
const { media, loading, error } = useMediaLibrary();
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<number | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!search.trim()) return media;
|
||||
const q = search.toLowerCase();
|
||||
return media.filter((m) => m.canonicalTitle.toLowerCase().includes(q));
|
||||
}, [media, search]);
|
||||
|
||||
const totalMs = media.reduce((sum, m) => sum + m.totalActiveMs, 0);
|
||||
|
||||
if (selectedVideoId !== null) {
|
||||
return <MediaDetailView videoId={selectedVideoId} onBack={() => setSelectedVideoId(null)} />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search titles..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
<div className="text-xs text-ctp-overlay2 shrink-0">
|
||||
{filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2 p-4">No media found</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filtered.map((item) => (
|
||||
<MediaCard
|
||||
key={item.videoId}
|
||||
item={item}
|
||||
onClick={() => setSelectedVideoId(item.videoId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
stats/src/components/library/MediaCard.tsx
Normal file
33
stats/src/components/library/MediaCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber } from '../../lib/formatters';
|
||||
import type { MediaLibraryItem } from '../../types/stats';
|
||||
|
||||
interface MediaCardProps {
|
||||
item: MediaLibraryItem;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MediaCard({ item, onClick }: MediaCardProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg overflow-hidden hover:border-ctp-surface2 transition-colors text-left w-full"
|
||||
>
|
||||
<CoverImage
|
||||
videoId={item.videoId}
|
||||
title={item.canonicalTitle}
|
||||
className="w-full aspect-[3/4] rounded-t-lg"
|
||||
/>
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">{item.canonicalTitle}</div>
|
||||
<div className="text-xs text-ctp-overlay2 mt-1">
|
||||
{formatDuration(item.totalActiveMs)} · {formatNumber(item.totalCards)} cards
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{item.totalSessions} session{item.totalSessions !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
32
stats/src/components/library/MediaDetailView.tsx
Normal file
32
stats/src/components/library/MediaDetailView.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useMediaDetail } from '../../hooks/useMediaDetail';
|
||||
import { MediaHeader } from './MediaHeader';
|
||||
import { MediaWatchChart } from './MediaWatchChart';
|
||||
import { MediaSessionList } from './MediaSessionList';
|
||||
|
||||
interface MediaDetailViewProps {
|
||||
videoId: number;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function MediaDetailView({ videoId, onBack }: MediaDetailViewProps) {
|
||||
const { data, loading, error } = useMediaDetail(videoId);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data?.detail) return <div className="text-ctp-overlay2 p-4">Media not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="text-sm text-ctp-blue hover:text-ctp-sapphire transition-colors"
|
||||
>
|
||||
← Back to Library
|
||||
</button>
|
||||
<MediaHeader detail={data.detail} />
|
||||
<MediaWatchChart rollups={data.rollups} />
|
||||
<MediaSessionList sessions={data.sessions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
stats/src/components/library/MediaHeader.tsx
Normal file
55
stats/src/components/library/MediaHeader.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { CoverImage } from './CoverImage';
|
||||
import { formatDuration, formatNumber, formatPercent } from '../../lib/formatters';
|
||||
import type { MediaDetailData } from '../../types/stats';
|
||||
|
||||
interface MediaHeaderProps {
|
||||
detail: NonNullable<MediaDetailData['detail']>;
|
||||
}
|
||||
|
||||
export function MediaHeader({ detail }: MediaHeaderProps) {
|
||||
const hitRate = detail.totalLookupCount > 0
|
||||
? detail.totalLookupHits / detail.totalLookupCount
|
||||
: null;
|
||||
const avgSessionMs = detail.totalSessions > 0
|
||||
? Math.round(detail.totalActiveMs / detail.totalSessions)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<CoverImage
|
||||
videoId={detail.videoId}
|
||||
title={detail.canonicalTitle}
|
||||
className="w-32 h-44 rounded-lg shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-bold text-ctp-text truncate">{detail.canonicalTitle}</h2>
|
||||
<div className="grid grid-cols-2 gap-2 mt-3 text-sm">
|
||||
<div>
|
||||
<div className="text-ctp-blue font-medium">{formatDuration(detail.totalActiveMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">total watch time</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(detail.totalCards)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">cards mined</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(detail.totalWordsSeen)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">words seen</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-peach font-medium">{formatPercent(hitRate)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">lookup rate</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{detail.totalSessions}</div>
|
||||
<div className="text-xs text-ctp-overlay2">sessions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-text font-medium">{formatDuration(avgSessionMs)}</div>
|
||||
<div className="text-xs text-ctp-overlay2">avg session</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
stats/src/components/library/MediaSessionList.tsx
Normal file
40
stats/src/components/library/MediaSessionList.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { formatDuration, formatRelativeDate, formatNumber } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface MediaSessionListProps {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
export function MediaSessionList({ sessions }: MediaSessionListProps) {
|
||||
if (sessions.length === 0) {
|
||||
return <div className="text-sm text-ctp-overlay2">No sessions recorded</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Session History</h3>
|
||||
{sessions.map((s) => (
|
||||
<div
|
||||
key={s.sessionId}
|
||||
className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center justify-between"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm text-ctp-text">
|
||||
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
stats/src/components/library/MediaWatchChart.tsx
Normal file
79
stats/src/components/library/MediaWatchChart.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface MediaWatchChartProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
export function MediaWatchChart({ rollups }: MediaWatchChartProps) {
|
||||
const [range, setRange] = useState<Range>(30);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis dataKey="date" tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 10, fill: CHART_THEME.tick }} axisLine={false} tickLine={false} width={30} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
stats/src/components/overview/HeroStats.tsx
Normal file
51
stats/src/components/overview/HeroStats.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { formatDuration, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
|
||||
import type { OverviewSummary } from '../../lib/dashboard-data';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface HeroStatsProps {
|
||||
summary: OverviewSummary;
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
export function HeroStats({ summary, sessions }: HeroStatsProps) {
|
||||
const today = todayLocalDay();
|
||||
const sessionsToday = sessions.filter(
|
||||
(s) => localDayFromMs(s.startedAtMs) === today,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 xl:grid-cols-6 gap-3">
|
||||
<StatCard
|
||||
label="Watch Time Today"
|
||||
value={formatDuration(summary.todayActiveMs)}
|
||||
color="text-ctp-blue"
|
||||
/>
|
||||
<StatCard
|
||||
label="Cards Mined Today"
|
||||
value={formatNumber(summary.todayCards)}
|
||||
color="text-ctp-green"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sessions Today"
|
||||
value={formatNumber(sessionsToday)}
|
||||
color="text-ctp-lavender"
|
||||
/>
|
||||
<StatCard
|
||||
label="Episodes Today"
|
||||
value={formatNumber(summary.episodesToday)}
|
||||
color="text-ctp-teal"
|
||||
/>
|
||||
<StatCard
|
||||
label="Current Streak"
|
||||
value={`${summary.streakDays}d`}
|
||||
color="text-ctp-peach"
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Anime"
|
||||
value={formatNumber(summary.activeAnimeCount)}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
stats/src/components/overview/OverviewTab.tsx
Normal file
80
stats/src/components/overview/OverviewTab.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useOverview } from '../../hooks/useOverview';
|
||||
import { useStreakCalendar } from '../../hooks/useStreakCalendar';
|
||||
import { HeroStats } from './HeroStats';
|
||||
import { StreakCalendar } from './StreakCalendar';
|
||||
import { RecentSessions } from './RecentSessions';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildOverviewSummary, buildStreakCalendar } from '../../lib/dashboard-data';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
|
||||
export function OverviewTab() {
|
||||
const { data, sessions, loading, error } = useOverview();
|
||||
const { calendar, loading: calLoading } = useStreakCalendar(90);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
if (!data) return null;
|
||||
|
||||
const summary = buildOverviewSummary(data);
|
||||
const streakData = buildStreakCalendar(calendar);
|
||||
const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<HeroStats summary={summary} sessions={sessions} />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Last 14 Days Watch Time (min)"
|
||||
data={summary.recentWatchTime}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
{!calLoading && <StreakCalendar data={streakData} />}
|
||||
</div>
|
||||
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Tracking Snapshot</h3>
|
||||
{showTrackedCardNote && (
|
||||
<div className="mb-3 rounded-lg border border-ctp-surface2 bg-ctp-surface1/50 px-3 py-2 text-xs text-ctp-subtext0">
|
||||
No tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here.
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3 text-sm">
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Total Sessions</div>
|
||||
<div className="mt-1 text-xl font-semibold text-ctp-lavender">
|
||||
{formatNumber(summary.totalSessions)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Episodes Today</div>
|
||||
<div className="mt-1 text-xl font-semibold text-ctp-teal">
|
||||
{formatNumber(summary.episodesToday)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">All-Time Hours</div>
|
||||
<div className="mt-1 text-xl font-semibold text-ctp-mauve">
|
||||
{formatNumber(summary.allTimeHours)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Active Days</div>
|
||||
<div className="mt-1 text-xl font-semibold text-ctp-peach">
|
||||
{formatNumber(summary.activeDays)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface1/60 p-3">
|
||||
<div className="text-xs uppercase tracking-wide text-ctp-overlay2">Cards Mined</div>
|
||||
<div className="mt-1 text-xl font-semibold text-ctp-green">
|
||||
{formatNumber(summary.totalTrackedCards)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecentSessions sessions={sessions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
stats/src/components/overview/QuickStats.tsx
Normal file
46
stats/src/components/overview/QuickStats.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { todayLocalDay } from '../../lib/formatters';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface QuickStatsProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
export function QuickStats({ rollups }: QuickStatsProps) {
|
||||
const daysWithActivity = new Set(
|
||||
rollups.filter((r) => r.totalActiveMin > 0).map((r) => r.rollupDayOrMonth),
|
||||
);
|
||||
const today = todayLocalDay();
|
||||
const streakStart = daysWithActivity.has(today) ? today : today - 1;
|
||||
let streak = 0;
|
||||
for (let d = streakStart; daysWithActivity.has(d); d--) {
|
||||
streak++;
|
||||
}
|
||||
|
||||
const weekStart = today - 6;
|
||||
const weekRollups = rollups.filter((r) => r.rollupDayOrMonth >= weekStart);
|
||||
const weekMinutes = weekRollups.reduce((sum, r) => sum + r.totalActiveMin, 0);
|
||||
const weekCards = weekRollups.reduce((sum, r) => sum + r.totalCards, 0);
|
||||
const avgMinPerDay = Math.round(weekMinutes / 7);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Quick Stats</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Streak</span>
|
||||
<span className="text-ctp-peach font-medium">
|
||||
{streak} day{streak !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Avg/day this week</span>
|
||||
<span className="text-ctp-text">{avgMinPerDay}m</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-ctp-subtext0">Cards this week</span>
|
||||
<span className="text-ctp-green font-medium">{weekCards}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
stats/src/components/overview/RecentSessions.tsx
Normal file
242
stats/src/components/overview/RecentSessions.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { useState } from 'react';
|
||||
import { formatDuration, formatRelativeDate, formatNumber, todayLocalDay, localDayFromMs } from '../../lib/formatters';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface RecentSessionsProps {
|
||||
sessions: SessionSummary[];
|
||||
}
|
||||
|
||||
interface AnimeGroup {
|
||||
key: string;
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number | null;
|
||||
sessions: SessionSummary[];
|
||||
totalCards: number;
|
||||
totalWords: number;
|
||||
totalActiveMs: number;
|
||||
}
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
const groups = new Map<string, SessionSummary[]>();
|
||||
const today = todayLocalDay();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionDay = localDayFromMs(session.startedAtMs);
|
||||
let label: string;
|
||||
if (sessionDay === today) {
|
||||
label = 'Today';
|
||||
} else if (sessionDay === today - 1) {
|
||||
label = 'Yesterday';
|
||||
} else {
|
||||
label = new Date(session.startedAtMs).toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
const group = groups.get(label);
|
||||
if (group) {
|
||||
group.push(session);
|
||||
} else {
|
||||
groups.set(label, [session]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function groupSessionsByAnime(sessions: SessionSummary[]): AnimeGroup[] {
|
||||
const map = new Map<string, AnimeGroup>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.animeId != null
|
||||
? `anime-${session.animeId}`
|
||||
: session.videoId != null
|
||||
? `video-${session.videoId}`
|
||||
: `session-${session.sessionId}`;
|
||||
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.sessions.push(session);
|
||||
existing.totalCards += session.cardsMined;
|
||||
existing.totalWords += session.wordsSeen;
|
||||
existing.totalActiveMs += session.activeWatchedMs;
|
||||
} else {
|
||||
map.set(key, {
|
||||
key,
|
||||
animeId: session.animeId,
|
||||
animeTitle: session.animeTitle,
|
||||
videoId: session.videoId,
|
||||
sessions: [session],
|
||||
totalCards: session.cardsMined,
|
||||
totalWords: session.wordsSeen,
|
||||
totalActiveMs: session.activeWatchedMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (!videoId) {
|
||||
return (
|
||||
<div className="w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
alt=""
|
||||
className="w-12 h-16 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={(e) => {
|
||||
const target = e.currentTarget;
|
||||
target.style.display = 'none';
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'w-12 h-16 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-lg font-bold shrink-0';
|
||||
placeholder.textContent = fallbackChar;
|
||||
target.parentElement?.insertBefore(placeholder, target);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionItem({ session }: { session: SessionSummary }) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3">
|
||||
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimeGroupRow({ group }: { group: AnimeGroup }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (group.sessions.length === 1) {
|
||||
return <SessionItem session={group.sessions[0]!} />;
|
||||
}
|
||||
|
||||
const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media';
|
||||
const mostRecentSession = group.sessions[0]!;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<CoverThumbnail videoId={mostRecentSession.videoId} title={displayTitle} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{displayTitle}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{group.sessions.length} sessions · {formatDuration(group.totalActiveMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(group.totalCards)}</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(group.totalWords)}</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-overlay2 text-xs transition-transform ${expanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{group.sessions.map((s) => (
|
||||
<div
|
||||
key={s.sessionId}
|
||||
className="bg-ctp-mantle border border-ctp-surface0 rounded-lg p-2.5 flex items-center gap-3"
|
||||
>
|
||||
<CoverThumbnail videoId={s.videoId} title={s.canonicalTitle ?? 'Unknown'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-subtext1 truncate">
|
||||
{s.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(s.cardsMined)}</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(s.wordsSeen)}</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentSessions({ sessions }: RecentSessionsProps) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="text-sm text-ctp-overlay2">No sessions yet</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupSessionsByDay(sessions);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => {
|
||||
const animeGroups = groupSessionsByAnime(daySessions);
|
||||
return (
|
||||
<div key={dayLabel}>
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{animeGroups.map((group) => (
|
||||
<AnimeGroupRow key={group.key} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
stats/src/components/overview/StreakCalendar.tsx
Normal file
96
stats/src/components/overview/StreakCalendar.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import type { StreakCalendarPoint } from '../../lib/dashboard-data';
|
||||
|
||||
interface StreakCalendarProps {
|
||||
data: StreakCalendarPoint[];
|
||||
}
|
||||
|
||||
function intensityClass(value: number): string {
|
||||
if (value === 0) return 'bg-ctp-surface0';
|
||||
if (value <= 30) return 'bg-ctp-green/30';
|
||||
if (value <= 60) return 'bg-ctp-green/60';
|
||||
return 'bg-ctp-green';
|
||||
}
|
||||
|
||||
const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', ''];
|
||||
|
||||
export function StreakCalendar({ data }: StreakCalendarProps) {
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; text: string } | null>(null);
|
||||
|
||||
const lookup = new Map(data.map((d) => [d.date, d.value]));
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const endDate = new Date(today);
|
||||
const startDate = new Date(today);
|
||||
startDate.setDate(startDate.getDate() - 89);
|
||||
|
||||
const startDow = (startDate.getDay() + 6) % 7;
|
||||
|
||||
const cells: Array<{ date: string; value: number; row: number; col: number }> = [];
|
||||
let col = 0;
|
||||
let row = startDow;
|
||||
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= endDate) {
|
||||
const dateStr = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`;
|
||||
cells.push({ date: dateStr, value: lookup.get(dateStr) ?? 0, row, col });
|
||||
|
||||
row += 1;
|
||||
if (row >= 7) {
|
||||
row = 0;
|
||||
col += 1;
|
||||
}
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
|
||||
const totalCols = col + (row > 0 ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Activity (90 days)</h3>
|
||||
<div className="relative flex gap-1">
|
||||
<div className="flex flex-col gap-1 text-[10px] text-ctp-overlay2 pr-1 shrink-0">
|
||||
{DAY_LABELS.map((label, i) => (
|
||||
<div key={i} className="h-3 flex items-center leading-none">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-[3px]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${totalCols}, 12px)`,
|
||||
gridTemplateRows: 'repeat(7, 12px)',
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => (
|
||||
<div
|
||||
key={cell.date}
|
||||
className={`w-3 h-3 rounded-sm ${intensityClass(cell.value)} cursor-default`}
|
||||
style={{ gridRow: cell.row + 1, gridColumn: cell.col + 1 }}
|
||||
onMouseEnter={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setTooltip({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top - 4,
|
||||
text: `${cell.date}: ${Math.round(cell.value * 100) / 100}m`,
|
||||
});
|
||||
}}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{tooltip && (
|
||||
<div
|
||||
className="fixed z-50 px-2 py-1 text-xs bg-ctp-crust text-ctp-text rounded shadow-lg pointer-events-none -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: tooltip.x, top: tooltip.y }}
|
||||
>
|
||||
{tooltip.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
stats/src/components/overview/WatchTimeChart.tsx
Normal file
85
stats/src/components/overview/WatchTimeChart.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useState } from 'react';
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import type { DailyRollup } from '../../types/stats';
|
||||
|
||||
interface WatchTimeChartProps {
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
type Range = 14 | 30 | 90;
|
||||
|
||||
function formatActiveMinutes(value: number | string, _name?: string, _payload?: unknown) {
|
||||
const minutes = Number(value);
|
||||
return [`${Number.isFinite(minutes) ? minutes : 0} min`, 'Active Time'];
|
||||
}
|
||||
|
||||
export function WatchTimeChart({ rollups }: WatchTimeChartProps) {
|
||||
const [range, setRange] = useState<Range>(14);
|
||||
|
||||
const byDay = new Map<number, number>();
|
||||
for (const r of rollups) {
|
||||
byDay.set(r.rollupDayOrMonth, (byDay.get(r.rollupDayOrMonth) ?? 0) + r.totalActiveMin);
|
||||
}
|
||||
const chartData = Array.from(byDay.entries())
|
||||
.sort(([dayA], [dayB]) => dayA - dayB)
|
||||
.map(([day, mins]) => ({
|
||||
date: epochDayToDate(day).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
minutes: Math.round(mins),
|
||||
}))
|
||||
.slice(-range);
|
||||
|
||||
const ranges: Range[] = [14, 30, 90];
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">Watch Time</h3>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className={`px-2 py-0.5 text-xs rounded ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r}d
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<BarChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 10, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={30}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
}}
|
||||
labelStyle={{ color: CHART_THEME.tooltipLabel }}
|
||||
formatter={formatActiveMinutes}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill={CHART_THEME.barFill} radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
stats/src/components/sessions/SessionDetail.tsx
Normal file
121
stats/src/components/sessions/SessionDetail.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { useSessionDetail } from '../../hooks/useSessions';
|
||||
import { CHART_THEME } from '../../lib/chart-theme';
|
||||
import { EventType } from '../../types/stats';
|
||||
|
||||
interface SessionDetailProps {
|
||||
sessionId: number;
|
||||
cardsMined: number;
|
||||
}
|
||||
|
||||
const tooltipStyle = {
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 11,
|
||||
};
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Partial<Record<number, { color: string; label: string }>> = {
|
||||
[EventType.CARD_MINED]: { color: '#a6da95', label: 'Card mined' },
|
||||
[EventType.PAUSE_START]: { color: '#f5a97f', label: 'Pause' },
|
||||
};
|
||||
|
||||
export function SessionDetail({ sessionId, cardsMined }: SessionDetailProps) {
|
||||
const { timeline, events, loading, error } = useSessionDetail(sessionId);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 text-xs p-2">Loading timeline...</div>;
|
||||
if (error) return <div className="text-ctp-red text-xs p-2">Error: {error}</div>;
|
||||
|
||||
const chartData = [...timeline]
|
||||
.reverse()
|
||||
.map((t) => ({
|
||||
tsMs: t.sampleMs,
|
||||
time: formatTime(t.sampleMs),
|
||||
words: t.wordsSeen,
|
||||
cards: t.cardsMined,
|
||||
}));
|
||||
|
||||
const pauseCount = events.filter((e) => e.eventType === EventType.PAUSE_START).length;
|
||||
const seekCount = events.filter(
|
||||
(e) => e.eventType === EventType.SEEK_FORWARD || e.eventType === EventType.SEEK_BACKWARD,
|
||||
).length;
|
||||
const cardEventCount = events.filter((e) => e.eventType === EventType.CARD_MINED).length;
|
||||
|
||||
const markerEvents = events.filter((e) => EVENT_COLORS[e.eventType]);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-mantle border border-ctp-surface1 rounded-lg p-3 mt-1 space-y-3">
|
||||
{chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<LineChart data={chartData}>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 9, fill: CHART_THEME.tick }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={28}
|
||||
/>
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
<Line dataKey="words" stroke="#c6a0f6" strokeWidth={1.5} dot={false} name="Words" />
|
||||
<Line dataKey="cards" stroke="#a6da95" strokeWidth={1.5} dot={false} name="Cards" />
|
||||
{markerEvents.map((e, i) => {
|
||||
const cfg = EVENT_COLORS[e.eventType]!;
|
||||
const matchIdx = chartData.findIndex((d) => d.tsMs >= e.tsMs);
|
||||
const x = matchIdx >= 0 ? chartData[matchIdx]!.time : null;
|
||||
if (!x) return null;
|
||||
return (
|
||||
<ReferenceLine
|
||||
key={`${e.eventType}-${i}`}
|
||||
x={x}
|
||||
stroke={cfg.color}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.6}
|
||||
label=""
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-xs text-ctp-subtext0">
|
||||
<span>{pauseCount} pause{pauseCount !== 1 ? 's' : ''}</span>
|
||||
<span>{seekCount} seek{seekCount !== 1 ? 's' : ''}</span>
|
||||
<span className="text-ctp-green">{Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined</span>
|
||||
</div>
|
||||
|
||||
{markerEvents.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3 text-[10px]">
|
||||
{Object.entries(EVENT_COLORS).map(([type, cfg]) => {
|
||||
if (!cfg) return null;
|
||||
const count = markerEvents.filter((e) => e.eventType === Number(type)).length;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<span key={type} className="flex items-center gap-1">
|
||||
<span className="inline-block w-2.5 h-0.5 rounded" style={{ background: cfg.color }} />
|
||||
<span className="text-ctp-overlay2">{cfg.label} ({count})</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
stats/src/components/sessions/SessionRow.tsx
Normal file
76
stats/src/components/sessions/SessionRow.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { BASE_URL } from '../../lib/api-client';
|
||||
import {
|
||||
formatDuration,
|
||||
formatRelativeDate,
|
||||
formatNumber,
|
||||
} from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
interface SessionRowProps {
|
||||
session: SessionSummary;
|
||||
isExpanded: boolean;
|
||||
detailsId: string;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function CoverThumbnail({ videoId, title }: { videoId: number | null; title: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const fallbackChar = title.charAt(0) || '?';
|
||||
|
||||
if (!videoId || failed) {
|
||||
return (
|
||||
<div className="w-10 h-14 rounded bg-ctp-surface2 flex items-center justify-center text-ctp-overlay2 text-sm font-bold shrink-0">
|
||||
{fallbackChar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${BASE_URL}/api/stats/media/${videoId}/cover`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="w-10 h-14 rounded object-cover shrink-0 bg-ctp-surface2"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionRow({ session, isExpanded, detailsId, onToggle }: SessionRowProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={detailsId}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-3 flex items-center gap-3 hover:border-ctp-surface2 transition-colors text-left"
|
||||
>
|
||||
<CoverThumbnail videoId={session.videoId} title={session.canonicalTitle ?? 'Unknown'} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-ctp-text truncate">
|
||||
{session.canonicalTitle ?? 'Unknown Media'}
|
||||
</div>
|
||||
<div className="text-xs text-ctp-overlay2">
|
||||
{formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)}{' '}
|
||||
active
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs text-center shrink-0">
|
||||
<div>
|
||||
<div className="text-ctp-green font-medium">{formatNumber(session.cardsMined)}</div>
|
||||
<div className="text-ctp-overlay2">cards</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-ctp-mauve font-medium">{formatNumber(session.wordsSeen)}</div>
|
||||
<div className="text-ctp-overlay2">words</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-ctp-blue text-xs transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
>
|
||||
{'\u25B8'}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
99
stats/src/components/sessions/SessionsTab.tsx
Normal file
99
stats/src/components/sessions/SessionsTab.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useSessions } from '../../hooks/useSessions';
|
||||
import { SessionRow } from './SessionRow';
|
||||
import { SessionDetail } from './SessionDetail';
|
||||
import { todayLocalDay, localDayFromMs } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
function groupSessionsByDay(sessions: SessionSummary[]): Map<string, SessionSummary[]> {
|
||||
const groups = new Map<string, SessionSummary[]>();
|
||||
const today = todayLocalDay();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionDay = localDayFromMs(session.startedAtMs);
|
||||
let label: string;
|
||||
if (sessionDay === today) {
|
||||
label = 'Today';
|
||||
} else if (sessionDay === today - 1) {
|
||||
label = 'Yesterday';
|
||||
} else {
|
||||
label = new Date(session.startedAtMs).toLocaleDateString(undefined, {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
const group = groups.get(label);
|
||||
if (group) {
|
||||
group.push(session);
|
||||
} else {
|
||||
groups.set(label, [session]);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function SessionsTab() {
|
||||
const { sessions, loading, error } = useSessions();
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return sessions;
|
||||
return sessions.filter(
|
||||
(s) => s.canonicalTitle?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [sessions, search]);
|
||||
|
||||
const groups = useMemo(() => groupSessionsByDay(filtered), [filtered]);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by title..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-ctp-surface0 border border-ctp-surface1 rounded-lg px-3 py-2 text-sm text-ctp-text placeholder:text-ctp-overlay2 focus:outline-none focus:border-ctp-blue"
|
||||
/>
|
||||
|
||||
{Array.from(groups.entries()).map(([dayLabel, daySessions]) => (
|
||||
<div key={dayLabel}>
|
||||
<h3 className="text-xs font-semibold text-ctp-overlay2 uppercase tracking-wider mb-2">
|
||||
{dayLabel}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{daySessions.map((s) => {
|
||||
const detailsId = `session-details-${s.sessionId}`;
|
||||
return (
|
||||
<div key={s.sessionId}>
|
||||
<SessionRow
|
||||
session={s}
|
||||
isExpanded={expandedId === s.sessionId}
|
||||
detailsId={detailsId}
|
||||
onToggle={() => setExpandedId(expandedId === s.sessionId ? null : s.sessionId)}
|
||||
/>
|
||||
{expandedId === s.sessionId && (
|
||||
<div id={detailsId}>
|
||||
<SessionDetail sessionId={s.sessionId} cardsMined={s.cardsMined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<div className="text-ctp-overlay2 text-sm">
|
||||
{search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
stats/src/components/trends/DateRangeSelector.tsx
Normal file
58
stats/src/components/trends/DateRangeSelector.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { TimeRange, GroupBy } from '../../hooks/useTrends';
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
range: TimeRange;
|
||||
groupBy: GroupBy;
|
||||
onRangeChange: (r: TimeRange) => void;
|
||||
onGroupByChange: (g: GroupBy) => void;
|
||||
}
|
||||
|
||||
export function DateRangeSelector({
|
||||
range,
|
||||
groupBy,
|
||||
onRangeChange,
|
||||
onGroupByChange,
|
||||
}: DateRangeSelectorProps) {
|
||||
const ranges: TimeRange[] = ['7d', '30d', '90d', 'all'];
|
||||
const groups: GroupBy[] = ['day', 'month'];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Range</span>
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onRangeChange(r)}
|
||||
aria-pressed={range === r}
|
||||
className={`px-2.5 py-1 rounded text-xs ${
|
||||
range === r
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{r === 'all' ? 'All' : r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-ctp-surface2">{'\u00B7'}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider text-ctp-overlay1 mr-1">Group by</span>
|
||||
{groups.map((g) => (
|
||||
<button
|
||||
key={g}
|
||||
onClick={() => onGroupByChange(g)}
|
||||
aria-pressed={groupBy === g}
|
||||
className={`px-2.5 py-1 rounded text-xs capitalize ${
|
||||
groupBy === g
|
||||
? 'bg-ctp-surface2 text-ctp-text'
|
||||
: 'text-ctp-overlay2 hover:text-ctp-subtext0'
|
||||
}`}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
stats/src/components/trends/StackedTrendChart.tsx
Normal file
100
stats/src/components/trends/StackedTrendChart.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { epochDayToDate } from '../../lib/formatters';
|
||||
|
||||
export interface PerAnimeDataPoint {
|
||||
epochDay: number;
|
||||
animeTitle: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface StackedTrendChartProps {
|
||||
title: string;
|
||||
data: PerAnimeDataPoint[];
|
||||
}
|
||||
|
||||
const LINE_COLORS = [
|
||||
'#8aadf4', '#c6a0f6', '#a6da95', '#f5a97f', '#f5bde6',
|
||||
'#91d7e3', '#ee99a0', '#f4dbd6',
|
||||
];
|
||||
|
||||
function buildLineData(raw: PerAnimeDataPoint[]) {
|
||||
const totalByAnime = new Map<string, number>();
|
||||
for (const entry of raw) {
|
||||
totalByAnime.set(entry.animeTitle, (totalByAnime.get(entry.animeTitle) ?? 0) + entry.value);
|
||||
}
|
||||
|
||||
const sorted = [...totalByAnime.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const topTitles = sorted.slice(0, 7).map(([title]) => title);
|
||||
const topSet = new Set(topTitles);
|
||||
|
||||
const byDay = new Map<number, Record<string, number>>();
|
||||
for (const entry of raw) {
|
||||
if (!topSet.has(entry.animeTitle)) continue;
|
||||
const row = byDay.get(entry.epochDay) ?? {};
|
||||
row[entry.animeTitle] = (row[entry.animeTitle] ?? 0) + Math.round(entry.value * 10) / 10;
|
||||
byDay.set(entry.epochDay, row);
|
||||
}
|
||||
|
||||
const points = [...byDay.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([epochDay, values]) => ({
|
||||
label: epochDayToDate(epochDay).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }),
|
||||
...values,
|
||||
}));
|
||||
|
||||
return { points, seriesKeys: topTitles };
|
||||
}
|
||||
|
||||
export function StackedTrendChart({ title, data }: StackedTrendChartProps) {
|
||||
const { points, seriesKeys } = buildLineData(data);
|
||||
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
};
|
||||
|
||||
if (points.length === 0) {
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<div className="text-xs text-ctp-overlay2">No data</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
<LineChart data={points}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} />
|
||||
{seriesKeys.map((key, i) => (
|
||||
<Line
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
stroke={LINE_COLORS[i % LINE_COLORS.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-2">
|
||||
{seriesKeys.map((key, i) => (
|
||||
<span key={key} className="flex items-center gap-1 text-[10px] text-ctp-subtext0">
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: LINE_COLORS[i % LINE_COLORS.length] }}
|
||||
/>
|
||||
{key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
stats/src/components/trends/TrendChart.tsx
Normal file
43
stats/src/components/trends/TrendChart.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
BarChart, Bar, LineChart, Line,
|
||||
XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
interface TrendChartProps {
|
||||
title: string;
|
||||
data: Array<{ label: string; value: number }>;
|
||||
color: string;
|
||||
type: 'bar' | 'line';
|
||||
formatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
export function TrendChart({ title, data, color, type, formatter }: TrendChartProps) {
|
||||
const tooltipStyle = {
|
||||
background: '#363a4f', border: '1px solid #494d64', borderRadius: 6, color: '#cad3f5', fontSize: 12,
|
||||
};
|
||||
|
||||
const formatValue = (v: number) => formatter ? [formatter(v), title] : [String(v), title];
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-xs font-semibold text-ctp-text mb-2">{title}</h3>
|
||||
<ResponsiveContainer width="100%" height={120}>
|
||||
{type === 'bar' ? (
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Bar dataKey="value" fill={color} radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
) : (
|
||||
<LineChart data={data}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={{ fontSize: 9, fill: '#a5adcb' }} axisLine={false} tickLine={false} width={28} />
|
||||
<Tooltip contentStyle={tooltipStyle} formatter={formatValue} />
|
||||
<Line dataKey="value" stroke={color} strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
stats/src/components/trends/TrendsTab.tsx
Normal file
160
stats/src/components/trends/TrendsTab.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState } from 'react';
|
||||
import { useTrends, type TimeRange, type GroupBy } from '../../hooks/useTrends';
|
||||
import { DateRangeSelector } from './DateRangeSelector';
|
||||
import { TrendChart } from './TrendChart';
|
||||
import { StackedTrendChart, type PerAnimeDataPoint } from './StackedTrendChart';
|
||||
import { buildTrendDashboard, type ChartPoint } from '../../lib/dashboard-data';
|
||||
import { localDayFromMs } from '../../lib/formatters';
|
||||
import type { SessionSummary } from '../../types/stats';
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function buildWatchTimeByDayOfWeek(sessions: SessionSummary[]): ChartPoint[] {
|
||||
const totals = new Array(7).fill(0);
|
||||
for (const s of sessions) {
|
||||
const dow = new Date(s.startedAtMs).getDay();
|
||||
totals[dow] += s.activeWatchedMs;
|
||||
}
|
||||
return DAY_NAMES.map((name, i) => ({ label: name, value: Math.round(totals[i] / 60_000) }));
|
||||
}
|
||||
|
||||
function buildWatchTimeByHour(sessions: SessionSummary[]): ChartPoint[] {
|
||||
const totals = new Array(24).fill(0);
|
||||
for (const s of sessions) {
|
||||
const hour = new Date(s.startedAtMs).getHours();
|
||||
totals[hour] += s.activeWatchedMs;
|
||||
}
|
||||
return totals.map((ms, i) => ({
|
||||
label: `${String(i).padStart(2, '0')}:00`,
|
||||
value: Math.round(ms / 60_000),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildCumulativePerAnime(points: PerAnimeDataPoint[]): PerAnimeDataPoint[] {
|
||||
const byAnime = new Map<string, Map<number, number>>();
|
||||
for (const p of points) {
|
||||
const dayMap = byAnime.get(p.animeTitle) ?? new Map();
|
||||
dayMap.set(p.epochDay, (dayMap.get(p.epochDay) ?? 0) + p.value);
|
||||
byAnime.set(p.animeTitle, dayMap);
|
||||
}
|
||||
const result: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of byAnime) {
|
||||
const sorted = [...dayMap.entries()].sort(([a], [b]) => a - b);
|
||||
let cumulative = 0;
|
||||
for (const [epochDay, value] of sorted) {
|
||||
cumulative += value;
|
||||
result.push({ epochDay, animeTitle, value: cumulative });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildPerAnimeFromSessions(
|
||||
sessions: SessionSummary[],
|
||||
getValue: (s: SessionSummary) => number,
|
||||
): PerAnimeDataPoint[] {
|
||||
const map = new Map<string, Map<number, number>>();
|
||||
for (const s of sessions) {
|
||||
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
|
||||
const day = localDayFromMs(s.startedAtMs);
|
||||
const animeMap = map.get(title) ?? new Map();
|
||||
animeMap.set(day, (animeMap.get(day) ?? 0) + getValue(s));
|
||||
map.set(title, animeMap);
|
||||
}
|
||||
const points: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of map) {
|
||||
for (const [epochDay, value] of dayMap) {
|
||||
points.push({ epochDay, animeTitle, value });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function buildEpisodesPerAnimeFromSessions(sessions: SessionSummary[]): PerAnimeDataPoint[] {
|
||||
// Group by anime+day, counting distinct videoIds
|
||||
const map = new Map<string, Map<number, Set<number | null>>>();
|
||||
for (const s of sessions) {
|
||||
const title = s.animeTitle ?? s.canonicalTitle ?? 'Unknown';
|
||||
const day = localDayFromMs(s.startedAtMs);
|
||||
const animeMap = map.get(title) ?? new Map();
|
||||
const videoSet = animeMap.get(day) ?? new Set();
|
||||
videoSet.add(s.videoId);
|
||||
animeMap.set(day, videoSet);
|
||||
map.set(title, animeMap);
|
||||
}
|
||||
const points: PerAnimeDataPoint[] = [];
|
||||
for (const [animeTitle, dayMap] of map) {
|
||||
for (const [epochDay, videoSet] of dayMap) {
|
||||
points.push({ epochDay, animeTitle, value: videoSet.size });
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function SectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<h3 className="text-ctp-subtext0 text-sm font-medium uppercase tracking-wider mt-6 mb-2 col-span-full">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrendsTab() {
|
||||
const [range, setRange] = useState<TimeRange>('30d');
|
||||
const [groupBy, setGroupBy] = useState<GroupBy>('day');
|
||||
const { data, loading, error } = useTrends(range, groupBy);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
const dashboard = buildTrendDashboard(data.rollups);
|
||||
const watchByDow = buildWatchTimeByDayOfWeek(data.sessions);
|
||||
const watchByHour = buildWatchTimeByHour(data.sessions);
|
||||
|
||||
const watchTimePerAnime = data.watchTimePerAnime.map((e) => ({
|
||||
epochDay: e.epochDay, animeTitle: e.animeTitle, value: e.totalActiveMin,
|
||||
}));
|
||||
const episodesPerAnime = buildEpisodesPerAnimeFromSessions(data.sessions);
|
||||
const cardsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.cardsMined);
|
||||
const wordsPerAnime = buildPerAnimeFromSessions(data.sessions, (s) => s.wordsSeen);
|
||||
|
||||
const animeProgress = buildCumulativePerAnime(episodesPerAnime);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<DateRangeSelector
|
||||
range={range}
|
||||
groupBy={groupBy}
|
||||
onRangeChange={setRange}
|
||||
onGroupByChange={setGroupBy}
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<SectionHeader>Activity</SectionHeader>
|
||||
<TrendChart title="Watch Time (min)" data={dashboard.watchTime} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Cards Mined" data={dashboard.cards} color="#a6da95" type="bar" />
|
||||
<TrendChart title="Words Seen" data={dashboard.words} color="#8bd5ca" type="bar" />
|
||||
<TrendChart title="Sessions" data={dashboard.sessions} color="#b7bdf8" type="line" />
|
||||
<TrendChart
|
||||
title="Avg Session (min)"
|
||||
data={dashboard.averageSessionMinutes}
|
||||
color="#f5bde6"
|
||||
type="line"
|
||||
/>
|
||||
|
||||
<SectionHeader>Efficiency</SectionHeader>
|
||||
<TrendChart title="Cards per Hour" data={dashboard.cardsPerHour} color="#f5a97f" type="line" />
|
||||
|
||||
<SectionHeader>Anime</SectionHeader>
|
||||
<StackedTrendChart title="Anime Progress (episodes)" data={animeProgress} />
|
||||
<StackedTrendChart title="Watch Time per Anime (min)" data={watchTimePerAnime} />
|
||||
<StackedTrendChart title="Episodes per Anime" data={episodesPerAnime} />
|
||||
<StackedTrendChart title="Cards Mined per Anime" data={cardsPerAnime} />
|
||||
<StackedTrendChart title="Words Seen per Anime" data={wordsPerAnime} />
|
||||
|
||||
<SectionHeader>Patterns</SectionHeader>
|
||||
<TrendChart title="Watch Time by Day of Week (min)" data={watchByDow} color="#8aadf4" type="bar" />
|
||||
<TrendChart title="Watch Time by Hour (min)" data={watchByHour} color="#c6a0f6" type="bar" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal file
46
stats/src/components/vocabulary/KanjiBreakdown.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { KanjiEntry } from '../../types/stats';
|
||||
|
||||
interface KanjiBreakdownProps {
|
||||
kanji: KanjiEntry[];
|
||||
selectedKanjiId?: number | null;
|
||||
onSelectKanji?: (entry: KanjiEntry) => void;
|
||||
}
|
||||
|
||||
export function KanjiBreakdown({
|
||||
kanji,
|
||||
selectedKanjiId = null,
|
||||
onSelectKanji,
|
||||
}: KanjiBreakdownProps) {
|
||||
if (kanji.length === 0) return null;
|
||||
|
||||
const maxFreq = kanji.reduce((max, entry) => Math.max(max, entry.frequency), 1);
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-ctp-text mb-3">Kanji Encountered</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{kanji.map((k) => {
|
||||
const ratio = k.frequency / maxFreq;
|
||||
const opacity = Math.max(0.3, ratio);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={k.kanji}
|
||||
className={`cursor-pointer rounded px-1 text-lg text-ctp-teal transition ${
|
||||
selectedKanjiId === k.kanjiId
|
||||
? 'bg-ctp-teal/10 ring-1 ring-ctp-teal'
|
||||
: 'hover:bg-ctp-surface1/80'
|
||||
}`}
|
||||
style={{ opacity }}
|
||||
title={`${k.kanji} — seen ${k.frequency}x`}
|
||||
aria-label={`${k.kanji} — seen ${k.frequency} times`}
|
||||
onClick={() => onSelectKanji?.(k)}
|
||||
>
|
||||
{k.kanji}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
232
stats/src/components/vocabulary/KanjiDetailPanel.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useKanjiDetail } from '../../hooks/useKanjiDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
|
||||
const OCCURRENCES_PAGE_SIZE = 50;
|
||||
|
||||
interface KanjiDetailPanelProps {
|
||||
kanjiId: number | null;
|
||||
onClose: () => void;
|
||||
onSelectWord?: (wordId: number) => void;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function KanjiDetailPanel({ kanjiId, onClose, onSelectWord, onNavigateToAnime }: KanjiDetailPanelProps) {
|
||||
const { data, loading, error } = useKanjiDetail(kanjiId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
const [occLoadingMore, setOccLoadingMore] = useState(false);
|
||||
const [occError, setOccError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [occLoaded, setOccLoaded] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
if (kanjiId === null) return null;
|
||||
|
||||
const loadOccurrences = async (kanji: string, offset: number, append: boolean) => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
} else {
|
||||
setOccLoading(true);
|
||||
setOccError(null);
|
||||
}
|
||||
try {
|
||||
const rows = await apiClient.getKanjiOccurrences(kanji, OCCURRENCES_PAGE_SIZE, offset);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccError(err instanceof Error ? err.message : String(err));
|
||||
if (!append) {
|
||||
setOccurrences([]);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowOccurrences = () => {
|
||||
if (!data) return;
|
||||
void loadOccurrences(data.detail.kanji, 0, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || occLoadingMore || !hasMore) return;
|
||||
void loadOccurrences(data.detail.kanji, occurrences.length, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close kanji detail panel"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Kanji Detail</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h2 className="mt-1 text-5xl font-semibold text-ctp-teal">{data.detail.kanji}</h2>
|
||||
<div className="mt-2 text-sm text-ctp-subtext0">
|
||||
{formatNumber(data.detail.frequency)} total occurrences
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-teal">{formatNumber(data.detail.frequency)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map(a => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-teal hover:ring-1 hover:ring-ctp-teal text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-ctp-teal/10 px-2 py-0.5 text-[11px] font-medium text-ctp-teal">
|
||||
{formatNumber(a.occurrenceCount)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.words.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Words Using This Kanji</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.words.map(w => (
|
||||
<button
|
||||
key={w.wordId}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
|
||||
onClick={() => onSelectWord?.(w.wordId)}
|
||||
>
|
||||
{w.headword}
|
||||
<span className="opacity-60">({formatNumber(w.frequency)})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal"
|
||||
onClick={handleShowOccurrences}
|
||||
>
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
)}
|
||||
{occurrences.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occ, idx) => (
|
||||
<article
|
||||
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occ.animeTitle ?? occ.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occ.videoTitle} · line {occ.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-teal/10 px-2 py-1 text-[11px] font-medium text-ctp-teal">
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occ.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{occLoaded && !occLoading && !occError && hasMore && (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-teal hover:text-ctp-teal disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleLoadMore}
|
||||
disabled={occLoadingMore}
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
149
stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { KanjiEntry, VocabularyEntry, VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
|
||||
type VocabularyDrawerTarget =
|
||||
| {
|
||||
kind: 'word';
|
||||
entry: VocabularyEntry;
|
||||
}
|
||||
| {
|
||||
kind: 'kanji';
|
||||
entry: KanjiEntry;
|
||||
};
|
||||
|
||||
interface VocabularyOccurrencesDrawerProps {
|
||||
target: VocabularyDrawerTarget | null;
|
||||
occurrences: VocabularyOccurrenceEntry[];
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
onClose: () => void;
|
||||
onLoadMore: () => void;
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function renderTitle(target: VocabularyDrawerTarget): string {
|
||||
return target.kind === 'word' ? target.entry.headword : target.entry.kanji;
|
||||
}
|
||||
|
||||
function renderSubtitle(target: VocabularyDrawerTarget): string {
|
||||
if (target.kind === 'word') {
|
||||
return target.entry.reading || target.entry.word;
|
||||
}
|
||||
return `${formatNumber(target.entry.frequency)} seen`;
|
||||
}
|
||||
|
||||
function renderFrequency(target: VocabularyDrawerTarget): string {
|
||||
return `${formatNumber(target.entry.frequency)} total`;
|
||||
}
|
||||
|
||||
export function VocabularyOccurrencesDrawer({
|
||||
target,
|
||||
occurrences,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
hasMore,
|
||||
onClose,
|
||||
onLoadMore,
|
||||
}: VocabularyOccurrencesDrawerProps) {
|
||||
if (!target) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close occurrence drawer"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">
|
||||
{target.kind === 'word' ? 'Word Occurrences' : 'Kanji Occurrences'}
|
||||
</div>
|
||||
<h2 className="mt-1 truncate text-2xl font-semibold text-ctp-text">
|
||||
{renderTitle(target)}
|
||||
</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">{renderSubtitle(target)}</div>
|
||||
<div className="mt-2 text-xs text-ctp-overlay1">
|
||||
{renderFrequency(target)} · {formatNumber(occurrences.length)} loaded
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading ? <div className="text-sm text-ctp-overlay2">Loading occurrences...</div> : null}
|
||||
{!loading && error ? <div className="text-sm text-ctp-red">Error: {error}</div> : null}
|
||||
{!loading && !error && occurrences.length === 0 ? (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
) : null}
|
||||
{!loading && !error ? (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occurrence, index) => (
|
||||
<article
|
||||
key={`${occurrence.sessionId}-${occurrence.lineIndex}-${occurrence.segmentStartMs ?? index}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occurrence.animeTitle ?? occurrence.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occurrence.videoTitle} · line {occurrence.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(occurrence.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occurrence.segmentStartMs)}-{formatSegment(occurrence.segmentEndMs)} · session{' '}
|
||||
{occurrence.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occurrence.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!loading && !error && hasMore ? (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { VocabularyDrawerTarget };
|
||||
109
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
109
stats/src/components/vocabulary/VocabularyTab.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useVocabulary } from '../../hooks/useVocabulary';
|
||||
import { StatCard } from '../layout/StatCard';
|
||||
import { WordList } from './WordList';
|
||||
import { KanjiBreakdown } from './KanjiBreakdown';
|
||||
import { KanjiDetailPanel } from './KanjiDetailPanel';
|
||||
import { formatNumber } from '../../lib/formatters';
|
||||
import { TrendChart } from '../trends/TrendChart';
|
||||
import { buildVocabularySummary } from '../../lib/dashboard-data';
|
||||
import { isFilterable } from './pos-helpers';
|
||||
import type { KanjiEntry, VocabularyEntry } from '../../types/stats';
|
||||
|
||||
interface VocabularyTabProps {
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
onOpenWordDetail?: (wordId: number) => void;
|
||||
}
|
||||
|
||||
export function VocabularyTab({ onNavigateToAnime, onOpenWordDetail }: VocabularyTabProps) {
|
||||
const { words, kanji, loading, error } = useVocabulary();
|
||||
const [selectedKanjiId, setSelectedKanjiId] = useState<number | null>(null);
|
||||
const [hideParticles, setHideParticles] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredWords = useMemo(
|
||||
() => hideParticles ? words.filter(w => !isFilterable(w)) : words,
|
||||
[words, hideParticles],
|
||||
);
|
||||
|
||||
if (loading) return <div className="text-ctp-overlay2 p-4">Loading...</div>;
|
||||
if (error) return <div className="text-ctp-red p-4">Error: {error}</div>;
|
||||
|
||||
const summary = buildVocabularySummary(filteredWords, kanji);
|
||||
|
||||
const handleSelectWord = (entry: VocabularyEntry): void => {
|
||||
onOpenWordDetail?.(entry.wordId);
|
||||
};
|
||||
|
||||
const openKanjiDetail = (entry: KanjiEntry): void => {
|
||||
setSelectedKanjiId(entry.kanjiId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<StatCard label="Unique Words" value={formatNumber(summary.uniqueWords)} color="text-ctp-blue" />
|
||||
<StatCard label="Unique Kanji" value={formatNumber(summary.uniqueKanji)} color="text-ctp-green" />
|
||||
<StatCard
|
||||
label="New This Week"
|
||||
value={`+${formatNumber(summary.newThisWeek)}`}
|
||||
color="text-ctp-mauve"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-xs text-ctp-subtext0 select-none cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideParticles}
|
||||
onChange={(e) => setHideParticles(e.target.checked)}
|
||||
className="rounded border-ctp-surface2 bg-ctp-surface1 text-ctp-blue focus:ring-ctp-blue"
|
||||
/>
|
||||
Hide particles & single kana
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search words..."
|
||||
className="rounded border border-ctp-surface2 bg-ctp-surface1 px-3 py-1 text-xs text-ctp-text placeholder:text-ctp-overlay0 focus:border-ctp-blue focus:outline-none focus:ring-1 focus:ring-ctp-blue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<TrendChart
|
||||
title="Top Repeated Words"
|
||||
data={summary.topWords}
|
||||
color="#8aadf4"
|
||||
type="bar"
|
||||
/>
|
||||
<TrendChart
|
||||
title="New Words by Day"
|
||||
data={summary.newWordsTimeline}
|
||||
color="#c6a0f6"
|
||||
type="line"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<WordList
|
||||
words={filteredWords}
|
||||
selectedKey={null}
|
||||
onSelectWord={handleSelectWord}
|
||||
search={search}
|
||||
/>
|
||||
|
||||
<KanjiBreakdown
|
||||
kanji={kanji}
|
||||
selectedKanjiId={selectedKanjiId}
|
||||
onSelectKanji={openKanjiDetail}
|
||||
/>
|
||||
|
||||
<KanjiDetailPanel
|
||||
kanjiId={selectedKanjiId}
|
||||
onClose={() => setSelectedKanjiId(null)}
|
||||
onSelectWord={onOpenWordDetail}
|
||||
onNavigateToAnime={onNavigateToAnime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
246
stats/src/components/vocabulary/WordDetailPanel.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useWordDetail } from '../../hooks/useWordDetail';
|
||||
import { apiClient } from '../../lib/api-client';
|
||||
import { formatNumber, formatRelativeDate } from '../../lib/formatters';
|
||||
import type { VocabularyOccurrenceEntry } from '../../types/stats';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
|
||||
const OCCURRENCES_PAGE_SIZE = 50;
|
||||
|
||||
interface WordDetailPanelProps {
|
||||
wordId: number | null;
|
||||
onClose: () => void;
|
||||
onSelectWord?: (wordId: number) => void;
|
||||
onNavigateToAnime?: (animeId: number) => void;
|
||||
}
|
||||
|
||||
function formatSegment(ms: number | null): string {
|
||||
if (ms == null || !Number.isFinite(ms)) return '--:--';
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function WordDetailPanel({ wordId, onClose, onSelectWord, onNavigateToAnime }: WordDetailPanelProps) {
|
||||
const { data, loading, error } = useWordDetail(wordId);
|
||||
const [occurrences, setOccurrences] = useState<VocabularyOccurrenceEntry[]>([]);
|
||||
const [occLoading, setOccLoading] = useState(false);
|
||||
const [occLoadingMore, setOccLoadingMore] = useState(false);
|
||||
const [occError, setOccError] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [occLoaded, setOccLoaded] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
if (wordId === null) return null;
|
||||
|
||||
const loadOccurrences = async (detail: NonNullable<typeof data>['detail'], offset: number, append: boolean) => {
|
||||
const reqId = ++requestIdRef.current;
|
||||
if (append) {
|
||||
setOccLoadingMore(true);
|
||||
} else {
|
||||
setOccLoading(true);
|
||||
setOccError(null);
|
||||
}
|
||||
try {
|
||||
const rows = await apiClient.getWordOccurrences(
|
||||
detail.headword, detail.word, detail.reading,
|
||||
OCCURRENCES_PAGE_SIZE, offset,
|
||||
);
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccurrences(prev => append ? [...prev, ...rows] : rows);
|
||||
setHasMore(rows.length === OCCURRENCES_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccError(err instanceof Error ? err.message : String(err));
|
||||
if (!append) {
|
||||
setOccurrences([]);
|
||||
setHasMore(false);
|
||||
}
|
||||
} finally {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setOccLoading(false);
|
||||
setOccLoadingMore(false);
|
||||
setOccLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowOccurrences = () => {
|
||||
if (!data) return;
|
||||
void loadOccurrences(data.detail, 0, false);
|
||||
};
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (!data || occLoadingMore || !hasMore) return;
|
||||
void loadOccurrences(data.detail, occurrences.length, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-40">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close word detail panel"
|
||||
className="absolute inset-0 bg-ctp-crust/70 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside className="absolute right-0 top-0 h-full w-full max-w-xl border-l border-ctp-surface1 bg-ctp-mantle shadow-2xl">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-ctp-surface1 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs uppercase tracking-[0.18em] text-ctp-overlay1">Word Detail</div>
|
||||
{loading && <div className="mt-2 text-sm text-ctp-overlay2">Loading...</div>}
|
||||
{error && <div className="mt-2 text-sm text-ctp-red">Error: {error}</div>}
|
||||
{data && (
|
||||
<>
|
||||
<h2 className="mt-1 truncate text-3xl font-semibold text-ctp-text">{data.detail.headword}</h2>
|
||||
<div className="mt-1 text-sm text-ctp-subtext0">{data.detail.reading || data.detail.word}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{data.detail.partOfSpeech && <PosBadge pos={data.detail.partOfSpeech} />}
|
||||
{data.detail.pos1 && data.detail.pos1 !== data.detail.partOfSpeech && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos1}</span>
|
||||
)}
|
||||
{data.detail.pos2 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos2}</span>
|
||||
)}
|
||||
{data.detail.pos3 && (
|
||||
<span className="rounded-full bg-ctp-surface1 px-2 py-0.5 text-[11px] text-ctp-subtext0">{data.detail.pos3}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-ctp-surface2 px-3 py-1.5 text-xs font-medium text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-lg font-bold text-ctp-blue">{formatNumber(data.detail.frequency)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Frequency</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-green">{formatRelativeDate(data.detail.firstSeen)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">First Seen</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-ctp-surface0 p-3 text-center">
|
||||
<div className="text-sm font-medium text-ctp-mauve">{formatRelativeDate(data.detail.lastSeen)}</div>
|
||||
<div className="text-[11px] text-ctp-overlay1 uppercase">Last Seen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.animeAppearances.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Anime Appearances</h3>
|
||||
<div className="space-y-1.5">
|
||||
{data.animeAppearances.map(a => (
|
||||
<button
|
||||
key={a.animeId}
|
||||
type="button"
|
||||
onClick={() => { onClose(); onNavigateToAnime?.(a.animeId); }}
|
||||
className="w-full flex items-center justify-between rounded-lg bg-ctp-surface0 px-3 py-2 text-sm transition hover:border-ctp-blue hover:ring-1 hover:ring-ctp-blue text-left"
|
||||
>
|
||||
<span className="truncate text-ctp-text">{a.animeTitle}</span>
|
||||
<span className="ml-2 shrink-0 rounded-full bg-ctp-blue/10 px-2 py-0.5 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(a.occurrenceCount)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{data.similarWords.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Similar Words</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{data.similarWords.map(sw => (
|
||||
<button
|
||||
key={sw.wordId}
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-ctp-blue bg-ctp-blue/10 transition hover:ring-1 hover:ring-ctp-blue"
|
||||
onClick={() => onSelectWord?.(sw.wordId)}
|
||||
>
|
||||
{sw.headword}
|
||||
<span className="opacity-60">({formatNumber(sw.frequency)})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ctp-overlay1 mb-2">Example Lines</h3>
|
||||
{!occLoaded && !occLoading && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue"
|
||||
onClick={handleShowOccurrences}
|
||||
>
|
||||
Load example lines
|
||||
</button>
|
||||
)}
|
||||
{occLoading && <div className="text-sm text-ctp-overlay2">Loading occurrences...</div>}
|
||||
{occError && <div className="text-sm text-ctp-red">Error: {occError}</div>}
|
||||
{occLoaded && !occLoading && occurrences.length === 0 && (
|
||||
<div className="text-sm text-ctp-overlay2">No occurrences tracked yet.</div>
|
||||
)}
|
||||
{occurrences.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{occurrences.map((occ, idx) => (
|
||||
<article
|
||||
key={`${occ.sessionId}-${occ.lineIndex}-${occ.segmentStartMs ?? idx}`}
|
||||
className="rounded-xl border border-ctp-surface1 bg-ctp-surface0/90 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-ctp-text">
|
||||
{occ.animeTitle ?? occ.videoTitle}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ctp-subtext0">
|
||||
{occ.videoTitle} · line {occ.lineIndex}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full bg-ctp-blue/10 px-2 py-1 text-[11px] font-medium text-ctp-blue">
|
||||
{formatNumber(occ.occurrenceCount)} in line
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-ctp-overlay1">
|
||||
{formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId}
|
||||
</div>
|
||||
<p className="mt-3 rounded-lg bg-ctp-base/70 px-3 py-3 text-sm leading-6 text-ctp-text">
|
||||
{occ.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{occLoaded && !occLoading && !occError && hasMore && (
|
||||
<div className="border-t border-ctp-surface1 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg border border-ctp-surface2 bg-ctp-surface0 px-4 py-2 text-sm font-medium text-ctp-text transition hover:border-ctp-blue hover:text-ctp-blue disabled:cursor-not-allowed disabled:opacity-60"
|
||||
onClick={handleLoadMore}
|
||||
disabled={occLoadingMore}
|
||||
>
|
||||
{occLoadingMore ? 'Loading more...' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
stats/src/components/vocabulary/WordList.tsx
Normal file
126
stats/src/components/vocabulary/WordList.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
import { PosBadge } from './pos-helpers';
|
||||
|
||||
interface WordListProps {
|
||||
words: VocabularyEntry[];
|
||||
selectedKey?: string | null;
|
||||
onSelectWord?: (word: VocabularyEntry) => void;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
type SortKey = 'frequency' | 'lastSeen' | 'firstSeen';
|
||||
|
||||
function toWordKey(word: VocabularyEntry): string {
|
||||
return `${word.headword}\u0000${word.word}\u0000${word.reading}`;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function WordList({ words, selectedKey = null, onSelectWord, search = '' }: WordListProps) {
|
||||
const [sortBy, setSortBy] = useState<SortKey>('frequency');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const titleBySort: Record<SortKey, string> = {
|
||||
frequency: 'Most Seen Words',
|
||||
lastSeen: 'Recently Seen Words',
|
||||
firstSeen: 'First Seen Words',
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
if (!needle) return words;
|
||||
return words.filter(
|
||||
w => w.headword.toLowerCase().includes(needle)
|
||||
|| w.word.toLowerCase().includes(needle)
|
||||
|| w.reading.toLowerCase().includes(needle),
|
||||
);
|
||||
}, [words, search]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const copy = [...filtered];
|
||||
if (sortBy === 'frequency') copy.sort((a, b) => b.frequency - a.frequency);
|
||||
else if (sortBy === 'lastSeen') copy.sort((a, b) => b.lastSeen - a.lastSeen);
|
||||
else copy.sort((a, b) => b.firstSeen - a.firstSeen);
|
||||
return copy;
|
||||
}, [filtered, sortBy]);
|
||||
|
||||
const totalPages = Math.ceil(sorted.length / PAGE_SIZE);
|
||||
const paged = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
||||
const maxFreq = words.reduce((max, word) => Math.max(max, word.frequency), 1);
|
||||
|
||||
const getFrequencyColor = (freq: number) => {
|
||||
const ratio = freq / maxFreq;
|
||||
if (ratio > 0.5) return 'text-ctp-blue bg-ctp-blue/10';
|
||||
if (ratio > 0.2) return 'text-ctp-green bg-ctp-green/10';
|
||||
return 'text-ctp-mauve bg-ctp-mauve/10';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-ctp-surface0 border border-ctp-surface1 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ctp-text">
|
||||
{titleBySort[sortBy]}
|
||||
{search && <span className="ml-2 text-ctp-overlay1 font-normal">({filtered.length} matches)</span>}
|
||||
</h3>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => { setSortBy(e.target.value as SortKey); setPage(0); }}
|
||||
className="text-xs bg-ctp-surface1 text-ctp-subtext0 border border-ctp-surface2 rounded px-2 py-1"
|
||||
>
|
||||
<option value="frequency">Frequency</option>
|
||||
<option value="lastSeen">Last Seen</option>
|
||||
<option value="firstSeen">First Seen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{paged.map((w) => (
|
||||
<button
|
||||
type="button"
|
||||
key={toWordKey(w)}
|
||||
className={`inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs transition ${
|
||||
getFrequencyColor(w.frequency)
|
||||
} ${
|
||||
selectedKey === toWordKey(w)
|
||||
? 'ring-1 ring-ctp-blue ring-offset-1 ring-offset-ctp-surface0'
|
||||
: 'hover:ring-1 hover:ring-ctp-surface2'
|
||||
}`}
|
||||
title={`${w.word} (${w.reading}) — seen ${w.frequency}x`}
|
||||
onClick={() => onSelectWord?.(w)}
|
||||
>
|
||||
{w.headword}
|
||||
{w.partOfSpeech && (
|
||||
<PosBadge pos={w.partOfSpeech} />
|
||||
)}
|
||||
<span className="opacity-60">({w.frequency})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page === 0}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-ctp-overlay1">
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= totalPages - 1}
|
||||
className="rounded border border-ctp-surface2 px-2 py-0.5 text-xs text-ctp-subtext0 transition hover:border-ctp-blue hover:text-ctp-blue disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { toWordKey };
|
||||
37
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
37
stats/src/components/vocabulary/pos-helpers.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { VocabularyEntry } from '../../types/stats';
|
||||
|
||||
const POS_COLORS: Record<string, string> = {
|
||||
noun: 'bg-ctp-blue/15 text-ctp-blue',
|
||||
verb: 'bg-ctp-green/15 text-ctp-green',
|
||||
adjective: 'bg-ctp-mauve/15 text-ctp-mauve',
|
||||
adverb: 'bg-ctp-peach/15 text-ctp-peach',
|
||||
particle: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
auxiliary_verb: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
conjunction: 'bg-ctp-overlay0/15 text-ctp-overlay0',
|
||||
prenominal: 'bg-ctp-yellow/15 text-ctp-yellow',
|
||||
suffix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
|
||||
prefix: 'bg-ctp-flamingo/15 text-ctp-flamingo',
|
||||
interjection: 'bg-ctp-rosewater/15 text-ctp-rosewater',
|
||||
};
|
||||
|
||||
const DEFAULT_POS_COLOR = 'bg-ctp-surface1 text-ctp-subtext0';
|
||||
|
||||
export function posColor(pos: string): string {
|
||||
return POS_COLORS[pos] ?? DEFAULT_POS_COLOR;
|
||||
}
|
||||
|
||||
export function PosBadge({ pos }: { pos: string }) {
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${posColor(pos)}`}>
|
||||
{pos.replace(/_/g, ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const PARTICLE_POS = new Set(['particle', 'auxiliary_verb', 'conjunction']);
|
||||
|
||||
export function isFilterable(entry: VocabularyEntry): boolean {
|
||||
if (PARTICLE_POS.has(entry.partOfSpeech ?? '')) return true;
|
||||
if (entry.headword.length === 1 && /[\u3040-\u309F\u30A0-\u30FF]/.test(entry.headword)) return true;
|
||||
return false;
|
||||
}
|
||||
22
stats/src/hooks/useAnimeDetail.ts
Normal file
22
stats/src/hooks/useAnimeDetail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeDetailData } from '../types/stats';
|
||||
|
||||
export function useAnimeDetail(animeId: number | null) {
|
||||
const [data, setData] = useState<AnimeDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (animeId === null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getAnimeDetail(animeId)
|
||||
.then(setData)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [animeId]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
21
stats/src/hooks/useAnimeLibrary.ts
Normal file
21
stats/src/hooks/useAnimeLibrary.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { AnimeLibraryItem } from '../types/stats';
|
||||
|
||||
export function useAnimeLibrary() {
|
||||
const [anime, setAnime] = useState<AnimeLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getAnimeLibrary()
|
||||
.then((data) => { if (!cancelled) setAnime(data); })
|
||||
.catch((err: Error) => { if (!cancelled) setError(err.message); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
return { anime, loading, error };
|
||||
}
|
||||
22
stats/src/hooks/useKanjiDetail.ts
Normal file
22
stats/src/hooks/useKanjiDetail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { KanjiDetailData } from '../types/stats';
|
||||
|
||||
export function useKanjiDetail(kanjiId: number | null) {
|
||||
const [data, setData] = useState<KanjiDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (kanjiId === null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getKanjiDetail(kanjiId)
|
||||
.then(setData)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [kanjiId]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
22
stats/src/hooks/useMediaDetail.ts
Normal file
22
stats/src/hooks/useMediaDetail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { MediaDetailData } from '../types/stats';
|
||||
|
||||
export function useMediaDetail(videoId: number | null) {
|
||||
const [data, setData] = useState<MediaDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoId === null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getMediaDetail(videoId)
|
||||
.then(setData)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [videoId]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
19
stats/src/hooks/useMediaLibrary.ts
Normal file
19
stats/src/hooks/useMediaLibrary.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { MediaLibraryItem } from '../types/stats';
|
||||
|
||||
export function useMediaLibrary() {
|
||||
const [media, setMedia] = useState<MediaLibraryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getStatsClient()
|
||||
.getMediaLibrary()
|
||||
.then(setMedia)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { media, loading, error };
|
||||
}
|
||||
23
stats/src/hooks/useOverview.ts
Normal file
23
stats/src/hooks/useOverview.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { OverviewData, SessionSummary } from '../types/stats';
|
||||
|
||||
export function useOverview() {
|
||||
const [data, setData] = useState<OverviewData | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const client = getStatsClient();
|
||||
Promise.all([client.getOverview(), client.getSessions(50)])
|
||||
.then(([overview, allSessions]) => {
|
||||
setData(overview);
|
||||
setSessions(allSessions);
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { data, sessions, loading, error };
|
||||
}
|
||||
78
stats/src/hooks/useSessions.ts
Normal file
78
stats/src/hooks/useSessions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { SessionSummary, SessionTimelinePoint, SessionEvent } from '../types/stats';
|
||||
|
||||
export function useSessions(limit = 50) {
|
||||
const [sessions, setSessions] = useState<SessionSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const client = getStatsClient();
|
||||
client
|
||||
.getSessions(limit)
|
||||
.then((nextSessions) => {
|
||||
if (cancelled) return;
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [limit]);
|
||||
|
||||
return { sessions, loading, error };
|
||||
}
|
||||
|
||||
export function useSessionDetail(sessionId: number | null) {
|
||||
const [timeline, setTimeline] = useState<SessionTimelinePoint[]>([]);
|
||||
const [events, setEvents] = useState<SessionEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
if (sessionId == null) {
|
||||
setTimeline([]);
|
||||
setEvents([]);
|
||||
setLoading(false);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setLoading(true);
|
||||
setTimeline([]);
|
||||
setEvents([]);
|
||||
const client = getStatsClient();
|
||||
Promise.all([client.getSessionTimeline(sessionId), client.getSessionEvents(sessionId)])
|
||||
.then(([nextTimeline, nextEvents]) => {
|
||||
if (cancelled) return;
|
||||
setTimeline(nextTimeline);
|
||||
setEvents(nextEvents);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return { timeline, events, loading, error };
|
||||
}
|
||||
7
stats/src/hooks/useStatsApi.ts
Normal file
7
stats/src/hooks/useStatsApi.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { apiClient } from '../lib/api-client';
|
||||
|
||||
export type StatsClient = typeof apiClient;
|
||||
|
||||
export function getStatsClient(): StatsClient {
|
||||
return apiClient;
|
||||
}
|
||||
21
stats/src/hooks/useStreakCalendar.ts
Normal file
21
stats/src/hooks/useStreakCalendar.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { StreakCalendarDay } from '../types/stats';
|
||||
|
||||
export function useStreakCalendar(days = 90) {
|
||||
const [calendar, setCalendar] = useState<StreakCalendarDay[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getStatsClient()
|
||||
.getStreakCalendar(days)
|
||||
.then((data) => { if (!cancelled) setCalendar(data); })
|
||||
.catch((err: Error) => { if (!cancelled) setError(err.message); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [days]);
|
||||
|
||||
return { calendar, loading, error };
|
||||
}
|
||||
58
stats/src/hooks/useTrends.ts
Normal file
58
stats/src/hooks/useTrends.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { DailyRollup, MonthlyRollup, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime, SessionSummary, AnimeLibraryItem } from '../types/stats';
|
||||
|
||||
export type TimeRange = '7d' | '30d' | '90d' | 'all';
|
||||
export type GroupBy = 'day' | 'month';
|
||||
|
||||
export interface TrendsData {
|
||||
rollups: DailyRollup[] | MonthlyRollup[];
|
||||
episodesPerDay: EpisodesPerDay[];
|
||||
newAnimePerDay: NewAnimePerDay[];
|
||||
watchTimePerAnime: WatchTimePerAnime[];
|
||||
sessions: SessionSummary[];
|
||||
animeLibrary: AnimeLibraryItem[];
|
||||
}
|
||||
|
||||
export function useTrends(range: TimeRange, groupBy: GroupBy) {
|
||||
const [data, setData] = useState<TrendsData>({
|
||||
rollups: [],
|
||||
episodesPerDay: [],
|
||||
newAnimePerDay: [],
|
||||
watchTimePerAnime: [],
|
||||
sessions: [],
|
||||
animeLibrary: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const client = getStatsClient();
|
||||
const limitMap: Record<TimeRange, number> = { '7d': 7, '30d': 30, '90d': 90, all: 365 };
|
||||
const limit = limitMap[range];
|
||||
const monthlyLimit = Math.max(1, Math.ceil(limit / 30));
|
||||
|
||||
const rollupFetcher =
|
||||
groupBy === 'month'
|
||||
? client.getMonthlyRollups(monthlyLimit)
|
||||
: client.getDailyRollups(limit);
|
||||
|
||||
Promise.all([
|
||||
rollupFetcher,
|
||||
client.getEpisodesPerDay(limit),
|
||||
client.getNewAnimePerDay(limit),
|
||||
client.getWatchTimePerAnime(limit),
|
||||
client.getSessions(500),
|
||||
client.getAnimeLibrary(),
|
||||
])
|
||||
.then(([rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary]) => {
|
||||
setData({ rollups, episodesPerDay, newAnimePerDay, watchTimePerAnime, sessions, animeLibrary });
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [range, groupBy]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
39
stats/src/hooks/useVocabulary.ts
Normal file
39
stats/src/hooks/useVocabulary.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { VocabularyEntry, KanjiEntry } from '../types/stats';
|
||||
|
||||
export function useVocabulary() {
|
||||
const [words, setWords] = useState<VocabularyEntry[]>([]);
|
||||
const [kanji, setKanji] = useState<KanjiEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const client = getStatsClient();
|
||||
Promise.allSettled([client.getVocabulary(500), client.getKanji(200)])
|
||||
.then(([wordsResult, kanjiResult]) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (wordsResult.status === 'fulfilled') {
|
||||
setWords(wordsResult.value);
|
||||
} else {
|
||||
errors.push(wordsResult.reason.message);
|
||||
}
|
||||
|
||||
if (kanjiResult.status === 'fulfilled') {
|
||||
setKanji(kanjiResult.value);
|
||||
} else {
|
||||
errors.push(kanjiResult.reason.message);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join('; '));
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { words, kanji, loading, error };
|
||||
}
|
||||
22
stats/src/hooks/useWordDetail.ts
Normal file
22
stats/src/hooks/useWordDetail.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStatsClient } from './useStatsApi';
|
||||
import type { WordDetailData } from '../types/stats';
|
||||
|
||||
export function useWordDetail(wordId: number | null) {
|
||||
const [data, setData] = useState<WordDetailData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (wordId === null) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getStatsClient()
|
||||
.getWordDetail(wordId)
|
||||
.then(setData)
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [wordId]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
117
stats/src/lib/api-client.ts
Normal file
117
stats/src/lib/api-client.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
OverviewData,
|
||||
DailyRollup,
|
||||
MonthlyRollup,
|
||||
SessionSummary,
|
||||
SessionTimelinePoint,
|
||||
SessionEvent,
|
||||
VocabularyEntry,
|
||||
KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem,
|
||||
MediaDetailData,
|
||||
AnimeLibraryItem,
|
||||
AnimeDetailData,
|
||||
AnimeWord,
|
||||
StreakCalendarDay,
|
||||
EpisodesPerDay,
|
||||
NewAnimePerDay,
|
||||
WatchTimePerAnime,
|
||||
WordDetailData,
|
||||
KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
} from '../types/stats';
|
||||
|
||||
export const BASE_URL = window.location.protocol === 'file:'
|
||||
? 'http://127.0.0.1:5175'
|
||||
: window.location.origin;
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`);
|
||||
if (!res.ok) {
|
||||
let body = '';
|
||||
try {
|
||||
body = (await res.text()).trim();
|
||||
} catch {
|
||||
body = '';
|
||||
}
|
||||
throw new Error(
|
||||
body ? `Stats API error: ${res.status} ${body}` : `Stats API error: ${res.status}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
getOverview: () => fetchJson<OverviewData>('/api/stats/overview'),
|
||||
getDailyRollups: (limit = 60) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/daily-rollups?limit=${limit}`),
|
||||
getMonthlyRollups: (limit = 24) =>
|
||||
fetchJson<MonthlyRollup[]>(`/api/stats/monthly-rollups?limit=${limit}`),
|
||||
getSessions: (limit = 50) => fetchJson<SessionSummary[]>(`/api/stats/sessions?limit=${limit}`),
|
||||
getSessionTimeline: (id: number, limit = 200) =>
|
||||
fetchJson<SessionTimelinePoint[]>(`/api/stats/sessions/${id}/timeline?limit=${limit}`),
|
||||
getSessionEvents: (id: number, limit = 500) =>
|
||||
fetchJson<SessionEvent[]>(`/api/stats/sessions/${id}/events?limit=${limit}`),
|
||||
getVocabulary: (limit = 100) =>
|
||||
fetchJson<VocabularyEntry[]>(`/api/stats/vocabulary?limit=${limit}`),
|
||||
getWordOccurrences: (
|
||||
headword: string,
|
||||
word: string,
|
||||
reading: string,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
`/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
getKanji: (limit = 100) => fetchJson<KanjiEntry[]>(`/api/stats/kanji?limit=${limit}`),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
fetchJson<VocabularyOccurrenceEntry[]>(
|
||||
`/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`,
|
||||
),
|
||||
getMediaLibrary: () => fetchJson<MediaLibraryItem[]>('/api/stats/media'),
|
||||
getMediaDetail: (videoId: number) =>
|
||||
fetchJson<MediaDetailData>(`/api/stats/media/${videoId}`),
|
||||
getAnimeLibrary: () => fetchJson<AnimeLibraryItem[]>('/api/stats/anime'),
|
||||
getAnimeDetail: (animeId: number) =>
|
||||
fetchJson<AnimeDetailData>(`/api/stats/anime/${animeId}`),
|
||||
getAnimeWords: (animeId: number, limit = 50) =>
|
||||
fetchJson<AnimeWord[]>(`/api/stats/anime/${animeId}/words?limit=${limit}`),
|
||||
getAnimeRollups: (animeId: number, limit = 90) =>
|
||||
fetchJson<DailyRollup[]>(`/api/stats/anime/${animeId}/rollups?limit=${limit}`),
|
||||
getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`,
|
||||
getStreakCalendar: (days = 90) =>
|
||||
fetchJson<StreakCalendarDay[]>(`/api/stats/streak-calendar?days=${days}`),
|
||||
getEpisodesPerDay: (limit = 90) =>
|
||||
fetchJson<EpisodesPerDay[]>(`/api/stats/trends/episodes-per-day?limit=${limit}`),
|
||||
getNewAnimePerDay: (limit = 90) =>
|
||||
fetchJson<NewAnimePerDay[]>(`/api/stats/trends/new-anime-per-day?limit=${limit}`),
|
||||
getWatchTimePerAnime: (limit = 90) =>
|
||||
fetchJson<WatchTimePerAnime[]>(`/api/stats/trends/watch-time-per-anime?limit=${limit}`),
|
||||
getWordDetail: (wordId: number) =>
|
||||
fetchJson<WordDetailData>(`/api/stats/vocabulary/${wordId}/detail`),
|
||||
getKanjiDetail: (kanjiId: number) =>
|
||||
fetchJson<KanjiDetailData>(`/api/stats/kanji/${kanjiId}/detail`),
|
||||
getEpisodeDetail: (videoId: number) =>
|
||||
fetchJson<EpisodeDetailData>(`/api/stats/episode/${videoId}/detail`),
|
||||
setVideoWatched: async (videoId: number, watched: boolean): Promise<void> => {
|
||||
await fetch(`${BASE_URL}/api/stats/media/${videoId}/watched`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched }),
|
||||
});
|
||||
},
|
||||
ankiBrowse: async (noteId: number): Promise<void> => {
|
||||
await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' });
|
||||
},
|
||||
ankiNotesInfo: async (noteIds: number[]): Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>> => {
|
||||
const res = await fetch(`${BASE_URL}/api/stats/anki/notesInfo`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ noteIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Stats API error: ${res.status}`);
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
8
stats/src/lib/chart-theme.ts
Normal file
8
stats/src/lib/chart-theme.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const CHART_THEME = {
|
||||
tick: '#a5adcb',
|
||||
tooltipBg: '#363a4f',
|
||||
tooltipBorder: '#494d64',
|
||||
tooltipText: '#cad3f5',
|
||||
tooltipLabel: '#b8c0e0',
|
||||
barFill: '#8aadf4',
|
||||
} as const;
|
||||
137
stats/src/lib/dashboard-data.test.ts
Normal file
137
stats/src/lib/dashboard-data.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { DailyRollup, OverviewData, SessionSummary, StreakCalendarDay, VocabularyEntry } from '../types/stats';
|
||||
import {
|
||||
buildOverviewSummary,
|
||||
buildStreakCalendar,
|
||||
buildTrendDashboard,
|
||||
buildVocabularySummary,
|
||||
} from './dashboard-data';
|
||||
|
||||
test('buildOverviewSummary aggregates tracked totals and recent windows', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const sessions: SessionSummary[] = [
|
||||
{
|
||||
sessionId: 1,
|
||||
canonicalTitle: 'A',
|
||||
videoId: 1,
|
||||
animeId: null,
|
||||
animeTitle: null,
|
||||
startedAtMs: now - 3_600_000,
|
||||
endedAtMs: now - 1_800_000,
|
||||
totalWatchedMs: 3_600_000,
|
||||
activeWatchedMs: 3_000_000,
|
||||
linesSeen: 20,
|
||||
wordsSeen: 100,
|
||||
tokensSeen: 80,
|
||||
cardsMined: 2,
|
||||
lookupCount: 10,
|
||||
lookupHits: 8,
|
||||
},
|
||||
];
|
||||
const rollups: DailyRollup[] = [
|
||||
{
|
||||
rollupDayOrMonth: today,
|
||||
videoId: 1,
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 50,
|
||||
totalLinesSeen: 20,
|
||||
totalWordsSeen: 100,
|
||||
totalTokensSeen: 80,
|
||||
totalCards: 2,
|
||||
cardsPerHour: 2.4,
|
||||
wordsPerMin: 2,
|
||||
lookupHitRate: 0.8,
|
||||
},
|
||||
];
|
||||
const overview: OverviewData = {
|
||||
sessions,
|
||||
rollups,
|
||||
hints: { totalSessions: 1, activeSessions: 0, episodesToday: 2, activeAnimeCount: 3 },
|
||||
};
|
||||
|
||||
const summary = buildOverviewSummary(overview, now);
|
||||
assert.equal(summary.todayCards, 2);
|
||||
assert.equal(summary.totalTrackedCards, 2);
|
||||
assert.equal(summary.episodesToday, 2);
|
||||
assert.equal(summary.activeAnimeCount, 3);
|
||||
assert.equal(summary.averageSessionMinutes, 50);
|
||||
});
|
||||
|
||||
test('buildVocabularySummary treats firstSeen timestamps as seconds', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const nowSec = now / 1000;
|
||||
const words: VocabularyEntry[] = [
|
||||
{
|
||||
wordId: 1,
|
||||
headword: '猫',
|
||||
word: '猫',
|
||||
reading: 'ねこ',
|
||||
partOfSpeech: null,
|
||||
pos1: null,
|
||||
pos2: null,
|
||||
pos3: null,
|
||||
frequency: 4,
|
||||
firstSeen: nowSec - 2 * 86_400,
|
||||
lastSeen: nowSec - 1,
|
||||
},
|
||||
];
|
||||
|
||||
const summary = buildVocabularySummary(words, [], now);
|
||||
assert.equal(summary.newThisWeek, 1);
|
||||
});
|
||||
|
||||
test('buildTrendDashboard derives dense chart series', () => {
|
||||
const now = Date.UTC(2026, 2, 13, 12);
|
||||
const today = Math.floor(now / 86_400_000);
|
||||
const rollups: DailyRollup[] = [
|
||||
{
|
||||
rollupDayOrMonth: today - 1,
|
||||
videoId: 1,
|
||||
totalSessions: 2,
|
||||
totalActiveMin: 60,
|
||||
totalLinesSeen: 30,
|
||||
totalWordsSeen: 120,
|
||||
totalTokensSeen: 100,
|
||||
totalCards: 3,
|
||||
cardsPerHour: 3,
|
||||
wordsPerMin: 2,
|
||||
lookupHitRate: 0.5,
|
||||
},
|
||||
{
|
||||
rollupDayOrMonth: today,
|
||||
videoId: 1,
|
||||
totalSessions: 1,
|
||||
totalActiveMin: 30,
|
||||
totalLinesSeen: 10,
|
||||
totalWordsSeen: 40,
|
||||
totalTokensSeen: 30,
|
||||
totalCards: 1,
|
||||
cardsPerHour: 2,
|
||||
wordsPerMin: 1.33,
|
||||
lookupHitRate: 0.75,
|
||||
},
|
||||
];
|
||||
|
||||
const dashboard = buildTrendDashboard(rollups);
|
||||
assert.equal(dashboard.watchTime.length, 2);
|
||||
assert.equal(dashboard.words[1]?.value, 40);
|
||||
assert.equal(dashboard.sessions[0]?.value, 2);
|
||||
});
|
||||
|
||||
test('buildStreakCalendar converts epoch days to YYYY-MM-DD dates', () => {
|
||||
const days: StreakCalendarDay[] = [
|
||||
{ epochDay: 20525, totalActiveMin: 45 },
|
||||
{ epochDay: 20526, totalActiveMin: 0 },
|
||||
{ epochDay: 20527, totalActiveMin: 30 },
|
||||
];
|
||||
|
||||
const points = buildStreakCalendar(days);
|
||||
assert.equal(points.length, 3);
|
||||
assert.match(points[0]!.date, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.equal(points[0]!.value, 45);
|
||||
assert.equal(points[1]!.value, 0);
|
||||
assert.equal(points[2]!.value, 30);
|
||||
});
|
||||
224
stats/src/lib/dashboard-data.ts
Normal file
224
stats/src/lib/dashboard-data.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import type { DailyRollup, KanjiEntry, OverviewData, StreakCalendarDay, VocabularyEntry } from '../types/stats';
|
||||
import { epochDayToDate, localDayFromMs } from './formatters';
|
||||
|
||||
export interface ChartPoint {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface OverviewSummary {
|
||||
todayActiveMs: number;
|
||||
todayCards: number;
|
||||
streakDays: number;
|
||||
allTimeHours: number;
|
||||
totalTrackedCards: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
averageSessionMinutes: number;
|
||||
totalSessions: number;
|
||||
activeDays: number;
|
||||
recentWatchTime: ChartPoint[];
|
||||
}
|
||||
|
||||
export interface TrendDashboard {
|
||||
watchTime: ChartPoint[];
|
||||
cards: ChartPoint[];
|
||||
words: ChartPoint[];
|
||||
sessions: ChartPoint[];
|
||||
cardsPerHour: ChartPoint[];
|
||||
lookupHitRate: ChartPoint[];
|
||||
averageSessionMinutes: ChartPoint[];
|
||||
}
|
||||
|
||||
export interface VocabularySummary {
|
||||
uniqueWords: number;
|
||||
uniqueKanji: number;
|
||||
newThisWeek: number;
|
||||
topWords: ChartPoint[];
|
||||
newWordsTimeline: ChartPoint[];
|
||||
recentDiscoveries: VocabularyEntry[];
|
||||
}
|
||||
|
||||
function makeRollupLabel(value: number): string {
|
||||
if (value > 100_000) {
|
||||
const year = Math.floor(value / 100);
|
||||
const month = value % 100;
|
||||
return new Date(Date.UTC(year, month - 1, 1)).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
return epochDayToDate(value).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function sumBy<T>(values: T[], select: (value: T) => number): number {
|
||||
return values.reduce((sum, value) => sum + select(value), 0);
|
||||
}
|
||||
|
||||
function buildAggregatedDailyRows(rollups: DailyRollup[]) {
|
||||
const byKey = new Map<
|
||||
number,
|
||||
{
|
||||
activeMin: number;
|
||||
cards: number;
|
||||
words: number;
|
||||
sessions: number;
|
||||
lookupHitRateSum: number;
|
||||
lookupWeight: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const rollup of rollups) {
|
||||
const existing = byKey.get(rollup.rollupDayOrMonth) ?? {
|
||||
activeMin: 0,
|
||||
cards: 0,
|
||||
words: 0,
|
||||
sessions: 0,
|
||||
lookupHitRateSum: 0,
|
||||
lookupWeight: 0,
|
||||
};
|
||||
|
||||
existing.activeMin += rollup.totalActiveMin;
|
||||
existing.cards += rollup.totalCards;
|
||||
existing.words += rollup.totalWordsSeen;
|
||||
existing.sessions += rollup.totalSessions;
|
||||
if (rollup.lookupHitRate != null) {
|
||||
const weight = Math.max(rollup.totalSessions, 1);
|
||||
existing.lookupHitRateSum += rollup.lookupHitRate * weight;
|
||||
existing.lookupWeight += weight;
|
||||
}
|
||||
|
||||
byKey.set(rollup.rollupDayOrMonth, existing);
|
||||
}
|
||||
|
||||
return Array.from(byKey.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
label: makeRollupLabel(key),
|
||||
activeMin: Math.round(value.activeMin),
|
||||
cards: value.cards,
|
||||
words: value.words,
|
||||
sessions: value.sessions,
|
||||
cardsPerHour: value.activeMin > 0 ? +((value.cards * 60) / value.activeMin).toFixed(1) : 0,
|
||||
averageSessionMinutes:
|
||||
value.sessions > 0 ? +(value.activeMin / value.sessions).toFixed(1) : 0,
|
||||
lookupHitRate:
|
||||
value.lookupWeight > 0 ? Math.round((value.lookupHitRateSum / value.lookupWeight) * 100) : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildOverviewSummary(
|
||||
overview: OverviewData,
|
||||
nowMs: number = Date.now(),
|
||||
): OverviewSummary {
|
||||
const today = localDayFromMs(nowMs);
|
||||
const aggregated = buildAggregatedDailyRows(overview.rollups);
|
||||
const todayRow = aggregated.find((row) => row.key === today);
|
||||
const daysWithActivity = new Set(
|
||||
aggregated.filter((row) => row.activeMin > 0).map((row) => row.key),
|
||||
);
|
||||
|
||||
const sessionCards = sumBy(overview.sessions, (session) => session.cardsMined);
|
||||
const rollupCards = sumBy(aggregated, (row) => row.cards);
|
||||
|
||||
let streakDays = 0;
|
||||
const streakStart = daysWithActivity.has(today) ? today : today - 1;
|
||||
for (let day = streakStart; daysWithActivity.has(day); day -= 1) {
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
const todaySessions = overview.sessions.filter(
|
||||
(session) => localDayFromMs(session.startedAtMs) === today,
|
||||
);
|
||||
const todayActiveFromSessions = sumBy(todaySessions, (session) => session.activeWatchedMs);
|
||||
const todayActiveFromRollup = (todayRow?.activeMin ?? 0) * 60_000;
|
||||
|
||||
return {
|
||||
todayActiveMs: Math.max(todayActiveFromRollup, todayActiveFromSessions),
|
||||
todayCards: Math.max(todayRow?.cards ?? 0, sumBy(todaySessions, (session) => session.cardsMined)),
|
||||
streakDays,
|
||||
allTimeHours: Math.round(sumBy(aggregated, (row) => row.activeMin) / 60),
|
||||
totalTrackedCards: Math.max(sessionCards, rollupCards),
|
||||
episodesToday: overview.hints.episodesToday ?? 0,
|
||||
activeAnimeCount: overview.hints.activeAnimeCount ?? 0,
|
||||
averageSessionMinutes:
|
||||
overview.sessions.length > 0
|
||||
? Math.round(sumBy(overview.sessions, (session) => session.activeWatchedMs) / overview.sessions.length / 60_000)
|
||||
: 0,
|
||||
totalSessions: overview.hints.totalSessions,
|
||||
activeDays: daysWithActivity.size,
|
||||
recentWatchTime: aggregated.slice(-14).map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTrendDashboard(
|
||||
rollups: DailyRollup[],
|
||||
): TrendDashboard {
|
||||
const aggregated = buildAggregatedDailyRows(rollups);
|
||||
return {
|
||||
watchTime: aggregated.map((row) => ({ label: row.label, value: row.activeMin })),
|
||||
cards: aggregated.map((row) => ({ label: row.label, value: row.cards })),
|
||||
words: aggregated.map((row) => ({ label: row.label, value: row.words })),
|
||||
sessions: aggregated.map((row) => ({ label: row.label, value: row.sessions })),
|
||||
cardsPerHour: aggregated.map((row) => ({ label: row.label, value: row.cardsPerHour })),
|
||||
lookupHitRate: aggregated.map((row) => ({ label: row.label, value: row.lookupHitRate })),
|
||||
averageSessionMinutes: aggregated.map((row) => ({
|
||||
label: row.label,
|
||||
value: row.averageSessionMinutes,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVocabularySummary(
|
||||
words: VocabularyEntry[],
|
||||
kanji: KanjiEntry[],
|
||||
nowMs: number = Date.now(),
|
||||
): VocabularySummary {
|
||||
const weekAgoSec = nowMs / 1000 - 7 * 86_400;
|
||||
const byDay = new Map<number, number>();
|
||||
|
||||
for (const word of words) {
|
||||
const day = Math.floor(word.firstSeen / 86_400);
|
||||
byDay.set(day, (byDay.get(day) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
uniqueWords: words.length,
|
||||
uniqueKanji: kanji.length,
|
||||
newThisWeek: words.filter((word) => word.firstSeen >= weekAgoSec).length,
|
||||
topWords: [...words]
|
||||
.sort((left, right) => right.frequency - left.frequency)
|
||||
.slice(0, 12)
|
||||
.map((word) => ({ label: word.headword, value: word.frequency })),
|
||||
newWordsTimeline: Array.from(byDay.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.slice(-14)
|
||||
.map(([day, count]) => ({
|
||||
label: makeRollupLabel(day),
|
||||
value: count,
|
||||
})),
|
||||
recentDiscoveries: [...words]
|
||||
.sort((left, right) => right.firstSeen - left.firstSeen)
|
||||
.slice(0, 8),
|
||||
};
|
||||
}
|
||||
|
||||
export interface StreakCalendarPoint {
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export function buildStreakCalendar(days: StreakCalendarDay[]): StreakCalendarPoint[] {
|
||||
return days.map((d) => {
|
||||
const dt = epochDayToDate(d.epochDay);
|
||||
const y = dt.getUTCFullYear();
|
||||
const m = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return { date: `${y}-${m}-${day}`, value: d.totalActiveMin };
|
||||
});
|
||||
}
|
||||
45
stats/src/lib/formatters.test.ts
Normal file
45
stats/src/lib/formatters.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { formatRelativeDate } from './formatters';
|
||||
|
||||
test('formatRelativeDate: future timestamps return "just now"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() + 60_000), 'just now');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 0ms ago returns "just now"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now()), 'just now');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 30s ago returns "just now"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 30_000), 'just now');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 5 minutes ago returns "5m ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 5 * 60_000), '5m ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 59 minutes ago returns "59m ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 59 * 60_000), '59m ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 2 hours ago returns "2h ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 2 * 3_600_000), '2h ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 23 hours ago returns "23h ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 23 * 3_600_000), '23h ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 36 hours ago returns "Yesterday"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 36 * 3_600_000), 'Yesterday');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 5 days ago returns "5d ago"', () => {
|
||||
assert.equal(formatRelativeDate(Date.now() - 5 * 86_400_000), '5d ago');
|
||||
});
|
||||
|
||||
test('formatRelativeDate: 10 days ago returns locale date string', () => {
|
||||
const ts = Date.now() - 10 * 86_400_000;
|
||||
assert.equal(formatRelativeDate(ts), new Date(ts).toLocaleDateString());
|
||||
});
|
||||
44
stats/src/lib/formatters.ts
Normal file
44
stats/src/lib/formatters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
export function formatDuration(ms: number): string {
|
||||
const totalMin = Math.round(ms / 60_000);
|
||||
if (totalMin < 60) return `${totalMin}m`;
|
||||
const hours = Math.floor(totalMin / 60);
|
||||
const mins = totalMin % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatPercent(ratio: number | null): string {
|
||||
if (ratio == null) return '\u2014';
|
||||
return `${Math.round(ratio * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatRelativeDate(ms: number): string {
|
||||
const now = Date.now();
|
||||
const diffMs = now - ms;
|
||||
if (diffMs < 60_000) return 'just now';
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHours = Math.floor(diffMs / 3_600_000);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
const diffDays = Math.floor(diffMs / 86_400_000);
|
||||
if (diffDays < 2) return 'Yesterday';
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return new Date(ms).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function epochDayToDate(epochDay: number): Date {
|
||||
return new Date(epochDay * 86_400_000);
|
||||
}
|
||||
|
||||
export function localDayFromMs(ms: number): number {
|
||||
const d = new Date(ms);
|
||||
const localMidnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
return Math.floor(localMidnight / 86_400_000);
|
||||
}
|
||||
|
||||
export function todayLocalDay(): number {
|
||||
return localDayFromMs(Date.now());
|
||||
}
|
||||
96
stats/src/lib/ipc-client.ts
Normal file
96
stats/src/lib/ipc-client.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
OverviewData, DailyRollup, MonthlyRollup,
|
||||
SessionSummary, SessionTimelinePoint, SessionEvent,
|
||||
VocabularyEntry, KanjiEntry,
|
||||
VocabularyOccurrenceEntry,
|
||||
MediaLibraryItem, MediaDetailData,
|
||||
AnimeLibraryItem, AnimeDetailData, AnimeWord,
|
||||
StreakCalendarDay, EpisodesPerDay, NewAnimePerDay, WatchTimePerAnime,
|
||||
WordDetailData, KanjiDetailData,
|
||||
EpisodeDetailData,
|
||||
} from '../types/stats';
|
||||
|
||||
interface StatsElectronAPI {
|
||||
stats: {
|
||||
getOverview: () => Promise<OverviewData>;
|
||||
getDailyRollups: (limit?: number) => Promise<DailyRollup[]>;
|
||||
getMonthlyRollups: (limit?: number) => Promise<MonthlyRollup[]>;
|
||||
getSessions: (limit?: number) => Promise<SessionSummary[]>;
|
||||
getSessionTimeline: (id: number, limit?: number) => Promise<SessionTimelinePoint[]>;
|
||||
getSessionEvents: (id: number, limit?: number) => Promise<SessionEvent[]>;
|
||||
getVocabulary: (limit?: number) => Promise<VocabularyEntry[]>;
|
||||
getWordOccurrences: (
|
||||
headword: string,
|
||||
word: string,
|
||||
reading: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||
getKanji: (limit?: number) => Promise<KanjiEntry[]>;
|
||||
getKanjiOccurrences: (
|
||||
kanji: string,
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
) => Promise<VocabularyOccurrenceEntry[]>;
|
||||
getMediaLibrary: () => Promise<MediaLibraryItem[]>;
|
||||
getMediaDetail: (videoId: number) => Promise<MediaDetailData>;
|
||||
getAnimeLibrary: () => Promise<AnimeLibraryItem[]>;
|
||||
getAnimeDetail: (animeId: number) => Promise<AnimeDetailData>;
|
||||
getAnimeWords: (animeId: number, limit?: number) => Promise<AnimeWord[]>;
|
||||
getAnimeRollups: (animeId: number, limit?: number) => Promise<DailyRollup[]>;
|
||||
getAnimeCoverUrl: (animeId: number) => string;
|
||||
getStreakCalendar: (days?: number) => Promise<StreakCalendarDay[]>;
|
||||
getEpisodesPerDay: (limit?: number) => Promise<EpisodesPerDay[]>;
|
||||
getNewAnimePerDay: (limit?: number) => Promise<NewAnimePerDay[]>;
|
||||
getWatchTimePerAnime: (limit?: number) => Promise<WatchTimePerAnime[]>;
|
||||
getWordDetail: (wordId: number) => Promise<WordDetailData>;
|
||||
getKanjiDetail: (kanjiId: number) => Promise<KanjiDetailData>;
|
||||
getEpisodeDetail: (videoId: number) => Promise<EpisodeDetailData>;
|
||||
ankiBrowse: (noteId: number) => Promise<void>;
|
||||
ankiNotesInfo: (noteIds: number[]) => Promise<Array<{ noteId: number; fields: Record<string, { value: string }> }>>;
|
||||
hideOverlay: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: StatsElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
function getIpc(): StatsElectronAPI['stats'] {
|
||||
const api = window.electronAPI?.stats;
|
||||
if (!api) throw new Error('Electron IPC not available');
|
||||
return api;
|
||||
}
|
||||
|
||||
export const ipcClient = {
|
||||
getOverview: () => getIpc().getOverview(),
|
||||
getDailyRollups: (limit = 60) => getIpc().getDailyRollups(limit),
|
||||
getMonthlyRollups: (limit = 24) => getIpc().getMonthlyRollups(limit),
|
||||
getSessions: (limit = 50) => getIpc().getSessions(limit),
|
||||
getSessionTimeline: (id: number, limit = 200) => getIpc().getSessionTimeline(id, limit),
|
||||
getSessionEvents: (id: number, limit = 500) => getIpc().getSessionEvents(id, limit),
|
||||
getVocabulary: (limit = 100) => getIpc().getVocabulary(limit),
|
||||
getWordOccurrences: (headword: string, word: string, reading: string, limit = 50, offset = 0) =>
|
||||
getIpc().getWordOccurrences(headword, word, reading, limit, offset),
|
||||
getKanji: (limit = 100) => getIpc().getKanji(limit),
|
||||
getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) =>
|
||||
getIpc().getKanjiOccurrences(kanji, limit, offset),
|
||||
getMediaLibrary: () => getIpc().getMediaLibrary(),
|
||||
getMediaDetail: (videoId: number) => getIpc().getMediaDetail(videoId),
|
||||
getAnimeLibrary: () => getIpc().getAnimeLibrary(),
|
||||
getAnimeDetail: (animeId: number) => getIpc().getAnimeDetail(animeId),
|
||||
getAnimeWords: (animeId: number, limit = 50) => getIpc().getAnimeWords(animeId, limit),
|
||||
getAnimeRollups: (animeId: number, limit = 90) => getIpc().getAnimeRollups(animeId, limit),
|
||||
getAnimeCoverUrl: (animeId: number) => getIpc().getAnimeCoverUrl(animeId),
|
||||
getStreakCalendar: (days = 90) => getIpc().getStreakCalendar(days),
|
||||
getEpisodesPerDay: (limit = 90) => getIpc().getEpisodesPerDay(limit),
|
||||
getNewAnimePerDay: (limit = 90) => getIpc().getNewAnimePerDay(limit),
|
||||
getWatchTimePerAnime: (limit = 90) => getIpc().getWatchTimePerAnime(limit),
|
||||
getWordDetail: (wordId: number) => getIpc().getWordDetail(wordId),
|
||||
getKanjiDetail: (kanjiId: number) => getIpc().getKanjiDetail(kanjiId),
|
||||
getEpisodeDetail: (videoId: number) => getIpc().getEpisodeDetail(videoId),
|
||||
ankiBrowse: (noteId: number) => getIpc().ankiBrowse(noteId),
|
||||
ankiNotesInfo: (noteIds: number[]) => getIpc().ankiNotesInfo(noteIds),
|
||||
};
|
||||
18
stats/src/main.tsx
Normal file
18
stats/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
const isOverlay = new URLSearchParams(window.location.search).has('overlay');
|
||||
if (isOverlay) {
|
||||
document.body.classList.add('overlay-mode');
|
||||
}
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
41
stats/src/styles/globals.css
Normal file
41
stats/src/styles/globals.css
Normal file
@@ -0,0 +1,41 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-ctp-base: #24273a;
|
||||
--color-ctp-mantle: #1e2030;
|
||||
--color-ctp-crust: #181926;
|
||||
--color-ctp-surface0: #363a4f;
|
||||
--color-ctp-surface1: #494d64;
|
||||
--color-ctp-surface2: #5b6078;
|
||||
--color-ctp-text: #cad3f5;
|
||||
--color-ctp-subtext1: #b8c0e0;
|
||||
--color-ctp-subtext0: #a5adcb;
|
||||
--color-ctp-overlay2: #939ab7;
|
||||
--color-ctp-overlay1: #8087a2;
|
||||
--color-ctp-overlay0: #6e738d;
|
||||
--color-ctp-blue: #8aadf4;
|
||||
--color-ctp-green: #a6da95;
|
||||
--color-ctp-mauve: #c6a0f6;
|
||||
--color-ctp-peach: #f5a97f;
|
||||
--color-ctp-red: #ed8796;
|
||||
--color-ctp-yellow: #eed49f;
|
||||
--color-ctp-teal: #8bd5ca;
|
||||
--color-ctp-lavender: #b7bdf8;
|
||||
--color-ctp-flamingo: #f0c6c6;
|
||||
--color-ctp-rosewater: #f4dbd6;
|
||||
--color-ctp-sky: #91d7e3;
|
||||
--color-ctp-sapphire: #7dc4e4;
|
||||
--color-ctp-maroon: #ee99a0;
|
||||
--color-ctp-pink: #f5bde6;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||
background-color: var(--color-ctp-base);
|
||||
color: var(--color-ctp-text);
|
||||
}
|
||||
|
||||
body.overlay-mode {
|
||||
background-color: rgba(36, 39, 58, 0.85);
|
||||
}
|
||||
285
stats/src/types/stats.ts
Normal file
285
stats/src/types/stats.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
export interface SessionSummary {
|
||||
sessionId: number;
|
||||
canonicalTitle: string | null;
|
||||
videoId: number | null;
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
startedAtMs: number;
|
||||
endedAtMs: number | null;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
lookupCount: number;
|
||||
lookupHits: number;
|
||||
}
|
||||
|
||||
export interface DailyRollup {
|
||||
rollupDayOrMonth: number;
|
||||
videoId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMin: number;
|
||||
totalLinesSeen: number;
|
||||
totalWordsSeen: number;
|
||||
totalTokensSeen: number;
|
||||
totalCards: number;
|
||||
cardsPerHour: number | null;
|
||||
wordsPerMin: number | null;
|
||||
lookupHitRate: number | null;
|
||||
}
|
||||
|
||||
export type MonthlyRollup = DailyRollup;
|
||||
|
||||
export interface SessionTimelinePoint {
|
||||
sampleMs: number;
|
||||
totalWatchedMs: number;
|
||||
activeWatchedMs: number;
|
||||
linesSeen: number;
|
||||
wordsSeen: number;
|
||||
tokensSeen: number;
|
||||
cardsMined: number;
|
||||
}
|
||||
|
||||
export interface SessionEvent {
|
||||
eventType: EventType;
|
||||
tsMs: number;
|
||||
payload: string | null;
|
||||
}
|
||||
|
||||
export interface VocabularyEntry {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface KanjiEntry {
|
||||
kanjiId: number;
|
||||
kanji: string;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export interface VocabularyOccurrenceEntry {
|
||||
animeId: number | null;
|
||||
animeTitle: string | null;
|
||||
videoId: number;
|
||||
videoTitle: string;
|
||||
sessionId: number;
|
||||
lineIndex: number;
|
||||
segmentStartMs: number | null;
|
||||
segmentEndMs: number | null;
|
||||
text: string;
|
||||
occurrenceCount: number;
|
||||
}
|
||||
|
||||
export interface OverviewData {
|
||||
sessions: SessionSummary[];
|
||||
rollups: DailyRollup[];
|
||||
hints: {
|
||||
totalSessions: number;
|
||||
activeSessions: number;
|
||||
episodesToday: number;
|
||||
activeAnimeCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MediaLibraryItem {
|
||||
videoId: number;
|
||||
canonicalTitle: string;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
lastWatchedMs: number;
|
||||
hasCoverArt: number;
|
||||
}
|
||||
|
||||
export interface MediaDetailData {
|
||||
detail: {
|
||||
videoId: number;
|
||||
canonicalTitle: string;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
} | null;
|
||||
sessions: SessionSummary[];
|
||||
rollups: DailyRollup[];
|
||||
}
|
||||
|
||||
export const EventType = {
|
||||
SUBTITLE_LINE: 1,
|
||||
MEDIA_BUFFER: 2,
|
||||
LOOKUP: 3,
|
||||
CARD_MINED: 4,
|
||||
SEEK_FORWARD: 5,
|
||||
SEEK_BACKWARD: 6,
|
||||
PAUSE_START: 7,
|
||||
PAUSE_END: 8,
|
||||
} as const;
|
||||
|
||||
export type EventType = (typeof EventType)[keyof typeof EventType];
|
||||
|
||||
export interface AnimeLibraryItem {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
episodeCount: number;
|
||||
episodesTotal: number | null;
|
||||
lastWatchedMs: number;
|
||||
}
|
||||
|
||||
export interface AnilistEntry {
|
||||
anilistId: number;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
season: number | null;
|
||||
}
|
||||
|
||||
export interface AnimeDetailData {
|
||||
detail: {
|
||||
animeId: number;
|
||||
canonicalTitle: string;
|
||||
anilistId: number | null;
|
||||
titleRomaji: string | null;
|
||||
titleEnglish: string | null;
|
||||
titleNative: string | null;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
totalWordsSeen: number;
|
||||
totalLinesSeen: number;
|
||||
totalLookupCount: number;
|
||||
totalLookupHits: number;
|
||||
episodeCount: number;
|
||||
lastWatchedMs: number;
|
||||
};
|
||||
episodes: AnimeEpisode[];
|
||||
anilistEntries: AnilistEntry[];
|
||||
}
|
||||
|
||||
export interface AnimeEpisode {
|
||||
videoId: number;
|
||||
episode: number | null;
|
||||
season: number | null;
|
||||
durationMs: number;
|
||||
watched: number;
|
||||
canonicalTitle: string;
|
||||
totalSessions: number;
|
||||
totalActiveMs: number;
|
||||
totalCards: number;
|
||||
lastWatchedMs: number;
|
||||
}
|
||||
|
||||
export interface AnimeWord {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
frequency: number;
|
||||
}
|
||||
|
||||
export interface StreakCalendarDay {
|
||||
epochDay: number;
|
||||
totalActiveMin: number;
|
||||
}
|
||||
|
||||
export interface EpisodesPerDay {
|
||||
epochDay: number;
|
||||
episodeCount: number;
|
||||
}
|
||||
|
||||
export interface NewAnimePerDay {
|
||||
epochDay: number;
|
||||
newAnimeCount: number;
|
||||
}
|
||||
|
||||
export interface WatchTimePerAnime {
|
||||
epochDay: number;
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
totalActiveMin: number;
|
||||
}
|
||||
|
||||
export interface WordDetailData {
|
||||
detail: {
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
partOfSpeech: string | null;
|
||||
pos1: string | null;
|
||||
pos2: string | null;
|
||||
pos3: string | null;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
animeAppearances: Array<{
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
occurrenceCount: number;
|
||||
}>;
|
||||
similarWords: Array<{
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
frequency: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface EpisodeCardEvent {
|
||||
eventId: number;
|
||||
sessionId: number;
|
||||
tsMs: number;
|
||||
cardsDelta: number;
|
||||
noteIds: number[];
|
||||
}
|
||||
|
||||
export interface EpisodeDetailData {
|
||||
sessions: SessionSummary[];
|
||||
words: AnimeWord[];
|
||||
cardEvents: EpisodeCardEvent[];
|
||||
}
|
||||
|
||||
export interface KanjiDetailData {
|
||||
detail: {
|
||||
kanjiId: number;
|
||||
kanji: string;
|
||||
frequency: number;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
};
|
||||
animeAppearances: Array<{
|
||||
animeId: number;
|
||||
animeTitle: string;
|
||||
occurrenceCount: number;
|
||||
}>;
|
||||
words: Array<{
|
||||
wordId: number;
|
||||
headword: string;
|
||||
word: string;
|
||||
reading: string;
|
||||
frequency: number;
|
||||
}>;
|
||||
}
|
||||
19
stats/tsconfig.json
Normal file
19
stats/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
stats/vite.config.ts
Normal file
12
stats/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user