Activity Calendar Partial

An activity calendar-widget is well known from content management systems like Wordpress. Users can see the activities/posts written in a calendar view and navigate through the archive. In this article, I show you one possible approach to achieve this with Hugo.

What is an activity calendar?

The most illuminating way to show you what we will achieve in this post is to see a live demo for a calendar I’ve built for this website.

It’s relatively straightforward seeing on one view when articles have been published, and a user can navigate through the archive. However I doubt that end users browse through a calendar to see posts, but for the sake of SEO or using it for an event-schedule seems pretty useful.

Let’s get prepared

The partials

In this case and for respecting the single responsibility principle we will create four partials that are linked together.

The main partial I called the calendar.html where you can specify when the calendar will start when the calendar ends and what .Pages should be considered. This partial will take multiple parameters through a map using dict (dict is basically a way to create a key-value-map like dict key1 value1 key2 value2 ...). When we pass in multiple parameters into a partial, we need to pass the context (aka the dot .) into. Otherwise, you have no chance to use context-based functions or access their data.

Next, there is a partial called year.html where a single year gets rendered. I put them in a separate folder layouts/partials/calendar/ to keep it organized. A single year is then looping through the months that needs to be rendered and opens a partial called month.html. And you can imagine - a month partial loops through a day.html to render the actual days. All of them are in the /calendar/-Folder.

So we end up with the following file structure:

|-- layouts
|  |-- partials
|  |  |-- calendar
|  |  |  |-- year.html
|  |  |  |-- month.html
|  |  |  |-- day.html
|  |  |-- calendar.html

The basic usage of the calendar-partial will look like this:

{{ partial "calendar" (dict "context" . "from" 2016 "fromMonth" 10 "to" (now.Format "2006") "toMonth" (now.Format "1") "pages" .Site.Pages) }}

Small explanations for the parameters we pass in:

context

This is crucial to pass the current context into the partial. While partials have an own context otherwise without access to the related parent context.

from

This is the year as (YYYY) when you want to start the calendar.

fromMonth

This is the month as a single digit number without leading zero in which month the calendar needs to start (including this month).

to

This is the year as (YYYY) when you want to end the calendar.

toMonth

This is the month as a single digit number without leading zero in which month the calendar needs to stop (including this month).

pages

Pages expect a list of pages to check whether or not there was a post in a certain year/month/day. This will also provide a bunch of flexibility and multi usage of the partial.

If you are wondering what (now.Format "2006") makes - have a look at the explanation of dateFormat in Hugo.

Taxonomy structure

In order to make working links to the archive we need to create taxonomies that creates the list-pages for the articles. For this we need changes in the config.toml and prepare some layouts for the archive-taxonomy.

config.toml

# adding taxonomy term 'archive'
[taxonomies]
    archive = "archive"

Then create a layout file called archive.html in /layouts/taxonomy/. To start with the following content:

archive.html

<h1>Archive! Hooray...</h1>
{{ $currentTaxonomy := index (last 1 (split (delimit (split .URL "/") "," "") ",")) 0 }}
{{ .Pages | jsonify }}

Next choose a page you want to be rendered within the activity calendar-view and add the following front matter:

your-content.md

+++
date = "2017-03-06T21:27:05.454Z"
archive = ["2017","2017-03","2017-03-06"]
+++

In this example the page will be rendered in the archive of the Year 2017, the month of March and within the specific day’s archive at the 6th of March, 2017.


Let’s rock the show!

Fill the above created partials with the following content:

calendar.html

{{ $context := .context }}
{{ $from := int .from }}
{{ $fromMonth := int .fromMonth }}
{{ $to := int .to }}
{{ $toMonth := int .toMonth }}
{{ $pages := .pages }}

{{ $context.Scratch.Set "currentYear" $from }}
{{ $context.Scratch.Add "consideredYears" (slice $from) }}

{{ range ($pages.GroupByPublishDate "2006") }}
    {{ $context.Scratch.SetInMap "ArticlesPerYear" .Key (len .Pages) }}
{{ end }}

{{ range ($pages.GroupByPublishDate "2006-01") }}
    {{ $context.Scratch.SetInMap "ArticlesPerMonth" .Key (len .Pages) }}
{{ end }}

{{ range ($pages.GroupByPublishDate "2006-01-02") }}
    {{ $context.Scratch.SetInMap "ArticlesPerDay" .Key (len .Pages) }}
{{ end }}

