Skip to content
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,51 @@ postgresql://postgres:password@localhost:5432/classroom
8. Run `npm run mock-fcc-data`
9. Run `npx prisma studio`

### Challenge map (FCC Proper)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add some explanation about the purpose of the Challenge Map? Why does does Classroom App need it? How is it consumed/transformed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added additional notation in the readme about the purpose of the Challenge Map and how it works so that the functionality is not lost going forward as well as specifics in the file itself and the Utils file explaining how the breakdown works so that if anyone addresses the functions in the future they have context.


**What it is:** The challenge map (`data/challengeMap.json`) is a flat lookup
that maps every freeCodeCamp challenge id to the superblock(s) and block(s) it
belongs to, plus its human-readable name:

```json
{
"<challengeId>": {
"superblocks": ["responsive-web-design", "responsive-web-design-22"],
"blocks": ["basic-html-and-html5"],
"name": "Say Hello to HTML Elements"
}
}
```

**Why Classroom needs it:** FCC Proper reports a student's progress as a flat
list of completed challenge ids, with no curriculum structure attached. The
teacher dashboard needs that progress grouped by certification and block. The
helpers in [`util/challengeMapUtils.js`](util/challengeMapUtils.js) look each
completed id up in this map and re-nest the flat data into the
`{ certifications: [...] }` shape the dashboard renders. A challenge can belong
to several superblocks/blocks (e.g. current and legacy `-22` versions), so both
are stored as arrays ordered as the GraphQL build encounters them. The dashboard
must group each challenge under exactly one cert/block, so
[`util/challengeMapHelpers.js`](util/challengeMapHelpers.js) centralizes a
"first occurrence wins" rule: the first element of each array is used as the
canonical location. This keeps callers and tests from independently re-implementing
the same choice and drifting apart.

It is built from the FCC Proper GraphQL curriculum database by
`scripts/build-challenge-map-graphql.mjs`.

To generate or refresh the map:

```console
node scripts/build-challenge-map-graphql.mjs
```

To run the challenge map tests (they read the current `data/challengeMap.json`):

```console
npm run test:challenge-map
```

**Note:** The classroom app runs on port 3001 and mock data on port 3002 to avoid conflicts with freeCodeCamp's main platform (ports 3000/8000).

