diff --git a/stats/bun.lock b/stats/bun.lock new file mode 100644 index 0000000..6997aba --- /dev/null +++ b/stats/bun.lock @@ -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=="], + } +} diff --git a/stats/index.html b/stats/index.html new file mode 100644 index 0000000..f1dba12 --- /dev/null +++ b/stats/index.html @@ -0,0 +1,13 @@ + + + + + + + SubMiner Stats + + +
+ + + diff --git a/stats/package.json b/stats/package.json new file mode 100644 index 0000000..fd1c580 --- /dev/null +++ b/stats/package.json @@ -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" + } +} diff --git a/stats/public/favicon.png b/stats/public/favicon.png new file mode 100644 index 0000000..ed163b0 Binary files /dev/null and b/stats/public/favicon.png differ diff --git a/stats/src/App.tsx b/stats/src/App.tsx new file mode 100644 index 0000000..e71d5d4 --- /dev/null +++ b/stats/src/App.tsx @@ -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('overview'); + const [selectedAnimeId, setSelectedAnimeId] = useState(null); + const [globalWordId, setGlobalWordId] = useState(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 ( +
+
+

SubMiner Stats

+ +
+
+ + + + + +
+ setGlobalWordId(null)} + onSelectWord={openWordDetail} + onNavigateToAnime={navigateToAnime} + /> +
+ ); +} diff --git a/stats/src/components/anime/AnimeCard.tsx b/stats/src/components/anime/AnimeCard.tsx new file mode 100644 index 0000000..bee479e --- /dev/null +++ b/stats/src/components/anime/AnimeCard.tsx @@ -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 ( + + ); +} diff --git a/stats/src/components/anime/AnimeCardsList.tsx b/stats/src/components/anime/AnimeCardsList.tsx new file mode 100644 index 0000000..c4e6864 --- /dev/null +++ b/stats/src/components/anime/AnimeCardsList.tsx @@ -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(null); + + if (totalCards === 0) { + return ( + +

No cards mined from this anime yet.

+
+ ); + } + + const withCards = episodes.filter((ep) => ep.totalCards > 0); + + return ( + + + + + + + + + + + {withCards.map((ep) => ( + + 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" + > + + + + + + {expandedVideoId === ep.videoId && ( + + + + )} + + ))} + +
+ EpisodeCardsLast Watched
+ {expandedVideoId === ep.videoId ? '▼' : '▶'} + + + {ep.episode != null ? `#${ep.episode}` : ''} + + {ep.canonicalTitle} + + {formatNumber(ep.totalCards)} + + {ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'} +
+ +
+
+ ); +} diff --git a/stats/src/components/anime/AnimeCoverImage.tsx b/stats/src/components/anime/AnimeCoverImage.tsx new file mode 100644 index 0000000..69dff53 --- /dev/null +++ b/stats/src/components/anime/AnimeCoverImage.tsx @@ -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 ( +
+ {fallbackChar} +
+ ); + } + + const src = getStatsClient().getAnimeCoverUrl(animeId); + + return ( + {title} setFailed(true)} + /> + ); +} diff --git a/stats/src/components/anime/AnimeDetailView.tsx b/stats/src/components/anime/AnimeDetailView.tsx new file mode 100644 index 0000000..2ed7c60 --- /dev/null +++ b/stats/src/components/anime/AnimeDetailView.tsx @@ -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([]); + const [range, setRange] = useState(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(); + 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 ( +
+
+

Watch Time

+
+ {ranges.map((r) => ( + + ))} +
+
+ + + + + + + + +
+ ); +} + +export function AnimeDetailView({ animeId, onBack, onNavigateToWord }: AnimeDetailViewProps) { + const { data, loading, error } = useAnimeDetail(animeId); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + if (!data?.detail) return
Anime not found
; + + const { detail, episodes, anilistEntries } = data; + const avgSessionMs = detail.totalSessions > 0 + ? Math.round(detail.totalActiveMs / detail.totalSessions) + : 0; + + return ( +
+ + +
+ + + + + +
+ + + +
+ ); +} diff --git a/stats/src/components/anime/AnimeHeader.tsx b/stats/src/components/anime/AnimeHeader.tsx new file mode 100644 index 0000000..37b9e79 --- /dev/null +++ b/stats/src/components/anime/AnimeHeader.tsx @@ -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 ( + + {label} + {'\u2197'} + + ); +} + +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 ( +
+ +
+

{detail.canonicalTitle}

+ {uniqueAltTitles.length > 0 && ( +
+ {uniqueAltTitles.join(' · ')} +
+ )} +
+ {detail.episodeCount} episode{detail.episodeCount !== 1 ? 's' : ''} +
+ {anilistEntries.length > 0 ? ( +
+ {hasMultipleEntries ? ( + anilistEntries.map((entry) => ( + + )) + ) : ( + + View on AniList {'\u2197'} + + )} +
+ ) : detail.anilistId ? ( + + View on AniList {'\u2197'} + + ) : null} +
+
+ ); +} diff --git a/stats/src/components/anime/AnimeTab.tsx b/stats/src/components/anime/AnimeTab.tsx new file mode 100644 index 0000000..af3fde2 --- /dev/null +++ b/stats/src/components/anime/AnimeTab.tsx @@ -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 = { + 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['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('lastWatched'); + const [cardSize, setCardSize] = useState('md'); + const [selectedAnimeId, setSelectedAnimeId] = useState(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 ( + setSelectedAnimeId(null)} + onNavigateToWord={onNavigateToWord} + /> + ); + } + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+
+ 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" + /> + +
+ {(['sm', 'md', 'lg'] as const).map((size) => ( + + ))} +
+
+ {filtered.length} anime · {formatDuration(totalMs)} +
+
+ + {filtered.length === 0 ? ( +
No anime found
+ ) : ( +
+ {filtered.map((item) => ( + setSelectedAnimeId(item.animeId)} + /> + ))} +
+ )} +
+ ); +} diff --git a/stats/src/components/anime/AnimeWordList.tsx b/stats/src/components/anime/AnimeWordList.tsx new file mode 100644 index 0000000..42266e7 --- /dev/null +++ b/stats/src/components/anime/AnimeWordList.tsx @@ -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([]); + 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
Loading words...
; + if (words.length === 0) return null; + + return ( + +
+ {words.map((w) => ( + + ))} +
+
+ ); +} diff --git a/stats/src/components/anime/CollapsibleSection.tsx b/stats/src/components/anime/CollapsibleSection.tsx new file mode 100644 index 0000000..aead1b6 --- /dev/null +++ b/stats/src/components/anime/CollapsibleSection.tsx @@ -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 ( +
+ + {open &&
{children}
} +
+ ); +} diff --git a/stats/src/components/anime/EpisodeDetail.tsx b/stats/src/components/anime/EpisodeDetail.tsx new file mode 100644 index 0000000..251f836 --- /dev/null +++ b/stats/src/components/anime/EpisodeDetail.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [noteInfos, setNoteInfos] = useState>(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(); + 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
Loading...
; + if (!data) return
Failed to load episode details.
; + + const { sessions, cardEvents } = data; + + return ( +
+ {sessions.length > 0 && ( +
+

Sessions

+
+ {sessions.map((s) => ( +
+ + {s.startedAtMs > 0 ? formatRelativeDate(s.startedAtMs) : '\u2014'} + + {formatDuration(s.activeWatchedMs)} + {formatNumber(s.cardsMined)} cards + {formatNumber(s.wordsSeen)} words +
+ ))} +
+
+ )} + + {cardEvents.length > 0 && ( +
+

Cards Mined

+
+ {cardEvents.map((ev) => ( +
+ + {formatRelativeDate(ev.tsMs)} + + {ev.noteIds.length > 0 ? ( + ev.noteIds.map((noteId) => { + const info = noteInfos.get(noteId); + return ( +
+ {info?.expression && ( + {info.expression} + )} + +
+ ); + }) + ) : ( + + +{ev.cardsDelta} {ev.cardsDelta === 1 ? 'card' : 'cards'} + + )} +
+ ))} +
+
+ )} + + {sessions.length === 0 && cardEvents.length === 0 && ( +
No detailed data available.
+ )} +
+ ); +} diff --git a/stats/src/components/anime/EpisodeList.tsx b/stats/src/components/anime/EpisodeList.tsx new file mode 100644 index 0000000..b40b5e9 --- /dev/null +++ b/stats/src/components/anime/EpisodeList.tsx @@ -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(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 ( +
+
+

Episodes

+ + {watchedCount}/{episodes.length} watched + +
+
+ + + + + + + + + + + + + {sorted.map((ep, idx) => ( + + 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" + > + + + + + + + + + + {expandedVideoId === ep.videoId && ( + + + + )} + + ))} + +
+ #TitleProgressWatch TimeCardsLast Watched +
+ {expandedVideoId === ep.videoId ? '\u25BC' : '\u25B6'} + + {ep.episode ?? idx + 1} + + {ep.canonicalTitle} + + {ep.durationMs > 0 ? ( + = 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))}% + + ) : ( + {'\u2014'} + )} + + {formatDuration(ep.totalActiveMs)} + + {formatNumber(ep.totalCards)} + + {ep.lastWatchedMs > 0 ? formatRelativeDate(ep.lastWatchedMs) : '\u2014'} + + +
+ +
+
+
+ ); +} diff --git a/stats/src/components/layout/StatCard.tsx b/stats/src/components/layout/StatCard.tsx new file mode 100644 index 0000000..3a59ea6 --- /dev/null +++ b/stats/src/components/layout/StatCard.tsx @@ -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 ( +
+
{value}
+
{label}
+ {subValue && ( +
{subValue}
+ )} + {trend && ( +
+ {trend.direction === 'up' ? '\u25B2' : trend.direction === 'down' ? '\u25BC' : '\u2014'} {trend.text} +
+ )} +
+ ); +} diff --git a/stats/src/components/layout/TabBar.tsx b/stats/src/components/layout/TabBar.tsx new file mode 100644 index 0000000..1815c6f --- /dev/null +++ b/stats/src/components/layout/TabBar.tsx @@ -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 ( + + ); +} diff --git a/stats/src/components/library/CoverImage.tsx b/stats/src/components/library/CoverImage.tsx new file mode 100644 index 0000000..2a4d925 --- /dev/null +++ b/stats/src/components/library/CoverImage.tsx @@ -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 ( +
+ {fallbackChar} +
+ ); + } + + return ( + {title} setFailed(true)} + /> + ); +} diff --git a/stats/src/components/library/LibraryTab.tsx b/stats/src/components/library/LibraryTab.tsx new file mode 100644 index 0000000..e142ed5 --- /dev/null +++ b/stats/src/components/library/LibraryTab.tsx @@ -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(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 setSelectedVideoId(null)} />; + } + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+
+ 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" + /> +
+ {filtered.length} title{filtered.length !== 1 ? 's' : ''} · {formatDuration(totalMs)} +
+
+ + {filtered.length === 0 ? ( +
No media found
+ ) : ( +
+ {filtered.map((item) => ( + setSelectedVideoId(item.videoId)} + /> + ))} +
+ )} +
+ ); +} diff --git a/stats/src/components/library/MediaCard.tsx b/stats/src/components/library/MediaCard.tsx new file mode 100644 index 0000000..930c9d9 --- /dev/null +++ b/stats/src/components/library/MediaCard.tsx @@ -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 ( + + ); +} diff --git a/stats/src/components/library/MediaDetailView.tsx b/stats/src/components/library/MediaDetailView.tsx new file mode 100644 index 0000000..08e774a --- /dev/null +++ b/stats/src/components/library/MediaDetailView.tsx @@ -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
Loading...
; + if (error) return
Error: {error}
; + if (!data?.detail) return
Media not found
; + + return ( +
+ + + + +
+ ); +} diff --git a/stats/src/components/library/MediaHeader.tsx b/stats/src/components/library/MediaHeader.tsx new file mode 100644 index 0000000..a16a560 --- /dev/null +++ b/stats/src/components/library/MediaHeader.tsx @@ -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; +} + +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 ( +
+ +
+

{detail.canonicalTitle}

+
+
+
{formatDuration(detail.totalActiveMs)}
+
total watch time
+
+
+
{formatNumber(detail.totalCards)}
+
cards mined
+
+
+
{formatNumber(detail.totalWordsSeen)}
+
words seen
+
+
+
{formatPercent(hitRate)}
+
lookup rate
+
+
+
{detail.totalSessions}
+
sessions
+
+
+
{formatDuration(avgSessionMs)}
+
avg session
+
+
+
+
+ ); +} diff --git a/stats/src/components/library/MediaSessionList.tsx b/stats/src/components/library/MediaSessionList.tsx new file mode 100644 index 0000000..0d5cb68 --- /dev/null +++ b/stats/src/components/library/MediaSessionList.tsx @@ -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
No sessions recorded
; + } + + return ( +
+

Session History

+ {sessions.map((s) => ( +
+
+
+ {formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active +
+
+
+
+
{formatNumber(s.cardsMined)}
+
cards
+
+
+
{formatNumber(s.wordsSeen)}
+
words
+
+
+
+ ))} +
+ ); +} diff --git a/stats/src/components/library/MediaWatchChart.tsx b/stats/src/components/library/MediaWatchChart.tsx new file mode 100644 index 0000000..cc9e500 --- /dev/null +++ b/stats/src/components/library/MediaWatchChart.tsx @@ -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(30); + + const byDay = new Map(); + 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 ( +
+
+

Watch Time

+
+ {ranges.map((r) => ( + + ))} +
+
+ + + + + + + + +
+ ); +} diff --git a/stats/src/components/overview/HeroStats.tsx b/stats/src/components/overview/HeroStats.tsx new file mode 100644 index 0000000..748219f --- /dev/null +++ b/stats/src/components/overview/HeroStats.tsx @@ -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 ( +
+ + + + + + +
+ ); +} diff --git a/stats/src/components/overview/OverviewTab.tsx b/stats/src/components/overview/OverviewTab.tsx new file mode 100644 index 0000000..112845e --- /dev/null +++ b/stats/src/components/overview/OverviewTab.tsx @@ -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
Loading...
; + if (error) return
Error: {error}
; + if (!data) return null; + + const summary = buildOverviewSummary(data); + const streakData = buildStreakCalendar(calendar); + const showTrackedCardNote = summary.totalTrackedCards === 0 && summary.totalSessions > 0; + + return ( +
+ + +
+ + {!calLoading && } +
+ +
+

Tracking Snapshot

+ {showTrackedCardNote && ( +
+ No tracked card-add events in the current immersion DB yet. New cards mined after this fix will show here. +
+ )} +
+
+
Total Sessions
+
+ {formatNumber(summary.totalSessions)} +
+
+
+
Episodes Today
+
+ {formatNumber(summary.episodesToday)} +
+
+
+
All-Time Hours
+
+ {formatNumber(summary.allTimeHours)} +
+
+
+
Active Days
+
+ {formatNumber(summary.activeDays)} +
+
+
+
Cards Mined
+
+ {formatNumber(summary.totalTrackedCards)} +
+
+
+
+ + +
+ ); +} diff --git a/stats/src/components/overview/QuickStats.tsx b/stats/src/components/overview/QuickStats.tsx new file mode 100644 index 0000000..04e43aa --- /dev/null +++ b/stats/src/components/overview/QuickStats.tsx @@ -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 ( +
+

Quick Stats

+
+
+ Streak + + {streak} day{streak !== 1 ? 's' : ''} + +
+
+ Avg/day this week + {avgMinPerDay}m +
+
+ Cards this week + {weekCards} +
+
+
+ ); +} diff --git a/stats/src/components/overview/RecentSessions.tsx b/stats/src/components/overview/RecentSessions.tsx new file mode 100644 index 0000000..0f7ed63 --- /dev/null +++ b/stats/src/components/overview/RecentSessions.tsx @@ -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 { + const groups = new Map(); + 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(); + + 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 ( +
+ {fallbackChar} +
+ ); + } + + return ( + { + 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 ( +
+ +
+
+ {session.canonicalTitle ?? 'Unknown Media'} +
+
+ {formatRelativeDate(session.startedAtMs)} · {formatDuration(session.activeWatchedMs)} active +
+
+
+
+
{formatNumber(session.cardsMined)}
+
cards
+
+
+
{formatNumber(session.wordsSeen)}
+
words
+
+
+
+ ); +} + +function AnimeGroupRow({ group }: { group: AnimeGroup }) { + const [expanded, setExpanded] = useState(false); + + if (group.sessions.length === 1) { + return ; + } + + const displayTitle = group.animeTitle ?? group.sessions[0]?.canonicalTitle ?? 'Unknown Media'; + const mostRecentSession = group.sessions[0]!; + + return ( +
+ + {expanded && ( +
+ {group.sessions.map((s) => ( +
+ +
+
+ {s.canonicalTitle ?? 'Unknown Media'} +
+
+ {formatRelativeDate(s.startedAtMs)} · {formatDuration(s.activeWatchedMs)} active +
+
+
+
+
{formatNumber(s.cardsMined)}
+
cards
+
+
+
{formatNumber(s.wordsSeen)}
+
words
+
+
+
+ ))} +
+ )} +
+ ); +} + +export function RecentSessions({ sessions }: RecentSessionsProps) { + if (sessions.length === 0) { + return ( +
+
No sessions yet
+
+ ); + } + + const groups = groupSessionsByDay(sessions); + + return ( +
+ {Array.from(groups.entries()).map(([dayLabel, daySessions]) => { + const animeGroups = groupSessionsByAnime(daySessions); + return ( +
+

+ {dayLabel} +

+
+ {animeGroups.map((group) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/stats/src/components/overview/StreakCalendar.tsx b/stats/src/components/overview/StreakCalendar.tsx new file mode 100644 index 0000000..74323f5 --- /dev/null +++ b/stats/src/components/overview/StreakCalendar.tsx @@ -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 ( +
+

Activity (90 days)

+
+
+ {DAY_LABELS.map((label, i) => ( +
+ {label} +
+ ))} +
+
+ {cells.map((cell) => ( +
{ + 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)} + /> + ))} +
+ {tooltip && ( +
+ {tooltip.text} +
+ )} +
+
+ ); +} diff --git a/stats/src/components/overview/WatchTimeChart.tsx b/stats/src/components/overview/WatchTimeChart.tsx new file mode 100644 index 0000000..aff9d70 --- /dev/null +++ b/stats/src/components/overview/WatchTimeChart.tsx @@ -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(14); + + const byDay = new Map(); + 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 ( +
+
+

Watch Time

+
+ {ranges.map((r) => ( + + ))} +
+
+ + + + + + + + +
+ ); +} diff --git a/stats/src/components/sessions/SessionDetail.tsx b/stats/src/components/sessions/SessionDetail.tsx new file mode 100644 index 0000000..89b1cbf --- /dev/null +++ b/stats/src/components/sessions/SessionDetail.tsx @@ -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> = { + [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
Loading timeline...
; + if (error) return
Error: {error}
; + + 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 ( +
+ {chartData.length > 0 && ( + + + + + + + + {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 ( + + ); + })} + + + )} + +
+ {pauseCount} pause{pauseCount !== 1 ? 's' : ''} + {seekCount} seek{seekCount !== 1 ? 's' : ''} + {Math.max(cardEventCount, cardsMined)} card{Math.max(cardEventCount, cardsMined) !== 1 ? 's' : ''} mined +
+ + {markerEvents.length > 0 && ( +
+ {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 ( + + + {cfg.label} ({count}) + + ); + })} +
+ )} +
+ ); +} diff --git a/stats/src/components/sessions/SessionRow.tsx b/stats/src/components/sessions/SessionRow.tsx new file mode 100644 index 0000000..74f0886 --- /dev/null +++ b/stats/src/components/sessions/SessionRow.tsx @@ -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 ( +
+ {fallbackChar} +
+ ); + } + + return ( + setFailed(true)} + /> + ); +} + +export function SessionRow({ session, isExpanded, detailsId, onToggle }: SessionRowProps) { + return ( + + ); +} diff --git a/stats/src/components/sessions/SessionsTab.tsx b/stats/src/components/sessions/SessionsTab.tsx new file mode 100644 index 0000000..75f377a --- /dev/null +++ b/stats/src/components/sessions/SessionsTab.tsx @@ -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 { + const groups = new Map(); + 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(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
Loading...
; + if (error) return
Error: {error}
; + + return ( +
+ 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]) => ( +
+

+ {dayLabel} +

+
+ {daySessions.map((s) => { + const detailsId = `session-details-${s.sessionId}`; + return ( +
+ setExpandedId(expandedId === s.sessionId ? null : s.sessionId)} + /> + {expandedId === s.sessionId && ( +
+ +
+ )} +
+ ); + })} +
+
+ ))} + + {filtered.length === 0 && ( +
+ {search.trim() ? 'No sessions matching your search.' : 'No sessions recorded yet.'} +
+ )} +
+ ); +} diff --git a/stats/src/components/trends/DateRangeSelector.tsx b/stats/src/components/trends/DateRangeSelector.tsx new file mode 100644 index 0000000..ba66f79 --- /dev/null +++ b/stats/src/components/trends/DateRangeSelector.tsx @@ -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 ( +
+
+ Range + {ranges.map((r) => ( + + ))} +
+ {'\u00B7'} +
+ Group by + {groups.map((g) => ( + + ))} +
+
+ ); +} diff --git a/stats/src/components/trends/StackedTrendChart.tsx b/stats/src/components/trends/StackedTrendChart.tsx new file mode 100644 index 0000000..59bd25f --- /dev/null +++ b/stats/src/components/trends/StackedTrendChart.tsx @@ -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(); + 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>(); + 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 ( +
+

{title}

+
No data
+
+ ); + } + + return ( +
+

{title}

+ + + + + + {seriesKeys.map((key, i) => ( + + ))} + + +
+ {seriesKeys.map((key, i) => ( + + + {key} + + ))} +
+
+ ); +} diff --git a/stats/src/components/trends/TrendChart.tsx b/stats/src/components/trends/TrendChart.tsx new file mode 100644 index 0000000..4a7862e --- /dev/null +++ b/stats/src/components/trends/TrendChart.tsx @@ -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 ( +
+

{title}

+ + {type === 'bar' ? ( + + + + + + + ) : ( + + + + + + + )} + +
+ ); +} diff --git a/stats/src/components/trends/TrendsTab.tsx b/stats/src/components/trends/TrendsTab.tsx new file mode 100644 index 0000000..276ad3c --- /dev/null +++ b/stats/src/components/trends/TrendsTab.tsx @@ -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>(); + 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>(); + 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>>(); + 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 ( +

+ {children} +

+ ); +} + +export function TrendsTab() { + const [range, setRange] = useState('30d'); + const [groupBy, setGroupBy] = useState('day'); + const { data, loading, error } = useTrends(range, groupBy); + + if (loading) return
Loading...
; + if (error) return
Error: {error}
; + + 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 ( +
+ +
+ Activity + + + + + + + Efficiency + + + Anime + + + + + + + Patterns + + +
+
+ ); +} diff --git a/stats/src/components/vocabulary/KanjiBreakdown.tsx b/stats/src/components/vocabulary/KanjiBreakdown.tsx new file mode 100644 index 0000000..68095d3 --- /dev/null +++ b/stats/src/components/vocabulary/KanjiBreakdown.tsx @@ -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 ( +
+

Kanji Encountered

+
+ {kanji.map((k) => { + const ratio = k.frequency / maxFreq; + const opacity = Math.max(0.3, ratio); + return ( + + ); + })} +
+
+ ); +} diff --git a/stats/src/components/vocabulary/KanjiDetailPanel.tsx b/stats/src/components/vocabulary/KanjiDetailPanel.tsx new file mode 100644 index 0000000..a386ab0 --- /dev/null +++ b/stats/src/components/vocabulary/KanjiDetailPanel.tsx @@ -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([]); + const [occLoading, setOccLoading] = useState(false); + const [occLoadingMore, setOccLoadingMore] = useState(false); + const [occError, setOccError] = useState(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 ( +
+ +
+ +
+ {data && ( + <> +
+
+
{formatNumber(data.detail.frequency)}
+
Frequency
+
+
+
{formatRelativeDate(data.detail.firstSeen)}
+
First Seen
+
+
+
{formatRelativeDate(data.detail.lastSeen)}
+
Last Seen
+
+
+ + {data.animeAppearances.length > 0 && ( +
+

Anime Appearances

+
+ {data.animeAppearances.map(a => ( + + ))} +
+
+ )} + + {data.words.length > 0 && ( +
+

Words Using This Kanji

+
+ {data.words.map(w => ( + + ))} +
+
+ )} + +
+

Example Lines

+ {!occLoaded && !occLoading && ( + + )} + {occLoading &&
Loading occurrences...
} + {occError &&
Error: {occError}
} + {occLoaded && !occLoading && occurrences.length === 0 && ( +
No occurrences tracked yet.
+ )} + {occurrences.length > 0 && ( +
+ {occurrences.map((occ, idx) => ( +
+
+
+
+ {occ.animeTitle ?? occ.videoTitle} +
+
+ {occ.videoTitle} · line {occ.lineIndex} +
+
+
+ {formatNumber(occ.occurrenceCount)} in line +
+
+
+ {formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId} +
+

+ {occ.text} +

+
+ ))} +
+ )} +
+ + )} +
+ + {occLoaded && !occLoading && !occError && hasMore && ( +
+ +
+ )} +
+ + + ); +} diff --git a/stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx b/stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx new file mode 100644 index 0000000..26a320c --- /dev/null +++ b/stats/src/components/vocabulary/VocabularyOccurrencesDrawer.tsx @@ -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 ( +
+ +
+ +
+ {loading ?
Loading occurrences...
: null} + {!loading && error ?
Error: {error}
: null} + {!loading && !error && occurrences.length === 0 ? ( +
No occurrences tracked yet.
+ ) : null} + {!loading && !error ? ( +
+ {occurrences.map((occurrence, index) => ( +
+
+
+
+ {occurrence.animeTitle ?? occurrence.videoTitle} +
+
+ {occurrence.videoTitle} · line {occurrence.lineIndex} +
+
+
+ {formatNumber(occurrence.occurrenceCount)} in line +
+
+
+ {formatSegment(occurrence.segmentStartMs)}-{formatSegment(occurrence.segmentEndMs)} · session{' '} + {occurrence.sessionId} +
+

+ {occurrence.text} +

+
+ ))} +
+ ) : null} +
+ + {!loading && !error && hasMore ? ( +
+ +
+ ) : null} + + + + ); +} + +export type { VocabularyDrawerTarget }; diff --git a/stats/src/components/vocabulary/VocabularyTab.tsx b/stats/src/components/vocabulary/VocabularyTab.tsx new file mode 100644 index 0000000..a8602a7 --- /dev/null +++ b/stats/src/components/vocabulary/VocabularyTab.tsx @@ -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(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
Loading...
; + if (error) return
Error: {error}
; + + const summary = buildVocabularySummary(filteredWords, kanji); + + const handleSelectWord = (entry: VocabularyEntry): void => { + onOpenWordDetail?.(entry.wordId); + }; + + const openKanjiDetail = (entry: KanjiEntry): void => { + setSelectedKanjiId(entry.kanjiId); + }; + + return ( +
+
+ + + +
+ +
+ + 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" + /> +
+ +
+ + +
+ + + + + + setSelectedKanjiId(null)} + onSelectWord={onOpenWordDetail} + onNavigateToAnime={onNavigateToAnime} + /> +
+ ); +} diff --git a/stats/src/components/vocabulary/WordDetailPanel.tsx b/stats/src/components/vocabulary/WordDetailPanel.tsx new file mode 100644 index 0000000..9536a3f --- /dev/null +++ b/stats/src/components/vocabulary/WordDetailPanel.tsx @@ -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([]); + const [occLoading, setOccLoading] = useState(false); + const [occLoadingMore, setOccLoadingMore] = useState(false); + const [occError, setOccError] = useState(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['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 ( +
+ +
+ +
+ {data && ( + <> +
+
+
{formatNumber(data.detail.frequency)}
+
Frequency
+
+
+
{formatRelativeDate(data.detail.firstSeen)}
+
First Seen
+
+
+
{formatRelativeDate(data.detail.lastSeen)}
+
Last Seen
+
+
+ + {data.animeAppearances.length > 0 && ( +
+

Anime Appearances

+
+ {data.animeAppearances.map(a => ( + + ))} +
+
+ )} + + {data.similarWords.length > 0 && ( +
+

Similar Words

+
+ {data.similarWords.map(sw => ( + + ))} +
+
+ )} + +
+

Example Lines

+ {!occLoaded && !occLoading && ( + + )} + {occLoading &&
Loading occurrences...
} + {occError &&
Error: {occError}
} + {occLoaded && !occLoading && occurrences.length === 0 && ( +
No occurrences tracked yet.
+ )} + {occurrences.length > 0 && ( +
+ {occurrences.map((occ, idx) => ( +
+
+
+
+ {occ.animeTitle ?? occ.videoTitle} +
+
+ {occ.videoTitle} · line {occ.lineIndex} +
+
+
+ {formatNumber(occ.occurrenceCount)} in line +
+
+
+ {formatSegment(occ.segmentStartMs)}-{formatSegment(occ.segmentEndMs)} · session {occ.sessionId} +
+

+ {occ.text} +

+
+ ))} +
+ )} +
+ + )} +
+ + {occLoaded && !occLoading && !occError && hasMore && ( +
+ +
+ )} + + + + ); +} diff --git a/stats/src/components/vocabulary/WordList.tsx b/stats/src/components/vocabulary/WordList.tsx new file mode 100644 index 0000000..8d2a686 --- /dev/null +++ b/stats/src/components/vocabulary/WordList.tsx @@ -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('frequency'); + const [page, setPage] = useState(0); + + const titleBySort: Record = { + 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 ( +
+
+

+ {titleBySort[sortBy]} + {search && ({filtered.length} matches)} +

+ +
+
+ {paged.map((w) => ( + + ))} +
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+ ); +} + +export { toWordKey }; diff --git a/stats/src/components/vocabulary/pos-helpers.tsx b/stats/src/components/vocabulary/pos-helpers.tsx new file mode 100644 index 0000000..604e098 --- /dev/null +++ b/stats/src/components/vocabulary/pos-helpers.tsx @@ -0,0 +1,37 @@ +import type { VocabularyEntry } from '../../types/stats'; + +const POS_COLORS: Record = { + 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 ( + + {pos.replace(/_/g, ' ')} + + ); +} + +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; +} diff --git a/stats/src/hooks/useAnimeDetail.ts b/stats/src/hooks/useAnimeDetail.ts new file mode 100644 index 0000000..ce0ee06 --- /dev/null +++ b/stats/src/hooks/useAnimeDetail.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useAnimeLibrary.ts b/stats/src/hooks/useAnimeLibrary.ts new file mode 100644 index 0000000..e1991d3 --- /dev/null +++ b/stats/src/hooks/useAnimeLibrary.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useKanjiDetail.ts b/stats/src/hooks/useKanjiDetail.ts new file mode 100644 index 0000000..f5be433 --- /dev/null +++ b/stats/src/hooks/useKanjiDetail.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useMediaDetail.ts b/stats/src/hooks/useMediaDetail.ts new file mode 100644 index 0000000..b8ef195 --- /dev/null +++ b/stats/src/hooks/useMediaDetail.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useMediaLibrary.ts b/stats/src/hooks/useMediaLibrary.ts new file mode 100644 index 0000000..9842a4b --- /dev/null +++ b/stats/src/hooks/useMediaLibrary.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getStatsClient() + .getMediaLibrary() + .then(setMedia) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); + + return { media, loading, error }; +} diff --git a/stats/src/hooks/useOverview.ts b/stats/src/hooks/useOverview.ts new file mode 100644 index 0000000..254d117 --- /dev/null +++ b/stats/src/hooks/useOverview.ts @@ -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(null); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useSessions.ts b/stats/src/hooks/useSessions.ts new file mode 100644 index 0000000..daee82c --- /dev/null +++ b/stats/src/hooks/useSessions.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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([]); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useStatsApi.ts b/stats/src/hooks/useStatsApi.ts new file mode 100644 index 0000000..fbc8b55 --- /dev/null +++ b/stats/src/hooks/useStatsApi.ts @@ -0,0 +1,7 @@ +import { apiClient } from '../lib/api-client'; + +export type StatsClient = typeof apiClient; + +export function getStatsClient(): StatsClient { + return apiClient; +} diff --git a/stats/src/hooks/useStreakCalendar.ts b/stats/src/hooks/useStreakCalendar.ts new file mode 100644 index 0000000..a2a964a --- /dev/null +++ b/stats/src/hooks/useStreakCalendar.ts @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useTrends.ts b/stats/src/hooks/useTrends.ts new file mode 100644 index 0000000..7e1016e --- /dev/null +++ b/stats/src/hooks/useTrends.ts @@ -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({ + rollups: [], + episodesPerDay: [], + newAnimePerDay: [], + watchTimePerAnime: [], + sessions: [], + animeLibrary: [], + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + const client = getStatsClient(); + const limitMap: Record = { '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 }; +} diff --git a/stats/src/hooks/useVocabulary.ts b/stats/src/hooks/useVocabulary.ts new file mode 100644 index 0000000..84a1095 --- /dev/null +++ b/stats/src/hooks/useVocabulary.ts @@ -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([]); + const [kanji, setKanji] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/hooks/useWordDetail.ts b/stats/src/hooks/useWordDetail.ts new file mode 100644 index 0000000..d98ddf7 --- /dev/null +++ b/stats/src/hooks/useWordDetail.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +} diff --git a/stats/src/lib/api-client.ts b/stats/src/lib/api-client.ts new file mode 100644 index 0000000..0388e98 --- /dev/null +++ b/stats/src/lib/api-client.ts @@ -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(path: string): Promise { + 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; +} + +export const apiClient = { + getOverview: () => fetchJson('/api/stats/overview'), + getDailyRollups: (limit = 60) => + fetchJson(`/api/stats/daily-rollups?limit=${limit}`), + getMonthlyRollups: (limit = 24) => + fetchJson(`/api/stats/monthly-rollups?limit=${limit}`), + getSessions: (limit = 50) => fetchJson(`/api/stats/sessions?limit=${limit}`), + getSessionTimeline: (id: number, limit = 200) => + fetchJson(`/api/stats/sessions/${id}/timeline?limit=${limit}`), + getSessionEvents: (id: number, limit = 500) => + fetchJson(`/api/stats/sessions/${id}/events?limit=${limit}`), + getVocabulary: (limit = 100) => + fetchJson(`/api/stats/vocabulary?limit=${limit}`), + getWordOccurrences: ( + headword: string, + word: string, + reading: string, + limit = 50, + offset = 0, + ) => + fetchJson( + `/api/stats/vocabulary/occurrences?headword=${encodeURIComponent(headword)}&word=${encodeURIComponent(word)}&reading=${encodeURIComponent(reading)}&limit=${limit}&offset=${offset}`, + ), + getKanji: (limit = 100) => fetchJson(`/api/stats/kanji?limit=${limit}`), + getKanjiOccurrences: (kanji: string, limit = 50, offset = 0) => + fetchJson( + `/api/stats/kanji/occurrences?kanji=${encodeURIComponent(kanji)}&limit=${limit}&offset=${offset}`, + ), + getMediaLibrary: () => fetchJson('/api/stats/media'), + getMediaDetail: (videoId: number) => + fetchJson(`/api/stats/media/${videoId}`), + getAnimeLibrary: () => fetchJson('/api/stats/anime'), + getAnimeDetail: (animeId: number) => + fetchJson(`/api/stats/anime/${animeId}`), + getAnimeWords: (animeId: number, limit = 50) => + fetchJson(`/api/stats/anime/${animeId}/words?limit=${limit}`), + getAnimeRollups: (animeId: number, limit = 90) => + fetchJson(`/api/stats/anime/${animeId}/rollups?limit=${limit}`), + getAnimeCoverUrl: (animeId: number) => `${BASE_URL}/api/stats/anime/${animeId}/cover`, + getStreakCalendar: (days = 90) => + fetchJson(`/api/stats/streak-calendar?days=${days}`), + getEpisodesPerDay: (limit = 90) => + fetchJson(`/api/stats/trends/episodes-per-day?limit=${limit}`), + getNewAnimePerDay: (limit = 90) => + fetchJson(`/api/stats/trends/new-anime-per-day?limit=${limit}`), + getWatchTimePerAnime: (limit = 90) => + fetchJson(`/api/stats/trends/watch-time-per-anime?limit=${limit}`), + getWordDetail: (wordId: number) => + fetchJson(`/api/stats/vocabulary/${wordId}/detail`), + getKanjiDetail: (kanjiId: number) => + fetchJson(`/api/stats/kanji/${kanjiId}/detail`), + getEpisodeDetail: (videoId: number) => + fetchJson(`/api/stats/episode/${videoId}/detail`), + setVideoWatched: async (videoId: number, watched: boolean): Promise => { + 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 => { + await fetch(`${BASE_URL}/api/stats/anki/browse?noteId=${noteId}`, { method: 'POST' }); + }, + ankiNotesInfo: async (noteIds: number[]): Promise }>> => { + 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(); + }, +}; diff --git a/stats/src/lib/chart-theme.ts b/stats/src/lib/chart-theme.ts new file mode 100644 index 0000000..549b015 --- /dev/null +++ b/stats/src/lib/chart-theme.ts @@ -0,0 +1,8 @@ +export const CHART_THEME = { + tick: '#a5adcb', + tooltipBg: '#363a4f', + tooltipBorder: '#494d64', + tooltipText: '#cad3f5', + tooltipLabel: '#b8c0e0', + barFill: '#8aadf4', +} as const; diff --git a/stats/src/lib/dashboard-data.test.ts b/stats/src/lib/dashboard-data.test.ts new file mode 100644 index 0000000..59b067c --- /dev/null +++ b/stats/src/lib/dashboard-data.test.ts @@ -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); +}); diff --git a/stats/src/lib/dashboard-data.ts b/stats/src/lib/dashboard-data.ts new file mode 100644 index 0000000..cd6222b --- /dev/null +++ b/stats/src/lib/dashboard-data.ts @@ -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(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(); + + 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 }; + }); +} diff --git a/stats/src/lib/formatters.test.ts b/stats/src/lib/formatters.test.ts new file mode 100644 index 0000000..7be19df --- /dev/null +++ b/stats/src/lib/formatters.test.ts @@ -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()); +}); diff --git a/stats/src/lib/formatters.ts b/stats/src/lib/formatters.ts new file mode 100644 index 0000000..bb4249c --- /dev/null +++ b/stats/src/lib/formatters.ts @@ -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()); +} diff --git a/stats/src/lib/ipc-client.ts b/stats/src/lib/ipc-client.ts new file mode 100644 index 0000000..6600c36 --- /dev/null +++ b/stats/src/lib/ipc-client.ts @@ -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; + getDailyRollups: (limit?: number) => Promise; + getMonthlyRollups: (limit?: number) => Promise; + getSessions: (limit?: number) => Promise; + getSessionTimeline: (id: number, limit?: number) => Promise; + getSessionEvents: (id: number, limit?: number) => Promise; + getVocabulary: (limit?: number) => Promise; + getWordOccurrences: ( + headword: string, + word: string, + reading: string, + limit?: number, + offset?: number, + ) => Promise; + getKanji: (limit?: number) => Promise; + getKanjiOccurrences: ( + kanji: string, + limit?: number, + offset?: number, + ) => Promise; + getMediaLibrary: () => Promise; + getMediaDetail: (videoId: number) => Promise; + getAnimeLibrary: () => Promise; + getAnimeDetail: (animeId: number) => Promise; + getAnimeWords: (animeId: number, limit?: number) => Promise; + getAnimeRollups: (animeId: number, limit?: number) => Promise; + getAnimeCoverUrl: (animeId: number) => string; + getStreakCalendar: (days?: number) => Promise; + getEpisodesPerDay: (limit?: number) => Promise; + getNewAnimePerDay: (limit?: number) => Promise; + getWatchTimePerAnime: (limit?: number) => Promise; + getWordDetail: (wordId: number) => Promise; + getKanjiDetail: (kanjiId: number) => Promise; + getEpisodeDetail: (videoId: number) => Promise; + ankiBrowse: (noteId: number) => Promise; + ankiNotesInfo: (noteIds: number[]) => Promise }>>; + 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), +}; diff --git a/stats/src/main.tsx b/stats/src/main.tsx new file mode 100644 index 0000000..2d20070 --- /dev/null +++ b/stats/src/main.tsx @@ -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( + + + + ); +} diff --git a/stats/src/styles/globals.css b/stats/src/styles/globals.css new file mode 100644 index 0000000..e2638fa --- /dev/null +++ b/stats/src/styles/globals.css @@ -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); +} diff --git a/stats/src/types/stats.ts b/stats/src/types/stats.ts new file mode 100644 index 0000000..1bcd3cf --- /dev/null +++ b/stats/src/types/stats.ts @@ -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; + }>; +} diff --git a/stats/tsconfig.json b/stats/tsconfig.json new file mode 100644 index 0000000..1225539 --- /dev/null +++ b/stats/tsconfig.json @@ -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"] +} diff --git a/stats/vite.config.ts b/stats/vite.config.ts new file mode 100644 index 0000000..7fb4d95 --- /dev/null +++ b/stats/vite.config.ts @@ -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, + }, +});