From 8c9e0e725a22fbd294def47f60719dc33d8bd32f Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Wed, 11 Feb 2026 20:01:35 +0100 Subject: [PATCH] feat(quando-tn3): implement relative date parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ParseRelative() and ParseRelativeWithClock() for parsing relative date expressions like "today", "tomorrow", "+2 days", "-1 week", etc. Features: - Keywords: today, tomorrow, yesterday (case-insensitive) - Relative offsets: +/-N format - Supported units: day(s), week(s), month(s), quarter(s), year(s) - All results return at 00:00:00 in local timezone - Comprehensive error handling with ErrInvalidFormat Implementation: - parse.go: Added ParseRelative(), ParseRelativeWithClock(), parseUnitString() - parse_test.go: Added 38 test cases covering all expressions and errors - example_test.go: Added 4 example functions demonstrating usage Test results: - 100% code coverage for all ParseRelative functions - Benchmarks: ~67ns (keywords), ~563ns (offsets) - well under <20µs target - All existing tests pass Complex expressions like "next monday" or "start of month" are out of scope for Phase 1 and documented for future implementation. --- .beads/issues.jsonl | 2 +- example_test.go | 61 ++++++++ parse.go | 125 +++++++++++++++++ parse_test.go | 331 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 518 insertions(+), 1 deletion(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2e23396..6799249 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -16,7 +16,7 @@ {"id":"quando-j2s","title":"Core infrastructure: Date type and conversions","description":"Implement the core Date type that wraps time.Time and provides the foundation for the fluent API.\n\n**Technical Details:**\n- `Date` struct with private `time.Time` field and optional `Lang` field\n- Package-level constructors: `Now()`, `From(time.Time)`\n- Conversion methods: `Time()` returns underlying time.Time\n- Unix timestamp support: `Unix()` and `FromUnix(int64)`\n\n**Implementation Notes:**\n- Date must wrap time.Time, not reimplement it\n- All operations return new Date instances (immutability)\n- Support full Go time.Time range (year 0-9999+)\n- Support negative Unix timestamps (before 1970)\n\n## Acceptance Criteria\n- [ ] Date struct defined with time.Time and Lang fields\n- [ ] Now() returns current date\n- [ ] From(time.Time) converts to Date\n- [ ] Time() extracts underlying time.Time\n- [ ] Unix() returns Unix timestamp (int64)\n- [ ] FromUnix(int64) creates Date from timestamp\n- [ ] Unit tests with 95%+ coverage\n- [ ] Godoc comments for all exported types/functions\n- [ ] Example tests in example_test.go","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:20:29.134906992+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:31:25.561098309+01:00","closed_at":"2026-02-11T16:31:25.561098309+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-j2s","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.272420642+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":2,"issue_id":"quando-j2s","author":"Oliver Jakoubek","text":"Plan: 1) Create date.go with Date struct (t time.Time, lang Lang), 2) Implement package-level constructors Now() and From(time.Time), 3) Add conversion methods Time(), Unix(), FromUnix(), 4) Create quando.go for package-level exports, 5) Write comprehensive unit tests covering edge cases (negative Unix timestamps, full time.Time range), 6) Add example tests in example_test.go, 7) Ensure all exports have godoc comments","created_at":"2026-02-11T15:28:39Z"}]} {"id":"quando-ljj","title":"Duration type and Diff calculation","description":"Implement Duration type and Diff function for calculating differences between dates.\n\n**API:**\n```go\nfunc Diff(a, b time.Time) Duration\n\ntype Duration struct {\n // private fields\n}\n\n// Integer methods (rounded down)\nfunc (dur Duration) Seconds() int64\nfunc (dur Duration) Minutes() int64\nfunc (dur Duration) Hours() int64\nfunc (dur Duration) Days() int\nfunc (dur Duration) Weeks() int\nfunc (dur Duration) Months() int\nfunc (dur Duration) Years() int\n\n// Float methods (precise)\nfunc (dur Duration) MonthsFloat() float64\nfunc (dur Duration) YearsFloat() float64\n```\n\n**Precision:**\n- Integer variants return rounded-down values\n- Float variants for precise calculations\n- Handle negative differences (date1 \u003c date2)\n- Cross year boundaries correctly\n- Handle leap years correctly\n\n## Acceptance Criteria\n- [ ] Duration type defined\n- [ ] Diff(a, b) returns Duration\n- [ ] All integer methods implemented (Seconds through Years)\n- [ ] Float methods for Months and Years implemented\n- [ ] Negative differences handled correctly\n- [ ] Calculations correct across year boundaries\n- [ ] Leap year handling correct\n- [ ] Unit tests with 95%+ coverage\n- [ ] Table-driven tests for various date ranges\n- [ ] Benchmarks meet \u003c1µs (int) and \u003c2µs (float) targets\n- [ ] Godoc comments with precision explanation","status":"closed","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:06.159742785+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T17:38:26.443877375+01:00","closed_at":"2026-02-11T17:38:26.443877375+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-ljj","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:08.127266864+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":7,"issue_id":"quando-ljj","author":"Oliver Jakoubek","text":"Plan: 1) Create diff.go with Duration type (private fields: start, end time.Time), 2) Implement Diff(a, b) package function, 3) Implement integer methods (Seconds through Years) with rounded-down values, 4) Implement float methods (MonthsFloat, YearsFloat) for precise calculations, 5) Handle negative differences (when a \u003c b), 6) Correctly handle year boundaries and leap years in month/year calculations, 7) Write comprehensive table-driven tests covering edge cases, 8) Add benchmarks, 9) Godoc comments with precision explanation","created_at":"2026-02-11T16:34:17Z"}]} {"id":"quando-r1o","title":"Comprehensive test suite and benchmarks","description":"Ensure comprehensive test coverage (95%+) and performance benchmarks for all features.\n\n**Test Requirements:**\n- Minimum 95% coverage for all calculation functions\n- Table-driven tests for edge cases\n- Example tests for godoc\n- Separate benchmark file\n\n**Critical Test Scenarios:**\n1. Month arithmetic: Overflow, leap years, negative\n2. Snap operations: All units, edge cases\n3. Next/Prev: Same weekday edge case\n4. Diff calculations: Year boundaries, leap years, negative\n5. DST handling: Add across DST transitions\n6. Parsing: All formats, ambiguous, invalid\n7. WeekNumber: ISO 8601 compliance\n8. Formatting: All formats, i18n\n\n**Benchmarks:**\nTarget performance:\n- Add/Sub: \u003c1µs\n- Diff: \u003c1µs (int), \u003c2µs (float)\n- Format: \u003c5µs (no i18n), \u003c10µs (with i18n)\n- Parse: \u003c10µs (auto), \u003c20µs (relative)\n\n## Acceptance Criteria\n- [ ] Test coverage ≥95% for all calculation functions\n- [ ] Table-driven tests for all edge cases\n- [ ] Example tests in example_test.go\n- [ ] Benchmark file with all critical operations\n- [ ] All benchmarks meet performance targets\n- [ ] CI/CD runs tests and reports coverage\n- [ ] Tests use FixedClock for determinism\n- [ ] Edge cases documented in test names/comments","status":"open","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:38.100200304+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:22:38.100200304+01:00","dependencies":[{"issue_id":"quando-r1o","depends_on_id":"quando-b4r","type":"blocks","created_at":"2026-02-11T16:23:15.418358186+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-dsx","type":"blocks","created_at":"2026-02-11T16:23:15.474799177+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-9sf","type":"blocks","created_at":"2026-02-11T16:23:15.513811922+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-10t","type":"blocks","created_at":"2026-02-11T16:23:15.545561332+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-gr5","type":"blocks","created_at":"2026-02-11T16:23:15.578273993+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-5ol","type":"blocks","created_at":"2026-02-11T16:23:15.609031891+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-5ib","type":"blocks","created_at":"2026-02-11T16:23:15.640381484+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-r1o","depends_on_id":"quando-41g","type":"blocks","created_at":"2026-02-11T16:23:15.672161997+01:00","created_by":"Oliver Jakoubek"}]} -{"id":"quando-tn3","title":"Relative date parsing","description":"Implement ParseRelative for parsing relative date expressions.\n\n**API:**\n```go\nfunc ParseRelative(s string) (Date, error)\n```\n\n**Supported Expressions (Phase 1):**\n- \"today\" → Today 00:00:00\n- \"tomorrow\" → Tomorrow 00:00:00\n- \"yesterday\" → Yesterday 00:00:00\n- \"+2 days\" → Today + 2 days\n- \"-1 week\" → Today - 1 week\n- \"+3 months\" → Today + 3 months\n\n**Format:**\n- Relative offsets: [+|-]\u003cnumber\u003e \u003cunit\u003e\n- Units: day(s), week(s), month(s), quarter(s), year(s)\n- Singular and plural forms supported\n\n**Out of Scope (Phase 1):**\n- Complex expressions (\"next monday\", \"start of month\")\n- These are nice-to-have for later versions\n\n## Acceptance Criteria\n- [ ] ParseRelative() implemented\n- [ ] \"today\" returns today 00:00:00\n- [ ] \"tomorrow\" returns tomorrow 00:00:00\n- [ ] \"yesterday\" returns yesterday 00:00:00\n- [ ] \"+N \u003cunit\u003e\" adds N units to today\n- [ ] \"-N \u003cunit\u003e\" subtracts N units from today\n- [ ] Singular and plural units both work\n- [ ] All Phase 1 units supported (days, weeks, months, quarters, years)\n- [ ] Invalid expressions return clear errors\n- [ ] Never panics\n- [ ] Unit tests for all supported expressions\n- [ ] Unit tests for invalid inputs\n- [ ] Benchmark meets \u003c20µs target\n- [ ] Godoc with supported expressions listed\n- [ ] Note about future complex expressions","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:40.790156181+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:21:40.790156181+01:00","dependencies":[{"issue_id":"quando-tn3","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:11.247985003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-tn3","depends_on_id":"quando-36t","type":"blocks","created_at":"2026-02-11T16:23:11.278394998+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-tn3","depends_on_id":"quando-b4r","type":"blocks","created_at":"2026-02-11T16:23:11.309549914+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"quando-tn3","title":"Relative date parsing","description":"Implement ParseRelative for parsing relative date expressions.\n\n**API:**\n```go\nfunc ParseRelative(s string) (Date, error)\n```\n\n**Supported Expressions (Phase 1):**\n- \"today\" → Today 00:00:00\n- \"tomorrow\" → Tomorrow 00:00:00\n- \"yesterday\" → Yesterday 00:00:00\n- \"+2 days\" → Today + 2 days\n- \"-1 week\" → Today - 1 week\n- \"+3 months\" → Today + 3 months\n\n**Format:**\n- Relative offsets: [+|-]\u003cnumber\u003e \u003cunit\u003e\n- Units: day(s), week(s), month(s), quarter(s), year(s)\n- Singular and plural forms supported\n\n**Out of Scope (Phase 1):**\n- Complex expressions (\"next monday\", \"start of month\")\n- These are nice-to-have for later versions\n\n## Acceptance Criteria\n- [ ] ParseRelative() implemented\n- [ ] \"today\" returns today 00:00:00\n- [ ] \"tomorrow\" returns tomorrow 00:00:00\n- [ ] \"yesterday\" returns yesterday 00:00:00\n- [ ] \"+N \u003cunit\u003e\" adds N units to today\n- [ ] \"-N \u003cunit\u003e\" subtracts N units from today\n- [ ] Singular and plural units both work\n- [ ] All Phase 1 units supported (days, weeks, months, quarters, years)\n- [ ] Invalid expressions return clear errors\n- [ ] Never panics\n- [ ] Unit tests for all supported expressions\n- [ ] Unit tests for invalid inputs\n- [ ] Benchmark meets \u003c20µs target\n- [ ] Godoc with supported expressions listed\n- [ ] Note about future complex expressions","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:40.790156181+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T20:01:22.266890625+01:00","closed_at":"2026-02-11T20:01:22.266890625+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-tn3","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:11.247985003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-tn3","depends_on_id":"quando-36t","type":"blocks","created_at":"2026-02-11T16:23:11.278394998+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-tn3","depends_on_id":"quando-b4r","type":"blocks","created_at":"2026-02-11T16:23:11.309549914+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-vih","title":"Clock abstraction for testability","description":"Implement Clock interface to enable dependency injection and deterministic testing.\n\n**Technical Details:**\n```go\ntype Clock interface {\n Now() Date\n From(t time.Time) Date\n}\n```\n\n**Implementations:**\n- DefaultClock: Uses time.Now()\n- FixedClock: Returns fixed time for tests\n\n**API:**\n- `NewClock()` - returns DefaultClock\n- `NewFixedClock(time.Time)` - returns FixedClock for tests\n\n## Acceptance Criteria\n- [ ] Clock interface defined\n- [ ] DefaultClock implementation using time.Now()\n- [ ] FixedClock implementation with fixed time\n- [ ] NewClock() factory function\n- [ ] NewFixedClock(time.Time) factory function\n- [ ] Unit tests demonstrating deterministic test patterns\n- [ ] Godoc comments\n- [ ] Example test showing test usage pattern","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:20:33.357927572+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:33:26.936900547+01:00","closed_at":"2026-02-11T16:33:26.936900547+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-vih","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.308383809+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":3,"issue_id":"quando-vih","author":"Oliver Jakoubek","text":"Plan: 1) Create clock.go with Clock interface (Now, From methods), 2) Implement DefaultClock using time.Now(), 3) Implement FixedClock with fixed time for deterministic tests, 4) Add factory functions NewClock() and NewFixedClock(time.Time), 5) Write comprehensive unit tests demonstrating deterministic test patterns, 6) Add example tests showing test usage, 7) Ensure all exports have godoc comments","created_at":"2026-02-11T15:31:45Z"}]} {"id":"quando-wny","title":"Explicit parsing with layout","description":"Implement explicit parsing using Go's standard layout format.\n\n**API:**\n```go\nfunc ParseWithLayout(s, layout string) (Date, error)\n```\n\n**Purpose:**\nHandle ambiguous or custom formats by providing explicit layout\n\n**Examples:**\n```go\nParseWithLayout(\"01/02/2026\", \"02/01/2006\") // EU format\nParseWithLayout(\"01/02/2026\", \"01/02/2006\") // US format\nParseWithLayout(\"9. Februar 2026\", \"2. January 2006\") // German custom\n```\n\n**Implementation:**\n- Delegate to time.Parse() with layout\n- Wrap result in quando.Date\n- Return clear errors for invalid inputs\n\n## Acceptance Criteria\n- [ ] ParseWithLayout() implemented\n- [ ] Uses Go's standard layout format (reference date)\n- [ ] Wraps time.Parse() correctly\n- [ ] Returns Date on success\n- [ ] Returns error on parse failure\n- [ ] Never panics\n- [ ] Unit tests for various layouts\n- [ ] Unit tests for invalid inputs\n- [ ] Benchmark meets \u003c10µs target\n- [ ] Godoc with Go layout format explanation\n- [ ] Example tests showing EU vs US disambiguation","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:33.246073999+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:50:59.125974057+01:00","closed_at":"2026-02-11T19:50:59.125974057+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-wny","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:11.182490834+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-wny","depends_on_id":"quando-36t","type":"blocks","created_at":"2026-02-11T16:23:11.216169487+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-zbr","title":"Language constants and i18n infrastructure","description":"Define language constants and i18n infrastructure for multi-language support.\n\n**API:**\n```go\ntype Lang string\n\nconst (\n LangEN Lang = \"en\" // English (Default)\n LangDE Lang = \"de\" // Deutsch\n)\n\nfunc (d Date) Lang(lang Lang) Date // Fluent API\n```\n\n**Phase 1 Languages:**\n- EN (English) - default\n- DE (Deutsch) - must-have\n\n**i18n Applies To:**\n- Format(Long) - \"February 9, 2026\" vs \"9. Februar 2026\"\n- Custom layouts with month/weekday names\n- Human() duration format\n\n**i18n Does NOT Apply To:**\n- ISO, EU, US, RFC2822 formats (always language-independent)\n- Numeric outputs (WeekNumber, Quarter, etc.)\n\n## Acceptance Criteria\n- [ ] Lang type defined as string\n- [ ] LangEN and LangDE constants defined\n- [ ] Lang() method on Date for fluent API\n- [ ] Month names in EN and DE\n- [ ] Weekday names in EN and DE\n- [ ] Time unit names for Human() in EN and DE\n- [ ] Unit tests for language switching\n- [ ] Godoc comments\n- [ ] Documentation noting future language expansion (21 more)","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:19.72536676+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:27:04.692864456+01:00","closed_at":"2026-02-11T19:27:04.692864456+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-zbr","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:09.056424359+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/example_test.go b/example_test.go index 1877d15..7902a4a 100644 --- a/example_test.go +++ b/example_test.go @@ -568,3 +568,64 @@ func ExampleParseWithLayout_error() { } // Output: Invalid date format detected } + +// ExampleParseRelative demonstrates parsing relative date expressions +func ExampleParseRelative() { + // Simple keywords (results depend on current date) + today, _ := quando.ParseRelative("today") + tomorrow, _ := quando.ParseRelative("tomorrow") + yesterday, _ := quando.ParseRelative("yesterday") + + fmt.Printf("Type: %T\n", today) + fmt.Printf("Type: %T\n", tomorrow) + fmt.Printf("Type: %T\n", yesterday) + // Output: + // Type: quando.Date + // Type: quando.Date + // Type: quando.Date +} + +// ExampleParseRelative_offsets demonstrates relative offset expressions +func ExampleParseRelative_offsets() { + // Note: Results depend on current date + // Using ParseRelativeWithClock for deterministic example + + clock := quando.NewFixedClock(time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)) + + twoDaysFromNow, _ := quando.ParseRelativeWithClock("+2 days", clock) + oneWeekAgo, _ := quando.ParseRelativeWithClock("-1 week", clock) + threeMonthsFromNow, _ := quando.ParseRelativeWithClock("+3 months", clock) + + fmt.Println(twoDaysFromNow) + fmt.Println(oneWeekAgo) + fmt.Println(threeMonthsFromNow) + // Output: + // 2026-02-17 00:00:00 + // 2026-02-08 00:00:00 + // 2026-05-15 00:00:00 +} + +// ExampleParseRelative_caseInsensitive demonstrates case-insensitive parsing +func ExampleParseRelative_caseInsensitive() { + clock := quando.NewFixedClock(time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC)) + + // All of these work + date1, _ := quando.ParseRelativeWithClock("today", clock) + date2, _ := quando.ParseRelativeWithClock("TODAY", clock) + date3, _ := quando.ParseRelativeWithClock("Today", clock) + + fmt.Println(date1.Unix() == date2.Unix()) + fmt.Println(date2.Unix() == date3.Unix()) + // Output: + // true + // true +} + +// ExampleParseRelative_error demonstrates error handling +func ExampleParseRelative_error() { + _, err := quando.ParseRelative("next monday") // Not supported in Phase 1 + if errors.Is(err, quando.ErrInvalidFormat) { + fmt.Println("Complex expressions not yet supported") + } + // Output: Complex expressions not yet supported +} diff --git a/parse.go b/parse.go index beba91e..7f38204 100644 --- a/parse.go +++ b/parse.go @@ -205,3 +205,128 @@ func ParseWithLayout(s, layout string) (Date, error) { // Wrap in quando.Date with default language return Date{t: t, lang: EN}, nil } + +// ParseRelative parses relative date expressions and returns a Date. +// +// Supported expressions: +// - Keywords: "today", "tomorrow", "yesterday" +// - Relative offsets: "+N ", "-N " +// +// Supported units (singular and plural): +// - day, days +// - week, weeks +// - month, months +// - quarter, quarters +// - year, years +// +// Examples: +// +// ParseRelative("today") // Today at 00:00:00 +// ParseRelative("tomorrow") // Tomorrow at 00:00:00 +// ParseRelative("yesterday") // Yesterday at 00:00:00 +// ParseRelative("+2 days") // Two days from today +// ParseRelative("-1 week") // One week ago +// ParseRelative("+3 months") // Three months from today +// +// All keywords and unit names are case-insensitive. +// Results are always at 00:00:00 in the local timezone. +// +// Note: Complex expressions like "next monday" or "start of month" are not +// yet supported. Use ParseRelative("+7 days") and StartOf(Months) instead. +// +// Returns an error wrapping ErrInvalidFormat if the expression cannot be parsed. +func ParseRelative(s string) (Date, error) { + clock := NewClock() + return ParseRelativeWithClock(s, clock) +} + +// ParseRelativeWithClock parses relative date expressions using a specific Clock. +// This is the testable version of ParseRelative that accepts a Clock parameter. +// +// See ParseRelative for supported expressions and usage examples. +func ParseRelativeWithClock(s string, clock Clock) (Date, error) { + // Trim whitespace and convert to lowercase for case-insensitive matching + s = strings.TrimSpace(s) + sLower := strings.ToLower(s) + + // Empty input check + if s == "" { + return Date{}, fmt.Errorf("parsing relative date: empty input: %w", ErrInvalidFormat) + } + + // Get base date (today at 00:00:00 in local timezone) + now := clock.Now() + t := now.Time() + loc := t.Location() + today := Date{ + t: time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc), + lang: EN, + } + + // Handle simple keywords + switch sLower { + case "today": + return today, nil + case "tomorrow": + return today.Add(1, Days), nil + case "yesterday": + return today.Add(-1, Days), nil + } + + // Handle relative offset pattern: "+N unit" or "-N unit" + // Examples: "+2 days", "-1 week", "+3 months" + + // Split on whitespace + parts := strings.Fields(s) + if len(parts) != 2 { + return Date{}, fmt.Errorf("parsing relative date %q: invalid format (expected \"today\", \"tomorrow\", \"yesterday\", or \"+/-N unit\"): %w", s, ErrInvalidFormat) + } + + offsetStr := parts[0] + unitStr := strings.ToLower(parts[1]) + + // Parse offset (must start with + or -) + if len(offsetStr) < 2 || (offsetStr[0] != '+' && offsetStr[0] != '-') { + return Date{}, fmt.Errorf("parsing relative date %q: offset must start with + or - (e.g., \"+2\" or \"-1\"): %w", s, ErrInvalidFormat) + } + + // Check for invalid characters (like decimal points) + if strings.Contains(offsetStr, ".") { + return Date{}, fmt.Errorf("parsing relative date %q: offset must be an integer, not a float: %w", s, ErrInvalidFormat) + } + + // Parse the number part + var offset int + _, err := fmt.Sscanf(offsetStr, "%d", &offset) + if err != nil { + return Date{}, fmt.Errorf("parsing relative date %q: invalid offset number %q: %w", s, offsetStr, ErrInvalidFormat) + } + + // Map unit string to Unit constant + unit, err := parseUnitString(unitStr) + if err != nil { + return Date{}, fmt.Errorf("parsing relative date %q: %w", s, err) + } + + // Apply the offset + return today.Add(offset, unit), nil +} + +// parseUnitString maps unit name strings to Unit constants. +// Supports both singular and plural forms, case-insensitive. +func parseUnitString(s string) (Unit, error) { + switch s { + case "day", "days": + return Days, nil + case "week", "weeks": + return Weeks, nil + case "month", "months": + return Months, nil + case "quarter", "quarters": + return Quarters, nil + case "year", "years": + return Years, nil + default: + return 0, fmt.Errorf("unknown unit %q (supported: day, week, month, quarter, year): %w", s, ErrInvalidFormat) + } +} diff --git a/parse_test.go b/parse_test.go index 9f23bfe..9e9b608 100644 --- a/parse_test.go +++ b/parse_test.go @@ -524,3 +524,334 @@ func TestParseWithLayoutImmutability(t *testing.T) { t.Error("Add should return a new Date instance") } } + +func TestParseRelativeWithClock(t *testing.T) { + // Fixed clock for deterministic testing + fixedTime := time.Date(2026, 2, 15, 14, 30, 45, 0, time.UTC) // Saturday, Feb 15, 2:30 PM + clock := NewFixedClock(fixedTime) + + tests := []struct { + name string + input string + expectedYear int + expectedMonth time.Month + expectedDay int + }{ + // Simple keywords + { + name: "today", + input: "today", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 15, + }, + { + name: "tomorrow", + input: "tomorrow", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 16, + }, + { + name: "yesterday", + input: "yesterday", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 14, + }, + + // Case-insensitive keywords + { + name: "TODAY (uppercase)", + input: "TODAY", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 15, + }, + { + name: "Tomorrow (mixed case)", + input: "Tomorrow", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 16, + }, + + // Relative offsets - days + { + name: "+1 day", + input: "+1 day", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 16, + }, + { + name: "+2 days", + input: "+2 days", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 17, + }, + { + name: "-1 day", + input: "-1 day", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 14, + }, + { + name: "+7 days", + input: "+7 days", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 22, + }, + + // Relative offsets - weeks + { + name: "+1 week", + input: "+1 week", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 22, + }, + { + name: "+2 weeks", + input: "+2 weeks", + expectedYear: 2026, + expectedMonth: time.March, + expectedDay: 1, + }, + { + name: "-1 week", + input: "-1 week", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 8, + }, + + // Relative offsets - months + { + name: "+1 month", + input: "+1 month", + expectedYear: 2026, + expectedMonth: time.March, + expectedDay: 15, + }, + { + name: "+3 months", + input: "+3 months", + expectedYear: 2026, + expectedMonth: time.May, + expectedDay: 15, + }, + { + name: "-1 month", + input: "-1 month", + expectedYear: 2026, + expectedMonth: time.January, + expectedDay: 15, + }, + + // Relative offsets - quarters + { + name: "+1 quarter", + input: "+1 quarter", + expectedYear: 2026, + expectedMonth: time.May, + expectedDay: 15, + }, + { + name: "-1 quarter", + input: "-1 quarter", + expectedYear: 2025, + expectedMonth: time.November, + expectedDay: 15, + }, + + // Relative offsets - years + { + name: "+1 year", + input: "+1 year", + expectedYear: 2027, + expectedMonth: time.February, + expectedDay: 15, + }, + { + name: "-1 year", + input: "-1 year", + expectedYear: 2025, + expectedMonth: time.February, + expectedDay: 15, + }, + + // Whitespace variations + { + name: "leading whitespace", + input: " today", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 15, + }, + { + name: "trailing whitespace", + input: "+2 days ", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 17, + }, + { + name: "extra spaces between parts", + input: "+2 days", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 17, + }, + + // Case-insensitive units + { + name: "+2 DAYS (uppercase)", + input: "+2 DAYS", + expectedYear: 2026, + expectedMonth: time.February, + expectedDay: 17, + }, + { + name: "+1 Month (mixed case)", + input: "+1 Month", + expectedYear: 2026, + expectedMonth: time.March, + expectedDay: 15, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + date, err := ParseRelativeWithClock(tt.input, clock) + if err != nil { + t.Fatalf("ParseRelativeWithClock(%q) unexpected error: %v", tt.input, err) + } + + tm := date.Time() + if tm.Year() != tt.expectedYear { + t.Errorf("Year = %d, want %d", tm.Year(), tt.expectedYear) + } + if tm.Month() != tt.expectedMonth { + t.Errorf("Month = %v, want %v", tm.Month(), tt.expectedMonth) + } + if tm.Day() != tt.expectedDay { + t.Errorf("Day = %d, want %d", tm.Day(), tt.expectedDay) + } + + // Verify time is 00:00:00 (StartOf(Days) behavior) + if tm.Hour() != 0 || tm.Minute() != 0 || tm.Second() != 0 { + t.Errorf("Time should be 00:00:00, got %02d:%02d:%02d", tm.Hour(), tm.Minute(), tm.Second()) + } + + // Verify default language is set + if date.lang != EN { + t.Errorf("lang = %v, want EN", date.lang) + } + }) + } +} + +func TestParseRelativeErrors(t *testing.T) { + clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC)) + + tests := []struct { + name string + input string + }{ + {"empty string", ""}, + {"whitespace only", " "}, + {"unknown keyword", "now"}, + {"unknown keyword 2", "currently"}, + {"invalid format - no sign", "2 days"}, + {"invalid format - wrong parts", "+2"}, + {"invalid format - too many parts", "+2 days ago"}, + {"invalid offset - not a number", "+abc days"}, + {"invalid offset - float", "+1.5 days"}, + {"invalid unit", "+2 fortnights"}, + {"invalid unit 2", "+1 decade"}, + {"invalid unit 3", "+3 hours"}, // hours not supported in Phase 1 + {"sign without number", "+ days"}, + {"number without unit", "+2"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseRelativeWithClock(tt.input, clock) + if err == nil { + t.Errorf("ParseRelativeWithClock(%q) expected error, got nil", tt.input) + } + + // Verify error wraps ErrInvalidFormat + if !errors.Is(err, ErrInvalidFormat) { + t.Errorf("error should wrap ErrInvalidFormat, got: %v", err) + } + }) + } +} + +func TestParseRelative(t *testing.T) { + // Test the production function (uses system clock) + // Just verify it doesn't error on valid inputs + validInputs := []string{ + "today", + "tomorrow", + "yesterday", + "+1 day", + "-2 weeks", + "+3 months", + } + + for _, input := range validInputs { + t.Run(input, func(t *testing.T) { + _, err := ParseRelative(input) + if err != nil { + t.Errorf("ParseRelative(%q) unexpected error: %v", input, err) + } + }) + } +} + +func TestParseRelativeImmutability(t *testing.T) { + clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC)) + + // Parse the same expression twice + date1, err1 := ParseRelativeWithClock("tomorrow", clock) + date2, err2 := ParseRelativeWithClock("tomorrow", clock) + + if err1 != nil || err2 != nil { + t.Fatalf("ParseRelativeWithClock failed: %v, %v", err1, err2) + } + + // Modify date1 + modified := date1.Add(5, Days) + + // Verify date2 is unchanged + if date2.Unix() != date1.Unix() { + t.Error("date2 should not be affected by operations on date1") + } + if modified.Unix() == date1.Unix() { + t.Error("Add should return a new Date instance") + } +} + +func BenchmarkParseRelativeKeyword(b *testing.B) { + clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC)) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ParseRelativeWithClock("today", clock) + } +} + +func BenchmarkParseRelativeOffset(b *testing.B) { + clock := NewFixedClock(time.Date(2026, 2, 15, 12, 0, 0, 0, time.UTC)) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = ParseRelativeWithClock("+2 days", clock) + } +}