Reading Time: 7 minutes

As I mentioned in part 1 and part 2, there are still some features missing that I would love to use for my calendar-to-mite script. Right now, the code adds all calendar entries as a time booking disregarding if you even attended (i.e., accepted) the meeting. There might also be some calendar entries that should be ignored – like my weekly calendar entry for our ‘Running Group’. Additionally, I want to have time bookings only in 15-minute steps. With this logic, a 50-minute meeting will be rounded up to one hour, or 25 minutes to 30.

In summary:

  • only add time booking entries for meetings you actively accepted
  • ignore meetings which have an ignore flag in your mapping file
  • round up durations to 15-minute steps

And with the preparation in part 2 and the first tests in part 3, I can quickly implement new features using the Test-Driven Development (TDD) approach.

So let’s grab a coffee and talk about: TDD!

https://media.giphy.com/media/NHUONhmbo448/source.gif

Test-Driven Development

There is a lot of great literature about Test-Driven Development (TDD), so I just want to make sure that every reader gets the idea and share a few links and book recommendations for you to dive deeper into this topic.

What is Test-Driven Development?

TDD is a programming paradigm where you develop your code driven by the (unit) tests you write first to define the requirements for your code. You start with a new (failing) test and try to implement the most straightforward implementation to make your tests pass. From there, you can refactor your code until you are happy with the result – making sure the tests still pass. This pattern is called Red-Green-Refactor.

Why TDD?

Writing your tests first and working in short iterations offers a lot of benefits. The first two are obvious: you make sure tests are being written, and you can also make sure that your code is implemented in a way that it is testable. Looking at what happened in part 1 and part 2, we could’ve started with writing tests first. By this, part 2 would have been utterly obsolete because the code were already in a form that it is testable. So TDD gives you a lot of opportunities to think about your code’s structure, and it provides confidence to refactor because you already have tests for your code. And you get the feedback immediately by letting your tests run whenever you change something. There’s a lot more to it that you can read in the links I will share at the end of the post.

When to do TDD?

In my opinion, test-driven development can help you boost your productivity and speed in some cases, but there are others where it simply doesn’t make sense to code test-driven. For example, I would not consider writing tests (first) for adding a new field to an admin form or changing what kind of label is sent to the template. On the other hand, everything regarding business logic and services is very suitable for TDD. You will get a personal feeling about when to do it, and if you haven’t tried TDD yet, you should definitely give it a try!

Filtering ignored calendar entries

Let’s say once a week, you have lunch with your team. To not get any meetings in that time slot, you want to have a “blocker” inside your calendar. Similarly, you might have calendar entries that you don’t want to book in your time tracking app (for example, your weekly Yoga session, running group, or your Friday’s office party).

So my first idea was to add a #ignore to the title and filter those out in the script. But then you have to customize events that you haven’t created and won’t get any new updates (because now, you have a customized event which is not in-sync with the original event). Instead, we can add new mappings to our .csv file and add a new column called ignore.

Let’s dive right in but this time, we will turn things around. We will start with the unit test and then add our implementation.

const eventFilter = require('../src/EventFilter')

describe('filterIgnoreMappings', () => {
    it('should return 2 events that are not ignored', () => {
        const mappings = [
            {keyword: '#ignore', projectId: 0, serviceId: 0, ignore: true},
            {keyword: '#123', projectId: 1234, serviceId: 4321, ignore: false},
            {keyword: '#456', projectId: 9876, serviceId: 5432, ignore: false}
        ];

        const events = [
            {summary: '#ignore'},
            {summary: '#123 test'},
            {summary: '#123 another test'},
            {summary: '#32145 #ignored'}
        ];

        const results = eventFilter.filterIgnoreMappings(events, mappings);

        expect(results.length).toBe(2);
    });
});

I created three fake mappings with a new ignore flag, additionally I created four events which should be filtered so only the two events that don’t include #ignore in their summary string should be returned. Obviously, this will fail because we don’t have an EventFilter class yet, so let’s add the most straightforward implementation possible:

