docs: spec blockSize amendment + v1 implementation plan (11 tasks)
0e81826e8a1a7b970964665f843f0dea85ab2a89
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 15:42
parent 667fea5d
2 files changed
docs/superpowers/plans/2026-07-29-dots-cli-v1.md +1952 −0
@@ -0,0 +1,1952 @@
+# 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<CliToken | null>`, `generateCliToken(userId): Promise<CliToken>`, `revokeCliToken(userId): Promise<void>`, `getUserIdByCliToken(token): Promise<string | null>` — 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<string | null>` — Clerk session first, else CLI bearer token. `resolveSubscribedUserId(request: Request): Promise<string | null>` — 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<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.)
+
+- [ ] **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 `<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`.
+
+- [ ] **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 <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":
+
+```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 <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
+}
+```
+
+- [ ] **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 <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.
+
+- [ ] **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).
docs/superpowers/specs/2026-07-29-dots-cli-design.md +6 −2
@@ -31,12 +31,16 @@ (dev/testing; defaults to `https://dots.humdrum.one`).
### Time syntax
-Dots blocks are 30-minute slots. Accepted forms:
+Dots blocks are `blockSize`-minute slots, where `blockSize` is a per-day setting of
+15, 30, or 60 (`blockIndex = minutesSinceMidnight / blockSize`). The CLI reads
+`day.blockSize` from the day payload and does all slot math against it. Accepted forms
+(examples at blockSize 30):
- *(omitted)* — the slot containing the current device time
- `14:30` (also `9:00`, `09:00`) — the single slot containing that time; times snap
down to the slot boundary (`14:47` → `14:30`)
-- `13:00-15:00` — range, end-exclusive: slots 13:00, 13:30, 14:00, 14:30
+- `13:00-15:00` — range, end-exclusive: slots 13:00, 13:30, 14:00, 14:30. Ranges do
+ not cross midnight.
Argument parsing: the first positional arg is treated as a time/range only when it
matches the time syntax (`H:MM`, `HH:MM`, or a `-`-joined pair); otherwise it is the