DEV Community

Daveyon Mayne 😻
Daveyon Mayne 😻

Posted on

Infinite Y-Axis Calendar View Scroll

A snapshop of the calendar view from ektarOS (FarmSwop) journal app

I admired the ScrollView on iOS. When done right, you get a smooth infinite scrolling of the y-axis while it handles loading of data onto the view. A similar design was done for the DayOne iOS app; it's an infinite scroll back in the past as it's a journaling app so that make sense. I'm building a similar app but for farmers for my app called ektarOS (FarmSwop).

# index.html.erb: ie farmswop.com/calendar

<div class="journal-calendar-container">
  <header class="journal-calendar-header bg-earth-50 z-50 dark:bg-gray-850 dark:text-bubble-800 grid grid-cols-7 p-1">
    <span class="journal-calendar-header__item">mon</span>
    <span class="journal-calendar-header__item">tue</span>
    <span class="journal-calendar-header__item">wed</span>
    <span class="journal-calendar-header__item">thu</span>
    <span class="journal-calendar-header__item">fri</span>
    <span class="journal-calendar-header__item">sat</span>
    <span class="journal-calendar-header__item">sun</span>
  </header>

  <div id="journal-container" class="h-screen overflow-y-auto">
    <%= render "journals/calendar/scrollable", direction: "past" %>
    <%= render "journals/calendar/scrollable", direction: "future" %>
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

As you can see, I'm rendering journals/calendar/scrollable twice; the top one that fetches past data while the other fetches present and future data. Let's take a look at the partial:

views/journals/calendar/scrollable:

<%= turbo_frame_tag "#{direction}_journal_timeline",
  src: journal_timeline_path(direction: direction, month_year: local_assigns[:month_year] || nil ),
  loading: :lazy, target: "_top" do %>
    <p>Loading...</p>
<% end %>

Enter fullscreen mode Exit fullscreen mode

On page load, we make two requests; one for past data and the other for present/future data. journal_timeline_path renders a repeat of views/journals/calendar/scrollable in an odd way:

<%= turbo_frame_tag :"#{@direction}_journal_timeline" do %>
  <div class="flex flex-col flex-grow">

    <% if @direction == "past" %>
      <%= render "journals/calendar/scrollable", month_year: @month_year, direction: @direction %>

      <%= render "shared/journal_calendar",direction: @direction, calendar_data: @calendar_data %>

    <% else %>
      <%= render "shared/journal_calendar", direction: @direction, calendar_data: @calendar_data %>

      <%= render "journals/calendar/scrollable", month_year: @month_year, direction: @direction %>
    <% end %>

  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

For past data, I try to keep journals/calendar/scrollable from not being in view until the user scrolls up. It's not perfect as yet, we have to use JavaScript for this section. I'll come to that later.

shared/journal_calendar:

<%= turbo_frame_tag :"#{direction}_journal_timeline" do %>
  <article class="bg-earth-100 dark:bg-gray-850 invisible" data-controller="journal" data-direction="<%= direction %>">
    <div class="bg-earth-50 dark:bg-gray-840 dark:text-bubble-900 px-4 py-1 text-sm font-stdmedium">
      <%= @date.strftime('%B %Y') %>
    </div>
    <div class="flex items-center justify-between overflow-x-auto">
      <table class="w-full">
        <thead>

        </thead>
        <tbody>
          <% calendar_data.each do |week| %>
            <tr>
              <% week.each do |day| %>
                <td class="journal__cell <%= day.present? ? 'journal__cell--on' : 'journal__cell--off' %>">
                  <!-- Your rendered cell -->
                  <%# render "shared/journal_cal_cell", day: %>
                </td>
              <% end %>
            </tr>
          <% end %>
        </tbody>
      </table>
    </div>
  </article>
<% end %>
Enter fullscreen mode Exit fullscreen mode

The Calendar Controller

Calendar days are server-side rendered. No JavaScript is used to render the dates nor its data.

class Journals::CalendarController < ApplicationController  
  def index; end
end
Enter fullscreen mode Exit fullscreen mode

And that's it! Literally! Joking! Journals::CalendarController is only needed to render the index page. If you look back at views/journals/calendar/scrollable, it fetches journal_timeline_path. Here's that controller:

class Journals::TimelineController < ApplicationController
  def index
    @direction = params[:direction]
    @month_year = params[:month_year].present? ? Date.parse(params[:month_year]) : Date.today

    case @direction
    when "past"
      @month_year -= 1.month
    end

    @date = @month_year.beginning_of_month
    @calendar_data = Journal::Timeline.new(start_date: @date, bucket: @bucket).calendar_data

    case @direction
    when "past"
      @month_year -= 1.month
    else
      @month_year += 1.month
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

You may see references to "buckets", they are not relevant to this post. Most of this code I copied directly from my code editor.

class Journal::Timeline
  def initialize(start_date: nil, bucket: nil)
    @start_date = start_date
    @bucket = bucket
  end

  def calendar_data
    generate_calendar_data
  end

  private

    def generate_calendar_data
      calendar_data = []
      current_date = @start_date
      week = []

      # Determine the number of empty cells before the first day of the month
      empty_cells = (current_date.wday - 1) % 7
      empty_cells = 6 if empty_cells < 0

      # Calculate the date of the first day of the current month
      first_day_of_current_month = current_date.beginning_of_month

      # Add days from the previous month, if needed
      empty_cells.downto(1) do |i|
        date = first_day_of_current_month.prev_day(i)
        day_data = initialize_day_data(date)
        week << {} # day_data
      end

      # Generate calendar data for the entire month
      while current_date.month == @start_date.month
        day_data = initialize_day_data(current_date)
        week << day_data

        if current_date.sunday?
          calendar_data << week
          week = []
        end

        current_date = current_date.tomorrow
      end

      # Add the last week to the calendar data
      calendar_data << week unless week.empty?

      calendar_data
    end

    def initialize_day_data(date)
      # Get the data you need by date
      journals = Recording.select do |rec|
        observed_at_date = rec.recordable.observed_at.to_date if rec.recordable.observed_at.present?
        created_at_date = rec.recordable.created_at.to_date

        observed_at_date == date.to_date || created_at_date == date.to_date
      end

      # Your data format
      {
        day: date.day,
        date: date.strftime("%d-%m-%Y"),
        journals: journals
        # Add any other data you need for the day, such as events
      }
    end
end
Enter fullscreen mode Exit fullscreen mode

generate_calendar_data took some time for me to get right. I hope it was well written?

The Stimulus JavaScripts!

Every time shared/journal_calendar gets rendered, we execute some other code:

app/javascript/controllers/journal_controller.js:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ['cell']

  connect() {
    const container = document.getElementById('journal-container');
    const scrollTopBefore = container.scrollTop;

    const direction = this.element.dataset.direction;

    const newItemsHeight = this.element.clientHeight; // clientHeight/offsetHeight

    if (direction === 'past') {
      // Adjust the scroll position
      container.scrollTop = scrollTopBefore + newItemsHeight;

      this.element.classList.remove('invisible')

    } else {
       // Not sure I need this anymore.
      this.element.classList.remove('invisible')
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

There's a bug with this JS. While the "past" data scrolls nicely on desktop, not so much on mobile devices. I suspect the issue with getting the height with clientHeight. Any ways, I'll look on that later.

That's all there is for an infinite y-axis scroll. For initialize_day_data > journals, you can put an empty array if you do not have any data and the infinite scrolling should work, but without any data.

Spot any bug(s)? Please let me know!

Have a great day! 👋

Top comments (0)