# 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 - Spec: `docs/superpowers/specs/2026-07-29-dots-cli-design.md` — binding. - **Both repos are jj-colocated. Commit with `jj commit -m "..."`, never `git commit`. Never use interactive jj flags.** - Server repo: `/Users/kortum/Developer/Humdrum-One/Dots` (github `humdrum-tiv/dots`). CLI repo: `/Users/kortum/Developer/Humdrum-One/dots-cli`. - Server repo has NO test runner. Server tasks verify with `npx tsc --noEmit` (and `npm run build` where stated). Do not add a test framework. - CLI repo: table-driven Go tests; `go test ./...` must pass at the end of every task. - **No silent overwrite:** any write that changes or removes an existing logged block prints a diff table and requires `y` confirmation or `--yes`. Non-TTY without `--yes` = abort, exit 1. - `blockIndex = minutesSinceMidnight / day.blockSize`; blockSize ∈ {15, 30, 60}, read from the day payload. Never hardcode 30. - Server URL: `https://dots.humdrum.one`, overridable via `DOTS_URL` env var. - Go module path: `github.com/humdrum-tiv/dots-cli`. - Errors: one line to stderr, exit code 1. No retries. --- ## 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:** - Create: `supabase/migrations/20260729000000_cli_tokens.sql` - Create: `src/lib/db/cliTokens.ts` - Modify: `src/types/index.ts` (append near `WidgetTokenRow`, ~line 532) **Interfaces:** - Produces: `getCliToken(userId): Promise`, `generateCliToken(userId): Promise`, `revokeCliToken(userId): Promise`, `getUserIdByCliToken(token): Promise` — exact mirrors of the widget-token functions in `src/lib/db/widgetTokens.ts`. - [ ] **Step 1: Write the migration** ```sql -- 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); ``` - [ ] **Step 2: Add row/domain types** In `src/types/index.ts`, directly after the `WidgetToken` interface (search for `export interface WidgetTokenRow`), append: ```ts 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.) - [ ] **Step 3: Write `src/lib/db/cliTokens.ts`** Copy `src/lib/db/widgetTokens.ts` wholesale, then rename: `widget_tokens` → `cli_tokens`, `WidgetToken` → `CliToken`, `WidgetTokenRow` → `CliTokenRow`, `getWidgetToken` → `getCliToken`, `generateWidgetToken` → `generateCliToken`, `revokeWidgetToken` → `revokeCliToken`, `getUserIdByWidgetToken` → `getUserIdByCliToken`. Same `randomBytes(32).toString("hex")` token, same delete-then-insert replace behavior. - [ ] **Step 4: Typecheck** Run: `npx tsc --noEmit` (repo root). Expected: clean. - [ ] **Step 5: Commit** ```bash 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:** - Create: `src/lib/apiAuth.ts` - Modify: `src/app/api/widget/data/route.ts` (top ~25 lines) - Modify: `src/app/api/days/[date]/blocks/route.ts` (PUT only) - Modify: `src/app/api/days/[date]/notes/route.ts` (POST only) **Interfaces:** - Consumes: `getUserIdByCliToken` (Task 1), `getUserIdByWidgetToken` (existing). - Produces: `resolveUserId(request: Request): Promise` — Clerk session first, else CLI bearer token. `resolveSubscribedUserId(request: Request): Promise` — same, but also requires `publicMetadata.subscribed === true` (via `currentUser()` on the Clerk path, `clerkClient().users.getUser(userId)` on the token path). - [ ] **Step 1: Write `src/lib/apiAuth.ts`** ```ts 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 { 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 { 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.) - [ ] **Step 2: Widen `GET /api/widget/data` to accept CLI tokens** 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: ```ts 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. - [ ] **Step 3: Token-auth on blocks PUT** In `src/app/api/days/[date]/blocks/route.ts`, in `PUT` only, replace: ```ts const { userId } = await auth(); if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); ``` with: ```ts 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. - [ ] **Step 4: Token-auth on notes POST** In `src/app/api/days/[date]/notes/route.ts`, in `POST` only, replace `const userId = await getSubscribedUserId();` with: ```ts const userId = await resolveSubscribedUserId(request); ``` Add `import { resolveSubscribedUserId } from "@/lib/apiAuth";`. Leave `GET` on `getSubscribedUserId()`. - [ ] **Step 5: Typecheck + commit** Run: `npx tsc --noEmit`. Expected: clean. ```bash 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:** - Create: `src/app/api/cli/token/route.ts` - Create: `src/app/cli/page.tsx` - Create: `src/components/settings/CliTokenSettings.tsx` **Interfaces:** - Consumes: Task 1 lib functions. - Produces: `GET/POST/DELETE /api/cli/token` (Clerk-session-only, JSON `{ token }`), page at `/cli`. - [ ] **Step 1: Write `src/app/api/cli/token/route.ts`** 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. - [ ] **Step 2: Write the settings component + page** 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 `` with a page title "CLI access". If Clerk middleware protects pages by default, no extra guard code is needed — confirm against `src/middleware.ts`. - [ ] **Step 3: Typecheck + build** Run: `npx tsc --noEmit` then `npm run build`. Expected: both clean. - [ ] **Step 4: Commit, push, PR** ```bash 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:** - Create: `go.mod`, `cmd/dots/main.go`, `internal/config/config.go`, `.gitignore` **Interfaces:** - Produces: `config.BaseURL() string` (env `DOTS_URL` else `https://dots.humdrum.one`), cobra root command `dots` that later tasks hang subcommands on via `root.AddCommand`. `main.go` exposes `func newRootCmd() *cobra.Command` (in package main) so tests can exercise wiring. - [ ] **Step 1: Init module + deps** ```bash 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 ``` - [ ] **Step 2: Write `internal/config/config.go`** ```go 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 } ``` - [ ] **Step 3: Write `cmd/dots/main.go`** ```go 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) } } ``` - [ ] **Step 4: `.gitignore`** ``` dots dist/ ``` - [ ] **Step 5: Verify + commit** Run: `go build ./... && go run ./cmd/dots --help`. Expected: help text prints. ```bash jj commit -m "feat: scaffold go module, config, cobra root" ``` ### Task 5: slot math (`internal/day`) **Files:** - Create: `internal/day/slots.go`, `internal/day/slots_test.go` **Interfaces:** - Produces (package `day`): - `ParseTarget(arg string, blockSize int, now time.Time) ([]int, error)` — arg may be `""` (current slot), `"H:MM"`/`"HH:MM"`, or `"start-end"`; returns canonical block indices for the day's blockSize. - `IsTimeSyntax(arg string) bool` — whether a positional arg should be treated as time/range rather than activity. - `SlotLabel(index, blockSize int) string` — `"14:30"` style label for output. - [ ] **Step 1: Write failing tests** `internal/day/slots_test.go`: ```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) } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/day/ -v`. Expected: FAIL (undefined functions). - [ ] **Step 3: Implement `internal/day/slots.go`** ```go 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) } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/day/ -v`. Expected: PASS. - [ ] **Step 5: Commit** ```bash jj commit -m "feat: slot math — target parsing, time syntax detection, labels" ``` ### Task 6: activity fuzzy matching (`internal/day`) **Files:** - Create: `internal/day/match.go`, `internal/day/match_test.go` **Interfaces:** - Consumes: nothing from other tasks (pure). - Produces: `type ActivityType struct { ID, Name, Color string }` (package `day`; the API client in Task 8 returns this type) and `MatchActivity(query string, types []ActivityType) (matches []ActivityType)` — case-insensitive; exact match wins outright (len 1), else all prefix matches, else all substring matches, else empty. Caller decides what to do with 0/1/many. - [ ] **Step 1: Write failing tests** `internal/day/match_test.go`: ```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) } } } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/day/ -run TestMatchActivity -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/day/match.go`** ```go 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 } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/day/ -v`. Expected: PASS. - [ ] **Step 5: Commit** ```bash jj commit -m "feat: activity fuzzy matching (exact > prefix > substring)" ``` ### Task 7: plan-fill + overwrite diff (`internal/day`) **Files:** - Create: `internal/day/resolve.go`, `internal/day/resolve_test.go` **Interfaces:** - Consumes: `SlotLabel` (Task 5). - Produces (package `day`): - `type Change struct { Index int; FromID, ToID *string }` — nil = empty/cleared. - `ResolveLog(targets []int, activityID *string, blocks, planBlocks map[int]string) (assignments map[int]*string, overwrites []Change, err error)` — activityID nil ⇒ plan-fill; error `ErrNoPlan` variant listing slots with no plan block (all-or-nothing). - `ResolveClear(targets []int, blocks map[int]string) (assignments map[int]*string, cleared []Change)` — only already-logged slots are cleared (assignments contains nil for each logged target; empty targets are no-ops). - Maps use only logged/planned entries: index present ⇒ non-empty activityTypeId. The API client (Task 8) delivers this shape. - [ ] **Step 1: Write failing tests** `internal/day/resolve_test.go`: ```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)`.) - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/day/ -run TestResolve -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/day/resolve.go`** ```go 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 } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/day/ -v`. Expected: PASS (all of slots, match, resolve). - [ ] **Step 5: Commit** ```bash jj commit -m "feat: plan-fill resolution and overwrite/clear diff computation" ``` ### Task 8: API client (`internal/api`) **Files:** - Create: `internal/api/client.go`, `internal/api/client_test.go` **Interfaces:** - Consumes: `day.ActivityType` (Task 6). - Produces (package `api`): - `type Client struct { BaseURL, Token string; HTTP *http.Client }` and `New(baseURL, token string) *Client`. - `type DayData struct { BlockSize int; Timezone string; Types []day.ActivityType; Blocks, PlanBlocks map[int]string }` - `(*Client) GetDay(date, tz string) (*DayData, error)` — `GET /api/widget/data?date=...&tz=...`. - `(*Client) WriteBlocks(date string, assignments map[int]*string) error` — `PUT /api/days/{date}/blocks`, JSON body `{"29":"typeid","30":null}`. - `(*Client) AddNote(date, content string) error` — `POST /api/days/{date}/notes`, body `{"content":"..."}`. - `ErrUnauthorized` sentinel (401) — commands map it to "not linked — run `dots auth`". 403 on notes maps to its own message ("subscription required"). - [ ] **Step 1: Write failing tests** `internal/api/client_test.go` — httptest server with a fixture mirroring the real widget/data shape: ```go 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) } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/api/ -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/api/client.go`** ```go 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 } ``` - [ ] **Step 4: Run tests** Run: `go test ./... `. Expected: PASS. - [ ] **Step 5: Commit** ```bash jj commit -m "feat: API client — GetDay, WriteBlocks, AddNote over widget/data + day routes" ``` ### Task 9: token storage + `dots auth` (`internal/auth`) **Files:** - Create: `internal/auth/store.go`, `internal/auth/store_test.go`, `internal/cli/auth.go` - Modify: `cmd/dots/main.go` (register command) **Interfaces:** - Consumes: `api.New(...).GetDay(...)` (Task 8) for verification, `config.BaseURL()` (Task 4). - Produces (package `auth`): `SaveToken(token string) error`, `LoadToken() (string, error)` — keychain service `"dots-cli"`, account `"token"`; on keyring error, fallback file `~/.config/dots/token` (0600). `ErrNoToken` when nothing stored. - Produces (package `cli`): `NewAuthCmd() *cobra.Command`, plus helpers used by every later command: `RequireClient() (*api.Client, error)` (loads token, returns "not linked — run `dots auth`" error if missing) and `Today(tz *time.Location) string` date helper. Also `NewRootWiring` note: `cmd/dots/main.go` gains `root.AddCommand(cli.NewAuthCmd())`. - [ ] **Step 1: Write failing tests (file fallback only — keychain is not unit-testable)** `internal/auth/store_test.go`: ```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") } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/auth/ -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/auth/store.go`** ```go 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() } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/auth/ -v`. Expected: PASS. - [ ] **Step 5: Implement `internal/cli/auth.go` + shared helpers** ```go 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: ```go root.AddCommand(cli.NewAuthCmd()) ``` with import `"github.com/humdrum-tiv/dots-cli/internal/cli"`. - [ ] **Step 6: Build + full tests + commit** Run: `go build ./... && go test ./...`. Expected: clean, PASS. ```bash jj commit -m "feat: token storage (keychain + file fallback) and dots auth command" ``` ### Task 10: `log`, `clear`, `note` commands with confirmation gate **Files:** - Create: `internal/cli/confirm.go`, `internal/cli/confirm_test.go`, `internal/cli/log.go`, `internal/cli/note.go` - Modify: `cmd/dots/main.go` (register commands) **Interfaces:** - Consumes: everything from Tasks 5–9. - Produces: `NewLogCmd()`, `NewClearCmd()`, `NewNoteCmd()` (package `cli`); `FormatChanges(changes []day.Change, typesByID map[string]string, blockSize int) string` and `Confirm(prompt string, yes bool, in io.Reader, out io.Writer, isTTY bool) (bool, error)` — pure, tested. - [ ] **Step 1: Write failing tests for the confirmation pieces** `internal/cli/confirm_test.go`: ```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)") } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/cli/ -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/cli/confirm.go`** ```go 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 } ``` - [ ] **Step 4: Run tests** Run: `go test ./internal/cli/ -v`. Expected: PASS. - [ ] **Step 5: Implement `internal/cli/log.go` (log + clear)** ```go 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 ", 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": ```go 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.) - [ ] **Step 6: Implement `internal/cli/note.go`** ```go package cli import ( "fmt" "strings" "github.com/spf13/cobra" ) func NewNoteCmd() *cobra.Command { var date string cmd := &cobra.Command{ Use: "note ", 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 } ``` - [ ] **Step 7: Register in `cmd/dots/main.go`** ```go root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd()) ``` - [ ] **Step 8: Build + tests + commit** Run: `go build ./... && go test ./...`. Expected: clean, PASS. ```bash jj commit -m "feat: log/clear/note commands with overwrite confirmation gate" ``` ### Task 11: `today` + `types` commands, live E2E, wrap-up **Files:** - Create: `internal/render/grid.go`, `internal/render/grid_test.go`, `internal/cli/today.go`, `internal/cli/types.go` - Modify: `cmd/dots/main.go`, `README.md` (create) **Interfaces:** - Consumes: `api.DayData` (Task 8), `day.SlotLabel` (Task 5). - Produces: `render.Grid(d *api.DayData, currentIndex int) string` — lipgloss truecolor grid + legend; `currentIndex` −1 for non-today. `NewTodayCmd()`, `NewTypesCmd()`. - [ ] **Step 1: Write failing render test (structure, not colors)** `internal/render/grid_test.go`: ```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) } } ``` - [ ] **Step 2: Run to verify failure** Run: `go test ./internal/render/ -v`. Expected: FAIL. - [ ] **Step 3: Implement `internal/render/grid.go`** ```go 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() } ``` - [ ] **Step 4: Run tests** 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.) - [ ] **Step 5: Implement `internal/cli/today.go` and `internal/cli/types.go`** `today.go`: ```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`: ```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`: ```go root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd(), cli.NewTodayCmd(), cli.NewTypesCmd()) ``` - [ ] **Step 6: Full build + tests** Run: `go build ./... && go test ./...`. Expected: clean, PASS. - [ ] **Step 7: README** 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. - [ ] **Step 8: Commit** ```bash jj commit -m "feat: today grid + types commands, README" ``` - [ ] **Step 9: Live E2E (requires server PR merged + migration applied)** Preconditions (user-owned, flag if missing): Dots PR from Task 3 merged & deployed, `cli_tokens` migration applied in Supabase. ```bash 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 # paint an empty overnight slot /tmp/dots today # verify dot appears /tmp/dots log 3:00 # 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. - [ ] **Step 10: Repo hygiene** ```bash 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 - Spec coverage: auth (T3/T9), log+plan-fill+overwrite rule (T5/T7/T10), clear (T7/T10), note (T10), today+json (T11), types (T11), server changes (T1–T3), keychain (T9), errors/exit codes (client `do()` + cobra SilenceUsage), testing strategy (per task), release (T11 README + repo hygiene). Out-of-scope list respected — no retries, no TUI, no type creation. - Types consistent across tasks: `day.ActivityType`, `day.Change`, `api.DayData`, `ResolveLog(..., blockSize int)` five-param form is the binding signature. - Known judgment calls an implementer may hit: exact Clerk `clerkClient()` call shape (T2 notes it), lipgloss version API drift (if `lipgloss.Color` differs, adapt — structure tests don't assert escapes), widget/data `planBlocks` may be absent for non-subscribers (`.catch(() => ({}))` server-side — treat missing map as empty).