▍ humdrum codex / dots v1.0.0
56.9 KB raw

dots-cli v1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A Go CLI (dots) for reflex-speed Dots logging — log, clear, note, today, types, auth — plus a small server PR adding CLI-token auth.

Architecture: Thin online-only client: every command is 1–2 HTTP calls. Reads ride the existing GET /api/widget/data payload (day, blocks, planBlocks, activityTypes). Writes reuse PUT /api/days/[date]/blocks and POST /api/days/[date]/notes, which gain Bearer-token auth via a new cli_tokens table cloned from the existing widget_tokens machinery. All slot/match/diff logic is pure functions in internal/day.

Tech Stack: Go 1.23+, cobra, lipgloss, zalando/go-keyring, golang.org/x/term. Server side: Next.js 15 App Router, Supabase, Clerk (existing Dots stack).

Global Constraints


Server tasks (repo: /Users/kortum/Developer/Humdrum-One/Dots)

Work on a bookmark: from repo root run jj new main then jj bookmark create cli-tokens -r @.

Task 1: cli_tokens table + db lib

Files:

Interfaces:

-- CLI tokens: allow the dots CLI to authenticate without Clerk sessions.
-- Mirror of widget_tokens; separate table so revoking one doesn't kill the other.

CREATE TABLE cli_tokens (
  id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
  user_id text NOT NULL,
  token text NOT NULL UNIQUE,
  created_at timestamptz DEFAULT now(),
  UNIQUE(user_id)
);
ALTER TABLE cli_tokens ENABLE ROW LEVEL SECURITY;
CREATE POLICY deny_all ON cli_tokens FOR ALL USING (false);
CREATE INDEX idx_cli_tokens_token ON cli_tokens(token);

In src/types/index.ts, directly after the WidgetToken interface (search for export interface WidgetTokenRow), append:

export interface CliTokenRow {
  id: string;
  user_id: string;
  token: string;
  created_at: string;
}

export interface CliToken {
  id: string;
  userId: string;
  token: string;
  createdAt: string;
}

(Match the existing WidgetTokenRow/WidgetToken field shapes exactly — open the file and copy their structure if they differ from the above.)

Copy src/lib/db/widgetTokens.ts wholesale, then rename: widget_tokenscli_tokens, WidgetTokenCliToken, WidgetTokenRowCliTokenRow, getWidgetTokengetCliToken, generateWidgetTokengenerateCliToken, revokeWidgetTokenrevokeCliToken, getUserIdByWidgetTokengetUserIdByCliToken. Same randomBytes(32).toString("hex") token, same delete-then-insert replace behavior.

Run: npx tsc --noEmit (repo root). Expected: clean.

jj commit -m "feat: cli_tokens table + db lib (mirror of widget tokens)"

