Building a Free Automated MAS T-Bill Calendar

I finally had enough of manually checking the Monetary Authority of Singapore (MAS) T-Bill calendar from the official site. Just miss the buying window? Great! now I'll have to wait for another week or so.

Sometimes life gets in the way, then you forget, then you miss again. All that cash just sitting there doing nothing. I mean, there must be a better way to handle this, right?

💬
Context: MAS T-bills are considered safe investments. It has lower credit risk than fixed deposits, which makes it an attractive investment option (~3.8% in 2023). Check the historical MAS T-bill yield here.

Idea

Existing Solution

Before diving into any project, I always like to see if there are existing solutions that might fit the bill. In my search, I stumbled upon ilovessb.com, which supports email notifications.

That's a step in the right direction, but there's still the possibility of missing those email notifications. Plus, I don’t really feel like giving away my email.

Google Calendar

Google Calendar notifications on the other hand are pretty neat. It supports device notifications, emails, or both. So yeah, it’s a real winner!

Anyway, I generally start my week by looking at my Google Calendar to see what's ahead for the week. Naturally, it occurred to me that having the MAS T-bill issuance calendar sync to my Google Calendar would be a neat solution. Why not automate this process?

The idea is pretty straightforward:

  1. Get the necessary data (bonds/bills issuance information and dates)
  2. Generate calendars and events using that data
  3. Run this on a regular schedule (monthly/yearly)

This should be an easy one. After all, I've already dabbled in a similar project before.

Goal

The ultimate objective here is to create a calendar that I can easily share with anyone by simply providing them a link. The idea is to offer people the benefits of using this calendar without requiring them to give up their email or name.

Google Calendar example
Example of the final look. You can check out the source code on GitHub

Anyway, if you're not interested in the rambling and just want to import the calendars, here are the links to the calendars you can access:

Calendar Link
SGS Bonds Calendar Add calendar
6-Month T-Bill Calendar Add calendar
1-Year T-Bill Calendar Add calendar
Savings Bond Calendar Add calendar

The only potential downside to this is that if I accidentally mess things up and delete these calendars, users might have to subscribe to a new calendar with a new link. But as long as I don't touch it, it should be smooth sailing (famous last words I guess). Anyway…

Data Source

The first step involves figuring out how to obtain the data we need to create events for the calendar. There are 2 approaches:

  1. Inspecting the network requests made when visiting the issuance calendar site
  2. Scraping the site’s HTML

Option 1 is always my preferred method due to its more structured and reliable nature.

MAS API

After some digging around the network tabs, I was able to identify the API calls made, which opened the door for us to fetch the data.

Inspecting the network tabs of the MAS website
Inspecting the network tabs
⚠️
It's crucial to remember not to bombard them with unnecessary requests. A little courtesy goes a long way.

Then, I created a simple class that wraps around the respective MAS API requests. This file contains a class that wraps around the API requests. This makes it possible for us to interact with MAS API and gather the data required for our calendars later on.

If we want to fetch other data from other endpoints in the future, we can easily add support for those endpoints. No sweat!

Google Apps Script (GAS)

The core logic of Cron jobs (i.e. time-driven triggers), creating calendars, and events is written in TypeScript and deployed as a GAS project.

You can check out the code here.

Why Google Apps Script?

Well, there are a few reasons:

  1. It's free to run. This is an important point, especially since I plan to maintain this calendar for public use. Ensuring it's free is essential for its sustainability.
  2. Its Google Calendar API is super easy to use. No need to handle authentication like you would with its other languages SDKs. Dealing with that kind of stuff is an extra annoyance.
  3. It can be written in JavaScript/TypeScript, which is a language I'm already familiar with.

Also, since I haven't written any GAS project using TypeScript, I saw this as an opportunity to experiment with it. (The developer experience turned out great!)

💬
Oh, this project was bootstrapped and deployed following this Quickstart guide.

Challenges

Oddly enough, I find the most challenging part of this project is to write tests. Well, more specifically when it comes to writing tests for GAS projects.

Wait, why bother writing tests for a pet project?

Well, the number of “live” tiny projects that I currently maintain is slowly getting out of hand. I’d imagine coming back to this project a year later, trying to refactor, add a small feature, or fix a bug, and thinking, "Will I break this..?".

For me, writing tests reduces that mental toil. It provides a certain “safety net”, assuring me that the changes won't break existing functionality. It's an oddly comforting feeling.

