Files
thekims.family/dashboard/PLAN.md
T
timothykim 2bdddd3188
Build and Push Docker Image / build (push) Successful in 11s
adds dashbaord init
2026-04-16 13:55:10 -04:00

22 KiB
Raw Blame History

thekims-dashboard — Claude Code Plan

A DAKboard-style family dashboard served at https://thekims.family/dashboard, rendered on a Raspberry Pi 5 driving a 1440p portrait monitor via Firefox kiosk mode.


Architecture Overview

┌─────────────────────┐       ┌──────────────────────────────────┐
│  Raspberry Pi 5     │       │  hantim (Vultr VPS)              │
│  Firefox kiosk mode │──────▶│  nginx ─► Go binary (Docker)     │
│  1440x2560 portrait │       │           ├─ /dashboard          │
└─────────────────────┘       │           ├─ /dashboard/admin    │
                              │           └─ /dashboard/api/*    │
                              │                                  │
                              │  config.json (Docker volume)     │
                              │  themes/*.css (Docker volume)    │
                              └───────┬──────────┬───────────────┘
                                      │          │
                         ┌────────────┘          └────────────┐
                         ▼                                    ▼
                ┌───────────────────┐                ┌─────────────────┐
                │ External Services │                │ Home Server     │
                │ - api.weather.gov │                │ (argento)       │
                │ - CalDAV servers  │                │ - Immich API    │
                │   (incl Google)   │                │                 │
                └───────────────────┘                └─────────────────┘

Key decisions:

  • Go backend does ALL external fetching. Client never talks to third-party services.
  • Client receives lightweight JSON + proxied images. Vanilla JS: poll, swap DOM, CSS transitions.
  • Server caches aggressively. Client polls server on staggered intervals.
  • No frameworks (JS or Go). Vanilla JS. Go html/template. CSS custom properties for theming.

Go Concepts You'll Encounter, By Phase

This is not a Go tutorial — you'll pick up syntax from Claude Code and the spec. This section maps engineering concepts you already know to Go's way of expressing them, so you can pattern-match instead of cargo-culting.

The things that'll feel familiar

  • Statically typed, compiled, C-family syntax.
  • Structs instead of classes. Methods are functions with a receiver argument.
  • Explicit error returns (val, err := doThing()) instead of exceptions. You'll write if err != nil hundreds of times. This is normal.
  • Interfaces are satisfied implicitly — if your struct has the right method signatures, it implements the interface. No implements keyword.

The things that'll trip you up

Packages, not files. All .go files in a directory share the same package namespace. There are no file-scoped imports or per-file visibility. If you declare func helper() in a.go and func Handler() in b.go in the same package, Handler can call helper directly. Capitalization controls visibility: Exported (public) vs unexported (package-private).

No constructors. You'll see a convention of NewThing() functions that return initialized structs. That's just a convention, not language machinery.

Goroutines and channels. Go's concurrency primitive. go doSomething() spawns a lightweight thread. Channels (chan T) are typed pipes between goroutines. You'll use these for the cache refresh tickers and background photo list fetching. The sync package (sync.Mutex, sync.RWMutex) is the alternative when channels are overkill.

context.Context. Passed as the first argument to almost every function that does I/O. It carries deadlines, cancellation signals, and request-scoped values. The HTTP handler gives you one via r.Context(). Thread it through to all downstream calls.

Dependency injection via struct fields. Go has no DI framework. You wire dependencies manually:

type Server struct {
    config  *config.Config
    photos  *photos.Manager
    weather *weather.Client
}

Construct it in main(), pass it around. This will feel tedious at first. It's intentional — the dependency graph is always explicit.

defer. Schedules a function call to run when the enclosing function returns. Used for cleanup: defer file.Close(), defer mutex.Unlock(). Runs in LIFO order. You'll use it constantly.

Zero values. Uninitialized variables get the zero value for their type: 0 for ints, "" for strings, nil for pointers/slices/maps. This matters because Go has no null in the Java/JS sense — but nil on a slice or map will still panic if you write to it without initializing.

Slices vs arrays. You almost never use arrays directly. Slices ([]T) are the standard dynamic-length sequence type. append() grows them. They're reference types (like Java's ArrayList, but built-in).

Error wrapping. fmt.Errorf("failed to fetch weather: %w", err) wraps an error with context while preserving the original for errors.Is() / errors.As() checks downstream. Always wrap, never swallow.

Project layout conventions

Go doesn't enforce directory structure, but the community follows a loose standard:

thekims-dashboard/
├── main.go                    # Entry point only: wire deps, start server
├── go.mod                     # Module name + dependencies (like package.json)
├── go.sum                     # Lock file (committed to git)
│
├── internal/                  # Private to this module — can't be imported externally
│   ├── config/
│   │   └── config.go
│   ├── server/
│   │   └── server.go          # HTTP server struct, router setup, middleware
│   ├── handler/
│   │   ├── pages.go
│   │   ├── api_config.go
│   │   ├── api_photo.go
│   │   ├── api_calendar.go
│   │   └── api_weather.go
│   ├── service/
│   │   ├── photo/
│   │   │   ├── manager.go     # Rotation logic, queue, index tracking
│   │   │   └── immich.go      # Immich API client
│   │   ├── calendar/
│   │   │   └── caldav.go      # CalDAV client, event parsing/merging
│   │   └── weather/
│   │       └── nws.go         # NWS API client, zip-to-latlon
│   └── cache/
│       └── cache.go           # Generic TTL cache
│
├── templates/
│   ├── dashboard.html
│   └── admin.html
│
├── static/
│   ├── css/
│   │   └── dashboard.css
│   ├── js/
│   │   ├── dashboard.js
│   │   └── admin.js
│   └── themes/
│       └── default.css
│
├── data/                      # Docker volume mount (gitignored)
│   ├── config.json
│   └── themes/
│
├── Dockerfile
├── docker-compose.yml
└── README.md

The internal/ directory is special in Go — the compiler enforces that nothing outside this module can import packages under it. It's the idiomatic way to say "these are implementation details."


Data Model

config.json

{
  "timezone": "America/New_York",
  "weather": {
    "zip_code": "22030",
    "lat": 38.8462,
    "lon": -77.3064,
    "grid_office": "LWX",
    "grid_x": 90,
    "grid_y": 78,
    "observation_station": "KDAA"
  },
  "photo_sources": [
    {
      "id": "immich-main",
      "type": "immich",
      "name": "Family Photos",
      "url": "https://photos.example.com",
      "api_key": "...",
      "album_id": "optional-album-uuid"
    }
  ],
  "active_photo_source": "immich-main",
  "calendar_sources": [
    {
      "id": "family-cal",
      "name": "Family",
      "url": "https://caldav.example.com/dav/calendars/...",
      "username": "...",
      "password": "...",
      "color": "#e74c3c"
    },
    {
      "id": "google-tim",
      "name": "Tim's Google",
      "url": "https://apidata.googleusercontent.com/caldav/v2/.../events",
      "username": "tim@gmail.com",
      "password": "app-specific-password",
      "color": "#3498db"
    }
  ],
  "active_theme": "default",
  "photo_interval_seconds": 10,
  "calendar_refresh_seconds": 60,
  "weather_refresh_seconds": 600
}

Weather grid resolution: On admin save of a zip code, the backend geocodes zip → lat/lon, calls GET https://api.weather.gov/points/{lat},{lon} to resolve gridId, gridX, gridY, and nearest observationStation, then persists all resolved values so subsequent weather fetches skip re-resolution.


API Endpoints

All prefixed with /dashboard/api. All return application/json unless noted.

Endpoint Method Description
/config GET Display-relevant config (timezone, theme, intervals). No secrets.
/config POST Full config update from admin. Triggers cache invalidation.
/photo/current GET {imageUrl, sourceUrl, index, total}
/photo/next GET Same shape, for preloading.
/photo/image/{index} GET Proxied image binary. Resized server-side to ≤2560px tall.
/calendar/today GET {events: [{title, start, end, color, calendarName}]}
/calendar/month GET {startDate, endDate, events: [...]} — 4-week window
/weather GET {current: {temp, humidity, condition, icon}, today: {high, low}}
/themes GET List available theme filenames.
/themes/{name} GET Serve CSS file.

Frontend Notes

Display page DOM

<body class="dashboard" data-theme="default">
  <div id="photo-layer">
    <img id="photo-a" class="photo active" />
    <img id="photo-b" class="photo" />
  </div>
  <div id="gradient-overlay"></div>
  <div id="overlay-top-left" class="overlay">
    <canvas id="qr-code"></canvas>
  </div>
  <div id="overlay-top-center" class="overlay">
    <div id="clock-time"></div>
    <div id="clock-date"></div>
  </div>
  <div id="overlay-top-right" class="overlay">
    <h2>Today</h2>
    <ul id="today-events"></ul>
  </div>
  <div id="overlay-weather" class="overlay">
    <div id="weather-temp"></div>
    <div id="weather-condition"></div>
    <div id="weather-range"></div>
    <div id="weather-humidity"></div>
  </div>
  <div id="calendar-month">
    <div class="calendar-header">
      <span>S</span><span>M</span><span>T</span><span>W</span>
      <span>T</span><span>F</span><span>S</span>
    </div>
    <div id="calendar-grid"></div>
  </div>
</body>

Photo crossfade: Two <img> stacked via position: absolute. Toggle .active class, CSS transition: opacity 1.5s ease. After transition completes, preload next image into the hidden element.

Staggered polling: setInterval with offsets — photos at t+0s, calendar at t+3s, weather at t+7s.

QR code: qrcode-generator library (~4 KB, no deps, renders to canvas). Updates on each photo swap.

Admin page

Vanilla HTML form. Sections: timezone select (auto-detect via Intl.DateTimeFormat), zip code, photo sources (dynamic add/remove), calendar sources (dynamic with color picker), theme upload + selector. admin.js serializes → POST /api/config.


Implementation Phases

Each phase introduces Go concepts incrementally. Complete each end-to-end (compile, run, test in browser) before moving to the next.


Phase 1 — Hello World Web Server

Goal: Go toolchain, first compiled binary, first HTTP response.

Go concepts introduced: go mod init, packages, net/http, http.HandleFunc, http.ListenAndServe, log package, html/template.

Steps:

  1. go mod init git.timothykim.net/timothy/thekims-dashboard
  2. main.go: register one route (GET /dashboard), return a hardcoded HTML string. Run with go run .
  3. Add templates/dashboard.html with the full DOM structure from above (hardcoded placeholder content). Parse and execute via html/template.
  4. Serve static/ via http.FileServer. Add dashboard.css with the portrait 1440×2560 layout. Add empty dashboard.js.
  5. Dockerfile + docker-compose.yml. Build, run, verify.

End state: http://localhost:8080/dashboard renders the full layout skeleton with placeholder text and styling. No dynamic data.

Claude Code prompt hint: "Set up a Go HTTP server that serves an HTML template at /dashboard and static files from /static/. Use Go 1.22+ stdlib routing. Show me idiomatic project structure."


Phase 2 — Config System

Goal: JSON persistence, struct serialization, first custom package.

Go concepts introduced: Structs with JSON tags, encoding/json, os.ReadFile/os.WriteFile, internal/ packages, exported vs unexported, pointer receivers.

Steps:

  1. Define Config struct in internal/config/config.go with all JSON tags.
  2. Load(path) (*Config, error) — read file, unmarshal. Return defaults if file missing.
  3. Save(path) error — marshal indented, write atomically (temp file + os.Rename).
  4. Wire into main.go: load on startup, pass to handlers.
  5. GET /dashboard/api/config — return sanitized config (strip credentials).
  6. POST /dashboard/api/config — accept JSON, validate, save, reload in-memory state.

Atomic write note: os.Rename is atomic on Linux (same filesystem). Write to config.json.tmp, then rename. Prevents corruption on crash.

End state: POST config from curl, restart server, GET returns persisted values.

Claude Code prompt hint: "Create a config package under internal/ that loads/saves JSON. Show idiomatic error handling and struct tags. Use atomic file writes."


Phase 3 — Admin Page

Goal: Functional admin UI that reads/writes config.

Go concepts introduced: Template data passing, http.ServeFile.

Steps:

  1. templates/admin.html — full form, inject current config values via template.
  2. static/js/admin.js — form → JSON → POST /api/config, inline success/error feedback.
  3. Timezone dropdown (from IANA list).
  4. Dynamic add/remove rows for photo and calendar sources.
  5. Theme file upload: POST /dashboard/api/themes/upload (multipart form, save to data/themes/).

End state: Fill form, save, refresh — values persist. Basic auth is nginx config (Phase 8), not app logic.


Phase 4 — Weather Service

Goal: First external HTTP client, first background goroutine, first cache.

Go concepts introduced: net/http client, json.Decoder, context.Context, goroutines, time.Ticker, sync.RWMutex, generics.

This is the simplest external integration (no auth, well-documented API) — a good place to learn the HTTP client patterns you'll reuse in calendar and photo services.

Steps:

  1. internal/service/weather/nws.go:
    • FetchCurrentConditions(ctx, station) → Observation
    • FetchForecast(ctx, office, gridX, gridY) → Forecast
    • Both: http.NewRequestWithContext, set User-Agent header (NWS policy), decode JSON.
  2. internal/cache/cache.go — generic TTL cache using Go 1.18+ generics:
    type Cache[T any] struct {
        mu    sync.RWMutex
        data  T
        exp   time.Time
        ttl   time.Duration
        fetch func(context.Context) (T, error)
    }
    func (c *Cache[T]) Get(ctx context.Context) (T, error)
    
    Get() returns cached value if fresh, else calls fetch function under write lock.
  3. Wire in main.go: weather cache with 10-min TTL.
  4. GET /dashboard/api/weather.
  5. Weather rendering in dashboard.js — fetch on load, poll every 600s with stagger offset.
  6. Zip → lat/lon resolution on admin save: embed a lookup table or call Census Geocoder API. Also resolve NWS grid point + observation station and persist to config.

End state: Dashboard shows live weather. Change zip in admin → weather updates on next poll.

Claude Code prompt hint: "Write an HTTP client for the NWS weather.gov API. Show context usage, custom headers, JSON decoding. Then build a generic TTL cache with sync.RWMutex and generics."


Phase 5 — Calendar Service

Goal: CalDAV integration, multi-source parallel fetch and merge.

Go concepts introduced: Third-party deps (go get), interfaces, sync.WaitGroup for parallel fan-out, time.LoadLocation.

Steps:

  1. go get github.com/emersion/go-webdav
  2. internal/service/calendar/caldav.go:
    • FetchEvents(ctx, source, start, end) → []Event
    • CalDAV REPORT with time-range filter, parse iCal response.
  3. Multi-source merge: fan out with goroutines, collect with WaitGroup:
    var wg sync.WaitGroup
    results := make([][]Event, len(sources))
    for i, src := range sources {
        wg.Add(1)
        go func(i int, src CalDAVConfig) {
            defer wg.Done()
            events, err := FetchEvents(ctx, src, start, end)
            if err != nil {
                log.Printf("calendar %s: %v", src.Name, err)
                return
            }
            results[i] = events
        }(i, src)
    }
    wg.Wait()
    
  4. Cache merged result (1-min TTL).
  5. GET /api/calendar/today — filter to today.
  6. GET /api/calendar/month — 4-week window (-1 week through +2 weeks).
  7. Client-side: render today overlay + 4-week grid.

Google Calendar CalDAV: URL format is https://apidata.googleusercontent.com/caldav/v2/{calendarId}/events, auth via basic auth with Gmail address + app-specific password. Note this in admin form help text.

End state: Dashboard shows real events from all configured CalDAV sources, color-coded.


Phase 6 — Photo Service

Goal: Immich API integration, image proxying, binary streaming.

Go concepts introduced: Interfaces (for future extensibility), io.Reader/io.Writer streaming, image package.

Steps:

  1. Define PhotoSource interface (even with only Immich, this keeps the door open for adding sources later without refactoring):
    type PhotoSource interface {
        ListPhotos(ctx context.Context) ([]PhotoMeta, error)
        FetchPhoto(ctx context.Context, id string) (io.ReadCloser, string, error)
        SourceURL(id string) string
    }
    
  2. ImmichSource (immich.go):
    • List: GET /api/assets or GET /api/albums/{id}, x-api-key header.
    • Fetch: GET /api/assets/{id}/original — stream body directly, don't buffer.
    • Source URL: {base_url}/photos/{id}
  3. Manager (manager.go):
    • On init/config change: ListPhotos(), shuffle, store list.
    • Track currentIndex (use sync/atomic).
    • ServeImage(w, r, index) — fetch, resize to ≤2560px height, stream response.
    • Background goroutine refreshes photo list every 5 min.
  4. Image resize: go get github.com/disintegration/imaging. Decode → resize → JPEG encode → write. Cache current + next resized images in memory (~10-20 MB for two 1440p JPEGs).
  5. API endpoints: /photo/current, /photo/next, /photo/image/{index}.
  6. Client-side: crossfade, preload, QR code via qrcode-generator.

Streaming vs buffering: For proxying without resize, stream directly via io.Copy(w, reader). For resize, you must buffer (decode image.Image, resize, re-encode). Cache the resized result to avoid reprocessing.

End state: Photos cycle from Immich with smooth crossfade and QR code linking to the photo in the Immich web UI.


Phase 7 — Theming + Polish

Steps:

  1. CSS custom properties in dashboard.css for all colors, fonts, spacing, overlay positions.
  2. Theme CSS files override variables. Active theme loaded via <link> tag, swapped by JS.
  3. Admin: theme upload + dropdown selector.
  4. Clock overlay: setInterval(1000), format via Intl.DateTimeFormat with configured timezone.
  5. Error indicator: 3 consecutive fetch failures → show "offline" badge. Auto-clear on recovery.
  6. Stagger all polling start times so no two intervals fire in the same second.
  7. Graceful shutdown: signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT) + server.Shutdown(ctx).
  8. HTTP server timeouts: ReadTimeout, WriteTimeout, IdleTimeout.

Phase 8 — Deployment

  1. Multi-stage Dockerfile: build in golang:1.24-alpine, run in alpine:3.21.
  2. docker-compose.yml: shared network, port 8080, volume for data/.
  3. nginx on hantim:
    location /dashboard {
        proxy_pass http://thekims-dashboard:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
    location /dashboard/admin {
        auth_basic "Dashboard Admin";
        auth_basic_user_file /etc/nginx/htpasswd.dashboard;
        proxy_pass http://thekims-dashboard:8080;
    }
    
  4. SSL via existing certbot.
  5. Raspberry Pi autostart:
    unclutter -idle 0 &
    xset s off && xset -dpms
    firefox-esr --kiosk https://thekims.family/dashboard
    

Purpose Library Notes
HTTP routing stdlib net/http (Go 1.22+) New pattern matching makes third-party routers unnecessary here.
CalDAV github.com/emersion/go-webdav Maintained, clean, handles REPORT.
Image resize github.com/disintegration/imaging Simple API, pure Go.
JSON stdlib encoding/json Sufficient at this scale.
Logging stdlib log/slog (Go 1.21+) Structured logging, built-in.

Avoid gin, echo, fiber, gorm, or any heavy framework. The stdlib is more capable than you'd expect coming from other ecosystems.


Open Questions

  • Photo cropping: Landscape on portrait = heavy crop (object-fit: cover) or letterbox (contain). Recommend cover + object-position: center as default, configurable per-theme.
  • Multi-day calendar events: v1 lists them in each day cell. Spanning banners (like DAKboard) are complex layout — defer to a later enhancement.
  • Credential storage: Plaintext in config.json. Acceptable for single-user dashboard behind basic auth. Bitwarden Secrets Manager integration possible later.