Skip to content

Instantly share code, notes, and snippets.

@digitalWestie
Last active December 2, 2025 17:30
Show Gist options
  • Select an option

  • Save digitalWestie/a5b045b610891e3d6db1d23fc17404ca to your computer and use it in GitHub Desktop.

Select an option

Save digitalWestie/a5b045b610891e3d6db1d23fc17404ca to your computer and use it in GitHub Desktop.
RRule strategy for Stimulus.js

RRule Builder for Rails

A Stimulus-based RRULE (iCalendar recurrence rule) builder component for Rails applications. This provides a user-friendly interface for creating recurring event schedules without requiring external dependencies like jQuery UI or React.

Overview

This RRule builder generates iCalendar-compliant RRULE strings that can be used with libraries like rrule (Ruby gem) or rrule.js (JavaScript) to calculate recurring event occurrences.

Features:

  • ✅ Pure Stimulus controller (no jQuery UI or React dependencies)
  • ✅ Declarative data-action attributes (idiomatic Stimulus)
  • ✅ Supports daily, weekly, monthly, and yearly frequencies
  • ✅ Flexible interval support (every N days/weeks/months/years)
  • ✅ Weekday selection for weekly patterns
  • ✅ Month day selection for monthly patterns
  • ✅ Yearly patterns with month/day selection
  • ✅ End conditions (count or until date)
  • ✅ Parses and displays existing RRULE strings
  • ✅ Integrates with Rails forms seamlessly

Dependencies

Ruby Gems

# Gemfile
gem 'rrule'  # For parsing and generating occurrences from RRULE strings

JavaScript

  • Stimulus (already included in Rails 7+ with Hotwire)
  • No additional JavaScript dependencies required

Installation

1. Copy the Stimulus Controller

Copy app/javascript/controllers/rrule_builder_controller.js to your Rails app:

# In your Rails app
cp /path/to/rrule_builder_controller.js app/javascript/controllers/

2. Register the Controller

Ensure the controller is registered in app/javascript/controllers/index.js:

import RruleBuilderController from "./rrule_builder_controller"
application.register("rrule-builder", RruleBuilderController)

3. Add Model Support

Add the necessary fields to your model:

# Migration
class AddScheduleFieldsToYourModel < ActiveRecord::Migration[7.0]
  def change
    add_column :your_models, :dtstart, :datetime
    add_column :your_models, :rrule, :string
    add_column :your_models, :duration, :string  # ISO8601 duration format (e.g., "PT1H30M")
  end
end

Update your model:

# app/models/your_model.rb
class YourModel < ApplicationRecord
  # Parse RRULE and generate occurrences
  def occurrences(limit: nil)
    return [dtstart] if rrule.blank?
    
    options = { dtstart: dtstart }
    options[:tzid] = timezone if respond_to?(:timezone) && timezone.present?
    
    rrule_instance = RRule::Rule.new(rrule, **options)
    limit ? rrule_instance.all(limit: limit) : rrule_instance.all
  end
  
  def has_schedule?
    dtstart.present? || rrule.present?
  end
end

4. Update Strong Parameters

Add schedule fields to your controller's strong parameters:

# app/controllers/your_controller.rb
def your_model_params
  params.require(:your_model).permit(:dtstart, :rrule, :duration, ...)
end

Usage

Basic Form Integration

Add the RRule builder to your form:

<!-- app/views/your_models/_form.html.erb -->
<%= form_with model: [@account, @your_model] do |form| %>
  
  <!-- Start Date/Time Field -->
  <div class="form-group">
    <%= form.label :dtstart, "Start Date/Time", class: "form-label" %>
    <%= form.datetime_local_field :dtstart, 
          class: "form-control", 
          data: { 
            action: "change->rrule-builder#dtstartChange" 
          } %>
  </div>

  <!-- RRule Builder -->
  <div class="form-group" 
       data-controller="rrule-builder" 
       data-rrule-builder-initial-rrule-value="<%= @your_model.rrule || '' %>">
    
    <%= form.label :rrule, "Recurrence Rule", class: "form-label" %>
    <%= form.hidden_field :rrule,
          data: { 
            "rrule-builder-target": "input"
          } %>

    <div class="space-y-4 rounded-lg border border-gray-200 p-4">
      <!-- Recurring Toggle -->
      <div class="space-y-2">
        <p class="text-sm font-medium text-gray-700">Recurring Event?</p>
        <div class="flex items-center gap-4 text-sm text-gray-700">
          <label class="inline-flex items-center gap-2">
            <input type="radio" 
                   name="event-recurring" 
                   value="no" 
                   checked="checked" 
                   data-action="change->rrule-builder#recurringToggle">
            <span>No</span>
          </label>
          <label class="inline-flex items-center gap-2">
            <input type="radio" 
                   name="event-recurring" 
                   value="yes" 
                   data-action="change->rrule-builder#recurringToggle">
            <span>Yes</span>
          </label>
        </div>
      </div>

      <!-- Recurring Rules (shown when "Yes" is selected) -->
      <div id="recurring-rules" 
           class="space-y-4" 
           style="display:none;" 
           data-rrule-builder-target="recurringRules">
        
        <!-- Frequency Selection -->
        <div class="space-y-2">
          <label class="text-sm font-medium text-gray-700">Frequency</label>
          <select name="freq" 
                  class="form-select w-full" 
                  data-action="change->rrule-builder#frequencyChange">
            <option value="daily" class="days">Daily</option>
            <option value="weekly" class="weeks">Weekly</option>
            <option value="monthly" class="months">Monthly</option>
            <option value="yearly" class="years">Yearly</option>
          </select>
        </div>

        <!-- Interval -->
        <div class="space-y-2">
          <label class="text-sm font-medium text-gray-700">Interval</label>
          <div class="flex items-center gap-2 text-sm text-gray-700">
            <span>Every</span>
            <input type="number" 
                   name="interval" 
                   value="1" 
                   min="1" 
                   class="form-control w-20" 
                   data-action="input->rrule-builder#intervalChange">
            <span class="freq-selection" 
                  data-rrule-builder-target="freqSelection">day(s)</span>
          </div>
        </div>

        <!-- Weekday Selection (shown for weekly) -->
        <div id="weekday-select" 
             class="weeks-choice space-y-2" 
             style="display:none;" 
             data-rrule-builder-target="weekdaySelect">
          <p class="text-sm font-medium text-gray-700">Days of the week</p>
          <div class="flex flex-wrap gap-2">
            <% %w[SU MO TU WE TH FR SA].each do |day| %>
              <button type="button" 
                      class="rounded border px-3 py-1 text-sm font-medium text-gray-700" 
                      id="<%= day %>" 
                      data-action="click->rrule-builder#weekdayClick">
                <%= day %>
              </button>
            <% end %>
          </div>
        </div>

        <!-- End Condition -->
        <div id="until-rules" 
             class="space-y-4" 
             style="display:none;" 
             data-rrule-builder-target="untilRules">
          <p class="text-sm font-medium text-gray-700">End condition</p>
          
          <label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700" 
                 for="count-select">
            <span class="inline-flex items-center gap-2">
              <input type="radio" 
                     name="end-select" 
                     value="count" 
                     id="count-select" 
                     checked="checked" 
                     data-action="change->rrule-builder#endSelect">
              <span>Number of occurrences</span>
            </span>
            <input type="number" 
                   name="count" 
                   min="1" 
                   max="50" 
                   value="1" 
                   step="1" 
                   class="form-control w-28" 
                   data-action="input->rrule-builder#countChange">
          </label>
          
          <label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700" 
                 for="until-select">
            <span class="inline-flex items-center gap-2">
              <input type="radio" 
                     name="end-select" 
                     value="until" 
                     id="until-select" 
                     data-action="change->rrule-builder#endSelect">
              <span>Specific date</span>
            </span>
            <input type="datetime-local" 
                   name="until" 
                   class="form-control" 
                   disabled 
                   data-action="change->rrule-builder#untilDateChange">
          </label>
        </div>
      </div>
    </div>

    <p class="text-sm text-gray-500 mt-1">Leave blank for one-off events</p>
  </div>

  <%= form.submit "Save", class: "btn btn-primary" %>