Note for the final task: the migration must be applied to Supabase before live verification (supabase db push or dashboard SQL editor — the user does this; flag it, don't run it).

Task 2: shared token-auth helper, applied to the three routes

Files:

Interfaces:

import { auth, clerkClient, currentUser } from "@clerk/nextjs/server";
import { getUserIdByCliToken } from "@/lib/db/cliTokens";

function bearerToken(request: Request): string | null {
  const h = request.headers.get("authorization");
  return h?.startsWith("Bearer ") ? h.slice(7) : null;
}

/** Clerk session first, else CLI bearer token. Null if neither. */
export async function resolveUserId(request: Request): Promise<string | null> {
  const { userId } = await auth();
  if (userId) return userId;
  const token = bearerToken(request);
  if (!token) return null;
  return getUserIdByCliToken(token);
}

/** Like resolveUserId, but also requires an active subscription. */
export async function resolveSubscribedUserId(request: Request): Promise<string | null> {
  const { userId } = await auth();
  if (userId) {
    const user = await currentUser();
    return user?.publicMetadata?.subscribed === true ? userId : null;
  }
  const token = bearerToken(request);
  if (!token) return null;
  const tokenUserId = await getUserIdByCliToken(token);
  if (!tokenUserId) return null;
  const client = await clerkClient();
  const user = await client.users.getUser(tokenUserId);
  return user.publicMetadata?.subscribed === true ? tokenUserId : null;
}

(Check the repo's @clerk/nextjs version for whether clerkClient is called as a function — ^6.0.0 uses await clerkClient(). If tsc complains, adjust to the version's calling convention.)

In src/app/api/widget/data/route.ts, replace the token-resolution block at the top of GET (the lines that read the authorization header and call getUserIdByWidgetToken) with:

const authHeader = request.headers.get("authorization");
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;

if (!token) {
  return NextResponse.json({ error: "Missing Authorization header" }, { status: 401 });
}

const userId = (await getUserIdByWidgetToken(token)) ?? (await getUserIdByCliToken(token));
if (!userId) {
  return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}

Add import { getUserIdByCliToken } from "@/lib/db/cliTokens"; to the imports. Everything below stays untouched.

In src/app/api/days/[date]/blocks/route.ts, in PUT only, replace:

const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

with:

const userId = await resolveUserId(request);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

Add import { resolveUserId } from "@/lib/apiAuth";. Leave GET on plain Clerk auth(). If auth is now unused, drop the import.

In src/app/api/days/[date]/notes/route.ts, in POST only, replace const userId = await getSubscribedUserId(); with:

const userId = await resolveSubscribedUserId(request);

Add import { resolveSubscribedUserId } from "@/lib/apiAuth";. Leave GET on getSubscribedUserId().

Run: npx tsc --noEmit. Expected: clean.

jj commit -m "feat: accept CLI bearer tokens on widget/data, blocks PUT, notes POST"

Task 3: /api/cli/token route + /cli page, push + PR

Files:

Interfaces:

Copy src/app/api/widget/token/route.ts wholesale; swap the three imports to @/lib/db/cliTokens (getCliToken, generateCliToken, revokeCliToken) and update the console.error labels to /api/cli/token. Clerk-session auth stays as-is — tokens are only ever minted from a browser session.

First read src/components/settings/ScriptableWidgetSettings.tsx to see how the existing widget-token UI fetches/generates/revokes and its styling. Build CliTokenSettings.tsx the same way against /api/cli/token: show current token (fetched on mount from GET), a Generate button (POST, replaces), a Copy button (navigator.clipboard.writeText), a Revoke button (DELETE), and a one-line hint: Paste this token into \dots auth` in your terminal.` Reuse the same class names / UI primitives that file uses so it matches the app.

Then src/app/cli/page.tsx: follow the pattern of an existing signed-in page (look at how src/app/page.tsx guards auth) and render <CliTokenSettings /> with a page title "CLI access". If Clerk middleware protects pages by default, no extra guard code is needed — confirm against src/middleware.ts.

Run: npx tsc --noEmit then npm run build. Expected: both clean.

jj commit -m "feat: /cli token page + /api/cli/token route"
jj git push -b cli-tokens --allow-new
gh pr create --repo humdrum-tiv/dots --head cli-tokens --title "CLI token auth (dots-cli v1)" --body "cli_tokens table (mirror of widget_tokens), resolveUserId/resolveSubscribedUserId helper, CLI bearer accepted on widget/data GET + blocks PUT + notes POST, /cli token management page. Server half of dots-cli v1 (spec in dots-cli repo).

🤖 Generated with [Claude Code](https://claude.com/claude-code)"

Expected: PR opens. Flag to the user: merge + supabase db push (migration) must happen before Task 10's live verification.


CLI tasks (repo: /Users/kortum/Developer/Humdrum-One/dots-cli)

Task 4: scaffold Go module + cobra root

Files:

Interfaces:

go mod init github.com/humdrum-tiv/dots-cli
go get github.com/spf13/cobra@latest github.com/charmbracelet/lipgloss@latest github.com/zalando/go-keyring@latest golang.org/x/term@latest
package config

import "os"

const defaultBaseURL = "https://dots.humdrum.one"

// BaseURL returns the Dots server root, without trailing slash.
func BaseURL() string {
	if v := os.Getenv("DOTS_URL"); v != "" {
		return v
	}
	return defaultBaseURL
}
package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

func newRootCmd() *cobra.Command {
	root := &cobra.Command{
		Use:           "dots",
		Short:         "Log your day from the terminal",
		SilenceUsage:  true,
		SilenceErrors: true,
	}
	return root
}

func main() {
	if err := newRootCmd().Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}
dots
dist/

Run: go build ./... && go run ./cmd/dots --help. Expected: help text prints.

jj commit -m "feat: scaffold go module, config, cobra root"

Task 5: slot math (internal/day)

Files:

Interfaces:

internal/day/slots_test.go:

package day

import (
	"reflect"
	"testing"
	"time"
)

func at(h, m int) time.Time {
	return time.Date(2026, 7, 29, h, m, 0, 0, time.UTC)
}

func TestParseTarget(t *testing.T) {
	cases := []struct {
		name      string
		arg       string
		blockSize int
		now       time.Time
		want      []int
		wantErr   bool
	}{
		{"empty is current slot", "", 30, at(14, 47), []int{29}, false},
		{"single time", "14:30", 30, at(0, 0), []int{29}, false},
		{"snap down", "14:47", 30, at(0, 0), []int{29}, false},
		{"single digit hour", "9:00", 30, at(0, 0), []int{18}, false},
		{"leading zero", "09:00", 30, at(0, 0), []int{18}, false},
		{"range end exclusive", "13:00-15:00", 30, at(0, 0), []int{26, 27, 28, 29}, false},
		{"range at 15min blocks", "13:00-14:00", 15, at(0, 0), []int{52, 53, 54, 55}, false},
		{"range at 60min blocks", "13:30-15:00", 60, at(0, 0), []int{13, 14}, false},
		{"midnight slot", "0:00", 30, at(0, 0), []int{0}, false},
		{"last slot", "23:30", 30, at(0, 0), []int{47}, false},
		{"end before start", "15:00-13:00", 30, at(0, 0), nil, true},
		{"end equals start", "13:00-13:00", 30, at(0, 0), nil, true},
		{"hour out of range", "24:00", 30, at(0, 0), nil, true},
		{"minute out of range", "12:60", 30, at(0, 0), nil, true},
		{"garbage", "archer", 30, at(0, 0), nil, true},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			got, err := ParseTarget(c.arg, c.blockSize, c.now)
			if c.wantErr != (err != nil) {
				t.Fatalf("err = %v, wantErr %v", err, c.wantErr)
			}
			if !c.wantErr && !reflect.DeepEqual(got, c.want) {
				t.Fatalf("got %v, want %v", got, c.want)
			}
		})
	}
}

