▍ humdrum codex / dots v1.0.0

feat: today grid + types commands, README

800b25a17a8f7a818405fade10f830ace6ec1270
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 17:33

parent 76e77ecf

6 files changed

README.md +63 −0
@@ -0,0 +1,63 @@
+# dots-cli
+
+A command-line client for [Dots](https://dots.humdrum.one) optimized for reflex-speed logging from the terminal: paint a block, add a day note, glance at today — faster than reaching for the phone. Third client after web and iOS; rides the same API surface. Not an offline client — every command is one or two HTTP calls against the production API. If the network is down the command fails fast.
+
+## Installation
+
+Clone this repository and install:
+
+```bash
+go install github.com/humdrum-tiv/dots-cli/cmd/dots@latest
+```
+
+Or build directly to your PATH:
+
+```bash
+go build -o ~/bin/dots ./cmd/dots
+```
+
+## Commands
+
+```
+dots auth [--yes]              Link CLI to account (one-time token paste)
+dots log [time|range] [type]   Paint logged block(s)
+dots clear <time|range>        Clear logged block(s)
+dots note "text" [--date D]    Append a day note
+dots today [--date D] [--json] Day grid readout
+dots types                     List active activity types
+```
+
+### Time syntax
+
+Blocks are `blockSize`-minute slots (15, 30, or 60). Accepted time forms (examples at blockSize 30):
+
+- *(omitted)* — the slot containing the current device time
+- `14:30` — the single slot containing that time; times snap down to the slot boundary
+- `13:00-15:00` — range, end-exclusive: slots 13:00, 13:30, 14:00, 14:30
+
+### Auth flow
+
+1. Run `dots auth` (opens `https://dots.humdrum.one/cli` in your browser)
+2. Copy your CLI token from the page
+3. Paste it in the terminal prompt
+4. Token is stored securely in macOS Keychain (or `~/.config/dots/token` on other platforms)
+
+All commands require an active auth token. Requests send `Authorization: Bearer <token>`.
+
+### Environment variables
+
+- `DOTS_URL` — override the server URL (defaults to `https://dots.humdrum.one`; use for dev/testing)
+
+## Details
+
+Full specification, auth details, logging semantics, activity matching, and CLI internals: see [`docs/superpowers/specs/2026-07-29-dots-cli-design.md`](docs/superpowers/specs/2026-07-29-dots-cli-design.md).
+
+## Development
+
+```bash
+go build ./...          # compile all packages
+go test ./...           # run all tests
+go run ./cmd/dots       # run the CLI
+```
+
+Written in Go 1.23+ with cobra, lipgloss, and zalando/go-keyring.
cmd/dots/main.go +1 −1
@@ -16,7 +16,7 @@ 		Short:         "Log your day from the terminal",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 	}
-	root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())
+	root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd(), cli.NewTodayCmd(), cli.NewTypesCmd())
 	return root
 }
 
internal/cli/today.go +53 −0
@@ -0,0 +1,53 @@
+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
+}
internal/cli/types.go +31 −0
@@ -0,0 +1,31 @@
+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
+		},
+	}
+}
internal/render/grid.go +54 −0
@@ -0,0 +1,54 @@
+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()
+}
internal/render/grid_test.go +30 −0
@@ -0,0 +1,30 @@
+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)
+	}
+}