22 KiB
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 writeif err != nilhundreds of times. This is normal. - Interfaces are satisfied implicitly — if your struct has the right method signatures, it implements the interface. No
implementskeyword.
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:
go mod init git.timothykim.net/timothy/thekims-dashboardmain.go: register one route (GET /dashboard), return a hardcoded HTML string. Run withgo run .- Add
templates/dashboard.htmlwith the full DOM structure from above (hardcoded placeholder content). Parse and execute viahtml/template. - Serve
static/viahttp.FileServer. Adddashboard.csswith the portrait 1440×2560 layout. Add emptydashboard.js. - 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:
- Define
Configstruct ininternal/config/config.gowith all JSON tags. Load(path) (*Config, error)— read file, unmarshal. Return defaults if file missing.Save(path) error— marshal indented, write atomically (temp file +os.Rename).- Wire into
main.go: load on startup, pass to handlers. GET /dashboard/api/config— return sanitized config (strip credentials).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:
templates/admin.html— full form, inject current config values via template.static/js/admin.js— form → JSON →POST /api/config, inline success/error feedback.- Timezone dropdown (from IANA list).
- Dynamic add/remove rows for photo and calendar sources.
- Theme file upload:
POST /dashboard/api/themes/upload(multipart form, save todata/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:
internal/service/weather/nws.go:FetchCurrentConditions(ctx, station) → ObservationFetchForecast(ctx, office, gridX, gridY) → Forecast- Both:
http.NewRequestWithContext, setUser-Agentheader (NWS policy), decode JSON.
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.- Wire in
main.go: weather cache with 10-min TTL. GET /dashboard/api/weather.- Weather rendering in
dashboard.js— fetch on load, poll every 600s with stagger offset. - 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:
go get github.com/emersion/go-webdavinternal/service/calendar/caldav.go:FetchEvents(ctx, source, start, end) → []Event- CalDAV
REPORTwith time-range filter, parse iCal response.
- 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() - Cache merged result (1-min TTL).
GET /api/calendar/today— filter to today.GET /api/calendar/month— 4-week window (-1 week through +2 weeks).- 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:
- Define
PhotoSourceinterface (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 } ImmichSource(immich.go):- List:
GET /api/assetsorGET /api/albums/{id},x-api-keyheader. - Fetch:
GET /api/assets/{id}/original— stream body directly, don't buffer. - Source URL:
{base_url}/photos/{id}
- List:
Manager(manager.go):- On init/config change:
ListPhotos(), shuffle, store list. - Track
currentIndex(usesync/atomic). ServeImage(w, r, index)— fetch, resize to ≤2560px height, stream response.- Background goroutine refreshes photo list every 5 min.
- On init/config change:
- 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). - API endpoints:
/photo/current,/photo/next,/photo/image/{index}. - 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:
- CSS custom properties in
dashboard.cssfor all colors, fonts, spacing, overlay positions. - Theme CSS files override variables. Active theme loaded via
<link>tag, swapped by JS. - Admin: theme upload + dropdown selector.
- Clock overlay:
setInterval(1000), format viaIntl.DateTimeFormatwith configured timezone. - Error indicator: 3 consecutive fetch failures → show "offline" badge. Auto-clear on recovery.
- Stagger all polling start times so no two intervals fire in the same second.
- Graceful shutdown:
signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT)+server.Shutdown(ctx). - HTTP server timeouts:
ReadTimeout,WriteTimeout,IdleTimeout.
Phase 8 — Deployment
- Multi-stage Dockerfile: build in
golang:1.24-alpine, run inalpine:3.21. docker-compose.yml:sharednetwork, port 8080, volume fordata/.- 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; } - SSL via existing certbot.
- Raspberry Pi autostart:
unclutter -idle 0 & xset s off && xset -dpms firefox-esr --kiosk https://thekims.family/dashboard
Recommended Libraries
| 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). Recommendcover+object-position: centeras 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.