Dynamic Climbing Leaderboard (Google Sheets Integration)

Dynamic Climbing Leaderboard (Google Sheets Integration)

A dynamic leaderboard for a climbing gym challenge, pulling live data from a Google Sheet via GViz API. It aggregates feet climbed per participant, splits staff vs climbers, shows progress toward a mile, and includes filtering and highlighting features.

See it here: Climbing Leaderboard

Capabilities

  • Fetches and processes Google Sheets data using GViz JSONP API.
  • Aggregates total feet climbed per email, using the latest full name and staff status.
  • Displays separate leaderboards for climbers and staff, with rankings, totals, and progress bars toward 5280 feet (1 mile).
  • Includes filtering by name/email, highlighting via URL params, and auto-refresh every 2 minutes.
  • Handles errors, loading states, and empty results gracefully.

Key HTML Snippet: Progress Bar in Table Row

The markup uses Vue directives for reactivity, with tables for leaderboards and inline-styled progress bars.

<tr v-for="(row, idx) in filteredPaying" :key="row.email">
    <td>{{ idx + 1 }}</td>
    <td>{{ row.fullName || '(no name)' }}</td>
    <td style="text-align:right;">{{ Math.round(row.totalFeet).toLocaleString() }}</td>
    <td>
        <div style="display:flex; align-items:center; gap:6px;">
            <div style="flex:1; background:#e5e7eb; border-radius:6px; overflow:hidden; height:12px; position:relative;">
                <div :style="{
                    width: progressPercent(row.totalFeet).toFixed(1) + '%',
                    background: progressPercent(row.totalFeet) >= 100 ? '#16a34a' : '#2563eb',
                    height: '100%',
                    transition: 'width .3s'
                }"></div>
            </div>
            <div style="min-width:45px; font-size:0.75rem; text-align:right;">
                {{ progressPercent(row.totalFeet) >= 100 ? '100%' : progressPercent(row.totalFeet).toFixed(1) + '%' }}
            </div>
        </div>
    </td>
</tr>

Key JavaScript Snippets (Vue 2 Options API)

The Vue 2 app handles data fetching, processing, and reactivity with key methods for loading and filtering.

Data Loading via Google Visualization API

Uses JSONP to fetch Google Sheets data asynchronously.

loadViaGViz() {
    return new Promise((resolve, reject) => {
        const SHEET_ID = '123';
        const SHEET_NAME = 'Form Responses 1';

        const timeout = setTimeout(() => {
            reject(new Error('Timed out loading sheet via GViz JSONP'));
        }, 10000);

        window.google = window.google || {};
        window.google.visualization = window.google.visualization || {};
        window.google.visualization.Query = window.google.visualization.Query || {};

        window.google.visualization.Query.setResponse = (payload) => {
            clearTimeout(timeout);
            try {
                const board = this.processGVizPayload(payload);
                resolve(board);
            } catch (e) {
                reject(e);
            }
        };

        const script = document.createElement('script');
        const base = `https://docs.google.com/spreadsheets/d/${encodeURIComponent(SHEET_ID)}/gviz/tq`;
        const params = new URLSearchParams({
            tq: 'select *',
            tqx: 'out:json',
            sheet: SHEET_NAME,
        });
        script.src = `${base}?${params.toString()}`;
        script.onerror = () => {
            clearTimeout(timeout);
            reject(new Error('Failed to load GViz JSONP script'));
        };
        document.body.appendChild(script);
    });
}

Data Processing and Aggregation

Processes GViz response, aggregates feet by email, tracks latest name and staff status.

processGVizPayload(data) {
    if (!data || !data.table) throw new Error('Bad GViz payload');
    const cols = data.table.cols.map(c => (c.label || '').toString().trim());
    const lower = cols.map(c => c.toLowerCase());
    const emailIdx = lower.findIndex(h => h === 'email address');
    const feetIdx = lower.findIndex(h => h === 'how many feet climbed');
    const nameIdx = lower.findIndex(h => h === 'full name');
    const timestampIdx = lower.findIndex(h => h === 'timestamp');
    const staffIdx = lower.findIndex(h => h === 'are you a staff member');

    if (emailIdx === -1 || feetIdx === -1 || nameIdx === -1 || timestampIdx === -1) {
        throw new Error(
            `Missing expected headers. Found: ${cols.join(
                ', '
            )}. Need Email Address, How many feet climbed, Full Name, Timestamp.`
        );
    }

    const accum = new Map();
    const rows = data.table.rows || [];
    for (const r of rows) {
        const cells = (r.c || []).map(cell => {
            if (!cell) return '';
            return typeof cell.v === 'number' ? cell.v : (cell.v || '').toString().trim();
        });

        const emailRaw = cells[emailIdx] || '';
        const email = this.normalizeEmail(emailRaw);
        if (!email) continue;

        let feetRaw = cells[feetIdx] || '';
        feetRaw = feetRaw.toString().replace(/,/g, '');
        const feet = parseFloat(feetRaw);
        if (isNaN(feet)) continue;

        const fullName = (cells[nameIdx] || '').toString().trim();
        const timestampVal = this.parseTimestamp(cells[timestampIdx]);
        let isStaff = false;
        if (staffIdx !== -1) {
            const staffVal = (cells[staffIdx] || '').toString().trim().toLowerCase();
            if (staffVal === 'yes') isStaff = true;
        }

        const existing = accum.get(email);
        if (!existing) {
            accum.set(email, {
                totalFeet: feet,
                latestTimestamp: timestampVal,
                fullName,
                isStaff,
            });
        } else {
            existing.totalFeet += feet;
            if (timestampVal > existing.latestTimestamp) {
                existing.latestTimestamp = timestampVal;
                existing.fullName = fullName;
                existing.isStaff = isStaff;
            }
            accum.set(email, existing);
        }
    }

    const arr = Array.from(accum.entries()).map(([email, info]) => ({
        email,
        totalFeet: info.totalFeet,
        fullName: info.fullName,
        isStaff: !!info.isStaff,
        latestTimestamp: info.latestTimestamp,
    }));
    arr.sort((a, b) => b.totalFeet - a.totalFeet);
    return arr;
}

Computed Properties for Filtering

Reactive filtering for climbers and staff based on name/email.

filteredPaying() {
    if (!this.filter.trim()) return this.paying;
    const f = this.filter.toLowerCase();
    return this.paying.filter(r => {
        return (
            (r.fullName && r.fullName.toLowerCase().includes(f)) ||
            (r.email && r.email.toLowerCase().includes(f))
        );
    });
}

Auto-Refresh on Mount

Loads data on mount and sets up 2-minute interval refresh.

async mounted() {
    this.parseHighlight();
    await this.load();
    this.autoRefreshHandle = setInterval(() => this.load(), 120000);
}