class EventFilter {
    filterIgnoreMappings(events, mappings) {
        return [null, null];
    }
}

module.exports = new EventFilter();

Um, okay, it works but is it really what we wanted to achieve? It might be necessary to test that we get the correct events back from the filter method:

expect(results[0].summary).toBe('#123 test');
expect(results[1].summary).toContain('another test');
class EventFilter {
    filterIgnoreMappings(events, mappings) {
        let ignoreMappings = mappings.filter(mapping => mapping.ignore);

        return events.filter(event => {
            return ignoreMappings.filter(mapping => {
                return event.summary.indexOf(mapping.keyword.toLowerCase()) !== -1;
            }).length === 0;
        });
    }
}

module.exports = new EventFilter();

The test is still green, and we could now refactor the code and get immediate feedback if we broke the functionality. Instead, I want to add another test to make sure that the code is not somehow always returning some events, i.e., that it returns an empty result if all events are ignored:

it('should return 0 events because all are ignored', () => {
    const mappings = [
        {keyword: '#ignore', projectId: 0, serviceId: 0, ignore: true},
        {keyword: '#123', projectId: 1234, serviceId: 4321, ignore: true},
        {keyword: '#456', projectId: 9876, serviceId: 5432, ignore: false}
    ];

    const events = [
        {summary: '#ignore'},
        {summary: '#123 test'},
        {summary: '#123 another test'},
        {summary: '#32145 #ignored'}
    ];

    const results = eventFilter.filterIgnoreMappings(events, mappings);

    expect(results.length).toBe(0);
});

Filtering events you did not attend

Sometimes you get invited to events that you cannot attend. While you could remove them from your calendar, we want to spend the least amount of time creating our time entries. So instead, we filter these non-accepted events out.

