Bob Graham

The Bob Graham 24 Hour Club

Data API v1

This file explains how to use the API v1 to embed data about Club members in a website. You will need to have an understanding of Javascript modules, Promises and template strings when looking at the examples.

Import the API file:

Since the file you will import is a module you will need a slightly different setting up of your code compared to traditional Javascript. If you don't set the type to “module” then things won’t work. It's also worth noting that doing this forces strict mode so your code will have to be compliant with the strictures imposed by that.

<script src="yourscript.js" type ="module"></script>

Then as part of the first block of code, there can’t be anything before imports, in yourscript.js put:

import Bgr from "http://bobgrahamclub.org.uk/api/v1/bgr.js";

The module does not define a namespace so it’s up to you to define one, in this case it’s “Bgr”.

Function Calls

memberData()

The main API function is “memberData”, this returns the data that you’ll use in a Promise. The function takes an optional configuration object with the following elements. The first three are ANDed together meaning if you set more than one then all the conditions must be true. i.e ({club: “dark peak”, year: “1980”} would return those members of Dark Peak FR who succeeded in 1980.

The options are listed in “blocks” relating to their usage.


memberData({
	// name: default value, comment
	// filters
	// -------
	club: "",
		// This is the club name of a member at the time they did the Round not their current club. 
		// Case is not important.

	name: "",
		//This is the family name (surname) of a member. Case is not important.

	year: "",
		// The year of a successful round. 
		// For a decade leave off the last digit, i.e. "199" would return members in all years 1990 - 1999

	// additional rounds
	// -----------------
	members: [],
		//an array of membership numbers. Defaults to an empty array.

	second: false,
		//determines if second (non qualifying) rounds should be included.

	// predefined filters
	filtered: null,
		// Allows a user defined filtering function to be passed.

	// behavioural/sorting
	// -------------------
	sortCol: "mem",  (Deprecated, see sortBy below) 

	sortOrder: "asc"  (Deprecated, see sortBy below)

	sortBy:  [{col: 'mem', order: 'asc', numeric: true}]
		// an array of objects of sort instructions
		// col: the name of the column to sort on
			// If you set second to true then it's a good idea to set this to "date" 
			// otherwise second rounds can appear before that person's qualifying round.
			// Note that you shouldn't use "name" for this as that column is a combination
			// of given name and family name, use "fn" (family name) instead.
		// order: ascending ('asc') or descending ('desc'), defaults to 'asc'
		// numeric: whether to force numeric comparison, defaults to false

	limit: -1,
		// Set to a positive integer to limit the number of values returned.

	columns: [],
		// If this is provided then only the named columns will appear in the resulting data set.

	// debugging
	// ---------
	debug: "none",
		// set to "console" to get console logging; "file" to log to file; "console file" will do both.

	logPath: "",
		// the path to your logging directory
	filename: "log_errors.txt",
		// the filename you wish to use.
		// the default is log_errors.txt in the root folder of your site.
});

One point about the sortBy value. The rules are run in order so the following set of rules sort by family name then given name then age. Note that numeric should only be set to “true” for actual numeric fields as listed below, i.e. fields like “fn”, “date” and “club” cannot be converted to a number so numeric should be “false” in instances like those.

Only the following column names will act on numeric being “true”.


	sortBy: [{col: 'fn', order: 'asc', numeric: false},
		{col: 'gn', order: 'asc', numeric: false},
		{col: 'age', order: 'asc', numeric: true}
	],

Information returned

The data returned consists of an array of objects containing the following fields:


{
	member: // the membership number. For second rounds this is inside angle brackets - <52>
	name: // the full name, i.e. forename surname.
	age: // their age on the day that they did their round.
	gender: // Male or Female.
	date: // the date of their round.
	time: // the time taken to do the round.
	direction: // clockwise or anti-clockwise.
	route: // Sergeant Man or High Raise first.
	nationality: // obvious
	club: // obvious. Note that this is the club the person belonged to when they did the round, 
		  // we don't monitor changes to club membership.
	prev: // The number of previous attempts (only since 2014)
	postcode: // The first part of the postcode before the space.

	// There are additional fields that in some cases are used to generate one of the above.
	// gn, fn and died become "name" for example
	record: // rounds that were part of the progression of the record.
	lr: // rounds that were part of the progression of the women's record.
	gn: // given name
	fn: // family name - combined with gn into "name".
	died: // year of death, actually appended to name in the form (died: 2010)
	orig: // For second rounds this is the original membership number.
	season: // summer, winter or mid-winter.
	opt: // This is the basis for "route" and depends on dir as to what text appears in "route"
	dir: // This is the numerical basis for "direction"
	weekNum: // the ISO week number in which the round took place
}

Here’s an example of the data returned:


{
	age: "38",
	club: "Ambleside Athletics Club",
	date: "1975-06-21",
	died: "0",
	dir: "C",
	direction: "CW", // derived from "dir"
	fn: "Astles",
	gender: "M",
	gn: "Bob",
	lr: "0",
	mem: "35",
	member: "35", // derived from "mem"
	name: "Bob Astles ", // derived from "gn", "fn" and "died"
	nationality: "British",
	opt: "0",
	orig: "0",
	postcode: "LA22",
   	prev: "0",
	record: "0",
	route: "SM -> HR", // derived from "opt" and "dir"
	season: "summer",
	time: "23:06",
	weekNum: "25",
}

By default each record in the dataset being returned consists of all the fields. If this is not what you want then you may specify the exact list of fields you require. The following options object...


{
	columns: ['date', 'postcode']
}

Would return the date and postcode fields for the entire set of rounds. I.e. if each member’s record was in the horizontal axis then it would slice the data vertically through through each record. Best used when you want a small number of fields from every record and also wish to limit the size of data being returned.

This slicing takes place after all other data transforms such as filtering and sorting (including user defined filtering, see the next section) have taken place.

User defined filtering

The filtered option may be used to pass a function to filter the data set in a different way to what may be achieved by the built-in options. This function is passed to the standard JS filter function which will run it against every element in the array. If this option is set then any other options such as name or club are ignored. If the value passed in is not a function then an error is raised.


	// filter for Kendal based clubs
	(({club}) => club.startsWith('Kendal') || club.startsWith('Helm'))
	// get all those in their 50s
	(({age}) => age > "49" && age < "60")
	// get all men in their 50s who succeeded on their second attempt
	(({age, gender, prev}) => gender === "M" && age > "49" && age < "60" && prev === "1")
	// get all anticlockwise rounds that visited Sergeant Man first
	(({dir, opt}) => dir === "A" && opt === "1")
	// Get those rounds that are part of the progression of the ladies' record
	(({lr} => lr === "1"))

Note that for debugging purposes if you set the debug property to “console” you will see log messages in the browser console.

assists()


assists({
	members: []
		// An array of comma separated membership numbers.
});

This call returns how an individual has helped other members

Information returned

The data returned consists of an array of objects containing the following fields:


{
	name: // full name
	mem_num: // membership number
	leg_one:  // the number to times help has been provided on each leg.
	leg_two:
	leg_three:
	leg_four:
	leg_five:
	road: // the number of times help provided at road crossings.
	total: // total number of members helped
	assists: // a space separated list of membership numbers helped by the individual
}

Note that “total” isn’t the sum of legs one to five but the number of individuals helped. If someone has paced legs one and two for just one person then leg_one = 1, leg_two = 1, total = 1.

Also note that if someone hasn't helped on other rounds then the function will return “undefined” rather than zero for them. You might wish to clean this up. See the example at the foot of the page.

Here’s an example:


{
	name: "Peter Dawes",
	mem_num: "23",
	leg_one: "13",
	leg_two: "15",
	leg_three: "24",
	leg_four: "13",
	leg_five: "16",
	road: "3",
	total: "49",
	assists: "49,33 34 44 47 49 59 68 69 71 72 73 74 75 78 98 
			99 111 112 113 114 118 140 141 143 144 147 148 149 
			222 226 227 284 309 312 315 324 365 366 392 430 432
			433 486 491 645 769 771 772 811" 
}

meta()

This function returns information about the various files. It’s primary purpose is to allow to cache data while allowing for updates. Rather than potentially pull across a lot of data it returns a small amount that the code on the client side can use to determine if the data stored locally needs to be updated.


	meta(type);

The call returns a JSON object with information about the API including the timestamps of the data files. The “version” values come from the main regeneration of the files done each year and will always be midnight of the 1st January of the given year. The “updated” values reflect any minor changes that may be made throughout the year if errors are reported and it’s easier to fix them in place.

The first sub-object, “info” is part of the OpenAPI specification but the API doesn’t follow that overall specification so it’s just a convenient way to present that information. The rest of the data returned depends on the parameter passed to the function. “type” may be one of the following strings:


	timestamps: // The default. Returns the last modification details of the data files.
	history: // Returns details about each version of the API.
	full: // returns everything.

Bgr.meta("timestamps"); would return the following JSON object...


Typically the call will be used like:


// Returns true if the remote data has been updated or the local data hasn't been set up yet. 
function remoteFileIsNewer() {
    return Bgr.meta("timestamps")
        .then((text) => {
            const lastTime = localStorage.getItem('bgr_updated');

            if (lastTime === null) {
                localStorage.setItem('bgr_updated', text.files.listing.updated);
                return true;
            }
            const lastUpdate = new Date(text.files.listing.updated).getTime();
            const local = new Date(lastTime).getTime();

            return lastUpdate > local;
        });
}

augmentData()

If you wish to add your own data, reports, lists of helpers, links as an extra column then use this function.

Parameters

augmentData(
	data: [] // the array to augment.
	extra: [] // a sparse array, indexed by membership number, holding the data for the new column
	columnName: "" // the name of the new column. Should not be the same as an existing column.
)

The function returns the original array with each element having an extra property - “columnName”.

totalRows()

If you want a totals row then call this function

Parameters

totalRows(
	data: [], // the array to total
	config: {} // a configuration object. See the next section for a description
)

The configuration object has two properties. The first an array of checks, one for each column to sum over. The second is a pre-initialised object, totals, with properties that match a row in the data array.


{
	checks: [
		{
            column: "age",
            test: "less than", // or "equals", "greater than", "greater than equals", "lesser than equals"
            value: "25"
        },

	],
	totals: {
		name: "Totals",
		age: 0,
		// etc.
	}
}

The above check will each age property in the data array that it is less than 25. If the test property is missing then the default check is for equality. The value to test for should be the same underlying type, i.e. string or number as that in the data array otherwise the check will fail.

An example.


const target = document.getElementById("bgr_members");
const copy_target = document.getElementById("bgr_copyright");
const reports = [];
reports[170] = "<a href='jb_report.html'>Report</a>";
reports[1248] = "<a href='to_report.html'>Report</a>";

Bgr.memberData({club: "Ambleside"})
.then((text) => Bgr.augmentData(text, reports, "report"))
.then((text) => {
    createTable(target, 
        text,
        ['member', 'name', 'date', 'time', 'direction', 'route', 'report']);
    showCopyright(copy_target, Bgr.copyright());
})
.catch((error) => console.log(error));

Copyright()

Displays a copyright notice.

Takes two optional parameters. The first determines the type of notice to display: There are three options depending on how you use the data.


"basic" // The default so no requirement to specify this. Use when displaying tables of the supplied data.
"derived" // Use when the data being displayed is derived from the supplied data: averages, etc.
"graph" // Use below any graphs based on the supplied data.

The second optional parameter is an array of strings of names of CSS classes from your website that are applied to the links within the notice. Use these to style those links in line with your website's look and feel.


Usage

The most likely scenario would be to set the club name in a call to memberData(). The name doesn't have to be complete, just sufficient that it can be distinguished from other clubs.

The name or club field can filter for text at the start or end of a name:

The year field uses a text comparison so if you wish to get a decade’s worth of members leave off the last digit.

The call below will return an array containing all those members of Cumberland Fell Runners (but not West Cumberland Fell Runners or West Cumberland Orienteering Club because of the caret at the start of the string) who have succeeded on the BGR during the years 1990 to 1999 inclusive ordered by membership number (i.e. the default).

Bgr.memberData({club: "^cumber", year: "199"});

The call below will return an array containing all those members of Keswick AC called “Bland”.

Bgr.memberData({club: "kesw", name: "bland"});

We can now do what we want by chaining “then()” calls. Here it’s assumed that the client has a function “createTable” to perform that action. Note that you should also display the copyright notice next to the table, usually just beneath it.


const target = document.getElementById("bgr_members");
const copy_target = document.getElementById("bgr_copyright");

Bgr.memberData({club: "Ambleside"})
.then((text) => {
    createTable(target, text, ['member', 'name', 'date', 'time', 'direction', 'route']);
    showCopyright(copy_target, Bgr.copyright());
})
.catch((error) => console.log(error));

Note that internally the code has already extracted the text object holding the data from the Promise so you don’t need to do this:

.then((response) => response.text())

A more dynamic selection

This example assumes that there’s a drop down list of year values: 1980, 1981, etc. and will return just those club members from the selected year. Perhaps an advantage if your club has many BG successes.

See this page for “live” examples where you can make various selections.


const target = document.getElementById("bgr_members"),
    copy_target = document.getElementById("bgr_copyright"),
    curYear = new Date().getFullYear(),
    yearList = d.getElementById('year_list');
let yr = 1960;

yearList.options.length = 0;
yearList.append(new Option(curLang.init_val, -1));
for (yr = 1960; yr < curYear; yr += 1) {
    yearList.append(new Option(yr, yr));
}

yearList.addEventListener('change', function () {
    clearTable(target);
    Bgr.memberData({year: this.value, club: "Ambleside"})
        .then((text) => {
            createTable(target, text);
            showCopyright(copy_target, Bgr.copyright());
        })
        .catch((error));
});

A complete example

If you wish to include current members of your club who did their round whilst a member of a different club then you’ll need to add a members array to the config object holding the membership numbers you wish to include.

The following code is that used by this page to create the table below. The call selects all those whose name ends in “and” who have done the round, including their second rounds, plus the BGC committee members, again including their second rounds. Second rounds have a gold background, name and club columns are left aligned. The sort column is set to “date” to put the second rounds in their correct order. The assists API function is also called and this data is added to the main table of data for display. The copyright notice is added as a caption to the table, the CSS places this at the table foot.


/*Copyright: Bob Graham Club 2021 */

// best read from bottom to top.

import Bgr from 'http://bobgrahamclub.org.uk/api/v1/bgr.js';

const target = document.getElementById("bgr_members");

function getAssists(text, column) {
    const members = [];

    text.forEach(({member}) => {
    	// handle second rounds which are of the form "< nn >"
        if (member.charAt(0) === '<') {
            members.push(parseInt(member.substring(1, member.indexOf('>')), 10));
        } else {
            members.push(parseInt(member, 10));
        }
    });

    return Bgr.assists({members: members})
        .then((data) => {
            data.forEach((assist) => {
                text.forEach((mem) => {
                    let membership = parseInt(mem.member, 10);
                    if (mem.member.charAt(0) === '<') {
                        membership = parseInt(mem.member.substring(1, mem.member.indexOf('>')), 10);
                    }
                    if (parseInt(assist.mem_num, 10) === parseInt(membership, 10)) {
                        mem[column] = assist.total;
                    }
                });
            });
            return text;
        })
        .then((d) => {
        // Tidy things up since if a member hasn't supported anyone then the value is undefined.
            d.forEach((elem) => {
                if(elem[column] === undefined) {
                    elem[column] = 0;
                }
            });
            return d;
        });
}

// 3a. helper function
// applies styles to rows
function formatRows(test, styles, name) {
    let format = "";

    if (test) {
        const styleName = `rows_${name}`;

        if (styles[styleName]) {
            format = ` class='${styles[styleName]}'`;
        }
    }

    return format;
}

// 3b. helper function
// applies styles to columns.
function formatCols(col, styles, names) {
    let format = "",
        result = "";

    names.forEach((name) => {
        const styleName = `cols_${name}`;

        if (styles[styleName]) {
            if (styles[styleName].includes(col)) {
                result += `${styles[name]} `;
            }
        }
    });

    if (result.length > 0) {
        format = ` class='${result}'`;
    }

    return format;
}

// 2.
// Example createTable function.
// The styles object contains a class for the table style used in the tables used for the membership
// table and the record data tables. This site's own CSS has classes for headers and 
// also stripes alternate rows for any table with that class.
// The sample_header class uses text-transform to capitalise the header text.
// The table_caption class sets the caption to beneath the table.
function createTable(elem, data, fields, caption, styles = {}) {
    const table = typeof styles.table !== undefined ? `class='${styles.table}'` : "";
    const header = typeof styles.header !== undefined ? `class='${styles.header}'` : "";
    const body = typeof styles.body !== undefined ? `class='${styles.body}'` : "";
    const row = typeof styles.row !== undefined ? `class='${styles.row}'` : "";
    const capt = typeof styles.caption !== undefined ? `class='${styles.caption}'` : "";
    let text = `<table ${table}><caption ${capt}>${caption}</caption>`;

    text += `<thead ${header}>`;
    fields.forEach((field) => text += `<th>${field}</th>`);
    text += `</thead><tbody ${body}>`;
    data.forEach((member) => {
    	// Give second rounds a gold background
        // See #3a above
        text += `<tr ${formatRows(member.member.startsWith('<'), styles, 'second')}>`;
        // left align name and club columns
        fields.forEach((field) => {
            // See #3b above
            text += `<td${formatCols(field, styles, ['left'])}>${member[field]}</td>`;
        });
        text += "</tr>";
    });
    text += "</tbody></table>";
    elem.innerHTML = text;
}

// 1.
// get the data
// add our own fields
// create the table
Bgr.memberData({name: "and$", // All those whose name ends in "and"
		members: [103, 139, 170, 324, 1248, 2067, 2309], // plus the committee members
		second: true, // and second rounds
		sortBy: [{col : 'date', order : 'asc', numeric : false}]
	})
    .then((data) => getAssists(data, "assists"))
    .then((data) => {
    	// See #2 above
        createTable(target,
            data,
            ['member', 'name', 'date', 'time', 'direction', 'route', 'assists', 'club'],
            Bgr.copyright(),
            {
                table: "bgr_table ten_block", // ten_block gives a stronger bottom border to every tenth row.
                header: "bgr_header",
                caption: "bgr_caption",
                rows_second: "second_round", // Give second rounds a gold background
                left: "cell_left",
                cols_left: ['club', 'name'] // left align name and club columns
            });
    })
    .catch((error) => console.log(error));


The above call results in the following table.

The table uses a separate CSS file from the main site, it’s deliberately “different”, so if you want to be really lazy you can just link that and copy the above code into a local JavaScript file and everything will just work! You’d just have to:

Debugging

You can turn on some basic logging by setting the debug property to “console” in the call to memberData. This will appear in the console log that is enabled in your browser by turning on the developer tools. The debug shows: what the code thinks you’ve asked it to do; the amount of data it starts with; the number of items the options result in.

It’s worth starting off with one of the above examples since they are known to work. As a rough progression we’d suggest the following:

Once you’ve verified that everything is working using one of the above examples then tweek the call to suit your needs.

The search for club name simply looks for that text in the club’s name but see the first example higher on this page for how to fix the search term to the start or end of the name. Something like “harriers” or “fell” will return a lot of entries!

You also need to be aware of caching issues when developing, sometimes you need to force the browser to get the file with your changes. Fortunately the developer mode of modern browsers bust the cache as a default action when you refresh but sometimes you may need a “hard” reload.

If you wish to keep an eye on your usage in case of errors then set logging to “file” and set up the path and filename of where to log the data. It defaults to “log_errors.txt” in the root folder of your site.

The code is based on the code behind the members page so if that page works then first check to see if the request has been blocked for security reasons. The console in the developer bar/window of your browser is the place to look.

There’s a test page to let you play around with various combinations of values that can be passed to the code.

Terms

The data provided by the API has been generated from the ratification forms of the Club’s members. It is provided here under the Creative Commons License, specifically the Attributed, Non-Commercial, Share-alike license.

This means that you are permitted to:

In addition the license imposes the following limitations...

Summary

Hopefully the above will help you get started in embedding “live” BGR data on your website. The data that this code uses is updated around the end of each year unless there’s a number of errata to publish. If anything isn’t clear then contact the Membership Secretary, details on the contacts page.