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 (
<>
+
+
+
+ {activeTab === 'projects' && }
+ {activeTab === 'charts' && }
+
+
>
)
}
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 (
-
+
)
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 Couldn't load chart data right now.
+ if (!trend || !keywordCounts || !experienceTrend) return Loading chart…
+ if (experienceTrend.length === 0) {
+ return No experience-level data available yet.
+ }
+
+ return (
+
+
Submissions, past 12 months
+
+
+
+
+
+
+
+
+
+
+ Keyword frequency
+
+
+
+
+
+
+
+
+
+
+ Submissions by experience level
+
+
+
+
+
+
+
+ {EXPERIENCE_LEVELS.map((level) => (
+
+ props.index === experienceTrend.length - 1 ? (
+
+ {level}
+
+ ) : null
+ }
+ />
+ ))}
+
+
+
+ )
+}
+
+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 (
+
+ onSearchChange(event.target.value)}
+ />
+
+ {count} projects
+
+ )
+}
+
+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 Loading projects…
+ if (error) return Couldn't load projects right now.
+
+ 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 (
+ <>
+
+
+ >
+ )
+}
+
+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 (
+
+ {project.image_url && (
+
+ )}
+ {project.project_name}
+
+ {project.categories.map((category) => (
+
+ {category}
+
+ ))}
+
+ {project.project_desc}
+
+ )
+}
+
+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 No projects match your search.
+ }
+
+ return (
+
+ {projects.map((project) => (
+
+ ))}
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 },
+ ])
+})