diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 475302c..c86ed13 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -5,7 +5,7 @@ {"id":"checkvist-api-1zf","title":"Write unit tests for Filter","description":"Create filter_test.go with tests:\n- TestFilter_WithTag\n- TestFilter_WithMultipleTags\n- TestFilter_WithStatus\n- TestFilter_WithDueBefore\n- TestFilter_WithDueAfter\n- TestFilter_WithDueOn\n- TestFilter_WithOverdue\n- TestFilter_WithSearch\n- TestFilter_Combined\n- TestFilter_Performance_1000Tasks\nUse table-driven tests.","status":"closed","priority":1,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:38.136650557+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:34:50.361097261+01:00","closed_at":"2026-01-14T14:34:50.361097261+01:00","close_reason":"Alle geforderten Tests implementiert und bestanden","dependencies":[{"issue_id":"checkvist-api-1zf","depends_on_id":"checkvist-api-1ze","type":"blocks","created_at":"2026-01-14T12:33:14.4378645+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-347","title":"Write unit tests for Checklists","description":"Create checklists_test.go with tests:\n- TestChecklists_List\n- TestChecklists_ListArchived\n- TestChecklists_Get\n- TestChecklists_Get_NotFound\n- TestChecklists_Create\n- TestChecklists_Update\n- TestChecklists_Delete\nUse table-driven tests. Create testdata/checklists/ fixtures.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:37.243525209+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:41:58.076714398+01:00","closed_at":"2026-01-14T13:41:58.076714398+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-347","depends_on_id":"checkvist-api-c2k","type":"blocks","created_at":"2026-01-14T12:33:13.512887479+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-47y","title":"Quality \u0026 Documentation","description":"Phase 4: Complete unit tests, GoDoc examples, README, and CHANGELOG. Target \u003e80% test coverage. All public functions documented with examples.","status":"closed","priority":0,"issue_type":"epic","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.655509387+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T14:37:52.12671299+01:00","closed_at":"2026-01-14T14:37:52.12671299+01:00","close_reason":"Alle zugehörigen Features und Tasks abgeschlossen"} -{"id":"checkvist-api-4qn","title":"Fix date parsing for Checkvist API format","description":"## Problem\n\nThe Checkvist API returns timestamps in a non-standard format that Go's `time.Time` cannot parse with default JSON unmarshaling.\n\n**API returns:** `\"2026/01/14 16:07:31 +0000\"`\n**Go expects:** `\"2006-01-02T15:04:05Z07:00\"` (RFC3339/ISO8601)\n\nThis causes errors like:\n```\nparsing time \"2026/01/14 16:07:31 +0000\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"/01/14 16:07:31 +0000\" as \"-\"\n```\n\n## Affected Fields\n\n- `Checklist.UpdatedAt`\n- `Task.UpdatedAt`\n- `Task.CreatedAt`\n- `Note.UpdatedAt`\n- `Note.CreatedAt`\n\n## Solution\n\nCreate a custom `APITime` type that implements `json.Unmarshaler` to handle the Checkvist date format:\n\n```go\ntype APITime struct {\n time.Time\n}\n\nfunc (t *APITime) UnmarshalJSON(data []byte) error {\n // Try multiple formats:\n // 1. Checkvist format: \"2006/01/02 15:04:05 -0700\"\n // 2. ISO8601/RFC3339: \"2006-01-02T15:04:05Z07:00\"\n}\n```\n\nReplace `time.Time` with `APITime` in all model structs.\n\n## Test\n\nA test has been added that documents this bug:\n`TestChecklists_List_RealAPIDateFormat` in `checklists_test.go`\n\nCurrently skips with message showing the parsing error. Should pass after fix.","status":"open","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T17:55:53.54028308+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T17:55:53.54028308+01:00"} +{"id":"checkvist-api-4qn","title":"Fix date parsing for Checkvist API format","description":"## Problem\n\nThe Checkvist API returns timestamps in a non-standard format that Go's `time.Time` cannot parse with default JSON unmarshaling.\n\n**API returns:** `\"2026/01/14 16:07:31 +0000\"`\n**Go expects:** `\"2006-01-02T15:04:05Z07:00\"` (RFC3339/ISO8601)\n\nThis causes errors like:\n```\nparsing time \"2026/01/14 16:07:31 +0000\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"/01/14 16:07:31 +0000\" as \"-\"\n```\n\n## Affected Fields\n\n- `Checklist.UpdatedAt`\n- `Task.UpdatedAt`\n- `Task.CreatedAt`\n- `Note.UpdatedAt`\n- `Note.CreatedAt`\n\n## Solution\n\nCreate a custom `APITime` type that implements `json.Unmarshaler` to handle the Checkvist date format:\n\n```go\ntype APITime struct {\n time.Time\n}\n\nfunc (t *APITime) UnmarshalJSON(data []byte) error {\n // Try multiple formats:\n // 1. Checkvist format: \"2006/01/02 15:04:05 -0700\"\n // 2. ISO8601/RFC3339: \"2006-01-02T15:04:05Z07:00\"\n}\n```\n\nReplace `time.Time` with `APITime` in all model structs.\n\n## Test\n\nA test has been added that documents this bug:\n`TestChecklists_List_RealAPIDateFormat` in `checklists_test.go`\n\nCurrently skips with message showing the parsing error. Should pass after fix.","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T17:55:53.54028308+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T18:09:52.876804767+01:00","closed_at":"2026-01-14T18:09:52.876804767+01:00","close_reason":"APITime type with custom UnmarshalJSON implemented, all tests passing"} {"id":"checkvist-api-5ab","title":"Implement Note operations","description":"Create notes.go with NoteService:\n- client.Notes(checklistID, taskID) returns NoteService\n- List(ctx) ([]Note, error) - GET /checklists/{id}/tasks/{task_id}/comments.json\n- Create(ctx, comment string) (*Note, error) - POST /checklists/{id}/tasks/{task_id}/comments.json\n- Update(ctx, noteID, comment string) (*Note, error) - PUT /checklists/{id}/tasks/{task_id}/comments/{note_id}.json\n- Delete(ctx, noteID) error - DELETE /checklists/{id}/tasks/{task_id}/comments/{note_id}.json\nContext support for all methods.","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:54.268124634+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:40:31.06124818+01:00","closed_at":"2026-01-14T13:40:31.06124818+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-5ab","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:32:55.843507717+01:00","created_by":"Oliver Jakoubek"}]} {"id":"checkvist-api-5wr","title":"Initialize Go module and project structure","description":"Create go.mod with module path code.beautifulmachines.dev/jakoubek/checkvist-api. Set up flat package structure with placeholder files: client.go, checklists.go, tasks.go, notes.go, filter.go, models.go, errors.go, options.go. Create magefiles/ directory with separate go.mod for Mage build targets. Add LICENSE (MIT) file.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:06.285510329+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:43:45.392753825+01:00","closed_at":"2026-01-14T12:43:45.392753825+01:00","close_reason":"Closed"} {"id":"checkvist-api-8bn","title":"Write unit tests for Client and Auth","description":"Create client_test.go with tests using httptest.Server:\n- TestNewClient_Defaults\n- TestNewClient_WithOptions\n- TestAuthenticate_Success\n- TestAuthenticate_InvalidCredentials\n- TestAuthenticate_2FA\n- TestTokenRefresh_Auto\n- TestTokenRefresh_Manual\n- TestCurrentUser\n- TestRetryLogic_429\n- TestRetryLogic_5xx\n- TestRetryLogic_NetworkError\nUse table-driven tests. Create testdata/auth/ fixtures.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.964610587+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:35:19.981723023+01:00","closed_at":"2026-01-14T13:35:19.981723023+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:33:12.783142853+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:33:13.232028837+01:00","created_by":"Oliver Jakoubek"}]} diff --git a/checklists_test.go b/checklists_test.go index 52f7aa3..8951120 100644 --- a/checklists_test.go +++ b/checklists_test.go @@ -162,7 +162,7 @@ func TestChecklists_Create(t *testing.T) { Name: req.Name, Public: false, Archived: false, - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: @@ -208,7 +208,7 @@ func TestChecklists_Update(t *testing.T) { response := Checklist{ ID: 1, Name: req.Name, - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: @@ -284,7 +284,7 @@ func TestChecklists_Archive(t *testing.T) { ID: 1, Name: "Archived Checklist", Archived: true, - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: @@ -328,7 +328,7 @@ func TestChecklists_Unarchive(t *testing.T) { ID: 1, Name: "Unarchived Checklist", Archived: false, - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: diff --git a/models.go b/models.go index 97d91d9..a2de2e9 100644 --- a/models.go +++ b/models.go @@ -2,6 +2,7 @@ package checkvist import ( "fmt" + "strings" "time" ) @@ -37,6 +38,50 @@ func (s TaskStatus) String() string { // Tags represents a set of tags as a map for efficient lookup. type Tags map[string]bool +// APITime wraps time.Time with custom JSON unmarshaling for Checkvist API format. +// The Checkvist API returns timestamps in format "2006/01/02 15:04:05 +0000" +// instead of the standard RFC3339 format that Go expects. +type APITime struct { + time.Time +} + +// UnmarshalJSON handles multiple date formats from the Checkvist API. +func (t *APITime) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), `"`) + if s == "" || s == "null" { + return nil + } + + // Try formats in order of likelihood + formats := []string{ + "2006/01/02 15:04:05 -0700", // Checkvist API format + time.RFC3339, // ISO8601 with timezone + "2006-01-02T15:04:05Z", // RFC3339 without offset + } + + for _, format := range formats { + if parsed, err := time.Parse(format, s); err == nil { + t.Time = parsed + return nil + } + } + + return fmt.Errorf("cannot parse %q as time", s) +} + +// MarshalJSON outputs time in RFC3339 format. +func (t APITime) MarshalJSON() ([]byte, error) { + if t.IsZero() { + return []byte("null"), nil + } + return []byte(`"` + t.Format(time.RFC3339) + `"`), nil +} + +// NewAPITime creates an APITime from a time.Time value. +func NewAPITime(t time.Time) APITime { + return APITime{Time: t} +} + // Checklist represents a Checkvist checklist. type Checklist struct { // ID is the unique identifier of the checklist. @@ -58,7 +103,7 @@ type Checklist struct { // TagsAsText is the raw tags string from the API. TagsAsText string `json:"tags_as_text"` // UpdatedAt is the timestamp of the last update. - UpdatedAt time.Time `json:"updated_at"` + UpdatedAt APITime `json:"updated_at"` } // Task represents a task within a Checkvist checklist. @@ -92,9 +137,9 @@ type Task struct { // UpdateLine contains brief update information. UpdateLine string `json:"update_line"` // UpdatedAt is the timestamp of the last update. - UpdatedAt time.Time `json:"updated_at"` + UpdatedAt APITime `json:"updated_at"` // CreatedAt is the timestamp when the task was created. - CreatedAt time.Time `json:"created_at"` + CreatedAt APITime `json:"created_at"` // Children contains nested child tasks (when fetched with tree structure). Children []*Task `json:"tasks,omitempty"` // Notes contains the comments/notes attached to this task. @@ -110,9 +155,9 @@ type Note struct { // Comment is the text content of the note. Comment string `json:"comment"` // UpdatedAt is the timestamp of the last update. - UpdatedAt time.Time `json:"updated_at"` + UpdatedAt APITime `json:"updated_at"` // CreatedAt is the timestamp when the note was created. - CreatedAt time.Time `json:"created_at"` + CreatedAt APITime `json:"created_at"` } // User represents a Checkvist user. diff --git a/models_test.go b/models_test.go new file mode 100644 index 0000000..bc03ae6 --- /dev/null +++ b/models_test.go @@ -0,0 +1,166 @@ +package checkvist + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAPITime_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + check func(t *testing.T, got APITime) + }{ + { + name: "Checkvist API format", + input: `"2026/01/14 16:07:31 +0000"`, + wantErr: false, + check: func(t *testing.T, got APITime) { + if got.Year() != 2026 || got.Month() != 1 || got.Day() != 14 { + t.Errorf("date mismatch: got %v", got.Time) + } + if got.Hour() != 16 || got.Minute() != 7 || got.Second() != 31 { + t.Errorf("time mismatch: got %v", got.Time) + } + }, + }, + { + name: "RFC3339 format", + input: `"2026-01-14T10:00:00Z"`, + wantErr: false, + check: func(t *testing.T, got APITime) { + if got.Year() != 2026 || got.Month() != 1 || got.Day() != 14 { + t.Errorf("date mismatch: got %v", got.Time) + } + if got.Hour() != 10 || got.Minute() != 0 { + t.Errorf("time mismatch: got %v", got.Time) + } + }, + }, + { + name: "RFC3339 with timezone offset", + input: `"2026-01-14T10:00:00+02:00"`, + wantErr: false, + check: func(t *testing.T, got APITime) { + if got.Year() != 2026 || got.Month() != 1 || got.Day() != 14 { + t.Errorf("date mismatch: got %v", got.Time) + } + }, + }, + { + name: "empty string", + input: `""`, + wantErr: false, + check: func(t *testing.T, got APITime) { + if !got.IsZero() { + t.Error("expected zero time for empty string") + } + }, + }, + { + name: "null", + input: `null`, + wantErr: false, + check: func(t *testing.T, got APITime) { + if !got.IsZero() { + t.Error("expected zero time for null") + } + }, + }, + { + name: "invalid format", + input: `"not a date"`, + wantErr: true, + check: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got APITime + err := json.Unmarshal([]byte(tt.input), &got) + + if (err != nil) != tt.wantErr { + t.Errorf("UnmarshalJSON() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.check != nil { + tt.check(t, got) + } + }) + } +} + +func TestAPITime_MarshalJSON(t *testing.T) { + tests := []struct { + name string + input APITime + expected string + }{ + { + name: "normal time", + input: NewAPITime(time.Date(2026, 1, 14, 10, 30, 0, 0, time.UTC)), + expected: `"2026-01-14T10:30:00Z"`, + }, + { + name: "zero time", + input: APITime{}, + expected: `null`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := json.Marshal(tt.input) + if err != nil { + t.Fatalf("MarshalJSON() error = %v", err) + } + if string(got) != tt.expected { + t.Errorf("MarshalJSON() = %s, want %s", got, tt.expected) + } + }) + } +} + +func TestAPITime_InStruct(t *testing.T) { + // Test unmarshaling a struct with APITime fields using real API format + jsonData := `{ + "id": 1, + "name": "Test Checklist", + "updated_at": "2026/01/14 16:07:31 +0000" + }` + + type testStruct struct { + ID int `json:"id"` + Name string `json:"name"` + UpdatedAt APITime `json:"updated_at"` + } + + var result testStruct + err := json.Unmarshal([]byte(jsonData), &result) + if err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if result.UpdatedAt.Year() != 2026 { + t.Errorf("expected year 2026, got %d", result.UpdatedAt.Year()) + } + if result.UpdatedAt.Month() != 1 { + t.Errorf("expected month 1, got %d", result.UpdatedAt.Month()) + } + if result.UpdatedAt.Day() != 14 { + t.Errorf("expected day 14, got %d", result.UpdatedAt.Day()) + } +} + +func TestNewAPITime(t *testing.T) { + now := time.Now() + apiTime := NewAPITime(now) + + if !apiTime.Time.Equal(now) { + t.Errorf("NewAPITime() did not preserve time: got %v, want %v", apiTime.Time, now) + } +} diff --git a/notes_test.go b/notes_test.go index d1c22b0..7167a2b 100644 --- a/notes_test.go +++ b/notes_test.go @@ -71,8 +71,8 @@ func TestNotes_Create(t *testing.T) { ID: 600, TaskID: 101, Comment: req.Comment, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedAt: NewAPITime(time.Now()), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: @@ -119,7 +119,7 @@ func TestNotes_Update(t *testing.T) { ID: 501, TaskID: 101, Comment: req.Comment, - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: diff --git a/tasks_test.go b/tasks_test.go index 0b07a2b..d4d4a48 100644 --- a/tasks_test.go +++ b/tasks_test.go @@ -98,8 +98,8 @@ func TestTasks_Create(t *testing.T) { ChecklistID: 1, Content: req.Content, Status: StatusOpen, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + CreatedAt: NewAPITime(time.Now()), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: @@ -203,7 +203,7 @@ func TestTasks_Update(t *testing.T) { ID: 101, ChecklistID: 1, Content: "Updated content", - UpdatedAt: time.Now(), + UpdatedAt: NewAPITime(time.Now()), } json.NewEncoder(w).Encode(response) default: diff --git a/testdata/checklists/list.json b/testdata/checklists/list.json index 38d5ad7..6db1d25 100644 --- a/testdata/checklists/list.json +++ b/testdata/checklists/list.json @@ -8,7 +8,7 @@ "task_count": 10, "task_completed": 3, "tags_as_text": "", - "updated_at": "2026-01-14T10:00:00Z" + "updated_at": "2026/01/14 10:00:00 +0000" }, { "id": 2, @@ -19,6 +19,6 @@ "task_count": 25, "task_completed": 12, "tags_as_text": "work, important", - "updated_at": "2026-01-13T15:30:00Z" + "updated_at": "2026/01/13 15:30:00 +0000" } ] diff --git a/testdata/checklists/list_archived.json b/testdata/checklists/list_archived.json index 79471df..3f98296 100644 --- a/testdata/checklists/list_archived.json +++ b/testdata/checklists/list_archived.json @@ -8,6 +8,6 @@ "task_count": 50, "task_completed": 50, "tags_as_text": "", - "updated_at": "2025-06-01T09:00:00Z" + "updated_at": "2025/06/01 09:00:00 +0000" } ] diff --git a/testdata/checklists/single.json b/testdata/checklists/single.json index 46ad7a5..ea56133 100644 --- a/testdata/checklists/single.json +++ b/testdata/checklists/single.json @@ -7,5 +7,5 @@ "task_count": 10, "task_completed": 3, "tags_as_text": "", - "updated_at": "2026-01-14T10:00:00Z" + "updated_at": "2026/01/14 10:00:00 +0000" } diff --git a/testdata/notes/list.json b/testdata/notes/list.json index 2c17c14..6e24ae0 100644 --- a/testdata/notes/list.json +++ b/testdata/notes/list.json @@ -3,14 +3,14 @@ "id": 501, "task_id": 101, "comment": "First comment on task", - "updated_at": "2026-01-14T10:00:00Z", - "created_at": "2026-01-13T09:00:00Z" + "updated_at": "2026/01/14 10:00:00 +0000", + "created_at": "2026/01/13 09:00:00 +0000" }, { "id": 502, "task_id": 101, "comment": "Second comment with more details", - "updated_at": "2026-01-14T11:30:00Z", - "created_at": "2026-01-14T11:00:00Z" + "updated_at": "2026/01/14 11:30:00 +0000", + "created_at": "2026/01/14 11:00:00 +0000" } ] diff --git a/testdata/tasks/list.json b/testdata/tasks/list.json index 6da6fc9..b3f5737 100644 --- a/testdata/tasks/list.json +++ b/testdata/tasks/list.json @@ -12,8 +12,8 @@ "assignee_ids": [], "comments_count": 0, "update_line": "", - "updated_at": "2026-01-14T10:00:00Z", - "created_at": "2026-01-10T09:00:00Z" + "updated_at": "2026/01/14 10:00:00 +0000", + "created_at": "2026/01/10 09:00:00 +0000" }, { "id": 102, @@ -28,7 +28,7 @@ "assignee_ids": [1, 2], "comments_count": 3, "update_line": "", - "updated_at": "2026-01-14T11:00:00Z", - "created_at": "2026-01-11T10:00:00Z" + "updated_at": "2026/01/14 11:00:00 +0000", + "created_at": "2026/01/11 10:00:00 +0000" } ] diff --git a/testdata/tasks/single.json b/testdata/tasks/single.json index 206a796..ebd53a9 100644 --- a/testdata/tasks/single.json +++ b/testdata/tasks/single.json @@ -11,6 +11,6 @@ "assignee_ids": [], "comments_count": 0, "update_line": "", - "updated_at": "2026-01-14T10:00:00Z", - "created_at": "2026-01-10T09:00:00Z" + "updated_at": "2026/01/14 10:00:00 +0000", + "created_at": "2026/01/10 09:00:00 +0000" }