Looking at Google’s documentation (https://developers.google.com/calendar/v3/reference/events#resource) we find that there are two situations that can occur:

  1. You are an attendee. We can find this out by iterating through the attendee’s list and check the self boolean flag.
  2. You are an organizer. That means you created the event yourself! In my understanding, those events should not be filtered out by the first check!
describe('filterMissedMeetings', () => {
    it('should only return the 1 meeting I attended', () => {
       const events = [
           {
               summary: 'event 1',
               attendees: [
                   {displayName: 'user A', self: false, responseStatus: 'accepted'},
                   {displayName: 'user B', self: false, responseStatus: 'accepted'},
                   {displayName: 'user ME', self: true, responseStatus: 'accepted'},
               ]
           },{
               summary: 'event 2',
               attendees: [
                   {displayName: 'user A', self: false, responseStatus: 'accepted'},
                   {displayName: 'user B', self: false, responseStatus: 'accepted'},
                   {displayName: 'user ME', self: true, responseStatus: 'declined'},
               ]
           },
       ];

        const result = eventFilter.filterMissedMeetings(events);

        expect(result.length).toBe(1);
        expect(result[0].summary).toBe('event 1');
    });
});

This is the minimal representation of two Google Calendar events providing us all the data we need for this test.

filterMissedMeetings(events) {
    return events.filter(event => {
        return event.attendees.filter(attendee => {
            return attendee.self && attendee.responseStatus === 'accepted'
        }).length === 1;
    });
}

As I mentioned before, there is another case where we created a meeting ourselves. Let’s see what happens then:

it('should return meetings which I organized', () => {
    const events = [
        {
            summary: 'event 1',
            organizer: {displayName: 'user ME', self: true},
            attendees: [
                {name: 'user A', self: false, responseStatus: 'accepted'},
                {name: 'user B', self: false, responseStatus: 'declined'},
            ]
        }
    ];

    const result = eventFilter.filterMissedMeetings(events);

    expect(result.length).toBe(1);
    expect(result[0].summary).toBe('event 1');
});

Obviously, the code failed because we are not inside the attendees array. So we need to adjust our filter logic:

filterMissedMeetings(events) {
    return events.filter(event => {
        let acceptedAttendee = false;
        if (typeof event.attendees !== 'undefined') {
          acceptedAttendee = event.attendees.filter(attendee => {
            return attendee.self && attendee.responseStatus === 'accepted'
          }).length === 1;
        }

        let organizer = false;
        if (typeof event.organizer !== 'undefined') {
          organizer = event.organizer.self === true;
        }

        return acceptedAttendee || organizer;
    });
}

Tests are green, looking good!

https://media.giphy.com/media/3uBMPwDSVpR1C/source.gif

(God, I love that movie ❤)!

Rounding meeting durations

The last feature I wanted to implement in this article was rounding meetings to full 15-minute units.

const MeetingDuration = require('../src/MeetingDuration.js');

describe('roundDuration', () => {
    it('should round 10 minutes to 15 minutes', () => {
        const rawDuration = 10;
        const ceiledDuration = MeetingDuration.roundDuration(rawDuration);

        expect(ceiledDuration).toBe(15);
    });

    it('should round 20 minutes to 30 minutes', () => {
        const rawDuration = 20;
        const ceiledDuration = MeetingDuration.roundDuration(rawDuration);

        expect(ceiledDuration).toBe(30);
    });

    it('should round 40 minutes to 45 minutes', () => {
        const rawDuration = 40;
        const ceiledDuration = MeetingDuration.roundDuration(rawDuration);

        expect(ceiledDuration).toBe(45);
    });

    it('should round 50 minutes to 60 minutes', () => {
        const rawDuration = 50;
        const ceiledDuration = MeetingDuration.roundDuration(rawDuration);

        expect(ceiledDuration).toBe(60);
    });
});

Wait a minute! (Pun intended ? )

Before we even start writing our implementation, we could use data sets to get rid of all this code duplication.

const MeetingDuration = require('../src/MeetingDuration.js');

describe('roundDuration', () => {
    it.each([
        [10, 15],
        [15, 15],
        [16, 30],
        [20, 30],
        [40, 45],
        [50, 60],
        [123, 135],
    ])('should round to next 15 minute unit', (duration, expected) => {
        expect(MeetingDuration.roundDuration(duration)).toBe(expected);
    })
});

That’s definitely shorter, and we even added a few more test cases!
Let’s look at the implementation now:

class MeetingDuration {
    roundDuration(durationInMinutes) {
        let quarterHourPercentile = durationInMinutes / 15; // e.g. 0.66
        return Math.ceil(quarterHourPercentile) * 15;
    }
}

module.exports = new MeetingDuration();

Putting it all together

While we have added new functionality, we are not yet using it anywhere. To do so, we need to add the filters to calendar2mite.js and I add the rounding of the duration to Mite.js for now. We could think about using a Chain of Responsibility, but for now, I am happy with the implementation.

const eventFilter = require('./src/EventFilter');

// ...

let [events, mappings] = values;

events = eventFilter.filterIgnoreMappings(events, mappings);
events = eventFilter.filterMissedMeetings(events);

// ...
const duration = meetingDuration.roundDuration(dateTime.getDurationInMinutes(start, end));

return {
    'date_at': dateTime.formatDate(start), // needs to be YYYY-MM-DD
    'minutes': duration,
    'note': event.summary,
    'project_id': projectId,
    'service_id': serviceId
};

Conclusion

And with this, we implemented three new features: ignore, attendance, and round. Additionally, they have all been implemented using a test-first approach! You can check out the current state of the repository here:

https://github.com/moritzwachter/calendar-to-mite/tree/0.5.2

I hope you enjoyed our little journey into the world of test-driven development. As mentioned before, there are a lot of brilliant resources online that I recommend to you:

I gained my first experience with TDD by reading Kent Beck’s brilliant book “Test-Driven Development by Example” which you can find here and which I can absolutely recommend you to read:

https://www.amazon.de/Test-Driven-Development-Example-Signature/dp/0321146530

Moritz Wachter

Author Moritz Wachter

More posts by Moritz Wachter