▍ humdrum codex / ticktock v0.0.2

feat(config): read ~/.config/ticktock/config.json for grid settings

3a64e530f8be5d3e8990307140123ef24666bc9b
Kevin Kortum <kevinkortum@me.com> · 2026-07-07 21:42

parent b042fcd3

2 files changed

internal/config/config.go +69 −0
@@ -0,0 +1,69 @@
+// Package config reads the optional ticktock config file. tock owns data;
+// this only tunes the grid's presentation (slot size, window, default project).
+package config
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+)
+
+// Config controls grid presentation. Zero value is not valid; use Default.
+type Config struct {
+	SlotMinutes int
+	GridStart   string
+	GridEnd     string
+	Project     string
+}
+
+// Default returns the documented fallbacks used when config is absent.
+func Default() Config {
+	return Config{SlotMinutes: 30, GridStart: "07:00", GridEnd: "21:00", Project: ""}
+}
+
+// raw mirrors the on-disk JSON; pointers distinguish "absent" from "zero".
+type raw struct {
+	SlotMinutes *int    `json:"slot_minutes"`
+	GridStart   *string `json:"grid_start"`
+	GridEnd     *string `json:"grid_end"`
+	Project     *string `json:"project"`
+}
+
+// Load reads ~/.config/ticktock/config.json, falling back to defaults.
+func Load() Config {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return Default()
+	}
+	return LoadFrom(filepath.Join(home, ".config", "ticktock", "config.json"))
+}
+
+// LoadFrom merges the JSON at path over Default(). Any read/parse error, or a
+// missing key, leaves that field at its default. SlotMinutes floors at 5.
+func LoadFrom(path string) Config {
+	c := Default()
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return c
+	}
+	var r raw
+	if err := json.Unmarshal(b, &r); err != nil {
+		return c
+	}
+	if r.SlotMinutes != nil {
+		c.SlotMinutes = *r.SlotMinutes
+	}
+	if r.GridStart != nil {
+		c.GridStart = *r.GridStart
+	}
+	if r.GridEnd != nil {
+		c.GridEnd = *r.GridEnd
+	}
+	if r.Project != nil {
+		c.Project = *r.Project
+	}
+	if c.SlotMinutes < 5 {
+		c.SlotMinutes = 5
+	}
+	return c
+}
internal/config/config_test.go +42 −0
@@ -0,0 +1,42 @@
+package config
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestDefault(t *testing.T) {
+	c := Default()
+	if c.SlotMinutes != 30 || c.GridStart != "07:00" || c.GridEnd != "21:00" || c.Project != "" {
+		t.Fatalf("bad defaults: %+v", c)
+	}
+}
+
+func TestLoadFromMissingFileReturnsDefaults(t *testing.T) {
+	c := LoadFrom(filepath.Join(t.TempDir(), "nope.json"))
+	if c != Default() {
+		t.Fatalf("missing file should give defaults, got %+v", c)
+	}
+}
+
+func TestLoadFromMergesAndClampsSlot(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "config.json")
+	if err := os.WriteFile(p, []byte(`{"slot_minutes":2,"grid_start":"06:00","project":"ARCHER"}`), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	c := LoadFrom(p)
+	if c.SlotMinutes != 5 { // clamped up from 2
+		t.Errorf("slot=%d, want 5", c.SlotMinutes)
+	}
+	if c.GridStart != "06:00" {
+		t.Errorf("grid_start=%q, want 06:00", c.GridStart)
+	}
+	if c.GridEnd != "21:00" { // untouched key keeps default
+		t.Errorf("grid_end=%q, want 21:00", c.GridEnd)
+	}
+	if c.Project != "ARCHER" {
+		t.Errorf("project=%q, want ARCHER", c.Project)
+	}
+}