Create a Time-Tracking CLI Tool with Go, OAuth2, and Google Calendar API

In 2024, there’s been a lot of hype around Golang. After falling down a YouTube go-pher hole from my favorite tech content creators and Rob Pike conference talks, I felt inspired to try it out and write a program in this language.

Learning a new language is often most effective when driven by a personal problem that needs solving. Recently, such a problem presented itself to me.

I found myself at odds with my workplace regarding my time-tracking etiquette, and, truth be told, I wasn’t entirely satisfied with my personal process either. This seemed like the perfect opportunity to kill two birds with one stone (so they say).

So, I decided to write a Golang CLI tool to help me log my time.

Problem

In a consultant’s line of work, meticulous time tracking is crucial for ensuring client invoices are correct, payments are received, and staff gets its slice of the pie.

More specifically, here at Atomic, all atoms adhere to a system where work hours are logged in consecutive time chunks rounded to the nearest 15th minute. These chunks are associated with specific projects, tasks, and additional notes. Some of the work is billable to clients, while some isn’t. It’s expected that each workday includes no less than 7 hours of logged time without any gaps and with clear indication of the nature of the work for operational purposes.

I found time-tracking to be tedious. The non-linear nature of knowledge work makes it challenging to stay up-to-date with logging hours accurately. My workday typically includes a few meetings and rarely involves uninterrupted programming sessions.

Consequently, I often deferred time-tracking until the end of the week when I could reflect on my activities using references such as Google Calendar, Git commits, Slack messages, and personal notes. While this approach alleviated some of the burden, it wasn’t operationally acceptable.

Aside: one of my colleagues came up with a novel solution to this problem by using a third-party SaaS product called IFTTT (If this, then that). I definitely recommend checking out his post here.

Solution

To address this challenge, I formulated a plan. Given that my workdays typically follow a 9-to-5 schedule, with meetings and collaborative sessions already scheduled in my Google Calendar, I realized I could streamline time-tracking by developing a program that fetches events from my calendar and organizes them into time chunks suitable for our time-tracking system’s API.

Solving this problem intrigues me as it’ll enhance operational efficiency while providing an opportunity for experimenting in Golang. Now, with the problem’s context in mind, let me further frame the specifics:

High-level objectives

  • Set up a Google Console app to query events from my Google Work Calendar following this guide.
  • Implement authorization and authentication using Google’s OAuth2 workflow, using the guide above as an example.
  • Parse program flags supplied to the CLI program (e.g., date).
  • Fetch Google Calendar events that were accepted for a given day.
  • Develop the business logic that processes daily calendar events to generate consecutive time chunks based on default parameters (e.g., start/end of day times), with event summaries used as notes.
  • Output the generated time chunks to the terminal, serving as a foundation for daily time-tracking, which can be altered, and in the future be used with a time-tracking API.

Rules for the time chunks

  • Cannot overlap; overlapping events adjust the end time of the preceding chunk, with overlapping events tracked.
  • Chunks must be rounded and quartered to the nearest 15th minute.
  • There should be no gaps between chunks; they must be consecutive.
  • Chunks must fall within default start and end times, although they can exceed these bounds if calendar events dictate.
  • Start and end times must be represented in decimal format (e.g., 2:15 PM as 14.25).

main.go


package main

import (
    "context"
    "encoding/json"
    "flag"
    "fmt"
    "log"
    "math"
    "net/http"
    "os"
    "strings"
    "time"

    "golang.org/x/oauth2"
    "golang.org/x/oauth2/google"
    "google.golang.org/api/calendar/v3"
    "google.golang.org/api/option"
)

const (
    startOfDay = 9            // 9 AM
    endOfDay   = 17           // 5 PM
    dateLayout = "2006-01-02" // YYYY-MM-DD
)

func main() {
    dateStr := flag.String("date", time.Now().Format(dateLayout), "The date in the format 'YYYY-MM-DD'")
    flag.Parse()
    date, err := time.ParseInLocation(dateLayout, *dateStr, time.Now().Location())
    if err != nil {
        log.Fatal(err.Error())
    }

    ctx := context.Background()
    oauth2Client, err := authenticateClient(ctx)
    if err != nil {
        log.Fatalf(err.Error())
    }
    calendarService, err := calendar.NewService(ctx, option.WithHTTPClient(oauth2Client))
    if err != nil {
        log.Fatalf(err.Error())
    }

    result, _ := calendarService.Events.List("primary").
        ShowDeleted(false).
        SingleEvents(true).
        TimeMin(date.Format(time.RFC3339)).
        TimeMax(date.Add(24 * time.Hour).Format(time.RFC3339)).
        OrderBy("startTime").
        Do()

    chunks := Chunkify(date, result.Items)

    totalHours := 0.0
    buf := strings.Builder{}

    buf.WriteString("start,end,notes\n")
    for _, chunk := range chunks {
        totalHours += chunk.end.Sub(chunk.start).Hours()
        line := fmt.Sprintf("%s,%s,%s\n",
            formatTime(chunk.start),
            formatTime(chunk.end),
            chunk.notes,
        )
        buf.WriteString(line)
    }

    output := fmt.Sprintf(`
CSV report for the date: %s with a total of %.2f hours.

%s`,
        date.Format(dateLayout),
        totalHours,
        buf.String(),
    )
    fmt.Print(output)
}

