Module 03: Data Shaping — From API to Chart

In Modules 01 and 02 you learned to draw bars and lines on a canvas. But the data was hard-coded. In a real dashboard, data comes from a reporting API — and it never arrives in the exact shape your chart needs. This module teaches the critical middle step: fetching data and transforming it into chart-ready arrays.

Demo Files

Try it: Open fetch-and-chart.html in a browser. It loads sample-data.json via fetch(), transforms the data, and renders a bar chart — no server required.

The Three-Step Pipeline

Every dashboard chart follows the same pipeline:

┌──────────┐ ┌─────────────┐ ┌──────────┐ │ FETCH │─────▶│ TRANSFORM │─────▶│ RENDER │ │ │ │ │ │ │ │ fetch() │ │ sort, group │ │ canvas / │ │ async/ │ │ format, │ │ Chart.js │ │ await │ │ filter │ │ / D3 │ └──────────┘ └─────────────┘ └──────────┘ API response chart-ready pixels on (JSON) arrays screen

Keeping these steps separate makes your code testable and reusable. The transform layer is a pure function — it has no side effects, takes JSON in, and returns arrays out.

Step 1: Fetching Data with fetch()

The fetch() API returns a Promise. Pair it with async/await for clean, readable code:

async function loadReport() {
    // 1. Make the HTTP request
    const response = await fetch('/api/report?range=30d');

    // 2. Check for HTTP errors (fetch doesn't throw on 4xx/5xx)
    if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    // 3. Parse the JSON body
    const json = await response.json();

    // 4. Verify the application-level status
    if (!json.success) {
        throw new Error(json.error || 'API returned success: false');
    }

    return json.data;
}
Why check response.ok? Unlike XMLHttpRequest, fetch() only rejects on network failures. A 404 or 500 response resolves successfully — you must check response.ok (or response.status) yourself.

The API Response Shape

Our reporting API returns a standardized envelope. The data property contains pre-aggregated results from SQL GROUP BY queries:

{
    "success": true,
    "data": {
        "byDay": [
            { "date": "2026-01-01", "pageviews": 245, "sessions": 98 },
            { "date": "2026-01-02", "pageviews": 312, "sessions": 121 },
            ...                          // ~30 rows for a 30-day range
        ],
        "topPages": [
            { "page": "/home",     "views": 1250 },
            { "page": "/about",    "views": 430 },
            ...                          // top 8 pages
        ],
        "browsers": {
            "Chrome": 5200,
            "Safari": 2100,
            "Firefox": 890,
            "Edge": 450,
            "Other": 160
        },
        "summary": {
            "totalPageviews": 8800,
            "totalSessions": 3200,
            "avgSessionDuration": 185
        }
    }
}

Notice the structure: the server has already done the heavy lifting. Each key (byDay, topPages, browsers) is ready for a different chart type.

The Hybrid Architecture

A well-designed analytics stack splits work between server and client:

DATABASE (millions of rows) │ │ SELECT page, COUNT(*) as views │ FROM pageviews │ WHERE timestamp > NOW() - INTERVAL 30 DAY │ GROUP BY page │ ORDER BY views DESC │ LIMIT 8 │ ▼ REPORTING API ──── sends ~30 rows as JSON ────▶ BROWSER │ │ sort, format, │ pick colors │ ▼ CHART
Layer Responsibility Handles
Database Storage, indexing Millions of raw events
Reporting API SQL GROUP BY, filtering, authentication Heavy aggregation, access control
Browser Sort, format, colorize, render ~30 pre-aggregated rows
Never send raw event logs to the browser. The reporting API does GROUP BY in SQL, sending ~30 rows, not 100,000. Sending raw logs wastes bandwidth, exposes PII, and makes the browser do work the database is optimized for.

Step 2: Data Transformation

Even pre-aggregated data needs shaping before a chart can use it. Common transforms include:

groupBy — Group Array Elements by Key

Given an array of objects, group them by a computed key using Array.reduce():

// Group pageviews by day-of-week
function groupBy(array, keyFn) {
    return array.reduce((groups, item) => {
        const key = keyFn(item);
        if (!groups[key]) groups[key] = [];
        groups[key].push(item);
        return groups;
    }, {});
}

// Usage: group daily data by weekday name
const byWeekday = groupBy(data.byDay, row => {
    const d = new Date(row.date + 'T00:00:00');
    return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()];
});
// → { Mon: [{...},{...},...], Tue: [{...},...], ... }
Why reduce()? It processes every element in a single pass, building up the result object as it goes. This is the standard pattern for grouping in JavaScript — equivalent to Python's itertools.groupby or SQL's GROUP BY.

sortDesc — Sort by Value

// Sort pages descending by views (do NOT mutate the original)
function sortDesc(array, valueFn) {
    return [...array].sort((a, b) => valueFn(b) - valueFn(a));
}

const ranked = sortDesc(data.topPages, p => p.views);
// → [{page:'/home', views:1250}, {page:'/about', views:430}, ...]

formatDate — Axis-Friendly Labels

