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

494 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```go
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
```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
```html
<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:
```go
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`:
```go
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):
```go
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:
```nginx
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:
```bash
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`). 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.