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 "...", nevergit commit. Never use interactive jj flags. - Server repo:
/Users/kortum/Developer/Humdrum-One/Dots(githubhumdrum-tiv/dots). CLI repo:/Users/kortum/Developer/Humdrum-One/dots-cli. - Server repo has NO test runner. Server tasks verify with
npx tsc --noEmit(andnpm run buildwhere 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
yconfirmation 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 viaDOTS_URLenv 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 nearWidgetTokenRow, ~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 insrc/lib/db/widgetTokens.ts. -
Step 1: Write the migration
-- 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:
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
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 requirespublicMetadata.subscribed === true(viacurrentUser()on the Clerk path,clerkClient().users.getUser(userId)on the token path). -
Step 1: Write
src/lib/apiAuth.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/datato 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:
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:
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
with:
const userId = await resolveUserId(request);
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
Add import { resolveUserId } from "@/lib/apiAuth";. Leave GET on plain Clerk auth(). If auth is now unused, drop the import.
- 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:
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.
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
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(envDOTS_URLelsehttps://dots.humdrum.one), cobra root commanddotsthat later tasks hang subcommands on viaroot.AddCommand.main.goexposesfunc newRootCmd() *cobra.Command(in package main) so tests can exercise wiring. -
Step 1: Init module + deps
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
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
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.
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:
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
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
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 }(packageday; the API client in Task 8 returns this type) andMatchActivity(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:
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
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
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; errorErrNoPlanvariant 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:
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
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
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 }andNew(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":"..."}.ErrUnauthorizedsentinel (401) — commands map it to "not linked — rundots 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:
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
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
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).ErrNoTokenwhen nothing stored. -
Produces (package
cli):NewAuthCmd() *cobra.Command, plus helpers used by every later command:RequireClient() (*api.Client, error)(loads token, returns "not linked — rundots auth" error if missing) andToday(tz *time.Location) stringdate helper. AlsoNewRootWiringnote:cmd/dots/main.gogainsroot.AddCommand(cli.NewAuthCmd()). -
Step 1: Write failing tests (file fallback only — keychain is not unit-testable)
internal/auth/store_test.go:
package auth
import (
"os"
"path/filepath"
"testing"
)
func TestFileFallbackRoundTrip(t *testing.T) {
dir := t.TempDir()
t.Setenv("DOTS_TOKEN_DIR", dir) // test hook: overrides ~/.config/dots
if err := saveTokenFile("abc123"); err != nil {
t.Fatal(err)
}
got, err := loadTokenFile()
if err != nil {
t.Fatal(err)
}
if got != "abc123" {
t.Fatalf("got %q", got)
}
info, _ := os.Stat(filepath.Join(dir, "token"))
if info.Mode().Perm() != 0o600 {
t.Fatalf("perm %v", info.Mode().Perm())
}
}
func TestLoadTokenFileMissing(t *testing.T) {
t.Setenv("DOTS_TOKEN_DIR", t.TempDir())
if _, err := loadTokenFile(); err == nil {
t.Fatal("want error for missing token file")
}
}
- Step 2: Run to verify failure
Run: go test ./internal/auth/ -v. Expected: FAIL.
- Step 3: Implement
internal/auth/store.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
package cli
import (
"bufio"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/humdrum-tiv/dots-cli/internal/api"
"github.com/humdrum-tiv/dots-cli/internal/auth"
"github.com/humdrum-tiv/dots-cli/internal/config"
)
// RequireClient loads the stored token and returns a ready API client.
func RequireClient() (*api.Client, error) {
token, err := auth.LoadToken()
if err != nil {
return nil, fmt.Errorf("not linked — run `dots auth`")
}
return api.New(config.BaseURL(), token), nil
}
// Today returns the current date (YYYY-MM-DD) in the device timezone.
func Today() string {
return time.Now().Format("2006-01-02")
}
// TZ returns the device IANA timezone name.
func TZ() string {
zone, _ := time.Now().Zone()
if loc := time.Local.String(); loc != "Local" {
return loc
}
return zone
}
func openBrowser(url string) {
if runtime.GOOS == "darwin" {
exec.Command("open", url).Start()
} else {
exec.Command("xdg-open", url).Start()
}
}
func NewAuthCmd() *cobra.Command {
return &cobra.Command{
Use: "auth",
Short: "Link this machine to your Dots account",
RunE: func(cmd *cobra.Command, args []string) error {
pageURL := config.BaseURL() + "/cli"
fmt.Printf("Opening %s — generate a token there, then paste it here.\n", pageURL)
openBrowser(pageURL)
fmt.Print("Token: ")
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("no token entered")
}
token := strings.TrimSpace(line)
if token == "" {
return fmt.Errorf("no token entered")
}
client := api.New(config.BaseURL(), token)
if _, err := client.GetDay(Today(), TZ()); err != nil {
return fmt.Errorf("token check failed: %w", err)
}
if err := auth.SaveToken(token); err != nil {
return fmt.Errorf("could not store token: %w", err)
}
fmt.Println("Linked. Try `dots today`.")
return nil
},
}
}
In cmd/dots/main.go, inside newRootCmd, add:
root.AddCommand(cli.NewAuthCmd())
with import "github.com/humdrum-tiv/dots-cli/internal/cli".
- Step 6: Build + full tests + commit
Run: go build ./... && go test ./.... Expected: clean, PASS.
jj commit -m "feat: token storage (keychain + file fallback) and dots auth command"
Task 10: log, clear, note commands with confirmation gate
Files:
- 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()(packagecli);FormatChanges(changes []day.Change, typesByID map[string]string, blockSize int) stringandConfirm(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:
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
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)
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/humdrum-tiv/dots-cli/internal/day"
)
func stdinIsTTY() bool { return term.IsTerminal(int(os.Stdin.Fd())) }
func typeNames(types []day.ActivityType) map[string]string {
m := make(map[string]string, len(types))
for _, t := range types {
m[t.ID] = t.Name
}
return m
}
// pickActivity resolves a query to exactly one type or errors.
func pickActivity(query string, types []day.ActivityType) (*day.ActivityType, error) {
matches := day.MatchActivity(query, types)
switch len(matches) {
case 1:
return &matches[0], nil
case 0:
names := make([]string, len(types))
for i, t := range types {
names[i] = t.Name
}
return nil, fmt.Errorf("no activity matches %q — have: %v", query, names)
default:
if !stdinIsTTY() {
return nil, fmt.Errorf("%q is ambiguous: %v", query, namesOf(matches))
}
fmt.Println("Which one?")
for i, m := range matches {
fmt.Printf(" %d) %s\n", i+1, m.Name)
}
fmt.Print("> ")
var n int
if _, err := fmt.Scanln(&n); err != nil || n < 1 || n > len(matches) {
return nil, fmt.Errorf("no selection")
}
return &matches[n-1], nil
}
}
func namesOf(ts []day.ActivityType) []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = t.Name
}
return out
}
func NewLogCmd() *cobra.Command {
var date string
var yes bool
cmd := &cobra.Command{
Use: "log [time|range] [activity]",
Short: "Paint logged block(s); no activity = fill from plan",
Args: cobra.MaximumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := RequireClient()
if err != nil {
return err
}
// Positional parsing: first arg is a time only if it looks like one.
target, activityQuery := "", ""
switch len(args) {
case 1:
if day.IsTimeSyntax(args[0]) {
target = args[0]
} else {
activityQuery = args[0]
}
case 2:
if !day.IsTimeSyntax(args[0]) {
return fmt.Errorf("first argument %q is not a time — usage: dots log [time|range] [activity]", args[0])
}
target, activityQuery = args[0], args[1]
}
if date == "" {
date = Today()
}
d, err := client.GetDay(date, TZ())
if err != nil {
return err
}
targets, err := day.ParseTarget(target, d.BlockSize, timeNow())
if err != nil {
return err
}
var activityID *string
if activityQuery != "" {
t, err := pickActivity(activityQuery, d.Types)
if err != nil {
return err
}
activityID = &t.ID
}
assignments, overwrites, err := day.ResolveLog(targets, activityID, d.Blocks, d.PlanBlocks, d.BlockSize)
if err != nil {
return err
}
if len(overwrites) > 0 {
fmt.Print(FormatChanges(overwrites, typeNames(d.Types), d.BlockSize))
ok, err := Confirm("Overwrite?", yes, os.Stdin, os.Stdout, stdinIsTTY())
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted, nothing written.")
return nil
}
}
if err := client.WriteBlocks(date, assignments); err != nil {
return err
}
fmt.Printf("Logged %d block(s).\n", len(assignments))
return nil
},
}
cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
return cmd
}
func NewClearCmd() *cobra.Command {
var date string
var yes bool
cmd := &cobra.Command{
Use: "clear <time|range>",
Short: "Clear logged block(s)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := RequireClient()
if err != nil {
return err
}
if !day.IsTimeSyntax(args[0]) {
return fmt.Errorf("%q is not a time or range", args[0])
}
if date == "" {
date = Today()
}
d, err := client.GetDay(date, TZ())
if err != nil {
return err
}
targets, err := day.ParseTarget(args[0], d.BlockSize, timeNow())
if err != nil {
return err
}
assignments, cleared := day.ResolveClear(targets, d.Blocks)
if len(cleared) == 0 {
fmt.Println("Nothing logged there.")
return nil
}
fmt.Print(FormatChanges(cleared, typeNames(d.Types), d.BlockSize))
ok, err := Confirm("Clear?", yes, os.Stdin, os.Stdout, stdinIsTTY())
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted, nothing cleared.")
return nil
}
if err := client.WriteBlocks(date, assignments); err != nil {
return err
}
fmt.Printf("Cleared %d block(s).\n", len(cleared))
return nil
},
}
cmd.Flags().StringVar(&date, "date", "", "day to write (YYYY-MM-DD, default today)")
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip confirmation prompts")
return cmd
}
Add to the same file a seam for tests and DST-safe "now":
import "time"
// timeNow is a seam so command tests can pin the clock.
var timeNow = func() time.Time { return time.Now() }
(Fold the time import into the existing import block.)
- Step 6: Implement
internal/cli/note.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
root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())
- Step 8: Build + tests + commit
Run: go build ./... && go test ./.... Expected: clean, PASS.
jj commit -m "feat: log/clear/note commands with overwrite confirmation gate"
Task 11: today + types commands, live E2E, wrap-up
Files:
- 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:
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
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.goandinternal/cli/types.go
today.go:
package cli
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"golang.org/x/term"
"github.com/humdrum-tiv/dots-cli/internal/render"
)
func NewTodayCmd() *cobra.Command {
var date string
var asJSON bool
cmd := &cobra.Command{
Use: "today",
Short: "Show the day grid",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := RequireClient()
if err != nil {
return err
}
isToday := date == ""
if isToday {
date = Today()
}
d, err := client.GetDay(date, TZ())
if err != nil {
return err
}
if asJSON || !term.IsTerminal(int(os.Stdout.Fd())) {
return json.NewEncoder(os.Stdout).Encode(map[string]any{
"date": date, "blockSize": d.BlockSize,
"blocks": d.Blocks, "planBlocks": d.PlanBlocks, "types": d.Types,
})
}
current := -1
if isToday {
now := timeNow()
current = (now.Hour()*60 + now.Minute()) / d.BlockSize
}
fmt.Printf("%s\n\n", date)
fmt.Print(render.Grid(d, current))
return nil
},
}
cmd.Flags().StringVar(&date, "date", "", "day to show (YYYY-MM-DD, default today)")
cmd.Flags().BoolVar(&asJSON, "json", false, "raw JSON output")
return cmd
}
types.go:
package cli
import (
"fmt"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
func NewTypesCmd() *cobra.Command {
return &cobra.Command{
Use: "types",
Short: "List active activity types",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
client, err := RequireClient()
if err != nil {
return err
}
d, err := client.GetDay(Today(), TZ())
if err != nil {
return err
}
for _, t := range d.Types {
sw := lipgloss.NewStyle().Foreground(lipgloss.Color(t.Color)).Render("●")
fmt.Printf("%s %s\n", sw, t.Name)
}
return nil
},
}
}
Register both in cmd/dots/main.go:
root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd(), cli.NewTodayCmd(), cli.NewTypesCmd())
- 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
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.
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
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 (iflipgloss.Colordiffers, adapt — structure tests don't assert escapes), widget/dataplanBlocksmay be absent for non-subscribers (.catch(() => ({}))server-side — treat missing map as empty).