* 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.
288 lines
6.6 KiB
Go
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)
|
|
}
|
|
}
|