func TestIsTimeSyntax(t *testing.T) {
	yes := []string{"14:30", "9:00", "09:00", "13:00-15:00"}
	no := []string{"archer", "", "14", "14:3", "a-b", "14:30ish"}
	for _, s := range yes {
		if !IsTimeSyntax(s) {
			t.Errorf("IsTimeSyntax(%q) = false, want true", s)
		}
	}
	for _, s := range no {
		if IsTimeSyntax(s) {
			t.Errorf("IsTimeSyntax(%q) = true, want false", s)
		}
	}
}

func TestSlotLabel(t *testing.T) {
	if got := SlotLabel(29, 30); got != "14:30" {
		t.Fatalf("got %q", got)
	}
	if got := SlotLabel(0, 30); got != "0:00" {
		t.Fatalf("got %q", got)
	}
	if got := SlotLabel(55, 15); got != "13:45" {
		t.Fatalf("got %q", got)
	}
}

Run: go test ./internal/day/ -v. Expected: FAIL (undefined functions).

package day

import (
	"fmt"
	"regexp"
	"time"
)

var timeRe = regexp.MustCompile(`^(\d{1,2}):(\d{2})$`)
var rangeRe = regexp.MustCompile(`^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$`)

// IsTimeSyntax reports whether arg looks like a time or time range,
// deciding whether a positional arg is a target or an activity name.
func IsTimeSyntax(arg string) bool {
	return timeRe.MatchString(arg) || rangeRe.MatchString(arg)
}

func parseMinutes(s string) (int, error) {
	m := timeRe.FindStringSubmatch(s)
	if m == nil {
		return 0, fmt.Errorf("invalid time %q (want H:MM)", s)
	}
	var h, min int
	fmt.Sscanf(m[1], "%d", &h)
	fmt.Sscanf(m[2], "%d", &min)
	if h > 23 || min > 59 {
		return 0, fmt.Errorf("invalid time %q", s)
	}
	return h*60 + min, nil
}

// ParseTarget resolves a time argument to canonical block indices.
// arg "" means the slot containing now. Ranges are end-exclusive.
func ParseTarget(arg string, blockSize int, now time.Time) ([]int, error) {
	if arg == "" {
		return []int{(now.Hour()*60 + now.Minute()) / blockSize}, nil
	}
	if m := rangeRe.FindStringSubmatch(arg); m != nil {
		start, err := parseMinutes(m[1])
		if err != nil {
			return nil, err
		}
		end, err := parseMinutes(m[2])
		if err != nil {
			return nil, err
		}
		if end <= start {
			return nil, fmt.Errorf("range end %s is not after start %s", m[2], m[1])
		}
		first := start / blockSize
		last := (end - 1) / blockSize
		out := make([]int, 0, last-first+1)
		for i := first; i <= last; i++ {
			out = append(out, i)
		}
		return out, nil
	}
	mins, err := parseMinutes(arg)
	if err != nil {
		return nil, err
	}
	return []int{mins / blockSize}, nil
}

// SlotLabel renders a block index as its start time, e.g. "14:30".
func SlotLabel(index, blockSize int) string {
	mins := index * blockSize
	return fmt.Sprintf("%d:%02d", mins/60, mins%60)
}

Run: go test ./internal/day/ -v. Expected: PASS.

jj commit -m "feat: slot math — target parsing, time syntax detection, labels"

Task 6: activity fuzzy matching (internal/day)

Files:

Interfaces:

internal/day/match_test.go:

package day

import "testing"

var types = []ActivityType{
	{ID: "1", Name: "ARCHER", Color: "#c04000"},
	{ID: "2", Name: "Archery practice", Color: "#00c040"},
	{ID: "3", Name: "Foofaraw", Color: "#4000c0"},
	{ID: "4", Name: "Reading", Color: "#404040"},
}

func names(ms []ActivityType) []string {
	out := make([]string, len(ms))
	for i, m := range ms {
		out[i] = m.Name
	}
	return out
}

func TestMatchActivity(t *testing.T) {
	cases := []struct {
		query string
		want  []string
	}{
		{"archer", []string{"ARCHER"}},          // exact beats prefix
		{"arch", []string{"ARCHER", "Archery practice"}}, // prefix, both
		{"foo", []string{"Foofaraw"}},
		{"ading", []string{"Reading"}},          // substring fallback
		{"xyz", []string{}},
		{"READING", []string{"Reading"}},
	}
	for _, c := range cases {
		got := names(MatchActivity(c.query, types))
		if len(got) != len(c.want) {
			t.Fatalf("%q: got %v, want %v", c.query, got, c.want)
		}
		for i := range got {
			if got[i] != c.want[i] {
				t.Fatalf("%q: got %v, want %v", c.query, got, c.want)
			}
		}
	}
}

Run: go test ./internal/day/ -run TestMatchActivity -v. Expected: FAIL.

package day

import "strings"

// ActivityType is the CLI-side view of a Dots activity type.
type ActivityType struct {
	ID    string
	Name  string
	Color string
}

// MatchActivity finds candidate types for a query: exact (case-insensitive)
// wins outright; otherwise all prefix matches; otherwise all substring matches.
func MatchActivity(query string, types []ActivityType) []ActivityType {
	q := strings.ToLower(query)
	var prefix, substr []ActivityType
	for _, t := range types {
		n := strings.ToLower(t.Name)
		if n == q {
			return []ActivityType{t}
		}
		if strings.HasPrefix(n, q) {
			prefix = append(prefix, t)
		} else if strings.Contains(n, q) {
			substr = append(substr, t)
		}
	}
	if len(prefix) > 0 {
		return prefix
	}
	if substr == nil {
		return []ActivityType{}
	}
	return substr
}