<% end %>

Controller Targets

The Stimulus controller uses the following targets:

  • input - The hidden field that stores the generated RRULE string
  • recurringRules - Container for recurring rule options
  • untilRules - Container for end condition options
  • freqSelection - Span that displays the frequency label (day/week/month/year)
  • weekdaySelect - Container for weekday selection buttons
  • monthdaySelect - Container for month day selection
  • bymonthSelect - Container for yearly month selection
  • yearlyMultipleMonthsButtons - Container for yearly multiple month buttons

Controller Values

  • initialRrule - An existing RRULE string to parse and display (optional)

Action Methods

All interactions use Stimulus data-action attributes:

  • recurringToggle - Toggle between recurring and one-off
  • frequencyChange - Change frequency (daily/weekly/monthly/yearly)
  • intervalChange - Update interval value
  • weekdayClick - Toggle weekday selection
  • monthdayClick - Toggle month day selection
  • monthdayPosSelect - Switch between specific days and relative patterns
  • monthBydayChange - Update monthly relative pattern
  • yearlyOptions - Switch yearly pattern type
  • yearlyMonthClick - Toggle yearly month selection
  • yearlySelectsChange - Update yearly select values
  • endSelect - Switch between count and until date
  • countChange - Update occurrence count
  • untilDateChange - Update until date
  • dtstartChange - Update when start date changes

Generated RRULE Format

The controller generates RRULE strings in iCalendar format:

FREQ=DAILY;INTERVAL=1;DTSTART=20240101T040000Z;COUNT=10
FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;DTSTART=20240101T040000Z;UNTIL=20241231T235959Z
FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15;DTSTART=20240101T040000Z;COUNT=12

Key Points:

  • Property names are uppercase (FREQ, INTERVAL, BYDAY, etc.)
  • FREQ values are uppercase (DAILY, WEEKLY, MONTHLY, YEARLY)
  • DTSTART format: YYYYMMDDTHHMMSSZ
  • UNTIL format: YYYYMMDDTHHMMSSZ
  • Multiple values are comma-separated (e.g., BYDAY=MO,WE,FR)

Styling

The component uses Tailwind CSS classes. You can customize the styling by:

  1. Modifying the classes in your form partial
  2. Adding custom CSS for the .active class on buttons
  3. Adjusting the border, padding, and spacing classes

Example button styling:

/* Active state for weekday/monthday buttons */
button.active {
  @apply bg-primary text-white;
}

Advanced Features

Monthly Patterns

The builder supports two monthly pattern types:

  1. Specific Days - Select specific days of the month (1-31)
  2. Relative Patterns - "First Monday", "Last Friday", etc.

Yearly Patterns

Three yearly pattern types:

  1. One Month - Specific month and day (e.g., "January 15")
  2. Multiple Months - Select multiple months with the same day
  3. Precise Pattern - "First Monday of January", "Last Friday of December", etc.

Integration with Other Controllers

The RRULE builder can trigger other Stimulus controllers when the RRULE changes:

<%= form.hidden_field :rrule,
      data: { 
        "rrule-builder-target": "input",
        action: "input->your-controller#handleRruleChange"
      } %>

Example: Displaying Occurrences

# In your controller
def show
  @your_model = YourModel.find(params[:id])
  @occurrences = @your_model.occurrences(limit: 50) if @your_model.has_schedule?
end
<!-- In your view -->
<% if @your_model.has_schedule? %>
  <h3>Upcoming Sessions</h3>
  <ul>
    <% @occurrences.each do |occurrence| %>
      <li><%= occurrence.strftime("%B %d, %Y at %I:%M %p") %></li>
    <% end %>
  </ul>
<% end %>

Troubleshooting

RRULE not generating

  • Ensure the dtstart field has a value
  • Check that "Recurring Event?" is set to "Yes"
  • Verify the hidden field has the correct data-rrule-builder-target="input" attribute

Existing RRULE not parsing

  • Ensure the RRULE string is in valid iCalendar format
  • Check that data-rrule-builder-initial-rrule-value is set correctly
  • Verify the RRULE includes both FREQ and DTSTART

Styling issues

  • Ensure Tailwind CSS is properly configured
  • Check that button .active class styles are defined
  • Verify form control classes match your design system