// "2026-01-15" → "Jan 15"
function formatDate(isoString) {
    const d = new Date(isoString + 'T00:00:00');
    const months = ['Jan','Feb','Mar','Apr','May','Jun',
                    'Jul','Aug','Sep','Oct','Nov','Dec'];
    return `${months[d.getMonth()]} ${d.getDate()}`;
}

// Map over daily data to build axis labels
const labels = data.byDay.map(row => formatDate(row.date));
// → ["Jan 1", "Jan 2", "Jan 3", ...]
Timezone trap: new Date('2026-01-15') parses as UTC midnight, which can shift to the previous day in western time zones. Adding 'T00:00:00' forces local-time parsing and avoids the off-by-one bug.

Putting Transforms Together

A typical transform function chains these utilities into a single pipeline:

function prepareTopPagesChart(apiData) {
    // 1. Sort descending by views
    const sorted = sortDesc(apiData.topPages, p => p.views);

    // 2. Take top 6
    const top6 = sorted.slice(0, 6);

    // 3. Split into parallel arrays for the chart
    return {
        labels: top6.map(p => p.page),
        values: top6.map(p => p.views)
    };
}

function prepareDailyChart(apiData) {
    return {
        labels: apiData.byDay.map(row => formatDate(row.date)),
        pageviews: apiData.byDay.map(row => row.pageviews),
        sessions:  apiData.byDay.map(row => row.sessions)
    };
}

Working with the Browser Object

The browsers field arrives as an object (not an array). Convert it for charting:

// Object → array of {name, count} sorted descending
function prepareBrowserChart(browsers) {
    const entries = Object.entries(browsers);   // [['Chrome',5200], ...]
    const sorted  = entries.sort((a, b) => b[1] - a[1]);

    return {
        labels: sorted.map(([name])    => name),
        values: sorted.map(([, count]) => count)
    };
}

// → { labels: ['Chrome','Safari','Firefox','Edge','Other'],
//     values: [5200, 2100, 890, 450, 160] }

Step 3: Connecting to the Canvas

With clean data, the render step is straightforward. Here is the full pipeline wired together:

async function main() {
    // FETCH
    const response = await fetch('sample-data.json');
    const json     = await response.json();

    // TRANSFORM
    const { labels, values } = prepareTopPagesChart(json.data);

    // RENDER (reusing Module 01 / 02 canvas skills)
    const ctx  = document.getElementById('chart').getContext('2d');
    const maxV = Math.max(...values);
    const barW = 600 / labels.length;

    labels.forEach((label, i) => {
        const barH = (values[i] / maxV) * 300;
        ctx.fillStyle = '#16a085';
        ctx.fillRect(80 + i * barW, 350 - barH, barW - 10, barH);

        ctx.fillStyle = '#333';
        ctx.font = '12px sans-serif';
        ctx.textAlign = 'center';
        ctx.fillText(label, 80 + i * barW + barW / 2, 368);
        ctx.fillText(values[i], 80 + i * barW + barW / 2, 345 - barH);
    });
}

main();

Offline Development with sample-data.json

During development you often do not have a running reporting API. A static JSON file lets you build and test charts without a server:

// In development: load from static file
const API_URL = 'sample-data.json';

// In production: switch to the real API
// const API_URL = '/api/report?range=30d';

const response = await fetch(API_URL);
const json     = await response.json();

The sample-data.json file included in this module mimics the exact shape the reporting API returns. Your transform code works identically against either source — that is the whole point of keeping fetch and transform separate.

Fallback pattern: The fetch-and-chart.html demo also embeds a small fallback dataset. If fetch() fails (e.g., opened via file:// without a server), it renders from the embedded data instead. This is a useful resilience pattern for demos and error states.

The Reusable Transform Module

The data-transforms.js file exports six utility functions you can import into any page:

Function Purpose Example
groupBy(arr, keyFn) Group elements by key groupBy(rows, r => r.browser)
sortDesc(arr, valFn) Sort descending by value sortDesc(pages, p => p.views)
formatDate(iso) ISO string to "Jan 15" formatDate('2026-01-15')
parseBrowser(ua) Extract browser name parseBrowser(navigator.userAgent)
sumBy(arr, valFn) Sum numeric values sumBy(rows, r => r.pageviews)
topN(arr, n, valFn) Top N items by value topN(pages, 5, p => p.views)

Import them as ES modules:

<script type="module">
import { sortDesc, topN, formatDate } from './data-transforms.js';

const ranked = sortDesc(data.topPages, p => p.views);
const labels = data.byDay.map(r => formatDate(r.date));
const top5   = topN(data.topPages, 5, p => p.views);
</script>

Error Handling

Production dashboards must handle failures gracefully. Wrap your pipeline in try/catch:

async function main() {
    try {
        const data    = await fetchReport();
        const shaped  = transformForChart(data);
        renderChart(shaped);
    } catch (err) {
        console.error('Dashboard error:', err);
        showErrorState('Failed to load data. Please try again.');
    }
}

function showErrorState(message) {
    const ctx = document.getElementById('chart').getContext('2d');
    ctx.fillStyle = '#999';
    ctx.font = '16px sans-serif';
    ctx.textAlign = 'center';
    ctx.fillText(message, 400, 200);
}

Summary