Need more help? Ran into issues? Check out this [guide](https://docs.google.com/document/d/1apfjzfIwDAfg6QQf2KD1E1aeD-KU7DEllwnH9Levq4A/edit) that walks you through all the steps of setting up the repository locally, without Docker.
Expand Down
195 changes: 132 additions & 63 deletions __tests__/utils/challengeMapUtils.test.js
Original file line number Diff line number Diff line change
@@ -1,66 +1,143 @@
// Mock the file system to avoid ES module issues
jest.mock('fs');
jest.mock('path');

// Create the mock functions directly to test the core logic
function buildStudentDashboardData(completedChallenges, challengeMap) {
const result = { certifications: [] };
const certMap = {};

completedChallenges.forEach(challenge => {
const mapEntry = challengeMap[challenge.id];
if (!mapEntry) {
return; // skip unknown ids
}
// Use first superblock as canonical for dashboard grouping
const { superblocks, blocks, name } = mapEntry;
const certification = superblocks[0];
const block = blocks[0];
if (!certMap[certification]) {
certMap[certification] = { blocks: {} };
}
if (!certMap[certification].blocks[block]) {
certMap[certification].blocks[block] = { completedChallenges: [] };
const { existsSync, readFileSync } = require('fs');
const path = require('path');
const {
buildStudentDashboardData,
resolveAllStudentsToDashboardFormat
} = require('../../util/challengeMapUtils');
const {
getCanonicalChallengeMapLocation
} = require('../../util/challengeMapHelpers');

const CHALLENGE_MAP_PATH = path.join(__dirname, '../../data/challengeMap.json');

const hasChallengeMap = existsSync(CHALLENGE_MAP_PATH);
const isCi = Boolean(process.env.CI);
let challengeMap = null;

function formatPathForLog(rawPath) {
const normalized = path.normalize(rawPath);
const match = normalized.match(/^([A-Za-z]:)\\\1\\(.*)$/);
if (match) {
return `${match[1]}\\${match[2]}`;
}
return normalized;
}

if (!hasChallengeMap) {
console.log(
[
'\x1b[31m[challengeMapUtils.test] Missing challenge map\x1b[0m',
` Missing challenge map path: ${formatPathForLog(CHALLENGE_MAP_PATH)}`,
` Current working directory: ${formatPathForLog(process.cwd())}`,
` Resolved map path: ${formatPathForLog(
path.resolve(CHALLENGE_MAP_PATH)
)}`,
' To generate the challengeMap.json please run:',
` \x1b[31m node scripts/build-challenge-map-graphql.mjs\x1b[0m`,
'',
' Tests that rely on the challenge map will fail until the map is generated.'
].join('\n')
);
}

function getFirstMapEntry(map) {
const entries = Object.entries(map);
for (const [challengeId, mapEntry] of entries) {
const { certification, block } = getCanonicalChallengeMapLocation(mapEntry);
if (certification && block) {
return { challengeId, mapEntry, certification, block };
}
certMap[certification].blocks[block].completedChallenges.push({
...challenge,
challengeName: name
}
return null;
}

beforeAll(() => {
if (!hasChallengeMap) {
return;
}

console.log(
'[challengeMapUtils.test] Using challenge map:',
CHALLENGE_MAP_PATH
);
const raw = readFileSync(CHALLENGE_MAP_PATH, 'utf8');
challengeMap = JSON.parse(raw);
console.log(
'[challengeMapUtils.test] Challenge map entries:',
Object.keys(challengeMap).length
);
});

const shouldSkipRealMap = !hasChallengeMap && isCi;
const describeRealMap = shouldSkipRealMap ? describe.skip : describe;

describeRealMap('challengeMapUtils (real challengeMap.json)', () => {
if (!hasChallengeMap) {
test('challengeMap.json must exist to run real-map tests', () => {
expect(true).toBe(true);
throw new Error(
'Missing data/challengeMap.json. Run: node scripts/build-challenge-map-graphql.mjs'
);
});
return;
}
it('loads a non-empty challenge map', () => {
expect(challengeMap).toBeTruthy();
expect(typeof challengeMap).toBe('object');
expect(Object.keys(challengeMap).length).toBeGreaterThan(0);
});

it('builds dashboard data using the first valid map entry', () => {
const entry = getFirstMapEntry(challengeMap);
expect(entry).toBeTruthy();

const completedChallenges = [
{ id: entry.challengeId, completedDate: '2024-01-15' }
];

const result = buildStudentDashboardData(completedChallenges, challengeMap);

expect(result.certifications.length).toBe(1);
const certKey = Object.keys(result.certifications[0])[0];
expect(certKey).toBe(entry.certification);
const blockKey = Object.keys(
result.certifications[0][certKey].blocks[0]
)[0];
expect(blockKey).toBe(entry.block);
});

it('skips unknown challenge IDs', () => {
const result = buildStudentDashboardData(
[{ id: 'unknown-challenge-id', completedDate: '2024-01-16' }],
challengeMap
);

expect(result.certifications).toEqual([]);
});

// Convert to the expected nested array format
for (const cert in certMap) {
const certObj = {};
certObj[cert] = {
blocks: Object.entries(certMap[cert].blocks).map(
([blockName, blockObj]) => ({
[blockName]: blockObj
})
)
it('resolves multiple students against the current map', () => {
const entry = getFirstMapEntry(challengeMap);
expect(entry).toBeTruthy();

const studentDataFromFCC = {
'student1@test.com': [
{ id: entry.challengeId, completedDate: '2024-01-15' }
],
'student2@test.com': []
};
result.certifications.push(certObj);
}

return result;
}
const result = resolveAllStudentsToDashboardFormat(
studentDataFromFCC,
challengeMap
);

function resolveAllStudentsToDashboardFormat(
studentDataFromFCC,
curriculumMap = null
) {
const mockChallengeMap = {}; // Would load from file in actual implementation
if (!studentDataFromFCC || typeof studentDataFromFCC !== 'object') return [];
const mapToUse = curriculumMap || mockChallengeMap;
return Object.entries(studentDataFromFCC).map(
([email, completedChallenges]) => ({
email,
...buildStudentDashboardData(completedChallenges, mapToUse)
})
);
}
expect(result.length).toBe(2);
expect(result[0]).toHaveProperty('email');
expect(result[0]).toHaveProperty('certifications');
});
});

describe('challengeMapUtils', () => {
// Mock challenge map with array structure (superblocks and blocks as arrays)
describe('challengeMapUtils (synthetic map)', () => {
const mockChallengeMap = {
bd7123c8c441eddfaeb5bdef: {
superblocks: ['responsive-web-design'],
Expand Down Expand Up @@ -217,9 +294,6 @@ describe('challengeMapUtils', () => {
mockChallengeMap
);

// bd7123c8c441eddfaeb5bdef -> responsive-web-design
// 56533eb9ac21ba0edf2244cf -> javascript-algorithms-and-data-structures (first)
// m2n3o4p5q6r7s8t9u0v1w2x3 -> full-stack-developer
expect(result.certifications.length).toBe(3);
const certNames = result.certifications
.map(c => Object.keys(c)[0])
Expand Down Expand Up @@ -461,12 +535,9 @@ describe('challengeMapUtils', () => {

expect(result.length).toBe(2);

// Alice should have 2 certifications (responsive-web-design and javascript-algorithms-and-data-structures)
const alice = result.find(s => s.email === 'alice@example.com');
expect(alice.certifications.length).toBe(2);

// Bob should have 2 certifications (javascript-algorithms-and-data-structures from challenge 56533eb9ac21ba0edf2244cf
// and full-stack-developer from challenge m2n3o4p5q6r7s8t9u0v1w2x3)
const bob = result.find(s => s.email === 'bob@example.com');
expect(bob.certifications.length).toBe(2);
});
Expand All @@ -481,12 +552,10 @@ describe('challengeMapUtils', () => {
mockChallengeMap
);

// Challenge appears in 2 superblocks, but should be grouped under first one
const certification =
result.certifications[0]['javascript-algorithms-and-data-structures'];
expect(certification).toBeDefined();

// Should NOT have an entry for full-stack-developer since we use first occurrence
const hasFullStack = result.certifications.some(
c => Object.keys(c)[0] === 'full-stack-developer'
);
Expand Down
19 changes: 9 additions & 10 deletions scripts/build-challenge-map-graphql.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,19 @@ async function fetchCurriculumData() {
/**
* Transform GraphQL response into flat challenge map
*
* Structure (using first occurrence as canonical):
* Structure:
* {
* "challengeId": {
* "certification": "superblock-dashed-name",
* "block": "block-dashed-name",
* "superblocks": ["superblock-dashed-name", ...],
* "blocks": ["block-dashed-name", ...],
* "name": "Challenge Title"
* }
* }
*
* Note: Challenges may appear in multiple superblocks, but we use the first occurrence.
* Note: Challenges may appear in multiple superblocks/blocks, so every
* association is recorded. Consumers that need a single location treat the
* first element of each array as canonical
* (see util/challengeMapHelpers.js).
*/
function buildChallengeMap(data) {
console.log('🔨 Building challenge map...');
Expand All @@ -119,14 +122,10 @@ function buildChallengeMap(data) {

for (const challenge of block.challengeOrder) {
const challengeId = challenge.id;

if (challengeMap[challengeId]) {
// Add superblock if not already present
if (
!challengeMap[challengeId].superblocks.includes(
superblockDashedName
)
) {
if (!challengeMap[challengeId].superblocks.includes(superblockDashedName)) {
challengeMap[challengeId].superblocks.push(superblockDashedName);
}
// Add block if not already present
Expand Down
28 changes: 28 additions & 0 deletions util/challengeMapHelpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Resolve the canonical (certification, block) location for a single challenge
* map entry.
*
* A challenge can be reused across several superblocks and blocks in the
* freeCodeCamp curriculum (for example, a challenge that exists in both the
* current `responsive-web-design` superblock and the legacy
* `responsive-web-design-22` one). The challenge map therefore records *every*
* association as arrays — `superblocks` and `blocks` — ordered as they are
* emitted by the GraphQL build (`scripts/build-challenge-map-graphql.mjs`).
*
* For dashboard grouping we collapse each challenge down to a single location
* by taking the first element of each array as canonical. Centralizing that
* choice here keeps the "first occurrence wins" rule in one place so callers
* (and tests) cannot drift apart.
*
* @param {{ superblocks?: string[], blocks?: string[] }} mapEntry - A single
* entry from `data/challengeMap.json`.
* @returns {{ certification: string | undefined, block: string | undefined }}
* The canonical superblock (as `certification`) and block. Either field is
* `undefined` when the corresponding array is missing or empty.
*/
export function getCanonicalChallengeMapLocation(mapEntry) {
return {
certification: (mapEntry.superblocks || [])[0],
block: (mapEntry.blocks || [])[0]
};
}
Loading