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.

Canonical Bharat NCAP Test Record — Field Contract vehicle make : string model : string variant : string bodyType : enum modelYear : int 2000–2030 vin : string (optional) required: make, model, variant testConfig testType : enum frontal / side / pole pedestrian / esc protocolVersion : string testDate : ISO-8601 date labId : string required: testType, testDate rating stars : int 0–5 overallScore : num 0–100 ratingLabel : enum (e.g. "5 Star") awarded : boolean effectiveDate : date required: stars, overallScore subScores — array of component assessments component : enum (adultOccupant | childOccupant | pedestrian | safetyAssist) rawValue : number // value as reported by the lab scale : { min, max } // original scale, e.g. {min:0, max:16} normalized : number 0–100 // filled by normalization layer (case 3) required: component, rawValue, scale validateRecord() → { valid, errors[] }

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.

Keep the validator pure and synchronous. It has no I/O, no logging, and no side effects, so it can be reused unchanged inside the CSV ingestion loop, a REST endpoint, or a build-time check. When case 2 needs row-level error reporting, it simply collects the errors array per row.
Do not add fields to the sample record that the validator does not know about and then rely on them downstream. Either extend the contract (and its validation) or leave the field out. Silent extra fields are how schemas rot — a later case will read a property that was never guaranteed to exist.

Contract Reference

GroupFieldType / RuleRequired
vehiclemake, model, variantnon-empty stringyes
vehiclebodyTypeenum: hatchback, sedan, suv, mpv, pickupno
vehiclemodelYearinteger 2000–2030no
testConfigtestTypeenum: frontal, side, pole, pedestrian, escyes
testConfigtestDateISO-8601 dateyes
ratingstarsinteger 0–5yes
ratingoverallScorenumber 0–100yes
subScores[]componentenum: adultOccupant, childOccupant, pedestrian, safetyAssistyes
subScores[]rawValuenumberyes
subScores[]scale{min, max}, min < maxyes
subScores[]normalizednumber 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.