Run: go test ./internal/day/ -v. Expected: PASS.

jj commit -m "feat: activity fuzzy matching (exact > prefix > substring)"

Task 7: plan-fill + overwrite diff (internal/day)

Files:

Interfaces:

internal/day/resolve_test.go:

package day

import (
	"strings"
	"testing"
)

func sp(s string) *string { return &s }

func TestResolveLogExplicitActivity(t *testing.T) {
	blocks := map[int]string{28: "old"}
	asg, over, err := ResolveLog([]int{28, 29}, sp("new"), blocks, nil)
	if err != nil {
		t.Fatal(err)
	}
	if len(asg) != 2 || *asg[28] != "new" || *asg[29] != "new" {
		t.Fatalf("assignments %v", asg)
	}
	if len(over) != 1 || over[0].Index != 28 || *over[0].FromID != "old" || *over[0].ToID != "new" {
		t.Fatalf("overwrites %v", over)
	}
}

func TestResolveLogSameActivityNoOverwrite(t *testing.T) {
	blocks := map[int]string{28: "same"}
	_, over, err := ResolveLog([]int{28}, sp("same"), blocks, nil)
	if err != nil {
		t.Fatal(err)
	}
	if len(over) != 0 {
		t.Fatalf("re-logging same activity should not count as overwrite: %v", over)
	}
}

func TestResolveLogPlanFill(t *testing.T) {
	plan := map[int]string{29: "planned-a", 30: "planned-b"}
	asg, over, err := ResolveLog([]int{29, 30}, nil, map[int]string{}, plan)
	if err != nil {
		t.Fatal(err)
	}
	if *asg[29] != "planned-a" || *asg[30] != "planned-b" {
		t.Fatalf("assignments %v", asg)
	}
	if len(over) != 0 {
		t.Fatalf("overwrites %v", over)
	}
}

func TestResolveLogPlanFillMissingPlanAllOrNothing(t *testing.T) {
	plan := map[int]string{29: "planned-a"}
	_, _, err := ResolveLog([]int{29, 30, 31}, nil, map[int]string{}, plan)
	if err == nil {
		t.Fatal("want error for slots without plan")
	}
	msg := err.Error()
	if !strings.Contains(msg, "15:00") || !strings.Contains(msg, "15:30") {
		t.Fatalf("error should list offending slots (blockSize 30): %q", msg)
	}
}

func TestResolveClear(t *testing.T) {
	blocks := map[int]string{28: "a"}
	asg, cleared := ResolveClear([]int{28, 29}, blocks)
	if len(asg) != 1 || asg[28] != nil {
		t.Fatalf("assignments %v", asg)
	}
	if len(cleared) != 1 || cleared[0].Index != 28 || *cleared[0].FromID != "a" || cleared[0].ToID != nil {
		t.Fatalf("cleared %v", cleared)
	}
}

Note: the missing-plan error must render slot labels, which needs blockSize — give ResolveLog a fifth parameter blockSize int and update the test calls above to pass 30. (Signature: ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string, blockSize int).)

Run: go test ./internal/day/ -run TestResolve -v. Expected: FAIL.

package day

import (
	"fmt"
	"strings"
)

// Change describes one slot transition for the confirmation table.
type Change struct {
	Index  int
	FromID *string // nil = was empty
	ToID   *string // nil = cleared
}

// ResolveLog computes the write set for a log command. activityID nil means
// plan-fill: every target slot takes its plan block's activity, and any slot
// without one aborts the whole command (all-or-nothing).
func ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string, blockSize int) (map[int]*string, []Change, error) {
	assignments := make(map[int]*string, len(targets))
	var overwrites []Change

	if activityID == nil {
		var missing []string
		for _, i := range targets {
			if _, ok := planBlocks[i]; !ok {
				missing = append(missing, SlotLabel(i, blockSize))
			}
		}
		if len(missing) > 0 {
			return nil, nil, fmt.Errorf("no planned activity at %s — nothing painted", strings.Join(missing, ", "))
		}
	}

	for _, i := range targets {
		to := activityID
		if to == nil {
			v := planBlocks[i]
			to = &v
		}
		assignments[i] = to
		if from, ok := blocks[i]; ok && from != *to {
			f := from
			overwrites = append(overwrites, Change{Index: i, FromID: &f, ToID: to})
		}
	}
	return assignments, overwrites, nil
}

// ResolveClear computes the write set for a clear command. Only slots that
// are actually logged are touched; clearing an empty slot is a no-op.
func ResolveClear(targets []int, blocks map[int]string) (map[int]*string, []Change) {
	assignments := make(map[int]*string)
	var cleared []Change
	for _, i := range targets {
		if from, ok := blocks[i]; ok {
			f := from
			assignments[i] = nil
			cleared = append(cleared, Change{Index: i, FromID: &f, ToID: nil})
		}
	}
	return assignments, cleared
}

Run: go test ./internal/day/ -v. Expected: PASS (all of slots, match, resolve).

jj commit -m "feat: plan-fill resolution and overwrite/clear diff computation"

Task 8: API client (internal/api)

Files:

Interfaces:

internal/api/client_test.go — httptest server with a fixture mirroring the real widget/data shape:

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

Run: go test ./internal/api/ -v. Expected: FAIL.

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
}