{{ range $i, $sequence := (seq ((sub $to $from))) }}
    {{ $currentYear := $context.Scratch.Get "currentYear" }}
    {{ $nextYear := (add $from $sequence) }}

    {{ if le $nextYear $to }}
        {{ $context.Scratch.Add "consideredYears" (slice $nextYear) }}
        {{ $context.Scratch.Set "currentYear" $nextYear }}
    {{ end }}

{{ end }}

{{ range ($context.Scratch.Get "consideredYears") }}
    {{ partialCached "calendar/year" (dict "context" $context "year" . "from" $from "fromMonth" $fromMonth "to" $to "toMonth" $toMonth) (string (delimit (slice $from $fromMonth $to $toMonth .) "")) }}
{{ end }}

calendar/year.html

{{ $months := seq 12 }}
{{ $context := .context }}
{{ $year := .year }}
{{ $from := .from }}
{{ $fromMonth := .fromMonth }}
{{ $to := .to }}
{{ $toMonth := .toMonth }}

{{ $isLeapYear := (modBool $year 4) }}

<div class="calendar-year">
<header>
    <h2>
        {{ $articlesThisYear := index ($context.Scratch.Get "ArticlesPerYear") (string $year) }}
        {{ if gt $articlesThisYear 0}}<a href="/archive/{{ $year }}/">{{ end }}
            {{ $year }}
        {{ if gt $articlesThisYear 0}}</a>{{ end }}
    </h2>
</header>
{{ range $months }}
    {{ if or (and (ne $year $from) (ne $year $to)) (and (eq $year $from) (ge . $fromMonth)) (and (eq $year $to) (le . $toMonth))  }}
    {{ partialCached "calendar/month" (dict "context" $context "year" $year "isLeapYear" $isLeapYear "month" .) (string (delimit (slice $year .) "")) }}
    {{ end }}
{{ end }}
</div>

calendar/month.html

{{ $context := .context }}
{{ $year := .year }}
{{ $isLeapYear := .isLeapYear }}
{{ $month := .month }}

{{ $daysPerMonth := dict "1" (seq 31) "2" (seq 28) "2-leap" (seq 29) "3" (seq 31) "4" (seq 30) "5" (seq 31) "6" (seq 30) "7" (seq 31) "8" (seq 31) "9" (seq 30) "10" (seq 31) "11" (seq 30) "12" (seq 31) }}
{{ $daysPerWeekMap := dict "Mon" 0 "Tue" 1 "Wed" 2 "Thu" 3 "Fri" 4 "Sat" 5 "Sun" 6 }}
{{ $daysPerWeek := slice "Mon" "Tue" "Wed" "Thu" "Fri" "Sat" "Sun" }}

{{ $context.Scratch.Set "daysThisMonth" (index $daysPerMonth (string $month)) }}

{{ if and $isLeapYear (eq $month 2) }}
    {{ $context.Scratch.Set "daysThisMonth" (index $daysPerMonth "2-leap") }}
{{ end }}

{{ $daysThisMonth := $context.Scratch.Get "daysThisMonth" }}

{{ $monthTwoLetters := printf "%02d" $month }}

{{ $monthInLetters := dateFormat "January" (string (delimit (slice $year "-" $monthTwoLetters "-01") ""))  }}
{{ $firstWeekdayInLetters := dateFormat "Mon" (string (delimit (slice $year "-" $monthTwoLetters "-01") ""))  }}
{{ $firstWeekdayInNumbers := index $daysPerWeekMap $firstWeekdayInLetters }}
{{ $firstWeekdayMondayOffset := (sub (add $firstWeekdayInNumbers 6) 6) }}

<div class="calendar-month">

<header>
    <h4>
    {{ $articlesThisMonth := index ($context.Scratch.Get "ArticlesPerMonth") (string (delimit (slice $year $monthTwoLetters) "-")) }}
    {{ if gt $articlesThisMonth 0}}<a href="/archive/{{ delimit (slice $year $monthTwoLetters) "-" }}/">{{ end }}
    {{ $monthInLetters }}
    {{ if gt $articlesThisMonth 0}}</a>{{ end }}
    </h4>
</header>

<ul class="calendar-weekdays">
    {{- range $daysPerWeek -}}
    <li class="calendar-weekday-{{ lower . }}">{{ . }}</li>
    {{- end -}}
</ul>

