Custom Calendar Widget

Hi! I discovered Scriptable through Jason Snell, and I got hooked. I’m a Javascript novice, so no idea how good my code is. I made a very simple calendar widget because I mostly like the built-in one, but I made a few edits. (These are just my personal preferences.)

  • Titles are smaller, so more text fits
  • When there’s only one event, show the entire title
  • I already vaguely know how long my meetings are, remove end times to reduce clutter
  • When I’m done with events for the day, don’t stress me out with tomorrow’s events or say “No more events”. Instead, show a nice image.

You can modify IMAGE_SOURCE to be either “Bing” or “Unsplash”, and optionally enter comma-separated search terms for Unsplash in the TERMS variable.

mz_calendar

10 Likes

your calendar which it looks amazing, but I’m not able to get to work on my phone. Keeps telling me that I need to allow access and system settings.

1 Like

You will need to give access to your calendar, in order for the widget to display calendar events. I did realize that if you’ve never given the Scriptable app calendar access before, you’ll need to run the script in test mode before it’ll work in the widget.

To do that, open the script in the Scriptable app. At the top, change const TEST_MODE = false to const TEST_MODE = true, then run the script (play button icon). At that point it’ll prompt you to give calendar access, and then show a preview of the widget. After you’ve done that, you can change it back to const TEST_MODE = false and then add the script to the widget. Hope that helps!

2 Likes

Thank you so much :sweat_smile: I’m an idiot at coding but admire all of y’alls work!

2 Likes

Happy to help! And fun projects like widgets are a perfect way to get more comfortable with coding :blush:

1 Like

Hi Max, I installed your widget last night and it was showing an Unsplash picture at first but now it’s just a blank image. I have tasks today in my calendar and none yesterday, is it affecting the background?

Thank you for taking time to make this and answer.

Hi Thinh, my intention was for the image to show once all events are over for the day, since I prefer to have the clean background when I’m displaying events. Here’s a version that keeps the photo all day, might need some design tweaks:

// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;
const IMAGE_SOURCE = "Unsplash"
const TERMS = "wallpaper"

const FORCE_IMAGE_UPDATE = false
const TEST_MODE = false

// Store current datetime
const date = new Date()

// If we're running the script normally, go to the Calendar.
if (!config.runsInWidget && !TEST_MODE) {
  const appleDate = new Date('2001/01/01')
  const timestamp = (date.getTime() - appleDate.getTime()) / 1000
  const callback = new CallbackURL("calshow:"+timestamp)
  callback.open()
  Script.complete()
  
// Otherwise, create the widget.  
} else {

  let widget = new ListWidget()
  
  // Format the date info
  let df = new DateFormatter()
  df.dateFormat = "EEEE"
  let dayOfWeek = widget.addText(df.string(date).toUpperCase())
  let dateNumber = widget.addText(date.getDate().toString())
  dayOfWeek.font = Font.semiboldSystemFont(13)
  dateNumber.font = Font.lightSystemFont(34)
  dayOfWeek.textColor = Color.white()
  dateNumber.textColor = Color.white()
  
  // Find future events that aren't all day and aren't canceled
  const events = await CalendarEvent.today([])
  let futureEvents = []
  for (const event of events) {
      if (event.startDate.getTime() > date.getTime() && !event.isAllDay && !event.title.startsWith("Canceled:")) {
          futureEvents.push(event)  
      }
  }
  
  // Look for the image file
  let files = FileManager.local()
  const path = files.documentsDirectory() + "/calendar_widget.jpg"
  const modificationDate = files.modificationDate(path) 
  
  // Download image if it doesn't exist, wasn't created today, or update is forced
  if (!modificationDate || !sameDay(modificationDate,date) || FORCE_IMAGE_UPDATE) {
     try {
        let img = await provideImage(IMAGE_SOURCE,TERMS)
        files.writeImage(path,img)
        widget.backgroundImage = img
     } catch { 
        widget.backgroundImage = files.readImage(path)
     }
  } else {
     widget.backgroundImage = files.readImage(path)  
  }
  
  // If there is at least one future event today
  if (futureEvents.length != 0) {

    widget.addSpacer()
    
    let titleOne = widget.addText(futureEvents[0].title) 
    titleOne.font = Font.mediumSystemFont(14)
    titleOne.textColor = Color.white()
    
    widget.addSpacer(7)
    
    let timeOne = widget.addText(formatTime(futureEvents[0].startDate))
    timeOne.font = Font.regularSystemFont(14)
    timeOne.textColor = Color.lightGray()
    
    // Add bigger overlay
    let gradient = new LinearGradient()
    gradient.colors = [new Color("#000000",0.75), new Color("#000000",0)]
    gradient.locations = [0, 1]
    widget.backgroundGradient = gradient
   
    // If we have multiple future events, show the following one
    if (futureEvents.length > 1) {
      
        // We only have room for single-line event names
        titleOne.lineLimit = 1
        
        widget.addSpacer(12)
        
        let titleTwo = widget.addText(futureEvents[1].title) 
        titleTwo.font = Font.mediumSystemFont(14)
        titleTwo.textColor = Color.white()
        titleTwo.lineLimit = 1
        
        widget.addSpacer(7)
        
        let timeTwo = widget.addText(formatTime(futureEvents[1].startDate))
        timeTwo.font = Font.regularSystemFont(14)
        timeTwo.textColor = Color.lightGray()
    }
      
  // If there are no future events today
  } else {
      
      // Add 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

      widget.addSpacer()
  }
  
  // Finalize widget settings
  widget.setPadding(16,16,16,0)
  widget.spacing = -3
  
  Script.setWidget(widget)
  widget.presentSmall()
  Script.complete() 
}

