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)
+ }
+}