Skip to content

Instantly share code, notes, and snippets.

@thoukydides
Last active October 31, 2023 20:00
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thoukydides/9caf3aae0a2ebd96d1a2a387e34bf691 to your computer and use it in GitHub Desktop.
Save thoukydides/9caf3aae0a2ebd96d1a2a387e34bf691 to your computer and use it in GitHub Desktop.
An iOS Scriptable widget to display South Cambridgeshire bin collection dates
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: green; icon-glyph: trash-alt;
// South Cambridgeshire bin collection widget from iOS Scriptable
// Copyright © 2020 Alexander Thoukydides
'use strict';
// South Cambridgeshire District Council API configuration
// (reverse engineered from https://www.scambs.gov.uk/Scripts/bin-calendar.js)
const WEB_URL = 'https://www.scambs.gov.uk/bins/find-your-household-bin-collection-day/#id='
const API_URL = 'https://servicelayer3c.azure-api.net/wastecalendar/';
// Keychain key for storing the address identifier
const ID_KEY = 'SCDC Address ID';
// Retrieve any saved address identifier
let id;
if (Keychain.contains(ID_KEY)) id = Keychain.get(ID_KEY);
// Action depends on whether the script is being run within a Widge
if (config.runsInWidget) {
// Check that an address has been selected
if (!id) throw new Error('Run this script within Scriptable to select address');
// Create and display the widget
let widget = await createWidget(id, config.widgetFamily);
Script.setWidget(widget);
} else {
// Require an address to be selected on first use
if (!id) await selectAddress();
// Allow the widget to be previewed or the address changed
let index = id ? 0 : -1;
while (index != -1) {
// Construct a menu of available options
let alert = new Alert();
alert.title = Script.name();
let address = await getAddress(id);
alert.message = address;
let actions = [
{ text: 'Preview widget: Small', action: () => previewWidget('small') },
{ text: 'Preview widget: Medium', action: () => previewWidget('medium') },
{ text: 'Preview widget: Large', action: () => previewWidget('large') },
{ text: 'Change address', action: () => selectAddress(), destructive: true }
];
for (let action of actions) {
if (action.destructive) alert.addDestructiveAction(action.text);
else alert.addAction(action.text);
}
alert.addCancelAction('Cancel');
// Display the menu and perform the selected action
index = await alert.presentAlert();
if (index != -1) await actions[index].action();
}
}
// That's all folks!
Script.complete();
// Get a new address and store it in the Keychain
async function selectAddress() {
// Input a postcode
let postcodeAlert = new Alert();
postcodeAlert.title = Script.name();
postcodeAlert.message = 'Enter postcode';
postcodeAlert.addTextField('postcode');
postcodeAlert.addAction('Lookup postcode');
postcodeAlert.addCancelAction('Cancel');
let postcodeIndex = await postcodeAlert.presentAlert();
if (postcodeIndex == -1) return;
let postcode = postcodeAlert.textFieldValue(0).trim();
// Offer a list of matching addresses
console.log(postcode);
let addresses = await getAddresses(postcode);
let addressAlert = new Alert();
addressAlert.title = Script.name();
addressAlert.message = 'Select address';
for (let address of Object.values(addresses)) {
addressAlert.addAction(address);
}
addressAlert.addCancelAction('Cancel');
let addressIndex = await addressAlert.presentAlert();
if (addressIndex == -1) return;
id = Object.keys(addresses)[addressIndex];
// Save the address identifier in the Keychain
Keychain.set(ID_KEY, id);
}
// Lookup addresses with a particular postcode
async function getAddresses(postcode) {
let url = API_URL + 'address/search/?postCode=' + encodeURIComponent(postcode);
let request = new Request(url);
let result = await request.loadJSON();
console.log(result);
return Object.fromEntries(result.map(address => [address.id, formatAddress(address)]));
}
// Lookup the address for a particular identifier
async function getAddress(id) {
let url = API_URL + 'address/search/?id=' + id;
let request = new Request(url);
let result = await request.loadJSON();
console.log(result);
return formatAddress(result);
}
// Tidy an address returned by the API
function formatAddress(address) {
let street = [address.houseNumber + ' ' + address.street].join(' ');
let postcode = address.postCode.slice(0, -3) + ' ' + address.postCode.slice(-3);
return [titleCase(street), titleCase(address.town), postcode].join(', ');
}
// Get details of upcoming collections
async function getCollections(id, maxResults) {
let url = API_URL + 'collection/search/' + id + '/?numberOfCollections=' + maxResults;
let request = new Request(url);
let result = await request.loadJSON();
console.log(result);
return result.collections;
}
// Get URL for the web page
function getWebURL(id) {
return WEB_URL + id;
}
// Create and preview the widget
async function previewWidget(size) {
let widget = await createWidget(id, size);
switch (size) {
case 'small': await widget.presentSmall(); break;
case 'medium': await widget.presentMedium(); break;
case 'large': await widget.presentLarge(); break;
}
}
// Create the widget
async function createWidget(id, size) {
// Retrieve details of the upcoming bin collections
let collections = await getCollections(id, 3);
// Create the widget
let widget = new ListWidget();
// Add details of the next bin collection
let next = collections[0];
let nextBits = describeCollection(next);
if (size != 'small') nextBits.bins += ' will be collected';
let nextText = widget.addText(nextBits.bins);
nextText.font = Font.mediumSystemFont(14);
nextText.textColor = Color.black();
nextText.centerAlignText();
widget.addSpacer();
// Date of next bin collection
let dayText = widget.addText(nextBits.day);
dayText.font = Font.boldSystemFont(28);
dayText.textColor = next.slippedCollection ? Color.red() : Color.yellow();
dayText.centerAlignText();
let dateText = widget.addText(nextBits.date);
dateText.font = Font.mediumSystemFont(14);
dateText.textColor = Color.white();
dateText.centerAlignText();
widget.addSpacer();
// Add details of the following bin collection
if (size != 'small') {
let after = collections[1];
let afterBits = describeCollection(after);
let afterText = widget.addText(afterBits.bins + ' collected ' + afterBits.day + ' ' + afterBits.date);
afterText.font = Font.lightSystemFont(12);
afterText.textColor = after.slippedCollection ? Color.red() : Color.black();
afterText.centerAlignText();
}
// Pick a colour scheme for the widget
widget.backgroundColor = Color.black();
let gradient = new LinearGradient();
gradient.colors = coloursCollection(next);
gradient.locations = [0, 1];
widget.backgroundGradient = gradient;
// Set the URL to open if the widget is clicked
widget.url = getWebURL(id);
// Only need to refresh once a day
let today = new Date();
let tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
widget.refreshAfterDate = tomorrow;
// Return the widget
return widget;
}
// Textual description of a bin collection
function describeCollection(collection) {
// Describe the bins being collected
const ROUND_NAMES = { ORGANIC: 'Green', RECYCLE: 'Blue', DOMESTIC: 'Black' };
let names = collection.roundTypes.map(key => ROUND_NAMES[key]);
let binsText = names.join(' & ') + (names.length == 1 ? ' bin' : ' bins');
// Convert the collection date to text
let date = new Date(collection.date);
let dayText = isToday(date) ? 'TODAY'
: (isTomorrow(date) ? 'TOMORROW'
: date.toLocaleDateString(undefined, { weekday: 'long' }));
let dateText = date.toLocaleDateString(undefined, { month: 'long', day: 'numeric' });
// Return the results
return { bins: binsText, day: dayText, date: dateText };
}
// Colours for a bin collection
function coloursCollection(collection) {
const ROUND_COLOURS = { ORGANIC: '#77DD66', RECYCLE: '#6677FF', DOMESTIC: '#888888' };
let hexColours = collection.roundTypes.map(key => ROUND_COLOURS[key]);
let colours = hexColours.map(hex => new Color(hex));
if (colours.length == 1) colours.push(new Color(hexColours[0], 0.5));
return colours;
}
// Convert a string to title case
function titleCase(text) {
return text.toLowerCase().replaceAll(/\b\w/g, letter => letter.toUpperCase());
}
// Date comparisons
function isToday(date) {
let today = new Date();
return isSameDay(date, today);
}
function isTomorrow(date) {
let tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
return isSameDay(date, tomorrow);
}
function isSameDay(date1, date2) {
return date1.getFullYear() == date2.getFullYear()
&& date1.getMonth() == date2.getMonth()
&& date1.getDate() == date2.getDate();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment