Club Row Fitness

A Digital Reggata Experience


Club Row Fitness offers an incredible entertainment experience in Vancouver, BC. It mixes modern hip-hop and rowing, bringing together two worlds not yet before married. We were approached by the founder to create a mobile solution, which would be the first of its kind bringing this revolutionary idea from a shop in Vancouver, to worldwide.

During our initial meeting, we were shown apps like Peleton, LiveRow and other modern workout solutions. The client wasn't pleased with what was out there and had a vision to focus on their bread and butter - the Concept2 rowing machine.

Our main challenges were ensuring the capability of connecting a mobile device to an actual Concept2 PM5 using BLE (Bluetooth Low Energy), and accounting for thousands of data entry points per few seconds.

Bluetooth Connection (BLE) to the Concept2

The client initially sent over a Concept2, which we had the pleasure of both using for personal and business reasons. Right away, we had to work with the documentation for the Concept2 SDK (Software Development Kit) and write our own scripts to grab the data from the rower.

All data from the PM5 come in hex codes which need to be converted to a readable unit. We were than able to test as we rowed, isolating each data point with the correct mnemonic.

Real Time Global Interaction

Dealing with thousands of data points and users at the same time can be disastrous if not handled correctly. Each class is meant to be a worldwide interaction if possible, so latency is a major factor.

By having a good foundation, real time becomes easy using a language like Elixir, known for running low-latency, distributed and fault-tolerant systems. A tried and tested programming language used in the likes of Whatsapp and Discord.

Technical Journal

  • Converted the hex codes to readable data points
  • Designed and created an iOS app with Swift
  • Built the back-end using the Phoenix framework
  • Connected to a socket with Swift, sending incoming and outgoing events to routed channels
  • Snapshots of the users progression are taken every few seconds
  • Custom charts are built with Swift, utilizing the X-axis for 'Time'
  • Y-axis is agnostic to the metrics, requiring us to scale the data

var base = [
    { distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
    { distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
    { distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
    { distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
    { distance: 23, calories: 12, speed: 2, strokes_per_minute: 23, wattage: 300 },
    { distance: 23, calories: 12, speed: 9, strokes_per_minute: 35, wattage: 235 },
]

// result of transform(base)
// 0: {distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0}
// 1: {distance: 16.666666666666668, calories: 13.333333333333332, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 2: {distance: 33.333333333333336, calories: 26.666666666666664, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 3: {distance: 50, calories: 40, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 4: {distance: 66.66666666666667, calories: 53.33333333333333, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 5: {distance: 83.33333333333333, calories: 66.66666666666667, speed: 8.88888888888889, strokes_per_minute: 46, wattage: 90}
// 6: {distance: 100, calories: 80, speed: 40, strokes_per_minute: 70, wattage: 70.5}

function transform(snapshots) {

    var newSnapshots = [];
    if (snapshots.length == 0) {
        newSnapshots =  [
            { distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0 },
            { distance: 0, calories: 0, speed: 0, strokes_per_minute: 0, wattage: 0 }
        ]

        // draw error graph
        return newSnapshots;
    }
    

    //calculate cumulative sum of distance, calories
    var points = [];
    snapshots.reduce(function (a, b, i) {
        points[i] = snapshots[i];
        points[i].distance = a.distance + b.distance;
        points[i].calories = a.calories + b.calories;
        return points[i];
    }, { distance: 0, calories: 0 });

    // insert point(0, 0) automatically
    points.unshift({ 
        distance: 0, 
        calories: 0, 
        speed: 0, 
        strokes_per_minute: 0, 
        wattage: 0, 
        seconds_since_workout_started: 0});

    
    // calculate distance line
    line = points.map(function(point) {
        return point.distance;
    });
    line = reduce(line, 1.0);
    line.forEach(function(v, i) {
        newSnapshots[i] = Object.assign({distance: v}, newSnapshots[i]);
    });

    // calculate calories line
    line = points.map(function(point) {
        return point.calories;
    });
    line = reduce(line, 0.8);
    line.forEach(function(v, i) {
        newSnapshots[i] = Object.assign(newSnapshots[i], {calories: v});
    });

    // calculate speed line
    line = points.map(function(point) {
        return point.speed;
    });
    line = reduce(line, 0.4);
    line.forEach(function(v, i) {
        newSnapshots[i] = Object.assign(newSnapshots[i], {speed: v});
    });

    // calculate strokes line
    line = points.map(function(point) {
        return point.strokes_per_minute;
    });
    line = reduce(line, 0.7);
    line.forEach(function(v, i) {
        newSnapshots[i] = Object.assign(newSnapshots[i], {strokes_per_minute: v});
    });

    // calculate wattage line
    line = points.map(function(point) {
        return point.wattage;
    });
    line = reduce(line, 0.9);
    line.forEach(function(v, i) {
        newSnapshots[i] = Object.assign(newSnapshots[i], {wattage: v});
    });


    return newSnapshots;
}

// scale graph values in 0..<100 range
// multipler - 0..<1 weight to prevent line crossing and jumping
// accumulated - ignored
function reduce(samples, multipler, accumulated = false) {
    var count = samples.length;
    var output = [];
    if (count < 2) {
        return output;
    }
    if (accumulated == true) {
        samples.reduce(function(a,b,i) { return output[i] = a+b; },0);
    } else {
        output = samples;
    }
    output[0] = output[1]; // remove zero value point

    var max = Math.max.apply(Math, output)
    var min = Math.min.apply(Math, output)
    if (max < 0) {
        return output;
    }
    if (min < 0) {
        return output;
    }
    if (max == 0) {
        return samples;
    }
    if (max == min) {
        output = output.map(function (el) {
            return 100.0 * multipler;
        });
    } else {
        var rate = 100.0 / max;
        output = output.map(function (el) {
            return el * rate * multipler;
        });
    }
    output[0] = 0;
    return output;
}
  • Created a web dashboard using Next.js and React-Apollo
  • Various levels of account types (admin, instructor and user) allowing admins and instructors to create classes and view users

DevOps

Mobile and Web are two different beasts when it comes to DevOps and CI. The biggest issue was dealing with code signing, so after trying multiple solutions we decided on Bitrise. This allowed us to have a staging build for internal testing, while being able to set up the client with TestFlight - for beta users.

A massive undertaking, this project took 6 months to complete in creating an MVP the client is proud of, as are we.

© 2019 Creators Never Die. All Right Reserved. Made with Magic World Wide