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