Implement Timestamp type for Unix timestamp JSON handling

Add custom Timestamp type that wraps time.Time for Kanboard's
Unix timestamp format:

UnmarshalJSON supports:
- Unix timestamps as integers
- Empty strings and "0" strings as zero time
- Null values as zero time
- Numeric strings (e.g., "1609459200")

MarshalJSON returns:
- 0 for zero time
- Unix timestamp for non-zero time

Includes comprehensive tests for round-trip marshaling
and struct embedding scenarios.

Closes: kanboard-api-25y
This commit is contained in:
Oliver Jakoubek 2026-01-15 18:15:33 +01:00
commit dbac08ac1e
3 changed files with 268 additions and 1 deletions

View file

@ -1,6 +1,6 @@
{"id":"kanboard-api-0fz","title":"Implement Category API methods","description":"Implement direct API methods for category operations.\n\n## Methods to implement\n- GetAllCategories(ctx, projectID int) ([]Category, error) - getAllCategories\n- GetCategory(ctx, categoryID int) (*Category, error) - getCategory (Nice-to-have)\n\n## Files to create\n- categories.go\n\n## Acceptance criteria\n- Proper error handling\n- Returns empty slice when no categories exist","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:16.6133153+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:16.6133153+01:00","dependencies":[{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:42:53.161416595+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-0fz","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:42:53.226963473+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-16r","title":"Implement Tag API methods (CRITICAL)","description":"Implement direct API methods for tag operations. Tags are CRITICAL - heavily used.\n\n## Direct Client methods (Must-have)\n- GetTaskTags(ctx, taskID int) (map[int]string, error) - getTaskTags\n- SetTaskTags(ctx, projectID, taskID int, tags []string) error - setTaskTags\n\n## Direct Client methods (Nice-to-have)\n- GetAllTags(ctx) ([]Tag, error) - getAllTags\n- GetTagsByProject(ctx, projectID int) ([]Tag, error) - getTagsByProject\n- CreateTag(ctx, projectID int, name, colorID string) (int, error) - createTag\n- UpdateTag(ctx, tagID int, name, colorID string) error - updateTag\n- RemoveTag(ctx, tagID int) error - removeTag\n\n## Files to create\n- tags.go\n\n## IMPORTANT NOTE\nsetTaskTags REPLACES ALL tags. Individual add/remove requires read-modify-write pattern (implemented in TaskScope).\n\n## Acceptance criteria\n- GetTaskTags returns map[tagID]tagName\n- SetTaskTags accepts tag names (auto-creates if needed)","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:08.526810135+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:08.526810135+01:00","dependencies":[{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.223137796+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-16r","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.402237867+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:34:55.0044989+01:00"}
{"id":"kanboard-api-25y","title":"Implement Timestamp type with JSON handling","description":"Implement custom Timestamp type that handles Kanboard's Unix timestamp format.\n\n## Requirements\n- Timestamp struct wrapping time.Time\n- UnmarshalJSON supporting:\n - Unix timestamps as integers\n - Empty strings and \"0\" as zero time\n - Zero value (0) as zero time\n- MarshalJSON returning Unix timestamp or 0 for zero time\n\n## Files to create\n- timestamp.go\n\n## Acceptance criteria\n- Correctly parses integer Unix timestamps\n- Handles empty strings and \"0\" strings\n- Zero time marshals to 0\n- Non-zero time marshals to Unix timestamp","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:55.0044989+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:15:27.299644047+01:00","closed_at":"2026-01-15T18:15:27.299644047+01:00","close_reason":"Closed"}
{"id":"kanboard-api-2g1","title":"Implement JSON-RPC client foundation","description":"Implement the core JSON-RPC 2.0 client for Kanboard API communication.\n\n## Requirements\n- JSONRPCRequest struct with jsonrpc, method, id, params fields\n- JSONRPCResponse struct with jsonrpc, id, result, error fields \n- JSONRPCError struct with code and message\n- Generic `call` method to send requests and parse responses\n- Automatic `/jsonrpc.php` path appending to baseURL\n- Thread-safe request ID generation via atomic.Int64\n\n## Files to create\n- jsonrpc.go\n\n## Acceptance criteria\n- All JSON-RPC structs properly marshal/unmarshal\n- Request IDs increment atomically\n- Supports subdirectory installations (e.g. /kanboard/jsonrpc.php)","status":"closed","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:34:53.232007312+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:10:29.68466887+01:00","closed_at":"2026-01-15T18:10:29.68466887+01:00","close_reason":"Closed"}
{"id":"kanboard-api-2ze","title":"Implement BoardScope fluent builder","description":"Implement BoardScope for fluent project-scoped operations.\n\n## Requirements\n- BoardScope struct with client and projectID\n- Client.Board(projectID int) *BoardScope method\n- BoardScope methods:\n - GetColumns(ctx) ([]Column, error)\n - GetCategories(ctx) ([]Category, error)\n - GetTasks(ctx, status TaskStatus) ([]Task, error)\n - SearchTasks(ctx, query string) ([]Task, error)\n - CreateTask(ctx, task *TaskParams) (*Task, error)\n\n## Files to create\n- board_scope.go\n\n## Example usage\n```go\ncolumns, _ := client.Board(1).GetColumns(ctx)\ntask, _ := client.Board(1).CreateTask(ctx, kanboard.NewTask(\"Title\"))\n```\n\n## Acceptance criteria\n- All methods delegate to direct Client methods\n- Proper error propagation","status":"open","priority":1,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:35:40.044649709+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:35:40.044649709+01:00","dependencies":[{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-apl","type":"blocks","created_at":"2026-01-15T17:43:30.81063282+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-l9b","type":"blocks","created_at":"2026-01-15T17:43:30.874964284+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-0fz","type":"blocks","created_at":"2026-01-15T17:43:30.939377116+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-2ze","depends_on_id":"kanboard-api-91a","type":"blocks","created_at":"2026-01-15T17:43:31.005026627+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-api-3dc","title":"Implement Link API methods","description":"Implement direct API methods for task link operations.\n\n## Methods to implement\n- GetAllTaskLinks(ctx, taskID int) ([]TaskLink, error) - getAllTaskLinks\n- CreateTaskLink(ctx, taskID, oppositeTaskID, linkID int) (int, error) - createTaskLink\n- RemoveTaskLink(ctx, taskLinkID int) error - removeTaskLink (Nice-to-have)\n\n## TaskScope methods to add\n- GetLinks(ctx) ([]TaskLink, error)\n- LinkTo(ctx, oppositeTaskID, linkID int) error\n\n## Files to create\n- links.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- CreateTaskLink returns the link ID\n- Links include related task information","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.328552773+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T17:36:09.328552773+01:00","dependencies":[{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.785710003+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-3dc","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:49.886111429+01:00","created_by":"Oliver Jakoubek"}]}

64
timestamp.go Normal file
View file

@ -0,0 +1,64 @@
package kanboard
import (
"encoding/json"
"fmt"
"time"
)
// Timestamp wraps time.Time and handles Kanboard's Unix timestamp JSON format.
// Kanboard returns timestamps as Unix integers, with 0 or empty strings for null values.
type Timestamp struct {
time.Time
}
// UnmarshalJSON implements json.Unmarshaler.
// Supports Unix timestamps as integers, empty strings, "0" strings, and zero values.
func (t *Timestamp) UnmarshalJSON(data []byte) error {
// Try to unmarshal as integer (Unix timestamp)
var unix int64
if err := json.Unmarshal(data, &unix); err == nil {
if unix == 0 {
t.Time = time.Time{}
} else {
t.Time = time.Unix(unix, 0)
}
return nil
}
// Try to unmarshal as string (empty or "0")
var str string
if err := json.Unmarshal(data, &str); err == nil {
if str == "" || str == "0" {
t.Time = time.Time{}
return nil
}
// Try to parse as numeric string
var unix int64
if _, err := fmt.Sscanf(str, "%d", &unix); err == nil {
if unix == 0 {
t.Time = time.Time{}
} else {
t.Time = time.Unix(unix, 0)
}
return nil
}
}
// Handle null
if string(data) == "null" {
t.Time = time.Time{}
return nil
}
return fmt.Errorf("cannot unmarshal timestamp: %s", string(data))
}
// MarshalJSON implements json.Marshaler.
// Returns 0 for zero time, otherwise returns Unix timestamp.
func (t Timestamp) MarshalJSON() ([]byte, error) {
if t.IsZero() {
return []byte("0"), nil
}
return json.Marshal(t.Unix())
}

203
timestamp_test.go Normal file
View file

@ -0,0 +1,203 @@
package kanboard
import (
"encoding/json"
"testing"
"time"
)
func TestTimestamp_UnmarshalJSON_Integer(t *testing.T) {
tests := []struct {
name string
input string
expected time.Time
}{
{
name: "positive unix timestamp",
input: "1609459200",
expected: time.Unix(1609459200, 0), // 2021-01-01 00:00:00 UTC
},
{
name: "zero",
input: "0",
expected: time.Time{},
},
{
name: "recent timestamp",
input: "1704067200",
expected: time.Unix(1704067200, 0), // 2024-01-01 00:00:00 UTC
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ts Timestamp
if err := json.Unmarshal([]byte(tt.input), &ts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ts.Time.Equal(tt.expected) {
t.Errorf("expected %v, got %v", tt.expected, ts.Time)
}
})
}
}
func TestTimestamp_UnmarshalJSON_String(t *testing.T) {
tests := []struct {
name string
input string
expected time.Time
}{
{
name: "empty string",
input: `""`,
expected: time.Time{},
},
{
name: "zero string",
input: `"0"`,
expected: time.Time{},
},
{
name: "numeric string",
input: `"1609459200"`,
expected: time.Unix(1609459200, 0),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ts Timestamp
if err := json.Unmarshal([]byte(tt.input), &ts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ts.Time.Equal(tt.expected) {
t.Errorf("expected %v, got %v", tt.expected, ts.Time)
}
})
}
}
func TestTimestamp_UnmarshalJSON_Null(t *testing.T) {
var ts Timestamp
if err := json.Unmarshal([]byte("null"), &ts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !ts.IsZero() {
t.Errorf("expected zero time for null, got %v", ts.Time)
}
}
func TestTimestamp_MarshalJSON(t *testing.T) {
tests := []struct {
name string
ts Timestamp
expected string
}{
{
name: "zero time",
ts: Timestamp{},
expected: "0",
},
{
name: "positive timestamp",
ts: Timestamp{Time: time.Unix(1609459200, 0)},
expected: "1609459200",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
data, err := json.Marshal(tt.ts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != tt.expected {
t.Errorf("expected %s, got %s", tt.expected, string(data))
}
})
}
}
func TestTimestamp_RoundTrip(t *testing.T) {
// Test that marshal/unmarshal produces the same result
original := Timestamp{Time: time.Unix(1609459200, 0)}
data, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal error: %v", err)
}
var parsed Timestamp
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !original.Time.Equal(parsed.Time) {
t.Errorf("round trip failed: %v != %v", original.Time, parsed.Time)
}
}
func TestTimestamp_InStruct(t *testing.T) {
// Test Timestamp as part of a struct (simulating API response)
type Task struct {
ID int `json:"id"`
DateCreation Timestamp `json:"date_creation"`
DateDue Timestamp `json:"date_due"`
}
jsonData := `{"id":42,"date_creation":1609459200,"date_due":0}`
var task Task
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if task.ID != 42 {
t.Errorf("expected ID=42, got %d", task.ID)
}
if task.DateCreation.IsZero() {
t.Error("DateCreation should not be zero")
}
if !task.DateDue.IsZero() {
t.Error("DateDue should be zero")
}
}
func TestTimestamp_InStructWithStringTimestamp(t *testing.T) {
// Test with string timestamps (Kanboard sometimes returns these)
type Task struct {
ID int `json:"id"`
DateCreation Timestamp `json:"date_creation"`
DateDue Timestamp `json:"date_due"`
}
jsonData := `{"id":42,"date_creation":"1609459200","date_due":""}`
var task Task
if err := json.Unmarshal([]byte(jsonData), &task); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if task.ID != 42 {
t.Errorf("expected ID=42, got %d", task.ID)
}
if task.DateCreation.IsZero() {
t.Error("DateCreation should not be zero")
}
if !task.DateDue.IsZero() {
t.Error("DateDue should be zero")
}
}
func TestTimestamp_IsZero(t *testing.T) {
var zero Timestamp
if !zero.IsZero() {
t.Error("default Timestamp should be zero")
}
nonZero := Timestamp{Time: time.Unix(1609459200, 0)}
if nonZero.IsZero() {
t.Error("non-zero Timestamp should not be zero")
}
}