- {project.categories.map((category) => (
-
- {category}
-
- ))}
+
+
+
+
+
+ {project.categories.map((category) => (
+
+ {category}
+
+ ))}
+
+
{project.project_desc}
+
-
{project.project_desc}
-
+
)
}
diff --git a/src/components/charts/ExperienceTrendChart.jsx b/src/components/charts/ExperienceTrendChart.jsx
new file mode 100644
index 0000000..3c94856
--- /dev/null
+++ b/src/components/charts/ExperienceTrendChart.jsx
@@ -0,0 +1,25 @@
+import LineChart from './types/LineChart.jsx'
+import { EXPERIENCE_LEVELS } from '../../utils/experience.js'
+
+const EXPERIENCE_COLORS = {
+ 'No Experience': 'var(--chart-blue)',
+ Beginner: 'var(--chart-orange)',
+ Intermediate: 'var(--chart-aqua)',
+ Advanced: 'var(--chart-violet)',
+}
+
+function ExperienceTrendChart({ data }) {
+ return (
+
({
+ key: level,
+ name: level,
+ color: EXPERIENCE_COLORS[level],
+ }))}
+ />
+ )
+}
+
+export default ExperienceTrendChart
diff --git a/src/components/charts/KeywordFrequencyChart.jsx b/src/components/charts/KeywordFrequencyChart.jsx
new file mode 100644
index 0000000..f2ec4dc
--- /dev/null
+++ b/src/components/charts/KeywordFrequencyChart.jsx
@@ -0,0 +1,17 @@
+import BarChart from './types/BarChart.jsx'
+
+function KeywordFrequencyChart({ data }) {
+ return (
+
+ )
+}
+
+export default KeywordFrequencyChart
diff --git a/src/components/charts/SubmissionsTrendChart.jsx b/src/components/charts/SubmissionsTrendChart.jsx
new file mode 100644
index 0000000..8fbb7b4
--- /dev/null
+++ b/src/components/charts/SubmissionsTrendChart.jsx
@@ -0,0 +1,15 @@
+import BarChart from './types/BarChart.jsx'
+
+function SubmissionsTrendChart({ data }) {
+ return (
+
+ )
+}
+
+export default SubmissionsTrendChart
diff --git a/src/components/charts/TechUsedChart.jsx b/src/components/charts/TechUsedChart.jsx
new file mode 100644
index 0000000..8a05c75
--- /dev/null
+++ b/src/components/charts/TechUsedChart.jsx
@@ -0,0 +1,17 @@
+import BarChart from './types/BarChart.jsx'
+
+function TechUsedChart({ data }) {
+ return (
+
+ )
+}
+
+export default TechUsedChart
diff --git a/src/components/charts/ToolUsagePieChart.jsx b/src/components/charts/ToolUsagePieChart.jsx
new file mode 100644
index 0000000..8c3fa14
--- /dev/null
+++ b/src/components/charts/ToolUsagePieChart.jsx
@@ -0,0 +1,32 @@
+import PieChart from './types/PieChart.jsx'
+
+// Fixed-order categorical hues, pulled from the brand palette defined in
+// index.css; gray is reserved for the "Other" bucket and is never one of
+// the 6 identity colors.
+const TOOL_SLICE_COLORS = [
+ 'var(--chart-blue)',
+ 'var(--chart-orange)',
+ 'var(--chart-aqua)',
+ 'var(--chart-yellow)',
+ 'var(--chart-magenta)',
+ 'var(--chart-green)',
+]
+const OTHER_SLICE_COLOR = 'var(--chart-neutral)'
+
+function colorForToolSlice(entry, index) {
+ return entry.endpoint === 'Other' ? OTHER_SLICE_COLOR : TOOL_SLICE_COLORS[index]
+}
+
+function ToolUsagePieChart({ data }) {
+ return (
+
+ )
+}
+
+export default ToolUsagePieChart
diff --git a/src/components/charts/VibeCodedTrendChart.jsx b/src/components/charts/VibeCodedTrendChart.jsx
new file mode 100644
index 0000000..fe8087d
--- /dev/null
+++ b/src/components/charts/VibeCodedTrendChart.jsx
@@ -0,0 +1,24 @@
+import LineChart from './types/LineChart.jsx'
+import { VIBE_CODED_SERIES } from '../../utils/submissionsTrend.js'
+
+const VIBE_CODED_COLORS = {
+ 'Not vibe-coded': 'var(--chart-blue)',
+ 'Vibe-coded': 'var(--chart-orange)',
+}
+
+function VibeCodedTrendChart({ data }) {
+ return (
+ ({
+ key: series,
+ name: series,
+ color: VIBE_CODED_COLORS[series],
+ }))}
+ />
+ )
+}
+
+export default VibeCodedTrendChart
diff --git a/src/components/charts/types/BarChart.jsx b/src/components/charts/types/BarChart.jsx
new file mode 100644
index 0000000..3a79a49
--- /dev/null
+++ b/src/components/charts/types/BarChart.jsx
@@ -0,0 +1,44 @@
+import { Bar, BarChart as RechartsBarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
+
+function BarChart({
+ data,
+ dataKey,
+ categoryKey,
+ title,
+ layout = 'horizontal',
+ height = 360,
+ categoryWidth,
+ barName = 'Projects',
+ color = 'var(--accent)',
+}) {
+ return (
+ <>
+ {title}
+ {data.length === 0 ? (
+ Data unavailable.
+ ) : (
+
+ {layout === 'vertical' ? (
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ )}
+ >
+ )
+}
+
+export default BarChart
diff --git a/src/components/charts/types/LineChart.jsx b/src/components/charts/types/LineChart.jsx
new file mode 100644
index 0000000..7dc8238
--- /dev/null
+++ b/src/components/charts/types/LineChart.jsx
@@ -0,0 +1,54 @@
+import { CartesianGrid, Legend, Line, LineChart as RechartsLineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
+
+function LineChart({ data, series, title, description, height = 360 }) {
+ return (
+ <>
+ {title}
+ {description ? {description}
: null}
+ {data.length === 0 ? (
+ Data unavailable.
+ ) : (
+
+
+
+
+
+
+
+ {series.map(({ key, name, color }) => (
+
+ props.index === data.length - 1 ? (
+
+ {name}
+
+ ) : null
+ }
+ />
+ ))}
+
+
+ )}
+ >
+ )
+}
+
+export default LineChart
diff --git a/src/components/charts/types/PieChart.jsx b/src/components/charts/types/PieChart.jsx
new file mode 100644
index 0000000..a007ba0
--- /dev/null
+++ b/src/components/charts/types/PieChart.jsx
@@ -0,0 +1,34 @@
+import { Cell, Legend, Pie, PieChart as RechartsPieChart, ResponsiveContainer, Tooltip } from 'recharts'
+
+function PieChart({ data, dataKey, nameKey, title, colorForSlice, height = 360, outerRadius = 120 }) {
+ return (
+ <>
+ {title}
+ {data.length === 0 ? (
+ Data unavailable.
+ ) : (
+
+
+
+ {data.map((entry, index) => (
+ |
+ ))}
+
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default PieChart
diff --git a/src/index.css b/src/index.css
index f4bdf7c..191cbac 100644
--- a/src/index.css
+++ b/src/index.css
@@ -9,6 +9,21 @@
--accent-bg: rgba(72, 113, 191, 0.1);
--accent-border: rgba(72, 113, 191, 0.5);
--social-bg: rgba(237, 237, 236, 0.5);
+
+ /* Categorical chart/pill palette. Fixed hue order — validated colorblind-safe
+ (OKLab CVD ΔE >= 8 on adjacent pairs); assign in this order, never cycle
+ or reorder per-chart. --chart-neutral is reserved for "Other"/Uncategorized
+ and is never one of the identity colors. */
+ --chart-blue: #4B71B7;
+ --chart-orange: #D4896C;
+ --chart-aqua: #004E5F;
+ --chart-yellow: #CCB479;
+ --chart-magenta: #CB6158;
+ --chart-green: #00827F;
+ --chart-violet: #594176;
+ --chart-red: #802F3E;
+ --chart-neutral: #6f6f6f;
+
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
@@ -92,9 +107,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 +129,10 @@ code {
color: var(--text-h);
}
+.search-input {
+ width: 280px;
+}
+
.project-count {
color: var(--text);
font-size: 14px;
@@ -121,28 +147,87 @@ 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:hover {
+.project-card-front {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ text-align: center;
+}
+
+.project-card-front:hover {
background-color: var(--accent-bg);
}
-.project-card-image {
- width: 100%;
- max-height: 120px;
- object-fit: contain;
+.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/__tests__/categories.test.js b/src/utils/__tests__/categories.test.js
new file mode 100644
index 0000000..3871186
--- /dev/null
+++ b/src/utils/__tests__/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, /^var\(--chart-[a-z]+\)$/)
+ }
+})
+
+test('getCategoryColor returns a color for Uncategorized distinct from every known category', () => {
+ const uncategorizedColor = getCategoryColor(UNCATEGORIZED)
+ assert.match(uncategorizedColor, /^var\(--chart-[a-z]+\)$/)
+ 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/experience.test.js b/src/utils/__tests__/experience.test.js
similarity index 92%
rename from src/utils/experience.test.js
rename to src/utils/__tests__/experience.test.js
index 1828532..600d9aa 100644
--- a/src/utils/experience.test.js
+++ b/src/utils/__tests__/experience.test.js
@@ -1,6 +1,6 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
-import { EXPERIENCE_LEVELS, getExperienceLevel } from './experience.js'
+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'])
diff --git a/src/utils/__tests__/sefariaTools.test.js b/src/utils/__tests__/sefariaTools.test.js
new file mode 100644
index 0000000..d48d01f
--- /dev/null
+++ b/src/utils/__tests__/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/__tests__/submissionsTrend.test.js b/src/utils/__tests__/submissionsTrend.test.js
new file mode 100644
index 0000000..9ee26db
--- /dev/null
+++ b/src/utils/__tests__/submissionsTrend.test.js
@@ -0,0 +1,160 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { getSubmissionsTrendByExperience, getSubmissionsTrendByVibeCoded } from '../submissionsTrend.js'
+
+// Fixed "today" for every test below, so results are predictable instead of
+// depending on when the test happens to run. July 15 2026 in local time —
+// JS Date months are 0-indexed, so 6 means July.
+const referenceDate = new Date(2026, 6, 15) // July 2026
+
+// --- getSubmissionsTrendByExperience -------------------------------------
+// This function builds the data for ExperienceTrendChart: one row per
+// month, with a count for each experience level (No Experience, Beginner,
+// Intermediate, Advanced) in that month.
+
+test('getSubmissionsTrendByExperience returns [] when no project has a parseable date', () => {
+ // Neither project has a real "Month Year" tag ("Date unspecified" and an
+ // empty tags array both fail to parse), so there's nothing to chart —
+ // the function should bail out to an empty array rather than a chart
+ // full of zeros.
+ 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', () => {
+ // Only one project, submitted May 2026. The chart should still cover
+ // every month from May through the reference month (July), not just the
+ // one month that has data — June has no submissions, so it's zero-filled
+ // rather than skipped.
+ 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', () => {
+ // 5 projects across 2 months and multiple experience levels. This checks
+ // that each level gets its own running count per month, independent of
+ // the others — e.g. May should show 1 "No Experience" AND 2 "Beginner"
+ // in the same row, and July's counts shouldn't leak into May's.
+ 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', () => {
+ // The date parses fine, but technical_experience is blank, so
+ // getExperienceLevel can't map it to any of the 4 known levels. With no
+ // project contributing a real level, there's nothing meaningful to
+ // chart, so this should behave the same as "no data" (empty array).
+ 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', () => {
+ // Two projects: one in January with no usable experience level, one in
+ // July with a real level. If the January entry were allowed to set the
+ // chart's start month, the chart would open with 6 months of dead
+ // zero-rows (Jan-Jun) before any real data shows up. This test locks in
+ // that the January entry is skipped when picking the earliest month, so
+ // the chart starts at July instead — see submissionsTrend.js's comment
+ // on this exact behavior.
+ 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 },
+ ])
+})
+
+// --- getSubmissionsTrendByVibeCoded ---------------------------------------
+// This function builds the data for VibeCodedTrendChart: always exactly
+// the trailing 12 months (unlike the experience trend above, which starts
+// at the earliest real data), with a count of "Vibe-coded" vs.
+// "Not vibe-coded" submissions per month.
+
+test('getSubmissionsTrendByVibeCoded returns 12 zero-filled months when no project has a parseable date', () => {
+ const referenceDate = new Date(2026, 6, 15) // July 2026
+ // Neither project has a usable date, so every month should come back
+ // zero-filled — but note this function always returns exactly 12 months
+ // (the trailing year up to referenceDate) regardless of whether there's
+ // any data, unlike getSubmissionsTrendByExperience above which returns []
+ // when there's nothing to show.
+ const projects = [{ tags: ['Date unspecified'] }, {}]
+ const result = getSubmissionsTrendByVibeCoded(projects, referenceDate)
+
+ assert.equal(result.length, 12)
+ assert.equal(result[11].month, 'Jul 2026') // last of the 12 months is always referenceDate's month
+ 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
+ // 4 projects: 3 in July (2 vibe-coded, 1 not) and 1 in June (not
+ // vibe-coded). Checks that each project's vibe_coded boolean routes it
+ // into the right one of the two series, per month.
+ 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
+ // 3 projects, only 1 with a real date ("July 2026"); the other 2 have an
+ // unparseable date string and no tags at all, respectively. Both bad
+ // ones should be silently dropped rather than counted or causing an
+ // error — so the total across all months should be exactly 1.
+ 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/__tests__/techUsed.test.js b/src/utils/__tests__/techUsed.test.js
new file mode 100644
index 0000000..41b66d1
--- /dev/null
+++ b/src/utils/__tests__/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)
+})
diff --git a/src/utils/categories.js b/src/utils/categories.js
index 7b05223..8694df3 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, pulled from the brand palette defined in
+// index.css; 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': 'var(--chart-blue)',
+ 'Learning & Study Tools': 'var(--chart-orange)',
+ 'Community, Interaction, & Social': 'var(--chart-aqua)',
+ 'Visualization & Data Analysis': 'var(--chart-violet)',
+ 'Extensions, API Integrations, & GitHub Code': 'var(--chart-magenta)',
+ [UNCATEGORIZED]: 'var(--chart-neutral)',
+}
+
+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/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/submissionsTrend.js b/src/utils/submissionsTrend.js
index 472fc35..b3476ff 100644
--- a/src/utils/submissionsTrend.js
+++ b/src/utils/submissionsTrend.js
@@ -1,11 +1,19 @@
import { EXPERIENCE_LEVELS, getExperienceLevel } from './experience.js'
+// The two series names used by the vibe-coded trend chart. Exported so the
+// chart component can build its for each series without hardcoding
+// the strings itself (keeps the "source of truth" for series names here).
+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',
]
// Tag dates look like "July 2026" or "Date unspecified".
+// Turns that raw string into a { year, monthIndex } object, or null if it
+// doesn't match the expected "Month Year" shape (covers "Date unspecified",
+// missing tags, typos, etc. — anything we can't confidently parse).
function parseTagMonth(rawDate) {
const match = /^([A-Za-z]+) (\d{4})$/.exec(rawDate ?? '')
if (!match) return null
@@ -16,6 +24,9 @@ function parseTagMonth(rawDate) {
return { year: Number(match[2]), monthIndex }
}
+// Turns { year, monthIndex } into a string like "2026-07" — used as a Map
+// key so months can be looked up/compared without worrying about JS Date
+// object identity, and so keys sort correctly as plain strings.
function monthKey(year, monthIndex) {
return `${year}-${String(monthIndex + 1).padStart(2, '0')}`
}
@@ -30,23 +41,31 @@ function last12Months(referenceDate) {
return months
}
+// Builds the data for SubmissionsTrendChart: total submission count per
+// month, for the trailing 12 months ending at referenceDate.
export function getSubmissionsMonthlyTrend(projects, referenceDate = new Date()) {
const months = last12Months(referenceDate)
+ // Start every month at 0 so months with no submissions still show up in
+ // the chart instead of being skipped entirely.
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
+ if (!parsed) continue // unparseable/missing date — skip this project
const key = monthKey(parsed.year, parsed.monthIndex)
if (counts.has(key)) {
+ // Only bump the count if the month is within our 12-month window —
+ // a submission from 2 years ago would produce a key not in `counts`.
counts.set(key, counts.get(key) + 1)
}
}
+ // Recharts wants an array of plain objects, one per point on the x-axis,
+ // so convert the Map back into that shape here.
return months.map(({ year, monthIndex }) => ({
month: `${MONTH_NAMES[monthIndex].slice(0, 3)} ${year}`,
count: counts.get(monthKey(year, monthIndex)),
@@ -54,6 +73,9 @@ export function getSubmissionsMonthlyTrend(projects, referenceDate = new Date())
}
// Builds every calendar month from start through end (inclusive), oldest first.
+// Unlike last12Months above, the range length here is variable — used when
+// the chart's start point depends on the data rather than always being a
+// fixed 12 months back.
function monthsBetween(start, end) {
const months = []
let cursor = new Date(start.year, start.monthIndex, 1)
@@ -67,22 +89,28 @@ function monthsBetween(start, end) {
return months
}
+// Builds the data for ExperienceTrendChart: one row per month, with a
+// submission count for each of the 4 experience levels (No Experience,
+// Beginner, Intermediate, Advanced) in that month.
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)
+ .filter((entry) => entry.month !== null) // drop projects with no usable date
- if (parsed.length === 0) return []
+ if (parsed.length === 0) return [] // nothing to chart
// 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 []
+ if (withLevel.length === 0) return [] // every dated project has unknown experience — nothing meaningful to chart
+ // Find the earliest month among entries that have a real experience
+ // level, by comparing their string monthKeys (these sort correctly
+ // because monthKey pads to a fixed "YYYY-MM" width).
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
@@ -91,6 +119,8 @@ export function getSubmissionsTrendByExperience(projects, referenceDate = new Da
const end = { year: referenceDate.getFullYear(), monthIndex: referenceDate.getMonth() }
const months = monthsBetween(earliest, end)
+ // Each month starts with every experience level at 0, e.g.
+ // { 'No Experience': 0, Beginner: 0, Intermediate: 0, Advanced: 0 }
const counts = new Map(
months.map(({ year, monthIndex }) => [
monthKey(year, monthIndex),
@@ -99,13 +129,49 @@ export function getSubmissionsTrendByExperience(projects, referenceDate = new Da
)
for (const entry of parsed) {
- if (entry.level === null) continue
+ if (entry.level === null) continue // unknown experience — don't attribute it to any level
const key = monthKey(entry.month.year, entry.month.monthIndex)
if (counts.has(key)) {
counts.get(key)[entry.level] += 1
}
}
+ // Spread each month's per-level counts object into the result row
+ // alongside its month label, e.g. { month: 'Jul 2026', 'No Experience': 1, Beginner: 0, ... }
+ return months.map(({ year, monthIndex }) => ({
+ month: `${MONTH_NAMES[monthIndex].slice(0, 3)} ${year}`,
+ ...counts.get(monthKey(year, monthIndex)),
+ }))
+}
+
+// Builds the data for VibeCodedTrendChart: one row per month, with a
+// submission count for each of the 2 series ("Vibe-coded" / "Not
+// vibe-coded") in that month. Always covers the trailing 12 months —
+// unlike getSubmissionsTrendByExperience above, it doesn't hunt for an
+// earliest-data month, so it can't return [] the way that one can.
+export function getSubmissionsTrendByVibeCoded(projects, referenceDate = new Date()) {
+ const months = last12Months(referenceDate)
+
+ // Each month starts with both series at 0.
+ 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 // unparseable/missing date — skip this project
+
+ const key = monthKey(parsed.year, parsed.monthIndex)
+ if (!counts.has(key)) continue // outside the trailing-12-months window
+
+ 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
deleted file mode 100644
index cd92c55..0000000
--- a/src/utils/submissionsTrend.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-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 },
- ])
-})
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)
+}