Last active
April 29, 2025 23:26
-
-
Save pixelthing/47a65ac75faa12457d63c763ce6d8b0b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// JS SCRIPT TO USE WITH SCRIPTABLE APP + iOS14+ WIDGETS | |
// | |
// Requires Scriptable1.5+ and iOS14/iPadOS14 | |
// | |
// Download and install Scriptableapp https://scriptable.app | |
// Add this script into Scriptable, then add a scriptable widget to your homescreen | |
// (only the smallest size has been tested), hold down on the widget, then tap "edit widget", | |
// select *this* script as the "Script File", and in the *Parmameter* input, paste in | |
// the API key you use to allow access to the data feed. | |
// config | |
let apiKey = args.widgetParameter; // the API key (set in the widget param UI) sent to URL below. | |
// colours (background gradient start/end and complementary text colour) | |
let goalStartColor = '#beed58'; | |
let goalEndColor = '#576e23'; | |
let goalTextColor = '#ffffff'; | |
let midStartColor = '#ffd54f'; | |
let midEndColor = '#bd8f03'; | |
let midTextColor = '#000000'; | |
let loseStartColor = '#ff8484'; | |
let loseEndColor = '#8f3535'; | |
let loseTextColor = '#ffffff'; | |
// URLs | |
let url1 = `https://sheets.googleapis.com/v4/spreadsheets/SPREADSHEETURL/values/SHEETNAME!`; | |
let urlThisSoFar = `B4:B4`; | |
let urlThisBad = `B5:B5`; | |
let urlThisHourly = `E3:E101`; | |
let urlPrevSoFar = `B13:B13`; | |
let urlPrevTotal = `B14:B14`; | |
let urlPrevBad = `B15:B15`; | |
let urlPrevHourly = `M3:M101`; | |
// to get access to the spreadsheet, you need to create an app at | |
// https://console.cloud.google.com/apis/dashboard | |
// then add some api credentials | |
let url2 = `?key=${apiKey}`; | |
// create widget instance | |
let w = new ListWidget() | |
let data = { | |
thisSoFar: 0, | |
thisBad: 0, | |
thisHourly: [], | |
prevSoFar: 0, | |
prevTotal: 0, | |
prevBad: 0, | |
prevHourly: [] | |
} | |
// handler if data retrieval is successful | |
const processSuccess = function( | |
jsonThisSoFar, | |
jsonThisBad, | |
jsonPrevSoFar, | |
jsonPrevTotal, | |
jsonPrevBad, | |
jsonThisHourly, | |
jsonPrevHourly | |
) { | |
const hourlyCollate = (array) => { | |
const output = new Array(24); | |
output.fill(0); | |
if (array && array.length) { | |
array.forEach(d => { | |
const hour = parseInt(d[0]); | |
output[hour]++; | |
}); | |
} | |
return output; | |
} | |
// parse data for totals | |
data = { | |
thisSoFar: jsonThisSoFar.values[0], | |
thisBad: jsonThisBad.values[0], | |
thisHourly: hourlyCollate(jsonThisHourly.values), | |
prevSoFar: jsonPrevSoFar.values[0], | |
prevTotal: jsonPrevTotal.values[0], | |
prevBad: jsonPrevBad.values[0], | |
prevHourly: hourlyCollate(jsonPrevHourly.values) | |
} | |
console.log(data) | |
console.log('today ' + data.thisSoFar) | |
console.log('prev ' + data.prevSofar) | |
// format the widget | |
// determine colour schemes determined by how close to the goal we are | |
let startColor = midStartColor; | |
let endColor = midEndColor; | |
let textColor = midTextColor; | |
if (parseInt(data.thisSoFar) > parseInt(data.prevSoFar)) { | |
startColor = goalStartColor; | |
endColor = goalEndColor; | |
textColor = goalTextColor; | |
} else if (parseInt(data.thisSoFar) < parseInt(data.prevSoFar)) { | |
startColor = loseStartColor; | |
endColor = loseEndColor; | |
textColor = loseTextColor; | |
} | |
// set background (depending on context) | |
let gradient = new LinearGradient(); | |
gradient.colors = [new Color(startColor), new Color(endColor)]; | |
gradient.locations = [0.0, 1]; | |
w.backgroundGradient = gradient; | |
w.addSpacer(5); | |
// title row | |
let titleStack = w.addStack(); | |
titleStack.layoutHorizontally(); | |
titleStack.centerAlignContent(); | |
// title icon | |
// try and find an icon from https://sfsymbols.com | |
let extra1Icon = titleStack.addImage(SFSymbol.named('star.circle.fill').image); | |
extra1Icon.tintColor = new Color(textColor); | |
extra1Icon.imageSize = new Size(25, 25); | |
titleStack.addSpacer(5); | |
// title text: number label | |
let extra1Txt = titleStack.addText('Metric') | |
extra1Txt.textColor = new Color(textColor); | |
extra1Txt.font = Font.boldSystemFont(14); | |
w.addSpacer(5); | |
//Draw chart | |
let graphImg = columnGraph(data.thisHourly, 110, 20).getImage(); | |
let chartImg = w.addImage(graphImg); | |
chartImg.resizable = false; | |
// big number row | |
let bigNumStack = w.addStack(); | |
bigNumStack.layoutHorizontally(); | |
bigNumStack.bottomAlignContent(); | |
// big number: number | |
let bigNumTxt = bigNumStack.addText(data.thisSoFar + '') | |
bigNumTxt.textColor = new Color(textColor); | |
bigNumTxt.font = Font.lightSystemFont(40); | |
bigNumStack.addSpacer(3); | |
// big number: suffix | |
let bigNumSuffixStack = bigNumStack.addStack() | |
bigNumSuffixStack.layoutVertically(); | |
let bigNumSuffixHorizStack = bigNumSuffixStack.addStack(); | |
bigNumSuffixHorizStack.layoutHorizontally(); | |
bigNumSuffixHorizStack.centerAlignContent(); | |
let bigNumSuffix = bigNumSuffixHorizStack.addText('today') | |
bigNumSuffix.textColor = new Color(textColor); | |
bigNumSuffix.font = Font.mediumSystemFont(12); | |
bigNumSuffixHorizStack.addSpacer(2); | |
// big number: note if bad results | |
if (parseInt(data.thisBad) > 0) { | |
let bigNumBadIcon = bigNumSuffixHorizStack.addImage(SFSymbol.named('exclamationmark.triangle.fill').image); | |
bigNumBadIcon.tintColor = new Color(textColor,0.4); | |
bigNumBadIcon.imageSize = new Size(12, 12); | |
bigNumSuffixHorizStack.addSpacer(2); | |
let bigNumBadTxt = bigNumSuffixHorizStack.addText(data.thisBad + '') | |
bigNumBadTxt.textColor = new Color(textColor,0.4); | |
bigNumBadTxt.font = Font.mediumSystemFont(12); | |
} | |
bigNumSuffixStack.addSpacer(2); | |
// yesterday: previous day number at this point | |
let extra2Txt = bigNumSuffixStack.addText('yest ' + data.prevSoFar + '/' + data.prevTotal) | |
extra2Txt.textColor = new Color(textColor); | |
extra2Txt.font = Font.mediumSystemFont(12); | |
bigNumSuffixStack.addSpacer(7); | |
function columnGraph(data, width, height) { | |
let maxValueInArray = Math.max(...data); | |
console.log('max: ' + maxValueInArray) | |
let mean = 0 | |
let max = maxValueInArray; | |
let dataLength = data.length; | |
let currentHour = new Date(); | |
currentHour = currentHour.getHours(); | |
let context = new DrawContext(); | |
context.size = new Size(width, height); | |
context.opaque = false; | |
context.respectScreenScale = true; | |
for (let i = 0; i < dataLength; i++) { | |
let colorRect; | |
colorRect = textColor; | |
let w = width / (2 * dataLength - 1); | |
let h = (data[i] / max) * height; | |
if (!h) { | |
h = 2; | |
} | |
console.log(i + ' h: ' + h + '(' + data[i] + ',' + height + ',' + max + ')') | |
let x = (i * 2 + 1) * w; | |
let y = height - h; | |
context.setFillColor(new Color(colorRect)); | |
if (i > currentHour - 1) { | |
context.setFillColor(new Color(colorRect,0.3)); | |
} | |
const path = new Path(); | |
path.addRoundedRect(new Rect(x, y, w, h), 2, 2); | |
context.addPath(path); | |
context.fillPath(); | |
} | |
return context; | |
} | |
// set widget | |
Script.setWidget(w); | |
Script.complete(); | |
} | |
// handler if data retrieval is UNsuccessful | |
const processError = function(err) { | |
// format error message | |
let errorScript = (err + '').replace('Error: ',''); | |
// set background | |
let gradient = new LinearGradient(); | |
gradient.colors = [new Color(loseStartColor), new Color(loseEndColor)]; | |
gradient.locations = [0.0, 1]; | |
w.backgroundGradient = gradient | |
// set text: title | |
let titleTxt = w.addText('error') | |
titleTxt.textColor = Color.white() | |
titleTxt.font = Font.mediumSystemFont(24) | |
// set text: message | |
let extra1Txt = w.addText(errorScript) | |
extra1Txt.textColor = Color.white() | |
extra1Txt.font = Font.mediumSystemFont(12) | |
// set widget | |
Script.setWidget(w); | |
Script.complete(); | |
} | |
// run the whole thing | |
try { | |
// no API key? error out now. | |
if (!apiKey) { | |
processError('enter API key in parameter.') | |
console.log( 'no API key ' + apiKey ) | |
return; | |
// start data requests and process them | |
} else { | |
let reqThisSoFar = new Request(url1 + urlThisSoFar + url2); | |
let reqThisBad = new Request(url1 + urlThisBad + url2); | |
let reqThisHourly = new Request(url1 + urlThisHourly + url2); | |
let reqPrevSoFar = new Request(url1 + urlPrevSoFar + url2); | |
let reqPrevTotal = new Request(url1 + urlPrevTotal + url2); | |
let reqPrevBad = new Request(url1 + urlPrevBad + url2); | |
let reqPrevHourly = new Request(url1 + urlPrevHourly + url2); | |
let jsonThisSoFar = await reqThisSoFar.loadJSON(); | |
let jsonThisBad = await reqThisBad.loadJSON(); | |
let jsonThisHourly = await reqThisHourly.loadJSON(); | |
let jsonPrevSoFar = await reqPrevSoFar.loadJSON(); | |
let jsonPrevTotal = await reqPrevTotal.loadJSON(); | |
let jsonPrevBad = await reqPrevBad.loadJSON(); | |
let jsonPrevHourly = await reqPrevHourly.loadJSON(); | |
console.log('data fetched') | |
processSuccess(jsonThisSoFar, jsonThisBad, jsonPrevSoFar, jsonPrevTotal, jsonPrevBad, jsonThisHourly, jsonPrevHourly); | |
} | |
// if any data requests or parsing fails, error out here. | |
} catch (err) { | |
processError(err) | |
console.log( 'offline? data source down? ' + err ) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment