appscript.dev
Automation Intermediate Calendar Docs Drive

Export a printable monthly calendar PDF

Generate a Northwind schedule document each month — for the studio noticeboard.

Published Aug 21, 2025

Northwind keeps a paper schedule on the studio noticeboard — partly nostalgia, partly because a visitor walking in should be able to see what is on without asking. The handwritten version was a mess of crossings-out by week three of every month, and printing the calendar from the Google UI strips out all the context that makes the noticeboard worth reading.

This script renders the current month as a Google Doc grouped by day, then saves a PDF copy to Drive. Pin the PDF link in the studio Drive folder, send it to the printer on the first of every month, and the noticeboard is current without anybody typing dates by hand.

What you’ll need

  • Edit access to a Google Calendar (the script reads your default calendar).
  • Permission to create Docs and Drive files as the script owner.
  • A printer reachable from Drive — or you can just open the saved PDF and print from your browser.

The script

// The Drive folder ID the PDF should land in. Leave as null to drop it
// in My Drive, or paste a folder ID to keep months together.
const OUTPUT_FOLDER_ID = null;

// Format strings used in the document.
const MONTH_FORMAT = 'MMMM yyyy';
const DAY_HEADING_FORMAT = 'EEEE d';
const TIME_FORMAT = 'HH:mm';

/**
 * Reads the current month's events and produces a printable PDF
 * grouped by day. The intermediate Google Doc is left in Drive so
 * you can tweak the layout before reprinting.
 */
function monthlyPdf() {
  const tz = Session.getScriptTimeZone();
  const today = new Date();

  // 1. Build the month window. Day 0 of next month is the last day of
  //    this month — a small JavaScript trick that beats manual
  //    "30 vs 31" arithmetic.
  const start = new Date(today.getFullYear(), today.getMonth(), 1);
  const end = new Date(today.getFullYear(), today.getMonth() + 1, 0);
  end.setHours(23, 59, 59, 999);

  const events = CalendarApp.getDefaultCalendar().getEvents(start, end);

  // 2. Create a Doc titled "Calendar — June 2026". The doc URL is
  //    handy in the log for ad-hoc edits.
  const monthLabel = Utilities.formatDate(today, tz, MONTH_FORMAT);
  const doc = DocumentApp.create('Calendar — ' + monthLabel);
  const body = doc.getBody();

  // 3. Title at the top, big.
  body.appendParagraph(monthLabel)
    .setHeading(DocumentApp.ParagraphHeading.TITLE);

  // 4. Group events by day. The events come back in chronological
  //    order, so a running "current day" string is enough to detect
  //    when to insert a new heading.
  let day = '';
  for (const e of events) {
    const dayLabel = Utilities.formatDate(e.getStartTime(), tz, DAY_HEADING_FORMAT);
    if (dayLabel !== day) {
      body.appendParagraph(dayLabel)
        .setHeading(DocumentApp.ParagraphHeading.HEADING2);
      day = dayLabel;
    }

    const time = e.isAllDayEvent()
      ? 'all day'
      : Utilities.formatDate(e.getStartTime(), tz, TIME_FORMAT);
    body.appendParagraph(time + '  ' + e.getTitle());
  }

  if (!events.length) {
    body.appendParagraph('No events scheduled this month.');
  }

  doc.saveAndClose();

  // 5. Export the Doc to PDF and drop it in the chosen folder.
  const pdfBlob = DriveApp.getFileById(doc.getId()).getAs('application/pdf');
  pdfBlob.setName('Calendar — ' + monthLabel + '.pdf');

  const folder = OUTPUT_FOLDER_ID
    ? DriveApp.getFolderById(OUTPUT_FOLDER_ID)
    : DriveApp.getRootFolder();
  const pdfFile = folder.createFile(pdfBlob);

  Logger.log('Created ' + pdfFile.getUrl());
}

How it works

  1. The script anchors the current month with the first-of-month / day-zero trick — new Date(year, month + 1, 0) gives the last day of the current month without needing a switch on 28/30/31.
  2. getEvents reads every event whose interval touches the month, recurring instances expanded. Events come back in chronological order, which is what makes the grouping step simple.
  3. A new Google Doc is created with a title like “Calendar — June 2026”. The document title sits at the top in the TITLE heading style.
  4. The loop tracks the current day-of-month label in a day variable. When the next event’s day differs, a HEADING2 paragraph is appended; otherwise the event lines just stack under the heading already in place.
  5. Each event becomes a paragraph of time title. All-day events read “all day” instead of a clock time.
  6. After saveAndClose, the script fetches the Doc as a PDF blob via DriveApp.getFileById(...).getAs('application/pdf') and drops the PDF into the configured folder (or My Drive by default). The Doc itself stays in Drive, so you can tweak and re-export if you spot a typo.

Example run

For a calendar with a handful of events in June 2026, the resulting PDF contains:

Calendar — June 2026

Monday 1 09:30 Standup 14:00 Client kickoff — Patel

Tuesday 2 10:00 Northwind weekly sync

Wednesday 3 all day Deep work day — no meetings

Thursday 4 14:00 Appointment: Priya Patel

Print that to A4 and it fits a normal noticeboard without further design work.

Trigger it

This is a once-per-month job, so a monthly time-driven trigger is the right fit:

  1. In the Apps Script editor, open Triggers.
  2. Add a trigger for monthlyPdf, event source Time-driven, type Month timer, on the first of the month, time 6am to 7am.

If you prefer to print on the last working day of the previous month for the month ahead, change today to new Date(today.getFullYear(), today.getMonth() + 1, 1) so the window covers the next month instead.

Watch out for

  • Recurring events are expanded into one paragraph per instance. A weekly standup will appear four or five times in the month, which is what you want for a noticeboard — but it makes the document longer than the calendar UI suggests.
  • The intermediate Doc lives in My Drive (or the configured folder) and is not cleaned up. Over a year you accumulate twelve, plus their PDFs. Add a setTrashed(true) call on the Doc if you only care about the PDF.
  • Formatting is plain — title, day headings, plain paragraphs. For a prettier noticeboard, edit the Doc once, save it as a template, then switch the script to clone the template and fill in paragraphs rather than creating a Doc from scratch.
  • The script reads only your default calendar. Pull from getAllOwnedCalendars() and merge if the noticeboard should reflect the whole studio’s schedule.

Related