Peviously I wrote about paginating records by date.

Gem pagy also offers a pagination solution out of the box:

calendar pagination

Here’s how we can (and can’t) use it.

Initial setup #

First, let’s add a list of events that we can paginate:

# /db/seeds.rb
path = "https://raw.githubusercontent.com/ruby-conferences/ruby-conferences.github.io/master/_data/conferences.yml"
uri = URI.open(path)
yaml = YAML.load_file uri, permitted_classes: [Date]

yaml.each do |event|
  Event.create!(
    name: event["name"],
    location: event["location"],
    start_date: event["start_date"]
  )
end
rails g scaffold Event name location start_date:datetime
rails db:migarte db:seed

Add calendar pagination #

# terminal
bundle add pagy

Enable pagy calendar plugin:

# config/initializers/pagy.rb
require 'pagy/extras/calendar'

# optionally enable frontend libraries
# require 'pagy/extras/bootstrap' # https://ddnexus.github.io/pagy/docs/extras/bootstrap/
# https://ddnexus.github.io/pagy/docs/extras/tailwind/

Pagy does not know what date attribute we will use for pagination (created_at? starts_at? start_time?), so we have to define it:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  # enable pagy backend helpers globally
  include Pagy::Backend

  # start and end of calendar (first and last record in the list)
  def pagy_calendar_period(collection)
    collection.minmax.map(&:start_date)

    # between first event and Today
    # start_date = collection.min_by(&:start_date).start_date
    # end_date = Time.zone.now
    # [start_date, end_date]
  end

  # optionally: end on last event or today
  # def end_date(collection)
  #   last_event_date = collection.max_by(&:start_date).start_date
  #   return last_event_date if last_event_date > Time.zone.now

  #   Time.zone.now
  # end

  # query to paginate within start_date
  def pagy_calendar_filter(collection, from, to)
    collection.where(start_date: from..to)
  end
end

Enable pagy froentend helpers like pagy_nav:

# app/helpers/application_helper.rb
module ApplicationHelper
  include Pagy::Frontend
end

In the controller, wrap your collection into pagy_calendar. Uncomment for any pagination granularity that you like:

  • year
  • year/month
  • year/week
  • year/day (stupid)
  • year/month/day
  • ???
  def index
    collection = Event.all.order(start_date: :asc)
    @calendar, @pagy, @events = pagy_calendar(collection,
      year: {size: 4},
      # year:  { size:  [1, 1, 1, 1] },
      month: {size: 12, format: '%b'},
      # month:  { size: [0, 12, 12, 0], format: '%b' },
      week:  { size: 53, format: '%W' },
      # week:  { size: [0, 53, 53, 0], format: '%W' },
      day: {size: 31, format: '%d'},
      # day:  { size: [0, 31, 31, 0], format: '%d' },
      pagy:  { items: 10 }, # items per page
      active: !params[:skip]
    )
  end

ℹ️ size attribute defines how many pagy links to show: [pagination start, before current, after current, pagination end]. For example, if current selected page is 11 and size: [1, 2, 2, 1], the pagination links displayed can be [1, 9-10, 12-13, 100].

Display records (events) and pagination in a view:

# app/views/events/index.html.erb
<h1>Events</h1>

<div>
  <% if params[:skip] %>
    <%= link_to 'Show Calendar', events_path %>
  <% else %>
    <%= link_to 'Hide Calendar', events_path(skip: true) %>
    <br>
    <%= link_to 'Today', pagy_calendar_url_at(@calendar, Time.zone.now, fit_time: true) %>
  <% end %>
</div>

<% if @calendar %>
  <%== pagy_info(@pagy) %>
  for
  <%= @calendar.showtime %>
  <%#== @calendar[:year].label %>
  <%#== @calendar[:day]&.label %>
  <%#== @calendar[:month].label(format: '%B %Y') %>
  <%#== @calendar[:week].label %>
  <%== pagy_nav(@calendar[:year]) %>
  <%== pagy_nav(@calendar[:month]) %>
  <%== pagy_nav(@calendar[:week]) if @calendar[:week] %>
  <%== pagy_nav(@calendar[:day]) if @calendar[:day] %>
<% end %>

<%== pagy_nav(@pagy) %>

<hr>

<% if @calendar %>
  <%#= link_to "New event", new_event_path(start_date: [@calendar[:day]&.label, @calendar[:month].label(format: '%m-%Y')].compact.join('-')) %>
  <%= link_to "New event", new_event_path(start_date: @calendar.showtime) %>
<% else %>
  <%= link_to "New event", new_event_path %>
<% end %>

<hr>

<% if @events.any? %>
  <% @events.each do |event| %>
    <%= render 'event', event: event %>
  <% end %>
<% elsif @events.empty? %>
  No events found
<% end %>

Add new event to current date #

In the view, add a link_to add an event for a date.

@calendar.showtime will always give you the current date/month/year.

# app/views/events/index.html.erb

# for current_date
link_to "New event", new_event_path(start_date: @calendar.showtime)

# for any date
link_to "Add event (Today)", new_event_path(start_date: Date.today)

# other approaches

# if no format defined in controller
link_to "Add event", new_event_path(start_date: @calendar[:day].label)

# if :day format is defined in controller, we have to deduce todays date
link_to "New event", new_event_path(start_date: [@calendar[:day]&.label, @calendar[:month].label(format: '%m-%Y')].compact.join('-'))

Display the selected date in a form:

# app/views/events/_form.html.erb
<% if params[:start_date] %>
  <%= form.datetime_field :start_date, value: params[:start_date]&.to_date&.strftime('%Y-%m-%dT%H:%M:%S') || form.object.start_date %>
<% else %>
  <%= form.datetime_field :start_date %>
<% end %>

To redirect to the calendar page with this event, we need to define @calendar in the #create action the same way we did for #index.

# app/controllers/events_controller.rb
+ before_action :set_calendar, only: %i[ index create ]

  def create
    if @event.save
-     redirect_to events_path
+     redirect_to helpers.pagy_calendar_url_at(@calendar, @event.start_date)

  private
# this will be shared for both #index and #create actions
+  def set_calendar
+    # @events = Event.all
+    collection = Event.all.order(start_date: :asc)
+    @calendar, @pagy, @events = pagy_calendar(collection,
+      year: {size: 4},
+      month: {size: 12, format: '%b'},
+      day: {size: 31, format: '%d'},
+      pagy:  { items: 10 }, # items per page
+      active: !params[:skip]
+    )
+  end

Wishes for the future #

If we could have actual year in params, not page index, it would make URLs predictable:

# bad
http://localhost:3000/events?year_page=10&month_page=10&day_page=5
# good
http://localhost:3000/events?year_page=2023&month_page=10&day_page=5

Overall, Pagy Calendar is a great out of the box solution.

Huge respect to ddnexus for his work! 💪

To explore later:

  • Time zones
  • i18n