// 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/500x500/?"+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()
}

// Formats the times under each event
function formatTime(date) {
    let df = new DateFormatter()
    df.useNoDateStyle()
    df.useShortTimeStyle()
    return df.string(date)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}
1 Like

Thank you for taking the time to write that! I actually love it here, probably thinking of changing the gradient only to make the text easier to read in some cases but this is brilliant.

2 Likes

Like others, I can’t get enough of tweaking @mzeryck’s calendar widget to be exactly the way I want.

In case it’s useful for others, here’s a version that squeezes three events in, and lets you color-code the text and times. I use multiple calendars and differentiate by color, so this helps me see at a glance if my next appointments are work, personal, family, etc.

I also set some of the font sizes and colors at the top of the scripts, to make it easier to uniformly change the appearance of the items.
Edit: Now you can optionally make the first appointment and the next two different sizes. You can also adjust the spacing between items, and between items and times, using constants.

It also only displays events for calendars defined in the “colors” object defined at the top of the script. I did this because I have several calendars I normally hide – and the widget kept showing mysterious events from a “Found in Natural Language” calendar, which I didn’t want to see.

Right now it doesn’t do anything different for the medium and large size widgets. But with the new tools in the latest beta, I’m sure I’ll get to that soon!


// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;

// This widget was created by Max Zeryck @mzeryck with minor 

const IMAGE_SOURCE = "Unsplash"
const TERMS = "nature,water"

const FORCE_IMAGE_UPDATE = false
const TEST_MODE = true

// Store current datetime
const date = new Date()

// One stop text sizes
const daySize = 14
const dateSize = 30
const titleSize = 12
const timeSize = 10
const doneSize = 36

// change to make first item different
const titleSizeOne = titleSize
const timeSizeOne = timeSize

// spacer between time and next item
// values from 5-12 work well
const itemSpacer = 8

// spacer between title and time
// try values a little smaller than itemSpacer
const intraSpacer = 6

// set time indent here
// try spaces, bullets, emoji, etc. 
const indent = "  "

// store calendar colors
colors = {
  "Personal": Color.blue(),
  "Work": Color.green(),
  "Family": Color.orange(),
  "Jenny & Theo": Color.purple(),
  "Unfiled": Color.red()
}


