▍ humdrum codex / dots v1.0.0

feat: log/clear/note commands with overwrite confirmation gate

76e77ecf735434985e37c9094697c1646aa87252
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-29 17:22

parent 1e954ed2

5 files changed

cmd/dots/main.go +1 −1
@@ -16,7 +16,7 @@ 		Short:         "Log your day from the terminal",
 		SilenceUsage:  true,
 		SilenceErrors: true,
 	}
-	root.AddCommand(cli.NewAuthCmd())
+	root.AddCommand(cli.NewAuthCmd(), cli.NewLogCmd(), cli.NewClearCmd(), cli.NewNoteCmd())
 	return root
 }
 
internal/cli/confirm.go +46 −0
@@ -0,0 +1,46 @@
+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, _ := bufio.NewReader(in).ReadString('\n')
+	return strings.TrimSpace(strings.ToLower(line)) == "y", nil
+}
internal/cli/confirm_test.go +60 −0
@@ -0,0 +1,60 @@
+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)")
+	}
+	// y without newline (EOF) should accept
+	ok, _ = Confirm("overwrite?", false, strings.NewReader("y"), &strings.Builder{}, true)
+	if !ok {
+		t.Fatal("y without newline should accept")
+	}
+	// immediate EOF (empty input) should decline
+	ok, _ = Confirm("overwrite?", false, strings.NewReader(""), &strings.Builder{}, true)
+	if ok {
+		t.Fatal("immediate EOF should decline")
+	}
+}
internal/cli/log.go +190 −0
@@ -0,0 +1,190 @@
+package cli
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"github.com/spf13/cobra"
+	"golang.org/x/term"
+
+	"github.com/humdrum-tiv/dots-cli/internal/day"
+)
+
+// timeNow is a seam so command tests can pin the clock.
+var timeNow = func() time.Time { return time.Now() }
+
+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
+}
internal/cli/note.go +37 −0
@@ -0,0 +1,37 @@
+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
+}