diff --git a/.gitignore b/.gitignore index 81f818c..b3fe837 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ dist-ssr .superpowers/ docs/superpowers/ +# Raw local data dumps (may contain sensitive info, never commit) +data/ + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/README.md b/README.md index 90322fa..d294918 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Other scripts: - `npm run build` — production build - `npm run preview` — preview the production build locally - `npm run lint` — run ESLint +- `npm run screenshots` — regenerate the faint website-screenshot backgrounds shown on project cards. Fetches the current published project list, captures a screenshot of each project's site with headless Chrome, and writes the results to `public/screenshots/` + `src/data/screenshotManifest.json`. Run this manually whenever you want to pick up newly-added projects or refresh existing screenshots — it is **not** run automatically on build or deploy. Takes roughly 1–2 minutes for the current project count; sites that fail to load are skipped and logged, not treated as errors. ## Troubleshooting diff --git a/package-lock.json b/package-lock.json index baf445b..2d717cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "puppeteer": "^25.4.0", "vite": "^8.1.0" } }, @@ -571,6 +572,35 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@puppeteer/browsers": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.6.tgz", + "integrity": "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.7.6", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -1060,6 +1090,32 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1151,6 +1207,66 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/chromium-bidi/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1351,6 +1467,13 @@ "node": ">=8" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1653615", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz", + "integrity": "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/electron-to-chromium": { "version": "1.5.381", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.381.tgz", @@ -1358,6 +1481,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/es-toolkit": { "version": "1.50.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", @@ -1698,6 +1828,29 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2149,6 +2302,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2191,6 +2357,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.7.7", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.7.tgz", + "integrity": "sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2373,6 +2556,46 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.4.0.tgz", + "integrity": "sha512-xfQp8dFBcGaLc1hEMaVr7s+oW4ZkAurr8Y9H81ilKhu6QoLfSTkZjU7IavnyJ/VWpB9ni3KNJUQHUatslLWyGw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.4.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.4.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.4.0.tgz", + "integrity": "sha512-K1plkLOdeoUnGeT1OvdqF3qxl33v+Ra/uH5VyPEhXdMcpvGiEskHzxxEU3fgpccJpJLIipB/rPUsvkZRWeKqOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.0.6", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1653615", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.2", + "ws": "^8.21.1" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -2558,6 +2781,39 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -2602,6 +2858,13 @@ "node": ">= 0.8.0" } }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2752,6 +3015,13 @@ } } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2778,6 +3048,74 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2785,6 +3123,34 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 05d204f..137fe10 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "build": "vite build", "lint": "eslint .", "preview": "vite preview", - "test": "node --test" + "test": "node --test", + "screenshots": "node scripts/generate-screenshots.mjs" }, "dependencies": { "react": "^19.2.7", @@ -24,6 +25,7 @@ "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.6.0", + "puppeteer": "^25.4.0", "vite": "^8.1.0" } } diff --git a/public/screenshots/1.jpg b/public/screenshots/1.jpg new file mode 100644 index 0000000..524fa2e Binary files /dev/null and b/public/screenshots/1.jpg differ diff --git a/public/screenshots/10.jpg b/public/screenshots/10.jpg new file mode 100644 index 0000000..e83a33e Binary files /dev/null and b/public/screenshots/10.jpg differ diff --git a/public/screenshots/11.jpg b/public/screenshots/11.jpg new file mode 100644 index 0000000..11fb8af Binary files /dev/null and b/public/screenshots/11.jpg differ diff --git a/public/screenshots/12.jpg b/public/screenshots/12.jpg new file mode 100644 index 0000000..d6516cd Binary files /dev/null and b/public/screenshots/12.jpg differ diff --git a/public/screenshots/13.jpg b/public/screenshots/13.jpg new file mode 100644 index 0000000..f71b783 Binary files /dev/null and b/public/screenshots/13.jpg differ diff --git a/public/screenshots/15.jpg b/public/screenshots/15.jpg new file mode 100644 index 0000000..2c69d9a Binary files /dev/null and b/public/screenshots/15.jpg differ diff --git a/public/screenshots/16.jpg b/public/screenshots/16.jpg new file mode 100644 index 0000000..298b5d4 Binary files /dev/null and b/public/screenshots/16.jpg differ diff --git a/public/screenshots/17.jpg b/public/screenshots/17.jpg new file mode 100644 index 0000000..abb907a Binary files /dev/null and b/public/screenshots/17.jpg differ diff --git a/public/screenshots/18.jpg b/public/screenshots/18.jpg new file mode 100644 index 0000000..787f238 Binary files /dev/null and b/public/screenshots/18.jpg differ diff --git a/public/screenshots/19.jpg b/public/screenshots/19.jpg new file mode 100644 index 0000000..4fbcf5c Binary files /dev/null and b/public/screenshots/19.jpg differ diff --git a/public/screenshots/2.jpg b/public/screenshots/2.jpg new file mode 100644 index 0000000..3a57ad6 Binary files /dev/null and b/public/screenshots/2.jpg differ diff --git a/public/screenshots/20.jpg b/public/screenshots/20.jpg new file mode 100644 index 0000000..6350728 Binary files /dev/null and b/public/screenshots/20.jpg differ diff --git a/public/screenshots/21.jpg b/public/screenshots/21.jpg new file mode 100644 index 0000000..e7097db Binary files /dev/null and b/public/screenshots/21.jpg differ diff --git a/public/screenshots/22.jpg b/public/screenshots/22.jpg new file mode 100644 index 0000000..4bcbc98 Binary files /dev/null and b/public/screenshots/22.jpg differ diff --git a/public/screenshots/24.jpg b/public/screenshots/24.jpg new file mode 100644 index 0000000..54d28dc Binary files /dev/null and b/public/screenshots/24.jpg differ diff --git a/public/screenshots/25.jpg b/public/screenshots/25.jpg new file mode 100644 index 0000000..3a01eb3 Binary files /dev/null and b/public/screenshots/25.jpg differ diff --git a/public/screenshots/26.jpg b/public/screenshots/26.jpg new file mode 100644 index 0000000..03e0162 Binary files /dev/null and b/public/screenshots/26.jpg differ diff --git a/public/screenshots/27.jpg b/public/screenshots/27.jpg new file mode 100644 index 0000000..c204155 Binary files /dev/null and b/public/screenshots/27.jpg differ diff --git a/public/screenshots/28.jpg b/public/screenshots/28.jpg new file mode 100644 index 0000000..d57c744 Binary files /dev/null and b/public/screenshots/28.jpg differ diff --git a/public/screenshots/3.jpg b/public/screenshots/3.jpg new file mode 100644 index 0000000..f0028fc Binary files /dev/null and b/public/screenshots/3.jpg differ diff --git a/public/screenshots/30.jpg b/public/screenshots/30.jpg new file mode 100644 index 0000000..fa91f08 Binary files /dev/null and b/public/screenshots/30.jpg differ diff --git a/public/screenshots/31.jpg b/public/screenshots/31.jpg new file mode 100644 index 0000000..4f3ab63 Binary files /dev/null and b/public/screenshots/31.jpg differ diff --git a/public/screenshots/33.jpg b/public/screenshots/33.jpg new file mode 100644 index 0000000..716bc31 Binary files /dev/null and b/public/screenshots/33.jpg differ diff --git a/public/screenshots/34.jpg b/public/screenshots/34.jpg new file mode 100644 index 0000000..e1c6d02 Binary files /dev/null and b/public/screenshots/34.jpg differ diff --git a/public/screenshots/35.jpg b/public/screenshots/35.jpg new file mode 100644 index 0000000..6b0210c Binary files /dev/null and b/public/screenshots/35.jpg differ diff --git a/public/screenshots/36.jpg b/public/screenshots/36.jpg new file mode 100644 index 0000000..49dba1f Binary files /dev/null and b/public/screenshots/36.jpg differ diff --git a/public/screenshots/37.jpg b/public/screenshots/37.jpg new file mode 100644 index 0000000..ad32768 Binary files /dev/null and b/public/screenshots/37.jpg differ diff --git a/public/screenshots/4.jpg b/public/screenshots/4.jpg new file mode 100644 index 0000000..16615bb Binary files /dev/null and b/public/screenshots/4.jpg differ diff --git a/public/screenshots/5.jpg b/public/screenshots/5.jpg new file mode 100644 index 0000000..30d8eab Binary files /dev/null and b/public/screenshots/5.jpg differ diff --git a/public/screenshots/6.jpg b/public/screenshots/6.jpg new file mode 100644 index 0000000..735631f Binary files /dev/null and b/public/screenshots/6.jpg differ diff --git a/public/screenshots/8.jpg b/public/screenshots/8.jpg new file mode 100644 index 0000000..4fc91c1 Binary files /dev/null and b/public/screenshots/8.jpg differ diff --git a/public/screenshots/9.jpg b/public/screenshots/9.jpg new file mode 100644 index 0000000..b43139b Binary files /dev/null and b/public/screenshots/9.jpg differ diff --git a/scripts/generate-screenshots.mjs b/scripts/generate-screenshots.mjs new file mode 100644 index 0000000..ab391e4 --- /dev/null +++ b/scripts/generate-screenshots.mjs @@ -0,0 +1,109 @@ +import { writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import puppeteer from 'puppeteer' + +const API_URL = 'https://www.sefaria.org/api/powered-by' +const SCREENSHOTS_DIR = path.resolve('public/screenshots') +const MANIFEST_PATH = path.resolve('src/data/screenshotManifest.json') +const CONCURRENCY = 3 +const NAV_TIMEOUT_MS = 15000 +const VIEWPORT = { width: 1280, height: 800 } + +async function fetchPublishedProjects() { + 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 && project.project_link, + ) +} + +function isSafeUrl(rawUrl) { + let url + try { + url = new URL(rawUrl) + } catch { + return false + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false + if (/^(localhost|127\.|0\.|10\.|192\.168\.|169\.254\.|\[::1\])/.test(url.hostname)) return false + return true +} + +async function screenshotProject(browser, project) { + if (!isSafeUrl(project.project_link)) { + console.warn( + `Skipping ${project.project_name} (${project.project_link}): unsafe or invalid URL`, + ) + return { id: project.id, ok: false } + } + + const page = await browser.newPage() + try { + await page.setViewport(VIEWPORT) + await page.goto(project.project_link, { + waitUntil: 'networkidle2', + timeout: NAV_TIMEOUT_MS, + }) + const filePath = path.join(SCREENSHOTS_DIR, `${project.id}.jpg`) + await page.screenshot({ path: filePath, type: 'jpeg', quality: 60 }) + return { id: project.id, ok: true } + } catch (error) { + console.warn( + `Skipping ${project.project_name} (${project.project_link}): ${error.message}`, + ) + return { id: project.id, ok: false } + } finally { + await page.close() + } +} + +async function runPool(items, worker, concurrency) { + const results = [] + let index = 0 + + async function next() { + while (index < items.length) { + const current = items[index] + index += 1 + results.push(await worker(current)) + } + } + + await Promise.all(Array.from({ length: concurrency }, next)) + return results +} + +async function main() { + await mkdir(SCREENSHOTS_DIR, { recursive: true }) + + const projects = await fetchPublishedProjects() + const browser = await puppeteer.launch({ headless: true }) + + let results + try { + results = await runPool( + projects, + (project) => screenshotProject(browser, project), + CONCURRENCY, + ) + } finally { + await browser.close() + } + + const manifest = {} + for (const result of results) { + if (result.ok) manifest[result.id] = true + } + await writeFile(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n') + + const succeeded = results.filter((r) => r.ok).length + console.log(`Generated ${succeeded}/${results.length} screenshots`) +} + +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/src/components/ChartsAndAnalytics.jsx b/src/components/ChartsAndAnalytics.jsx index 53b8f67..350febc 100644 --- a/src/components/ChartsAndAnalytics.jsx +++ b/src/components/ChartsAndAnalytics.jsx @@ -3,17 +3,27 @@ import { Bar, BarChart, CartesianGrid, + Cell, Legend, Line, LineChart, + Pie, + PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import { fetchProjects } from '../data/fetchProjects.js' -import { getSubmissionsMonthlyTrend, getSubmissionsTrendByExperience } from '../utils/submissionsTrend.js' +import { + getSubmissionsMonthlyTrend, + getSubmissionsTrendByExperience, + getSubmissionsTrendByVibeCoded, + VIBE_CODED_SERIES, +} from '../utils/submissionsTrend.js' import { getKeywordCounts } from '../utils/keywords.js' +import { getToolUsageCounts } from '../utils/sefariaTools.js' +import { getTechCounts } from '../utils/techUsed.js' import { EXPERIENCE_LEVELS } from '../utils/experience.js' const EXPERIENCE_COLORS = { @@ -23,10 +33,27 @@ const EXPERIENCE_COLORS = { Advanced: '#4a3aa7', } +// Fixed-order categorical hues; gray is reserved for the "Other" bucket and +// is never one of the 6 identity colors. +const TOOL_SLICE_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300'] +const OTHER_SLICE_COLOR = '#9a9a94' + +function colorForToolSlice(endpoint, index) { + return endpoint === 'Other' ? OTHER_SLICE_COLOR : TOOL_SLICE_COLORS[index] +} + +const VIBE_CODED_COLORS = { + 'Not vibe-coded': '#2a78d6', + 'Vibe-coded': '#eb6834', +} + function ChartsAndAnalytics() { const [trend, setTrend] = useState(null) const [keywordCounts, setKeywordCounts] = useState(null) const [experienceTrend, setExperienceTrend] = useState(null) + const [toolUsage, setToolUsage] = useState(null) + const [vibeCodedTrend, setVibeCodedTrend] = useState(null) + const [techCounts, setTechCounts] = useState(null) const [error, setError] = useState(null) useEffect(() => { @@ -35,16 +62,17 @@ function ChartsAndAnalytics() { setTrend(getSubmissionsMonthlyTrend(projects)) setKeywordCounts(getKeywordCounts(projects)) setExperienceTrend(getSubmissionsTrendByExperience(projects)) + setToolUsage(getToolUsageCounts(projects)) + setVibeCodedTrend(getSubmissionsTrendByVibeCoded(projects)) + setTechCounts(getTechCounts(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.

