diff --git a/.gitignore b/.gitignore index 81f818c..ae9a385 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ dist-ssr *.local .superpowers/ docs/superpowers/ +.worktrees/ + +# Raw local data dumps (may contain sensitive info, never commit) +data/ # Editor directories and files .vscode/* diff --git a/src/components/ChartsAndAnalytics.jsx b/src/components/ChartsAndAnalytics.jsx index 53b8f67..212dfa9 100644 --- a/src/components/ChartsAndAnalytics.jsx +++ b/src/components/ChartsAndAnalytics.jsx @@ -1,32 +1,27 @@ import { useEffect, useState } from 'react' -import { - Bar, - BarChart, - CartesianGrid, - Legend, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from 'recharts' import { fetchProjects } from '../data/fetchProjects.js' -import { getSubmissionsMonthlyTrend, getSubmissionsTrendByExperience } from '../utils/submissionsTrend.js' +import { + getSubmissionsMonthlyTrend, + getSubmissionsTrendByExperience, + getSubmissionsTrendByVibeCoded, +} from '../utils/submissionsTrend.js' import { getKeywordCounts } from '../utils/keywords.js' -import { EXPERIENCE_LEVELS } from '../utils/experience.js' - -const EXPERIENCE_COLORS = { - 'No Experience': '#2a78d6', - Beginner: '#eb6834', - Intermediate: '#1baf7a', - Advanced: '#4a3aa7', -} +import { getToolUsageCounts } from '../utils/sefariaTools.js' +import { getTechCounts } from '../utils/techUsed.js' +import SubmissionsTrendChart from './charts/SubmissionsTrendChart.jsx' +import KeywordFrequencyChart from './charts/KeywordFrequencyChart.jsx' +import ExperienceTrendChart from './charts/ExperienceTrendChart.jsx' +import ToolUsagePieChart from './charts/ToolUsagePieChart.jsx' +import VibeCodedTrendChart from './charts/VibeCodedTrendChart.jsx' +import TechUsedChart from './charts/TechUsedChart.jsx' 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,83 +30,25 @@ 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

- - - - - - - - - - -

Keyword frequency

- - - - - - - - - - -

Submissions by experience level

- - - - - - - - {EXPERIENCE_LEVELS.map((level) => ( - - props.index === experienceTrend.length - 1 ? ( - - {level} - - ) : null - } - /> - ))} - - + + + + + +
) } 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..38c05bd 100644 --- a/src/components/ProjectCard.jsx +++ b/src/components/ProjectCard.jsx @@ -1,28 +1,44 @@ +import { useState } from 'react' +import { getCategoryColor } from '../utils/categories.js' + function ProjectCard({ project }) { + const [flipped, setFlipped] = useState(false) + + 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/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) +}