<ul class="calendar-days">

    {{ range seq $firstWeekdayMondayOffset }}
        <li class="calendar-day-empty"></li>
    {{ end }}

    {{ range $daysThisMonth }}
        {{ partial  "calendar/day" (dict "context" $context "year" $year "month" $month "day" .) (string (delimit (slice $year $month .) "")) }}
    {{ end }}

    {{ range (seq (mod (add (len $daysThisMonth) $firstWeekdayMondayOffset) 7)) }}
    <li class="calendar-day-empty"></li>
    {{ end }}

</ul>

</div>

calendar/day.html

{{ $context := .context }}
{{ $year := .year }}

{{ $day := .day }}
{{ $dayTwoLetters := printf "%02d" $day }}
{{ $pageMap := .pagemap }}

{{ $month := .month }}
{{ $monthTwoLetters := printf "%02d" $month }}

{{ $dateString := (string (delimit (slice $year $monthTwoLetters $dayTwoLetters) "-")) }}

{{ $context.Scratch.Set "isFuture" false }}

{{ if le now (time $dateString) }}
    {{ $context.Scratch.Set "isFuture" true }}
{{ end }}

{{ $isFuture := $context.Scratch.Get "isFuture" }}

{{- $articlesFound := index ($context.Scratch.Get "ArticlesPerDay") $dateString -}}
<li class="calendar-day {{ if $isFuture }}calendar-day-future{{ end }} {{ if gt $articlesFound 0 }}calendar-day-has-articles{{ end }} {{ if eq (now.Format "2006-01-02") $dateString }}calendar-day-is-today{{ end }}">
    {{- if gt $articlesFound 0 -}}
        <a href="/archive/{{ delimit (slice $year $monthTwoLetters $dayTwoLetters) "-" }}/" title="{{ $articlesFound }} article{{ if gt $articlesFound 1 }}s{{ end }}">
    {{- end -}}
        <time datetime="{{ $dateString }}">{{ $day }}<em>{{ $articlesFound }}</em></time>
    {{- if gt $articlesFound 0 -}}</a>{{- end -}}
</li>

Done! Or, do you not like the look?

styling.scss

$gutter-default : 20px;
$color-accent : #ff6600;
$color-white : #ffffff;

.calendar-year {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;

  list-style-type: none;
  padding: 0px;
  margin: 0px;
  text-align: center;

  header {
    width: 100%;
  }

}

.calendar-month {
  display: flex;
  flex-grow: 1;
  flex-direction: row;
  flex-wrap: wrap;
  align-content: flex-start;

  list-style-type: none;
  padding: 0px;
  text-align: center;

  width: calc(100% / 4);
  margin: $gutter-default;

  header {
    width: 100%;
  }

}

.calendar-weekdays {
  display: flex;
  flex-grow: 1;
  flex-direction: row;
  flex-wrap: wrap;
  list-style-type: none;
  padding: 0px;
  margin: 0px;
  text-align: center;

  width: 100%;

  li {
    width: calc(100% / 7);
    font-weight: 500;
    text-transform: uppercase;
    line-height: $gutter-default * 2;
    font-size: $gutter-default / 2;
    opacity: 0.6;

    &.calendar-weekday-sat,
    &.calendar-weekday-sun {
      opacity: 1;
    }

  }

}

.calendar-days {
  display: flex;
  flex-grow: 1;
  flex-direction: row;
  flex-wrap: wrap;
  list-style-type: none;
  padding: 0px;
  margin: 0px;
  text-align: center;

  li {
    width: calc(100% / 7);
  }

}

.calendar-day {
  line-height: $gutter-default * 1.5;
  font-size: $gutter-default / 1.8;
  text-align: center;

  &.calendar-day-future {
    opacity: 0.4;
  }

  &.calendar-day-has-articles {
    &::before {
      content: '';
      width: $gutter-default * 1.1;
      height: $gutter-default * 1.1;
      position: absolute;
      top: 50%;
      left: 50%;
      background-color: $color-accent;
      transform: translate(-50%, -50%);
      z-index: -1;
      border-radius: 50%;
    }

    color: $color-white;
    position: relative;

    a {
      color: $color-white;

      em {
        display: none;
      }

    }

  }

  &.calendar-day-is-today {
    &::before {
      content: '';
      width: $gutter-default * 1;
      height: $gutter-default * 1;
      position: absolute;
      top: 50%;
      left: 50%;
      border: 2px solid $color-accent;
      transform: translate(-50%, -50%);
      z-index: -1;
      border-radius: 50%;
    }

    position: relative;
    font-weight: 800;
    color: $color-accent;

  }

}

If you do not use Sass - just compile the code above with a tool like sassmeister.com and - done :-)

Hope you enjoyed.