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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6c84cad41fb5b9704bc324f3412f532d56a003159e2d23386c9b4adb8e58b55a
|
|
4
|
+
data.tar.gz: 0e5ee273250d73191e35621f265c330d8d5a12e0573c63d9b0e3d1f5374ece7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9796bf94a816424b8d405bfa96ce88830114e56eac34a352f916a969281eb0066031c3bbe47fdd3b257346fa8f5d302f22e3aecdf274a0e4781591cdcf8696ad
|
|
7
|
+
data.tar.gz: 4dac4cd449312e682c1105083cac7cd1277f77250fb511b963bd48aac230fadfad4b9283afcfab8e3be6faf3eea48222c47d8a323e175de45392f9af9394b18c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.1
|
|
4
|
+
|
|
5
|
+
- **Changed:** the gem includes the README, CHANGELOG and docs/, so they can be read offline beside the code.
|
|
6
|
+
- **Fixed:** a `now:` with only an offset, such as `Time.new(2026, 10, 17, 14, 0, 0, "-04:00")`, was read in the system zone, though the docs promise the zone of `now:`. Without `zone:`, its offset is now the zone (D45).
|
|
7
|
+
- **Fixed:** the `acute` command printed a failure's hint only with `--json`. Its plain output now adds it as `hint: …`, naming the command's flag, as the JSON does: a line of its own after `acute: <reason>`, or after three spaces on an `error:` line from stdin.
|
|
8
|
+
- **Fixed:** two rules with the same iCal were `==`, with one hash, though they listed or read differently: under another `dst:` policy or read in another zone, or one saying "starting" where the other didn't. Equality now covers all of these, and still not the text or the `now:` (only the default `after:` for `next`), so two phrasings of one rule stay equal.
|
|
9
|
+
|
|
10
|
+
## 1.0.0
|
|
11
|
+
|
|
12
|
+
The first release. From here on, what may change in a minor or patch release is in [docs/stability.md](docs/stability.md).
|
|
13
|
+
|
|
14
|
+
- **Parsing.** `Acute.parse`, `parse!` and `Acute.with` read a date, time or period in plain English against the `now:` and `zone:` each call is given, with daylight saving handled, and say which reading they chose and why: its assumptions, each tagged with its rule, and the alternatives. A failure is a value with a code and a hint; `parse!` raises it. `Acute.duration` reads lengths of time. [docs/usage.md](docs/usage.md), [docs/rules.md](docs/rules.md)
|
|
15
|
+
- **Recurrence.** `Acute.recurrence` reads a repeating time as an `Acute::Rule`, with its occurrences (`next`, `between`, `each`), iCalendar RRULE text, and cron lines both ways. [docs/recurrence.md](docs/recurrence.md)
|
|
16
|
+
- **Extract.** `Acute.extract` finds the dates, times, durations and rules inside longer text, in time linear in its length. [docs/usage.md](docs/usage.md#dates-inside-other-text)
|
|
17
|
+
- **Writing and checking dates.** `Acute.describe` writes a time, duration or rule back in English; `Acute.verify` checks a claim such as "next friday is the 23rd". [docs/usage.md](docs/usage.md#writing-and-checking-dates)
|
|
18
|
+
- **Agents.** `Acute::Tool` gives the calls to an LLM as tools with JSON schemas, and `acute mcp` serves them over MCP; the app, not the model, fixes the zone and now. [docs/agents.md](docs/agents.md)
|
|
19
|
+
- **The `acute` command** parses phrases from arguments or stdin, for people or as JSON Lines. [docs/cli.md](docs/cli.md)
|
|
20
|
+
- **chronic compatibility.** `require "acute/chronic"` and `"acute/chronic_duration"` keep those gems' APIs, answered by acute. [docs/chronic.md](docs/chronic.md)
|
|
21
|
+
- **No runtime dependencies.** IANA zone data (2026e, `Acute::TZDATA`) ships with the gem, so there is no tzinfo dependency. Ruby 3.3 or later.
|
|
22
|
+
- **Hardening.** Every entry point's cost is bounded (512 bytes a phrase, budgets on occurrences and business days), the inputs were probed against a threat model, and messages escape what they echo. The gem is published from GitHub by trusted publishing with a build provenance attestation. [SECURITY.md](SECURITY.md)
|
|
23
|
+
|
|
24
|
+
- **Fixed:** Cairo kept standard time all year after 2076. Its summer, which ends at 24:00 on the last Thursday of October, now carries on to 2200 (D47).
|
|
25
|
+
- **Fixed:** an hourly or finer rule kept an excepted time: "every hour except october 17 at 4pm" still listed 4pm. A time is now that instant, and a date leaves out each of the rule's occurrences that day; one needing more than 24 is `:conflict` (R12).
|
|
26
|
+
- **Fixed:** "week before last" and "the month before last" were incomplete; they are now the one before last week or month (D44). `extract` found only "fri" in "mon, wed and fri": a weekday in a list of weekdays is now a day, though "mon" and "wed" are also words (D18).
|
data/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# acute
|
|
2
|
+
|
|
3
|
+
[](https://github.com/skorks/acute/actions/workflows/check.yml)
|
|
4
|
+
|
|
5
|
+
Plain-English date and time parsing for Ruby: a successor to [chronic](https://github.com/mojombo/chronic).
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "acute"
|
|
9
|
+
|
|
10
|
+
r = Acute.parse("next friday at 3pm", now: "2026-10-17T14:00", zone: "America/New_York")
|
|
11
|
+
r.time # => 2026-10-23 15:00:00 -0400
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`r.time` is a plain Ruby `Time` at the offset of the zone you asked for, here New York's -04:00. `parse` returns nil when the text isn't a time, and `parse!` raises.
|
|
15
|
+
|
|
16
|
+
Ruby's own `Time.parse` reads formats, not phrases: it takes "next tuesday at 3pm" as 3pm today. chronic reads phrases, but it is unmaintained, has one global time zone, and silently guesses at words it doesn't know. acute is a rewrite from scratch: every call takes its own `now` and zone, daylight saving is handled properly, it reads a wide range of phrases (checked against more than 18,000 cases), it says which reading it chose and why, and it runs 20–100× faster ([measured](#performance)). Why, with examples, and the name: [docs/chronic.md](docs/chronic.md#why-acute-exists).
|
|
17
|
+
|
|
18
|
+
### When to use something else
|
|
19
|
+
|
|
20
|
+
- Your input is always ISO 8601 or one fixed format: Ruby's `Time.iso8601` and `Time.strptime` read it, with nothing to install.
|
|
21
|
+
- You need languages other than English: acute reads English only. Its locales (en-US, en-GB, en-AU…) set date order, week start and holidays, not the words.
|
|
22
|
+
- You want a best guess at vague or garbled text: acute fails rather than guess. "soon", "the day after the meeting", "mon, wed and fri" and "satterday" return nil; it corrects only a small table of common misspellings, with no fuzzy matching. Everything it won't read: [gotchas](docs/gotchas.md#soon-gave-nil).
|
|
23
|
+
- You need meaning beyond dates, times, lengths and repeats, such as intents or places: that's a job for an NLP library or an LLM, which can call acute for the dates ([agents](docs/agents.md)).
|
|
24
|
+
- You need chronic's answers exactly: acute differs where chronic is wrong or ambiguous ([the differences](docs/chronic.md#moving-over)).
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
<!-- no-doc-test -->
|
|
29
|
+
```ruby
|
|
30
|
+
gem "acute"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or `gem install acute`. acute needs Ruby 3.3 or later and has no runtime dependencies. Its zone data is IANA 2026e, shipped with the gem (`Acute::TZDATA`).
|
|
34
|
+
|
|
35
|
+
What each kind of release may change: [docs/stability.md](docs/stability.md).
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
The examples pin `now:` to Saturday 17 October 2026, 14:00, in New York. `now:` takes a Time or an ISO 8601 string, and without it acute uses the clock. Every call takes the same options (`now:`, `zone:`, `context:`, `locale:`, `strict:`, `dst:`…) and checks them, and `Acute.with` checks them once and returns a frozen parser to share across threads and Ractors. Every call, option and result field: [docs/usage.md](docs/usage.md).
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
now = "2026-10-17T14:00"
|
|
43
|
+
zone = "America/New_York"
|
|
44
|
+
|
|
45
|
+
r = Acute.parse("tomorrow", now:, zone:) # nil when the text isn't a time
|
|
46
|
+
r.begin # => 2026-10-18 00:00:00 -0400
|
|
47
|
+
r.end # => 2026-10-19 00:00:00 -0400
|
|
48
|
+
Acute.parse("tomorrow 3pm PST", now:, zone:).time # => 2026-10-18 19:00:00 -0400
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A phrase means the whole period it names: `time` is its start, and `end` is the first moment after it. A zone written in the text wins, and the answer comes back in yours. Without `zone:` acute falls back to the zone of `now:` (a TimeWithZone's, or a Time's fixed offset), Rails' `Time.zone`, then the system's; on a server, pass the zone you mean. Daylight saving and the options: [docs/usage.md](docs/usage.md#time-zones).
|
|
52
|
+
|
|
53
|
+
Times are plain `Time` values with a fixed offset, even when `now:` is a `TimeWithZone`; under Rails, `r.time.in_time_zone(r.zone)` before adding days ([why](docs/usage.md#time-zones)). Answers people trip on, such as "next friday", midnight, two-digit years and daylight saving, are on [the gotchas page](docs/gotchas.md).
|
|
54
|
+
|
|
55
|
+
### Ambiguity
|
|
56
|
+
|
|
57
|
+
When a phrase can be read more than one way, acute settles it with a numbered rule, records the rule, and keeps the other readings. Said at 14:00, "at 9" is 9am tomorrow by rule D4, and 9pm tonight is the alternative:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
r = Acute.parse("at 9", now:, zone:)
|
|
61
|
+
r.time # => 2026-10-18 09:00:00 -0400
|
|
62
|
+
r.alternatives.map(&:time) # => [2026-10-17 21:00:00 -0400]
|
|
63
|
+
r.assumptions.map(&:code) # => [:bare_hour_window, :next_occurrence]
|
|
64
|
+
r.assumptions.first.rule # => "D4"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Every rule is on [the rules page](docs/rules.md).
|
|
68
|
+
|
|
69
|
+
### Failures
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
Acute.parse!("tomorow at 3", now:, zone:)
|
|
73
|
+
# Acute::ParseError: unknown word "tomorow"
|
|
74
|
+
# e.code => :unknown_word, e.hint => "did you mean \"tomorrow\"?"
|
|
75
|
+
|
|
76
|
+
Acute.parse("meet bob tuesday", now:, zone:) # => nil
|
|
77
|
+
Acute.parse("meet bob tuesday", now:, zone:, strict: false) # => #<Acute::Result Tue, Oct 20, 2026, all day>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`parse` returns nil, and `parse!` raises. Both errors, and skipping unknown words: [docs/usage.md](docs/usage.md#failures).
|
|
81
|
+
|
|
82
|
+
### Durations and dates inside text
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Acute.duration("1h 30m").iso8601 # => "PT1H30M"
|
|
86
|
+
|
|
87
|
+
x = Acute.extract("Lunch with Sam next friday at noon, then a 2 hour review", now:, zone:)
|
|
88
|
+
x.mentions.map { [_1.offset, _1.value.to_s] }
|
|
89
|
+
# => [[15...34, "Fri, Oct 23, 2026, 12pm EDT"], [43...49, "2h"]]
|
|
90
|
+
x.remainder # => "Lunch with Sam, then a review"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
More: [durations](docs/usage.md#durations), [extract](docs/usage.md#dates-inside-other-text).
|
|
94
|
+
|
|
95
|
+
### Writing and checking dates
|
|
96
|
+
|
|
97
|
+
`describe` writes a time as the most natural phrase that reads back to it exactly. `verify` checks a time something else worked out, such as an LLM, with a message to pass straight back:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
Acute.describe(Time.new(2026, 10, 20, 15, 0, 0, "-04:00"), now:, zone:)
|
|
101
|
+
# => "Tuesday at 3pm"
|
|
102
|
+
|
|
103
|
+
Acute.verify("next friday at noon", "2026-10-24T12:00", now:, zone:).message
|
|
104
|
+
# => "\"next friday at noon\" is Fri 23 Oct, 12pm, not Sat 24 Oct, 12pm."
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
More: [docs/usage.md](docs/usage.md#writing-and-checking-dates).
|
|
108
|
+
|
|
109
|
+
### Repeating times
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
r = Acute.recurrence("every weekday at 9am", now:, zone:)
|
|
113
|
+
r.rrule # => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
|
|
114
|
+
r.cron # => "0 9 * * 1-5"
|
|
115
|
+
r.next # => 2026-10-19 09:00:00 -0400
|
|
116
|
+
r.next(2)
|
|
117
|
+
# => [2026-10-19 09:00:00 -0400, 2026-10-20 09:00:00 -0400]
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
iCalendar rules, their occurrences and cron: [docs/recurrence.md](docs/recurrence.md).
|
|
121
|
+
|
|
122
|
+
### Command line
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
$ acute --now 2026-10-17T14:00 --zone America/New_York "next friday at 3pm"
|
|
126
|
+
Fri, Oct 23, 3pm EDT
|
|
127
|
+
|
|
128
|
+
$ acute --now 2026-10-17T14:00 --zone America/New_York --json "next friday at 3pm"
|
|
129
|
+
{"kind":"time","text":"next friday at 3pm","time":"2026-10-23T15:00:00-04:00","begin":"2026-10-23T15:00:00-04:00","end":"2026-10-23T15:01:00-04:00","grain":"minute","zone":"America/New_York","assumptions":[],"alternatives":[]}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Without `--json` it prints the reading for people to read, in `describe`'s calendar style; with it, the Result's `to_json` for scripts. Every option, stdin and failures: [docs/cli.md](docs/cli.md).
|
|
133
|
+
|
|
134
|
+
### Agents and LLMs
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
$ claude mcp add acute -- acute mcp --zone America/New_York # an MCP server over stdio
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
require "acute/tool"
|
|
142
|
+
tool = Acute::Tool.new(now:, zone:) # the app sets the zone and now, never the model
|
|
143
|
+
tool.definitions(:anthropic).map { _1[:name] } # or :openai, for any SDK
|
|
144
|
+
# => ["parse", "duration", "recurrence", "extract", "verify", "describe", "now"]
|
|
145
|
+
tool.call("parse", { "text" => "next friday at 3pm" })
|
|
146
|
+
# => {"kind" => "time", "time" => "2026-10-23T15:00:00-04:00", "end" => "2026-10-23T15:01:00-04:00", "grain" => "minute", "zone" => "America/New_York"}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The tools, their failures and how zone and now are set: [docs/agents.md](docs/agents.md). With RubyLLM it is `chat.with_tools(*tool.ruby_llm)`: [docs/agents.md#rubyllm](docs/agents.md#rubyllm).
|
|
150
|
+
|
|
151
|
+
## Coming from chronic
|
|
152
|
+
|
|
153
|
+
`require "acute/chronic"` gives you chronic's API, `Chronic.parse` with chronic's options, answered by acute; `require "acute/chronic_duration"` does the same for chronic_duration. A few answers differ, following acute's rules: [docs/chronic.md](docs/chronic.md#moving-over).
|
|
154
|
+
|
|
155
|
+
## Performance
|
|
156
|
+
|
|
157
|
+
Over ten everyday phrases on Ruby 4.0.7, acute takes 5–19 µs a parse to chronic 0.10.2's 260–490 µs, 20–100× faster (median about 50×), and 1.5–8 µs under YJIT, 80–300× faster. `bin/bench-chronic` measures it on your machine: [docs/chronic.md](docs/chronic.md#performance).
|
|
158
|
+
|
|
159
|
+
## Documentation
|
|
160
|
+
|
|
161
|
+
- [docs/usage.md](docs/usage.md): every call, option and result field, with examples.
|
|
162
|
+
- [docs/recurrence.md](docs/recurrence.md): repeating times as iCalendar rules, their occurrences, and cron.
|
|
163
|
+
- [docs/cli.md](docs/cli.md): the `acute` command and its options.
|
|
164
|
+
- [docs/agents.md](docs/agents.md): acute as tools for an LLM, through `Acute::Tool` or `acute mcp`.
|
|
165
|
+
- [docs/chronic.md](docs/chronic.md): why acute exists, moving from chronic, and speed.
|
|
166
|
+
- [docs/gotchas.md](docs/gotchas.md): the answers people don't expect, why, the option that changes each, and what acute won't read.
|
|
167
|
+
- [docs/rules.md](docs/rules.md): every convention acute follows, by topic, with examples.
|
|
168
|
+
- [docs/stability.md](docs/stability.md): what SemVer covers, and what a minor or patch release may change.
|
|
169
|
+
- [schema/](schema): JSON Schemas for the JSON of a Result, Duration, Extraction and Verdict, and for the agent tools.
|
|
170
|
+
|
|
171
|
+
## Contributing
|
|
172
|
+
|
|
173
|
+
`bin/check` runs everything: lint, unit tests, the phrase corpus, chronic's tests, fuzzing, coverage and speed. To add a phrase, see [test/cases/FORMAT.md](test/cases/FORMAT.md).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT; see [LICENSE](LICENSE). The test suite draws on other projects' work, credited in [NOTICE](NOTICE).
|
data/docs/agents.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# acute for agents
|
|
2
|
+
|
|
3
|
+
Use this page to let an LLM hand date phrases to acute instead of working out dates itself. acute offers itself as seven tools. `Acute::Tool` gives them to an app that calls a model through an SDK; `acute mcp` serves the same tools to any MCP client. Both take their names, descriptions and argument schemas from [schema/tools/](../schema/tools).
|
|
4
|
+
|
|
5
|
+
Said on Saturday 17 October 2026 at 14:00 in New York:
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require "acute/tool"
|
|
9
|
+
tool = Acute::Tool.new(now: "2026-10-17T14:00", zone: "America/New_York")
|
|
10
|
+
tool.call("verify", { "text" => "next friday at noon", "value" => "2026-10-24T12:00" })
|
|
11
|
+
# => {"kind" => "verdict", "status" => "mismatch", "ok" => false, "message" => "The text is Fri 23 Oct, 12pm, not Sat 24 Oct, 12pm.", "expected" => {"kind" => "time", "time" => "2026-10-23T12:00:00-04:00", "end" => "2026-10-23T12:01:00-04:00", "grain" => "minute", "zone" => "America/New_York"}}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The tools
|
|
15
|
+
|
|
16
|
+
| Tool | Arguments ([schema/tools/](../schema/tools)) | Returns |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `parse` | `text`, `context` | [result.json](../schema/result.json) |
|
|
19
|
+
| `duration` | `text` | [duration.json](../schema/duration.json) |
|
|
20
|
+
| `recurrence` | `text` | [rule.json](../schema/rule.json), plus `next`: its first three occurrences |
|
|
21
|
+
| `extract` | `text`, `context` | [extraction.json](../schema/extraction.json) |
|
|
22
|
+
| `verify` | `text`, `value`, `context` | [verdict.json](../schema/verdict.json) |
|
|
23
|
+
| `describe` | `value`, `style` | [description.json](../schema/description.json): the wording as `description`, as a rule's is |
|
|
24
|
+
| `now` | none | [now.json](../schema/now.json) |
|
|
25
|
+
|
|
26
|
+
Any of them may instead return a [failure](../schema/failure.json).
|
|
27
|
+
|
|
28
|
+
`recurrence`'s `next` is the rule's first three occurrences, from `dtstart` on, as ISO 8601 times, or dates for a rule of whole days. A rule that ends sooner lists fewer. `cron`, when the rule has one, runs in the wall time of `zone` (`CRON_TZ`).
|
|
29
|
+
|
|
30
|
+
## Compact results
|
|
31
|
+
|
|
32
|
+
The tools return the hashes described in [schema/](../schema), with what repeats left out, since the model pays for every char of a result on every call. A result leaves out the input's `text` (on a parse, recurrence, extract or verify result, a mention's value and a verdict's `expected`), so a rule keeps only its own wording, `description`; a `begin` equal to `time`; a mention's `kind`, which its value's `kind` repeats; a verdict's `expected` when `ok` is true; `now`'s `offset`, which the `now` string ends with; a rule's `ical` unless it has an EXDATE, since `dtstart` and `rrule` hold the rest, and whichever of `cron` and `cron_reason` is null; and `assumptions`, `alternatives` and `unread` when they're empty, a failure's `unread` included. `extract` gives at most 50 mentions; past that, `more` counts the ones left out. An assumption's `message` is its short form, `short` in [data/assumptions.yml](../data/assumptions.yml): the gist in about 50 chars rather than 80. The library's own JSON keeps them all, and the long messages.
|
|
33
|
+
|
|
34
|
+
## Failures and `unread`
|
|
35
|
+
|
|
36
|
+
A tool never raises on what the model sends. Wrong arguments, or text acute can't read, come back as `{kind: "failure", code, error, unread}`, [schema/failure.json](../schema/failure.json). `error` is acute's own words and never quotes the input: text from the arguments could carry instructions, so the words a failure points at go in `unread`, which every tool's description marks as user data. A failure to read the text adds `position`, the characters (codepoints) of `text` it points at as `[begin, end]`, when that is less than the whole phrase, and, for a misspelt word, `hint`: `"did you mean \"tomorrow\"?"`, a word from acute's own table. The `acute` command with `--json` gives the same shape, `position` included, plus the input as `text` and hints for every code, naming its flags. More codes may come: treat one you don't know as a failure, and ignore fields you don't know. This holds for every `error`, `message` and `description` acute writes, including the MCP server's JSON-RPC errors.
|
|
37
|
+
|
|
38
|
+
## In an app: `Acute::Tool`
|
|
39
|
+
|
|
40
|
+
<!-- no-doc-test -->
|
|
41
|
+
```ruby
|
|
42
|
+
require "acute/tool"
|
|
43
|
+
tool = Acute::Tool.new(zone: user.zone, locale: user.locale)
|
|
44
|
+
tool.definitions(:anthropic) # or :openai
|
|
45
|
+
tool.call(name, arguments) # arguments as the SDK hands them: a Hash or OpenAI's JSON string
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The app fixes every option at construction, with the same keywords as `Acute.with`. The input schemas leave them out, so the model can set neither the zone nor the time: an argument named `zone` or `now` is an `unknown_argument` failure. `prefix:` namespaces the tool names, such as `prefix: "dates_"`.
|
|
49
|
+
|
|
50
|
+
### RubyLLM
|
|
51
|
+
|
|
52
|
+
`require "acute/tool/ruby_llm"` adds `tool.ruby_llm`, one `RubyLLM::Tool` per tool. ruby_llm isn't a dependency of acute: add `gem "ruby_llm"` to your Gemfile. Without it the require fails with
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
acute/tool/ruby_llm needs the ruby_llm gem; add it to your Gemfile (cannot load such file -- ruby_llm) (LoadError)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Build the tool per user or per request, with their zone:
|
|
59
|
+
|
|
60
|
+
<!-- no-doc-test -->
|
|
61
|
+
```ruby
|
|
62
|
+
require "acute/tool/ruby_llm"
|
|
63
|
+
|
|
64
|
+
tool = Acute::Tool.new(zone: current_user.time_zone) # or Acute::Tool.new(Acute.with(zone: current_user.time_zone, locale: "en-GB"))
|
|
65
|
+
chat = RubyLLM.chat
|
|
66
|
+
chat.with_tools(*tool.ruby_llm)
|
|
67
|
+
chat.ask("Move my dentist appointment to the second monday of may at 3pm")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
It's per user because the app, not the model, fixes the zone and now: the schemas give the model no way to set them, so every answer is in the zone the tool was built with.
|
|
71
|
+
|
|
72
|
+
An app that already has a tool called `parse` can rename acute's with `prefix:`: `Acute::Tool.new(zone: current_user.time_zone, prefix: "dates_")` gives the chat `dates_parse`, `dates_extract`, `dates_now` and so on.
|
|
73
|
+
|
|
74
|
+
Each call hands the model the compact hash [described above](#compact-results). A failure is a value too, never an exception, so ruby_llm passes it back and the model can correct its arguments and try again ([Failures and `unread`](#failures-and-unread)).
|
|
75
|
+
|
|
76
|
+
One caveat: when the model calls a tool name the chat doesn't have, ruby_llm answers, not acute, with ``Model tried to call unavailable tool `<name>`. Available tools: [...]``. That message quotes the name the model sent, and acute can't change it.
|
|
77
|
+
|
|
78
|
+
## Over MCP: `acute mcp`
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
$ claude mcp add acute -- acute mcp --zone America/New_York
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`acute mcp` reads one JSON-RPC message per line on stdin and writes one per line on stdout, and exits when stdin closes. It speaks MCP 2026-07-28 to a client whose requests carry `_meta`, and 2025-11-25 or 2025-06-18 to one that opens with `initialize`.
|
|
85
|
+
|
|
86
|
+
| Option | Sets |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `--zone ZONE` | the zone when a call names none (default: the system's) |
|
|
89
|
+
| `--locale LOCALE` | how numeric dates read, such as en-GB (default: from the zone) |
|
|
90
|
+
| `--context future\|past` | the default way a phrase that could point either way points |
|
|
91
|
+
| `--week-start sunday\|monday` | the first day of a week |
|
|
92
|
+
|
|
93
|
+
Zone and now differ from the library. The person at the other end of an MCP client may be anywhere, so every tool but `duration` takes an optional `zone`, and an invalid one is an `invalid_argument` failure. `now` is always the server's clock: no option or argument sets it.
|
|
94
|
+
|
|
95
|
+
Each tool's result is `structuredContent`, with a JSON copy in `content`. A failure is a result with `isError: true`. Since a failure is a structured result too, each tool's `outputSchema` is a `oneOf` of its own schema and the failure schema, self-contained so a client resolves it without fetching: a schema referred to once is written in place and one referred to more than once goes under `$defs`. It describes the result as the tool gives it, with the fields above left out or optional, and keeps the structure the result must conform to and drops the descriptions, which the model would pay for on every listing; [schema/](../schema) has them. An unknown tool, method or malformed request is a JSON-RPC error instead; a line over 64 KiB is refused and the server carries on.
|
|
96
|
+
|
|
97
|
+
## Security
|
|
98
|
+
|
|
99
|
+
- **The user's words.** No `error`, `message` or `description` quotes the arguments. The user's words come back in two fields only: a failure's `unread` (at most 5 items of 60 chars) and an extraction's `remainder`. Each tool's description tells the model they are user data, never instructions. The output schemas say so too, but a client may not show those to the model.
|
|
100
|
+
- **Limits.** `text` is at most 512 chars and 512 bytes, or 4096 chars for `extract`; `value` is at most 64 chars. A call counts at most 10,000 business days in all, across every count in it; past that a count fails with `business_day_limit`, and in `extract` that mention is not a time. Over MCP, a line over 64 KiB and a batch are refused, an `id` must be a string or an integer, JSON nested more than 100 deep is a parse error, and an internal error is -32603 with nothing of the input.
|
|
101
|
+
- **Zone and now.** Through `Acute::Tool`, only the app sets them. Over MCP the model may pass `zone`, which must be an IANA name or a UTC offset of at most 64 chars; anything else is an `invalid_argument` failure that shows it only in `unread`. `now` is always the server's clock.
|
data/docs/chronic.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# acute and chronic
|
|
2
|
+
|
|
3
|
+
Read this if you use chronic: why acute replaces it, how to move over, and how much faster it is.
|
|
4
|
+
|
|
5
|
+
## Why acute exists
|
|
6
|
+
|
|
7
|
+
chronic made parsing "next tuesday at 3pm" a one-liner for more than a decade, and it has over 130 million downloads. But it has stood still:
|
|
8
|
+
|
|
9
|
+
- **It is unmaintained.** There has been no release since 0.10.2 in 2013, and more than 120 issues are open.
|
|
10
|
+
- **It doesn't really do time zones.** The zone is one global setting, `Chronic.time_class`, so there is no per-call zone and no thread-safe way to change it (its top open issue, [#182](https://github.com/mojombo/chronic/issues/182)). It also assumes every day has 24 hours, which breaks around daylight-saving changes.
|
|
11
|
+
- **It guesses silently.** Words it doesn't know are skipped, so "a banana" parses as a time and "4/32" as a date in 2032.
|
|
12
|
+
- **It isn't comprehensive enough.** Many everyday phrases return nil or raise.
|
|
13
|
+
|
|
14
|
+
Said on Saturday 17 October 2026 at 14:00 in New York:
|
|
15
|
+
|
|
16
|
+
| Phrase | chronic 0.10.2 | acute |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| "a banana" | today 13:00 | nil |
|
|
19
|
+
| "4/32" | 16 April 2032 | nil |
|
|
20
|
+
| "last 14 days" | yesterday 14:00 | 3 Oct 14:00 to 17 Oct 14:00 |
|
|
21
|
+
| "2nd thursday of june" | nil | 10 June 2027 |
|
|
22
|
+
| "christmas eve" | nil | 24 December 2026 |
|
|
23
|
+
| "1 year, 7 months ago" | raises NoMethodError | 17 March 2025, 14:00 |
|
|
24
|
+
| "1 day from now", at noon on 31 Oct | 11:00 on 1 Nov (24 hours across the clock change) | 12:00 on 1 Nov |
|
|
25
|
+
| "march 8 2026 2:30am" (skipped by DST) | 01:30 EST | 03:30 EDT |
|
|
26
|
+
|
|
27
|
+
acute is a rewrite from scratch, with a different design:
|
|
28
|
+
|
|
29
|
+
- every call takes its own `now` and zone, and daylight saving is handled properly;
|
|
30
|
+
- it reads a wide range of phrases, checked against more than 18,000 cases gathered from chronic and a dozen other date parsers;
|
|
31
|
+
- when a phrase could mean more than one thing, it says which reading it chose, and why;
|
|
32
|
+
- it runs 20–100× faster than chronic, 80–300× under YJIT ([measured](#performance)).
|
|
33
|
+
|
|
34
|
+
**About the name.** In medicine, a *chronic* condition lingers for years, and an *acute* one is short and sharp. chronic has lingered since 2013. acute is short, sharp and over in a few microseconds.
|
|
35
|
+
|
|
36
|
+
## Moving over
|
|
37
|
+
|
|
38
|
+
`require "acute/chronic"` gives you chronic's API, `Chronic.parse` with chronic's options, answered by acute. `require "acute/chronic_duration"` does the same for chronic_duration.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
require "acute/chronic"
|
|
42
|
+
|
|
43
|
+
Chronic.parse("tomorrow 3pm", now: Time.now)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The answers follow acute's rules, so a few differ from chronic's. chronic's own test suite runs against this layer, and every place it disagrees is listed, with its rule, in [test/chronic/differences.yml](../test/chronic/differences.yml). The [compatibility notes](rules.md#compatibility-layers) explain how each option is read.
|
|
47
|
+
|
|
48
|
+
## Performance
|
|
49
|
+
|
|
50
|
+
`bin/bench-chronic` times chronic 0.10.2, the gem itself, and acute on ten everyday phrases ("tomorrow", "next tuesday 3pm", "3 days ago", "27/5/1979"…), in CPU time per parse, best of five rounds. Run it with `ruby bin/bench-chronic`, then `ruby --yjit bin/bench-chronic`; it needs `gem install chronic -v 0.10.2`. On Ruby 4.0.7 on an i5-13600K:
|
|
51
|
+
|
|
52
|
+
| | chronic 0.10.2 | acute | faster by |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| interpreter | 260–490 µs | 5–19 µs | 20–100×, median about 50× |
|
|
55
|
+
| YJIT | 350–650 µs | 1.5–8 µs | 80–300×, median about 150× |
|
|
56
|
+
|
|
57
|
+
The machine was busy, so read the ratios rather than the last digit; runs moved them by about 20%. Roughly, chronic manages two or three thousand parses a second per core, and acute around a hundred thousand, or several hundred thousand with YJIT. acute's own speed budget is checked on every commit.
|
data/docs/cli.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# The acute command
|
|
2
|
+
|
|
3
|
+
`acute` reads phrases in a shell or a script and prints when they are. The examples pin `--now` to Saturday 17 October 2026 at 14:00, so they give the same answers whenever you run them.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
$ acute --now 2026-10-17T14:00 --zone America/New_York "next friday at 3pm"
|
|
7
|
+
Fri, Oct 23, 3pm EDT
|
|
8
|
+
|
|
9
|
+
$ acute --now 2026-10-17T14:00 --zone America/New_York --dst reject "2026-03-08 02:30"
|
|
10
|
+
acute: "2026-03-08 02:30" names a wall time the clocks skip as they go forward
|
|
11
|
+
hint: --dst earlier, later or compatible picks an instant for it
|
|
12
|
+
|
|
13
|
+
$ acute --json < phrases.txt # one JSON line per phrase
|
|
14
|
+
|
|
15
|
+
$ acute --now 2026-10-17T14:00 --zone America/New_York --next 2 "every weekday at 9am"
|
|
16
|
+
every weekday at 9am
|
|
17
|
+
DTSTART;TZID=America/New_York:20261019T090000
|
|
18
|
+
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0
|
|
19
|
+
cron: 0 9 * * 1-5 (CRON_TZ=America/New_York)
|
|
20
|
+
Mon, Oct 19, 9am EDT
|
|
21
|
+
Tue, Oct 20, 9am EDT
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`acute PHRASE` prints the reading in describe's calendar style, with the zone's abbreviation, then any alternatives as "(also: …)". A duration prints as "1h 30m". A repeating time prints its words, its iCalendar lines (DTSTART, RRULE and any EXDATE) and its cron line when cron can express it, in the zone's wall time. `--next N` adds the rule's first N occurrences, from DTSTART on; N is 1 to 1,000, and a phrase that isn't a rule fails. With no phrase, it reads one phrase per line from stdin and prints exactly one line for each, a rule's lines joined by three spaces; it skips blank lines, which print nothing and don't fail. A failure prints `error: <reason>` on stdout, or `acute: <reason>` on stderr for a phrase in the arguments, then any hint as `hint: …`: on a line of its own, or after three spaces on a line from stdin. The JSON of a reading is that of `to_json`: [schema/result.json](../schema/result.json), [schema/duration.json](../schema/duration.json) or [schema/rule.json](../schema/rule.json), and `--next` adds a `next` array of ISO 8601 times, or dates for a rule of whole days.
|
|
25
|
+
|
|
26
|
+
With `--json`, every phrase gets one JSON line on stdout, a failure included. A failure has the tools' shape, [schema/failure.json](../schema/failure.json), plus the phrase as `text`. As in the tools, `position` is there when it points at less than the whole phrase, and counts characters (codepoints), not bytes or UTF-16 units. A `hint` names the flag that fixes the failure, such as `--dst earlier` for a wall time the clocks skip:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
$ acute --now 2026-10-17T14:00 --json "next fridya 3pm"
|
|
30
|
+
{"kind":"failure","text":"next fridya 3pm","code":"unknown_word","error":"unknown word \"fridya\"","position":[5,11],"hint":"did you mean \"friday\"?"}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Exit codes: 0 when everything parsed, 1 when a phrase failed, 2 for bad options. A bad option prints `acute: <reason>` on stderr, with `--json` too.
|
|
34
|
+
|
|
35
|
+
## Options
|
|
36
|
+
|
|
37
|
+
The options are those of [the library](usage.md#options), with dashes:
|
|
38
|
+
|
|
39
|
+
| Option | Sets |
|
|
40
|
+
|---|---|
|
|
41
|
+
| `--json` | print JSON (JSON Lines for stdin) |
|
|
42
|
+
| `--next N` | a rule's first N occurrences, 1–1,000 |
|
|
43
|
+
| `--now TIME` | now, in ISO 8601 (default: the clock) |
|
|
44
|
+
| `--zone ZONE` | an IANA zone or offset (default: the system's) |
|
|
45
|
+
| `--locale LOCALE` | en-US, en-GB, en-AU… (default: from the zone) |
|
|
46
|
+
| `--context CONTEXT` | future or past |
|
|
47
|
+
| `--week-start DAY` | sunday or monday |
|
|
48
|
+
| `--hemisphere SIDE` | north or south, for seasons |
|
|
49
|
+
| `--fiscal-year-start MONTH` | 1–12 |
|
|
50
|
+
| `--lenient` | skip unknown words (`strict: false`) |
|
|
51
|
+
| `--dst POLICY` | compatible, earlier, later or reject |
|
|
52
|
+
| `--holidays REGION` | a holidays-gem region, such as us (needs the gem) |
|
|
53
|
+
| `--year-bias YEARS` | two-digit years: the 100 from this many years ago (default 50) |
|
|
54
|
+
| `-v`, `--version` | the version |
|
|
55
|
+
| `-h`, `--help` | the usage |
|
|
56
|
+
|
|
57
|
+
## acute mcp
|
|
58
|
+
|
|
59
|
+
`mcp` is acute's only subcommand, and stays so: new modes come as flags. It counts only as the first argument.
|
|
60
|
+
|
|
61
|
+
`acute mcp [options]` serves acute's tools to an MCP client over stdio, one JSON-RPC message per line. It takes `--zone`, `--locale`, `--context` and `--week-start`; see [agents.md](agents.md#over-mcp-acute-mcp).
|
data/docs/gotchas.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Gotchas
|
|
2
|
+
|
|
3
|
+
Read this when acute gave an answer you didn't expect. Each answer follows a numbered rule, and each rule has an option or an alternative reading when the other answer is the one you meant. The examples are said on Saturday 17 October 2026 at 14:00, in New York:
|
|
4
|
+
|
|
5
|
+
```ruby
|
|
6
|
+
now = "2026-10-17T14:00"
|
|
7
|
+
zone = "America/New_York"
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## "at 9" gave tomorrow
|
|
11
|
+
|
|
12
|
+
A time that could be past or future means the future one, the next time the clock shows it. Said at 14:00, "at 9" is 9am tomorrow, with 9pm tonight as the alternative ([D4](rules.md#d4-a-time-of-day-without-ampm)), and "at 3pm" is today. For things that already happened, pass `context: :past` ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
Acute.parse("at 9", now:, zone:).time # => 2026-10-18 09:00:00 -0400
|
|
16
|
+
Acute.parse("at 9", now:, zone:).alternatives.map(&:time) # => [2026-10-17 21:00:00 -0400]
|
|
17
|
+
Acute.parse("at 3pm", now:, zone:, context: :past).time # => 2026-10-16 15:00:00 -0400
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## "next friday" has two answers
|
|
21
|
+
|
|
22
|
+
Said early in the week, "next friday" can mean the coming Friday or the one a week later. acute gives the coming one first and the other as an alternative. Said on a Saturday, the coming Friday is already next week's, so there is one answer ([D20](rules.md#d20-next-friday)):
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
wednesday = "2026-10-14T14:00"
|
|
26
|
+
r = Acute.parse("next friday", now: wednesday, zone:)
|
|
27
|
+
r.time # => 2026-10-16 00:00:00 -0400
|
|
28
|
+
r.alternatives.map(&:time) # => [2026-10-23 00:00:00 -0400]
|
|
29
|
+
Acute.parse("next friday", now:, zone:).alternatives # => []
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
If your users mean the later Friday, check `alternatives`, or confirm with them using `Acute.describe`.
|
|
33
|
+
|
|
34
|
+
## "monday midnight" is Tuesday
|
|
35
|
+
|
|
36
|
+
Midnight with a day is the end of that day, so "monday midnight" is Tuesday 00:00, with Monday 00:00 as the alternative. "12am" written out is the start of the day ([D6](rules.md#d6-midnight-with-a-day)). Any 12 o'clock without am/pm is noon ([D30](rules.md#d30-the-12-oclock-hour)):
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Acute.parse("monday midnight", now:, zone:).time # => 2026-10-20 00:00:00 -0400
|
|
40
|
+
Acute.parse("at 12", now:, zone:).time # => 2026-10-18 12:00:00 -0400
|
|
41
|
+
Acute.parse("12am", now:, zone:).time # => 2026-10-18 00:00:00 -0400
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## "1/2/2015" depends on the zone
|
|
45
|
+
|
|
46
|
+
Slashed dates follow the locale's order, and the locale comes from the zone: month first in the US, day first in the UK and Australia. The other order is an alternative when it is a valid date. Dotted dates are always day first ([D10](rules.md#d10-numeric-dates)). Pass `locale:` to fix the order:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Acute.parse("1/2/2015", now:, zone:).time # => 2015-01-02 00:00:00 -0500
|
|
50
|
+
Acute.parse("1/2/2015", now:, zone: "Australia/Sydney").time # => 2015-02-01 00:00:00 +1100
|
|
51
|
+
Acute.parse("1/2/2015", now:, zone:, locale: "en-GB").time # => 2015-02-01 00:00:00 -0500
|
|
52
|
+
Acute.parse("1.2.2015", now:, zone:).time # => 2015-02-01 00:00:00 -0500
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## "1/2/75" is 2075
|
|
56
|
+
|
|
57
|
+
A two-digit year falls in the 100 years that start 50 years before now: said in 2026, 75 is 2075 and 76 is 1976. For birthdays and other past dates, move the window back with `year_bias:` ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Acute.parse("1/2/75", now:, zone:).time.year # => 2075
|
|
61
|
+
Acute.parse("1/2/76", now:, zone:).time.year # => 1976
|
|
62
|
+
Acute.parse("1/2/75", now:, zone:, year_bias: 80).time.year # => 1975
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## "this week" starts on Sunday
|
|
66
|
+
|
|
67
|
+
The week starts on Sunday in the US and on Monday in the UK, Europe and Australia, again from the zone's locale. `week_start:` overrides it ([D19](rules.md#d19-week-start)). ISO week numbers always start on Monday:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Acute.parse("this week", now:, zone:).begin # => 2026-10-11 00:00:00 -0400
|
|
71
|
+
Acute.parse("this week", now:, zone: "Europe/London").begin # => 2026-10-12 00:00:00 +0100
|
|
72
|
+
Acute.parse("this week", now:, zone:, week_start: :monday).begin # => 2026-10-12 00:00:00 -0400
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## "tomorrow" ends at midnight, and "mon to wed" ends on Thursday
|
|
76
|
+
|
|
77
|
+
A phrase means the whole period it names. `time` is its start and `end` is the first moment after it, so a day ends at the next day's 00:00. A range of days includes its last day, so "mon to wed" ends at Thursday 00:00; a range of clock times ends at the stated time ([D9](rules.md#d9-where-ranges-end)):
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
Acute.parse("tomorrow", now:, zone:).end # => 2026-10-19 00:00:00 -0400
|
|
81
|
+
Acute.parse("mon to wed", now:, zone:).end # => 2026-10-22 00:00:00 -0400
|
|
82
|
+
Acute.parse("2 to 4pm", now:, zone:).end # => 2026-10-17 16:00:00 -0400
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## 2:30am on the night the clocks go forward is 3:30
|
|
86
|
+
|
|
87
|
+
New York's clocks skip from 2:00 to 3:00 on 8 March 2026, so 2:30 that night never happens. acute moves it forward by the gap. On 1 November the clocks go back and 1:30 happens twice; acute takes the first. `dst:` picks another instant, or `dst: :reject` fails instead ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Acute.parse("march 8 2026 2:30am", now:, zone:).time # => 2026-03-08 03:30:00 -0400
|
|
91
|
+
Acute.parse("march 8 2026 2:30am", now:, zone:, dst: :earlier).time # => 2026-03-08 01:30:00 -0500
|
|
92
|
+
Acute.parse("nov 1 2026 1:30am", now:, zone:).time # => 2026-11-01 01:30:00 -0400
|
|
93
|
+
Acute.parse("nov 1 2026 1:30am", now:, zone:, dst: :later).time # => 2026-11-01 01:30:00 -0500
|
|
94
|
+
|
|
95
|
+
Acute.parse!("march 8 2026 2:30am", now:, zone:, dst: :reject)
|
|
96
|
+
# Acute::ParseError: "march 8 2026 2:30am" names a wall time the clocks skip as they go forward
|
|
97
|
+
# e.code => :dst_skipped_time
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## "1530" is a year
|
|
101
|
+
|
|
102
|
+
Four digits alone are a year. "at", a leading zero, am/pm or "hrs" make them a time ([D5](rules.md#d5-a-bare-four-digit-number)):
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
Acute.parse("1530", now:, zone:).grain # => :year
|
|
106
|
+
Acute.parse("at 1530", now:, zone:).time # => 2026-10-17 15:30:00 -0400
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Adding days under Rails jumps an hour
|
|
110
|
+
|
|
111
|
+
Times come back as plain `Time` values with a fixed offset, so `+ 86_400` across a clock change keeps the old offset. Under Rails, convert first: `r.time.in_time_zone(r.zone)` ([time zones](usage.md#time-zones)).
|
|
112
|
+
|
|
113
|
+
## "soon" gave nil
|
|
114
|
+
|
|
115
|
+
acute fails rather than guess. A phrase it can't pin to an exact time returns nil, and `parse!` raises with the reason ([failures](usage.md#failures)):
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
Acute.parse("soon", now:, zone:) # => nil
|
|
119
|
+
Acute.parse("the day after the meeting", now:, zone:) # => nil
|
|
120
|
+
Acute.parse("satterday", now:, zone:) # => nil
|
|
121
|
+
Acute.parse("mon, wed and fri", now:, zone:) # => nil
|
|
122
|
+
Acute.parse("FY27", now:, zone:) # => nil
|
|
123
|
+
Acute.parse("FY27", now:, zone:, fiscal_year_start: 7).date.to_s # => "2026-07-01"
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **No time named**: "soon", "sometime next week".
|
|
127
|
+
- **Words it doesn't know**, such as an event or a name: "the day after the meeting". `strict: false` skips unknown words ([D22](rules.md#d22-strict-by-default)).
|
|
128
|
+
- **Misspellings** outside a small fixed table. There is no fuzzy matching: "tommorow" reads, "satterday" doesn't (D22).
|
|
129
|
+
- **Several dates in one phrase**: "mon, wed and fri", "tonight or tomorrow" (D22). `extract` reads dates out of text instead.
|
|
130
|
+
- **Other languages**: "mañana", "demain". A locale sets date order, week start and holidays, not the words.
|
|
131
|
+
- **Holidays outside the built-in set**, such as "chinese new year", without a `holidays:` calendar ([D15](rules.md#d15-holidays)); fiscal years without `fiscal_year_start:` ([D17](rules.md#d17-business-days-and-fiscal-years)); year 0 and BC ([D12](rules.md#d12-centuries-and-decades)).
|
|
132
|
+
- **A repeat that says how often but not when**: "twice a week", "every morning" ([what isn't a rule](recurrence.md#what-isnt-a-rule)).
|
|
133
|
+
- **Text over 512 bytes** fails with `too_long`.
|