acute 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +177 -0
- data/docs/agents.md +101 -0
- data/docs/chronic.md +57 -0
- data/docs/cli.md +61 -0
- data/docs/gotchas.md +133 -0
- data/docs/recurrence.md +157 -0
- data/docs/rules.md +923 -0
- data/docs/stability.md +37 -0
- data/docs/usage.md +220 -0
- data/lib/acute/cli.rb +7 -3
- data/lib/acute/context.rb +7 -2
- data/lib/acute/parser.rb +2 -1
- data/lib/acute/rule/expand.rb +2 -3
- data/lib/acute/rule.rb +7 -2
- data/lib/acute/version.rb +1 -1
- data/schema/description.json +1 -1
- data/schema/duration.json +1 -1
- data/schema/extraction.json +1 -1
- data/schema/failure.json +1 -1
- data/schema/now.json +1 -1
- data/schema/result.json +1 -1
- data/schema/rule.json +1 -1
- data/schema/tools/describe.json +1 -1
- data/schema/tools/duration.json +1 -1
- data/schema/tools/extract.json +1 -1
- data/schema/tools/now.json +1 -1
- data/schema/tools/parse.json +1 -1
- data/schema/tools/recurrence.json +1 -1
- data/schema/tools/verify.json +1 -1
- data/schema/verdict.json +1 -1
- metadata +11 -1
data/docs/recurrence.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Repeating times
|
|
2
|
+
|
|
3
|
+
acute reads "every weekday at 9am" as an iCalendar rule (RFC 5545), lists its occurrences, and writes it as a cron line when cron can say it. The conventions are the R-rules on [the rules page](rules.md#recurrence); the JSON form is [schema/rule.json](../schema/rule.json).
|
|
4
|
+
|
|
5
|
+
The examples are said on Saturday 17 October 2026 at 14:00 in New York:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
now = "2026-10-17T14:00"
|
|
9
|
+
zone = "America/New_York"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Reading a rule
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
r = Acute.recurrence("every weekday at 9am", now:, zone:) # nil when the text isn't a rule
|
|
16
|
+
r.to_s # => "every weekday at 9am"
|
|
17
|
+
r.text # => "every weekday at 9am"
|
|
18
|
+
r.rrule # => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
|
|
19
|
+
r.dtstart # => 2026-10-19 09:00:00 -0400
|
|
20
|
+
puts r.to_ical
|
|
21
|
+
# DTSTART;TZID=America/New_York:20261019T090000
|
|
22
|
+
# RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`Acute.recurrence!` raises an `Acute::ParseError` instead of returning nil. `Acute.read` tries a rule first, so it returns an `Acute::Rule` for these phrases. `Acute.parse` returns nil for them. Every option from [usage](usage.md#the-calls) works.
|
|
26
|
+
|
|
27
|
+
DTSTART is the first occurrence on or after now ([R2](rules.md#recurrence)). A rule with no clock is of whole days: its DTSTART is a Date, and its occurrences are Dates.
|
|
28
|
+
|
|
29
|
+
A Rule has:
|
|
30
|
+
|
|
31
|
+
| Method | Gives |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `rrule`, `dtstart`, `to_ical` | the RRULE value, the first occurrence, and all the iCalendar lines (DTSTART, RRULE, any EXDATE) |
|
|
34
|
+
| `to_s` | the rule in words, which reads back to the same rule; `description` in the JSON |
|
|
35
|
+
| `text` | the input it was read from: the whole text, a mention's own words, or the cron line |
|
|
36
|
+
| `now` | the now it was read at, in its zone; `next` counts from it |
|
|
37
|
+
| `cron`, `cron_reason` | a cron line, or nil and why; see [cron](#cron) |
|
|
38
|
+
| `next`, `between`, `each` | the occurrences; see below |
|
|
39
|
+
| `zone` | the zone its wall times are in |
|
|
40
|
+
| `to_h`, `to_json`, `as_json` | the JSON form, [schema/rule.json](../schema/rule.json) |
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
puts r.to_json
|
|
44
|
+
# {"kind":"rule","text":"every weekday at 9am","description":"every weekday at 9am","rrule":"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0","dtstart":"2026-10-19T09:00:00-04:00","zone":"America/New_York","ical":"DTSTART;TZID=America/New_York:20261019T090000\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0","cron":"0 9 * * 1-5","cron_reason":null}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Occurrences
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
r.next # the first after the now it was read at
|
|
51
|
+
# => 2026-10-19 09:00:00 -0400
|
|
52
|
+
|
|
53
|
+
r.next(3, after: Time.new(2026, 10, 17, 14, 0, 0, "-04:00"))
|
|
54
|
+
# => [2026-10-19 09:00:00 -0400, 2026-10-20 09:00:00 -0400, 2026-10-21 09:00:00 -0400]
|
|
55
|
+
|
|
56
|
+
r.between(Time.new(2026, 10, 19, 0, 0, 0, "-04:00"), Time.new(2026, 10, 21, 0, 0, 0, "-04:00"))
|
|
57
|
+
# => [2026-10-19 09:00:00 -0400, 2026-10-20 09:00:00 -0400]
|
|
58
|
+
|
|
59
|
+
r.each.first(2) # lazy; stops when the rule ends
|
|
60
|
+
# => [2026-10-19 09:00:00 -0400, 2026-10-20 09:00:00 -0400]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `next(after: now)` gives the next occurrence after `after`, or nil when the rule has ended. `next(n, after: now)` gives the next `n` as an Array, as Ruby's `first(n)` does; fewer come back when the rule ends. `after` defaults to the now the rule was read at, not the clock, so a rule read with `now:` lists the same occurrences whenever it is asked. An occurrence equal to `after` isn't after it.
|
|
64
|
+
- `between(from, to)` gives those from `from` up to `to`, leaving `to` out, so windows laid end to end list each occurrence once. A Date stands for its whole day.
|
|
65
|
+
- `each(after: nil)` lists every occurrence, lazily, from DTSTART or after `after`. DTSTART is on or after now, so `each.first(n)` is `next(n)` except when DTSTART is now itself, which only `each` lists.
|
|
66
|
+
|
|
67
|
+
For a rule of whole days they are Dates: `Acute.recurrence("every monday", now:, zone:).next(2, after: Date.new(2026, 10, 17))` gives the Dates 2026-10-19 and 2026-10-26.
|
|
68
|
+
|
|
69
|
+
One call to `next` or `between` gives at most 1,000 occurrences and takes at most 100,000 steps. Past either it returns nil, `next` without `n` too. `next!` and `between!` raise a `ParseError` with the code `:occurrence_limit` or `:step_limit`, which says why. `each` has no cap on how many it lists, so take what you need from it ([R19](rules.md#recurrence)).
|
|
70
|
+
|
|
71
|
+
## Exceptions
|
|
72
|
+
|
|
73
|
+
"except" leaves weekdays out of the rule and dates out of its occurrences, as EXDATEs ([R12](rules.md#recurrence)). "every weekday except fridays" is Monday to Thursday; "every day at 9am except halloween" leaves out 31 October at 9:00.
|
|
74
|
+
|
|
75
|
+
An hourly or finer rule takes a time as that instant: "every hour except october 17 at 4pm" leaves out 16:00 that day. A time the rule doesn't reach, such as 4:30pm, is written as said and leaves nothing out. A date leaves out every occurrence that day, one EXDATE each: 24 for "every hour except october 18", 23 on the day clocks go forward, and 24 on the day they go back, where the repeated hour's two occurrences share a wall time. A date that would need more than 24 is `:conflict`: "every 15 minutes except october 18" is nil, and `recurrence!` raises.
|
|
76
|
+
|
|
77
|
+
## Daylight saving
|
|
78
|
+
|
|
79
|
+
Clock changes don't change the rule. "Every weekday at 9am" is 9:00 in EDT and in EST. A wall time that is skipped or repeated follows `dst:`, as a one-off time does ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)): by default a skipped time moves forward by the gap and a repeated one is the earlier.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
r = Acute.recurrence("every day at 2:30am", now: "2027-03-10T12:00", zone:)
|
|
83
|
+
r.between(Time.new(2027, 3, 13, 0, 0, 0, "-05:00"), Time.new(2027, 3, 16, 0, 0, 0, "-04:00"))
|
|
84
|
+
# => [2027-03-13 02:30:00 -0500, 2027-03-14 03:30:00 -0400, 2027-03-15 02:30:00 -0400]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The 14th, whose 2:30 doesn't exist, comes back at 3:30. With `dst: :reject` that occurrence is left out and doesn't count towards a COUNT ([R18](rules.md#recurrence)):
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
r = Acute.recurrence("every day at 2:30am", now: "2027-03-10T12:00", zone:, dst: :reject)
|
|
91
|
+
r.between(Time.new(2027, 3, 13, 0, 0, 0, "-05:00"), Time.new(2027, 3, 16, 0, 0, 0, "-04:00"))
|
|
92
|
+
# => [2027-03-13 02:30:00 -0500, 2027-03-15 02:30:00 -0400]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Cron
|
|
96
|
+
|
|
97
|
+
`cron` writes the rule as a standard 5-field line. `cron_reason` says why there isn't one:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Acute.recurrence("every weekday at 9am", now:, zone:).cron # => "0 9 * * 1-5"
|
|
101
|
+
|
|
102
|
+
r = Acute.recurrence("every other friday", now:, zone:)
|
|
103
|
+
r.cron # => nil
|
|
104
|
+
r.cron_reason # => "cron can't express \"every other\""
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Cron has no zone: the line is the wall time in the rule's zone, so run it with `CRON_TZ=America/New_York` or on a host in that zone. The CLI prints it that way.
|
|
108
|
+
|
|
109
|
+
Cron can't express:
|
|
110
|
+
|
|
111
|
+
- a start, an end or an exception: "until", a count, "starting", "in july", "except" ([R21](rules.md#cron));
|
|
112
|
+
- seconds;
|
|
113
|
+
- intervals that don't divide their field: "every 40 minutes", "every other day", "every other friday" ([R23](rules.md#cron));
|
|
114
|
+
- a weekday counted in its month or a day counted from its end: "every second tuesday", "the last friday", "the last day of every month" ([R24](rules.md#cron)).
|
|
115
|
+
|
|
116
|
+
`Acute::Rule.from_cron(line, now:, zone:)` goes the other way. It reads a Vixie cron line as wall time in `zone:` and gives a Rule, or nil when the line isn't cron or has no rule's equivalent. `from_cron!` raises a `ParseError` with a code (`invalid_cron`, `unsupported_cron` or `too_long`) and the position of the field at fault.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
c = Acute::Rule.from_cron("*/15 9-17 * * 1-5", now:, zone:)
|
|
120
|
+
c.to_s # => "every 15 minutes from 9am to 6pm on weekdays"
|
|
121
|
+
c.rrule # => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9,10,11,12,13,14,15,16,17;BYMINUTE=0,15,30,45"
|
|
122
|
+
c.dtstart # => 2026-10-19 09:00:00 -0400
|
|
123
|
+
|
|
124
|
+
Acute::Rule.from_cron("nope") # => nil
|
|
125
|
+
Acute::Rule.from_cron!("0 0 30 2 *")
|
|
126
|
+
# Acute::ParseError: the line never runs
|
|
127
|
+
# e.code => :invalid_cron
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
What it reads, and the lines it can't hold: [R25 and R26](rules.md#cron).
|
|
131
|
+
|
|
132
|
+
## Rules in extract
|
|
133
|
+
|
|
134
|
+
`extract` finds rules in longer text. A mention has `kind` `:rule`, and its `value` is a Rule:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
x = Acute.extract("Standup every weekday at 9am, and lunch next friday at noon", now:, zone:)
|
|
138
|
+
x.mentions.map { [_1.kind, _1.text, _1.value.to_s] }
|
|
139
|
+
# => [[:rule, "every weekday at 9am", "every weekday at 9am"], [:time, "lunch next friday at noon", "Fri, Oct 23, 2026, 12pm EDT"]]
|
|
140
|
+
x.remainder # => "Standup, and"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A lone frequency word such as "daily" is a rule only when a rule's words go with it. Where mentions begin and end: the [E-rules](rules.md#extraction).
|
|
144
|
+
|
|
145
|
+
## What isn't a rule
|
|
146
|
+
|
|
147
|
+
`recurrence` returns nil for a count per period with no day, a part of the day with no clock, and a one-off time ([R15](rules.md#recurrence)):
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
Acute.recurrence("twice a week", now:, zone:) # => nil
|
|
151
|
+
Acute.recurrence("every morning", now:, zone:) # => nil
|
|
152
|
+
Acute.recurrence("next friday", now:, zone:) # => nil
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
So is "every day at 9am and 5:30pm": BYHOUR and BYMINUTE would add 9:30 and 17:00, and one rule can't hold only the two ([R4](rules.md#recurrence)).
|
|
156
|
+
|
|
157
|
+
`recurrence!` raises on all of these: `:vague_rule` ("\"twice a week\" says how often, not when") for a count per period or a part of the day with no clock, `:not_rule` ("not a repeating time") for a one-off time, and another code where the words fail first, such as `:invalid_date` for "every 0 days". A rule that ends before it starts, such as "every monday until last week", raises `:no_occurrence`.
|