// If we're running the script normally, go to the Calendar.
if (!config.runsInWidget && !TEST_MODE) {
  const appleDate = new Date('2001/01/01')
  const timestamp = (date.getTime() - appleDate.getTime()) / 1000
  const callback = new CallbackURL("calshow:"+timestamp)
  callback.open()
  Script.complete()
  
// Otherwise, create the widget.  
} else {

  let widget = new ListWidget()
  
  // Format the date info
  let df = new DateFormatter()
  df.dateFormat = "EEEE"
  let dayOfWeek = widget.addText(df.string(date).toUpperCase());
  let dateNumber = widget.addText(date.getDate().toString())
  dayOfWeek.font = Font.semiboldSystemFont(11)
  dateNumber.font = Font.lightSystemFont(dateSize)
  
  // Find future events that aren't all day and aren't canceled
  const events = await CalendarEvent.today([])
  let futureEvents = []
  for (const event of events) {
      if (event.startDate.getTime() >= date.getTime() && !event.isAllDay && !event.title.startsWith("Canceled:") && (event.calendar.title in colors)) {
          futureEvents.push(event)
      }
  }
  
  // If there is at least one future event today
  if (futureEvents.length != 0) {

    dayOfWeek.textColor = Color.red()
    widget.addSpacer(7)
    
    let titleOne = widget.addText(futureEvents[0].title) 
    titleOne.font = Font.semiboldSystemFont(titleSizeOne)
    
    let eventCal = futureEvents[0].calendar
    let colorOne = colors[eventCal.title]
    titleOne.textColor  = colorOne
    
    widget.addSpacer(intraSpacer)
    
    let timeOne = widget.addText(indent + formatTime(futureEvents[0].startDate))
    timeOne.font = Font.lightSystemFont(timeSizeOne)
    timeOne.textColor = colorOne
    widget.addSpacer(itemSpacer)
    // If we have multiple future events, show the following one
    if (futureEvents.length > 1) {
      
        // We only have room for single-line event names
        titleOne.lineLimit = 1
        
        let titleTwo = widget.addText(futureEvents[1].title) 
        titleTwo.font = Font.semiboldSystemFont(titleSize)
        titleTwo.lineLimit = 1
        
        eventCal = futureEvents[1].calendar
//         console.log(eventCal)
        let colorTwo = colors[eventCal.title]
        titleTwo.textColor = colorTwo
        
        widget.addSpacer(intraSpacer)
        
        let timeTwo = widget.addText(indent + formatTime(futureEvents[1].startDate))
        timeTwo.font = Font.lightSystemFont(timeSize)
        timeTwo.textColor = colorTwo
        widget.addSpacer(itemSpacer)
    }
    if (futureEvents.length >2) {
      let titleThree = widget.addText(futureEvents[2].title) 
      titleThree.font = Font.semiboldSystemFont(titleSize)
    
    eventCal = futureEvents[2].calendar
    let colorThree = colors[eventCal.title]
    titleThree.textColor = colorThree
    
    widget.addSpacer(intraSpacer)
    
    let timeThree = widget.addText(indent + formatTime(futureEvents[2].startDate))
    timeThree.font = Font.lightSystemFont(timeSize)
    timeThree.textColor = colorThree
    }
  widget.addSpacer()
  // If there are no future events today
  } else {
      dayOfWeek.textColor = Color.white()
      dateNumber.textColor = Color.white()
      
      let files = FileManager.local()
      const path = files.joinPath(files.documentsDirectory(), "mz_calendar_widget.jpg")
      const modificationDate = files.modificationDate(path) 
      
      // Download image if it doesn't exist, wasn't created today, or update is forced
      if (!modificationDate || !sameDay(modificationDate,date) || FORCE_IMAGE_UPDATE) {
         try {
            let img = await provideImage(IMAGE_SOURCE,TERMS)
            files.writeImage(path,img)
            widget.backgroundImage = img
         } catch { 
            widget.backgroundImage = files.readImage(path)
         }
      } else {
         widget.backgroundImage = files.readImage(path)  
      }
      
      // Add overlay to image
      let gradient = new LinearGradient()
      gradient.colors = [new Color("#000000",0.5), new Color("#000000",0)]
      gradient.locations = [0, 0.5]
      widget.backgroundGradient = gradient

      widget.addSpacer()
  }
  
  // Finalize widget settings
  widget.setPadding(16,16,16,0)
  widget.spacing = -3
  
  Script.setWidget(widget)
  widget.presentSmall()
  Script.complete() 
}

// 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/500x500/?"+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()
}

// Formats the times under each event
function formatTime(date) {
    let df = new DateFormatter()
    df.useNoDateStyle()
    df.useShortTimeStyle()
    return df.string(date)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}

6 Likes

Is it also possible to show all day events at the first row with no time?

It wouldn’t be too difficult — there’s currently a condition to ignore all-day events. I can add a constant at the top to determine whether to do that, or to include all-day events. Will tackle that later today if I can.

1 Like

This is awesome! I love the improvements with moving the colors and sizing options to be configurable at the top.

2 Likes

Here’s yet another version of my take on @mzeryck’s calendar widget. This one makes a couple more changes: 1. All-day events can optionally be shown, by setting a constant at the top; 2. you can set how many items you want in the widget – that way, if you make the text size smaller, you can fit more in.

Other features: Show only specific calendars, color-code events by calendar, set separate text size for event titles and times (including optionally setting the first item to be larger), and adjust the spacing between each item and/or between items and their times. All adjustments use constants defined (and commented) at the top of the script.

image


// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;

// This widget was created by Max Zeryck @mzeryck with minor 

const IMAGE_SOURCE = "Unsplash"
const TERMS = "nature,water"

const FORCE_IMAGE_UPDATE = false
const TEST_MODE = true

// Store current datetime
const date = new Date()

// how many events do you want?
const event_count = 3

// One stop text sizes
const daySize = 14
const dateSize = 30
const titleSize = 10
const timeSize = 10
const doneSize = 36

// next two lines ensure the first item is the same size as others 
var titleSizeOne = titleSize
var timeSizeOne = timeSize

// uncomment the next two lines to make first item a different size
// titleSizeOne = 14
// timeSizeOne = 11

// spacer between time and next item
// values from 5-12 work well
const itemSpacer = 8

// spacer between title and time
// try values a little smaller than itemSpacer
const intraSpacer = 6

// set time indent here
// try spaces, bullets, emoji, etc. 
const indent = "  "

// store calendar colors using the name as shown in the Calendar app
colors = {
  "Unfiled": Color.red(),
  "TestCal1": Color.purple(),
  "TestCal2": Color.blue(),
  "TestCal3": Color.green()
}

// set to true if you want to hide all-day events
hideAllDay = false


// If we're running the script normally, go to the Calendar.
if (!config.runsInWidget && !TEST_MODE) {
  const appleDate = new Date('2001/01/01')
  const timestamp = (date.getTime() - appleDate.getTime()) / 1000
  const callback = new CallbackURL("calshow:"+timestamp)
  callback.open()
  Script.complete()
  
// Otherwise, create the widget.  
} else {

  let widget = new ListWidget()
  
  // Format the date info
  let df = new DateFormatter()
  df.dateFormat = "EEEE"
  let dayOfWeek = widget.addText(df.string(date).toUpperCase());
  let dateNumber = widget.addText(date.getDate().toString())
  dayOfWeek.font = Font.semiboldSystemFont(11)
  dateNumber.font = Font.lightSystemFont(dateSize)
  
  // Find future events that aren't all day and aren't canceled
  const events = await CalendarEvent.today([])
  let futureEvents = []
  for (const event of events) {
      if (hideAllDay) {
        if (event.startDate.getTime() >= date.getTime() && !event.isAllDay && !event.title.startsWith("Canceled:") && (event.calendar.title in colors)) {
          futureEvents.push(event)
        }
      }
      else {
        if ((event.calendar.title in colors) && (event.isAllDay || (event.startDate.getTime() >= date.getTime() && !event.title.startsWith("Canceled:")))){
          futureEvents.push(event)
        }
    }
  }
  
  // initialize conditional variables to use in the loop
  var titleSizeNow;
  var timeSizeNow;
  
  // If there is at least one future event today
  if (futureEvents.length != 0) {
    dayOfWeek.textColor = Color.red()
    widget.addSpacer(7)
    
    // repeat creation for maximum of event_count
    
    if (event_count > futureEvents.length) {
      var max_items = futureEvents.length; 
    }
    else {
      var max_items = event_count;
    }
    var i;
    for (i=0;i < max_items; i++) {
      
      if (i==0) {
        titleSizeNow = titleSizeOne;
        timeSizeNow = timeSizeOne; 
      }
      else {
        titleSizeNow = titleSize;
        timeSizeNow = timeSize; 
      }
      
      let title = widget.addText(futureEvents[i].title) 
      title.font = Font.semiboldSystemFont(titleSizeNow)
      
      let eventCal = futureEvents[i].calendar
      let color = colors[eventCal.title]
      title.textColor  = color
          
      widget.addSpacer(intraSpacer)  
      
      if (!futureEvents[i].isAllDay) {
        var timeMessage = (formatTime(futureEvents[i].startDate))
      }
      else {
        var timeMessage = "all day"
      }
      let time = widget.addText(indent + timeMessage)
      time.font = Font.lightSystemFont(timeSize)
      time.textColor = color
      widget.addSpacer(itemSpacer)
    } // end for event_count
  widget.addSpacer()

  // If there are no future events today
  } else {
      dayOfWeek.textColor = Color.white()
      dateNumber.textColor = Color.white()
      
      let files = FileManager.local()
      const path = files.joinPath(files.documentsDirectory(), "mz_calendar_widget.jpg")
      const modificationDate = files.modificationDate(path) 
      
      // Download image if it doesn't exist, wasn't created today, or update is forced
      if (!modificationDate || !sameDay(modificationDate,date) || FORCE_IMAGE_UPDATE) {
         try {
            let img = await provideImage(IMAGE_SOURCE,TERMS)
            files.writeImage(path,img)
            widget.backgroundImage = img
         } catch { 
            widget.backgroundImage = files.readImage(path)
         }
      } else {
         widget.backgroundImage = files.readImage(path)  
      }
      
      // Add overlay to image
      let gradient = new LinearGradient()
      gradient.colors = [new Color("#000000",0.5), new Color("#000000",0)]
      gradient.locations = [0, 0.5]
      widget.backgroundGradient = gradient

      widget.addSpacer()
  }
  
  // Finalize widget settings
  widget.setPadding(16,16,16,0)
  widget.spacing = -3
  
  Script.setWidget(widget)
  widget.presentSmall()

  Script.complete() 
}

