diff --git a/.gitignore b/.gitignore index a547bf3..81f818c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ node_modules dist dist-ssr *.local +.superpowers/ +docs/superpowers/ # Editor directories and files .vscode/* diff --git a/index.html b/index.html index c9915a5..0601b37 100644 --- a/index.html +++ b/index.html @@ -2,8 +2,15 @@ - + + + + Powered by Sefaria Dashboard diff --git a/package-lock.json b/package-lock.json index 95d3847..baf445b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.0.0", "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "recharts": "^3.10.1" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -570,6 +571,32 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -834,6 +861,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -845,6 +884,69 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -870,7 +972,7 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -886,6 +988,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", @@ -976,16 +1084,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -1043,6 +1151,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1069,9 +1186,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1090,6 +1328,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1114,6 +1358,17 @@ "dev": true, "license": "ISC" }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1322,6 +1577,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1490,6 +1751,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -1500,6 +1771,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1919,9 +2199,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "dev": true, "funding": [ { @@ -2045,9 +2325,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "dev": true, "funding": [ { @@ -2065,7 +2345,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2114,6 +2394,87 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.10.1.tgz", + "integrity": "sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", @@ -2197,6 +2558,12 @@ "node": ">=0.10.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -2276,6 +2643,37 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "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" + } + }, "node_modules/vite": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", diff --git a/package.json b/package.json index bd20f98..05d204f 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,13 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test" }, "dependencies": { "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "recharts": "^3.10.1" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/App.jsx b/src/App.jsx index cc39614..65a5497 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,9 +1,27 @@ +import { useState } from 'react' import Title from './Title.jsx' +import Sidebar from './components/Sidebar.jsx' +import Dashboard from './components/Dashboard.jsx' +import ChartsAndAnalytics from './components/ChartsAndAnalytics.jsx' + +const TABS = [ + { id: 'projects', label: 'Projects' }, + { id: 'charts', label: 'Charts and Analytics' }, +] function App() { + const [activeTab, setActiveTab] = useState('projects') + return ( <> + <div className="app-body"> + <Sidebar tabs={TABS} activeTab={activeTab} onSelectTab={setActiveTab} /> + <main className="tab-content"> + {activeTab === 'projects' && <Dashboard />} + {activeTab === 'charts' && <ChartsAndAnalytics />} + </main> + </div> </> ) } diff --git a/src/Title.jsx b/src/Title.jsx index ecf794c..6eda98a 100644 --- a/src/Title.jsx +++ b/src/Title.jsx @@ -2,11 +2,10 @@ import developersLogo from './assets/developers_logo.png' function Title() { return ( - <header> + <header style={{ textAlign: 'center' }}> <h1>Powered by Sefaria Dashboard</h1> - <img src={developersLogo} alt="Sefaria Developers logo" /> <a href="https://developers.sefaria.org" target="_blank" rel="noreferrer"> - developers.sefaria.org + <img src={developersLogo} alt="Sefaria Developers logo" /> </a> </header> ) diff --git a/src/components/ChartsAndAnalytics.jsx b/src/components/ChartsAndAnalytics.jsx new file mode 100644 index 0000000..53b8f67 --- /dev/null +++ b/src/components/ChartsAndAnalytics.jsx @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react' +import { + Bar, + BarChart, + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts' +import { fetchProjects } from '../data/fetchProjects.js' +import { getSubmissionsMonthlyTrend, getSubmissionsTrendByExperience } from '../utils/submissionsTrend.js' +import { getKeywordCounts } from '../utils/keywords.js' +import { EXPERIENCE_LEVELS } from '../utils/experience.js' + +const EXPERIENCE_COLORS = { + 'No Experience': '#2a78d6', + Beginner: '#eb6834', + Intermediate: '#1baf7a', + Advanced: '#4a3aa7', +} + +function ChartsAndAnalytics() { + const [trend, setTrend] = useState(null) + const [keywordCounts, setKeywordCounts] = useState(null) + const [experienceTrend, setExperienceTrend] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetchProjects() + .then((projects) => { + setTrend(getSubmissionsMonthlyTrend(projects)) + setKeywordCounts(getKeywordCounts(projects)) + setExperienceTrend(getSubmissionsTrendByExperience(projects)) + }) + .catch((err) => setError(err.message)) + }, []) + + if (error) return <p className="charts-and-analytics">Couldn't load chart data right now.</p> + if (!trend || !keywordCounts || !experienceTrend) return <p className="charts-and-analytics">Loading chart…</p> + if (experienceTrend.length === 0) { + return <p className="charts-and-analytics">No experience-level data available yet.</p> + } + + return ( + <div className="charts-and-analytics"> + <h2>Submissions, past 12 months</h2> + <ResponsiveContainer width="100%" height={360}> + <BarChart data={trend}> + <CartesianGrid strokeDasharray="3 3" vertical={false} /> + <XAxis dataKey="month" /> + <YAxis allowDecimals={false} /> + <Tooltip /> + <Bar dataKey="count" name="Submissions" fill="var(--accent)" /> + </BarChart> + </ResponsiveContainer> + + <h2>Keyword frequency</h2> + <ResponsiveContainer width="100%" height={keywordCounts.length * 28 + 40}> + <BarChart + data={keywordCounts} + layout="vertical" + margin={{ left: 24 }} + > + <CartesianGrid strokeDasharray="3 3" horizontal={false} /> + <XAxis type="number" allowDecimals={false} /> + <YAxis type="category" dataKey="keyword" width={100} /> + <Tooltip /> + <Bar dataKey="count" name="Projects" fill="var(--accent)" /> + </BarChart> + </ResponsiveContainer> + + <h2>Submissions by experience level</h2> + <ResponsiveContainer width="100%" height={360}> + <LineChart data={experienceTrend} margin={{ right: 100 }}> + <CartesianGrid strokeDasharray="3 3" vertical={false} /> + <XAxis dataKey="month" /> + <YAxis allowDecimals={false} /> + <Tooltip /> + <Legend /> + {EXPERIENCE_LEVELS.map((level) => ( + <Line + key={level} + type="monotone" + dataKey={level} + name={level} + stroke={EXPERIENCE_COLORS[level]} + strokeWidth={2} + dot={{ r: 3 }} + // recharts 3.x gates the custom `label` render-prop behind an internal + // isAnimating flag cleared via onAnimationEnd, which doesn't fire + // reliably in this dev environment — without this, the end-of-line + // labels below silently never render (no test/lint failure to catch it). + isAnimationActive={false} + label={(props) => + props.index === experienceTrend.length - 1 ? ( + <text + x={props.x + 6} + y={props.y} + dy={4} + fill="var(--text-h)" + fontSize={12} + > + {level} + </text> + ) : null + } + /> + ))} + </LineChart> + </ResponsiveContainer> + </div> + ) +} + +export default ChartsAndAnalytics diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx new file mode 100644 index 0000000..634a903 --- /dev/null +++ b/src/components/Controls.jsx @@ -0,0 +1,33 @@ +function Controls({ + searchText, + selectedCategory, + categories, + count, + onSearchChange, + onCategoryChange, +}) { + return ( + <div className="dashboard-controls"> + <input + type="text" + placeholder="Search projects..." + value={searchText} + onChange={(event) => onSearchChange(event.target.value)} + /> + <select + value={selectedCategory} + onChange={(event) => onCategoryChange(event.target.value)} + > + <option value="All">All categories</option> + {categories.map((category) => ( + <option key={category} value={category}> + {category} + </option> + ))} + </select> + <span className="project-count">{count} projects</span> + </div> + ) +} + +export default Controls diff --git a/src/components/Dashboard.jsx b/src/components/Dashboard.jsx new file mode 100644 index 0000000..c8ac881 --- /dev/null +++ b/src/components/Dashboard.jsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from 'react' +import { fetchProjects } from '../data/fetchProjects.js' +import { KNOWN_CATEGORIES, UNCATEGORIZED } from '../utils/categories.js' +import Controls from './Controls.jsx' +import ProjectGrid from './ProjectGrid.jsx' + +const FILTER_CATEGORIES = [...KNOWN_CATEGORIES, UNCATEGORIZED] + +function Dashboard() { + const [projects, setProjects] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [searchText, setSearchText] = useState('') + const [selectedCategory, setSelectedCategory] = useState('All') + + useEffect(() => { + fetchProjects() + .then((data) => { + setProjects(data) + setLoading(false) + }) + .catch((err) => { + setError(err.message) + setLoading(false) + }) + }, []) + + if (loading) return <p>Loading projects…</p> + if (error) return <p>Couldn't load projects right now.</p> + + const search = searchText.toLowerCase() + + const visibleProjects = projects + .filter((project) => { + const matchesSearch = + project.project_name.toLowerCase().includes(search) || + project.project_desc.toLowerCase().includes(search) || + project.project_category.toLowerCase().includes(search) + + const matchesCategory = + selectedCategory === 'All' || project.categories.includes(selectedCategory) + + return matchesSearch && matchesCategory + }) + .sort((a, b) => a.project_name.localeCompare(b.project_name)) + + return ( + <> + <Controls + searchText={searchText} + selectedCategory={selectedCategory} + categories={FILTER_CATEGORIES} + count={visibleProjects.length} + onSearchChange={setSearchText} + onCategoryChange={setSelectedCategory} + /> + <ProjectGrid projects={visibleProjects} /> + </> + ) +} + +export default Dashboard diff --git a/src/components/ProjectCard.jsx b/src/components/ProjectCard.jsx new file mode 100644 index 0000000..e45e56b --- /dev/null +++ b/src/components/ProjectCard.jsx @@ -0,0 +1,29 @@ +function ProjectCard({ project }) { + return ( + <a + className="project-card" + href={project.project_link} + target="_blank" + rel="noreferrer" + > + {project.image_url && ( + <img + className="project-card-image" + src={project.image_url} + alt={`${project.project_name} logo`} + /> + )} + <h3>{project.project_name}</h3> + <div className="project-card-categories"> + {project.categories.map((category) => ( + <span key={category} className="project-card-category"> + {category} + </span> + ))} + </div> + <p className="project-card-desc">{project.project_desc}</p> + </a> + ) +} + +export default ProjectCard diff --git a/src/components/ProjectGrid.jsx b/src/components/ProjectGrid.jsx new file mode 100644 index 0000000..29858c6 --- /dev/null +++ b/src/components/ProjectGrid.jsx @@ -0,0 +1,17 @@ +import ProjectCard from './ProjectCard.jsx' + +function ProjectGrid({ projects }) { + if (projects.length === 0) { + return <p className="empty-state">No projects match your search.</p> + } + + return ( + <div className="project-grid"> + {projects.map((project) => ( + <ProjectCard key={project.id} project={project} /> + ))} + </div> + ) +} + +export default ProjectGrid diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx new file mode 100644 index 0000000..7115739 --- /dev/null +++ b/src/components/Sidebar.jsx @@ -0,0 +1,18 @@ +function Sidebar({ tabs, activeTab, onSelectTab }) { + return ( + <nav className="sidebar"> + {tabs.map((tab) => ( + <button + key={tab.id} + type="button" + className={tab.id === activeTab ? 'sidebar-tab active' : 'sidebar-tab'} + onClick={() => onSelectTab(tab.id)} + > + {tab.label} + </button> + ))} + </nav> + ) +} + +export default Sidebar diff --git a/src/data/fetchProjects.js b/src/data/fetchProjects.js new file mode 100644 index 0000000..91e6618 --- /dev/null +++ b/src/data/fetchProjects.js @@ -0,0 +1,20 @@ +import { getCategories } from '../utils/categories.js' + +const API_URL = 'https://www.sefaria.org/api/powered-by' + +export async function fetchProjects() { + const response = await fetch(API_URL) + + if (!response.ok) { + throw new Error(`Failed to fetch projects: ${response.status}`) + } + + const data = await response.json() + + return data.projects + .filter((project) => project.is_published && project.consent_to_display) + .map((project) => ({ + ...project, + categories: getCategories(project.project_category), + })) +} diff --git a/src/index.css b/src/index.css index 2c84af0..f4bdf7c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,23 +1,24 @@ :root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); + /* Sefaria brand palette */ + --text: #575757; + --text-h: #333333; + --bg: #fbfbfa; + --border: #ededec; + --code-bg: #e6e6e6; + --accent: #4871bf; + --accent-bg: rgba(72, 113, 191, 0.1); + --accent-border: rgba(72, 113, 191, 0.5); + --social-bg: rgba(237, 237, 236, 0.5); --shadow: rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --sans: 'EB Garamond', system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: 'EB Garamond', system-ui, 'Segoe UI', Roboto, sans-serif; --mono: ui-monospace, Consolas, monospace; font: 18px/145% var(--sans); letter-spacing: 0.18px; - color-scheme: light dark; + color-scheme: light; color: var(--text); background: var(--bg); font-synthesis: none; @@ -30,36 +31,13 @@ } } -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} - body { margin: 0; } #root { - width: 1126px; - max-width: 100%; - margin: 0 auto; + width: 100%; text-align: center; - border-inline: 1px solid var(--border); min-height: 100svh; display: flex; flex-direction: column; @@ -109,3 +87,141 @@ code { padding: 4px 8px; background: var(--code-bg); } + +.dashboard-controls { + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + flex-wrap: wrap; + margin: 24px 0; +} + +.dashboard-controls input, +.dashboard-controls select { + font: inherit; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-h); +} + +.project-count { + color: var(--text); + font-size: 14px; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 16px; + padding: 0 24px 32px; + text-align: left; +} + +.project-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + gap: 8px; + aspect-ratio: 1 / 1; + overflow: hidden; + color: inherit; + text-decoration: none; + transition: background-color 0.15s; +} + +.project-card:hover { + background-color: var(--accent-bg); +} + +.project-card-image { + width: 100%; + max-height: 120px; + object-fit: contain; +} + +.project-card-categories { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.project-card-category { + display: inline-block; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; + background: var(--accent-bg); + color: var(--accent); + border: 1px solid var(--accent-border); +} + +.empty-state { + padding: 48px 24px; + color: var(--text); +} + +.project-card-desc { + /* fills whatever vertical space the square card leaves after image/title/category/link */ + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.app-body { + display: flex; + flex: 1; + align-items: stretch; + text-align: left; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 4px; + width: 220px; + flex-shrink: 0; + border-right: 1px solid var(--border); + padding: 16px 12px; +} + +.sidebar-tab { + font: inherit; + text-align: left; + padding: 12px 16px; + border: none; + border-radius: 999px; + background: none; + color: var(--text); + cursor: pointer; + white-space: nowrap; + transition: background-color 0.15s, color 0.15s; +} + +.sidebar-tab:hover { + background: var(--accent-bg); +} + +.sidebar-tab.active { + color: var(--accent); + background: var(--accent-bg); + font-weight: 600; +} + +.tab-content { + flex: 1; + min-width: 0; +} + +.charts-and-analytics { + padding: 48px 24px; + color: var(--text); + display: flex; + flex-direction: column; + gap: 48px; +} diff --git a/src/utils/categories.js b/src/utils/categories.js new file mode 100644 index 0000000..7b05223 --- /dev/null +++ b/src/utils/categories.js @@ -0,0 +1,33 @@ +export const KNOWN_CATEGORIES = [ + 'AI Projects, Apps, & Other Tools', + 'Learning & Study Tools', + 'Community, Interaction, & Social', + 'Visualization & Data Analysis', + 'Extensions, API Integrations, & GitHub Code', +] + +export const UNCATEGORIZED = 'Uncategorized' + +// Some older submissions used this wording for category 5; treat it as the same category. +const LEGACY_LABEL_MAP = { + 'Extensions and API Integrations': 'Extensions, API Integrations, & GitHub Code', +} + +export function getCategories(rawCategory) { + if (!rawCategory) return [UNCATEGORIZED] + + const candidates = [...KNOWN_CATEGORIES, ...Object.keys(LEGACY_LABEL_MAP)] + + const matches = candidates + .map((candidate) => ({ + label: LEGACY_LABEL_MAP[candidate] ?? candidate, + index: rawCategory.indexOf(candidate), + })) + .filter(({ index }) => index !== -1) + .sort((a, b) => a.index - b.index) + .map(({ label }) => label) + + const uniqueMatches = [...new Set(matches)] + + return uniqueMatches.length > 0 ? uniqueMatches : [UNCATEGORIZED] +} diff --git a/src/utils/experience.js b/src/utils/experience.js new file mode 100644 index 0000000..1997f0e --- /dev/null +++ b/src/utils/experience.js @@ -0,0 +1,13 @@ +export const EXPERIENCE_LEVELS = ['No Experience', 'Beginner', 'Intermediate', 'Advanced'] + +const RAW_TO_LABEL = { + None: 'No Experience', + '<5 years': 'Beginner', + '5-10 years': 'Intermediate', + '10+ years': 'Advanced', +} + +// Returns null for unspecified/blank/unrecognized experience. +export function getExperienceLevel(rawExperience) { + return RAW_TO_LABEL[rawExperience] ?? null +} diff --git a/src/utils/experience.test.js b/src/utils/experience.test.js new file mode 100644 index 0000000..1828532 --- /dev/null +++ b/src/utils/experience.test.js @@ -0,0 +1,23 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { EXPERIENCE_LEVELS, getExperienceLevel } from './experience.js' + +test('EXPERIENCE_LEVELS is the fixed four-level order', () => { + assert.deepEqual(EXPERIENCE_LEVELS, ['No Experience', 'Beginner', 'Intermediate', 'Advanced']) +}) + +test('getExperienceLevel maps known raw values to labels', () => { + assert.equal(getExperienceLevel('None'), 'No Experience') + assert.equal(getExperienceLevel('<5 years'), 'Beginner') + assert.equal(getExperienceLevel('5-10 years'), 'Intermediate') + assert.equal(getExperienceLevel('10+ years'), 'Advanced') +}) + +test('getExperienceLevel returns null for unspecified experience', () => { + assert.equal(getExperienceLevel(''), null) + assert.equal(getExperienceLevel(undefined), null) +}) + +test('getExperienceLevel returns null for an unrecognized raw value', () => { + assert.equal(getExperienceLevel('not a real value'), null) +}) diff --git a/src/utils/keywords.js b/src/utils/keywords.js new file mode 100644 index 0000000..92b9698 --- /dev/null +++ b/src/utils/keywords.js @@ -0,0 +1,28 @@ +export const KNOWN_KEYWORDS = [ + 'Tefillah', 'Leining', 'Daf Yomi', 'Chatbot', 'Zmanim', 'Tanakh', 'Halakha', + 'AI', 'Calendar', 'Research', 'Parsha', 'Rambam', 'Forum', 'Kaballah', + 'Quiz', 'Commentary', 'Gemara', 'Classroom', 'Visual', 'Dictionary', +] + +// One project's tags used this spelling; treat it as the same keyword. +const ALIAS_MAP = { + Kabbalah: 'Kaballah', +} + +export function getKeywordCounts(projects) { + const counts = new Map(KNOWN_KEYWORDS.map((keyword) => [keyword, 0])) + + for (const project of projects) { + for (const tag of project.tags ?? []) { + const keyword = ALIAS_MAP[tag] ?? tag + if (counts.has(keyword)) { + counts.set(keyword, counts.get(keyword) + 1) + } + } + } + + return [...counts.entries()] + .map(([keyword, count]) => ({ keyword, count })) + .filter(({ count }) => count > 0) + .sort((a, b) => b.count - a.count) +} diff --git a/src/utils/submissionsTrend.js b/src/utils/submissionsTrend.js new file mode 100644 index 0000000..472fc35 --- /dev/null +++ b/src/utils/submissionsTrend.js @@ -0,0 +1,113 @@ +import { EXPERIENCE_LEVELS, getExperienceLevel } from './experience.js' + +const MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] + +// Tag dates look like "July 2026" or "Date unspecified". +function parseTagMonth(rawDate) { + const match = /^([A-Za-z]+) (\d{4})$/.exec(rawDate ?? '') + if (!match) return null + + const monthIndex = MONTH_NAMES.indexOf(match[1]) + if (monthIndex === -1) return null + + return { year: Number(match[2]), monthIndex } +} + +function monthKey(year, monthIndex) { + return `${year}-${String(monthIndex + 1).padStart(2, '0')}` +} + +// Builds the 12 calendar months ending with referenceDate's month, oldest first. +function last12Months(referenceDate) { + const months = [] + for (let i = 11; i >= 0; i--) { + const d = new Date(referenceDate.getFullYear(), referenceDate.getMonth() - i, 1) + months.push({ year: d.getFullYear(), monthIndex: d.getMonth() }) + } + return months +} + +export function getSubmissionsMonthlyTrend(projects, referenceDate = new Date()) { + const months = last12Months(referenceDate) + + const counts = new Map(months.map(({ year, monthIndex }) => [monthKey(year, monthIndex), 0])) + + for (const project of projects) { + const [rawDate] = project.tags ?? [] + + const parsed = parseTagMonth(rawDate) + if (!parsed) continue + + const key = monthKey(parsed.year, parsed.monthIndex) + if (counts.has(key)) { + counts.set(key, counts.get(key) + 1) + } + } + + return months.map(({ year, monthIndex }) => ({ + month: `${MONTH_NAMES[monthIndex].slice(0, 3)} ${year}`, + count: counts.get(monthKey(year, monthIndex)), + })) +} + +// Builds every calendar month from start through end (inclusive), oldest first. +function monthsBetween(start, end) { + const months = [] + let cursor = new Date(start.year, start.monthIndex, 1) + const endDate = new Date(end.year, end.monthIndex, 1) + + while (cursor.getTime() <= endDate.getTime()) { + months.push({ year: cursor.getFullYear(), monthIndex: cursor.getMonth() }) + cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1) + } + + return months +} + +export function getSubmissionsTrendByExperience(projects, referenceDate = new Date()) { + const parsed = projects + .map((project) => ({ + month: parseTagMonth(project.tags?.[0]), + level: getExperienceLevel(project.technical_experience), + })) + .filter((entry) => entry.month !== null) + + if (parsed.length === 0) return [] + + // Only consider entries with a known experience level when finding the + // earliest month — otherwise leading months with unspecified experience + // (but no actual experience data) stretch the chart with dead flat-zero lines. + const withLevel = parsed.filter((entry) => entry.level !== null) + if (withLevel.length === 0) return [] + + const earliest = withLevel.reduce((earliestSoFar, entry) => { + const key = monthKey(entry.month.year, entry.month.monthIndex) + return key < monthKey(earliestSoFar.year, earliestSoFar.monthIndex) ? entry.month : earliestSoFar + }, withLevel[0].month) + + const end = { year: referenceDate.getFullYear(), monthIndex: referenceDate.getMonth() } + const months = monthsBetween(earliest, end) + + const counts = new Map( + months.map(({ year, monthIndex }) => [ + monthKey(year, monthIndex), + Object.fromEntries(EXPERIENCE_LEVELS.map((level) => [level, 0])), + ]), + ) + + for (const entry of parsed) { + if (entry.level === null) continue + const key = monthKey(entry.month.year, entry.month.monthIndex) + if (counts.has(key)) { + counts.get(key)[entry.level] += 1 + } + } + + return months.map(({ year, monthIndex }) => ({ + month: `${MONTH_NAMES[monthIndex].slice(0, 3)} ${year}`, + ...counts.get(monthKey(year, monthIndex)), + })) +} diff --git a/src/utils/submissionsTrend.test.js b/src/utils/submissionsTrend.test.js new file mode 100644 index 0000000..cd92c55 --- /dev/null +++ b/src/utils/submissionsTrend.test.js @@ -0,0 +1,65 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { getSubmissionsTrendByExperience } from './submissionsTrend.js' + +const referenceDate = new Date(2026, 6, 15) // July 2026 + +test('getSubmissionsTrendByExperience returns [] when no project has a parseable date', () => { + const projects = [ + { tags: ['Date unspecified'], technical_experience: 'None' }, + { tags: [], technical_experience: 'Beginner' }, + ] + assert.deepEqual(getSubmissionsTrendByExperience(projects, referenceDate), []) +}) + +test('getSubmissionsTrendByExperience spans from the earliest submission through referenceDate, zero-filled', () => { + const projects = [ + { tags: ['May 2026'], technical_experience: 'None' }, + ] + const result = getSubmissionsTrendByExperience(projects, referenceDate) + + assert.deepEqual(result, [ + { month: 'May 2026', 'No Experience': 1, Beginner: 0, Intermediate: 0, Advanced: 0 }, + { month: 'Jun 2026', 'No Experience': 0, Beginner: 0, Intermediate: 0, Advanced: 0 }, + { month: 'Jul 2026', 'No Experience': 0, Beginner: 0, Intermediate: 0, Advanced: 0 }, + ]) +}) + +test('getSubmissionsTrendByExperience buckets each level independently per month', () => { + const projects = [ + { tags: ['May 2026'], technical_experience: 'None' }, + { tags: ['May 2026'], technical_experience: '<5 years' }, + { tags: ['May 2026'], technical_experience: '<5 years' }, + { tags: ['July 2026'], technical_experience: '5-10 years' }, + { tags: ['July 2026'], technical_experience: '10+ years' }, + ] + const result = getSubmissionsTrendByExperience(projects, referenceDate) + + assert.deepEqual(result, [ + { month: 'May 2026', 'No Experience': 1, Beginner: 2, Intermediate: 0, Advanced: 0 }, + { month: 'Jun 2026', 'No Experience': 0, Beginner: 0, Intermediate: 0, Advanced: 0 }, + { month: 'Jul 2026', 'No Experience': 0, Beginner: 0, Intermediate: 1, Advanced: 1 }, + ]) +}) + +test('getSubmissionsTrendByExperience returns [] when every parseable project has unspecified experience', () => { + const projects = [ + { tags: ['July 2026'], technical_experience: '' }, + ] + const result = getSubmissionsTrendByExperience(projects, referenceDate) + + assert.deepEqual(result, []) +}) + +test('getSubmissionsTrendByExperience ignores unspecified-experience months when finding the earliest month', () => { + const projects = [ + // Unspecified experience, earlier date — should NOT push the chart's start back. + { tags: ['January 2026'], technical_experience: '' }, + { tags: ['July 2026'], technical_experience: 'None' }, + ] + const result = getSubmissionsTrendByExperience(projects, referenceDate) + + assert.deepEqual(result, [ + { month: 'Jul 2026', 'No Experience': 1, Beginner: 0, Intermediate: 0, Advanced: 0 }, + ]) +})