+ if (!trend || !keywordCounts || !experienceTrend || !toolUsage || !vibeCodedTrend || !techCounts) { + return

Loading chart…

} - return (

Submissions, past 12 months

@@ -74,29 +102,100 @@ function ChartsAndAnalytics() {

Submissions by experience level

+ {experienceTrend.length === 0 ? ( +

No experience-level data available yet.

+ ) : ( + + + + + + + + {EXPERIENCE_LEVELS.map((level) => ( + + props.index === experienceTrend.length - 1 ? ( + + {level} + + ) : null + } + /> + ))} + + + )} + +

Most-used Sefaria API endpoints

+ {toolUsage.length === 0 ? ( +

No endpoint data available yet.

+ ) : ( + + + + {toolUsage.map((entry, index) => ( + + ))} + + + + + + )} + +

Vibe-coded vs. not, past 12 months

+

+ "Vibe-coded" is a newly-tracked field, so earlier months may be undercounted or unreported rather than confirmed non-vibe-coded. +

- + - {EXPERIENCE_LEVELS.map((level) => ( + {VIBE_CODED_SERIES.map((series) => ( - props.index === experienceTrend.length - 1 ? ( + props.index === vibeCodedTrend.length - 1 ? ( - {level} + {series} ) : null } @@ -112,6 +211,25 @@ function ChartsAndAnalytics() { ))} + +

Technologies used

+ {techCounts.length === 0 ? ( +

No technology data available yet.

+ ) : ( + + + + + + + + + + )}
) } diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index 634a903..be36432 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -10,6 +10,7 @@ function Controls({
onSearchChange(event.target.value)} diff --git a/src/components/ProjectCard.jsx b/src/components/ProjectCard.jsx index e45e56b..6058c1d 100644 --- a/src/components/ProjectCard.jsx +++ b/src/components/ProjectCard.jsx @@ -1,28 +1,49 @@ +import { useState } from 'react' +import { getCategoryColor } from '../utils/categories.js' +import { getScreenshotUrl } from '../utils/screenshots.js' + function ProjectCard({ project }) { + const [flipped, setFlipped] = useState(false) + const screenshotUrl = getScreenshotUrl(project.id) + + function handleCardClick() { + if (window.getSelection().toString()) return + setFlipped((current) => !current) + } + return ( - - {project.image_url && ( - {`${project.project_name} - )} -

{project.project_name}

-
- {project.categories.map((category) => ( - - {category} - - ))} +
+
+
+ event.stopPropagation()} + > + {project.project_name} + +
+
+
+ {project.categories.map((category) => ( + + {category} + + ))} +
+

{project.project_desc}

+
-

{project.project_desc}

- +
) } diff --git a/src/data/screenshotManifest.json b/src/data/screenshotManifest.json new file mode 100644 index 0000000..e800027 --- /dev/null +++ b/src/data/screenshotManifest.json @@ -0,0 +1,34 @@ +{ + "1": true, + "2": true, + "3": true, + "4": true, + "5": true, + "6": true, + "8": true, + "9": true, + "10": true, + "11": true, + "12": true, + "13": true, + "15": true, + "16": true, + "17": true, + "18": true, + "19": true, + "20": true, + "21": true, + "22": true, + "24": true, + "25": true, + "26": true, + "27": true, + "28": true, + "30": true, + "31": true, + "33": true, + "34": true, + "35": true, + "36": true, + "37": true +} diff --git a/src/index.css b/src/index.css index f4bdf7c..0b2424c 100644 --- a/src/index.css +++ b/src/index.css @@ -92,9 +92,16 @@ code { display: flex; gap: 12px; align-items: center; - justify-content: center; + justify-content: flex-start; flex-wrap: wrap; margin: 24px 0; + /* Anchors the row's left edge under the Developers logo's left edge. + The logo (646px, centered in the full-width header) and this row (inside + the sidebar-offset tab-content) live in different coordinate spaces, so + matching them means re-deriving the logo's position here: half the + leftover viewport width outside the logo, minus the sidebar's full + rendered width (220px content + 24px padding + 1px border-right). */ + margin-left: max(0px, calc((100vw - 646px) / 2 - 245px)); } .dashboard-controls input, @@ -107,6 +114,10 @@ code { color: var(--text-h); } +.search-input { + width: 280px; +} + .project-count { color: var(--text); font-size: 14px; @@ -121,28 +132,102 @@ code { } .project-card { + perspective: 1000px; + aspect-ratio: 1 / 1; + cursor: pointer; +} + +.project-card-inner { + position: relative; + width: 100%; + height: 100%; + transition: transform 0.6s; + transform-style: preserve-3d; +} + +.project-card-inner.flipped { + transform: rotateY(180deg); +} + +.project-card-inner.flipped .project-card-front { + pointer-events: none; + visibility: hidden; +} + +.project-card-back { + pointer-events: none; +} + +.project-card-inner.flipped .project-card-back { + pointer-events: auto; +} + +.project-card-front, +.project-card-back { + position: absolute; + inset: 0; 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; + backface-visibility: hidden; transition: background-color 0.15s; + box-sizing: border-box; +} + +.project-card-front { + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + text-align: center; + background-size: cover; + background-position: center; } -.project-card:hover { +.project-card-front:hover { background-color: var(--accent-bg); } -.project-card-image { - width: 100%; - max-height: 120px; - object-fit: contain; +.project-card-front.has-screenshot::before { + content: ''; + position: absolute; + inset: 0; + background: var(--bg); + opacity: 0.88; + z-index: 0; +} + +.project-card-front.has-screenshot:hover::before { + background: linear-gradient(var(--accent-bg), var(--accent-bg)), var(--bg); +} + +.project-card-title { + position: relative; + z-index: 1; + color: inherit; + text-decoration: none; + font-size: 1.4em; + font-weight: 600; + transition: color 0.15s; +} + +.project-card-title:hover { + color: var(--accent); + text-decoration: none; +} + +.project-card-back { + transform: rotateY(180deg); + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--bg); +} + +.project-card-back:hover { + background-color: var(--accent-bg); } .project-card-categories { @@ -156,9 +241,9 @@ code { font-size: 12px; padding: 2px 8px; border-radius: 4px; - background: var(--accent-bg); - color: var(--accent); - border: 1px solid var(--accent-border); + background: color-mix(in srgb, var(--category-color, var(--accent)) 14%, var(--bg)); + color: color-mix(in srgb, var(--category-color, var(--accent)) 70%, var(--text-h)); + border: 1px solid color-mix(in srgb, var(--category-color, var(--accent)) 40%, var(--bg)); } .empty-state { @@ -167,7 +252,7 @@ code { } .project-card-desc { - /* fills whatever vertical space the square card leaves after image/title/category/link */ + /* fills whatever vertical space the back face leaves after the category tags */ flex: 1; min-height: 0; overflow-y: auto; diff --git a/src/utils/categories.js b/src/utils/categories.js index 7b05223..5cda818 100644 --- a/src/utils/categories.js +++ b/src/utils/categories.js @@ -8,6 +8,22 @@ export const KNOWN_CATEGORIES = [ export const UNCATEGORIZED = 'Uncategorized' +// Fixed-order categorical hues, matching the palette already used elsewhere +// in the dashboard's charts; gray is reserved for Uncategorized (and any +// unrecognized label) and is never one of the known-category colors. +const CATEGORY_COLORS = { + 'AI Projects, Apps, & Other Tools': '#2a78d6', + 'Learning & Study Tools': '#eb6834', + 'Community, Interaction, & Social': '#1baf7a', + 'Visualization & Data Analysis': '#4a3aa7', + 'Extensions, API Integrations, & GitHub Code': '#e87ba4', + [UNCATEGORIZED]: '#9a9a94', +} + +export function getCategoryColor(category) { + return CATEGORY_COLORS[category] ?? CATEGORY_COLORS[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', diff --git a/src/utils/categories.test.js b/src/utils/categories.test.js new file mode 100644 index 0000000..f0d7604 --- /dev/null +++ b/src/utils/categories.test.js @@ -0,0 +1,21 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { getCategoryColor, KNOWN_CATEGORIES, UNCATEGORIZED } from './categories.js' + +test('getCategoryColor returns a distinct color for every known category', () => { + const colors = KNOWN_CATEGORIES.map(getCategoryColor) + assert.equal(new Set(colors).size, KNOWN_CATEGORIES.length) + for (const color of colors) { + assert.match(color, /^#[0-9a-f]{6}$/) + } +}) + +test('getCategoryColor returns a color for Uncategorized distinct from every known category', () => { + const uncategorizedColor = getCategoryColor(UNCATEGORIZED) + assert.match(uncategorizedColor, /^#[0-9a-f]{6}$/) + assert.equal(KNOWN_CATEGORIES.map(getCategoryColor).includes(uncategorizedColor), false) +}) + +test('getCategoryColor falls back to the Uncategorized color for an unrecognized label', () => { + assert.equal(getCategoryColor('Not a real category'), getCategoryColor(UNCATEGORIZED)) +}) diff --git a/src/utils/screenshots.js b/src/utils/screenshots.js new file mode 100644 index 0000000..27d4652 --- /dev/null +++ b/src/utils/screenshots.js @@ -0,0 +1,5 @@ +import manifest from '../data/screenshotManifest.json' with { type: 'json' } + +export function getScreenshotUrl(id) { + return manifest[id] ? `/screenshots/${id}.jpg` : null +} diff --git a/src/utils/screenshots.test.js b/src/utils/screenshots.test.js new file mode 100644 index 0000000..6375d75 --- /dev/null +++ b/src/utils/screenshots.test.js @@ -0,0 +1,15 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { getScreenshotUrl } from './screenshots.js' + +test('getScreenshotUrl returns the screenshot path for an id present in the manifest', () => { + assert.equal(getScreenshotUrl('1'), '/screenshots/1.jpg') +}) + +test('getScreenshotUrl returns the screenshot path when id is passed as a number', () => { + assert.equal(getScreenshotUrl(1), '/screenshots/1.jpg') +}) + +test('getScreenshotUrl returns null for an id not in the manifest', () => { + assert.equal(getScreenshotUrl('does-not-exist'), null) +}) diff --git a/src/utils/sefariaTools.js b/src/utils/sefariaTools.js new file mode 100644 index 0000000..9b00dad --- /dev/null +++ b/src/utils/sefariaTools.js @@ -0,0 +1,62 @@ +// Base paths from Sefaria's public API reference (developers.sefaria.org). +// Raw sefaria_tools_used values are free text and often include a trailing +// slash, a query string, or extra path segments (a specific text ref) after +// the real endpoint — normalizeEndpoint collapses all of that down to one +// of these base paths. +export const KNOWN_ENDPOINTS = [ + '/api/texts', '/api/v3/texts', '/api/texts/versions', '/api/texts/translations', + '/api/texts/random-by-topic', '/api/bulktext', '/api/passages', + '/api/index', '/api/v2/raw/index', + '/api/shape', '/api/links', '/api/related', '/api/ref-topic-links', + '/api/link-summary', '/api/search-wrapper', '/api/find-refs', + '/api/name', '/api/topics', '/api/v2/topics', '/api/topics-graph', + '/api/recommend/topics', '/api/calendars', '/api/calendars/next-read', + '/api/sheets', '/api/collections', '/api/counts', '/api/authors', + '/api/manuscripts', '/api/img-gen', '/api/ref', '/api/profile', '/api/async', + '/api/words/completion', '/api/words', +] + +// Longest base path first, so a more specific base (e.g. /api/words/completion) +// is tried before a shorter one it would otherwise also match (/api/words). +const ENDPOINTS_BY_LENGTH_DESC = [...KNOWN_ENDPOINTS].sort((a, b) => b.length - a.length) + +export function normalizeEndpoint(raw) { + const withoutQuery = raw.trim().split('?')[0] + const withoutTrailingSlash = + withoutQuery.length > 1 && withoutQuery.endsWith('/') + ? withoutQuery.slice(0, -1) + : withoutQuery + + const knownMatch = ENDPOINTS_BY_LENGTH_DESC.find( + (base) => withoutTrailingSlash === base || withoutTrailingSlash.startsWith(`${base}/`), + ) + + return knownMatch ?? withoutTrailingSlash +} + +const TOP_ENDPOINT_LIMIT = 6 + +export function getToolUsageCounts(projects) { + const counts = new Map() + + for (const project of projects) { + const normalized = new Set( + (project.sefaria_tools_used ?? []).filter((tool) => typeof tool === 'string').map(normalizeEndpoint), + ) + for (const endpoint of normalized) { + counts.set(endpoint, (counts.get(endpoint) ?? 0) + 1) + } + } + + const sorted = [...counts.entries()] + .map(([endpoint, count]) => ({ endpoint, count })) + .sort((a, b) => b.count - a.count) + + const top = sorted.slice(0, TOP_ENDPOINT_LIMIT) + const rest = sorted.slice(TOP_ENDPOINT_LIMIT) + + if (rest.length === 0) return top + + const otherCount = rest.reduce((sum, entry) => sum + entry.count, 0) + return [...top, { endpoint: 'Other', count: otherCount }] +} diff --git a/src/utils/sefariaTools.test.js b/src/utils/sefariaTools.test.js new file mode 100644 index 0000000..a8151ad --- /dev/null +++ b/src/utils/sefariaTools.test.js @@ -0,0 +1,92 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { normalizeEndpoint, getToolUsageCounts } from './sefariaTools.js' + +test('normalizeEndpoint strips a trailing slash', () => { + assert.equal(normalizeEndpoint('/api/texts/'), '/api/texts') +}) + +test('normalizeEndpoint strips a query string', () => { + assert.equal(normalizeEndpoint('/api/index?foo=bar'), '/api/index') +}) + +test('normalizeEndpoint collapses extra path segments onto the longest known base', () => { + assert.equal( + normalizeEndpoint('/api/v3/texts/Esther%201.1?version=french'), + '/api/v3/texts', + ) +}) + +test('normalizeEndpoint prefers the longest matching known base', () => { + assert.equal(normalizeEndpoint('/api/words/completion/foo/bar'), '/api/words/completion') + assert.equal(normalizeEndpoint('/api/words/foo'), '/api/words') +}) + +test('normalizeEndpoint keeps an unrecognized endpoint as its own bucket', () => { + assert.equal(normalizeEndpoint('/api/totally-unknown-thing'), '/api/totally-unknown-thing') +}) + +test('normalizeEndpoint trims whitespace', () => { + assert.equal(normalizeEndpoint(' /api/index '), '/api/index') +}) + +test('getToolUsageCounts returns [] when no project reports any tools', () => { + const projects = [{ sefaria_tools_used: [] }, {}, { sefaria_tools_used: undefined }] + assert.deepEqual(getToolUsageCounts(projects), []) +}) + +test('getToolUsageCounts counts each project once per normalized endpoint even with variant duplicates', () => { + const projects = [ + { sefaria_tools_used: ['/api/texts', '/api/texts/'] }, // same project, same endpoint twice + { sefaria_tools_used: ['/api/texts'] }, + ] + assert.deepEqual(getToolUsageCounts(projects), [{ endpoint: '/api/texts', count: 2 }]) +}) + +test('getToolUsageCounts sorts descending by count', () => { + const projects = [ + { sefaria_tools_used: ['/api/index'] }, + { sefaria_tools_used: ['/api/calendars'] }, + { sefaria_tools_used: ['/api/calendars'] }, + ] + assert.deepEqual(getToolUsageCounts(projects), [ + { endpoint: '/api/calendars', count: 2 }, + { endpoint: '/api/index', count: 1 }, + ]) +}) + +test('getToolUsageCounts skips non-string entries instead of throwing', () => { + const projects = [ + { sefaria_tools_used: [null, 42, '/api/index'] }, + { sefaria_tools_used: ['/api/index'] }, + ] + assert.doesNotThrow(() => getToolUsageCounts(projects)) + assert.deepEqual(getToolUsageCounts(projects), [{ endpoint: '/api/index', count: 2 }]) +}) + +test('getToolUsageCounts does not add an Other bucket at exactly 6 distinct endpoints', () => { + const endpoints = ['/api/index', '/api/texts', '/api/calendars', '/api/words', '/api/name', '/api/related'] + const projects = endpoints.map((endpoint) => ({ sefaria_tools_used: [endpoint] })) + const result = getToolUsageCounts(projects) + assert.equal(result.length, 6) + assert.equal(result.some((r) => r.endpoint === 'Other'), false) +}) + +test('getToolUsageCounts buckets past 6 distinct endpoints into Other', () => { + // 6 endpoints used twice each (ranked 1-6), 3 endpoints used once each (fold into Other) + const projects = [ + ...Array(2).fill({ sefaria_tools_used: ['/api/index'] }), + ...Array(2).fill({ sefaria_tools_used: ['/api/texts'] }), + ...Array(2).fill({ sefaria_tools_used: ['/api/calendars'] }), + ...Array(2).fill({ sefaria_tools_used: ['/api/words'] }), + ...Array(2).fill({ sefaria_tools_used: ['/api/name'] }), + ...Array(2).fill({ sefaria_tools_used: ['/api/related'] }), + { sefaria_tools_used: ['/api/shape'] }, + { sefaria_tools_used: ['/api/links'] }, + { sefaria_tools_used: ['/api/topics'] }, + ] + const result = getToolUsageCounts(projects) + assert.equal(result.length, 7) + assert.deepEqual(result[6], { endpoint: 'Other', count: 3 }) + assert.equal(result.slice(0, 6).every((r) => r.count === 2), true) +}) diff --git a/src/utils/submissionsTrend.js b/src/utils/submissionsTrend.js index 472fc35..f219812 100644 --- a/src/utils/submissionsTrend.js +++ b/src/utils/submissionsTrend.js @@ -1,5 +1,7 @@ import { EXPERIENCE_LEVELS, getExperienceLevel } from './experience.js' +export const VIBE_CODED_SERIES = ['Not vibe-coded', 'Vibe-coded'] + const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', @@ -111,3 +113,31 @@ export function getSubmissionsTrendByExperience(projects, referenceDate = new Da ...counts.get(monthKey(year, monthIndex)), })) } + +export function getSubmissionsTrendByVibeCoded(projects, referenceDate = new Date()) { + const months = last12Months(referenceDate) + + const counts = new Map( + months.map(({ year, monthIndex }) => [ + monthKey(year, monthIndex), + Object.fromEntries(VIBE_CODED_SERIES.map((series) => [series, 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)) continue + + const series = project.vibe_coded ? 'Vibe-coded' : 'Not vibe-coded' + counts.get(key)[series] += 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 index cd92c55..71a1da5 100644 --- a/src/utils/submissionsTrend.test.js +++ b/src/utils/submissionsTrend.test.js @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { getSubmissionsTrendByExperience } from './submissionsTrend.js' +import { getSubmissionsTrendByExperience, getSubmissionsTrendByVibeCoded } from './submissionsTrend.js' const referenceDate = new Date(2026, 6, 15) // July 2026 @@ -63,3 +63,49 @@ test('getSubmissionsTrendByExperience ignores unspecified-experience months when { month: 'Jul 2026', 'No Experience': 1, Beginner: 0, Intermediate: 0, Advanced: 0 }, ]) }) + +test('getSubmissionsTrendByVibeCoded returns 12 zero-filled months when no project has a parseable date', () => { + const referenceDate = new Date(2026, 6, 15) // July 2026 + const projects = [{ tags: ['Date unspecified'] }, {}] + const result = getSubmissionsTrendByVibeCoded(projects, referenceDate) + + assert.equal(result.length, 12) + assert.equal(result[11].month, 'Jul 2026') + for (const entry of result) { + assert.equal(entry['Vibe-coded'], 0) + assert.equal(entry['Not vibe-coded'], 0) + } +}) + +test('getSubmissionsTrendByVibeCoded buckets each project into the correct series for its month', () => { + const referenceDate = new Date(2026, 6, 15) // July 2026 + const projects = [ + { tags: ['July 2026'], vibe_coded: true }, + { tags: ['July 2026'], vibe_coded: true }, + { tags: ['July 2026'], vibe_coded: false }, + { tags: ['June 2026'], vibe_coded: false }, + ] + const result = getSubmissionsTrendByVibeCoded(projects, referenceDate) + + const july = result.find((entry) => entry.month === 'Jul 2026') + const june = result.find((entry) => entry.month === 'Jun 2026') + + assert.deepEqual(july, { month: 'Jul 2026', 'Vibe-coded': 2, 'Not vibe-coded': 1 }) + assert.deepEqual(june, { month: 'Jun 2026', 'Vibe-coded': 0, 'Not vibe-coded': 1 }) +}) + +test('getSubmissionsTrendByVibeCoded ignores projects with unparseable or missing dates', () => { + const referenceDate = new Date(2026, 6, 15) // July 2026 + const projects = [ + { tags: ['July 2026'], vibe_coded: true }, + { tags: ['not a date'], vibe_coded: true }, + { vibe_coded: true }, + ] + const result = getSubmissionsTrendByVibeCoded(projects, referenceDate) + + const july = result.find((entry) => entry.month === 'Jul 2026') + assert.deepEqual(july, { month: 'Jul 2026', 'Vibe-coded': 1, 'Not vibe-coded': 0 }) + + const total = result.reduce((sum, entry) => sum + entry['Vibe-coded'] + entry['Not vibe-coded'], 0) + assert.equal(total, 1) +}) diff --git a/src/utils/techUsed.js b/src/utils/techUsed.js new file mode 100644 index 0000000..4b4333e --- /dev/null +++ b/src/utils/techUsed.js @@ -0,0 +1,67 @@ +// tech_used_raw is free-text prose (not tags), so matching is done via +// case-insensitive substring search rather than exact lookup. "Claude Code" +// is checked and stripped first so a mention of the CLI tool doesn't also +// get counted toward the generic Claude/Anthropic API bucket. +const CLAUDE_CODE_PATTERN = 'claude code' + +export const KNOWN_TECHNOLOGIES = [ + { label: 'Claude/Anthropic API', patterns: ['claude', 'anthropic'] }, + { label: 'React', patterns: ['react'] }, + { label: 'Next.js', patterns: ['next.js', 'nextjs'] }, + { label: 'Vercel', patterns: ['vercel'] }, + { label: 'Python', patterns: ['python'] }, + { label: 'MCP', patterns: ['mcp'] }, + { label: 'Supabase', patterns: ['supabase'] }, + { label: 'Deepgram', patterns: ['deepgram'] }, + { label: '.NET', patterns: ['.net'] }, + { label: 'C#', patterns: ['c#'] }, + { label: 'Avalonia', patterns: ['avalonia'] }, + { label: 'LiteDB', patterns: ['litedb'] }, + { label: 'GitHub', patterns: ['github'] }, + { label: 'ChatGPT', patterns: ['chatgpt'] }, + { label: 'Gemini', patterns: ['gemini'] }, + { label: 'Base44', patterns: ['base44'] }, + { label: 'Discord', patterns: ['discord'] }, + { label: 'Spring Boot', patterns: ['springboot', 'spring boot'] }, + { label: 'GCP', patterns: ['gcp'] }, + { label: 'Lovable', patterns: ['lovable'] }, + { label: 'Llama Index', patterns: ['llama index', 'llamaindex'] }, + { label: 'Gradio', patterns: ['gradio'] }, + { label: 'OpenAI', patterns: ['openai'] }, + { label: 'Google Colab', patterns: ['colab'] }, + { label: 'Flask', patterns: ['flask'] }, + { label: 'PostgreSQL', patterns: ['postgres'] }, + { label: 'Drizzle ORM', patterns: ['drizzle'] }, + { label: 'Tiptap', patterns: ['tiptap'] }, + { label: 'Google Sheets API', patterns: ['google sheets'] }, + { label: 'Umami', patterns: ['umami'] }, + { label: 'Hebcal', patterns: ['hebcal'] }, +] + +const TOP_TECH_LIMIT = 8 + +export function getTechCounts(projects) { + const counts = new Map() + + for (const project of projects) { + const raw = (project.tech_used_raw ?? '').toLowerCase() + if (!raw.trim()) continue + + let working = raw + if (working.includes(CLAUDE_CODE_PATTERN)) { + counts.set('Claude Code', (counts.get('Claude Code') ?? 0) + 1) + working = working.replaceAll(CLAUDE_CODE_PATTERN, '') + } + + for (const { label, patterns } of KNOWN_TECHNOLOGIES) { + if (patterns.some((pattern) => working.includes(pattern))) { + counts.set(label, (counts.get(label) ?? 0) + 1) + } + } + } + + return [...counts.entries()] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count) + .slice(0, TOP_TECH_LIMIT) +} diff --git a/src/utils/techUsed.test.js b/src/utils/techUsed.test.js new file mode 100644 index 0000000..703cf30 --- /dev/null +++ b/src/utils/techUsed.test.js @@ -0,0 +1,64 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { getTechCounts } from './techUsed.js' + +test('getTechCounts counts a known technology mentioned by one project', () => { + const projects = [{ tech_used_raw: 'Next.js, Supabase, Vercel' }] + const result = getTechCounts(projects) + assert.deepEqual(result, [ + { label: 'Next.js', count: 1 }, + { label: 'Vercel', count: 1 }, + { label: 'Supabase', count: 1 }, + ]) +}) + +test('getTechCounts is case-insensitive', () => { + const projects = [{ tech_used_raw: 'REACT frontend, python3' }] + const result = getTechCounts(projects) + assert.deepEqual(result, [ + { label: 'React', count: 1 }, + { label: 'Python', count: 1 }, + ]) +}) + +test('getTechCounts ignores projects with empty or missing tech_used_raw', () => { + const projects = [{ tech_used_raw: '' }, {}, { tech_used_raw: 'React' }] + const result = getTechCounts(projects) + assert.deepEqual(result, [{ label: 'React', count: 1 }]) +}) + +test('getTechCounts treats "Claude Code" and generic Claude/Anthropic mentions as separate, exclusive buckets', () => { + const projects = [ + { tech_used_raw: 'Claude code, python3' }, + { tech_used_raw: 'Claude API (Anthropic)' }, + ] + const result = getTechCounts(projects) + assert.deepEqual(result, [ + { label: 'Claude Code', count: 1 }, + { label: 'Python', count: 1 }, + { label: 'Claude/Anthropic API', count: 1 }, + ]) +}) + +test('getTechCounts sorts descending by count', () => { + const projects = [ + { tech_used_raw: 'React' }, + { tech_used_raw: 'React, Vercel' }, + { tech_used_raw: 'React' }, + ] + const result = getTechCounts(projects) + assert.deepEqual(result, [ + { label: 'React', count: 3 }, + { label: 'Vercel', count: 1 }, + ]) +}) + +test('getTechCounts returns at most the top 8 technologies', () => { + const distinctSingleMentionTechs = [ + 'Supabase', 'Deepgram', '.NET', 'C#', 'Avalonia', 'LiteDB', 'GitHub', + 'ChatGPT', 'Gemini', 'Base44', 'Discord', 'GCP', 'Lovable', + ] + const projects = distinctSingleMentionTechs.map((tech) => ({ tech_used_raw: tech })) + const result = getTechCounts(projects) + assert.equal(result.length, 8) +})