// 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/500x500/?"+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()
}

// Formats the times under each event
function formatTime(date) {
    let df = new DateFormatter()
    df.useNoDateStyle()
    df.useShortTimeStyle()
    return df.string(date)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}
4 Likes

This. Is. Perfect! The only thing I reallly want to see is that the Unsplash picture is always visible. Can you add a parameter for that in the top?

I can try! Probably in a day or so.

Easier than expected, but I don’t know that it’s very readable unless you pick yourself colors and image carefully. If I can make it more readable with text shadows, I’ll edit this post.

image


// Variables used by Scriptable.
// These must be at the very top of the file. Do not edit.
// icon-color: red; icon-glyph: calendar-alt;

// This widget was created by Max Zeryck @mzeryck with minor 

const IMAGE_SOURCE = "Unsplash"
const TERMS = "nature,water"

const FORCE_IMAGE_UPDATE = false
const TEST_MODE = true

// always show the image?
const always_img = true

// Store current datetime
const date = new Date()

// how many events do you want?
const event_count = 3

// One stop text sizes
const daySize = 14
const dateSize = 30
const titleSize = 10
const timeSize = 10
const doneSize = 36

// next two lines ensure the first item is the same size as others 
var titleSizeOne = titleSize
var timeSizeOne = timeSize

// uncomment the next two lines to make first item a different size
titleSizeOne = 14
timeSizeOne = 11

// spacer between time and next item
// values from 5-12 work well
const itemSpacer = 8

// spacer between title and time
// try values a little smaller than itemSpacer
const intraSpacer = 6

// set time indent here
// try spaces, bullets, emoji, etc. 
const indent = "  "

// store calendar colors
colors = {
  "Unfiled": Color.red(),
  "TestCal1": Color.purple(),
  "TestCal2": Color.blue(),
  "TestCal3": Color.green()
}

// set to true if you want to hide all-day events
hideAllDay = false


