Huge shoutout to @mzeryck for getting me inspired with Scriptable.
One thing that I’ve been searching for in widgets is seeing BOTH my calendar events and timed reminders in one. Even Calendars 5, which is an old paid app I have, doesn’t show both in smaller widgets.
So I went ahead and modified the original script I found for my own liking! It’s totally customizable too: background images, colors, and font sizes. Works for both calendar and reminder accounts.
Note that the medium widget only works with the new Testflight beta. The small widget does also work in the medium size – it’s just not “optimized” for space and doesn’t have the cute greeting.
Here’s the code.
Medium:
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;
// NOTE: this currently only works in beta because it takes advantage of the widgetStack and URL scheme for on-tap.
// TEST MODE: run the first time with it true to get access to your calendars; you can also run the sample Scriptable overdue Reminders script to get access to your reminders
const TEST_MODE = false
// CALENDAR/REMINDERS SETUP: calendar and reminder names should match what's shown in the Calendar and Reminder apps
const YOUR_NAME = "Tiberiu"
const VISIBLE_CALENDARS = ["Personal", "edu", "com"]
const VISIBLE_REMINDERS = ["Reminders", "School"]
const CALENDAR_URL = "calendars://open" // For Calendars 5
const REMINDERS_URL = "calendars://open" // For Calendars 5
const NUM_ITEMS_TO_SHOW = 4 // 4 is the max without it being cramped
const NO_ITEMS_MESSAGE = "Enjoy it." // what's displayed when you have no items for the day
// COLOR SETUP: you can choose the background image and color, and all text colors
// NOTE: nothing changes with light/dark mode, everything is static
const USE_BACKGROUND_IMAGE = true
const IMAGE_SOURCE = "Unsplash" // options are Bing and Unsplash
const IMAGE_SEARCH_TERMS = "nature,water"
const FORCE_IMAGE_UPDATE = false // whether to update the image on every refresh
const BACKGROUND_COLOR = new Color("#C3C4C8")
const GREETING_COLOR = new Color("#eeeeee")
const DATE_COLOR = Color.red()
const ITEM_NAME_COLOR = Color.white()
const ITEM_TIME_COLOR = new Color("#eeeeee")
// NOTE: All calendars must have a color mapping, or else they'll show up white
const CALENDAR_COLORS = {
"Personal": Color.blue(),
"edu": new Color("#3f51b5"), // blueberry
"com": new Color("#e67c73") // flamingo
}
const REMINDER_COLORS = {
"Reminders": Color.yellow(),
"School": new Color("#3f51b5") // blueberry
}
// FONT SETUP
const GREETING_SIZE = 16
const ITEM_NAME_SIZE = 14
const ITEM_TIME_SIZE = 12
const ITEM_TIME_FONT = "Menlo-Regular"; // Monospace font so the names are aligned
// INTERNAL CONSTS
const DATE_FORMATTER = new DateFormatter()
const NOW = new Date()
// If we're running the script normally, go to the set calendar app
if (!config.runsInWidget && !TEST_MODE) {
const appleDate = new Date('2001/01/01')
const timestamp = (NOW.getTime() - appleDate.getTime()) / 1000
const callback = new CallbackURL(CALENDAR_URL + timestamp)
callback.open()
Script.complete()
} else { // Otherwise, work on the widget
// Collect events and reminders to show
// Store custom objects here with the fields: id, name, startDate, endDate, dateIncludesTime, isReminder, calendarTitle
let itemsToShow = []
// Find future events that aren't all day, aren't canceled, and are part of the calendar list
const events = await CalendarEvent.today([])
for (const event of events) {
if (event.endDate.getTime() > NOW.getTime()
&& VISIBLE_CALENDARS.includes(event.calendar.title)
&& !event.isAllDay && !event.title.startsWith("Canceled:")) {
itemsToShow.push({
id: event.identifier,
name: event.title,
startDate: event.startDate,
endDate: event.endDate,
dateIncludesTime: true,
isReminder: false,
calendarTitle: event.calendar.title
})
}
}
// Find today's reminders that are part of the reminder list
// NOTE: all-day reminders have their time set to 00:00 of the same day, but aren't returned with incompleteDueToday...
let queryStartTime = new Date(NOW)
queryStartTime.setDate(queryStartTime.getDate() - 1)
queryStartTime.setHours(23, 59, 59, 0)
let queryEndTime = new Date(NOW)
queryEndTime.setHours(23, 59, 59, 0)
const reminders = await Reminder.incompleteDueBetween(queryStartTime, queryEndTime)
for (const reminder of reminders) {
if (VISIBLE_REMINDERS.includes(reminder.calendar.title)) {
itemsToShow.push({
id: reminder.identifier,
name: reminder.title,
startDate: reminder.dueDate,
endDate: null,
dateIncludesTime: reminder.dueDateIncludesTime,
isReminder: true,
calendarTitle: reminder.calendar.title
})
}
}
// Sort and truncate them: events / timed reminders, in order, then all-day reminders
itemsToShow = itemsToShow.sort(sortItems).slice(0, NUM_ITEMS_TO_SHOW)
// Lay out the widget!
let widget = new ListWidget()
widget.backgroundColor = BACKGROUND_COLOR
// Add the top date and greeting
let topStack = widget.addStack()
topStack.layoutHorizontally()
topStack.topAlignContent()
// Greeting is left aligned, date is right aligned
let greetingStack = topStack.addStack()
let greeting = greetingStack.addText(getGreeting())
greeting.textColor = GREETING_COLOR
greeting.font = Font.lightSystemFont(GREETING_SIZE)
topStack.addSpacer()
let dateStack = topStack.addStack()
DATE_FORMATTER.dateFormat = "EEEE d"
let topDate = dateStack.addText(DATE_FORMATTER.string(NOW).toUpperCase())
topDate.textColor = DATE_COLOR
topDate.font = Font.semiboldSystemFont(GREETING_SIZE)
if (USE_BACKGROUND_IMAGE === true) {
// Look for the image file
let files = FileManager.local()
const path = files.documentsDirectory() + "/up_next_medium.jpg"
const modificationDate = files.modificationDate(path)
// Download image if it doesn't exist, wasn't created this hour, or update is forced
if (!modificationDate || !sameHour(modificationDate, NOW) || FORCE_IMAGE_UPDATE) {
try {
let img = await provideImage(IMAGE_SOURCE, IMAGE_SEARCH_TERMS)
files.writeImage(path, img)
widget.backgroundImage = img
} catch {
widget.backgroundImage = files.readImage(path)
}
} else {
widget.backgroundImage = files.readImage(path)
}
}
// Put all of the event items on the bottom
widget.addSpacer()
// If there is at least one item today
if (itemsToShow.length > 0) {
if (USE_BACKGROUND_IMAGE === true) {
// Add a darker overlay
let gradient = new LinearGradient()
gradient.colors = [new Color("#000000", 0.75), new Color("#000000", 0.15)]
gradient.locations = [0, 1]
widget.backgroundGradient = gradient
}
for (i = 0; i < itemsToShow.length; i++) {
// Add space between events
if (i != 0) {
widget.addSpacer(12)
}
// Add nested stacks so everything aligns nicely...
let itemStack = widget.addStack()
itemStack.layoutHorizontally()
itemStack.centerAlignContent()
itemStack.url = getItemUrl(itemsToShow[i])
let itemDate = itemStack.addText(formatItemDate(itemsToShow[i]))
itemDate.font = new Font(ITEM_TIME_FONT, ITEM_TIME_SIZE)
itemDate.textColor = ITEM_TIME_COLOR
itemStack.addSpacer(12)
let itemPrefix = itemStack.addText(formatItemPrefix(itemsToShow[i]))
itemPrefix.font = Font.semiboldSystemFont(ITEM_NAME_SIZE)
itemPrefix.textColor = getItemColor(itemsToShow[i])
itemStack.addSpacer(4)
let itemName = itemStack.addText(formatItemName(itemsToShow[i]))
itemName.lineLimit = 1
itemName.font = Font.semiboldSystemFont(ITEM_NAME_SIZE)
itemName.textColor = ITEM_NAME_COLOR
}
} else { // If there are no more items today
if (USE_BACKGROUND_IMAGE === true) {
// Add a more minimal overlay
let gradient = new LinearGradient()
gradient.colors = [new Color("#000000", 0.5), new Color("#000000", 0)]
gradient.locations = [0, 0.5]
widget.backgroundGradient = gradient
}
// Simple message to show you're done
let message = widget.addText(NO_ITEMS_MESSAGE)
message.textColor = ITEM_NAME_COLOR
message.font = Font.lightSystemFont(ITEM_NAME_SIZE)
}
// Finalize widget settings
widget.setPadding(16, 16, 16, 16)
widget.spacing = -3
Script.setWidget(widget)
widget.presentSmall()
Script.complete()
}
// WIDGET TEXT HELPERS
function getGreeting() {
let greeting = "Good "
if (NOW.getHours() < 6) {
greeting = greeting + "night, "
} else if (NOW.getHours() < 12) {
greeting = greeting + "morning, "
} else if (NOW.getHours() < 17) {
greeting = greeting + "afternoon, "
} else if (NOW.getHours() < 21) {
greeting = greeting + "evening, "
} else {
greeting = greeting + "night, "
}
return greeting + YOUR_NAME + "."
}
function sortItems(first, second) {
if (first.dateIncludesTime === false && second.dateIncludesTime === false) {
return 0
} else if (first.dateIncludesTime === false) {
return 1
} else if (second.dateIncludesTime === false) {
return -1
} else {
return first.startDate - second.startDate
}
}
function formatItemDate(item) {
DATE_FORMATTER.dateFormat = "hh:mma"
if (item.dateIncludesTime === true) {
return DATE_FORMATTER.string(item.startDate) // always 7 chars
} else {
return "TO-DO " // Not a TODO in the code, literally return that
}
}
function formatItemName(item) {
return item.name
}
function formatItemPrefix(item) {
if (item.isReminder === false) {
return "▐ "
} else {
return "□"
}
}
function getItemUrl(item) {
if (item.isReminder === false) {
return CALENDAR_URL + item.id
} else {
return REMINDERS_URL + item.id
}
}
function getItemColor(item) {
if (item.isReminder === true) {
return REMINDER_COLORS[item.calendarTitle]
} else {
return CALENDAR_COLORS[item.calendarTitle]
}
}
// BACKGROUND IMAGE HELPERS
// Helper function to interpret sources and terms
async function provideImage(source, terms) {
if (source === "Bing") {
const url = "http://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US"
const req = new Request(url)
const json = await req.loadJSON()
const imgURL = "http://bing.com" + json.images[0].url
const img = await downloadImage(imgURL)
const rect = new Rect(-78, 0, 356, 200)
return cropImage(img, rect)
} else if (source === "Unsplash") {
const img = await downloadImage("https://source.unsplash.com/featured/?" + terms)
return img
}
}
// Helper function to download images
async function downloadImage(url) {
const req = new Request(url)
return await req.loadImage()
}
// Crop an image into a rect
function cropImage(img, rect) {
let draw = new DrawContext()
draw.respectScreenScale = true
draw.drawImageInRect(img, rect)
return draw.getImage()
}
// Determines if two dates occur on the same hour
function sameHour(d1, d2) {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate() &&
d1.getHours() === d2.getHours()
}
Small:
// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;
// NOTE: this widget does not need beta, and does not actually yet support opening the proper calendar URL on-tap. (widgetStack required to use that)
// TEST MODE: run the first time with it true to get access to your calendars; you can also run the sample Scriptable overdue Reminders script to get access to your reminders
const TEST_MODE = false
// CALENDAR/REMINDERS SETUP: calendar and reminder names should match what's shown in the Calendar and Reminder apps
const VISIBLE_CALENDARS = ["Personal", "edu", "com"]
const VISIBLE_REMINDERS = ["Reminders", "School"]
const CALENDAR_URL = "calendars://open" // For Calendars 5
const REMINDERS_URL = "calendars://open" // For Calendars 5
const NUM_ITEMS_TO_SHOW = 3 // 3 is the max without it being cramped
const NO_ITEMS_MESSAGE = "Enjoy it." // what's displayed when you have no items for the day
// COLOR SETUP: you can choose the background image and color, and all text colors
// NOTE: nothing changes with light/dark mode, everything is static
const USE_BACKGROUND_IMAGE = true
const IMAGE_SOURCE = "Unsplash" // options are Bing and Unsplash
const IMAGE_SEARCH_TERMS = "nature,water"
const FORCE_IMAGE_UPDATE = false // whether to update the image on every refresh
const BACKGROUND_COLOR = new Color("#111111")
const DATE_COLOR = Color.red()
const ITEM_NAME_COLOR = Color.white()
const ITEM_TIME_COLOR = new Color("#eeeeee")
// NOTE: All calendars must have a color mapping, or else they'll show up white
const CALENDAR_COLORS = {
"Personal": Color.blue(),
"edu": new Color("#3f51b5"), // blueberry
"com": new Color("#e67c73") // flamingo
}
const REMINDER_COLORS = {
"Reminders": Color.yellow(),
"School": new Color("#3f51b5") // blueberry
}
// FONT SETUP
const DATE_SIZE = 16
const ITEM_NAME_SIZE = 14
const ITEM_TIME_SIZE = 12
// INTERNAL CONSTS
const DATE_FORMATTER = new DateFormatter()
const NOW = new Date()
// If we're running the script normally, go to the set calendar app
if (!config.runsInWidget && !TEST_MODE) {
const appleDate = new Date('2001/01/01')
const timestamp = (NOW.getTime() - appleDate.getTime()) / 1000
const callback = new CallbackURL(CALENDAR_URL + timestamp)
callback.open()
Script.complete()
} else { // Otherwise, work on the widget
// Collect events and reminders to show
// Store custom objects here with the fields: id, name, startDate, endDate, dateIncludesTime, isReminder, calendarTitle
let itemsToShow = []
// Find future events that aren't all day, aren't canceled, and are part of the calendar list
const events = await CalendarEvent.today([])
for (const event of events) {
if (event.endDate.getTime() > NOW.getTime()
&& VISIBLE_CALENDARS.includes(event.calendar.title)
&& !event.isAllDay && !event.title.startsWith("Canceled:")) {
itemsToShow.push({
id: event.identifier,
name: event.title,
startDate: event.startDate,
endDate: event.endDate,
dateIncludesTime: true,
isReminder: false,
calendarTitle: event.calendar.title
})
}
}
// Find today's reminders that are part of the reminder list
// NOTE: all-day reminders have their time set to 00:00 of the same day, but aren't returned with incompleteDueToday...
let queryStartTime = new Date(NOW)
queryStartTime.setDate(queryStartTime.getDate() - 1)
queryStartTime.setHours(23, 59, 59, 0)
let queryEndTime = new Date(NOW)
queryEndTime.setHours(23, 59, 59, 0)
const reminders = await Reminder.incompleteDueBetween(queryStartTime, queryEndTime)
for (const reminder of reminders) {
if (VISIBLE_REMINDERS.includes(reminder.calendar.title)) {
itemsToShow.push({
id: reminder.identifier,
name: reminder.title,
startDate: reminder.dueDate,
endDate: null,
dateIncludesTime: reminder.dueDateIncludesTime,
isReminder: true,
calendarTitle: reminder.calendar.title
})
}
}
// Sort and truncate them: events / timed reminders, in order, then all-day reminders
itemsToShow = itemsToShow.sort(sortItems).slice(0, NUM_ITEMS_TO_SHOW)
// Lay out the widget!
let widget = new ListWidget()
widget.backgroundColor = BACKGROUND_COLOR
// Add the top date
DATE_FORMATTER.dateFormat = "EEEE d"
let topDate = widget.addText(DATE_FORMATTER.string(NOW))
topDate.textColor = DATE_COLOR
topDate.font = Font.semiboldSystemFont(DATE_SIZE)
if (USE_BACKGROUND_IMAGE === true) {
// Look for the image file
let files = FileManager.local()
const path = files.documentsDirectory() + "/up_next_widget_small.jpg"
const modificationDate = files.modificationDate(path)
// Download image if it doesn't exist, wasn't created this hour, or update is forced
if (!modificationDate || !sameHour(modificationDate, NOW) || FORCE_IMAGE_UPDATE) {
try {
let img = await provideImage(IMAGE_SOURCE, IMAGE_SEARCH_TERMS)
files.writeImage(path, img)
widget.backgroundImage = img
} catch {
widget.backgroundImage = files.readImage(path)
}
} else {
widget.backgroundImage = files.readImage(path)
}
}
// Put all of the event items on the bottom
widget.addSpacer()
// If there is at least one item today
if (itemsToShow.length > 0) {
if (USE_BACKGROUND_IMAGE === true) {
// Add a darker overlay
let gradient = new LinearGradient()
gradient.colors = [new Color("#000000", 0.75), new Color("#000000", 0.15)]
gradient.locations = [0, 1]
widget.backgroundGradient = gradient
}
for (i = 0; i < itemsToShow.length; i++) {
// Add space between events
if (i != 0) {
widget.addSpacer(10)
}
// TODO: create a widgetStack and add the URL
let itemName = widget.addText(formatItemName(itemsToShow[i]))
itemName.lineLimit = 1
itemName.font = Font.semiboldSystemFont(ITEM_NAME_SIZE)
itemName.textColor = ITEM_NAME_COLOR
widget.addSpacer(5)
let itemDate = widget.addText(formatItemDate(itemsToShow[i]))
itemDate.font = Font.mediumSystemFont(ITEM_TIME_SIZE)
itemDate.textColor = getItemColor(itemsToShow[i])
}
} else { // If there are no more items today
if (USE_BACKGROUND_IMAGE === true) {
// Add a more minimal overlay
let gradient = new LinearGradient()
gradient.colors = [new Color("#000000", 0.5), new Color("#000000", 0)]
gradient.locations = [0, 0.5]
widget.backgroundGradient = gradient
}
// Simple message to show you're done
let message = widget.addText(NO_ITEMS_MESSAGE)
message.textColor = ITEM_NAME_COLOR
message.font = Font.lightSystemFont(ITEM_NAME_SIZE)
}
// Finalize widget settings
widget.setPadding(12, 12, 12, 0)
widget.spacing = -3
Script.setWidget(widget)
widget.presentSmall()
Script.complete()
}
// WIDGET TEXT HELPERS
function sortItems(first, second) {
if (first.dateIncludesTime === false && second.dateIncludesTime === false) {
return 0
} else if (first.dateIncludesTime === false) {
return 1
} else if (second.dateIncludesTime === false) {
return -1
} else {
return first.startDate - second.startDate
}
}
function formatItemDate(item) {
if (item.dateIncludesTime === true) {
if (item.isReminder === false) {
DATE_FORMATTER.dateFormat = "hh:mm"
let startDate = DATE_FORMATTER.string(item.startDate)
DATE_FORMATTER.dateFormat = "hh:mma"
let endDate = DATE_FORMATTER.string(item.endDate)
return "▐ " + startDate + "—" + endDate
} else {
DATE_FORMATTER.dateFormat = "hh:mma"
let startDate = DATE_FORMATTER.string(item.startDate)
return "□ " + startDate
}
} else {
return "□ TO-DO" // Not a TODO in the code, literally return that
}
}
function getItemColor(item) {
if (item.isReminder === true) {
return REMINDER_COLORS[item.calendarTitle]
} else {
return CALENDAR_COLORS[item.calendarTitle]
}
}
function formatItemName(item) {
return item.name
}
function getItemUrl(item) {
if (item.isReminder === false) {
return CALENDAR_URL + item.id
} else {
return REMINDERS_URL + item.id
}
}
// BACKGROUND IMAGE HELPERS
// Helper function to interpret sources and terms
async function provideImage(source, terms) {
if (source === "Bing") {
const url = "http://www.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=en-US"
const req = new Request(url)
const json = await req.loadJSON()
const imgURL = "http://bing.com" + json.images[0].url
const img = await downloadImage(imgURL)
const rect = new Rect(-78, 0, 356, 200)
return cropImage(img, rect)
} else if (source === "Unsplash") {
const img = await downloadImage("https://source.unsplash.com/featured/?" + terms)
return img
}
}
// Helper function to download images
async function downloadImage(url) {
const req = new Request(url)
return await req.loadImage()
}
// Crop an image into a rect
function cropImage(img, rect) {
let draw = new DrawContext()
draw.respectScreenScale = true
draw.drawImageInRect(img, rect)
return draw.getImage()
}
// Determines if two dates occur on the same hour
function sameHour(d1, d2) {
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate() &&
d1.getHours() === d2.getHours()
}
Hope this helps people!