Files
beads/internal/timeparsing/nlp_test.go
Peter Chanthamynavong d371baf2ca feat(dates): add --due and --defer timestamp options with natural language parsing (#847)
* feat(dates): add due date schema and --due flag

- Add due_at and defer_until columns to issues table via migration 035
- Implement --due flag on create command with ISO date parsing
- Extend RPC protocol and daemon to pass DueAt from CLI to storage
- Display DueAt and DeferUntil in show command output
- Update Issue type with new date fields

Users can now set due dates when creating issues, enabling deadline-based
task management.

* feat(dates): add compact duration parser (+6h, +1d, +2w)

- Create internal/timeparsing package with layered parser architecture
- Implement parseCompactDuration with regex pattern [+-]?\d+[hdwmy]
- Add comprehensive test suite (22 cases) for duration parsing
- Integrate into create.go with fallback to ISO format

Supports hours (h), days (d), weeks (w), months (m), and years (y).
Negative values allowed for past dates.

* feat(dates): add NLP parsing for natural language dates

Integrate olebedev/when library for natural language time expressions.
The layered parser now handles: compact duration → absolute formats → NLP.

Changes:
- Add olebedev/when dependency for NLP parsing
- Implement ParseNaturalLanguage and ParseRelativeTime functions
- Reorder layers: absolute formats before NLP to avoid misinterpretation
- Simplify create.go to use unified ParseRelativeTime
- Add comprehensive NLP test coverage (22 test cases)

Supports: tomorrow, next monday, in 3 days, 3 days ago

* feat(dates): add --defer flag to create/update/defer commands

Add time-based deferral support alongside existing status-based defer.
Issues can now be hidden from bd ready until a specific time.

Changes:
- Add --defer flag to bd create (sets defer_until on creation)
- Add --due and --defer flags to bd update (modify existing issues)
- Add --until flag to bd defer (combines status=deferred with defer_until)
- Add DueAt/DeferUntil fields to UpdateArgs in protocol.go

Supports: +1h, tomorrow, next monday, 2025-01-15

* feat(dates): add defer_until filtering to ready command

Add time-based deferral support to bd ready:

- Add --include-deferred flag to show issues with future defer_until
- Filter out issues where defer_until > now by default
- Update undefer to clear defer_until alongside status change
- Add IncludeDeferred to WorkFilter and RPC ReadyArgs

Part of GH#820: Relative Date Parsing (Phase 5)

* feat(dates): add polish and tests for relative date parsing

Add user-facing warnings when defer date is in the past to help catch
common mistakes. Expand help text with format examples and document
the olebedev/when September parsing quirk.

Tests:
- TestCreateSuite/WithDueAt, WithDeferUntil, WithBothDueAndDefer
- TestReadyWorkDeferUntil (ExcludesFutureDeferredByDefault, IncludeDeferredShowsAll)

Docs:
- CLAUDE.md quick reference updated with new flags
- Help text examples for --due, --defer on create/update

Closes: Phase 6 of beads-820-relative-dates spec

* feat(list): add time-based query filters for defer/due dates

Add --deferred, --defer-before, --defer-after, --due-before, --due-after,
and --overdue flags to bd list command. All date filters now support
relative time expressions (+6h, tomorrow, next monday) via the
timeparsing package.

Filters:
- --deferred: issues with defer_until set
- --defer-before/after: filter by defer_until date range
- --due-before/after: filter by due_at date range
- --overdue: due_at in past AND status != closed

Existing date filters (--created-after, etc.) now also support relative
time expressions through updated parseTimeFlag().

* build(nix): update vendorHash for olebedev/when dependency

The olebedev/when library was added for natural language date parsing
(GH#820). This changes go.sum, requiring an updated vendorHash in the
Nix flake configuration.
2026-01-01 20:06:13 -08:00

288 lines
6.6 KiB
Go

package timeparsing
import (
"testing"
"time"
)
// TestParseNaturalLanguage tests the NLP parser wrapper.
func TestParseNaturalLanguage(t *testing.T) {
// Fixed reference time: Wednesday, January 15, 2025, 10:00:00 AM
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
tests := []struct {
name string
input string
wantYear int
wantMonth time.Month
wantDay int
wantHour int // -1 means don't check hour
wantErr bool
}{
// Relative days
{
name: "tomorrow",
input: "tomorrow",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: -1,
wantErr: false,
},
{
name: "yesterday",
input: "yesterday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 14,
wantHour: -1,
wantErr: false,
},
// Next weekday (reference is Wednesday Jan 15)
{
name: "next monday",
input: "next monday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20, // Next Monday after Jan 15
wantHour: -1,
wantErr: false,
},
{
name: "next friday",
input: "next friday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 17, // Friday Jan 17 (same week)
wantHour: -1,
wantErr: false,
},
// With time
{
name: "tomorrow at 9am",
input: "tomorrow at 9am",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: 9,
wantErr: false,
},
{
name: "next monday at 2pm",
input: "next monday at 2pm",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20,
wantHour: 14,
wantErr: false,
},
// Relative durations (NLP style)
{
name: "in 3 days",
input: "in 3 days",
wantYear: 2025,
wantMonth: time.January,
wantDay: 18,
wantHour: -1,
wantErr: false,
},
{
name: "in 1 week",
input: "in 1 week",
wantYear: 2025,
wantMonth: time.January,
wantDay: 22,
wantHour: -1,
wantErr: false,
},
// Past relative
{
name: "3 days ago",
input: "3 days ago",
wantYear: 2025,
wantMonth: time.January,
wantDay: 12,
wantHour: -1,
wantErr: false,
},
// Invalid inputs
{
name: "random text",
input: "not a date at all",
wantErr: true,
},
{
name: "empty string",
input: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseNaturalLanguage(tt.input, now)
if (err != nil) != tt.wantErr {
t.Errorf("ParseNaturalLanguage(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if got.Year() != tt.wantYear {
t.Errorf("ParseNaturalLanguage(%q) year = %d, want %d", tt.input, got.Year(), tt.wantYear)
}
if got.Month() != tt.wantMonth {
t.Errorf("ParseNaturalLanguage(%q) month = %v, want %v", tt.input, got.Month(), tt.wantMonth)
}
if got.Day() != tt.wantDay {
t.Errorf("ParseNaturalLanguage(%q) day = %d, want %d", tt.input, got.Day(), tt.wantDay)
}
if tt.wantHour >= 0 && got.Hour() != tt.wantHour {
t.Errorf("ParseNaturalLanguage(%q) hour = %d, want %d", tt.input, got.Hour(), tt.wantHour)
}
})
}
}
// TestParseRelativeTime tests the layered parsing function.
func TestParseRelativeTime(t *testing.T) {
// Fixed reference time: Wednesday, January 15, 2025, 10:00:00 AM
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
tests := []struct {
name string
input string
wantYear int
wantMonth time.Month
wantDay int
wantHour int // -1 means don't check hour
wantErr bool
}{
// Layer 1: Compact duration (should be tried first)
{
name: "compact +1d",
input: "+1d",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: 10, // Same hour as now
wantErr: false,
},
{
name: "compact +6h",
input: "+6h",
wantYear: 2025,
wantMonth: time.January,
wantDay: 15,
wantHour: 16, // 10 + 6 = 16
wantErr: false,
},
// Layer 2: NLP
{
name: "NLP tomorrow",
input: "tomorrow",
wantYear: 2025,
wantMonth: time.January,
wantDay: 16,
wantHour: -1,
wantErr: false,
},
{
name: "NLP next monday",
input: "next monday",
wantYear: 2025,
wantMonth: time.January,
wantDay: 20,
wantHour: -1,
wantErr: false,
},
// Layer 3: Date-only
{
name: "date-only",
input: "2025-02-01",
wantYear: 2025,
wantMonth: time.February,
wantDay: 1,
wantHour: 0,
wantErr: false,
},
// Layer 4: RFC3339
{
name: "RFC3339",
input: "2025-03-15T14:30:00Z",
wantYear: 2025,
wantMonth: time.March,
wantDay: 15,
wantHour: 14,
wantErr: false,
},
// Invalid
{
name: "invalid expression",
input: "not-a-date",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseRelativeTime(tt.input, now)
if (err != nil) != tt.wantErr {
t.Errorf("ParseRelativeTime(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if tt.wantErr {
return
}
if got.Year() != tt.wantYear {
t.Errorf("ParseRelativeTime(%q) year = %d, want %d", tt.input, got.Year(), tt.wantYear)
}
if got.Month() != tt.wantMonth {
t.Errorf("ParseRelativeTime(%q) month = %v, want %v", tt.input, got.Month(), tt.wantMonth)
}
if got.Day() != tt.wantDay {
t.Errorf("ParseRelativeTime(%q) day = %d, want %d", tt.input, got.Day(), tt.wantDay)
}
if tt.wantHour >= 0 && got.Hour() != tt.wantHour {
t.Errorf("ParseRelativeTime(%q) hour = %d, want %d", tt.input, got.Hour(), tt.wantHour)
}
})
}
}
// TestParseRelativeTime_LayerPrecedence verifies that layers are tried in order.
func TestParseRelativeTime_LayerPrecedence(t *testing.T) {
now := time.Date(2025, 1, 15, 10, 0, 0, 0, time.Local)
// "+1d" is valid compact duration, should NOT be parsed as NLP
t1, err := ParseRelativeTime("+1d", now)
if err != nil {
t.Fatalf("ParseRelativeTime(\"+1d\") failed: %v", err)
}
// Compact duration adds exactly 1 day, preserving time
expected := now.AddDate(0, 0, 1)
if !t1.Equal(expected) {
t.Errorf("ParseRelativeTime(\"+1d\") = %v, want %v (compact duration should take precedence)", t1, expected)
}
// "2025-01-20" should parse as date-only, not NLP
t2, err := ParseRelativeTime("2025-01-20", now)
if err != nil {
t.Fatalf("ParseRelativeTime(\"2025-01-20\") failed: %v", err)
}
if t2.Day() != 20 || t2.Month() != time.January || t2.Year() != 2025 {
t.Errorf("ParseRelativeTime(\"2025-01-20\") = %v, want Jan 20, 2025", t2)
}
}