1. Bharat NCAP Scoring Dashboard Scaffold and Data Contract
Why a Data Contract Comes Before Any Dashboard Code
A Bharat NCAP scoring dashboard is only as trustworthy as the records it renders. Before writing a single chart component, the project needs two things: a predictable folder skeleton that later cases can extend without renegotiation, and a canonical JSON contract that every Bharat NCAP test record must satisfy. The contract is the single source of truth — ingestion, normalization, scoring, and visualization all read from it. If the schema drifts, star ratings silently disagree between views, and a safety dashboard becomes actively misleading.
This case builds that foundation. The deliverable is a runnable scaffold plus a validator that rejects malformed records with precise, field-level errors. Everything downstream (CSV ingestion, score normalization, rating aggregation) will consume exactly this schema.
Project Skeleton
Keep the layout flat and boring so later cases slot in cleanly. A minimal Node.js project with no framework dependency is enough for the scaffold; the dashboard UI can be added later without restructuring.
bncap-dashboard/
├── package.json
├── data/
│ └── records.sample.json
├── src/
│ ├── schema/
│ │ └── record.schema.js # canonical contract + validator
│ ├── ingest/ # reserved for case 2 (CSV pipeline)
│ ├── normalize/ # reserved for case 3 (AOP normalization)
│ └── index.js # loads sample data, validates, reports
└── test/
└── schema.test.js
Create the project and initialize it:
mkdir -p bncap-dashboard/src/schema bncap-dashboard/src/ingest \
bncap-dashboard/src/normalize bncap-dashboard/data bncap-dashboard/test
cd bncap-dashboard
npm init -y
# package.json: set "type": "module" so ES module imports work
Defining the Canonical Record Contract
A Bharat NCAP test record has four logical groups: vehicle metadata (identifies the tested variant), test configuration (which protocol and variant produced the result), star rating (the headline outcome), and sub-scores (the component assessments that justify the rating). The contract below fixes field names, types, and allowed ranges. Ranges are deliberately conservative: sub-scores are stored on a 0–100 scale, and the star rating is an integer 0–5.
Two design decisions matter for later cases. First, testType is an enum, not free text, so normalization can branch on it deterministically. Second, every sub-score object carries its own scale field — this lets case 3 map variant-specific scales into the common 0–100 range without losing the original value.
Implementing the Schema and Validator
The validator is hand-written rather than pulled from a library so the error messages stay domain-specific and the file has zero runtime dependencies. It returns a structured result instead of throwing, which is what the ingestion pipeline in case 2 needs to report row-level errors without crashing.
// src/schema/record.schema.js
export const TEST_TYPES = ['frontal', 'side', 'pole', 'pedestrian', 'esc'];
export const COMPONENTS = ['adultOccupant', 'childOccupant', 'pedestrian', 'safetyAssist'];
export const BODY_TYPES = ['hatchback', 'sedan', 'suv', 'mpv', 'pickup'];
const isStr = (v) => typeof v === 'string' && v.trim().length > 0;
const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
const inRange = (v, lo, hi) => isNum(v) && v >= lo && v <= hi;
const isISODate = (v) => isStr(v) && !Number.isNaN(Date.parse(v));
export function validateRecord(rec) {
const errors = [];
const push = (path, msg) => errors.push({ path, msg });
if (!rec || typeof rec !== 'object') {
return { valid: false, errors: [{ path: '$', msg: 'record must be an object' }] };
}
// --- vehicle ---
const v = rec.vehicle;
if (!v || typeof v !== 'object') {
push('vehicle', 'required object');
} else {
if (!isStr(v.make)) push('vehicle.make', 'required non-empty string');
if (!isStr(v.model)) push('vehicle.model', 'required non-empty string');
if (!isStr(v.variant)) push('vehicle.variant', 'required non-empty string');
if (v.bodyType !== undefined && !BODY_TYPES.includes(v.bodyType))
push('vehicle.bodyType', `must be one of ${BODY_TYPES.join(', ')}`);
if (v.modelYear !== undefined && !inRange(v.modelYear, 2000, 2030))
push('vehicle.modelYear', 'must be integer 2000–2030');
}
// --- testConfig ---
const t = rec.testConfig;
if (!t || typeof t !== 'object') {
push('testConfig', 'required object');
} else {
if (!TEST_TYPES.includes(t.testType))
push('testConfig.testType', `must be one of ${TEST_TYPES.join(', ')}`);
if (!isISODate(t.testDate)) push('testConfig.testDate', 'required ISO-8601 date');
if (t.protocolVersion !== undefined && !isStr(t.protocolVersion))
push('testConfig.protocolVersion', 'must be non-empty string');
}
// --- rating ---
const r = rec.rating;
if (!r || typeof r !== 'object') {
push('rating', 'required object');
} else {
if (!Number.isInteger(r.stars) || r.stars < 0 || r.stars > 5)
push('rating.stars', 'must be integer 0–5');
if (!inRange(r.overallScore, 0, 100))
push('rating.overallScore', 'must be number 0–100');
if (r.awarded !== undefined && typeof r.awarded !== 'boolean')
push('rating.awarded', 'must be boolean');
}
// --- subScores ---
if (!Array.isArray(rec.subScores) || rec.subScores.length === 0) {
push('subScores', 'required non-empty array');
} else {
rec.subScores.forEach((s, i) => {
const p = `subScores[${i}]`;
if (!COMPONENTS.includes(s.component))
push(`${p}.component`, `must be one of ${COMPONENTS.join(', ')}`);
if (!isNum(s.rawValue)) push(`${p}.rawValue`, 'required number');
if (!s.scale || !isNum(s.scale.min) || !isNum(s.scale.max) || s.scale.min >= s.scale.max)
push(`${p}.scale`, 'must be {min, max} with min < max');
if (s.normalized !== undefined && !inRange(s.normalized, 0, 100))
push(`${p}.normalized`, 'must be number 0–100 when present');
});
}
return { valid: errors.length === 0, errors };
}
Sample Record and a Runnable Check
The sample file exercises every required group. Note that normalized is intentionally absent — it is populated later, and the validator treats it as optional.
// data/records.sample.json
[
{
"vehicle": { "make": "Example Motors", "model": "Sedan X", "variant": "VXi",
"bodyType": "sedan", "modelYear": 2024 },
"testConfig": { "testType": "frontal", "protocolVersion": "BNCAP-2023",
"testDate": "2024-03-12", "labId": "LAB-01" },
"rating": { "stars": 5, "overallScore": 88.5, "ratingLabel": "5 Star",
"awarded": true, "effectiveDate": "2024-06-01" },
"subScores": [
{ "component": "adultOccupant", "rawValue": 14.5, "scale": { "min": 0, "max": 16 } },
{ "component": "childOccupant", "rawValue": 41, "scale": { "min": 0, "max": 49 } },
{ "component": "pedestrian", "rawValue": 22, "scale": { "min": 0, "max": 30 } }
]
}
]
// src/index.js
import { readFileSync } from 'node:fs';
import { validateRecord } from './schema/record.schema.js';
const records = JSON.parse(readFileSync(new URL('../data/records.sample.json', import.meta.url)));
let ok = 0, bad = 0;
records.forEach((rec, i) => {
const { valid, errors } = validateRecord(rec);
if (valid) { ok++; console.log(`record[${i}] OK`); }
else {
bad++;
console.error(`record[${i}] INVALID`);
errors.forEach(e => console.error(` ${e.path}: ${e.msg}`));
}
});
console.log(`\n${ok} valid, ${bad} invalid`);
Run it with node src/index.js. Expected output for the sample above: record[0] OK followed by 1 valid, 0 invalid. To confirm the validator actually rejects bad data, temporarily change "stars": 5 to "stars": 7 and re-run — you should see rating.stars: must be integer 0–5 and 0 valid, 1 invalid. That negative test is the real proof the contract is enforced, not just declared.
errors array per row.Contract Reference
| Group | Field | Type / Rule | Required |
|---|---|---|---|
| vehicle | make, model, variant | non-empty string | yes |
| vehicle | bodyType | enum: hatchback, sedan, suv, mpv, pickup | no |
| vehicle | modelYear | integer 2000–2030 | no |
| testConfig | testType | enum: frontal, side, pole, pedestrian, esc | yes |
| testConfig | testDate | ISO-8601 date | yes |
| rating | stars | integer 0–5 | yes |
| rating | overallScore | number 0–100 | yes |
| subScores[] | component | enum: adultOccupant, childOccupant, pedestrian, safetyAssist | yes |
| subScores[] | rawValue | number | yes |
| subScores[] | scale | {min, max}, min < max | yes |
| subScores[] | normalized | number 0–100 (populated later) | no |
With the skeleton in place and the contract enforced by a tested validator, every subsequent case has a stable target. The next step — ingesting real assessment exports — can now fail loudly and precisely at the field level instead of corrupting the dashboard silently.