DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

2 1

Natural Language Parser for Recurring Events using Stimulus

This article was originally published on Rails Designer


In this article I explored how I add recurring events in Rails. Let's now look at how to add a natural language parser input field. Instead of having users select from dropdown menus with Daily, Weekly, etc., I want users to type things like every week or monthly on the 15th.

Something like this:

Image description

(not pretty, but it works! 😄)

This feature is built on top of the work from the previous article. View the complete implementation in this commit.

First, let's look at the view changes. Replace the previous basic select input, in app/views/events/_form.html.erb, with a text field:

- <div>
-   <%= form.label :recurring_type, "Repeats" %>
-   <%= form.select :recurring_type, [
-     ["Daily", "daily"],
-     ["Weekly", "weekly"],
-     ["Every 2 weeks", "biweekly"],
-     ["Monthly", "monthly"]
-   ], { include_blank: "No recurrence" } %>
+ <div data-controller="recurring">
+   <%= form.label :natural_recurring %>
+   <%= form.text_field :natural_recurring, data: {action: "recurring#parse"} %>
+   <%= form.hidden_field :recurring_rule, data: {recurring_target: "input"} %>
+
+   <small data-recurring-target="feedback"></small>
</div>
Enter fullscreen mode Exit fullscreen mode

Now the Stimulus controller that handles the user input and provides feedback:

// app/javascript/controllers/recurring_controller.js
import { Controller } from "@hotwired/stimulus"
import { RecurringParser } from "src/recurring_parser"

export default class extends Controller {
  static targets = ["input", "feedback"]

  parse(event) {
    const result = this.#parser.parse(event.currentTarget.value)

    if (result.valid) {
      this.feedbackTarget.textContent = ""

      this.inputTarget.value = JSON.stringify(result.rule)
    } else {
      this.feedbackTarget.textContent = "Don't know what you mean…"

      this.inputTarget.value = ""
    }
  }

  get #parser() {
    return new RecurringParser({ backend: "iceCube" })
  }
}
Enter fullscreen mode Exit fullscreen mode

Really a basic Stimulus controller that is possible because the core of the implementation lives in the RecurringParser class. It uses a backend pattern that currently only supports IceCube (which I also used in the previous article), but is designed to be extensible for other recurring event implementations:

// app/javascript/src/recurring_parser.js
import { iceCube } from "src/recurring_parser/backends/ice_cube"
import { patterns } from "src/recurring_parser/patterns"

export class RecurringParser {
  static backends = { iceCube }

  constructor(options = {}) {
    this.backend = RecurringParser.backends[options.backend || "iceCube"]
  }

  parse(input) {
    if (!input?.trim()) return { valid: false }

    let result = { valid: false }

    Object.values(patterns).forEach(pattern => {
      const matches = input.match(pattern.regex)

      if (!matches) return

      result = {
        valid: true,
        rule: pattern.parse(matches, this.backend)
      }
    })

    return result
  }
}
Enter fullscreen mode Exit fullscreen mode

See how the result object returns valid: false by default? And true if a match is found? These valid and rule keys are used in the above Stimulus controller to show the feedback message.

Feel like this is all a bit overwhelming? Check out the book JavaScript for Rails Developers. 💡

The parser matches input against different patterns using regular expressions. Each pattern knows how to convert itself into the correct backend format:

// app/javascript/src/recurring_parser/patterns.js
export const patterns = {
  daily: {
    regex: /^every\s+day|daily$/i,
    parse: (_, backend) => backend.daily()
  },

  weekly: {
    regex: new RegExp(`^(?:every\\s+week|weekly)(?:\\s+on\\s+${dayPattern})?$`, "i"),
    parse: (matches, backend) => {
      const currentDay = new Date().getDay()
      const day = matches[1] ? dayToNumber(matches[1]) : currentDay

      return backend.weekly(day)
    }
  },

  // … see patterns for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
Enter fullscreen mode Exit fullscreen mode

The patterns support variations like:

  • every day or daily
  • weekly on monday or just weekly
  • every 2 weeks or bi-weekly
  • monthly on the 15th
  • yearly on december 25

The backend adapter (in this case for IceCube) defines how these patterns translate to the actual recurring event implementation need for the Event's recurring_rules field:

// app/javascript/src/recurring_parser/backends.js
export const iceCube = {
  daily: () => ({
    rule_type: "IceCube::DailyRule",
    validations: {},
    interval: 1
  }),

  weekly: (day) => ({
    rule_type: "IceCube::WeeklyRule",
    validations: { day: [day] },
    interval: 1
  }),

  // … see repo for all rules https://github.com/rails-designer-repos/recurring-events/commit/89580605f472c6408ad1c0ce4eb91876c0a1068a
}
Enter fullscreen mode Exit fullscreen mode

This backend pattern makes it easy to add support for other recurring event implementations (like recurrence). You'd simply need to create a new backend that implements the same interface but generates the appropriate rule structure for your system.

Finally remove the unnecessary attributes from the permitted params (you can also remove the include Recurrence::Builder from app/models/event.rb).

# app/controllers/events_controller.rb
def event_params
-  params.expect(event: [:title, :starts_at, :ends_at, :recurring_type, :recurring_day, :recurring_interval, :recurring_until])
+  params.expect(event: [:title, :starts_at, :ends_at, :recurring_rule, :recurring_until])
end
Enter fullscreen mode Exit fullscreen mode

The recurring_rule parameter now contains a JSON structure that maps directly to IceCube's rule system, making it easy to create the actual recurring events on the backend.

And with that you have the basics for natural language parsing. As you can see the app/javascript/src/recurring_parser/patterns.js is mainly a big regex—which will only get bigger when adding support for more ways to add recurring options (and that is without support for other languages than English even!).

Heroku

Deploy with ease. Manage efficiently. Scale faster.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

ACI image

ACI.dev: Fully Open-source AI Agent Tool-Use Infra (Composio Alternative)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Check out our GitHub!