Run: go test ./... . Expected: PASS.

jj commit -m "feat: API client — GetDay, WriteBlocks, AddNote over widget/data + day routes"

Task 9: token storage + dots auth (internal/auth)

Files:

Interfaces:

internal/auth/store_test.go:

package auth

import (
	"os"
	"path/filepath"
	"testing"
)

func TestFileFallbackRoundTrip(t *testing.T) {
	dir := t.TempDir()
	t.Setenv("DOTS_TOKEN_DIR", dir) // test hook: overrides ~/.config/dots

	if err := saveTokenFile("abc123"); err != nil {
		t.Fatal(err)
	}
	got, err := loadTokenFile()
	if err != nil {
		t.Fatal(err)
	}
	if got != "abc123" {
		t.Fatalf("got %q", got)
	}
	info, _ := os.Stat(filepath.Join(dir, "token"))
	if info.Mode().Perm() != 0o600 {
		t.Fatalf("perm %v", info.Mode().Perm())
	}
}

func TestLoadTokenFileMissing(t *testing.T) {
	t.Setenv("DOTS_TOKEN_DIR", t.TempDir())
	if _, err := loadTokenFile(); err == nil {
		t.Fatal("want error for missing token file")
	}
}

Run: go test ./internal/auth/ -v. Expected: FAIL.

package auth

import (
	"errors"
	"os"
	"path/filepath"
	"strings"

	"github.com/zalando/go-keyring"
)

const service = "dots-cli"
const account = "token"

// ErrNoToken means no token is stored anywhere.
var ErrNoToken = errors.New("no token stored")

func tokenDir() string {
	if d := os.Getenv("DOTS_TOKEN_DIR"); d != "" {
		return d
	}
	home, _ := os.UserHomeDir()
	return filepath.Join(home, ".config", "dots")
}

func saveTokenFile(token string) error {
	dir := tokenDir()
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(dir, "token"), []byte(token+"\n"), 0o600)
}

func loadTokenFile() (string, error) {
	b, err := os.ReadFile(filepath.Join(tokenDir(), "token"))
	if err != nil {
		return "", ErrNoToken
	}
	return strings.TrimSpace(string(b)), nil
}

// SaveToken prefers the OS keychain, falling back to a 0600 file.
func SaveToken(token string) error {
	if err := keyring.Set(service, account, token); err == nil {
		return nil
	}
	return saveTokenFile(token)
}

// LoadToken checks the keychain first, then the fallback file.
func LoadToken() (string, error) {
	if t, err := keyring.Get(service, account); err == nil && t != "" {
		return t, nil
	}
	return loadTokenFile()
}

Run: go test ./internal/auth/ -v. Expected: PASS.

package cli

import (
	"bufio"
	"fmt"
	"os"
	"os/exec"
	"runtime"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"github.com/humdrum-tiv/dots-cli/internal/api"
	"github.com/humdrum-tiv/dots-cli/internal/auth"
	"github.com/humdrum-tiv/dots-cli/internal/config"
)

// RequireClient loads the stored token and returns a ready API client.
func RequireClient() (*api.Client, error) {
	token, err := auth.LoadToken()
	if err != nil {
		return nil, fmt.Errorf("not linked — run `dots auth`")
	}
	return api.New(config.BaseURL(), token), nil
}

// Today returns the current date (YYYY-MM-DD) in the device timezone.
func Today() string {
	return time.Now().Format("2006-01-02")
}

// TZ returns the device IANA timezone name.
func TZ() string {
	zone, _ := time.Now().Zone()
	if loc := time.Local.String(); loc != "Local" {
		return loc
	}
	return zone
}

func openBrowser(url string) {
	if runtime.GOOS == "darwin" {
		exec.Command("open", url).Start()
	} else {
		exec.Command("xdg-open", url).Start()
	}
}

func NewAuthCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "auth",
		Short: "Link this machine to your Dots account",
		RunE: func(cmd *cobra.Command, args []string) error {
			pageURL := config.BaseURL() + "/cli"
			fmt.Printf("Opening %s — generate a token there, then paste it here.\n", pageURL)
			openBrowser(pageURL)
			fmt.Print("Token: ")
			reader := bufio.NewReader(os.Stdin)
			line, err := reader.ReadString('\n')
			if err != nil {
				return fmt.Errorf("no token entered")
			}
			token := strings.TrimSpace(line)
			if token == "" {
				return fmt.Errorf("no token entered")
			}
			client := api.New(config.BaseURL(), token)
			if _, err := client.GetDay(Today(), TZ()); err != nil {
				return fmt.Errorf("token check failed: %w", err)
			}
			if err := auth.SaveToken(token); err != nil {
				return fmt.Errorf("could not store token: %w", err)
			}
			fmt.Println("Linked. Try `dots today`.")
			return nil
		},
	}
}

In cmd/dots/main.go, inside newRootCmd, add:

root.AddCommand(cli.NewAuthCmd())

with import "github.com/humdrum-tiv/dots-cli/internal/cli".

Run: go build ./... && go test ./.... Expected: clean, PASS.

jj commit -m "feat: token storage (keychain + file fallback) and dots auth command"

Task 10: log, clear, note commands with confirmation gate

Files:

Interfaces:

internal/cli/confirm_test.go:

package cli

import (
	"strings"
	"testing"

	"github.com/humdrum-tiv/dots-cli/internal/day"
)

func sp(s string) *string { return &s }

