From f47897f3fdb3d442ad95cdb6bf8e63031e010a48 Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Thu, 12 Feb 2026 16:39:01 +0100 Subject: [PATCH 1/2] Change Now() and DefaultClock to use UTC instead of local time Changed quando.Now() and DefaultClock.Now() to return dates in UTC timezone instead of local server timezone. This aligns with industry standards for date/time libraries and prevents server-timezone-dependent behavior. Changes: - date.go: Now() uses time.Now().UTC() instead of time.Now() - Updated documentation comments to reflect UTC default - date_test.go: TestNow() now verifies UTC location - clock_test.go: TestDefaultClock_Now() now verifies UTC location - parse_test.go: TestParseRelative() verifies all results are UTC - parse.go: Updated comment from "local timezone" to "UTC timezone" Users needing local time can use: - quando.From(time.Now()) for explicit local time - quando.Now().In("Europe/Berlin") to convert to specific timezone Closes: quando-67n --- .beads/issues.jsonl | 1 + clock_test.go | 9 +++++++-- date.go | 4 ++-- date_test.go | 9 +++++++-- parse.go | 2 +- parse_test.go | 31 ++++++++++++++++++++++++++++--- 6 files changed, 46 insertions(+), 10 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 71fe3b3..1044b05 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,6 +4,7 @@ {"id":"quando-4bh","title":"Unit type and constants","description":"Define Unit type for time unit constants used in arithmetic operations.\n\n**Technical Details:**\n```go\ntype Unit int\n\nconst (\n Seconds Unit = iota\n Minutes\n Hours\n Days\n Weeks\n Months\n Quarters\n Years\n)\n```\n\n**Design:**\n- Type-safe constants (compile-time safety)\n- Use iota for clear ordering\n- Optional internal ParseUnit(string) for external inputs\n\n## Acceptance Criteria\n- [ ] Unit type defined as int\n- [ ] All 8 unit constants defined (Seconds through Years)\n- [ ] Units use iota for ordering\n- [ ] Godoc comments for Unit type and constants\n- [ ] Unit tests verifying constants","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:20:37.246514285+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T16:34:46.001417204+01:00","closed_at":"2026-02-11T16:34:46.001417204+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-4bh","depends_on_id":"quando-91w","type":"blocks","created_at":"2026-02-11T16:23:05.349519431+01:00","created_by":"Oliver Jakoubek"}],"comments":[{"id":4,"issue_id":"quando-4bh","author":"Oliver Jakoubek","text":"Plan: 1) Create unit.go with Unit type as int, 2) Define all 8 unit constants (Seconds, Minutes, Hours, Days, Weeks, Months, Quarters, Years) using iota, 3) Add String() method for better debugging, 4) Add comprehensive godoc comments, 5) Write unit tests verifying constants and ordering, 6) Add example tests","created_at":"2026-02-11T15:33:41Z"}]} {"id":"quando-5ib","title":"Date inspection methods","description":"Implement date inspection methods for querying metadata about a date.\n\n**Individual Methods:**\n```go\nfunc (d Date) WeekNumber() int\nfunc (d Date) Quarter() int\nfunc (d Date) DayOfYear() int\nfunc (d Date) IsWeekend() bool\nfunc (d Date) IsLeapYear() bool\n```\n\n**Aggregated Method:**\n```go\ntype DateInfo struct {\n WeekNumber int\n Quarter int\n DayOfYear int\n IsWeekend bool\n IsLeapYear bool\n Unix int64\n}\n\nfunc (d Date) Info() DateInfo\n```\n\n**Specifications:**\n- **WeekNumber**: ISO 8601 (Monday first, Week 1 = first week containing Thursday)\n- **Quarter**: 1-4 (Q1=Jan-Mar, Q2=Apr-Jun, Q3=Jul-Sep, Q4=Oct-Dec)\n- **DayOfYear**: 1-366 (considering leap years)\n- **IsWeekend**: Saturday or Sunday (not configurable in Phase 1)\n- **IsLeapYear**: Divisible by 4, except centuries, except divisible by 400\n\n## Acceptance Criteria\n- [ ] WeekNumber() returns ISO 8601 week number\n- [ ] Quarter() returns 1-4\n- [ ] DayOfYear() returns 1-366\n- [ ] IsWeekend() returns true for Sat/Sun\n- [ ] IsLeapYear() returns true for leap years\n- [ ] Info() returns struct with all fields\n- [ ] ISO 8601 week compliance (Week 1 contains Thursday)\n- [ ] Leap year rules correct (4, 100, 400 rule)\n- [ ] Unit tests for all methods\n- [ ] Edge cases: year boundaries, week 53, leap years\n- [ ] Benchmarks meet \u003c1µs target\n- [ ] Godoc for each method with conventions documented","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:06.966079923+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T20:45:38.905860871+01:00","closed_at":"2026-02-11T20:45:38.905860871+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-5ib","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:13.310800388+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-5ol","title":"Format presets and constants","description":"Implement preset format constants and Format() method.\n\n**API:**\n```go\ntype Format int\n\nconst (\n ISO Format = iota // \"2026-02-09\"\n EU // \"09.02.2026\"\n US // \"02/09/2026\"\n Long // \"February 9, 2026\" (language-dependent)\n RFC2822 // \"Mon, 09 Feb 2026 00:00:00 +0000\"\n)\n\nfunc (d Date) Format(format Format) string\n```\n\n**Language Dependency:**\n- ISO, EU, US, RFC2822: Always language-independent\n- Long: Uses Lang setting\n - EN: \"February 9, 2026\"\n - DE: \"9. Februar 2026\"\n\n**Implementation:**\n- Map Format constants to Go layout strings\n- Use Lang() setting for Long format\n- Delegate to time.Format() internally\n\n## Acceptance Criteria\n- [ ] Format type and constants defined\n- [ ] Format() method implemented\n- [ ] ISO format outputs \"YYYY-MM-DD\"\n- [ ] EU format outputs \"DD.MM.YYYY\"\n- [ ] US format outputs \"MM/DD/YYYY\"\n- [ ] RFC2822 format correct\n- [ ] Long format respects Lang setting (EN and DE)\n- [ ] Non-Long formats ignore Lang setting\n- [ ] Unit tests for all formats\n- [ ] Unit tests for Long with both EN and DE\n- [ ] Benchmark meets \u003c5µs without i18n, \u003c10µs with i18n\n- [ ] Godoc for each format constant\n- [ ] Example tests showing each format","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:53.217816331+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T20:11:47.455205479+01:00","closed_at":"2026-02-11T20:11:47.455205479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-5ol","depends_on_id":"quando-j2s","type":"blocks","created_at":"2026-02-11T16:23:12.164278602+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"quando-5ol","depends_on_id":"quando-zbr","type":"blocks","created_at":"2026-02-11T16:23:12.20045666+01:00","created_by":"Oliver Jakoubek"}]} +{"id":"quando-67n","title":"Change Now() and DefaultClock to use UTC instead of local time","description":"## Background\n`Now()` currently uses `time.Now()` which returns local server time. For a date library, UTC as default is the correct standard. Users who need local time can use `quando.Now().In(\"Europe/Berlin\")` or `quando.From(time.Now())`.\n\n## Changes\n\n### date.go\n- `Now()`: Change `time.Now()` → `time.Now().UTC()`\n\n### clock.go\n- `DefaultClock.Now()`: Change `time.Now()` → `time.Now().UTC()`\n\n### Tests\n- Update any tests that depend on local time behavior\n- Add explicit test: `Now().Time().Location() == time.UTC`\n\n## Rationale\n- UTC as default is industry standard for date/time libraries\n- Prevents server-timezone-dependent behavior (e.g. DatesAPI on German server vs. client in Tokyo)\n- No existing external users → no breaking change concern\n- Explicit timezone via `.In()` is cleaner than implicit server timezone\n\n## Acceptance Criteria\n- [ ] `quando.Now().Time().Location()` returns `time.UTC`\n- [ ] `DefaultClock.Now().Time().Location()` returns `time.UTC`\n- [ ] All existing tests pass (adjusted where needed)\n- [ ] `From(time.Now())` still works for local-time use case (documented)","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-12T15:42:17.745925554+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-12T16:38:35.582398347+01:00","closed_at":"2026-02-12T16:38:35.582398347+01:00","close_reason":"Closed"} {"id":"quando-6c3","title":"CI/CD pipeline setup","description":"Set up GitHub Actions CI/CD pipeline for automated testing and quality checks.\n\n**CI Workflow:**\n- Run on push and pull requests\n- Test on multiple Go versions (1.22, 1.23, latest)\n- Test on multiple platforms (Linux, macOS, Windows)\n- Run go fmt check\n- Run go vet\n- Run tests with coverage\n- Run benchmarks (informational)\n- Optional: golangci-lint\n\n**Coverage Reporting:**\n- Generate coverage report\n- Fail if coverage \u003c95% for core calculation functions\n- Optional: Upload to codecov.io or similar\n\n**Performance Monitoring:**\n- Run benchmarks on each commit\n- Report benchmark results (informational, no fail)\n\n## Acceptance Criteria\n- [ ] .github/workflows/ci.yml created\n- [ ] Tests run on push and PR\n- [ ] Multi-version Go support (1.22+)\n- [ ] Multi-platform support (Linux, macOS, Windows)\n- [ ] go fmt check passes\n- [ ] go vet check passes\n- [ ] Tests run with coverage report\n- [ ] Coverage requirement enforced (95%+)\n- [ ] Benchmarks run (informational)\n- [ ] CI badge in README (if applicable)","status":"tombstone","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:51.928117055+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T19:21:16.246958875+01:00","dependencies":[{"issue_id":"quando-6c3","depends_on_id":"quando-r1o","type":"blocks","created_at":"2026-02-11T16:23:16.259739409+01:00","created_by":"Oliver Jakoubek"}],"deleted_at":"2026-02-11T19:21:16.246958875+01:00","deleted_by":"daemon","delete_reason":"delete","original_type":"task"} {"id":"quando-6x3","title":"Documentation and examples","description":"Complete documentation including README, godoc, and example code.\n\n**README.md Contents:**\n- Project overview and vision\n- Installation instructions (go get)\n- Quick start examples\n- Core features showcase\n- Performance characteristics\n- Comparison to time.Time\n- Contributing guidelines\n- License\n\n**Godoc Requirements:**\n- Every exported type/function documented\n- Godoc comments start with type/function name\n- Complex behaviors explained (month-end overflow, DST, etc.)\n- Edge cases noted where applicable\n\n**Example Tests:**\n- Example_basicArithmetic\n- Example_monthEndOverflow\n- Example_snapOperations\n- Example_diffCalculation\n- Example_humanFormat\n- Example_parsing\n- Example_formatting\n- Example_timezoneConversion\n- Example_chainedOperations\n\n**Code Examples in README:**\n- Complex chaining\n- Month-end handling\n- Human-readable diffs\n- Timezone-aware operations\n- Comparison with stdlib\n\n## Acceptance Criteria\n- [ ] README.md complete with all sections\n- [ ] Installation instructions clear\n- [ ] At least 5 code examples in README\n- [ ] All exported items have godoc comments\n- [ ] Godoc comments follow conventions (name first)\n- [ ] At least 8 example tests\n- [ ] Examples demonstrate key features\n- [ ] Edge cases documented in relevant godoc\n- [ ] Comparison table with time.Time\n- [ ] Contributing guidelines (if open source)","status":"closed","priority":3,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:22:45.693695262+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T21:25:19.094926099+01:00","closed_at":"2026-02-11T21:25:19.094926099+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-6x3","depends_on_id":"quando-r1o","type":"blocks","created_at":"2026-02-11T16:23:16.222906911+01:00","created_by":"Oliver Jakoubek"}]} {"id":"quando-7m5","title":"MustParse convenience function","description":"Implement MustParse() convenience function that panics on error (for tests/initialization).\n\n**API:**\n```go\nfunc MustParse(s string) Date\n```\n\n**Behavior:**\n- Calls Parse() internally\n- Returns Date on success\n- Panics on error (with clear panic message)\n\n**Use Cases:**\n- Test fixtures\n- Static initialization\n- Configuration files where values are known-good\n\n**Documentation:**\n- MUST clearly document that this function panics\n- MUST recommend using Parse() in production code\n- MUST show test usage examples\n\n## Acceptance Criteria\n- [ ] MustParse() implemented\n- [ ] Returns Date on successful parse\n- [ ] Panics with clear message on error\n- [ ] Godoc clearly warns about panic behavior\n- [ ] Godoc recommends Parse() for production\n- [ ] Example test showing test fixture usage\n- [ ] Unit tests verifying panic on invalid input","status":"closed","priority":3,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-11T16:21:46.007442996+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-11T20:54:19.638519056+01:00","closed_at":"2026-02-11T20:54:19.638519056+01:00","close_reason":"Closed","dependencies":[{"issue_id":"quando-7m5","depends_on_id":"quando-gr5","type":"blocks","created_at":"2026-02-11T16:23:11.340600075+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/clock_test.go b/clock_test.go index 17cc08b..de6e65e 100644 --- a/clock_test.go +++ b/clock_test.go @@ -21,14 +21,19 @@ func TestNewClock(t *testing.T) { func TestDefaultClock_Now(t *testing.T) { clock := NewClock() - before := time.Now() + before := time.Now().UTC() date := clock.Now() - after := time.Now() + after := time.Now().UTC() // Verify that Now() returns a time between before and after if date.Time().Before(before) || date.Time().After(after) { t.Errorf("DefaultClock.Now() returned time outside expected range") } + + // Verify location is UTC + if date.Time().Location() != time.UTC { + t.Errorf("DefaultClock.Now().Time().Location() = %v, want UTC", date.Time().Location()) + } } func TestDefaultClock_From(t *testing.T) { diff --git a/date.go b/date.go index c12640a..56e09a0 100644 --- a/date.go +++ b/date.go @@ -70,14 +70,14 @@ type Date struct { } // Now returns a Date representing the current moment in time. -// The Date uses the local timezone by default. +// The Date uses the UTC timezone by default. // // Example: // // now := quando.Now() func Now() Date { return Date{ - t: time.Now(), + t: time.Now().UTC(), lang: EN, // Default language } } diff --git a/date_test.go b/date_test.go index 78450a5..5853898 100644 --- a/date_test.go +++ b/date_test.go @@ -7,15 +7,20 @@ import ( ) func TestNow(t *testing.T) { - before := time.Now() + before := time.Now().UTC() date := Now() - after := time.Now() + after := time.Now().UTC() // Verify that Now() returns a time between before and after if date.Time().Before(before) || date.Time().After(after) { t.Errorf("Now() returned time outside expected range") } + // Verify location is UTC + if date.Time().Location() != time.UTC { + t.Errorf("Now().Time().Location() = %v, want UTC", date.Time().Location()) + } + // Verify default language is EN if date.lang != EN { t.Errorf("Now() default lang = %v, want %v", date.lang, EN) diff --git a/parse.go b/parse.go index 829608a..6045e5e 100644 --- a/parse.go +++ b/parse.go @@ -294,7 +294,7 @@ func ParseRelativeWithClock(s string, clock Clock) (Date, error) { return Date{}, fmt.Errorf("parsing relative date: empty input: %w", ErrInvalidFormat) } - // Get base date (today at 00:00:00 in local timezone) + // Get base date (today at 00:00:00 in UTC timezone) now := clock.Now() t := now.Time() loc := t.Location() diff --git a/parse_test.go b/parse_test.go index a43b845..cfa7168 100644 --- a/parse_test.go +++ b/parse_test.go @@ -738,8 +738,7 @@ func TestParseRelativeErrors(t *testing.T) { } func TestParseRelative(t *testing.T) { - // Test the production function (uses system clock) - // Just verify it doesn't error on valid inputs + // Test the production function (uses system clock with UTC) validInputs := []string{ "today", "tomorrow", @@ -751,12 +750,38 @@ func TestParseRelative(t *testing.T) { for _, input := range validInputs { t.Run(input, func(t *testing.T) { - _, err := ParseRelative(input) + result, err := ParseRelative(input) if err != nil { t.Errorf("ParseRelative(%q) unexpected error: %v", input, err) } + + // Verify result is in UTC timezone + if result.Time().Location() != time.UTC { + t.Errorf("ParseRelative(%q) location = %v, want UTC", input, result.Time().Location()) + } }) } + + // Specifically test "today" to verify UTC behavior + t.Run("today returns UTC date", func(t *testing.T) { + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + + result, err := ParseRelative("today") + if err != nil { + t.Fatalf("ParseRelative(\"today\") error: %v", err) + } + + // Expect UTC location + if result.Time().Location() != time.UTC { + t.Errorf("ParseRelative(\"today\").Location() = %v, want UTC", result.Time().Location()) + } + + // Verify it matches expected date + if !result.Time().Equal(today) { + t.Errorf("ParseRelative(\"today\") = %v, want %v", result.Time(), today) + } + }) } func TestParseRelativeImmutability(t *testing.T) { From 9340a168322b285fad3e9b3d7738bcad286412da Mon Sep 17 00:00:00 2001 From: Oliver Jakoubek Date: Sat, 14 Feb 2026 10:18:49 +0100 Subject: [PATCH 2/2] chore: ignore beads export-state in git --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a9a84f9..f926b07 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ build/ # Temporary files tmp/ temp/ +.beads/export-state/