// If we're running the script normally, go to the Calendar.
if (!config.runsInWidget && !TEST_MODE) {
  const appleDate = new Date('2001/01/01')
  const timestamp = (date.getTime() - appleDate.getTime()) / 1000
  const callback = new CallbackURL("calshow:"+timestamp)
  callback.open()
  Script.complete()
  
// Otherwise, create the widget.  
} else {

  let widget = new ListWidget()
  
  // Format the date info
  let df = new DateFormatter()
  df.dateFormat = "EEEE"
  let dayOfWeek = widget.addText(df.string(date).toUpperCase());
  let dateNumber = widget.addText(date.getDate().toString())
  dayOfWeek.font = Font.semiboldSystemFont(11)
  dateNumber.font = Font.lightSystemFont(dateSize)
  
  // Find future events that aren't all day and aren't canceled
  const events = await CalendarEvent.today([])
  let futureEvents = []
  for (const event of events) {
      if (hideAllDay) {
        if (event.startDate.getTime() >= date.getTime() && !event.isAllDay && !event.title.startsWith("Canceled:") && (event.calendar.title in colors)) {
          futureEvents.push(event)
        }
      }
      else {
        if ((event.calendar.title in colors) && (event.isAllDay || (event.startDate.getTime() >= date.getTime() && !event.title.startsWith("Canceled:")))){
          futureEvents.push(event)
        }
    }
  }
  
  // initialize conditional variables to use in the loop
  var titleSizeNow;
  var timeSizeNow;
  
  // If there's at least 1 future event today
  if (futureEvents.length != 0) {
    dayOfWeek.textColor = Color.red()
    widget.addSpacer(7)
    
    // repeat creation for max of event_count
    
    if (event_count > futureEvents.length) {
      var max_items = futureEvents.length; 
    }
    else {
      var max_items = event_count;
    }
    var i;
    for (i=0;i < max_items; i++) {
      
      if (i==0) {
        titleSizeNow = titleSizeOne;
        timeSizeNow = timeSizeOne; 
      }
      else {
        titleSizeNow = titleSize;
        timeSizeNow = timeSize; 
      }
      
      let title = widget.addText(futureEvents[i].title) 
      title.font = Font.semiboldSystemFont(titleSizeNow)
      
      let eventCal = futureEvents[i].calendar
      let color = colors[eventCal.title]
      title.textColor  = color
          
      widget.addSpacer(intraSpacer)  
      
      if (!futureEvents[i].isAllDay) {
        var timeMessage = (formatTime(futureEvents[i].startDate))
      }
      else {
        var timeMessage = "all day"
      }
      let time = widget.addText(indent + timeMessage)
      time.font = Font.lightSystemFont(timeSize)
      time.textColor = color
      widget.addSpacer(itemSpacer)
    } // end for event_count
  widget.addSpacer()

  // if no events or image should alwasy show
  } 
  if (always_img || futureEvents.length == 0) {
      dayOfWeek.textColor = Color.white()
      dateNumber.textColor = Color.white()
      
      let files = FileManager.local()
      const path = files.joinPath(files.documentsDirectory(), "mz_calendar_widget.jpg")
      const modificationDate = files.modificationDate(path) 
      
      // Download image if it doesn't exist, wasn't created today, or update is forced
      if (!modificationDate || !sameDay(modificationDate,date) || FORCE_IMAGE_UPDATE) {
         try {
            let img = await provideImage(IMAGE_SOURCE,TERMS)
            files.writeImage(path,img)
            widget.backgroundImage = img
         } catch { 
            widget.backgroundImage = files.readImage(path)
         }
      } else {
         widget.backgroundImage = files.readImage(path)  
      }
      
      // Add overlay to image
      let gradient = new LinearGradient()
      gradient.colors = [new Color("#000000",0.5), new Color("#000000",0)]
      gradient.locations = [0, 0.5]
      widget.backgroundGradient = gradient

      widget.addSpacer()
  }
  
  // Finalize widget settings
  widget.setPadding(16,16,16,0)
  widget.spacing = -3
  
  Script.setWidget(widget)
  widget.presentSmall()

  Script.complete() 
}

// 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/500x500/?"+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()
}

// Formats the times under each event
function formatTime(date) {
    let df = new DateFormatter()
    df.useNoDateStyle()
    df.useShortTimeStyle()
    return df.string(date)
}

// Determines if two dates occur on the same day
function sameDay(d1, d2) {
  return d1.getFullYear() === d2.getFullYear() &&
    d1.getMonth() === d2.getMonth() &&
    d1.getDate() === d2.getDate()
}

1 Like

I am loving this calendar script. I’m trying to get it to show the event end time as well, but for the life of me I can’t. All I’m getting is a repeat of the start date. Any idea what I’m doing wrong?

// 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 = "Jimmy"
const VISIBLE_CALENDARS = ["Cal 1", "Cal 2", "Cal 3"]
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 = "All done." // 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 = false
const IMAGE_SOURCE = "Unsplash" // options are Bing and Unsplash
const IMAGE_SEARCH_TERMS = "nature,water"
const FORCE_IMAGE_UPDATE = true // whether to update the image on every refresh
const BACKGROUND_COLOR = new Color("#1c1c1e")
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 = {
    "Cal 1": Color.blue(),
    "Cal 2": Color.green(),
    "Cal 3": Color.brown(),
}
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 itemDateEnd = itemStack.addText(formatItemDate(itemsToShow[i]))
            itemDateEnd.font = new Font(ITEM_TIME_FONT, ITEM_TIME_SIZE)
            itemDateEnd.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 formatItemDateEnd(item) {
    DATE_FORMATTER.dateFormat = "hh:mma"
    if (item.dateIncludesTime === true) {
        return DATE_FORMATTER.string(item.endDate) // 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()
}
1 Like

I think you have a bug here. let itemDateEnd = itemStack.addText(formatItemDate(itemsToShow[i])) should say let itemDateEnd = itemStack.addText(formatItemDateEnd(itemsToShow[i]))

But also in your end function, you probably don’t want to say “TO-DO” again.

1 Like

AMAZING. Thank you! Works a charm!

I was trying to use event.calendar or event.calendar.title in customizing it for me. But every time I run it the event.calendar returns undefined. I would like to indicate the source of the event, but I can’t find how to do that.