feat: add timezone support with automatic timestamp conversion

Add GetTimezone() API method and WithTimezone() client option.
When enabled, the client lazily fetches the server timezone on
first API call and converts all Timestamp fields in responses
using reflection-based struct walking.
This commit is contained in:
Oliver Jakoubek 2026-02-02 12:34:15 +01:00
commit 1fba43cf90
6 changed files with 339 additions and 3 deletions

233
timezone_test.go Normal file
View file

@ -0,0 +1,233 @@
package kanboard
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestClient_GetTimezone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Method != "getTimezone" {
t.Errorf("expected method=getTimezone, got %s", req.Method)
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`"Europe/Berlin"`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
tz, err := client.GetTimezone(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tz != "Europe/Berlin" {
t.Errorf("expected Europe/Berlin, got %s", tz)
}
}
func TestClient_WithTimezone_ConvertsTaskTimestamps(t *testing.T) {
callCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
var result json.RawMessage
switch req.Method {
case "getTimezone":
callCount++
result = json.RawMessage(`"America/New_York"`)
case "getTask":
result = json.RawMessage(`{
"id": "1",
"title": "Test",
"description": "",
"date_creation": 1609459200,
"date_modification": 1609459200,
"date_completed": 0,
"date_started": 0,
"date_due": 0,
"date_moved": 0,
"color_id": "yellow",
"project_id": "1",
"column_id": "1",
"owner_id": "0",
"creator_id": "1",
"position": "1",
"is_active": "1",
"score": "0",
"category_id": "0",
"swimlane_id": "0",
"priority": "0",
"reference": "",
"recurrence_status": "0",
"recurrence_trigger": "0",
"recurrence_factor": "0",
"recurrence_timeframe": "0",
"recurrence_basedate": "0",
"recurrence_parent": "0",
"recurrence_child": "0"
}`)
default:
t.Errorf("unexpected method: %s", req.Method)
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: result,
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token").WithTimezone()
task, err := client.GetTask(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
loc, _ := time.LoadLocation("America/New_York")
expected := time.Unix(1609459200, 0).In(loc)
if !task.DateCreation.Time.Equal(expected) {
t.Errorf("expected time %v, got %v", expected, task.DateCreation.Time)
}
if task.DateCreation.Time.Location().String() != "America/New_York" {
t.Errorf("expected location America/New_York, got %s", task.DateCreation.Time.Location())
}
// Verify getTimezone was called exactly once
if callCount != 1 {
t.Errorf("expected getTimezone called once, got %d", callCount)
}
// Make a second call — should NOT call getTimezone again
_, err = client.GetTask(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error on second call: %v", err)
}
if callCount != 1 {
t.Errorf("expected getTimezone still called once, got %d", callCount)
}
}
func TestClient_WithTimezone_Disabled(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req JSONRPCRequest
json.NewDecoder(r.Body).Decode(&req)
if req.Method == "getTimezone" {
t.Error("getTimezone should not be called when timezone is disabled")
}
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(`{
"id": "1",
"title": "Test",
"description": "",
"date_creation": 1609459200,
"date_modification": 0,
"date_completed": 0,
"date_started": 0,
"date_due": 0,
"date_moved": 0,
"color_id": "yellow",
"project_id": "1",
"column_id": "1",
"owner_id": "0",
"creator_id": "1",
"position": "1",
"is_active": "1",
"score": "0",
"category_id": "0",
"swimlane_id": "0",
"priority": "0",
"reference": "",
"recurrence_status": "0",
"recurrence_trigger": "0",
"recurrence_factor": "0",
"recurrence_timeframe": "0",
"recurrence_basedate": "0",
"recurrence_parent": "0",
"recurrence_child": "0"
}`),
}
json.NewEncoder(w).Encode(resp)
}))
defer server.Close()
client := NewClient(server.URL).WithAPIToken("test-token")
task, err := client.GetTask(context.Background(), 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Without WithTimezone, timestamps stay as unmarshalled (Local from time.Unix)
if task.DateCreation.Time.Location() != time.Local {
t.Errorf("expected Local, got %s", task.DateCreation.Time.Location())
}
}
func TestConvertTimestamps(t *testing.T) {
loc, _ := time.LoadLocation("Asia/Tokyo")
client := &Client{timezone: loc}
t.Run("struct with Timestamp fields", func(t *testing.T) {
task := &Task{
DateCreation: Timestamp{Time: time.Unix(1609459200, 0)},
DateModification: Timestamp{Time: time.Unix(1609459200, 0)},
DateCompleted: Timestamp{}, // zero — should stay zero
}
client.convertTimestamps(task)
if task.DateCreation.Time.Location().String() != "Asia/Tokyo" {
t.Errorf("expected Asia/Tokyo, got %s", task.DateCreation.Time.Location())
}
if task.DateModification.Time.Location().String() != "Asia/Tokyo" {
t.Errorf("expected Asia/Tokyo, got %s", task.DateModification.Time.Location())
}
if !task.DateCompleted.IsZero() {
t.Error("zero timestamp should remain zero")
}
})
t.Run("slice of structs", func(t *testing.T) {
tasks := &[]Task{
{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}},
{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}},
}
client.convertTimestamps(tasks)
for i, task := range *tasks {
if task.DateCreation.Time.Location().String() != "Asia/Tokyo" {
t.Errorf("task[%d]: expected Asia/Tokyo, got %s", i, task.DateCreation.Time.Location())
}
}
})
t.Run("nil timezone is no-op", func(t *testing.T) {
noTzClient := &Client{}
task := &Task{DateCreation: Timestamp{Time: time.Unix(1609459200, 0)}}
noTzClient.convertTimestamps(task)
// Should not panic or change anything
if task.DateCreation.Time.Location() != time.Local {
t.Errorf("expected Local, got %s", task.DateCreation.Time.Location())
}
})
}