That being said, I always try to strike a balance between testing and maintenance. I believe in writing enough tests to check for regressions, but I also think that testing can become a maintenance burden if taken to an extreme. I don’t want to end up in a scenario where I spend more time wrestling with these darn unit tests than actually fixing the bug or adding a feature!

Why is it hard?

Testing a GAS project can be tricky. When you run code locally (e.g. using Jest) with Node.js, it uses the Node.js runtime. However, when you deploy that code as a GAS, it runs in a V8 runtime. This difference can cause issues when it comes to testing.

Let’s look at an example. First, make sure you have jest and ts-jest installed. If not, you can install them using npm or yarn:

npm install --save-dev jest ts-jest @types/jest

Now, imagine you've written a function called createMonthlyTrigger, which, as the name suggests, is used to create a monthly time-driven using the GAS built-in ScriptApp library.

Here's the code example:

export function createMonthlyTrigger(): GoogleAppsScript.Script.Trigger {
    const triggers = ScriptApp.getProjectTriggers();
    for (const trigger of triggers) {
        const triggerExist = trigger.getHandlerFunction() === main.name;
        if (triggerExist) {
            Logger.log(`Trigger "${main.name}" already exists`);
            return trigger;
        }
    }

    Logger.log(`Creating a new monthly trigger`);
    return ScriptApp.newTrigger(main.name).timeBased().onMonthDay(1).atHour(1).create();
}

The catch here is that you can't simply run jest and expect it to work smoothly.

Instead, we need to mock the ScriptApp module since it's a GAS-specific library and not available in a typical Node.js environment. To achieve this, you can use a library like ts-jest with jest to mock ScriptApp.

Without mock, we will run into errors like ReferenceError: Logger is not defined.

Here's an example of how to do it:

import { createMonthlyTrigger } from "../src/index";

// Create a mock for ScriptApp
const mockScriptApp = {
    getProjectTriggers: jest.fn(),
    newTrigger: jest.fn().mockReturnThis(),
    timeBased: jest.fn().mockReturnThis(),
    onMonthDay: jest.fn().mockReturnThis(),
    atHour: jest.fn().mockReturnThis(),
    create: jest.fn(),
};

// Mock the Logger
const mockLogger = {
    log: jest.fn(),
};

// Mock the global objects
(global as any)["Logger"] = mockLogger;
(global as any)["ScriptApp"] = mockScriptApp;

describe("createMonthlyTrigger", () => {
    afterEach(() => {
        // Clear the mock calls after each test
        jest.clearAllMocks();
    });

    it("should return an existing trigger if one exists", () => {
        // Mock an existing trigger
        mockScriptApp.getProjectTriggers.mockReturnValue([
            {
                getHandlerFunction: jest.fn().mockReturnValue("main"),
            },
        ]);

        createMonthlyTrigger();
        expect(mockScriptApp.newTrigger).not.toHaveBeenCalled();
    });

    it("should create a new trigger if none exists", () => {
        // Mock no existing triggers
        mockScriptApp.getProjectTriggers.mockReturnValue([]);

        createMonthlyTrigger();
        expect(mockScriptApp.newTrigger).toHaveBeenCalled();
    });
});

Running jest now works! Yay!

🗨️
See the full example on GitHub.

DevOps

These are the stuff that I always aim to automate as much as possible.

Continuous Integration (CI)

Setting up CI for your GAS project is pretty much the same as you'd do for any other Node.js project. There's nothing special or unconventional about it.

Take a look at the ci.yaml file.

Manual Deployment

I highly recommend using clasp (GitHub) when working on your GAS project. It's a game-changer, making your life a whole lot easier. It streamlines the development and deployment process, making it a more enjoyable experience.

I've been thinking about adding a Continuous Deployment (CD) part in the future, which would automate the deployment process. Though, I'm not sure how often I'll be deploying updates anytime soon. This project feels quite "completed" for now, but who knows?

Release

I'm using semantic-release to automate my releases. However, in this project, I've disabled the part where it automatically publishes the codebase as an NPM package (I mean it’s not a library where you would import).

You can take a look at the release.yml and package.json if you're curious.

Dependency Update

Just like many of my other projects, I've set up automatic dependency updates using Renovate. It's great for keeping the project's dependencies fresh and up-to-date with minimal effort on my part.

The best part? With CI setup and tests, it adds another layer of confidence to make sure that any automated updates don't pose a big risk to the project's stability.

Closing Thoughts

So, there you have it! This project is short and sweet, but it has been on my to-do list for a while. It's one of those small, nagging issues that bother me a bit every day, and I'm happy to finally find a solution.

Thanks for reading!

Hosted on Digital Ocean.