func TestFormatChanges(t *testing.T) {
	names := map[string]string{"t1": "archer", "t2": "foofaraw"}
	changes := []day.Change{
		{Index: 29, FromID: sp("t1"), ToID: sp("t2")},
		{Index: 30, FromID: sp("t1"), ToID: nil},
	}
	out := FormatChanges(changes, names, 30)
	if !strings.Contains(out, "14:30") || !strings.Contains(out, "archer → foofaraw") {
		t.Fatalf("out %q", out)
	}
	if !strings.Contains(out, "15:00") || !strings.Contains(out, "archer → (cleared)") {
		t.Fatalf("out %q", out)
	}
}

func TestConfirm(t *testing.T) {
	// --yes bypasses everything
	ok, err := Confirm("overwrite?", true, strings.NewReader(""), &strings.Builder{}, false)
	if err != nil || !ok {
		t.Fatalf("yes flag: ok=%v err=%v", ok, err)
	}
	// non-TTY without --yes = hard error
	if _, err := Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, false); err == nil {
		t.Fatal("non-TTY without --yes must error")
	}
	// TTY: y accepts, anything else declines
	ok, _ = Confirm("overwrite?", false, strings.NewReader("y\n"), &strings.Builder{}, true)
	if !ok {
		t.Fatal("y should accept")
	}
	ok, _ = Confirm("overwrite?", false, strings.NewReader("n\n"), &strings.Builder{}, true)
	if ok {
		t.Fatal("n should decline")
	}
	ok, _ = Confirm("overwrite?", false, strings.NewReader("\n"), &strings.Builder{}, true)
	if ok {
		t.Fatal("empty should decline (default N)")
	}
}

Run: go test ./internal/cli/ -v. Expected: FAIL.

package cli

import (
	"bufio"
	"fmt"
	"io"
	"strings"

	"github.com/humdrum-tiv/dots-cli/internal/day"
)

// FormatChanges renders the pre-write diff table shown before confirmation.
func FormatChanges(changes []day.Change, typeNames map[string]string, blockSize int) string {
	name := func(id *string) string {
		if id == nil {
			return "(cleared)"
		}
		if n, ok := typeNames[*id]; ok {
			return n
		}
		return *id
	}
	var b strings.Builder
	for _, c := range changes {
		from := "(empty)"
		if c.FromID != nil {
			from = name(c.FromID)
		}
		fmt.Fprintf(&b, "  %-6s %s → %s\n", day.SlotLabel(c.Index, blockSize), from, name(c.ToID))
	}
	return b.String()
}

// Confirm enforces the no-silent-overwrite rule: --yes bypasses, a real TTY
// prompts y/N (default N), and non-TTY without --yes refuses outright.
func Confirm(prompt string, yes bool, in io.Reader, out io.Writer, isTTY bool) (bool, error) {
	if yes {
		return true, nil
	}
	if !isTTY {
		return false, fmt.Errorf("would change existing blocks — re-run with --yes (non-interactive)")
	}
	fmt.Fprintf(out, "%s [y/N] ", prompt)
	line, err := bufio.NewReader(in).ReadString('\n')
	if err != nil {
		return false, nil
	}
	return strings.TrimSpace(strings.ToLower(line)) == "y", nil
}

Run: go test ./internal/cli/ -v. Expected: PASS.

package cli

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"golang.org/x/term"

	"github.com/humdrum-tiv/dots-cli/internal/day"
)

func stdinIsTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) }

func typeNames(types []day.ActivityType) map[string]string {
	m := make(map[string]string, len(types))
	for _, t := range types {
		m[t.ID] = t.Name
	}
	return m
}

// pickActivity resolves a query to exactly one type or errors.
func pickActivity(query string, types []day.ActivityType) (*day.ActivityType, error) {
	matches := day.MatchActivity(query, types)
	switch len(matches) {
	case 1:
		return &matches[0], nil
	case 0:
		names := make([]string, len(types))
		for i, t := range types {
			names[i] = t.Name
		}
		return nil, fmt.Errorf("no activity matches %q — have: %v", query, names)
	default:
		if !stdinIsTTY() {
			return nil, fmt.Errorf("%q is ambiguous: %v", query, namesOf(matches))
		}
		fmt.Println("Which one?")
		for i, m := range matches {
			fmt.Printf("  %d) %s\n", i+1, m.Name)
		}
		fmt.Print("> ")
		var n int
		if _, err := fmt.Scanln(&n); err != nil || n < 1 || n > len(matches) {
			return nil, fmt.Errorf("no selection")
		}
		return &matches[n-1], nil
	}
}

func namesOf(ts []day.ActivityType) []string {
	out := make([]string, len(ts))
	for i, t := range ts {
		out[i] = t.Name
	}
	return out
}