Credits

Based on the logic from rrule-generator-form but rewritten as a modern Stimulus controller without jQuery UI dependencies.

<div class="form-group" data-controller="rrule-builder" data-rrule-builder-initial-rrule-value="<%= resource.rrule || '' %>">
<%#= form.label :rrule, "Recurrence Rule (RRULE)", class: "form-label" %>
<%= form.text_field :rrule,
class: "",
data: { "rrule-builder-target": "input" } %>
<div class="space-y-4">
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700">Is this a recurring event?</p>
<div class="flex items-center gap-4 text-sm text-gray-700">
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="event-recurring" value="no" checked="checked" data-action="change->rrule-builder#recurringToggle">
<span>No</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<input type="radio" name="event-recurring" value="yes" data-action="change->rrule-builder#recurringToggle">
<span>Yes</span>
</label>
</div>
</div>
<div id="recurring-rules" class="space-y-4" style="display:none;" data-rrule-builder-target="recurringRules">
<div class="flex flex-row items-center gap-2">
<label class="text-sm mb-0 font-medium text-gray-700">Frequency</label>
<select name="freq" class="form-select w-full" data-action="change->rrule-builder#frequencyChange">
<option value="daily" class="days">Daily</option>
<option value="weekly" class="weeks">Weekly</option>
<option value="monthly" class="months">Monthly</option>
<!-- <option value="yearly" class="years">Yearly</option> -->
</select>
</div>
<div class="flex flex-row items-center gap-2">
<label class="mb-0 text-sm font-medium text-gray-700">Interval</label>
<div class="flex items-center gap-2 text-sm text-gray-700">
<span>Every</span>
<input type="number" name="interval" value="1" min="1" class="form-control w-20" data-action="input->rrule-builder#intervalChange">
<span class="freq-selection" data-rrule-builder-target="freqSelection">day(s)</span>
</div>
</div>
<div id="weekday-select" class="weeks-choice space-y-2" style="display:none;" data-rrule-builder-target="weekdaySelect">
<p class="text-sm font-medium text-gray-700">Days of the week</p>
<div class="flex flex-wrap gap-2">
<% %w[SU MO TU WE TH FR SA].each do |day| %>
<button type="button" class="rounded border px-3 py-1 text-sm font-medium active:bg-primary active:text-white" id="<%= day %>" data-action="click->rrule-builder#weekdayClick"><%= day %></button>
<% end %>
</div>
</div>
<div id="bymonth-select" class="years-choice space-y-4" style="display:none;" data-rrule-builder-target="bymonthSelect">
<div class="space-y-2">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="radio" name="yearly-options" id="yearly-one-month" checked="checked" data-action="change->rrule-builder#yearlyOptions">
<span>One month per year</span>
</label>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<select name="yearly-bymonth" id="yearly-bymonth" class="form-select yearly-one-month" data-action="change->rrule-builder#yearlySelectsChange">
<% (1..12).each do |month| %>
<option value="<%= month %>"><%= Date::MONTHNAMES[month] %></option>
<% end %>
</select>
<select name="yearly-bymonthday" id="yearly-bymonthday" class="form-select yearly-one-month" data-action="change->rrule-builder#yearlySelectsChange">
<% (1..31).each do |day| %>
<option value="<%= day %>"><%= day %></option>
<% end %>
</select>
</div>
</div>
<div class="space-y-2">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="radio" name="yearly-options" id="yearly-multiple-months" data-action="change->rrule-builder#yearlyOptions">
<span>Multiple months</span>
</label>
<div class="grid grid-cols-3 gap-2 yearly-multiple-months" data-rrule-builder-target="yearlyMultipleMonthsButtons">
<% Date::ABBR_MONTHNAMES.compact.each_with_index do |name, idx| %>
<button type="button" class="rounded border px-2 py-1 text-sm font-medium text-gray-700"
data-month-num="<%= idx + 1 %>" disabled="disabled" data-action="click->rrule-builder#yearlyMonthClick">
<%= name %>
</button>
<% end %>
</div>
</div>
<div class="space-y-2">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="radio" name="yearly-options" id="yearly-precise" data-action="change->rrule-builder#yearlyOptions">
<span>Precise pattern</span>
</label>
<div class="grid grid-cols-1 gap-2 md:grid-cols-3">
<select name="yearly-bysetpos" class="form-select yearly-precise" disabled="disabled" data-action="change->rrule-builder#yearlySelectsChange">
<option value="1">First</option>
<option value="2">Second</option>
<option value="3">Third</option>
<option value="4">Fourth</option>
<option value="-1">Last</option>
</select>
<select name="yearly-byday" class="form-select yearly-precise" disabled="disabled" data-action="change->rrule-builder#yearlySelectsChange">
<option value="SU">Sunday</option>
<option value="MO">Monday</option>
<option value="TU">Tuesday</option>
<option value="WE">Wednesday</option>
<option value="TH">Thursday</option>
<option value="FR">Friday</option>
<option value="SA">Saturday</option>
<option value="SU,MO,TU,WE,TH,FR,SA">Day</option>
<option value="MO,TU,WE,TH,FR">Weekday</option>
<option value="SU,SA">Weekend day</option>
</select>
<select name="yearly-bymonth-with-bysetpos-byday" class="form-select yearly-precise" disabled="disabled" data-action="change->rrule-builder#yearlySelectsChange">
<% (1..12).each do |month| %>
<option value="<%= month %>"><%= Date::MONTHNAMES[month] %></option>
<% end %>
</select>
</div>
</div>
</div>
<div id="monthday-select" class="months-choice space-y-4" style="display:none;" data-rrule-builder-target="monthdaySelect">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="radio" name="monthday-pos-select" value="monthday-selected" id="monthday-selected" checked="checked" data-action="change->rrule-builder#monthdayPosSelect">
<span>Specific days</span>
</label>
<div class="grid grid-cols-7 gap-2">
<% (1..31).each do |day| %>
<button type="button" class="rounded border px-2 py-1 text-sm font-medium" data-day-num="<%= day %>" data-action="click->rrule-builder#monthdayClick">
<%= day %>
</button>
<% end %>
</div>
<div class="space-y-2">
<label class="inline-flex items-center gap-2 text-sm font-medium text-gray-700">
<input type="radio" name="monthday-pos-select" value="month-byday-pos-selected" id="month-byday-pos-selected" data-action="change->rrule-builder#monthdayPosSelect">
<span>Relative pattern</span>
</label>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<select name="month-byday-pos" class="form-select" disabled="yes" data-action="change->rrule-builder#monthBydayChange">
<option value="1">First</option>
<option value="2">Second</option>
<option value="3">Third</option>
<option value="4">Fourth</option>
<option value="-1">Last</option>
</select>
<select name="month-byday-pos-name" class="form-select" disabled="yes" data-action="change->rrule-builder#monthBydayChange">
<option value="SU">Sunday</option>
<option value="MO">Monday</option>
<option value="TU">Tuesday</option>
<option value="WE">Wednesday</option>
<option value="TH">Thursday</option>
<option value="FR">Friday</option>
<option value="SA">Saturday</option>
<option value="SU,MO,TU,WE,TH,FR,SA">Day</option>
<option value="MO,TU,WE,TH,FR">Weekday</option>
<option value="SU,SA">Weekend day</option>
</select>
</div>
</div>
</div>
<p class="text-sm font-medium text-gray-700">End condition</p>
<div id="until-rules" class="grid grid-cols-1 sm:grid-cols-2 gap-2" style="display:none;" data-rrule-builder-target="untilRules">
<label class="flex flex-col gap-2 cursor-pointer rounded border border-gray-200 p-3 text-sm text-gray-700" for="count-select">
<span class="inline-flex items-center gap-2">
<input type="radio" name="end-select" value="count" id="count-select" checked="checked" data-action="change->rrule-builder#endSelect">
<span>After number of occurrences</span>
</span>
<input type="number" name="count" min="1" max="50" value="1" step="1" class="form-control w-28" data-action="input->rrule-builder#countChange">
</label>
<label class="flex flex-col gap-2 cursor-pointer rounded border border-gray-200 p-3 text-sm text-gray-700" for="until-select">
<span class="inline-flex items-center gap-2">
<input type="radio" name="end-select" value="until" id="until-select" data-action="change->rrule-builder#endSelect">
<span>Until end date</span>
</span>
<input type="datetime-local" name="until" class="form-control" disabled data-action="change->rrule-builder#untilDateChange">
</label>
</div>
</div>
</div>
<p class="text-sm text-gray-500 mt-1">Leave blank for one-off activities</p>
</div>
# RRule Builder for Rails
A Stimulus-based RRULE (iCalendar recurrence rule) builder component for Rails applications. This provides a user-friendly interface for creating recurring event schedules without requiring external dependencies like jQuery UI or React.
## Overview
This RRule builder generates iCalendar-compliant RRULE strings that can be used with libraries like `rrule` (Ruby gem) or `rrule.js` (JavaScript) to calculate recurring event occurrences.
**Features:**
- ✅ Pure Stimulus controller (no jQuery UI or React dependencies)
- ✅ Declarative data-action attributes (idiomatic Stimulus)
- ✅ Supports daily, weekly, monthly, and yearly frequencies
- ✅ Flexible interval support (every N days/weeks/months/years)
- ✅ Weekday selection for weekly patterns
- ✅ Month day selection for monthly patterns
- ✅ Yearly patterns with month/day selection
- ✅ End conditions (count or until date)
- ✅ Parses and displays existing RRULE strings
- ✅ Integrates with Rails forms seamlessly
## Dependencies
### Ruby Gems
```ruby
# Gemfile
gem 'rrule' # For parsing and generating occurrences from RRULE strings
```
### JavaScript
- **Stimulus** (already included in Rails 7+ with Hotwire)
- No additional JavaScript dependencies required
## Installation
### 1. Copy the Stimulus Controller
Copy `app/javascript/controllers/rrule_builder_controller.js` to your Rails app:
```bash
# In your Rails app
cp /path/to/rrule_builder_controller.js app/javascript/controllers/
```
### 2. Register the Controller
Ensure the controller is registered in `app/javascript/controllers/index.js`:
```javascript
import RruleBuilderController from "./rrule_builder_controller"
application.register("rrule-builder", RruleBuilderController)
```
### 3. Add Model Support
Add the necessary fields to your model:
```ruby
# Migration
class AddScheduleFieldsToYourModel < ActiveRecord::Migration[7.0]
def change
add_column :your_models, :dtstart, :datetime
add_column :your_models, :rrule, :string
add_column :your_models, :duration, :string # ISO8601 duration format (e.g., "PT1H30M")
end
end
```
Update your model:
```ruby
# app/models/your_model.rb
class YourModel < ApplicationRecord
# Parse RRULE and generate occurrences
def occurrences(limit: nil)
return [dtstart] if rrule.blank?
options = { dtstart: dtstart }
options[:tzid] = timezone if respond_to?(:timezone) && timezone.present?
rrule_instance = RRule::Rule.new(rrule, **options)
limit ? rrule_instance.all(limit: limit) : rrule_instance.all
end
def has_schedule?
dtstart.present? || rrule.present?
end
end
```
### 4. Update Strong Parameters
Add schedule fields to your controller's strong parameters:
```ruby
# app/controllers/your_controller.rb
def your_model_params
params.require(:your_model).permit(:dtstart, :rrule, :duration, ...)
end
```
## Usage
### Basic Form Integration
Add the RRule builder to your form:
```erb
<!-- app/views/your_models/_form.html.erb -->
<%= form_with model: [@account, @your_model] do |form| %>
<!-- Start Date/Time Field -->
<div class="form-group">
<%= form.label :dtstart, "Start Date/Time", class: "form-label" %>
<%= form.datetime_local_field :dtstart,
class: "form-control",
data: {
action: "change->rrule-builder#dtstartChange"
} %>
</div>
<!-- RRule Builder -->
<div class="form-group"
data-controller="rrule-builder"
data-rrule-builder-initial-rrule-value="<%= @your_model.rrule || '' %>">
<%= form.label :rrule, "Recurrence Rule", class: "form-label" %>
<%= form.hidden_field :rrule,
data: {
"rrule-builder-target": "input"
} %>
<div class="space-y-4 rounded-lg border border-gray-200 p-4">
<!-- Recurring Toggle -->
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700">Recurring Event?</p>
<div class="flex items-center gap-4 text-sm text-gray-700">
<label class="inline-flex items-center gap-2">
<input type="radio"
name="event-recurring"
value="no"
checked="checked"
data-action="change->rrule-builder#recurringToggle">
<span>No</span>
</label>
<label class="inline-flex items-center gap-2">
<input type="radio"
name="event-recurring"
value="yes"
data-action="change->rrule-builder#recurringToggle">
<span>Yes</span>
</label>
</div>
</div>
<!-- Recurring Rules (shown when "Yes" is selected) -->
<div id="recurring-rules"
class="space-y-4"
style="display:none;"
data-rrule-builder-target="recurringRules">
<!-- Frequency Selection -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Frequency</label>
<select name="freq"
class="form-select w-full"
data-action="change->rrule-builder#frequencyChange">
<option value="daily" class="days">Daily</option>
<option value="weekly" class="weeks">Weekly</option>
<option value="monthly" class="months">Monthly</option>
<option value="yearly" class="years">Yearly</option>
</select>
</div>
<!-- Interval -->
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700">Interval</label>
<div class="flex items-center gap-2 text-sm text-gray-700">
<span>Every</span>
<input type="number"
name="interval"
value="1"
min="1"
class="form-control w-20"
data-action="input->rrule-builder#intervalChange">
<span class="freq-selection"
data-rrule-builder-target="freqSelection">day(s)</span>
</div>
</div>
<!-- Weekday Selection (shown for weekly) -->
<div id="weekday-select"
class="weeks-choice space-y-2"
style="display:none;"
data-rrule-builder-target="weekdaySelect">
<p class="text-sm font-medium text-gray-700">Days of the week</p>
<div class="flex flex-wrap gap-2">
<% %w[SU MO TU WE TH FR SA].each do |day| %>
<button type="button"
class="rounded border px-3 py-1 text-sm font-medium text-gray-700"
id="<%= day %>"
data-action="click->rrule-builder#weekdayClick">
<%= day %>
</button>
<% end %>
</div>
</div>
<!-- End Condition -->
<div id="until-rules"
class="space-y-4"
style="display:none;"
data-rrule-builder-target="untilRules">
<p class="text-sm font-medium text-gray-700">End condition</p>
<label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700"
for="count-select">
<span class="inline-flex items-center gap-2">
<input type="radio"
name="end-select"
value="count"
id="count-select"
checked="checked"
data-action="change->rrule-builder#endSelect">
<span>Number of occurrences</span>
</span>
<input type="number"
name="count"
min="1"
max="50"
value="1"
step="1"
class="form-control w-28"
data-action="input->rrule-builder#countChange">
</label>
<label class="flex flex-col gap-2 rounded border border-gray-200 p-3 text-sm text-gray-700"
for="until-select">
<span class="inline-flex items-center gap-2">
<input type="radio"
name="end-select"
value="until"
id="until-select"
data-action="change->rrule-builder#endSelect">
<span>Specific date</span>
</span>
<input type="datetime-local"
name="until"
class="form-control"
disabled
data-action="change->rrule-builder#untilDateChange">
</label>
</div>
</div>
</div>
<p class="text-sm text-gray-500 mt-1">Leave blank for one-off events</p>
</div>
<%= form.submit "Save", class: "btn btn-primary" %>
<% end %>
```
## Controller Targets
The Stimulus controller uses the following targets:
- `input` - The hidden field that stores the generated RRULE string
- `recurringRules` - Container for recurring rule options
- `untilRules` - Container for end condition options
- `freqSelection` - Span that displays the frequency label (day/week/month/year)
- `weekdaySelect` - Container for weekday selection buttons
- `monthdaySelect` - Container for month day selection
- `bymonthSelect` - Container for yearly month selection
- `yearlyMultipleMonthsButtons` - Container for yearly multiple month buttons
## Controller Values
- `initialRrule` - An existing RRULE string to parse and display (optional)
## Action Methods
All interactions use Stimulus `data-action` attributes:
- `recurringToggle` - Toggle between recurring and one-off
- `frequencyChange` - Change frequency (daily/weekly/monthly/yearly)
- `intervalChange` - Update interval value
- `weekdayClick` - Toggle weekday selection
- `monthdayClick` - Toggle month day selection
- `monthdayPosSelect` - Switch between specific days and relative patterns
- `monthBydayChange` - Update monthly relative pattern
- `yearlyOptions` - Switch yearly pattern type
- `yearlyMonthClick` - Toggle yearly month selection
- `yearlySelectsChange` - Update yearly select values
- `endSelect` - Switch between count and until date
- `countChange` - Update occurrence count
- `untilDateChange` - Update until date
- `dtstartChange` - Update when start date changes
## Generated RRULE Format
The controller generates RRULE strings in iCalendar format:
```
FREQ=DAILY;INTERVAL=1;DTSTART=20240101T040000Z;COUNT=10
FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR;DTSTART=20240101T040000Z;UNTIL=20241231T235959Z
FREQ=MONTHLY;INTERVAL=1;BYMONTHDAY=15;DTSTART=20240101T040000Z;COUNT=12
```
**Key Points:**
- Property names are uppercase (FREQ, INTERVAL, BYDAY, etc.)
- FREQ values are uppercase (DAILY, WEEKLY, MONTHLY, YEARLY)
- DTSTART format: `YYYYMMDDTHHMMSSZ`
- UNTIL format: `YYYYMMDDTHHMMSSZ`
- Multiple values are comma-separated (e.g., `BYDAY=MO,WE,FR`)
## Styling
The component uses Tailwind CSS classes. You can customize the styling by:
1. Modifying the classes in your form partial
2. Adding custom CSS for the `.active` class on buttons
3. Adjusting the border, padding, and spacing classes
Example button styling:
```css
/* Active state for weekday/monthday buttons */
button.active {
@apply bg-primary text-white;
}
```
## Advanced Features
### Monthly Patterns
The builder supports two monthly pattern types:
1. **Specific Days** - Select specific days of the month (1-31)
2. **Relative Patterns** - "First Monday", "Last Friday", etc.
### Yearly Patterns
Three yearly pattern types:
1. **One Month** - Specific month and day (e.g., "January 15")
2. **Multiple Months** - Select multiple months with the same day
3. **Precise Pattern** - "First Monday of January", "Last Friday of December", etc.
## Integration with Other Controllers
The RRULE builder can trigger other Stimulus controllers when the RRULE changes:
```erb
<%= form.hidden_field :rrule,
data: {
"rrule-builder-target": "input",
action: "input->your-controller#handleRruleChange"
} %>
```
## Example: Displaying Occurrences
```ruby
# In your controller
def show
@your_model = YourModel.find(params[:id])
@occurrences = @your_model.occurrences(limit: 50) if @your_model.has_schedule?
end
```
```erb
<!-- In your view -->
<% if @your_model.has_schedule? %>
<h3>Upcoming Sessions</h3>
<ul>
<% @occurrences.each do |occurrence| %>
<li><%= occurrence.strftime("%B %d, %Y at %I:%M %p") %></li>
<% end %>
</ul>
<% end %>
```
## Troubleshooting
### RRULE not generating
- Ensure the `dtstart` field has a value
- Check that "Recurring Event?" is set to "Yes"
- Verify the hidden field has the correct `data-rrule-builder-target="input"` attribute
### Existing RRULE not parsing
- Ensure the RRULE string is in valid iCalendar format
- Check that `data-rrule-builder-initial-rrule-value` is set correctly
- Verify the RRULE includes both FREQ and DTSTART
### Styling issues
- Ensure Tailwind CSS is properly configured
- Check that button `.active` class styles are defined
- Verify form control classes match your design system
## Credits
Based on the logic from `rrule-generator-form` but rewritten as a modern Stimulus controller without jQuery UI dependencies.
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = [
"input", // Hidden rrule input field
"recurringRules", // Container for recurring rules
"untilRules", // Container for until/count rules
"freqSelection", // Frequency selection span
"weekdaySelect", // Weekday selection container
"monthdaySelect", // Monthday selection container
"bymonthSelect", // Bymonth selection container
"yearlyMultipleMonthsButtons" // Container for yearly multiple month buttons
];
static values = {
initialRrule: String
}
connect() {
console.log("RruleBuilderController connected");
this.recurringRule = {
freq: "daily",
dtstart: "",
interval: "1",
byday: "",
bysetpos: "",
bymonthday: "",
bymonth: "",
count: "",
until: ""
};
// Initialize from existing RRULE if present
if (this.initialRruleValue) {
this.parseRRule(this.initialRruleValue);
} else {
this.resetOptions();
}
this.updateRRule();
}
recurringToggle(event) {
const value = event.target.value;
if (value === "yes") {
this.recurringRulesTarget.style.display = "block";
this.untilRulesTarget.style.display = "block";
} else {
this.recurringRulesTarget.style.display = "none";
this.untilRulesTarget.style.display = "none";
this.recurringRule = {
freq: "daily",
dtstart: "",
interval: "1",
byday: "",
bysetpos: "",
bymonthday: "",
bymonth: "",
count: "",
until: ""
};
this.updateRRule();
}
}
frequencyChange(event) {
const freq = event.target.value;
this.recurringRule.freq = freq;
// Hide all frequency-specific containers
this.weekdaySelectTarget?.style.setProperty("display", "none");
this.monthdaySelectTarget?.style.setProperty("display", "none");
this.bymonthSelectTarget?.style.setProperty("display", "none");
// Show appropriate container
const freqClass = freq === "weekly" ? "weeks" :
freq === "monthly" ? "months" :
freq === "yearly" ? "years" : "days";
if (this.freqSelectionTarget) {
this.freqSelectionTarget.textContent = freqClass;
}
if (freq === "weekly") {
this.weekdaySelectTarget?.style.setProperty("display", "block");
} else if (freq === "monthly") {
this.monthdaySelectTarget?.style.setProperty("display", "block");
} else if (freq === "yearly") {
this.bymonthSelectTarget?.style.setProperty("display", "block");
}
// Reset frequency-specific fields
this.recurringRule.byday = "";
this.recurringRule.bysetpos = "";
this.recurringRule.bymonthday = "";
this.recurringRule.bymonth = "";
this.updateRRule();
}
weekdayClick(event) {
event.preventDefault();
const button = event.currentTarget;
button.classList.toggle("active");
const byday = [];
this.element.querySelectorAll("#weekday-select button.active").forEach(btn => {
byday.push(btn.id);
});
this.recurringRule.byday = byday.join(",");
this.updateRRule();
}
monthdayClick(event) {
event.preventDefault();
const button = event.currentTarget;
button.classList.toggle("active");
const bymonthday = [];
this.element.querySelectorAll("#monthday-select button.active").forEach(btn => {
bymonthday.push(btn.dataset.dayNum);
});
this.recurringRule.bymonthday = bymonthday.join(",");
this.recurringRule.byday = "";
this.recurringRule.bysetpos = "";
this.updateRRule();
}
monthdayPosSelect(event) {
const value = event.target.value;
if (value === "month-byday-pos-selected") {
// Disable monthday buttons, enable selects
this.element.querySelectorAll("#monthday-select button").forEach(btn => {
btn.disabled = true;
});
this.element.querySelectorAll('select[name^="month-byday"]').forEach(sel => {
sel.disabled = false;
});
this.recurringRule.bymonthday = "";
this.monthBydayChange();
} else {
// Enable monthday buttons, disable selects
this.element.querySelectorAll("#monthday-select button").forEach(btn => {
btn.disabled = false;
btn.classList.remove("active");
});
this.element.querySelectorAll('select[name^="month-byday"]').forEach(sel => {
sel.disabled = true;
});
this.recurringRule.byday = "";
this.recurringRule.bysetpos = "";
this.updateRRule();
}
}
intervalChange(event) {
this.recurringRule.interval = event.target.value || "1";
this.updateRRule();
}
countChange(event) {
this.recurringRule.count = event.target.value;
this.recurringRule.until = "";
this.updateRRule();
}
monthBydayChange() {
const bysetpos = this.element.querySelector('select[name="month-byday-pos"]')?.value || "";
const byday = this.element.querySelector('select[name="month-byday-pos-name"]')?.value || "";
this.recurringRule.bysetpos = bysetpos;
this.recurringRule.byday = byday; // Keep as string, RRULE format expects comma-separated
this.updateRRule();
}
yearlySelectsChange() {
this.handleYearlySelects();
}
dtstartChange() {
this.updateRRule();
}
yearlyOptions(event) {
const optionId = event.target.id;
// Reset all yearly fields
this.recurringRule.bymonthday = "";
this.recurringRule.byday = "";
this.recurringRule.bysetpos = "";
if (optionId === "yearly-one-month") {
// Enable one month selects
this.element.querySelectorAll('select.yearly-one-month').forEach(sel => {
sel.disabled = false;
});
this.element.querySelectorAll('select[class^="yearly"]:not(.yearly-one-month)').forEach(sel => {
sel.disabled = true;
});
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => {
btn.disabled = true;
btn.classList.remove("active");
});
this.handleYearlySelects();
} else if (optionId === "yearly-multiple-months") {
// Enable month buttons
this.element.querySelectorAll('select[class^="yearly"]').forEach(sel => {
sel.disabled = true;
});
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => {
btn.disabled = false;
});
this.recurringRule.bymonth = [];
this.updateRRule();
} else if (optionId === "yearly-precise") {
// Enable precise selects
this.element.querySelectorAll('select.yearly-precise').forEach(sel => {
sel.disabled = false;
});
this.element.querySelectorAll('select[class^="yearly"]:not(.yearly-precise)').forEach(sel => {
sel.disabled = true;
});
this.element.querySelectorAll('.yearly-multiple-months button').forEach(btn => {
btn.disabled = true;
btn.classList.remove("active");
});
this.handleYearlySelects();
}
}
yearlyMonthClick(event) {
event.preventDefault();
const button = event.currentTarget;
button.classList.toggle("active");
this.handleYearlySelects();
}
handleYearlySelects() {
const yearlyBymonth = this.element.querySelector('select[name="yearly-bymonth"]')?.value;
const yearlyBymonthday = this.element.querySelector('select[name="yearly-bymonthday"]')?.value;
const yearlyBysetpos = this.element.querySelector('select[name="yearly-bysetpos"]')?.value;
const yearlyByday = this.element.querySelector('select[name="yearly-byday"]')?.value;
const yearlyBymonthWithByday = this.element.querySelector('select[name="yearly-bymonth-with-bysetpos-byday"]')?.value;
if (yearlyBymonth && yearlyBymonthday) {
this.recurringRule.bymonth = yearlyBymonth;
this.recurringRule.bymonthday = yearlyBymonthday;
} else if (yearlyBysetpos && yearlyByday && yearlyBymonthWithByday) {
this.recurringRule.bymonth = yearlyBymonthWithByday;
this.recurringRule.byday = yearlyByday; // Keep as string
this.recurringRule.bysetpos = yearlyBysetpos;
this.recurringRule.bymonthday = "";
}
// Handle multiple months
const activeMonths = [];
this.element.querySelectorAll('.yearly-multiple-months button.active').forEach(btn => {
activeMonths.push(btn.dataset.monthNum);
});
if (activeMonths.length > 0) {
this.recurringRule.bymonth = activeMonths.join(",");
this.recurringRule.bymonthday = "";
}
this.updateRRule();
}
endSelect(event) {
const value = event.target.value;
if (value === "count") {
this.element.querySelector('input[name="until"]').disabled = true;
this.element.querySelector('input[name="count"]').disabled = false;
this.recurringRule.until = "";
} else if (value === "until") {
this.element.querySelector('input[name="count"]').disabled = true;
this.element.querySelector('input[name="until"]').disabled = false;
this.recurringRule.count = "";
const untilValue = this.element.querySelector('input[name="until"]').value;
if (untilValue) {
this.untilDateChange({ target: { value: untilValue } });
}
}
}
untilDateChange(event) {
const dateValue = event.target.value;
if (!dateValue) return;
const date = new Date(dateValue);
const untilString = date.getFullYear() +
('0' + (date.getMonth() + 1)).slice(-2) +
('0' + date.getDate()).slice(-2);
this.recurringRule.until = untilString + 'T040000z';
this.recurringRule.count = "";
this.updateRRule();
}
resetOptions() {
const today = new Date();
const dtstartString = today.getFullYear() +
('0' + (today.getMonth() + 1)).slice(-2) +
('0' + today.getDate()).slice(-2);
this.recurringRule = {
freq: "daily",
dtstart: dtstartString + 'T040000z',
interval: "1",
byday: "",
bysetpos: "",
bymonthday: "",
bymonth: "",
count: "1",
until: ""
};
// Reset UI
this.element.querySelectorAll('button').forEach(btn => {
btn.classList.remove("active");
});
if (this.freqSelectionTarget) {
this.freqSelectionTarget.textContent = "days";
}
this.element.querySelector('select[name="freq"]').value = "daily";
this.element.querySelector('input[name="interval"]').value = "1";
this.element.querySelector('input[name="count"]').value = "1";
this.element.querySelector('input[id="count-select"]').checked = true;
this.element.querySelector('input[id="until-select"]').checked = false;
}
updateRRule() {
// Check if recurring is enabled
const recurringYes = this.element.querySelector('input[name="event-recurring"][value="yes"]')?.checked;
if (!recurringYes) {
// Clear RRULE if not recurring
if (this.inputTarget) {
this.inputTarget.value = "";
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
return;
}
// Get dtstart from the main form's dtstart field
const form = this.element.closest('form');
const dtstartField = form?.querySelector('input[name*="dtstart"]') ||
form?.querySelector('input[type="datetime-local"]');
if (dtstartField && dtstartField.value) {
const date = new Date(dtstartField.value);
if (!isNaN(date.getTime())) {
const dtstartString = date.getFullYear() +
('0' + (date.getMonth() + 1)).slice(-2) +
('0' + date.getDate()).slice(-2);
this.recurringRule.dtstart = dtstartString + 'T040000z';
}
} else if (!this.recurringRule.dtstart) {
// If no dtstart field found, use today's date
const today = new Date();
const dtstartString = today.getFullYear() +
('0' + (today.getMonth() + 1)).slice(-2) +
('0' + today.getDate()).slice(-2);
this.recurringRule.dtstart = dtstartString + 'T040000z';
}
// Ensure count or until is set
if (!this.recurringRule.count && !this.recurringRule.until) {
this.recurringRule.count = "1";
}
// Build RRULE string
let rrule = "";
for (const key in this.recurringRule) {
if (this.recurringRule[key] !== "" && this.recurringRule[key] !== null && this.recurringRule[key] !== undefined) {
let value = this.recurringRule[key];
if (Array.isArray(value)) {
value = value.join(",");
}
// Uppercase FREQ value (DAILY, WEEKLY, MONTHLY, YEARLY)
if (key === "freq") {
value = value.toUpperCase();
}
rrule += key.toUpperCase() + '=' + value + ';';
}
}
// Remove trailing semicolon
rrule = rrule.replace(/;$/, "");
// Update hidden field
if (this.inputTarget) {
this.inputTarget.value = rrule;
this.inputTarget.dispatchEvent(new Event("input", { bubbles: true }));
}
}
parseRRule(rruleString) {
if (!rruleString) return;
const items = rruleString.split(';');
const recur = {};
items.forEach(item => {
if (item) {
const [key, value] = item.split('=');
if (key && value) {
recur[key.toUpperCase()] = value;
}
}
});
if (!recur.FREQ || !recur.DTSTART) return;
// Set basic fields
this.recurringRule.freq = recur.FREQ.toLowerCase();
this.recurringRule.dtstart = recur.DTSTART;
this.recurringRule.interval = recur.INTERVAL || "1";
// Set end condition
if (recur.COUNT) {
this.recurringRule.count = recur.COUNT;
this.recurringRule.until = "";
this.element.querySelector('input[id="count-select"]').checked = true;
this.element.querySelector('input[name="count"]').value = recur.COUNT;
} else if (recur.UNTIL) {
this.recurringRule.until = recur.UNTIL;
this.recurringRule.count = "";
this.element.querySelector('input[id="until-select"]').checked = true;
// Parse until date for display
const untilDate = this.parseRRuleDate(recur.UNTIL);
if (untilDate) {
this.element.querySelector('input[name="until"]').value = untilDate;
}
}
// Set frequency-specific fields
if (recur.BYDAY) this.recurringRule.byday = recur.BYDAY;
if (recur.BYSETPOS) this.recurringRule.bysetpos = recur.BYSETPOS;
if (recur.BYMONTHDAY) this.recurringRule.bymonthday = recur.BYMONTHDAY;
if (recur.BYMONTH) this.recurringRule.bymonth = recur.BYMONTH;
// Update UI based on frequency
this.element.querySelector('input[name="event-recurring"][value="yes"]').checked = true;
this.recurringToggle({ target: { value: "yes" } });
this.element.querySelector('select[name="freq"]').value = this.recurringRule.freq;
this.frequencyChange({ target: { value: this.recurringRule.freq } });
// Populate frequency-specific UI
this.populateFrequencyUI(recur);
}
populateFrequencyUI(recur) {
if (this.recurringRule.freq === "weekly" && recur.BYDAY) {
recur.BYDAY.split(",").forEach(day => {
const btn = this.element.querySelector(`#weekday-select button#${day}`);
if (btn) btn.classList.add("active");
});
} else if (this.recurringRule.freq === "monthly") {
if (recur.BYMONTHDAY) {
this.element.querySelector('input[id="monthday-selected"]').checked = true;
this.monthdayPosSelect({ target: { value: "monthday-selected" } });
recur.BYMONTHDAY.split(",").forEach(day => {
const btn = this.element.querySelector(`#monthday-select button[data-day-num="${day}"]`);
if (btn) btn.classList.add("active");
});
} else if (recur.BYSETPOS && recur.BYDAY) {
this.element.querySelector('input[id="month-byday-pos-selected"]').checked = true;
this.monthdayPosSelect({ target: { value: "month-byday-pos-selected" } });
this.element.querySelector('select[name="month-byday-pos"]').value = recur.BYSETPOS;
this.element.querySelector('select[name="month-byday-pos-name"]').value = recur.BYDAY;
this.monthBydayChange();
}
} else if (this.recurringRule.freq === "yearly") {
if (recur.BYMONTH && recur.BYMONTHDAY && !recur.BYDAY) {
this.element.querySelector('input[id="yearly-one-month"]').checked = true;
this.yearlyOptions({ target: { id: "yearly-one-month" } });
this.element.querySelector('select[name="yearly-bymonth"]').value = recur.BYMONTH;
this.element.querySelector('select[name="yearly-bymonthday"]').value = recur.BYMONTHDAY;
this.handleYearlySelects();
} else if (recur.BYMONTH && !recur.BYMONTHDAY) {
this.element.querySelector('input[id="yearly-multiple-months"]').checked = true;
this.yearlyOptions({ target: { id: "yearly-multiple-months" } });
recur.BYMONTH.split(",").forEach(month => {
const btn = this.element.querySelector(`.yearly-multiple-months button[data-month-num="${month}"]`);
if (btn) btn.classList.add("active");
});
this.handleYearlySelects();
} else if (recur.BYMONTH && recur.BYDAY && recur.BYSETPOS) {
this.element.querySelector('input[id="yearly-precise"]').checked = true;
this.yearlyOptions({ target: { id: "yearly-precise" } });
this.element.querySelector('select[name="yearly-bysetpos"]').value = recur.BYSETPOS;
this.element.querySelector('select[name="yearly-byday"]').value = recur.BYDAY;
this.element.querySelector('select[name="yearly-bymonth-with-bysetpos-byday"]').value = recur.BYMONTH;
this.handleYearlySelects();
}
}
}
parseRRuleDate(dateString) {
// Parse RRULE date format (YYYYMMDDTHHMMSSZ) to datetime-local format
if (dateString.length >= 8) {
const year = dateString.substring(0, 4);
const month = dateString.substring(4, 6);
const day = dateString.substring(6, 8);
return `${year}-${month}-${day}T00:00`;
}
return null;
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment