quick add: a run of days is a run of days, and all-day sentences create again
"london tues-thurs" made a Tuesday titled "london -thurs". Two things were wrong and only one of them was visible. RANGES ARE TRIED BEFORE SINGLE DATES, and that ordering is the fix rather than an implementation detail. Run the singles first and "12-14 March" matches "14 March" -- the range's own END -- which books one day and strands the "12" in the title. quickadd_range.go now takes weekday runs (tues-thurs, Monday to Wednesday), days sharing a month (12-14 March, March 12-14), two whole dates (30 September to 2 October) and the numeric form (12/10-14/10), all before takeDate sees the sentence. A range only ever matches around a CALENDAR WORD -- a month name, a weekday name or a slash. A range of bare numerals is a time, always, so "Retro 3-4pm" cannot become the third to the fourth. The en dash is in the separator class deliberately. macOS substitutes one for a typed hyphen mid-sentence, which is what the reported screenshot actually contained: a character the person never typed and could not see they had. A RANGE WITH NO CLOCK IS ALL DAY. A three-day trip is not an hour on the Tuesday with two days of silence after it. A range WITH a clock is one continuous block -- the start time on the first day, the end time on the last. That is the literal reading and it is not the only one: "Monday to Wednesday 9am-5pm" might have meant nine to five on each of three days. That is a recurrence, ruling 4 forbids inventing a rule the picker cannot re-display, so the literal reading wins and the pane prints both ends in full for anyone who meant the other. AND THE BUG NEXT DOOR: every all-day sentence had been silently refused. fill wrote "2027-03-12T00:00" for both ends of an all-day event. That is neither a DATE -- which is what stops a birthday landing west of Greenwich -- nor a non-empty half-open range. ToUTC could not parse it, CreateEvent returned a ValidationError, and the handler's own kindness did the rest: it redirects a refused parse to /events/new?text=... so nothing is retyped. So "Team offsite all day on 12 March" opened a prefilled form, and every all-day sentence typed since the feature shipped quietly created nothing at all. It is a DATE on both ends now, and the end is EXCLUSIVE like every other range in this app. The pane prints the INCLUSIVE last day, because telling somebody their trip runs to Friday when it ends on Thursday is the one thing a preview must not do. The regression test for that caught me first. It asserted the redirect started with "/events/" -- which /events/new?text=... also does, so it passed against the reverted code and proved nothing. It matches ^/events/\d+$ now, and reverting the fix fails it with the real redirect printed. The all-day branch also returned before writing the "nobody is emailed" note, so an all-day sentence with an address in it recorded an attendee and said nothing. Both branches go through one note method now. make browser grows a fifth pass: type a range, read the pane, assert both ends and no stranded word.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LV8Q1jhFAKEEeMbdzfhsAj
9 files changed,
+417
−28
docs/superpowers/specs/2026-09-02-quick-add-design.md+44 −4internal/calendar/handlers_quick.go+8 −0internal/calendar/quickadd.go+45 −17internal/calendar/quickadd_range.go+115 −0internal/calendar/quickadd_when.go+11 −3internal/calendar/quickadd_who.go+1 −1internal/calendartest/quickadd_http_test.go+76 −0internal/calendartest/quickadd_test.go+90 −2test/browser/quickadd.mjs+27 −1
diff --git a/docs/superpowers/specs/2026-09-02-quick-add-design.md b/docs/superpowers/specs/2026-09-02-quick-add-design.md| index 4d80921..a17e145 100644 |
| --- a/docs/superpowers/specs/2026-09-02-quick-add-design.md |
| +++ b/docs/superpowers/specs/2026-09-02-quick-add-design.md |
| @@ -73,6 +73,44 @@ browser gate asserts the hint is absent with JavaScript off. |
| The Add button is untouched. This is an enhancement on a floor that |
| already worked. |
| +## 2.2 A run of days is a run of days |
| + |
| +**A date RANGE makes one event spanning those days.** "london |
| +tues–thurs" is Tuesday, Wednesday and Thursday in London. |
| + |
| +Every range pattern is tried before any single-date one, and that |
| +ordering is the fix rather than a detail. Run the singles first and |
| +"12–14 March" matches "14 March" — the range's own END — which books one |
| +day and leaves a stray "12" in the title. The reported symptom was |
| +exactly this shape: "london tues–thurs" became a Tuesday titled |
| +"london –thurs". |
| + |
| +**A range with no clock in it is ALL DAY**, and the pane says so in |
| +words. A three-day trip is not an hour on the Tuesday with two days of |
| +silence after it. |
| + |
| +**A range WITH a clock is one continuous block**: the start time on the |
| +first day, the end time on the last. "Summit Monday to Wednesday |
| +9am–5pm" is 09:00 Monday until 17:00 Wednesday. That is the literal |
| +reading, and it is not the only possible one — somebody may have meant |
| +nine to five on each of three days. That reading is a recurrence, and |
| +ruling 4 forbids inventing a rule the picker cannot re-display, so the |
| +literal one wins and the pane prints both ends in full for anyone who |
| +meant the other. |
| + |
| +**A range only ever matches around a calendar word** — a month name, a |
| +weekday name, or a slash. A digits-only range is a TIME, always: |
| +"Retro 3-4pm" must never become the third to the fourth. |
| + |
| +The separators are `-`, `–`, `—`, `to`, `until`, `till`, `through` and |
| +`thru`. The en dash is not decoration: macOS substitutes one for a |
| +typed hyphen mid-sentence, so a parser that only knows ASCII fails on |
| +text the person watched themselves type correctly. |
| + |
| +**The end date stored is EXCLUSIVE**, like every other range in this |
| +app, and **the pane prints the INCLUSIVE last day**. Showing the stored |
| +end would tell somebody their trip runs a day longer than it does. |
| + |
| ## 3. Nothing is guessed silently |
| Every defaulted field is **named in the pane in words**, not implied: |
| @@ -111,10 +149,12 @@ the title rather than invented, and the pane shows it sitting there. |
| ## 6. What the parser takes |
| -Times `3pm`, `15:00`, `3:30pm`, `half three`; ranges `3-4pm`, `3pm to |
| -4:30`, `from 3 until 4`; durations `for 90 minutes`, `for 2h`, `for an |
| -hour`; dates `today`, `tomorrow`, `on Friday`, `next Tuesday`, `12 |
| -March`, `12/3`; `all day`; locations `in X` / `at X` where X is not a |
| +Times `3pm`, `15:00`, `3:30pm`, `half three`; time ranges `3-4pm`, `3pm |
| +to 4:30`, `from 3 until 4`; durations `for 90 minutes`, `for 2h`, `for |
| +an hour`; dates `today`, `tomorrow`, `on Friday`, `next Tuesday`, `12 |
| +March`, `12/3`; DATE RANGES `tues–thurs`, `Monday to Wednesday`, |
| +`12–14 March`, `March 12-14`, `30 September to 2 October`, |
| +`12/10-14/10`; `all day`; locations `in X` / `at X` where X is not a |
| time; attendees by email address; repeats `every day` / `every Tuesday` |
| / `every week` / `every month` / `every year`. |
diff --git a/internal/calendar/handlers_quick.go b/internal/calendar/handlers_quick.go| index ef40072..1655ae6 100644 |
| --- a/internal/calendar/handlers_quick.go |
| +++ b/internal/calendar/handlers_quick.go |
| @@ -55,6 +55,14 @@ func quickWhen(q QuickParse, zone string) string { |
| } |
| day := start.Format("Monday 2 January") |
| if q.AllDay { |
| + // EndLocal is exclusive, so the last day a person is actually |
| + // busy is the day before it. Printing the stored end would tell |
| + // somebody their trip runs a day longer than it does. |
| + if end, _, err := ParseLocal(q.EndLocal, loc); err == nil { |
| + if last := end.AddDate(0, 0, -1); last.After(start) { |
| + return day + " – " + last.Format("Monday 2 January") + ", all day" |
| + } |
| + } |
| return day + ", all day" |
| } |
| end, _, err := ParseLocal(q.EndLocal, loc) |
diff --git a/internal/calendar/quickadd.go b/internal/calendar/quickadd.go| index faf3cab..e24b74e 100644 |
| --- a/internal/calendar/quickadd.go |
| +++ b/internal/calendar/quickadd.go |
| @@ -151,40 +151,54 @@ func ParseQuick(text string, now time.Time, zone string) QuickParse { |
| q.AllDay = takeAllDay(c) |
| q.Repeat = takeRepeat(c) |
| - day, dayFound := takeDate(c, now) |
| + when := takeSpan(c, now) |
| start, end, timeFound := takeTimes(c) |
| dur, durFound := takeDuration(c) |
| q.Location = takeLocation(c) |
| q.Title = c.rest() |
| - q.fill(now, zone, day, dayFound, start, end, timeFound, dur, durFound) |
| + q.fill(now, zone, when, start, end, timeFound, dur, durFound) |
| return q |
| } |
| // fill turns what was found into a start and an end, and writes a note |
| // for every part it had to decide on its own. |
| -func (q *QuickParse) fill(now time.Time, zone string, day time.Time, dayFound bool, |
| +func (q *QuickParse) fill(now time.Time, zone string, when span, |
| start, end clockTime, timeFound bool, dur time.Duration, durFound bool) { |
| - if !dayFound { |
| - day = now |
| + day, last := when.start, when.last |
| + if !when.found { |
| + day, last = now, now |
| + } |
| + |
| + // A RUN of days with no clock anywhere in the sentence is an |
| + // all-day event. "london tues-thurs" is a trip, and reading it as an |
| + // hour on Tuesday with a two-day tail nobody asked for would be |
| + // wrong in a way the pane could not show. |
| + if when.isRange && !timeFound && !q.AllDay { |
| + q.AllDay = true |
| + q.Notes = append(q.Notes, |
| + "A run of days with no time, so this is an all-day event.") |
| } |
| if q.AllDay { |
| - q.StartLocal = day.Format(LayoutDate) + "T00:00" |
| - q.EndLocal = day.Format(LayoutDate) + "T00:00" |
| - if !dayFound { |
| - q.Notes = append(q.Notes, "No date found, so this is today.") |
| - } |
| + // A DATE, never a datetime at midnight - that is what puts a |
| + // birthday on the wrong day west of Greenwich. And the end is |
| + // EXCLUSIVE like every other range in this app: the day AFTER |
| + // the last one. Writing the same date twice makes an all-day |
| + // event of zero length, which the validator refuses outright. |
| + q.StartLocal = day.Format(LayoutDate) |
| + q.EndLocal = last.AddDate(0, 0, 1).Format(LayoutDate) |
| + q.note(when.found, len(q.Attendees) > 0) |
| return |
| } |
| if !timeFound { |
| // The next half hour, said out loud rather than assumed. |
| next := nextHalfHour(now) |
| - if !dayFound { |
| - day = next |
| + if !when.found { |
| + day, last = next, next |
| } |
| start = clockTime{h: next.Hour(), m: next.Minute()} |
| q.Notes = append(q.Notes, |
| @@ -193,29 +207,43 @@ func (q *QuickParse) fill(now time.Time, zone string, day time.Time, dayFound bo |
| startAt := time.Date(day.Year(), day.Month(), day.Day(), start.h, start.m, 0, 0, time.UTC) |
| + // Every end lands on the LAST day of the span, which is the same day |
| + // as the start unless a range was read - so there is one rule here |
| + // rather than a range case bolted onto a single-day one. |
| var endAt time.Time |
| switch { |
| case end.set: |
| - endAt = time.Date(day.Year(), day.Month(), day.Day(), end.h, end.m, 0, 0, time.UTC) |
| + endAt = time.Date(last.Year(), last.Month(), last.Day(), end.h, end.m, 0, 0, time.UTC) |
| // "9pm to 1am" crosses midnight; an end before its start is the |
| // next day rather than a negative meeting. |
| if !endAt.After(startAt) { |
| endAt = endAt.AddDate(0, 0, 1) |
| } |
| case durFound: |
| - endAt = startAt.Add(dur) |
| + endAt = time.Date(last.Year(), last.Month(), last.Day(), start.h, start.m, 0, 0, time.UTC).Add(dur) |
| default: |
| - endAt = startAt.Add(time.Hour) |
| - q.Notes = append(q.Notes, "No end time found, so this lasts an hour.") |
| + endAt = time.Date(last.Year(), last.Month(), last.Day(), start.h, start.m, 0, 0, time.UTC).Add(time.Hour) |
| + if when.isRange { |
| + q.Notes = append(q.Notes, |
| + "No end time found, so this ends an hour after it starts on the last day.") |
| + } else { |
| + q.Notes = append(q.Notes, "No end time found, so this lasts an hour.") |
| + } |
| } |
| q.StartLocal = startAt.Format(LayoutLocal) |
| q.EndLocal = endAt.Format(LayoutLocal) |
| + q.note(when.found, len(q.Attendees) > 0) |
| +} |
| +// note adds the two sentences every branch above owes the pane. They |
| +// live here because the all-day branch returns early and used to skip |
| +// the attendee one - the single line a person most needs to read. |
| +func (q *QuickParse) note(dayFound, haveAttendees bool) { |
| if !dayFound { |
| q.Notes = append(q.Notes, "No date found, so this is today.") |
| } |
| - if len(q.Attendees) > 0 { |
| + if haveAttendees { |
| q.Notes = append(q.Notes, |
| "Nobody is emailed. Attendees are recorded on the event only.") |
| } |
diff --git a/internal/calendar/quickadd_range.go b/internal/calendar/quickadd_range.go| new file mode 100644 |
| index 0000000..f51262e |
| --- /dev/null |
| +++ b/internal/calendar/quickadd_range.go |
| @@ -0,0 +1,115 @@ |
| +package calendar |
| + |
| +import ( |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// span is the day, or the RUN of days, that one sentence named. |
| +// |
| +// last is INCLUSIVE and equals start unless a range was read. "london |
| +// tues–thurs" is three days in London: read as a single date it becomes |
| +// a Tuesday with the word "thurs" stranded in the title, which is what |
| +// it did before this file existed. |
| +type span struct { |
| + start time.Time |
| + last time.Time |
| + found bool |
| + isRange bool |
| +} |
| + |
| +func oneDay(t time.Time, found bool) span { |
| + return span{start: t, last: t, found: found} |
| +} |
| + |
| +// rangeSep is what people actually type between two dates. The hyphen |
| +// family has three members because a keyboard gives you "-" and macOS |
| +// substitutes "–" behind your back mid-sentence — the screenshot that |
| +// prompted this had an en dash nobody typed. |
| +const rangeSep = `(?:\s*[-–—]\s*|\s+(?:to|until|till|through|thru)\s+)` |
| + |
| +var ( |
| + // A run of weekdays: "tues–thurs", "Monday to Wednesday". |
| + reWeekdayRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:(next|this)\s+)?(?:on\s+)?(` + |
| + weekdayAlt + `)` + rangeSep + `(?:next\s+)?(` + weekdayAlt + `)\b`) |
| + // Two full dates: "12 March to 14 April". |
| + reDateRange = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(` + |
| + monthAlt + `)` + rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`) |
| + // Days sharing a month: "12–14 March". |
| + reDayDayMonth = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?` + |
| + rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`) |
| + // The same, month first: "March 12–14". |
| + reMonthDayDay = regexp.MustCompile(`(?i)\b(?:from\s+)?(?:on\s+)?(` + monthAlt + |
| + `)\s+(\d{1,2})(?:st|nd|rd|th)?` + rangeSep + `(\d{1,2})(?:st|nd|rd|th)?\b`) |
| + // Numeric: "12/3-14/3". |
| + reNumericRange = regexp.MustCompile(`\b(?:from\s+)?(?:on\s+)?(\d{1,2})/(\d{1,2})` + |
| + rangeSep + `(\d{1,2})/(\d{1,2})\b`) |
| +) |
| + |
| +// takeSpan reads the day or the run of days out of the sentence. |
| +// |
| +// Every RANGE pattern is tried before any single-date one, and that |
| +// ordering is the whole fix. Run the singles first and "12–14 March" |
| +// matches "14 March" — the range's own end date — leaving a "12" behind |
| +// in the title and booking one day instead of three. |
| +// |
| +// Ranges are tried before takeTimes for the same reason a single date |
| +// is: the patterns here all require a month name, a weekday name or a |
| +// slash, so "Retro 3-4pm" cannot reach them. That is deliberate. A |
| +// digits-only range with no calendar word in it is a time, always. |
| +func takeSpan(c *consumed, now time.Time) span { |
| + if m := c.take(reDateRange); m != nil { |
| + d1, _ := strconv.Atoi(m[1]) |
| + d2, _ := strconv.Atoi(m[3]) |
| + s := onOrAfter(now, months[strings.ToLower(m[2])], d1) |
| + e := time.Date(s.Year(), months[strings.ToLower(m[4])], d2, 0, 0, 0, 0, now.Location()) |
| + return rangeSpan(s, e, 1, 0) |
| + } |
| + if m := c.take(reDayDayMonth); m != nil { |
| + d1, _ := strconv.Atoi(m[1]) |
| + d2, _ := strconv.Atoi(m[2]) |
| + mo := months[strings.ToLower(m[3])] |
| + s := onOrAfter(now, mo, d1) |
| + // A month rolls rather than a year: "30–2 April" is the 30th of |
| + // March only if somebody wrote it that way, but "30–2 May" read |
| + // as a year later would be absurd. |
| + return rangeSpan(s, time.Date(s.Year(), mo, d2, 0, 0, 0, 0, now.Location()), 0, 1) |
| + } |
| + if m := c.take(reMonthDayDay); m != nil { |
| + d1, _ := strconv.Atoi(m[2]) |
| + d2, _ := strconv.Atoi(m[3]) |
| + mo := months[strings.ToLower(m[1])] |
| + s := onOrAfter(now, mo, d1) |
| + return rangeSpan(s, time.Date(s.Year(), mo, d2, 0, 0, 0, 0, now.Location()), 0, 1) |
| + } |
| + if m := c.take(reNumericRange); m != nil { |
| + return rangeSpan(numericDay(now, m[1], m[2]), numericDay(now, m[3], m[4]), 1, 0) |
| + } |
| + if m := c.take(reWeekdayRange); m != nil { |
| + s := nextWeekday(now, weekdays[strings.ToLower(m[2])], strings.EqualFold(m[1], "next")) |
| + // The end is the FIRST of that weekday on or after the start, so |
| + // "Sunday to Tuesday" crosses the week boundary instead of |
| + // running backwards into a negative event. |
| + delta := (int(weekdays[strings.ToLower(m[3])]) - int(s.Weekday()) + 7) % 7 |
| + return span{start: s, last: s.AddDate(0, 0, delta), found: true, isRange: true} |
| + } |
| + day, found := takeDate(c, now) |
| + return oneDay(day, found) |
| +} |
| + |
| +// rangeSpan makes an end that is not before its start, rolling forward |
| +// by years or months as the caller's units say. |
| +func rangeSpan(s, e time.Time, years, months int) span { |
| + if e.Before(s) { |
| + e = e.AddDate(years, months, 0) |
| + } |
| + return span{start: s, last: e, found: true, isRange: true} |
| +} |
| + |
| +func numericDay(now time.Time, dd, mm string) time.Time { |
| + d, _ := strconv.Atoi(dd) |
| + mo, _ := strconv.Atoi(mm) |
| + return time.Date(now.Year(), time.Month(mo), d, 0, 0, 0, 0, now.Location()) |
| +} |
diff --git a/internal/calendar/quickadd_when.go b/internal/calendar/quickadd_when.go| index f4d77bf..79c6235 100644 |
| --- a/internal/calendar/quickadd_when.go |
| +++ b/internal/calendar/quickadd_when.go |
| @@ -120,12 +120,20 @@ func takeDuration(c *consumed) (time.Duration, bool) { |
| return 0, false |
| } |
| +// weekdayAlt and monthAlt are shared with quickadd_range.go. One |
| +// spelling of "the names of the days", so a range and a single date can |
| +// never disagree about whether "tues" is a word. |
| +const ( |
| + weekdayAlt = `monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun` |
| + monthAlt = `january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec` |
| +) |
| + |
| var ( |
| reToday = regexp.MustCompile(`(?i)\b(today|tonight)\b`) |
| reTomorrow = regexp.MustCompile(`(?i)\btomorrow\b`) |
| - reWeekday = regexp.MustCompile(`(?i)\b(?:(next|this)\s+)?(?:on\s+)?(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b`) |
| - reDayMonth = regexp.MustCompile(`(?i)\b(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\b`) |
| - reMonthDay = regexp.MustCompile(`(?i)\b(?:on\s+)?(january|february|march|april|may|june|july|august|september|october|november|december|jan|feb|mar|apr|jun|jul|aug|sep|sept|oct|nov|dec)\s+(\d{1,2})(?:st|nd|rd|th)?\b`) |
| + reWeekday = regexp.MustCompile(`(?i)\b(?:(next|this)\s+)?(?:on\s+)?(` + weekdayAlt + `)\b`) |
| + reDayMonth = regexp.MustCompile(`(?i)\b(?:on\s+)?(\d{1,2})(?:st|nd|rd|th)?\s+(` + monthAlt + `)\b`) |
| + reMonthDay = regexp.MustCompile(`(?i)\b(?:on\s+)?(` + monthAlt + `)\s+(\d{1,2})(?:st|nd|rd|th)?\b`) |
| reNumeric = regexp.MustCompile(`\b(?:on\s+)?(\d{1,2})/(\d{1,2})(?:/(\d{2,4}))?\b`) |
| ) |
diff --git a/internal/calendar/quickadd_who.go b/internal/calendar/quickadd_who.go| index 2c62669..dafb92a 100644 |
| --- a/internal/calendar/quickadd_who.go |
| +++ b/internal/calendar/quickadd_who.go |
| @@ -37,7 +37,7 @@ var reAllDay = regexp.MustCompile(`(?i)\b(all[\s-]?day)\b`) |
| func takeAllDay(c *consumed) bool { return c.take(reAllDay) != nil } |
| var ( |
| - reEveryWeekday = regexp.MustCompile(`(?i)\bevery\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday|mon|tue|tues|wed|weds|thu|thur|thurs|fri|sat|sun)\b`) |
| + reEveryWeekday = regexp.MustCompile(`(?i)\bevery\s+(` + weekdayAlt + `)\b`) |
| reEveryUnit = regexp.MustCompile(`(?i)\bevery\s+(day|weekday|week|fortnight|two\s+weeks|month|year)\b`) |
| reDailyWord = regexp.MustCompile(`(?i)\b(daily|weekly|fortnightly|monthly|yearly|annually)\b`) |
| ) |
diff --git a/internal/calendartest/quickadd_http_test.go b/internal/calendartest/quickadd_http_test.go| index 63ce69c..2d97419 100644 |
| --- a/internal/calendartest/quickadd_http_test.go |
| +++ b/internal/calendartest/quickadd_http_test.go |
| @@ -3,10 +3,20 @@ package calendartest |
| import ( |
| "net/http" |
| "net/url" |
| + "regexp" |
| "strings" |
| "testing" |
| ) |
| +// createdEvent is the redirect a successful quick add sends: /events/ |
| +// followed by an id and nothing else. |
| +// |
| +// The obvious check — HasPrefix "/events/" — is worthless here, because |
| +// the failure this file is guarding against redirects to |
| +// /events/new?text=..., which has that prefix too. Asserting the prefix |
| +// passes whether the event was created or refused. |
| +var createdEvent = regexp.MustCompile(`^/events/\d+$`) |
| + |
| // One sentence in, one event out — through the real router, with no |
| // script anywhere in the path. This is the scriptless floor, and it is |
| // the product rather than a fallback. |
| @@ -122,3 +132,69 @@ func TestQuickAddWithNoTextGoesToTheForm(t *testing.T) { |
| t.Errorf("empty quick add went to %q, want /events/new", got) |
| } |
| } |
| + |
| +// A run of days is a real event, created and visible. This is the whole |
| +// point of the range work, and it is asserted through the router rather |
| +// than against the parser because the bug it replaced was NOT in the |
| +// parse: an all-day event whose end equals its start is refused by the |
| +// validator, so the sentence used to bounce to the long form and |
| +// nothing was ever created. |
| +func TestQuickAddCreatesAnAllDayRun(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + rec := postAs(t, h, session, "/events/quick", url.Values{ |
| + "text": {"london tues-thurs"}, |
| + }) |
| + loc := rec.Header().Get("Location") |
| + if rec.Code != http.StatusSeeOther || !createdEvent.MatchString(loc) { |
| + t.Fatalf("quick add went to %q with status %d, want the new event", |
| + loc, rec.Code) |
| + } |
| + page := getAs(t, h, session, loc).Body.String() |
| + if !strings.Contains(page, "london") { |
| + t.Errorf("the created event does not show its title:\n%s", page) |
| + } |
| + // The stranded word is the symptom everybody sees: before the range |
| + // patterns existed the title came out as "london -thurs". |
| + if strings.Contains(page, "thurs") { |
| + t.Errorf("the end of the range was left in the title:\n%s", page) |
| + } |
| +} |
| + |
| +// A single all-day sentence has to create an event too. It did not: the |
| +// parser wrote "2027-03-12T00:00" for both ends, which is neither a |
| +// DATE nor a non-empty range. |
| +func TestQuickAddCreatesASingleAllDayEvent(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + rec := postAs(t, h, session, "/events/quick", url.Values{ |
| + "text": {"Team offsite all day on 12 March"}, |
| + }) |
| + if loc := rec.Header().Get("Location"); !createdEvent.MatchString(loc) { |
| + t.Fatalf("an all-day sentence went to %q instead of creating an event", loc) |
| + } |
| +} |
| + |
| +// The pane prints the last day a person is actually busy, not the |
| +// exclusive end. Telling somebody their trip runs to Friday when it ends |
| +// on Thursday is the one thing a preview must not do. |
| +func TestTheQuickAddPaneShowsTheInclusiveLastDay(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + body := postAs(t, h, session, "/events/quick/parse", url.Values{ |
| + "text": {"london tues-thurs"}, |
| + }).Body.String() |
| + |
| + if !strings.Contains(body, "Thursday") { |
| + t.Errorf("the pane does not name the last day of the run:\n%s", body) |
| + } |
| + if strings.Contains(body, "Friday") { |
| + t.Errorf("the pane printed the exclusive end as the last day:\n%s", body) |
| + } |
| + if !strings.Contains(body, "all day") { |
| + t.Errorf("the pane does not say the run is all day:\n%s", body) |
| + } |
| +} |
diff --git a/internal/calendartest/quickadd_test.go b/internal/calendartest/quickadd_test.go| index 1529f4d..e0d288e 100644 |
| --- a/internal/calendartest/quickadd_test.go |
| +++ b/internal/calendartest/quickadd_test.go |
| @@ -76,14 +76,102 @@ func TestQuickAddReadsASentence(t *testing.T) { |
| repeat: "none", |
| }, |
| { |
| + // A DATE on both ends, and the end is EXCLUSIVE like every |
| + // other range in this app. It used to write |
| + // "2027-03-12T00:00" twice, which is neither a date nor a |
| + // non-empty range: the validator refused it and quick add |
| + // silently bounced every all-day sentence to the long form. |
| name: "all day takes a date and no clock", |
| text: "Team offsite all day on 12 March", |
| title: "Team offsite", |
| - start: "2027-03-12T00:00", |
| - end: "2027-03-12T00:00", |
| + start: "2027-03-12", |
| + end: "2027-03-13", |
| allDay: true, |
| repeat: "none", |
| }, |
| + { |
| + name: "a run of weekdays is an all-day span", |
| + text: "london tues-thurs", |
| + title: "london", |
| + start: "2026-09-08", |
| + end: "2026-09-11", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "an en dash reads the same as a hyphen", |
| + text: "Berlin mon–wed", |
| + title: "Berlin", |
| + start: "2026-09-07", |
| + end: "2026-09-10", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "two words instead of a dash", |
| + text: "Conference Monday to Wednesday", |
| + title: "Conference", |
| + start: "2026-09-07", |
| + end: "2026-09-10", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "a run of weekdays crossing the week boundary", |
| + text: "Cover sunday to tuesday", |
| + title: "Cover", |
| + start: "2026-09-06", |
| + end: "2026-09-09", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "days sharing a month", |
| + text: "Holidays 12-14 March", |
| + title: "Holidays", |
| + start: "2027-03-12", |
| + end: "2027-03-15", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "the month first", |
| + text: "Holidays March 12-14", |
| + title: "Holidays", |
| + start: "2027-03-12", |
| + end: "2027-03-15", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "two whole dates, crossing a month", |
| + text: "Tour 30 September to 2 October", |
| + title: "Tour", |
| + start: "2026-09-30", |
| + end: "2026-10-03", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + name: "a numeric range", |
| + text: "Leave 12/10-14/10", |
| + title: "Leave", |
| + start: "2026-10-12", |
| + end: "2026-10-15", |
| + allDay: true, |
| + repeat: "none", |
| + }, |
| + { |
| + // A range with a clock in it is NOT all day. It is one |
| + // continuous block: the start time on the first day, the end |
| + // time on the last. |
| + name: "a range with times is one continuous block", |
| + text: "Summit Monday to Wednesday 9am-5pm", |
| + title: "Summit", |
| + start: "2026-09-07T09:00", |
| + end: "2026-09-09T17:00", |
| + repeat: "none", |
| + }, |
| { |
| name: "every weekday is the weekdays choice", |
| text: "Standup every weekday at 9:30", |
diff --git a/test/browser/quickadd.mjs b/test/browser/quickadd.mjs| index 9cf9e73..2bdfb80 100644 |
| --- a/test/browser/quickadd.mjs |
| +++ b/test/browser/quickadd.mjs |
| @@ -6,7 +6,7 @@ |
| // all (it starts Chromium and parks at about:blank until the Go test |
| // times out), and a gate that cannot run is not a gate. |
| // |
| -// Four passes, and the scriptless ones matter most: |
| +// Five passes, and the scriptless ones matter most: |
| // |
| // 1. WITH script — type, and the pane fills in from the server. |
| // 2. WITHOUT script — the same box still opens, still submits, and |
| @@ -14,6 +14,8 @@ |
| // 3. WITH script — Enter adds it, Shift+Enter does not. |
| // 4. WITHOUT script — the Enter hint is absent, because the shortcut |
| // does not exist there and a page must not promise one. |
| +// 5. WITH script — a date range reads as a run of days, and the pane |
| +// names the last one. |
| // |
| // The scriptless passes are the only way to prove the floor is real. |
| // Every other test in this repo asks Go what Go produced; these ask a |
| @@ -175,6 +177,30 @@ async function contextWith(javaScriptEnabled) { |
| await ctx.close(); |
| } |
| +// ── 5. A date range is a run of days ──────────────────────────────── |
| +{ |
| + const ctx = await contextWith(true); |
| + const page = await ctx.newPage(); |
| + await page.goto(origin + '/today', { waitUntil: 'networkidle' }); |
| + await page.locator('[cal-quick] > summary').click(); |
| + |
| + await page.locator('[cal-quick-text]').fill('london tues-thurs'); |
| + await page.waitForFunction( |
| + () => /Tuesday/.test(document.querySelector('[cal-quick-pane]')?.innerText || ''), |
| + null, { timeout: 5000 }, |
| + ); |
| + |
| + const pane = await page.locator('[cal-quick-pane]').innerText(); |
| + check(/Tuesday/.test(pane) && /Thursday/.test(pane), |
| + 'the pane names both ends of the run:\n' + pane); |
| + check(/all day/i.test(pane), |
| + 'the pane says a run with no time is all day:\n' + pane); |
| + // The stranded word: the whole reason this pass exists. |
| + check(!/thurs\b/i.test(pane.split('\n')[0] || ''), |
| + 'the end of the range was left in the title:\n' + pane); |
| + await ctx.close(); |
| +} |
| + |
| await browser.close(); |
| if (failures.length) { |