Jan 19, 2021 04:39 PM
Happy 2021 everyone! A new year, brings new projects to manage. And where would project managers be without their handy burndown charts? Well, with a bit of scripting and the vega-lite app, you can now make a burndown chart in Airtable!
For the uninitiated (aka me until two weeks ago), burndown charts “show the total effort against the amount of work for each iteration.” Meaning, they help you answer the question “are we completing our tasks on schedule?” They usually look something like this:
The line represents the planned tasks remaining, and the bars represent actual tasks remaining. if your bars are below the line that means you’re getting things done on time. The X axis can be grouped by the cadence in which you’re tracking - which could be days, weeks, sprints, etc.
Until recently, we couldn’t create burndown charts in Airtable because the native chart app doesn’t support combo/multi-field charts. But now with the Vega-Lite App (in beta), we have much more control over our charts and can achieve the elusive burndown!
To create a burndown chart, you need to know a couple things :
If you already have a list of tasks, you probably have all of this information in the following date fields in your Tasks table:
The tricky part about burndown charts are their cumulativeness, and because of this we’re actually going to need to create a new table called Burndown with the following fields.
Using the below script, we can use the fields from our Tasks table to calculate the information needed for our burndown chart and table. Additionally, the script outlines the code you’ll need to paste into the Vega-Lite app to render a burndown chart with helpful tooltips. This script uses script settings, so you should just be able to plug-and-play :crossed_fingers:t5:
/*
WHAT THIS SCRIPT "SHOULD" DO: Take a table of tasks with deadlines and calculate how many open tasks are available each interval by:
If Starting from Scratch:
1. Calculate the number of intervals between the Start Date (End date of First Task) and the
Max Date (End Date of last task), and a start date for each interval
2. Create Records for each interval in the burndown table
3. For each interval, calculate the cumulative number tasks expected to be open by that specific date. For example,
let's say your project runs from January to June. You expect the following open tasks for each month:
Jan: 600,
Feb: 500,
Mar: 400,
Apr: 300,
May: 200,
Jun: 100
The 100 tasks in June should be counted in each previous month. To get this number, we'll want to iterate through
intervals, and get the number of tasks that are due after the current time interval.
4. For each interval, calculate the cumulative number tasks that were actually open at the time of thtat interval.
To do this, we'll need to filter by the "Datet Completed" find when a task was open until.
If Updating:
- I'm not trying to think about this now
*/
// Settings - use the settinigs panel to configure
let settings = input.config({
title: 'Burndown Calculation',
description: 'This script will help you calculate the values needed to plot a burndown chart on the vega lite app. Your output table should have a linked field to the input table, and two number fields to calulate the # of tasks',
items:[
input.config.select('cadence',{
label:'Chart Cadence/Interval',
description: "Select the interval by which you'd like to track burndown",
options: [
{label: 'Daily', value: "1"},
{label: 'Weekly', value: "7"},
{label: 'Monthly', value: "30"},
{label: 'Quarterly', value:"90"}
]
}),
input.config.table('inputTable',{
label: 'Input Table',
description: "The table where you're tracking tasks and dates",
}),
input.config.table('outputTable',{
label: 'Output Table',
description: "The table that will hold the figures for the burndown chart",
}),
input.config.field('inputEndDate',{
parentTable: 'inputTable',
label: 'End Date',
description: "The field that is tracking dates in your input table"
}),
input.config.field('completedDate',{
parentTable: 'inputTable',
label: 'Completed Date',
description: "The field that tracks when a task was completed"
}),
input.config.field('intervalDate', {
parentTable: 'outputTable',
label: 'Interval Date',
description: 'Field where date of Interval will be stored'
}),
// input.config.field('tasksClosed', {
// parentTable: 'outputTable',
// label: 'Tasks Closed This Interval',
// description: 'Capture the number of tasks closed in the currrent interval'
// }),
input.config.field('plannedOpen', {
parentTable: 'outputTable',
label: 'Planned Open Tasks',
description: "Field where you'll capture the # of tasks that should be open for the week"
}),
input.config.field('actualOpen', {
parentTable: 'outputTable',
label: 'Actual Open Tasks',
description: "Field where you'll capture the current # of open tasks"
})
]
})
// 🍀 Load Input Table Records
let inputTableQuery = await settings.inputTable.selectRecordsAsync({fields:[settings.completedDate, settings.inputEndDate]})
let inputRecords = inputTableQuery.records
// 🍀 Later I'll figure out how to make thtis update friendly, right now I'm deleting everything in thte input table
let outputTableQuery = await settings.outputTable.selectRecordsAsync()
await batchAnd('Delete',settings.outputTable, outputTableQuery.records)
// 🍀 Find the number of intervals by getting the # of days between today/maxDate and dividing by the cadence.
let startDate = new Date(Math.min.apply(null,inputRecords.filter(a => a.getCellValue(settings.inputEndDate)).map( r => new Date(r.getCellValue(settings.inputEndDate)))))
let maxDate = new Date(Math.max.apply(null,inputRecords.map( r => new Date(r.getCellValue(settings.inputEndDate)))))
let diffTime = Math.abs(startDate - maxDate)
let diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
let intervals = parseInt(diffDays/parseInt(settings.cadence))
// 🍀 Get dates for each interval
let create = []
while (create.length < intervals){
startDate = addDays(startDate,parseInt(settings.cadence))
create.push(startDate)
}
//🍀 Calculate Planned/Actual Open Records for each Interval
create = create.map(a => ({fields:{
[settings.intervalDate.id]: a,
[settings.plannedOpen.id]: inputRecords.filter( r => new Date(r.getCellValue(settings.inputEndDate)) > a).length,
[settings.actualOpen.id]: a > new Date() ? 0 : inputRecords.filter( r => new Date(r.getCellValue(settings.completedDate)) > a ).length + inputRecords.filter(r => r.getCellValue(settings.completedDate) == null).length
}
}))
await batchAnd('Create',settings.outputTable,create)
let vegaLiteOutput =
`
{
"$schema": "https://vega.github.io/schema/vega-lite/v4.json",
"title": "Functional Workplans",
"width": "container",
"height": "container",
"layer": [
{
"mark": {"type": "bar", "tooltip": true},
"encoding": {
"x": {
"field": "${settings.intervalDate.name}",
"type": "temporal"
},
"y": {
"field": "${settings.actualOpen.name}",
"type": "quantitative"
}
}
},
{
"mark": {"type": "line", "point": true, "tooltip": true},
"encoding": {
"x": {
"field": "${settings.intervalDate.name}",
"type": "temporal"
},
"y": {
"field": "${settings.plannedOpen.name}",
"type": "quantitative"
},
"color": {
"value": "red"
}
}
}
]
}`
output.markdown(`## Paste the below snippet into the Vega Lite Editor`)
output.markdown('```'+vegaLiteOutput)
// Add Days function from StackOverflow
// Source: https://stackoverflow.com/questions/563406/add-days-to-javascript-date
function addDays(date, days) {
var result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/*
Author: Jeremy Oglesby
Use this function to perform 'Update', 'Create', or 'Delete'
async actions on batches of records that could potentially
more than 50 records.
::PARAMETERS::
action = string; one of 3 values:
- 'Update' to call table.updateRecordsAsync()
- 'Create' to call table.createRecordsAsync()
- 'Delete' to call table.deleteRecordsAsync()
table = Table; the table the action will be performed in
records = Array; the records to perform the action on
- Ensure the record objects inside the array are
formatted properly for the action you wish to
perform
::RETURNS::
recordsActedOn = integer, array of recordId's, or null;
- Update Success: integer; the number of records processed by the function
- Delete Success: integer; the number of records processed by the function
- Create Success: array; the id strings of records created by the function
- Failure: null;
*/
async function batchAnd(action, table, records) {
let recordsActedOn;
switch (action) {
case 'Update':
recordsActedOn = records.length;
while (records.length > 0) {
await table.updateRecordsAsync(records.slice(0, 50));
records = records.slice(50);
};
break;
case 'Create':
recordsActedOn = [];
while (records.length > 0) {
let recordIds = await table.createRecordsAsync(records.slice(0, 50));
recordsActedOn.push(...recordIds)
records = records.slice(50);
};
break;
case 'Delete':
recordsActedOn = records.length;
while (records.length > 0) {
await table.deleteRecordsAsync(records.slice(0, 50));
records = records.slice(50);
}
break;
default:
output.markdown(`**Please use either 'Update', 'Create', or 'Delete' as the "action" parameter for the "batchAnd()" function.**`);
recordsActedOn = null;
}
return recordsActedOn;
}
Some Notes/Things people can still do and I’d be grateful because I don’t know when I’ll have the time to revisit this:
Okay let me know how it goes and happy new year!