▍ humdrum codex / dots v1.0.0

feat: API client — GetDay, WriteBlocks, AddNote over widget/data + day routes

5ab15332417d6f5b84029e7fd273ef22c3af3db4
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 17:06

parent bb882be8

2 files changed

internal/api/client.go +154 −0
@@ -0,0 +1,154 @@
+package api
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"time"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// ErrUnauthorized means the stored token was rejected (or absent).
+var ErrUnauthorized = errors.New("unauthorized")
+
+// ErrForbidden means the server refused for subscription reasons (403).
+var ErrForbidden = errors.New("forbidden")
+
+type Client struct {
+	BaseURL string
+	Token   string
+	HTTP    *http.Client
+}
+
+func New(baseURL, token string) *Client {
+	return &Client{BaseURL: baseURL, Token: token, HTTP: &http.Client{Timeout: 15 * time.Second}}
+}
+
+// DayData is the CLI's view of GET /api/widget/data.
+type DayData struct {
+	BlockSize  int
+	Timezone   string
+	Types      []day.ActivityType
+	Blocks     map[int]string // only logged slots
+	PlanBlocks map[int]string // only planned slots
+}
+
+type widgetPayload struct {
+	Day struct {
+		BlockSize int    `json:"blockSize"`
+		Timezone  string `json:"timezone"`
+	} `json:"day"`
+	ActivityTypes []struct {
+		ID         string `json:"id"`
+		Name       string `json:"name"`
+		Color      string `json:"color"`
+		IsArchived bool   `json:"isArchived"`
+	} `json:"activityTypes"`
+	Blocks     map[string]*string `json:"blocks"`
+	PlanBlocks map[string]*string `json:"planBlocks"`
+}
+
+func (c *Client) do(method, path string, query url.Values, body any) (*http.Response, error) {
+	u := c.BaseURL + path
+	if query != nil {
+		u += "?" + query.Encode()
+	}
+	var buf *bytes.Buffer = &bytes.Buffer{}
+	if body != nil {
+		if err := json.NewEncoder(buf).Encode(body); err != nil {
+			return nil, err
+		}
+	}
+	req, err := http.NewRequest(method, u, buf)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Authorization", "Bearer "+c.Token)
+	if body != nil {
+		req.Header.Set("Content-Type", "application/json")
+	}
+	resp, err := c.HTTP.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("can't reach %s: %w", c.BaseURL, err)
+	}
+	switch {
+	case resp.StatusCode == http.StatusUnauthorized:
+		resp.Body.Close()
+		return nil, ErrUnauthorized
+	case resp.StatusCode == http.StatusForbidden:
+		resp.Body.Close()
+		return nil, ErrForbidden
+	case resp.StatusCode >= 400:
+		resp.Body.Close()
+		return nil, fmt.Errorf("server error: %s", resp.Status)
+	}
+	return resp, nil
+}
+
+func intKeyMap(in map[string]*string) map[int]string {
+	out := make(map[int]string)
+	for k, v := range in {
+		if v == nil {
+			continue
+		}
+		i, err := strconv.Atoi(k)
+		if err != nil {
+			continue
+		}
+		out[i] = *v
+	}
+	return out
+}
+
+func (c *Client) GetDay(date, tz string) (*DayData, error) {
+	q := url.Values{"date": {date}, "tz": {tz}}
+	resp, err := c.do(http.MethodGet, "/api/widget/data", q, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	var p widgetPayload
+	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+		return nil, fmt.Errorf("bad response: %w", err)
+	}
+	d := &DayData{
+		BlockSize:  p.Day.BlockSize,
+		Timezone:   p.Day.Timezone,
+		Blocks:     intKeyMap(p.Blocks),
+		PlanBlocks: intKeyMap(p.PlanBlocks),
+	}
+	for _, t := range p.ActivityTypes {
+		if t.IsArchived {
+			continue
+		}
+		d.Types = append(d.Types, day.ActivityType{ID: t.ID, Name: t.Name, Color: t.Color})
+	}
+	return d, nil
+}
+
+func (c *Client) WriteBlocks(date string, assignments map[int]*string) error {
+	body := make(map[string]*string, len(assignments))
+	for k, v := range assignments {
+		body[strconv.Itoa(k)] = v
+	}
+	resp, err := c.do(http.MethodPut, "/api/days/"+date+"/blocks", nil, body)
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	return nil
+}
+
+func (c *Client) AddNote(date, content string) error {
+	resp, err := c.do(http.MethodPost, "/api/days/"+date+"/notes", nil, map[string]string{"content": content})
+	if err != nil {
+		return err
+	}
+	resp.Body.Close()
+	return nil
+}
internal/api/client_test.go +129 −0
@@ -0,0 +1,129 @@
+package api
+
+import (
+	"encoding/json"
+	"errors"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+)
+
+const widgetFixture = `{
+  "day": {"blockSize": 30, "timeFormat": "24h", "timezone": "America/Los_Angeles"},
+  "activityTypes": [
+    {"id": "t1", "name": "ARCHER", "color": "#c04000", "isArchived": false, "displayOrder": 0},
+    {"id": "t2", "name": "Foofaraw", "color": "#4000c0", "isArchived": false, "displayOrder": 1}
+  ],
+  "blocks": {"28": "t1"},
+  "planBlocks": {"29": "t2"},
+  "events": [],
+  "extraEvents": {}
+}`
+
+func newTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *Client) {
+	t.Helper()
+	srv := httptest.NewServer(handler)
+	t.Cleanup(srv.Close)
+	return srv, New(srv.URL, "tok123")
+}
+
+func TestGetDay(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/api/widget/data" {
+			t.Errorf("path %s", r.URL.Path)
+		}
+		if r.Header.Get("Authorization") != "Bearer tok123" {
+			t.Errorf("auth header %q", r.Header.Get("Authorization"))
+		}
+		if r.URL.Query().Get("date") != "2026-07-29" || r.URL.Query().Get("tz") == "" {
+			t.Errorf("query %s", r.URL.RawQuery)
+		}
+		w.Write([]byte(widgetFixture))
+	})
+	d, err := c.GetDay("2026-07-29", "America/Los_Angeles")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if d.BlockSize != 30 || len(d.Types) != 2 || d.Blocks[28] != "t1" || d.PlanBlocks[29] != "t2" {
+		t.Fatalf("parsed %+v", d)
+	}
+}
+
+func TestGetDayUnauthorized(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusUnauthorized)
+	})
+	_, err := c.GetDay("2026-07-29", "UTC")
+	if !errors.Is(err, ErrUnauthorized) {
+		t.Fatalf("want ErrUnauthorized, got %v", err)
+	}
+}
+
+func TestGetDayForbidden(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		w.WriteHeader(http.StatusForbidden)
+	})
+	_, err := c.GetDay("2026-07-29", "UTC")
+	if !errors.Is(err, ErrForbidden) {
+		t.Fatalf("want ErrForbidden, got %v", err)
+	}
+}
+
+func TestWriteBlocks(t *testing.T) {
+	var got map[string]*string
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPut || r.URL.Path != "/api/days/2026-07-29/blocks" {
+			t.Errorf("%s %s", r.Method, r.URL.Path)
+		}
+		json.NewDecoder(r.Body).Decode(&got)
+		w.Write([]byte(`{}`))
+	})
+	tid := "t2"
+	if err := c.WriteBlocks("2026-07-29", map[int]*string{29: &tid, 28: nil}); err != nil {
+		t.Fatal(err)
+	}
+	if *got["29"] != "t2" || got["28"] != nil {
+		t.Fatalf("body %v", got)
+	}
+}
+
+func TestAddNote(t *testing.T) {
+	var got map[string]any
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodPost || r.URL.Path != "/api/days/2026-07-29/notes" {
+			t.Errorf("%s %s", r.Method, r.URL.Path)
+		}
+		json.NewDecoder(r.Body).Decode(&got)
+		w.WriteHeader(http.StatusCreated)
+		w.Write([]byte(`{}`))
+	})
+	if err := c.AddNote("2026-07-29", "hello"); err != nil {
+		t.Fatal(err)
+	}
+	if got["content"] != "hello" {
+		t.Fatalf("body %v", got)
+	}
+}
+
+func TestGetDayMissingBlockFields(t *testing.T) {
+	_, c := newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+		// Payload without blocks and planBlocks keys
+		w.Write([]byte(`{"day":{"blockSize":30,"timezone":"UTC"},"activityTypes":[]}`))
+	})
+	d, err := c.GetDay("2026-07-29", "UTC")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if d.Blocks == nil {
+		t.Fatal("Blocks should be non-nil empty map")
+	}
+	if len(d.Blocks) != 0 {
+		t.Fatalf("Blocks should be empty, got %v", d.Blocks)
+	}
+	if d.PlanBlocks == nil {
+		t.Fatal("PlanBlocks should be non-nil empty map")
+	}
+	if len(d.PlanBlocks) != 0 {
+		t.Fatalf("PlanBlocks should be empty, got %v", d.PlanBlocks)
+	}
+}