type Chunk struct {
    *calendar.Event
    start time.Time
    end   time.Time
    notes string
}

func Chunkify(date time.Time, items []*calendar.Event) []*Chunk {
    var (
        lo        time.Time = date.Add(startOfDay * time.Hour)
        hi        time.Time = date.Add(endOfDay * time.Hour)
        i         int       = 0
        chunks    []*Chunk  = make([]*Chunk, 0, len(items)*2)
        intersect *Chunk
    )

    if len(items) == 0 {
        chunks = append(chunks, &Chunk{start: lo, end: hi, notes: ""})
        return chunks
    }

    for _, e := range items {
        // exclude all-day events
        if e.Start.DateTime == "" || e.End.DateTime == "" {
            continue
        }

        // include event if you created it and are not an attendee
        if len(e.Attendees) == 0 && e.Creator.Self {
            e.Attendees = append(e.Attendees, &calendar.EventAttendee{
                Self: true,
            })
        }

        for _, attendee := range e.Attendees {
            // exclude events you are not an attendee or declined
            if !attendee.Self || attendee.ResponseStatus == "declined" {
                continue
            }

            start := roundToNearest15(e.Start)
            end := roundToNearest15(e.End)

            // include gap chunk if event starts after start of day
            if start.After(lo) {
                chunks = append(chunks, &Chunk{start: lo, end: start, notes: ""})
                if intersect != nil {
                    chunks[len(chunks)-1].notes = intersect.notes
                }
            }

            // include current event chunk and keep track of index
            chunks = append(chunks, &Chunk{Event: e, start: start, end: end, notes: e.Summary})
            i = len(chunks) - 1

            // modify previous chunk if current event intersects
            if i > 0 && start.Before(chunks[i-1].end) {
                intersect = chunks[i-1]
                chunks[i-1].end = start
            }

            lo = chunks[i].end
        }
    }

    // if last event ends before end of day, add a gap chunk
    if lo.Before(hi) {
        chunks = append(chunks, &Chunk{start: lo, end: hi, notes: ""})
        if intersect != nil {
            chunks[len(chunks)-1].notes = intersect.notes
        }
    }

    return chunks
}

func roundToNearest15(dt *calendar.EventDateTime) time.Time {
    t, _ := time.Parse(time.RFC3339, dt.DateTime)
    // 7.5 minutes rounds up to 15 minutes, 7.49 minutes rounds down to 0 minutes
    return t.Round(15 * time.Minute)
}

func formatTime(t time.Time) string {
    // valid hours 00-23
    // valid minutes 00, 25, 50, 75
    // valid time 00:00, 00:15, 00:30, 00:45, 01:00, 01:15, ..., 23:45
    return fmt.Sprintf("%s.%02d", t.Format("15"), int(math.Round(float64(t.Minute())/60*100)))
}

func authenticateClient(ctx context.Context) (*http.Client, error) {
    bytes, err := os.ReadFile("credentials.json")
    if err != nil {
        return nil, fmt.Errorf("error reading the credentials file: %v", err)
    }

    config, err := google.ConfigFromJSON(bytes, "https://www.googleapis.com/auth/calendar.events.readonly")
    if err != nil {
        return nil, fmt.Errorf("error creating the OAuth2 config: %v", err)
    }

    tokFile, err := os.OpenFile("token.json", os.O_RDWR|os.O_CREATE, 0644)
    if err != nil {
        return nil, fmt.Errorf("error opening the token file: %v", err)
    }
    defer tokFile.Close()

    tok := &oauth2.Token{}
    json.NewDecoder(tokFile).Decode(tok)

    if tok.Valid() {
        return config.Client(ctx, tok), nil
    }

    authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    fmt.Printf("Authenticate at this URL:\n\n%s\n", authURL)

    ch := make(chan string, 1)
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        ch <- r.URL.Query().Get("code")
        w.Write([]byte("You can now close this window."))
    })

    go http.ListenAndServe(":"+strings.Split(config.RedirectURL, ":")[2], nil)

    tok, _ = config.Exchange(ctx, <-ch)

    // save the token for future use
    tokFile.Seek(0, 0)
    tokFile.Truncate(0)
    json.NewEncoder(tokFile).Encode(tok)

    return config.Client(ctx, tok), nil
}

Usage

input

go run main.go -date 2024-03-22
output
CSV report for the date: 2024-03-22 with a total of 8.00 hours.

start,end,notes
09.00,10.00,
10.00,10.25,AOA2 Daily Standup
10.25,10.50,Project Standup
10.50,14.00,
14.00,15.00,Date Generated Notices sync/knowledge transfer
15.00,16.75,Economics of AO Workshop
16.75,17.00,

If you’re interested in trying it out yourself, you can find the source code in my GitHub repo linked below. The repo contains setup instructions, a usage guide, and tests, and it will contain the most up-to-date code.

github.com/papes1ns/chunkit

Or, if you are new to Go and want to learn more, then I recommend reading through gobyexample.com. It’s an excellent guide to learning the core language features. And then go dig through open-source projects. The awesome-go repo is an index of projects I frequent for inspiration and leveling up my Go programming skills.

The Advantages of Golang

Developing this simple program was an enjoyable experience. While it may not have immediate utility for anyone else, it demonstrates Golang’s suitability for creating CLI tools. This project only scratches the surface of what the language has to offer. The simple syntax, robust tooling, and extensive standard library that come with Golang are some of the best.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *