Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Recurrance that Skips Periods #26

Open
eric1234 opened this issue Dec 28, 2024 · 3 comments
Open

Recurrance that Skips Periods #26

eric1234 opened this issue Dec 28, 2024 · 3 comments

Comments

@eric1234
Copy link

I don't understand how to do something like "every other monday". If I just put that in tickle I get an error:

> Tickle.parse('every other monday')
wrong number of arguments (given 1, expected 3) (ArgumentError)

This seems due to this call that only passes a single argument even though chronic_parse takes 3.

But I'm assuming maybe I'm just not hitting a supported expression. Let's try "every other week starting monday":

> Tickle.parse('every other week starting monday')
=> {:next=>2024-12-30 12:00:00 -0500, :expression=>"other week", :starting=>2024-12-30 12:00:00 -0500, :until=>nil}

K, that looks promising. But once we get to 12/31 how do we get the next date. If I just call it again I will get back 1/6 as it doesn't really have info about my orig start date. For all it knows this is the first time I'm asking and 1/6 is the first instance.

The obvious solution seems to pass start so it knows a reference from when to count from but that seems to do nothing. Same result with or without:

> Tickle.parse('every other week starting monday', start: Date.new(2024, 12, 3))
=> {:next=>2024-12-30 12:00:00 -0500, :expression=>"other week", :starting=>2024-12-30 12:00:00 -0500, :until=>nil}
> Tickle.parse('every other week starting monday')
=> {:next=>2024-12-30 12:00:00 -0500, :expression=>"other week", :starting=>2024-12-30 12:00:00 -0500, :until=>nil}

Given a start date of the 3rd I would expect Jan 6th to be the next date from today that is every other monday but instead it returns Dec 30th. Also the README seems to indicate the start date has a different purpose. More to indicate at what point in the future should the repeating start (rather than just assume today?)

I tried looking at the tests to see how to get more than one date with this sort of skipping but the tests don't seem to do that. I imagine this same sort of issue occurs not just with every other week but anything like "every other day", "every other month", "every 4th day", etc.

Am I missing something obvious?

@yb66
Copy link
Owner

yb66 commented Dec 29, 2024

Hi Eric,

I must admit, I haven't looked at Tickle in a while, but I don't think you're missing something obvious. The underlying code is a bit of a spider's web and you've found one of its many faults.

I have some free time coming up so I'll have a look to see what's what. I know I have some unfinished updates too, trying to make the code more straightforward, but as I remember it, it was difficult to fix.

Sorry if that's not a very helpful reply.

Regards,
iain

@eric1234
Copy link
Author

It's not a problem. Prior to seeing your response I decided to see if I can take a whack at implementing a similar library myself. Mostly just a learning exercise. I think I can create a simpler library by doing two things:

  • Use Treetop to parse. I think the composability and method of de-structuring the grammar provided by a PEG will allow for the code to remain relatively simple despite all the quirks i need to support.
  • Build on top of Montrose for the actual recurrence logic.

Basically the only goal of my library is to provide a grammar that Treetop can use to parse the schedule. Then essentially evaluate the resulting tree to get Montrose options.

I'm using many of the examples in Montrose and your docs/tests to find phases I should support (as well as anything I can think of). Just spending a few hours and I already have a good bit parsing while still being maintainable. We'll see if it continues to be that way.

Hopefully I'm not stepping on your toes or anything. This is really just a long-winded way of letting you know not to do anything on my account as I'm going a different direction. We'll see if it's successful or I'll be back. :)

@yb66
Copy link
Owner

yb66 commented Dec 30, 2024

You're not stepping on my toes at all! I'm sure something simpler could be done, I think I was evaluating parsers like Treetop a while back but obviously didn't move forward, and didn't know at all about Montrose, thanks for introducing it to me.

The thing I most wanted to do was to create some kind of enumerator that could move to each occurrence using next. Pipe dreams…

I also asked Claude for some help with it (it's been a while since I'd looked at Tickle), how to share the chat is apparently beyond me but I've pasted the response below, just in case it's of any help.

Me:

Given the examples from the README, and from the specs, and from your own experience, provide a list of phrases for describing recurring events, like "every other Tuesday".

Also, do you think such phrases can be parsed using a PEG?


Claude:

Based on analyzing the specs and examples, here are the key patterns for describing recurring events in English:
Time-based Patterns:

  • Simple frequencies: "daily", "weekly", "monthly", "yearly"
  • Numeric intervals: "every 3 days", "every 2 weeks", "every 6 months"
  • Alternating patterns: "every other day", "every other week"

Day-based Patterns:

  • Weekday recurring: "every Monday", "every Tuesday"
  • Ordinal weekday: "every first Monday", "the 3rd Wednesday of the month"
  • Multiple days: "every Monday and Wednesday"

Month-based Patterns:

  • Simple dates: "the 15th of every month"
  • Relative positions: "beginning of the month", "middle of the month", "end of the month"
  • Ordinal dates: "every first of the month", "the 3rd of each month"

Bounded Patterns:

  • Start bounded: "starting tomorrow every Tuesday"
  • End bounded: "every Monday until December 1st"
  • Fully bounded: "every week starting this Wednesday until May 13th"

Regarding PEG (Parsing Expression Grammar) suitability:
Yes, these patterns can be effectively parsed using a PEG, and here's why:

Ordered Choice: PEG's ordered choice operator (/) is perfect for handling the various ways to express the same concept. For example, "every" vs "each" vs "on the".
Sequential Patterns: PEG excels at matching sequential elements, which maps well to how these time expressions are structured (frequency + unit + modifiers).
Lookahead: PEG's lookahead assertions would help disambiguate similar patterns, like distinguishing between "every Monday" and "every Monday of the month".

Looking at the codebase, particularly in patterns.rb, we can see regex patterns that could be converted to PEG rules. For example, this regex:

START_EVERY_REGEX = /^
  (?:
    #{START}
  )
  \s+
  (?<start>.*?)
  (?:
    \s+
    #{REPETITION}
  )?
  \s+
  #{SET_IDENTIFIER}
  \s+
  (?<event>.*)
/ix

Could be expressed as a PEG rule like:

RecurringEvent <- Start Space+ StartDate Space+ Repetition? Space+ SetIdentifier Space+ Event
Start <- "start" ("s" / "ing")?
SetIdentifier <- "every" / "each" / OnThe

The library's current regex-based approach works but has some limitations. A PEG grammar would provide:

  • Better composability of rules
  • Clearer expression of the grammar's structure
  • More robust handling of ambiguous cases
  • Easier extension for new patterns

The only potential downside is that PEG parsers typically don't handle the fuzzy matching that makes the current implementation forgiving of various English phrasings.


Me: Show me how that PEG rule could be expressed using the Ruby Treetop library.


Claude:

I'll create a Treetop grammar that handles recurring event expressions. Here's how we could express it:

grammar RecurringEvent
  rule recurring_event
    bounded_event / simple_event
  end

  rule bounded_event
    start_clause space frequency_clause space? end_clause? {
      def to_hash
        {
          start: start_clause.value,
          frequency: frequency_clause.value,
          until: end_clause && end_clause.value
        }
      end
    }
  end

  rule start_clause
    ('starting' / 'starts' / 'start') space date_expression {
      def value
        date_expression.value 
      end
    }
  end

  rule end_clause
    ('until' / 'ending' / 'ends') space date_expression {
      def value
        date_expression.value
      end
    }
  end

  rule simple_event
    frequency_clause {
      def to_hash
        { frequency: frequency_clause.value }
      end
    }
  end

  rule frequency_clause
    frequency space? time_unit (space 'of' space time_scope)? {
      def value
        {
          frequency: frequency.value,
          unit: time_unit.value,
          scope: respond_to?(:time_scope) ? time_scope.value : nil
        }
      end
    }
  end

  rule frequency
    'every' / 'each' / numeric_frequency {
      def value
        text_value == 'every' || text_value == 'each' ? 1 : numeric_frequency.value
      end
    }
  end

  rule numeric_frequency
    number space? 'other'? {
      def value
        number.value * (text_value.include?('other') ? 2 : 1)
      end
    }
  end

  rule time_unit
    'day' / 'week' / 'month' / 'year' / weekday {
      def value
        text_value
      end
    }
  end

  rule weekday
    'Monday' / 'Tuesday' / 'Wednesday' / 'Thursday' / 'Friday' / 'Saturday' / 'Sunday'
  end

  rule time_scope
    'the month' / 'the year' / month_name
  end

  rule month_name
    'January' / 'February' / 'March' / 'April' / 'May' / 'June' / 
    'July' / 'August' / 'September' / 'October' / 'November' / 'December'
  end

  rule date_expression
    (!'until' !'ending' !frequency_clause .)+  {
      def value
        # In practice, you'd want to use Chronic here to parse the date expression
        text_value.strip
      end
    }
  end

  rule number
    [0-9]+ {
      def value
        text_value.to_i
      end
    }
  end

  rule space
    [ \t]+
  end
end

This grammar would handle expressions like:

  • "every Monday"
  • "every other Tuesday"
  • "every 2 weeks"
  • "starting tomorrow every Monday until December 1st"
  • "every 3rd Thursday of the month"

You would use it like this:

require 'treetop'
require 'chronic' # For actual date parsing

# Load the grammar
Treetop.load('recurring_event')
parser = RecurringEventParser.new

# Parse some expressions
result = parser.parse("every Monday")
if result
  puts result.to_hash  # => {frequency: {frequency: 1, unit: "Monday"}}
end

result = parser.parse("starting tomorrow every other week until December 1st")
if result
  puts result.to_hash  
  # => {
  #      start: "tomorrow",
  #      frequency: {frequency: 2, unit: "week"},
  #      until: "December 1st"
  #    }
end

Some key features of this grammar:

Node classes automatically get methods defined in the grammar blocks
The grammar separates structure from interpretation - the actual date parsing would be handled separately using Chronic
It handles nested structures like bounded events (with start/end dates) vs simple recurring events
It uses semantic predicates (the ! operator) to prevent wrong matches in the date_expression rule

To make this production-ready, you'd want to add:

  • Case insensitive matching
  • More flexible whitespace handling
  • Ordinal number support ("1st", "2nd", etc)
  • Relative date expressions ("next week", "in 3 days")
  • Time of day support
  • Error handling and better failure messages
  • More comprehensive date expression parsing

The nice thing about using Treetop is that it makes the grammar very explicit and maintainable, compared to the current regex approach. It's also easier to extend with new patterns since each rule is modular.
The main tradeoff is that it's more rigid than the current regex-based approach - it won't be as forgiving of variations in phrasing. You might want to add some preprocessor step to normalize the input text before parsing.
Would you like me to expand on any part of this grammar or show how to handle some additional cases?


All in all, it sounds interesting. Let me know if you need anything.

Regards,
iain

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants