← Back to Blog

From Watch to Web: Building a Fitness Data Visualization System for a Static Blog

For years, my sports watch has been a faithful companion, logging every run, bike ride, and tennis match. But the data lived exclusively within the manufacturer's app—a walled garden, convenient but restrictive. As an engineer, I've always felt the itch to own my data and present it my way. Inspired by Apple Fitness's clean aesthetics, I decided to build a dedicated "Sports" section for my static blog, turning raw fitness data from my Amazfit T-Rex 3 into an interactive, personal dashboard.

The result: Apple Fitness-style progress rings tracking weekly goals, interactive GPS track maps powered by Leaflet.js, a monthly activity calendar with sport-type icons, and detailed activity cards with sport-specific metrics. All running on a purely static site—no backend, no database.

Architecture: Static-First, Load on Demand

The core constraint is simple: no server, all data processed at build time. The architecture follows this flow:

  1. Data Source: Raw .fit, .gpx, .tcx files exported from the Zepp app, stored in _content/fit/
  2. Build-Time Processing: A Node.js script parses all workout files and generates structured JSON
  3. Two-Layer JSON — the key performance optimization:
    • sports.json: Activity metadata (sport type, duration, distance, calories, heart rate, etc.) + weekly/monthly stats + configurable goals. This single file powers the homepage widget and the main sports page
    • tracks/{id}.json: Per-activity GPS track points. Lazy-loaded only when the user clicks to view a map
  4. Frontend Rendering: Reads JSON data, renders rings, calendar, activity list, and maps

This design keeps the initial page load lean. sports.json is typically a few dozen KB, while the larger GPS data is split into individual files for on-demand loading.

The Data Pipeline: FIT Files to Structured JSON

The build script build-sports-json.js is the heart of the system.

Parsing FIT Files

FIT (Flexible and Interoperable Data Transfer) is a compact binary format with detailed workout data. The fit-file-parser npm package makes parsing straightforward:

const FitParser = require('fit-file-parser').default;
const fitParser = new FitParser({
    force: true,
    speedUnit: 'km/h',
    lengthUnit: 'km',
    elapsedRecordField: true,
});

function parseFitFile(filePath) {
    const buffer = fs.readFileSync(filePath);
    return new Promise((resolve, reject) => {
        fitParser.parse(buffer, (error, data) => {
            if (error) return reject(error);

            const session = data.activity.sessions[0];
            const activity = {
                sport: session.sport,
                sub_sport: session.sub_sport,
                distance: session.total_distance,
                duration: session.total_elapsed_time,
                calories: session.total_calories,
                avgHeartRate: session.avg_heart_rate,
                // ...
            };

            const records = data.activity.records;
            resolve({ activity, records });
        });
    });
}

The script also handles .gpx and .tcx files using built-in XML parsing, and automatically deduplicates when the same activity exists in multiple formats (priority: .fit > .gpx > .tcx).

Smart Sport Type Resolution

FIT files contain sport and sub_sport fields that provide precise classification. For example, "indoor rowing" is recorded as sport: 'fitness_equipment', sub_sport: 'indoor_rowing'. A two-level mapping resolves these into clean, display-friendly types:

const SPORT_MAP = {
    running: 'running', cycling: 'cycling',
    swimming: 'swimming', walking: 'walking',
    hiking: 'hiking', tennis: 'tennis',
    fitness_equipment: 'training', training: 'training',
};

const SUB_SPORT_MAP = {
    indoor_rowing: 'rowing',
    stair_climbing: 'stair_climbing',
    strength_training: 'strength',
};

// Sub-sport overrides primary sport when more specific
const baseSport = SPORT_MAP[session.sport] || 'training';
const sport = SUB_SPORT_MAP[session.sub_sport] || baseSport;

This supports 15+ sport types—running, cycling, swimming, tennis, rowing, stair climbing, strength training, yoga, basketball, soccer, badminton, and more—each with a unique color and SVG icon.

GPS Track Downsampling

A one-hour run can produce thousands of GPS points. Loading all of them into the browser wastes bandwidth and causes map rendering jank. My approach: keep roughly one point every 5 seconds, stored in a compact array format:

function downsampleTrack(records) {
    const points = [];
    let lastTs = 0;

    for (const r of records) {
        if (!r.position_lat || !r.position_long) continue;
        const ts = new Date(r.timestamp).getTime() / 1000;
        if (ts - lastTs >= 5) {
            // Compact array: [lat, lon, elevation, heartRate, timestamp]
            points.push([
                r.position_lat,
                r.position_long,
                r.altitude || 0,
                r.heart_rate || 0,
                ts,
            ]);
            lastTs = ts;
        }
    }
    return points;
}

Using arrays [lat, lon, ele, hr, ts] instead of objects cuts file size by roughly 40%. After downsampling, each track file is typically 3-8 KB.

Orphan Cleanup

On every rebuild, the script cleans up stale track files that no longer correspond to any activity. This ensures that when source FIT files are deleted or renamed, old data doesn't linger:

const existingFiles = fs.readdirSync(tracksDir).filter(f => f.endsWith('.json'));
const validIds = new Set(Object.keys(newTracks));
for (const f of existingFiles) {
    const id = f.replace('.json', '');
    if (!validIds.has(id)) {
        fs.unlinkSync(path.join(tracksDir, f));
        console.log(`Removed orphan: ${f}`);
    }
}

Frontend Implementation

Apple Fitness-Style Progress Rings

The visual centerpiece: three concentric SVG rings representing weekly progress toward distance (red), time (green), and calories (orange) goals. The technique relies on stroke-dasharray and stroke-dashoffset:

const circumference = 2 * Math.PI * radius;
const percentage = Math.min(currentValue / goalValue, 1);
const offset = circumference * (1 - percentage);

// Background track (low opacity)
`<circle r="${radius}" stroke="${color}" stroke-width="${sw}" opacity="0.15"/>`
// Progress arc
`<circle r="${radius}" stroke="${color}" stroke-width="${sw}"
    stroke-dasharray="${circumference}"
    stroke-dashoffset="${offset}"
    transform="rotate(-90 50 50)"/>`

Goals are configurable in sports.json (defaults: 10 km / 3 hours / 1,000 kcal per week). The build script computes current-week completion percentages, and CSS transitions create the smooth ring-filling animation.

Interactive GPS Maps

Maps use Leaflet.js with OpenStreetMap tiles, employing a dual lazy-loading strategy:

  • Library lazy-loading: Leaflet JS/CSS are only loaded from CDN when the user first clicks a map button
  • Data lazy-loading: Clicking triggers a fetch for the corresponding tracks/{id}.json, then draws the track polyline and start/finish markers
async function showMap(activityId, bounds) {
    await ensureLeafletLoaded();

    const resp = await fetch(`/sports/tracks/${activityId}.json`);
    const points = await resp.json();

    const latLngs = points.map(p => [p[0], p[1]]);
    const polyline = L.polyline(latLngs, {
        color: '#C41E3A', weight: 3, opacity: 0.8
    }).addTo(map);

    map.fitBounds(polyline.getBounds(), { padding: [30, 30] });
}

Tracks are rendered in the site's accent vermilion (#C41E3A), with a green circle for start and red for finish. The map instance is reused globally to avoid repeated initialization.

Monthly Activity Calendar

Both the homepage and sports page feature a monthly calendar grid. Each day cell shows the date number, and for days with activities, small colored sport-type circle icons appear below. Multiple different sports on the same day display as multiple dots—running in red, tennis in gold, rowing in teal—making the week's variety immediately visible.

Homepage Widget

The homepage sports module uses a two-panel card layout: the left panel contains mini rings with detailed weekly stats (distance, time, calories with goal percentages) plus all-time totals; the right panel shows the current month's activity calendar. High information density without clutter.

Challenges and Takeaways

  • Data inconsistency: Different devices and export paths produce FIT files with varying field completeness. The script needs extensive defensive coding—handling indoor activities with no GPS, sessions with missing heart rate data, and edge cases in timestamp parsing
  • Sport type granularity: The sport field is too coarse (e.g., fitness_equipment covers rowing, stair climbing, and elliptical). Combining it with the sub_sport field via a second mapping layer was essential for accurate identification
  • Performance trade-offs: The constant tension between "show more data" and "keep the page fast" drove every architectural decision—two-layer JSON, GPS downsampling, Leaflet lazy-loading

Conclusion

Integrating fitness data into a personal blog turned out to be a rewarding exercise in data processing, SVG animation, map visualization, and performance optimization. The system is fully extensible—yearly summaries, personal records, richer analytics charts are all possible additions. If you're sitting on a trove of smartwatch data and want to do something creative with it, I hope this walkthrough gives you a starting point.