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