func NewLogCmd() *cobra.Command {
	var date string
	var yes bool
	cmd := &cobra.Command{
		Use:   "log [time|range] [activity]",
		Short: "Paint logged block(s); no activity = fill from plan",
		Args:  cobra.MaximumNArgs(2),
		RunE: func(cmd *cobra.Command, args []string) error {
			client, err := RequireClient()
			if err != nil {
				return err
			}
			// Positional parsing: first arg is a time only if it looks like one.
			target, activityQuery := "", ""
			switch len(args) {
			case 1:
				if day.IsTimeSyntax(args[0]) {
					target = args[0]
				} else {
					activityQuery = args[0]
				}
			case 2:
				if !day.IsTimeSyntax(args[0]) {
					return fmt.Errorf("first argument %q is not a time — usage: dots log [time|range] [activity]", args[0])
				}
				target, activityQuery = args[0], args[1]
			}

			if date == "" {
				date = Today()
			}
			d, err := client.GetDay(date, TZ())
			if err != nil {
				return err
			}
			targets, err := day.ParseTarget(target, d.BlockSize, timeNow())
			if err != nil {
				return err
			}

			var activityID *string
			if activityQuery != "" {
				t, err := pickActivity(activityQuery, d.Types)
				if err != nil {
					return err
				}
				activityID = &t.ID
			}

			assignments, overwrites, err := day.ResolveLog(targets, activityID, d.Blocks, d.PlanBlocks, d.BlockSize)
			if err != nil {
				return err
			}
			if len(overwrites) > 0 {
				fmt.Print(FormatChanges(overwrites, typeNames(d.Types), d.BlockSize))
				ok, err := Confirm("Overwrite?", yes, os.Stdin, os.Stdout, stdinIsTTY())
				if err != nil {
					return err
				}
				if !ok {
					fmt.Println("Aborted, nothing written.")
					return nil
				}
			}
			if err := client.WriteBlocks(date, assignments); err != nil {
				return err
			}
			fmt.Printf("Logged %d block(s).\n", len(assignments))
			return nil
		},
	}
	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
	return cmd
}

func NewClearCmd() *cobra.Command {
	var date string
	var yes bool
	cmd := &cobra.Command{
		Use:   "clear <time|range>",
		Short: "Clear logged block(s)",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			client, err := RequireClient()
			if err != nil {
				return err
			}
			if !day.IsTimeSyntax(args[0]) {
				return fmt.Errorf("%q is not a time or range", args[0])
			}
			if date == "" {
				date = Today()
			}
			d, err := client.GetDay(date, TZ())
			if err != nil {
				return err
			}
			targets, err := day.ParseTarget(args[0], d.BlockSize, timeNow())
			if err != nil {
				return err
			}
			assignments, cleared := day.ResolveClear(targets, d.Blocks)
			if len(cleared) == 0 {
				fmt.Println("Nothing logged there.")
				return nil
			}
			fmt.Print(FormatChanges(cleared, typeNames(d.Types), d.BlockSize))
			ok, err := Confirm("Clear?", yes, os.Stdin, os.Stdout, stdinIsTTY())
			if err != nil {
				return err
			}
			if !ok {
				fmt.Println("Aborted, nothing cleared.")
				return nil
			}
			if err := client.WriteBlocks(date, assignments); err != nil {
				return err
			}
			fmt.Printf("Cleared %d block(s).\n", len(cleared))
			return nil
		},
	}
	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
	cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
	return cmd
}

Add to the same file a seam for tests and DST-safe "now":

import "time"

// timeNow is a seam so command tests can pin the clock.
var timeNow = func() time.Time { return time.Now() }

(Fold the time import into the existing import block.)

package cli

import (
	"fmt"
	"strings"

	"github.com/spf13/cobra"
)

func NewNoteCmd() *cobra.Command {
	var date string
	cmd := &cobra.Command{
		Use:   "note <text>",
		Short: "Append a note to the day",
		Args:  cobra.MinimumNArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			client, err := RequireClient()
			if err != nil {
				return err
			}
			if date == "" {
				date = Today()
			}
			content := strings.TrimSpace(strings.Join(args, " "))
			if content == "" {
				return fmt.Errorf("empty note")
			}
			if err := client.AddNote(date, content); err != nil {
				return err
			}
			fmt.Println("Noted.")
			return nil
		},
	}
	cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
	return cmd
}
root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())

Run: go build ./... && go test ./.... Expected: clean, PASS.

jj commit -m "feat: log/clear/note commands with overwrite confirmation gate"

Task 11: today + types commands, live E2E, wrap-up

Files:

Interfaces:

internal/render/grid_test.go:

package render

import (
	"strings"
	"testing"

	"github.com/humdrum-tiv/dots-cli/internal/api"
	"github.com/humdrum-tiv/dots-cli/internal/day"
)

func TestGridStructure(t *testing.T) {
	d := &api.DayData{
		BlockSize: 30,
		Types: []day.ActivityType{
			{ID: "t1", Name: "archer", Color: "#c04000"},
		},
		Blocks:     map[int]string{28: "t1"},
		PlanBlocks: map[int]string{29: "t1"},
	}
	out := Grid(d, 29)
	// 48 slots at blockSize 30 → 6 rows of 8 (4h per row), each labeled with its start hour.
	for _, label := range []string{"0:00", "4:00", "8:00", "12:00", "16:00", "20:00"} {
		if !strings.Contains(out, label) {
			t.Fatalf("missing row label %s in:\n%s", label, out)
		}
	}
	if !strings.Contains(out, "archer") {
		t.Fatalf("missing legend entry:\n%s", out)
	}
}

Run: go test ./internal/render/ -v. Expected: FAIL.

package render

import (
	"fmt"
	"strings"

	"github.com/charmbracelet/lipgloss"

	"github.com/humdrum-tiv/dots-cli/internal/api"
	"github.com/humdrum-tiv/dots-cli/internal/day"
)

