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 34eea538f0
6 changed files with 339 additions and 3 deletions

View file

@ -1,6 +1,6 @@
{
"worktree_root": "/home/oli/Dev/kanboard-api",
"last_export_commit": "4e856cd206ba4e7e09d30d0f4892972df87db4a6",
"last_export_time": "2026-01-30T12:30:52.561186642+01:00",
"jsonl_hash": "46173e2c776a1bcf136d8903a3c384211ff17e44a6e7ba080445e5e0e0435749"
"last_export_commit": "c4caf4b8761660f52e465c37cbcefa80e11aec01",
"last_export_time": "2026-02-02T11:25:57.259799495+01:00",
"jsonl_hash": "ccd7fae0d9d72dc744a6028c3d1573e1df0b43bb6fb6b28d433a4a8c3c0d5eb6"
}

View file

@ -36,4 +36,5 @@
{"id":"kanboard-api-zg2","title":"Implement File API methods","description":"Implement direct API methods for task file operations.\n\n## Methods to implement (Must-have)\n- GetAllTaskFiles(ctx, taskID int) ([]TaskFile, error) - getAllTaskFiles\n- CreateTaskFile(ctx, projectID, taskID int, filename string, content []byte) (int, error) - createTaskFile\n\n## Methods to implement (Nice-to-have)\n- DownloadTaskFile(ctx, fileID int) ([]byte, error) - downloadTaskFile\n- RemoveTaskFile(ctx, fileID int) error - removeTaskFile\n\n## TaskScope methods to add\n- GetFiles(ctx) ([]TaskFile, error)\n- UploadFile(ctx, filename string, content []byte) (*TaskFile, error)\n\n## Files to create\n- files.go\n- task_scope.go (extend)\n\n## Acceptance criteria\n- File content base64 encoded for upload\n- CreateTaskFile returns file ID","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-15T17:36:09.748005313+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-15T18:45:05.499871045+01:00","closed_at":"2026-01-15T18:45:05.499871045+01:00","close_reason":"Closed","dependencies":[{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-uls","type":"blocks","created_at":"2026-01-15T17:43:49.984099418+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"kanboard-api-zg2","depends_on_id":"kanboard-api-cyc","type":"blocks","created_at":"2026-01-15T17:43:50.049331328+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"kanboard-iuf","title":"Remove Basic prefix from custom auth headers","description":"## Problem\n\nWhen using a custom header name (e.g., `X-API-Auth` for Kanboard), the library incorrectly prepends `\"Basic \"` to the base64-encoded credentials.\n\n**Current behavior** (`auth.go:27` and `auth.go:43`):\n```go\nreq.Header.Set(a.headerName, \"Basic \"+basicAuthValue(user, a.token))\n```\n\nSends: `X-API-Auth: Basic dXNlcjpwYXNzd29yZA==`\n\n**Expected behavior** per [Kanboard API docs](https://docs.kanboard.org/v1/api/authentication/):\n\n- Standard `Authorization` header: `Basic base64(username:password)` ✓\n- Custom `X-API-Auth` header: `base64(username:password)` (no \"Basic \" prefix!)\n\nShould send: `X-API-Auth: dXNlcjpwYXNzd29yZA==`\n\n## Impact\n\nAuthentication fails when using the custom header option. The server returns HTML (login page) instead of JSON because authentication is rejected.\n\n## Suggested Fix\n\nIn both `apiTokenAuth.Apply()` and `basicAuth.Apply()`, remove the \"Basic \" prefix when using a custom header name:\n\n```go\nif a.headerName != \"\" {\n req.Header.Set(a.headerName, basicAuthValue(user, a.token)) // no \"Basic \" prefix\n} else {\n req.SetBasicAuth(user, a.token) // uses standard Authorization header with \"Basic \"\n}\n```\n\n## Affected Code\n\n- `auth.go:27` - `apiTokenAuth.Apply()`\n- `auth.go:43` - `basicAuth.Apply()`\n\n## Acceptance Criteria\n\n- [ ] Custom header (`X-API-Auth`) sends raw base64 value without \"Basic \" prefix\n- [ ] Standard `Authorization` header still works with \"Basic \" prefix\n- [ ] Tests updated to verify both behaviors\n- [ ] All tests passing","status":"closed","priority":1,"issue_type":"bug","owner":"mail@oliverjakoubek.de","created_at":"2026-01-27T10:43:21.717917986+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-27T11:01:19.467981335+01:00","closed_at":"2026-01-27T11:01:19.467981335+01:00","close_reason":"Closed"}
{"id":"kanboard-n03","title":"Implement Swimlane CRUD methods","description":"The kanboard-api client already supports swimlane_id on tasks (InSwimlane builder, MoveTaskPosition, Task.SwimlaneID field) but lacks dedicated Swimlane management methods.\n\nImplement the following Kanboard JSON-RPC API methods:\n\n**Read operations:**\n- getActiveSwimlanes(project_id)\n- getAllSwimlanes(project_id)\n- getSwimlane(swimlane_id)\n- getSwimlaneById(swimlane_id)\n- getSwimlaneByName(project_id, name)\n\n**Write operations:**\n- addSwimlane(project_id, name, description)\n- updateSwimlane(swimlane_id, name, description)\n- removeSwimlane(project_id, swimlane_id)\n- changeSwimlanePosition(project_id, swimlane_id, position)\n- enableSwimlane(project_id, swimlane_id)\n- disableSwimlane(project_id, swimlane_id)\n\nCreate a new file `swimlanes.go` with a Swimlane struct:\n```go\ntype Swimlane struct {\n ID int\n Name string\n Description string\n Position int\n IsActive bool\n ProjectID int\n}\n```\n\nAnd corresponding test file `swimlanes_test.go`.\n\n## Acceptance Criteria\n- [ ] Swimlane struct defined with ID, Name, Description, Position, IsActive, ProjectID\n- [ ] All 11 JSON-RPC methods implemented\n- [ ] Read methods: getActiveSwimlanes, getAllSwimlanes, getSwimlane, getSwimlaneById, getSwimlaneByName\n- [ ] Write methods: addSwimlane, updateSwimlane, removeSwimlane, changeSwimlanePosition, enableSwimlane, disableSwimlane\n- [ ] Code in swimlanes.go follows existing patterns (e.g., categories.go)\n- [ ] Tests written in swimlanes_test.go and passing","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-01T10:07:54.192295081+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-01T10:07:54.192295081+01:00"}
{"id":"kanboard-qle","title":"Add timezone support with automatic timestamp conversion","description":"## Description\n\nImplement timezone support by calling the Kanboard `getTimezone` API endpoint and converting all UTC timestamps to the configured timezone.\n\nThe Kanboard API provides `getTimezone` which returns a timezone string (e.g. `Europe/Berlin`). The client should:\n\n1. Query the timezone on initialization (or lazily on first use)\n2. Automatically convert all timestamp fields to local time, including:\n - `date_creation`, `date_started`, `date_moved`, `date_due`\n - Comment dates (`date_creation`)\n - Any other timestamp fields returned by the API\n\n### Design Option\n\nProvide a `WithTimezone()` client option so callers can control whether conversion happens. When enabled, the client fetches the timezone from the API and converts timestamps transparently. When disabled (default for backward compatibility), timestamps remain as-is.\n\n## Acceptance Criteria\n\n- [ ] Implement `GetTimezone()` API method that calls `getTimezone`\n- [ ] Add `WithTimezone()` client option to enable automatic conversion\n- [ ] When enabled, all timestamp fields in responses are converted to the configured timezone\n- [ ] Timezone is fetched once and cached for the client lifetime\n- [ ] Tests written and passing for timezone conversion logic\n- [ ] Backward compatible: no behavior change without `WithTimezone()` option","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-02-02T12:25:32.830875466+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-02-02T12:34:10.955810869+01:00","closed_at":"2026-02-02T12:34:10.955810869+01:00","close_reason":"Closed"}
{"id":"kanboard-r3p","title":"Support optional API user in token authentication","description":"## Description\n\nBei der API-Token-Authentifizierung soll neben dem API-Key optional auch ein API-User konfigurierbar sein. Wenn kein User angegeben wird, soll weiterhin der Standard-User \"jsonrpc\" verwendet werden.\n\n## Current Behavior\n\nIn `auth.go`, the `apiTokenAuth` struct hardcodes the username \"jsonrpc\":\n\n```go\nfunc (a *apiTokenAuth) Apply(req *http.Request) {\n req.SetBasicAuth(\"jsonrpc\", a.token)\n}\n```\n\n## Expected Behavior\n\n- Add an optional `user` field to `apiTokenAuth`\n- If user is empty/not provided, default to \"jsonrpc\"\n- If user is provided, use that value for HTTP Basic Auth\n\n## Acceptance Criteria\n\n- [ ] `apiTokenAuth` struct has an optional user field\n- [ ] Default behavior unchanged when no user specified (uses \"jsonrpc\")\n- [ ] Custom user is used when explicitly provided\n- [ ] Client configuration supports setting the API user\n- [ ] Tests cover both default and custom user scenarios","status":"closed","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-23T17:39:37.745294723+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-23T17:55:23.860864584+01:00","closed_at":"2026-01-23T17:55:23.860864584+01:00","close_reason":"Closed"}

View file

@ -4,6 +4,7 @@ import (
"log/slog"
"net/http"
"strings"
"sync"
"time"
)
@ -19,6 +20,9 @@ type Client struct {
auth Authenticator
logger *slog.Logger
authHeaderName string // custom auth header, empty = use "Authorization"
timezone *time.Location
tzOnce sync.Once
tzEnabled bool
}
// NewClient creates a new Kanboard API client.
@ -103,3 +107,11 @@ func (c *Client) WithLogger(logger *slog.Logger) *Client {
c.logger = logger
return c
}
// WithTimezone enables automatic timestamp conversion. On the first API call,
// the client fetches the server's timezone via getTimezone and converts all
// Timestamp fields in responses to that timezone.
func (c *Client) WithTimezone() *Client {
c.tzEnabled = true
return c
}

View file

@ -47,6 +47,12 @@ func nextRequestID() int64 {
// call sends a JSON-RPC request and parses the response.
// The result parameter should be a pointer to the expected result type.
func (c *Client) call(ctx context.Context, method string, params interface{}, result interface{}) error {
if method != "getTimezone" {
if err := c.ensureTimezone(ctx); err != nil {
return fmt.Errorf("failed to load timezone: %w", err)
}
}
req := JSONRPCRequest{
JSONRPC: "2.0",
Method: method,
@ -138,6 +144,9 @@ func (c *Client) call(ctx context.Context, method string, params interface{}, re
if err := json.Unmarshal(rpcResp.Result, result); err != nil {
return fmt.Errorf("failed to unmarshal result: %w", err)
}
if c.tzEnabled && c.timezone != nil {
c.convertTimestamps(result)
}
}
return nil

81
timezone.go Normal file
View file

@ -0,0 +1,81 @@
package kanboard
import (
"context"
"fmt"
"reflect"
"time"
)
// GetTimezone returns the server's configured timezone string (e.g., "UTC", "Europe/Berlin").
func (c *Client) GetTimezone(ctx context.Context) (string, error) {
var tz string
if err := c.call(ctx, "getTimezone", nil, &tz); err != nil {
return "", fmt.Errorf("getTimezone: %w", err)
}
return tz, nil
}
// loadTimezone fetches and caches the timezone location from the server.
func (c *Client) loadTimezone(ctx context.Context) error {
tz, err := c.GetTimezone(ctx)
if err != nil {
return err
}
loc, err := time.LoadLocation(tz)
if err != nil {
return fmt.Errorf("invalid timezone %q: %w", tz, err)
}
c.timezone = loc
return nil
}
// ensureTimezone loads the timezone if tzEnabled and not yet loaded.
func (c *Client) ensureTimezone(ctx context.Context) error {
if !c.tzEnabled {
return nil
}
var err error
c.tzOnce.Do(func() {
err = c.loadTimezone(ctx)
})
return err
}
// convertTimestamps converts all Timestamp fields in v to the client's timezone.
// v must be a pointer. Handles structs, pointers to structs, and slices of structs.
func (c *Client) convertTimestamps(v any) {
if c.timezone == nil {
return
}
rv := reflect.ValueOf(v)
c.walkAndConvert(rv)
}
var timestampType = reflect.TypeOf(Timestamp{})
func (c *Client) walkAndConvert(rv reflect.Value) {
switch rv.Kind() {
case reflect.Ptr:
if !rv.IsNil() {
c.walkAndConvert(rv.Elem())
}
case reflect.Struct:
if rv.Type() == timestampType {
if rv.CanSet() {
ts := rv.Addr().Interface().(*Timestamp)
if !ts.IsZero() {
ts.Time = ts.Time.In(c.timezone)
}
}
return
}
for i := 0; i < rv.NumField(); i++ {
c.walkAndConvert(rv.Field(i))
}
case reflect.Slice:
for i := 0; i < rv.Len(); i++ {
c.walkAndConvert(rv.Index(i))
}
}
}

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())
}
})
}