// Grid renders the day as rows of colored dots with a legend.
// Logged = ● in type color; planned-only = ○ in type color; empty = dim ·.
// currentIndex (−1 to disable) gets an underline marker.
func Grid(d *api.DayData, currentIndex int) string {
	blocksPerDay := 1440 / d.BlockSize
	perRow := blocksPerDay / 6 // 6 rows → 4h per row at any blockSize

	colorOf := make(map[string]string, len(d.Types))
	for _, t := range d.Types {
		colorOf[t.ID] = t.Color
	}
	dim := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))

	var b strings.Builder
	for row := 0; row < 6; row++ {
		start := row * perRow
		b.WriteString(fmt.Sprintf("%-6s", day.SlotLabel(start, d.BlockSize)))
		for i := start; i < start+perRow; i++ {
			var cell string
			if id, ok := d.Blocks[i]; ok {
				cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("●")
			} else if id, ok := d.PlanBlocks[i]; ok {
				cell = lipgloss.NewStyle().Foreground(lipgloss.Color(colorOf[id])).Render("○")
			} else {
				cell = dim.Render("·")
			}
			if i == currentIndex {
				cell = lipgloss.NewStyle().Underline(true).Render(cell)
			}
			b.WriteString(cell + " ")
		}
		b.WriteString("\n")
	}

	b.WriteString("\n")
	for _, t := range d.Types {
		sw := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Color)).Render("●")
		b.WriteString(fmt.Sprintf("%s %s   ", sw, t.Name))
	}
	b.WriteString("\n")
	return b.String()
}

Run: go test ./internal/render/ -v. Expected: PASS. (lipgloss emits no escapes when output isn't a TTY profile, so the structural assertions hold either way.)

today.go:

package cli

import (
	"encoding/json"
	"fmt"
	"os"

	"github.com/spf13/cobra"
	"golang.org/x/term"

	"github.com/humdrum-tiv/dots-cli/internal/render"
)

func NewTodayCmd() *cobra.Command {
	var date string
	var asJSON bool
	cmd := &cobra.Command{
		Use:   "today",
		Short: "Show the day grid",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			client, err := RequireClient()
			if err != nil {
				return err
			}
			isToday := date == ""
			if isToday {
				date = Today()
			}
			d, err := client.GetDay(date, TZ())
			if err != nil {
				return err
			}
			if asJSON || !term.IsTerminal(int(os.Stdout.Fd())) {
				return json.NewEncoder(os.Stdout).Encode(map[string]any{
					"date": date, "blockSize": d.BlockSize,
					"blocks": d.Blocks, "planBlocks": d.PlanBlocks, "types": d.Types,
				})
			}
			current := -1
			if isToday {
				now := timeNow()
				current = (now.Hour()*60 + now.Minute()) / d.BlockSize
			}
			fmt.Printf("%s\n\n", date)
			fmt.Print(render.Grid(d, current))
			return nil
		},
	}
	cmd.Flags().StringVar(&date, "date", "", "day to show (YYYY-MM-DD, default today)")
	cmd.Flags().BoolVar(&asJSON, "json", false, "raw JSON output")
	return cmd
}

types.go:

package cli

import (
	"fmt"

	"github.com/charmbracelet/lipgloss"
	"github.com/spf13/cobra"
)

func NewTypesCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "types",
		Short: "List active activity types",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			client, err := RequireClient()
			if err != nil {
				return err
			}
			d, err := client.GetDay(Today(), TZ())
			if err != nil {
				return err
			}
			for _, t := range d.Types {
				sw := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Color)).Render("●")
				fmt.Printf("%s %s\n", sw, t.Name)
			}
			return nil
		},
	}
}

Register both in cmd/dots/main.go:

root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd(), cli.NewTodayCmd(), cli.NewTypesCmd())

Run: go build ./... && go test ./.... Expected: clean, PASS.

Write README.md: one-paragraph description, install (go install github.com/humdrum-tiv/dots-cli/cmd/dots@latest or go build -o ~/bin/dots ./cmd/dots), the command table from the spec, the auth flow (browser → /cli → paste), DOTS_URL note. Point at the spec for details.

jj commit -m "feat: today grid + types commands, README"

Preconditions (user-owned, flag if missing): Dots PR from Task 3 merged & deployed, cli_tokens migration applied in Supabase.

go build -o /tmp/dots ./cmd/dots
/tmp/dots auth                      # browser flow, paste token
/tmp/dots types                     # lists real types
/tmp/dots today                     # renders grid
/tmp/dots log 3:00 <some-type>      # paint an empty overnight slot
/tmp/dots today                     # verify dot appears
/tmp/dots log 3:00 <other-type>     # must show diff table + prompt; answer n → verify unchanged
/tmp/dots clear 3:00 -y             # cleanup without prompt
/tmp/dots note "cli e2e test"      # then delete the note in the web UI

Verify each step's output; then check the web app shows/loses the 3:00 dot accordingly. Report results; do not mark this step complete on partial success.

backlog init "dots-cli" --agent-instructions claude --integration-mode cli --task-prefix TASK --zero-padded-ids 3
backlog config set remoteOperations false

Then edit backlog/config.yml: default_status: "🟦 Backlog", statuses: ["🟦 Backlog", "🟢 In progress", "🚧 Paused", "🏁 Done"]. Create GitHub repo + push: gh repo create humdrum-tiv/dots-cli --private --source . --push (jj: jj git push --allow-new after adding the remote if needed). Commit any backlog files: jj commit -m "chore: backlog init".


Self-review notes