1018 lines
36 KiB
Markdown
1018 lines
36 KiB
Markdown
|
|
# Configuration (application vs environment) Implementation Plan
|
||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Instance-global application configuration (DB-backed `config` table, cold reload) alongside renamed environment configuration, exposed via `GET`/`PUT /api/config` and a `/config` page reached from a new icon-only header button.
|
||
|
|
|
||
|
|
**Architecture:** Defaults live in a registry in `internal/config`; the DB stores overrides only. `config.Load()` (env, fail-fast) is unchanged in mechanism but renames vars; new pure `config.LoadApp(overrides)` merges DB overrides over defaults into a typed struct wired once at startup (cold — restart to apply). The API serves a combined view (editable app config + masked read-only env snapshot built in `main.go`). The SPA gets a pathname-driven `/config` view and an icon-only header (👤 profile / ⚙️ settings / ✕ logout).
|
||
|
|
|
||
|
|
**Tech Stack:** Go (chi, SQLite), React + TypeScript (no router library — `history.pushState` + `popstate`).
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-08-03-configuration-design.md`
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- Env var renames (breaking, no fallbacks, no shims): `GENIUSRUN_ADDR`→`GENIUSRUN_BACKEND_ADDR`, `GARMIN_WRAPPER_PYTHON`→`GENIUSRUN_PYTHON_PATH`, `GARMIN_TOKENSTORE`→`GENIUSRUN_TOKENSTORE_PATH`; `GENIUSRUN_MIN_CONFIDENCE` and `GENIUSRUN_SESSION_DURATION` removed; `GENIUSRUN_SESSION_SECRET` unchanged.
|
||
|
|
- The one app-config key at launch: `session.duration`, integer hours, default `"720"`, must be > 0.
|
||
|
|
- Secret masking strings, verbatim: `•••• (set)` and `(unset)`.
|
||
|
|
- `config` table is deliberately NOT user-scoped (documented exception).
|
||
|
|
- No `ALTER TABLE`/migration shims — edit `schema.sql`, delete the local DB file, regenerate `docs/DATABASE.md` via `go run ./cmd/dumpschema`.
|
||
|
|
- Before every commit: `go build ./... && go vet ./...` clean, `gofmt -l .` silent (backend); `npm run build` + `npm run lint` clean (frontend).
|
||
|
|
- Backend commands run from `backend/`; frontend from `frontend/`.
|
||
|
|
- Known pre-existing failure to ignore (do not chase, do not fix): `TestProfile_DefaultsThenUpdate` fails while the local `schema.sql` backfill-default change (1095→90) is uncommitted.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 1: `config` table + store accessors
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `backend/internal/store/schema.sql` (append after the `sync_runs` table)
|
||
|
|
- Create: `backend/internal/store/config.go`
|
||
|
|
- Test: `backend/internal/store/config_test.go`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Produces: `(*store.DB).ConfigValues(ctx) (map[string]string, error)` and `(*store.DB).SetConfigValue(ctx, key, value string) error` — consumed by Tasks 2 and 3.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Append the table to `schema.sql`**
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- Application configuration: instance-global key/value overrides, shared
|
||
|
|
-- by every user -- deliberately the one table with no user_id, because
|
||
|
|
-- application configuration is common to all users by definition. Stores
|
||
|
|
-- overrides only; defaults live in internal/config's app-key registry.
|
||
|
|
-- Cold: read once at startup, a change applies on the next backend restart.
|
||
|
|
CREATE TABLE config (
|
||
|
|
key TEXT PRIMARY KEY,
|
||
|
|
value TEXT NOT NULL,
|
||
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||
|
|
);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Write the failing test**
|
||
|
|
|
||
|
|
`backend/internal/store/config_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestConfigValues_EmptyThenUpsertOverwrites(t *testing.T) {
|
||
|
|
db := openTestDB(t)
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
values, err := db.ConfigValues(ctx)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ConfigValues (empty): %v", err)
|
||
|
|
}
|
||
|
|
if len(values) != 0 {
|
||
|
|
t.Fatalf("expected no overrides in a fresh DB, got %v", values)
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := db.SetConfigValue(ctx, "session.duration", "168"); err != nil {
|
||
|
|
t.Fatalf("SetConfigValue: %v", err)
|
||
|
|
}
|
||
|
|
// Same key again: upsert must overwrite, not error or duplicate.
|
||
|
|
if err := db.SetConfigValue(ctx, "session.duration", "24"); err != nil {
|
||
|
|
t.Fatalf("SetConfigValue (overwrite): %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
values, err = db.ConfigValues(ctx)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("ConfigValues: %v", err)
|
||
|
|
}
|
||
|
|
if len(values) != 1 || values["session.duration"] != "24" {
|
||
|
|
t.Fatalf("expected {session.duration: 24}, got %v", values)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
(`openTestDB` already exists in `store_test.go`.)
|
||
|
|
|
||
|
|
- [ ] **Step 3: Run test to verify it fails**
|
||
|
|
|
||
|
|
Run: `go test ./internal/store/... -run TestConfigValues -v`
|
||
|
|
Expected: FAIL — `db.ConfigValues undefined`.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Implement `backend/internal/store/config.go`**
|
||
|
|
|
||
|
|
```go
|
||
|
|
package store
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"fmt"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ConfigValues returns every application-configuration override row as
|
||
|
|
// key -> value. An absent key means its default (from internal/config's
|
||
|
|
// app-key registry) is in effect -- the table stores overrides only.
|
||
|
|
func (db *DB) ConfigValues(ctx context.Context) (map[string]string, error) {
|
||
|
|
rows, err := db.QueryContext(ctx, `SELECT key, value FROM config`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("config values: %w", err)
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
values := map[string]string{}
|
||
|
|
for rows.Next() {
|
||
|
|
var k, v string
|
||
|
|
if err := rows.Scan(&k, &v); err != nil {
|
||
|
|
return nil, fmt.Errorf("scan config row: %w", err)
|
||
|
|
}
|
||
|
|
values[k] = v
|
||
|
|
}
|
||
|
|
return values, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetConfigValue upserts one override row. Key validation is the caller's
|
||
|
|
// job (config.ValidateAppValue) -- the store stays a dumb K/V layer.
|
||
|
|
func (db *DB) SetConfigValue(ctx context.Context, key, value string) error {
|
||
|
|
if _, err := db.ExecContext(ctx, `
|
||
|
|
INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
|
||
|
|
key, value); err != nil {
|
||
|
|
return fmt.Errorf("set config %q: %w", key, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run test to verify it passes**
|
||
|
|
|
||
|
|
Run: `go test ./internal/store/... -run TestConfigValues -v`
|
||
|
|
Expected: PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Regenerate the schema doc**
|
||
|
|
|
||
|
|
Run: `go run ./cmd/dumpschema`
|
||
|
|
Expected: `docs/DATABASE.md` gains the `config` table.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/store/schema.sql internal/store/envconfig.go internal/store/envconfig_test.go ../docs/DATABASE.md
|
||
|
|
git commit -m "feat(store): config table for instance-global app-config overrides"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 2: app-config registry + env renames, wired into `main.go`
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `backend/internal/config/appconfig.go`
|
||
|
|
- Modify: `../../../backend/internal/config/envconfig.go`, `backend/cmd/geniusrund/main.go`
|
||
|
|
- Modify: `backend/.env` (local, gitignored — edit but never commit), `CLAUDE.md`
|
||
|
|
- Test: create `backend/internal/config/appconfig_test.go`, modify `../../../backend/internal/config/envconfig_test.go`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `store.ConfigValues` (Task 1).
|
||
|
|
- Produces (consumed by Task 3):
|
||
|
|
- `config.AppKey{Key, Default, Description string; Validate func(string) error}`
|
||
|
|
- `config.AppRegistry() []AppKey`
|
||
|
|
- `config.ValidateAppValue(key, value string) error`
|
||
|
|
- `config.KeySessionDuration = "session.duration"`
|
||
|
|
- `config.AppConfig{SessionDuration time.Duration}` via `config.LoadApp(overrides map[string]string) (AppConfig, error)`
|
||
|
|
- `config.EnvEntry{Name, Value string}` via `(config.Config).DisplayEnv() []EnvEntry`
|
||
|
|
- Removes: `Config.MinConfidence`, `Config.SessionDuration`, `getEnvFloat`, `getEnvDuration`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing tests**
|
||
|
|
|
||
|
|
`backend/internal/config/appconfig_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package config
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestLoadApp(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
overrides map[string]string
|
||
|
|
want time.Duration
|
||
|
|
wantErr string
|
||
|
|
}{
|
||
|
|
{name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour},
|
||
|
|
{name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour},
|
||
|
|
{name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"},
|
||
|
|
{name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"},
|
||
|
|
}
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
got, err := LoadApp(tt.overrides)
|
||
|
|
if tt.wantErr != "" {
|
||
|
|
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||
|
|
t.Fatalf("err = %v, want containing %q", err, tt.wantErr)
|
||
|
|
}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("LoadApp: %v", err)
|
||
|
|
}
|
||
|
|
if got.SessionDuration != tt.want {
|
||
|
|
t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestValidateAppValue(t *testing.T) {
|
||
|
|
if err := ValidateAppValue("session.duration", "24"); err != nil {
|
||
|
|
t.Fatalf("valid value rejected: %v", err)
|
||
|
|
}
|
||
|
|
if err := ValidateAppValue("session.duration", "-1"); err == nil {
|
||
|
|
t.Fatal("negative hours accepted")
|
||
|
|
}
|
||
|
|
if err := ValidateAppValue("session.duration", "1.5"); err == nil {
|
||
|
|
t.Fatal("non-integer accepted")
|
||
|
|
}
|
||
|
|
if err := ValidateAppValue("nope", "1"); err == nil {
|
||
|
|
t.Fatal("unknown key accepted")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDisplayEnv_MasksSecrets(t *testing.T) {
|
||
|
|
cfg := Config{
|
||
|
|
Addr: ":8080", OIDCClientSecret: "hunter2",
|
||
|
|
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
|
||
|
|
}
|
||
|
|
entries := map[string]string{}
|
||
|
|
for _, e := range cfg.DisplayEnv() {
|
||
|
|
entries[e.Name] = e.Value
|
||
|
|
}
|
||
|
|
if entries["GENIUSRUN_BACKEND_ADDR"] != ":8080" {
|
||
|
|
t.Errorf("GENIUSRUN_BACKEND_ADDR = %q", entries["GENIUSRUN_BACKEND_ADDR"])
|
||
|
|
}
|
||
|
|
if entries["GENIUSRUN_OIDC_CLIENT_SECRET"] != "•••• (set)" {
|
||
|
|
t.Errorf("client secret not masked: %q", entries["GENIUSRUN_OIDC_CLIENT_SECRET"])
|
||
|
|
}
|
||
|
|
if entries["GENIUSRUN_SESSION_SECRET"] != "•••• (set)" {
|
||
|
|
t.Errorf("session secret not masked: %q", entries["GENIUSRUN_SESSION_SECRET"])
|
||
|
|
}
|
||
|
|
empty := Config{}
|
||
|
|
for _, e := range empty.DisplayEnv() {
|
||
|
|
if e.Name == "GENIUSRUN_OIDC_CLIENT_SECRET" && e.Value != "(unset)" {
|
||
|
|
t.Errorf("unset secret = %q, want (unset)", e.Value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `go test ./internal/config/... -run 'TestLoadApp|TestValidateAppValue|TestDisplayEnv' -v`
|
||
|
|
Expected: FAIL — `LoadApp`/`ValidateAppValue`/`DisplayEnv` undefined.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement `backend/internal/config/appconfig.go`**
|
||
|
|
|
||
|
|
```go
|
||
|
|
package config
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strconv"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// AppKey describes one application-configuration key: instance-global,
|
||
|
|
// stored (as an override) in the DB `config` table, read once at startup.
|
||
|
|
// Cold -- a changed value applies on the next backend restart.
|
||
|
|
type AppKey struct {
|
||
|
|
Key string
|
||
|
|
Default string
|
||
|
|
Description string
|
||
|
|
Validate func(value string) error
|
||
|
|
}
|
||
|
|
|
||
|
|
// KeySessionDuration is the session cookie lifetime, in integer hours.
|
||
|
|
const KeySessionDuration = "session.duration"
|
||
|
|
|
||
|
|
var appRegistry = []AppKey{
|
||
|
|
{
|
||
|
|
Key: KeySessionDuration,
|
||
|
|
Default: "720",
|
||
|
|
Description: "Session cookie lifetime in hours",
|
||
|
|
Validate: validatePositiveInt,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppRegistry returns every known application-configuration key, in
|
||
|
|
// display order.
|
||
|
|
func AppRegistry() []AppKey { return appRegistry }
|
||
|
|
|
||
|
|
func validatePositiveInt(v string) error {
|
||
|
|
n, err := strconv.Atoi(v)
|
||
|
|
if err != nil || n <= 0 {
|
||
|
|
return fmt.Errorf("must be a positive integer, got %q", v)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ValidateAppValue rejects unknown keys and invalid values -- every write
|
||
|
|
// path's guard, so the config table can never accumulate junk.
|
||
|
|
func ValidateAppValue(key, value string) error {
|
||
|
|
for _, k := range appRegistry {
|
||
|
|
if k.Key == key {
|
||
|
|
if err := k.Validate(value); err != nil {
|
||
|
|
return fmt.Errorf("%s: %w", key, err)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return fmt.Errorf("unknown configuration key %q", key)
|
||
|
|
}
|
||
|
|
|
||
|
|
// AppConfig is the typed result of merging DB overrides over registry
|
||
|
|
// defaults.
|
||
|
|
type AppConfig struct {
|
||
|
|
SessionDuration time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
// LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure
|
||
|
|
// -- it takes the raw map instead of a *store.DB so this package needs no
|
||
|
|
// store dependency and the merge logic tests without a database. A bad
|
||
|
|
// stored value fails fast, same posture as Load() for env vars.
|
||
|
|
func LoadApp(overrides map[string]string) (AppConfig, error) {
|
||
|
|
merged := map[string]string{}
|
||
|
|
for _, k := range appRegistry {
|
||
|
|
merged[k.Key] = k.Default
|
||
|
|
}
|
||
|
|
for key, value := range overrides {
|
||
|
|
if err := ValidateAppValue(key, value); err != nil {
|
||
|
|
return AppConfig{}, fmt.Errorf("app config: %w", err)
|
||
|
|
}
|
||
|
|
merged[key] = value
|
||
|
|
}
|
||
|
|
hours, _ := strconv.Atoi(merged[KeySessionDuration])
|
||
|
|
return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Rework `../../../backend/internal/config/envconfig.go`**
|
||
|
|
|
||
|
|
In `Config`: delete the `MinConfidence` and `SessionDuration` fields. In `Load()`, the first block becomes (note: no `MinConfidence`, no `SessionDuration` lines anywhere in `Load` anymore):
|
||
|
|
|
||
|
|
```go
|
||
|
|
cfg := Config{
|
||
|
|
Addr: getEnvDefault("GENIUSRUN_BACKEND_ADDR", ":8080"),
|
||
|
|
DBPath: getEnvDefault("GENIUSRUN_DB_PATH", "geniusrun.db"),
|
||
|
|
GarminPythonPath: getEnvDefault("GENIUSRUN_PYTHON_PATH", "python3"),
|
||
|
|
GarminTokenStoreRoot: getEnvDefault("GENIUSRUN_TOKENSTORE_PATH", ".garmin"),
|
||
|
|
LogLevel: getEnvDefault("GENIUSRUN_LOG_LEVEL", "info"),
|
||
|
|
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||
|
|
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||
|
|
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||
|
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||
|
|
OIDCRequiredRole: getEnvDefault("GENIUSRUN_OIDC_REQUIRED_ROLE", "geniusrun-user"),
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Delete `getEnvFloat` and `getEnvDuration` (now unused). Update the `GARMIN_TOKENSTORE` mention in the `TokenStoreRoot` doc comment to `GENIUSRUN_TOKENSTORE_PATH`. Append to the same file (kept next to `Config` so a new env var can't be added without the reviewer seeing this list):
|
||
|
|
|
||
|
|
```go
|
||
|
|
// EnvEntry is one environment-configuration variable as displayed on the
|
||
|
|
// config page. Display-only: secrets are masked here, so raw values never
|
||
|
|
// leave the process.
|
||
|
|
type EnvEntry struct {
|
||
|
|
Name string
|
||
|
|
Value string
|
||
|
|
}
|
||
|
|
|
||
|
|
// DisplayEnv returns the environment configuration as a display-safe
|
||
|
|
// list, in stable order, secrets masked.
|
||
|
|
func (c Config) DisplayEnv() []EnvEntry {
|
||
|
|
mask := func(set bool) string {
|
||
|
|
if set {
|
||
|
|
return "•••• (set)"
|
||
|
|
}
|
||
|
|
return "(unset)"
|
||
|
|
}
|
||
|
|
return []EnvEntry{
|
||
|
|
{Name: "GENIUSRUN_BACKEND_ADDR", Value: c.Addr},
|
||
|
|
{Name: "GENIUSRUN_DB_PATH", Value: c.DBPath},
|
||
|
|
{Name: "GENIUSRUN_PYTHON_PATH", Value: c.GarminPythonPath},
|
||
|
|
{Name: "GENIUSRUN_TOKENSTORE_PATH", Value: c.GarminTokenStoreRoot},
|
||
|
|
{Name: "GENIUSRUN_LOG_LEVEL", Value: c.LogLevel},
|
||
|
|
{Name: "GENIUSRUN_BACKEND_URL", Value: c.BackendURL},
|
||
|
|
{Name: "GENIUSRUN_FRONTEND_URL", Value: c.FrontendURL},
|
||
|
|
{Name: "GENIUSRUN_OIDC_ISSUER_URL", Value: c.OIDCIssuerURL},
|
||
|
|
{Name: "GENIUSRUN_OIDC_CLIENT_ID", Value: c.OIDCClientID},
|
||
|
|
{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: mask(c.OIDCClientSecret != "")},
|
||
|
|
{Name: "GENIUSRUN_OIDC_REQUIRED_ROLE", Value: c.OIDCRequiredRole},
|
||
|
|
{Name: "GENIUSRUN_SESSION_SECRET", Value: mask(len(c.SessionSecret) > 0)},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Update `envconfig_test.go` for the renames/removals** (exact edits)
|
||
|
|
|
||
|
|
- `TestLoad_DerivesOIDCSettingsFromBackendURL`: delete the `cfg.SessionDuration != 720*time.Hour` if-block.
|
||
|
|
- `TestLoad_GarminTokenStoreRootDefaultsIndependentlyOfDBPath` and `TestLoad_GarminTokenStoreRootExplicitOverridesDefault`: `t.Setenv("GARMIN_TOKENSTORE", ...)` → `t.Setenv("GENIUSRUN_TOKENSTORE_PATH", ...)`.
|
||
|
|
- `TestLoad_GarminPythonPathDefaultsToPython3` and `TestLoad_GarminPythonPathExplicitOverridesDefault`: `t.Setenv("GARMIN_WRAPPER_PYTHON", ...)` → `t.Setenv("GENIUSRUN_PYTHON_PATH", ...)`.
|
||
|
|
- `TestLoad_CustomRoleAndDuration`: rename to `TestLoad_CustomRole`; delete the `t.Setenv("GENIUSRUN_SESSION_DURATION", "24h")` line and the `cfg.SessionDuration != 24*time.Hour` if-block.
|
||
|
|
- The `time` import in `envconfig_test.go` becomes unused after these edits — remove it (the new `envconfig_test.go` has its own).
|
||
|
|
|
||
|
|
- [ ] **Step 6: Rewire `backend/cmd/geniusrund/main.go`**
|
||
|
|
|
||
|
|
After the `store.Open` block, insert:
|
||
|
|
|
||
|
|
```go
|
||
|
|
overrides, err := db.ConfigValues(context.Background())
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("app config: %v", err)
|
||
|
|
}
|
||
|
|
appCfg, err := config.LoadApp(overrides)
|
||
|
|
if err != nil {
|
||
|
|
log.Fatalf("app config: %v", err)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
In the `api.NewServer(...)` call: `appsync.Config{MinConfidence: cfg.MinConfidence}` → `appsync.Config{}` (a zero `MinConfidence` already defaults to `classify.DefaultMinConfidence` in `internal/sync/service.go` — that IS the "hard code it"; leave `sync.Config`'s field alone, tests use it), and `Duration: cfg.SessionDuration` → `Duration: appCfg.SessionDuration`.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Verify build + suites**
|
||
|
|
|
||
|
|
Run: `go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||
|
|
Expected: PASS (modulo the known pre-existing failure in Global Constraints).
|
||
|
|
|
||
|
|
- [ ] **Step 8: Update local `backend/.env`** (do not commit)
|
||
|
|
|
||
|
|
Rename in place: the `GARMIN_WRAPPER_PYTHON="..."` line → `GENIUSRUN_PYTHON_PATH="..."` (same value); commented `# GENIUSRUN_ADDR` → `# GENIUSRUN_BACKEND_ADDR`, `# GARMIN_TOKENSTORE=".garmin"` → `# GENIUSRUN_TOKENSTORE_PATH=".garmin"`; delete the `# GENIUSRUN_MIN_CONFIDENCE` and `# GENIUSRUN_SESSION_DURATION` lines.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Update `CLAUDE.md`**
|
||
|
|
|
||
|
|
Replace every mention: `GARMIN_WRAPPER_PYTHON`→`GENIUSRUN_PYTHON_PATH` (Commands + Garmin integration sections), `GARMIN_TOKENSTORE`→`GENIUSRUN_TOKENSTORE_PATH` (Garmin integration section). In the classification section, `GENIUSRUN_MIN_CONFIDENCE` mention → "hard-coded `classify.DefaultMinConfidence` (0.6)". In Authentication: remove `GENIUSRUN_SESSION_DURATION (default 720h)` from optional env vars; add a sentence that session duration is application configuration (`session.duration`, hours, default 720) editable on the `/config` page, applied on restart.
|
||
|
|
|
||
|
|
- [ ] **Step 10: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/config/ cmd/geniusrund/main.go ../CLAUDE.md
|
||
|
|
git commit -m "feat(config): app-config registry, env-var renames, wire session.duration from DB"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 3: `GET`/`PUT /api/config` + env snapshot into the server
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `../../../backend/internal/api/config.go`
|
||
|
|
- Modify: `backend/internal/api/server.go` (Server struct + routes), `backend/cmd/geniusrund/main.go` (EnvVars wiring)
|
||
|
|
- Test: `../../../backend/internal/api/config_test.go`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `config.AppRegistry`, `config.ValidateAppValue` (Task 2); `store.ConfigValues`, `store.SetConfigValue` (Task 1); `(config.Config).DisplayEnv()` (Task 2, in main.go).
|
||
|
|
- Produces: `api.EnvVar{Name, Value string}` (json `name`/`value`), `Server.EnvVars []EnvVar` field; routes `GET /api/config`, `PUT /api/config` behind `requireProvisionedUser`.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write the failing tests**
|
||
|
|
|
||
|
|
`../../../backend/internal/api/config_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package api
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"geniusrun/backend/internal/garmin"
|
||
|
|
"geniusrun/backend/internal/garmin/mock"
|
||
|
|
"geniusrun/backend/internal/store"
|
||
|
|
appsync "geniusrun/backend/internal/sync"
|
||
|
|
|
||
|
|
authmock "geniusrun/backend/internal/auth/mock"
|
||
|
|
)
|
||
|
|
|
||
|
|
type configTestResponse struct {
|
||
|
|
Application []struct {
|
||
|
|
Key string `json:"key"`
|
||
|
|
Value string `json:"value"`
|
||
|
|
Default string `json:"default"`
|
||
|
|
Overridden bool `json:"overridden"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
} `json:"application"`
|
||
|
|
Environment []struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Value string `json:"value"`
|
||
|
|
} `json:"environment"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) {
|
||
|
|
s, _, _ := newTestServer(t)
|
||
|
|
s.EnvVars = []EnvVar{{Name: "GENIUSRUN_OIDC_CLIENT_SECRET", Value: "•••• (set)"}}
|
||
|
|
|
||
|
|
rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil)
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
var resp configTestResponse
|
||
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||
|
|
t.Fatalf("unmarshal: %v", err)
|
||
|
|
}
|
||
|
|
if len(resp.Application) != 1 {
|
||
|
|
t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application))
|
||
|
|
}
|
||
|
|
e := resp.Application[0]
|
||
|
|
if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" {
|
||
|
|
t.Fatalf("unexpected default entry: %+v", e)
|
||
|
|
}
|
||
|
|
if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" {
|
||
|
|
t.Fatalf("env snapshot not passed through: %+v", resp.Environment)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestConfig_PutPersistsValidatesAllOrNothing(t *testing.T) {
|
||
|
|
s, _, _ := newTestServer(t)
|
||
|
|
router := s.Router()
|
||
|
|
|
||
|
|
rec := doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "168"})
|
||
|
|
if rec.Code != http.StatusOK {
|
||
|
|
t.Fatalf("valid PUT status = %d, body = %s", rec.Code, rec.Body.String())
|
||
|
|
}
|
||
|
|
var resp configTestResponse
|
||
|
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||
|
|
if resp.Application[0].Value != "168" || !resp.Application[0].Overridden {
|
||
|
|
t.Fatalf("override not reflected: %+v", resp.Application[0])
|
||
|
|
}
|
||
|
|
|
||
|
|
if rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"bogus.key": "1"}); rec.Code != http.StatusBadRequest {
|
||
|
|
t.Fatalf("unknown key status = %d, want 400", rec.Code)
|
||
|
|
}
|
||
|
|
rec = doJSON(t, router, http.MethodPut, "/api/config", map[string]string{"session.duration": "zero"})
|
||
|
|
if rec.Code != http.StatusBadRequest {
|
||
|
|
t.Fatalf("invalid value status = %d, want 400", rec.Code)
|
||
|
|
}
|
||
|
|
rec = doJSON(t, router, http.MethodGet, "/api/config", nil)
|
||
|
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||
|
|
if resp.Application[0].Value != "168" {
|
||
|
|
t.Fatalf("rejected PUT still changed the value: %+v", resp.Application[0])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Same gate as every data route: an unprovisioned session gets 403, per
|
||
|
|
// the repo's adversarial-isolation testing convention.
|
||
|
|
func TestConfig_RequiresProvisionedUser(t *testing.T) {
|
||
|
|
db, err := store.Open(t.TempDir() + "/config_gate_test.db")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("store.Open: %v", err)
|
||
|
|
}
|
||
|
|
defer db.Close()
|
||
|
|
m := &mock.Client{}
|
||
|
|
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
|
|
|
||
|
|
if rec := doJSON(t, s.Router(), http.MethodGet, "/api/config", nil); rec.Code != http.StatusForbidden {
|
||
|
|
t.Fatalf("GET status = %d, want 403", rec.Code)
|
||
|
|
}
|
||
|
|
if rec := doJSON(t, s.Router(), http.MethodPut, "/api/config", map[string]string{"session.duration": "1"}); rec.Code != http.StatusForbidden {
|
||
|
|
t.Fatalf("PUT status = %d, want 403", rec.Code)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `go test ./internal/api/... -run TestConfig_ -v`
|
||
|
|
Expected: FAIL — `EnvVar` undefined / compile error.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Implement**
|
||
|
|
|
||
|
|
Add to the `Server` struct in `server.go` (after `SyncConfig appsync.Config`):
|
||
|
|
|
||
|
|
```go
|
||
|
|
// EnvVars is the read-only, display-safe environment-configuration
|
||
|
|
// snapshot served by GET /api/config -- built once in main.go from
|
||
|
|
// config.Config.DisplayEnv() (secrets already masked there); handlers
|
||
|
|
// never call os.Getenv.
|
||
|
|
EnvVars []EnvVar
|
||
|
|
```
|
||
|
|
|
||
|
|
Routes, inside the `requireProvisionedUser` group next to `r.Post("/reclassify", ...)`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
r.Get("/config", s.handleGetConfig)
|
||
|
|
r.Put("/config", s.handlePutConfig)
|
||
|
|
```
|
||
|
|
|
||
|
|
`../../../backend/internal/api/config.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package api
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
|
||
|
|
"geniusrun/backend/internal/config"
|
||
|
|
)
|
||
|
|
|
||
|
|
// EnvVar is one read-only environment-configuration entry, already
|
||
|
|
// display-safe (masking happens in config.Config.DisplayEnv).
|
||
|
|
type EnvVar struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
Value string `json:"value"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type appConfigEntry struct {
|
||
|
|
Key string `json:"key"`
|
||
|
|
Value string `json:"value"`
|
||
|
|
Default string `json:"default"`
|
||
|
|
Overridden bool `json:"overridden"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// configView assembles the GET/PUT response: registry defaults overlaid
|
||
|
|
// with DB overrides, plus the env snapshot.
|
||
|
|
func (s *Server) configView(r *http.Request) (map[string]any, error) {
|
||
|
|
overrides, err := s.DB.ConfigValues(r.Context())
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
registry := config.AppRegistry()
|
||
|
|
app := make([]appConfigEntry, 0, len(registry))
|
||
|
|
for _, k := range registry {
|
||
|
|
value, overridden := overrides[k.Key]
|
||
|
|
if !overridden {
|
||
|
|
value = k.Default
|
||
|
|
}
|
||
|
|
app = append(app, appConfigEntry{
|
||
|
|
Key: k.Key, Value: value, Default: k.Default,
|
||
|
|
Overridden: overridden, Description: k.Description,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
envVars := s.EnvVars
|
||
|
|
if envVars == nil {
|
||
|
|
envVars = []EnvVar{}
|
||
|
|
}
|
||
|
|
return map[string]any{"application": app, "environment": envVars}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||
|
|
resp, err := s.configView(r)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, resp)
|
||
|
|
}
|
||
|
|
|
||
|
|
// handlePutConfig updates application configuration. Cold: saved values
|
||
|
|
// take effect on the next backend restart; the response is just the
|
||
|
|
// refreshed view, same shape as GET.
|
||
|
|
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
|
||
|
|
var body map[string]string
|
||
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
// Validate every pair before writing anything -- all-or-nothing.
|
||
|
|
for key, value := range body {
|
||
|
|
if err := config.ValidateAppValue(key, value); err != nil {
|
||
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for key, value := range body {
|
||
|
|
if err := s.DB.SetConfigValue(r.Context(), key, value); err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
resp, err := s.configView(r)
|
||
|
|
if err != nil {
|
||
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
writeJSON(w, http.StatusOK, resp)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Then wire the snapshot in `main.go`, right after the `api.NewServer(...)` call:
|
||
|
|
|
||
|
|
```go
|
||
|
|
for _, e := range cfg.DisplayEnv() {
|
||
|
|
server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
|
|
||
|
|
Run: `go test ./internal/api/... -run TestConfig_ -v` then `go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||
|
|
Expected: PASS (modulo the known pre-existing failure).
|
||
|
|
|
||
|
|
- [ ] **Step 5: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add internal/api/envconfig.go internal/api/envconfig_test.go internal/api/server.go cmd/geniusrund/main.go
|
||
|
|
git commit -m "feat(api): GET/PUT /api/config serving app + env configuration"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 4: frontend — `/config` page and icon-only header
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `frontend/src/types/api.ts`, `frontend/src/api/client.ts`
|
||
|
|
- Create: `frontend/src/pages/Config.tsx`
|
||
|
|
- Modify: `frontend/src/App.tsx`, `frontend/src/App.css`
|
||
|
|
|
||
|
|
**Interfaces:**
|
||
|
|
- Consumes: `GET/PUT /api/config` (Task 3 shapes, exactly).
|
||
|
|
- Produces: `api.getConfig(): Promise<ConfigResponse>`, `api.updateConfig(values: Record<string,string>)`, `<Config />` page, header buttons 👤/⚙️/✕.
|
||
|
|
|
||
|
|
- [ ] **Step 1: Types** — append to `frontend/src/types/api.ts`:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
export interface AppConfigEntry {
|
||
|
|
key: string;
|
||
|
|
value: string;
|
||
|
|
default: string;
|
||
|
|
overridden: boolean;
|
||
|
|
description: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface EnvVarEntry {
|
||
|
|
name: string;
|
||
|
|
value: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ConfigResponse {
|
||
|
|
application: AppConfigEntry[];
|
||
|
|
environment: EnvVarEntry[];
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Client** — in `frontend/src/api/client.ts`, import `ConfigResponse` and add before the `// Progression` block:
|
||
|
|
|
||
|
|
```ts
|
||
|
|
// Application/environment configuration (the /config page). Cold: saved
|
||
|
|
// values take effect after the backend restarts.
|
||
|
|
getConfig: () => request<ConfigResponse>("/api/config"),
|
||
|
|
updateConfig: (values: Record<string, string>) =>
|
||
|
|
request<ConfigResponse>("/api/config", { method: "PUT", body: JSON.stringify(values) }),
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Page** — create `frontend/src/pages/Config.tsx`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { api } from "../api/client";
|
||
|
|
import { showError, showSuccess } from "../banner";
|
||
|
|
import type { ConfigResponse } from "../types/api";
|
||
|
|
|
||
|
|
// Reached only via the header's gear button (or typing /config) -- not a
|
||
|
|
// nav tab. Application configuration is instance-global and cold: the
|
||
|
|
// backend reads it once at startup, so saves apply on the next restart.
|
||
|
|
export function Config() {
|
||
|
|
const [config, setConfig] = useState<ConfigResponse | null>(null);
|
||
|
|
const [edits, setEdits] = useState<Record<string, string>>({});
|
||
|
|
const [saving, setSaving] = useState(false);
|
||
|
|
const [loadFailed, setLoadFailed] = useState(false);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
api
|
||
|
|
.getConfig()
|
||
|
|
.then(setConfig)
|
||
|
|
.catch((e) => {
|
||
|
|
setLoadFailed(true);
|
||
|
|
showError(e instanceof Error ? e.message : String(e));
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
if (!config) {
|
||
|
|
return (
|
||
|
|
<div className="page">
|
||
|
|
{loadFailed ? <p className="empty-state">Couldn't load configuration.</p> : <p>Loading...</p>}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const dirty = config.application.filter(
|
||
|
|
(entry) => edits[entry.key] !== undefined && edits[entry.key] !== entry.value,
|
||
|
|
);
|
||
|
|
|
||
|
|
async function save() {
|
||
|
|
setSaving(true);
|
||
|
|
try {
|
||
|
|
const updated = await api.updateConfig(
|
||
|
|
Object.fromEntries(dirty.map((entry) => [entry.key, edits[entry.key]])),
|
||
|
|
);
|
||
|
|
setConfig(updated);
|
||
|
|
setEdits({});
|
||
|
|
showSuccess("Saved — changes take effect after the backend restarts.");
|
||
|
|
} catch (e) {
|
||
|
|
showError(e instanceof Error ? e.message : String(e));
|
||
|
|
} finally {
|
||
|
|
setSaving(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="page">
|
||
|
|
<fieldset className="kind-editor">
|
||
|
|
<legend>Application configuration</legend>
|
||
|
|
<p className="field-hint">
|
||
|
|
Stored in the database, shared by all users. Changes take effect after the backend restarts.
|
||
|
|
</p>
|
||
|
|
{config.application.map((entry) => (
|
||
|
|
<label key={entry.key}>
|
||
|
|
{entry.key}
|
||
|
|
<input
|
||
|
|
value={edits[entry.key] ?? entry.value}
|
||
|
|
onChange={(e) => setEdits({ ...edits, [entry.key]: e.target.value })}
|
||
|
|
/>
|
||
|
|
<span className="field-hint">
|
||
|
|
{entry.description} (default: {entry.default})
|
||
|
|
</span>
|
||
|
|
</label>
|
||
|
|
))}
|
||
|
|
<button type="button" disabled={saving || dirty.length === 0} onClick={save}>
|
||
|
|
Save
|
||
|
|
</button>
|
||
|
|
</fieldset>
|
||
|
|
<fieldset className="kind-editor">
|
||
|
|
<legend>Environment configuration</legend>
|
||
|
|
<p className="field-hint">Read-only — set through environment variables on the backend process.</p>
|
||
|
|
<dl className="env-config">
|
||
|
|
{config.environment.map((entry) => (
|
||
|
|
<div key={entry.name}>
|
||
|
|
<dt>{entry.name}</dt>
|
||
|
|
<dd>{entry.value === "" ? "(unset)" : entry.value}</dd>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</dl>
|
||
|
|
</fieldset>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: `App.tsx`** — pathname-driven config view + icon header. Add `import { Config } from "./pages/Config";`. Replace the state/handlers at the top of `App`:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
function App({ session }: { session: SessionInfo }) {
|
||
|
|
const [tab, setTab] = useState<TabKey>("activities");
|
||
|
|
// Profile and Config aren't tabs: Profile is account settings (reached
|
||
|
|
// via the avatar button), Config is instance settings (gear button,
|
||
|
|
// pathname-addressable as /config so it can be deep-linked).
|
||
|
|
const [showProfile, setShowProfile] = useState(false);
|
||
|
|
const [showConfig, setShowConfig] = useState(window.location.pathname === "/config");
|
||
|
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
api.getProfile().then((p) => setProfileName(p.Name)).catch(() => {});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const onPop = () => setShowConfig(window.location.pathname === "/config");
|
||
|
|
window.addEventListener("popstate", onPop);
|
||
|
|
return () => window.removeEventListener("popstate", onPop);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
function openConfig() {
|
||
|
|
if (window.location.pathname !== "/config") history.pushState({}, "", "/config");
|
||
|
|
setShowProfile(false);
|
||
|
|
setShowConfig(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Leaving the config view restores the root URL so /config always means
|
||
|
|
// "config is showing" and browser back/forward keep working.
|
||
|
|
function leaveConfig() {
|
||
|
|
if (window.location.pathname === "/config") history.pushState({}, "", "/");
|
||
|
|
setShowConfig(false);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Tab buttons: `className={!showProfile && !showConfig && t.key === tab ? "tab active" : "tab"}` and
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
onClick={() => {
|
||
|
|
leaveConfig();
|
||
|
|
setShowProfile(false);
|
||
|
|
setTab(t.key);
|
||
|
|
}}
|
||
|
|
```
|
||
|
|
|
||
|
|
Header actions block becomes:
|
||
|
|
|
||
|
|
```tsx
|
||
|
|
<div className="header-actions">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={showProfile ? "icon-button active" : "icon-button"}
|
||
|
|
title={profileName ?? "Profile"}
|
||
|
|
aria-label={profileName ?? "Profile"}
|
||
|
|
onClick={() => {
|
||
|
|
leaveConfig();
|
||
|
|
setShowProfile(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
👤
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className={showConfig ? "icon-button active" : "icon-button"}
|
||
|
|
title="Settings"
|
||
|
|
aria-label="Settings"
|
||
|
|
onClick={openConfig}
|
||
|
|
>
|
||
|
|
⚙️
|
||
|
|
</button>
|
||
|
|
<form method="post" action={`${BASE_URL}/api/session/logout`} title={session.email}>
|
||
|
|
<button type="submit" className="icon-button" aria-label="Log out" title="Log out">
|
||
|
|
✕
|
||
|
|
</button>
|
||
|
|
</form>
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
Main render: `<main>{showConfig ? <Config /> : showProfile ? <Profile onSaved={(p) => setProfileName(p.Name)} /> : <Active />}</main>`.
|
||
|
|
|
||
|
|
- [ ] **Step 5: `App.css`** — delete the `.profile-name` blocks (base, `::before`, `:hover`, `.active` — the ⚙️ `::before` glyph moves conceptually to the settings button) and both `.logout-link` blocks; add in their place:
|
||
|
|
|
||
|
|
```css
|
||
|
|
/* Icon-only header buttons (profile / settings / log out): circular chips,
|
||
|
|
deliberately unlike the rectangular .tab buttons -- these are account/
|
||
|
|
instance actions, not application views. No visible text: the glyph is
|
||
|
|
the label (full text lives in title/aria-label). */
|
||
|
|
.icon-button {
|
||
|
|
display: inline-flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: center;
|
||
|
|
width: 2.2rem;
|
||
|
|
height: 2.2rem;
|
||
|
|
background: #1a1d24;
|
||
|
|
border: 1px solid #2a2d35;
|
||
|
|
color: #9aa0ab;
|
||
|
|
border-radius: 999px;
|
||
|
|
cursor: pointer;
|
||
|
|
font-size: 0.95rem;
|
||
|
|
}
|
||
|
|
|
||
|
|
.icon-button:hover:not(.active) {
|
||
|
|
border-color: #3b82f6;
|
||
|
|
color: #e6e6e6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.icon-button.active {
|
||
|
|
background: #3b82f6;
|
||
|
|
border-color: #3b82f6;
|
||
|
|
color: white;
|
||
|
|
}
|
||
|
|
|
||
|
|
.env-config div {
|
||
|
|
display: flex;
|
||
|
|
gap: 1rem;
|
||
|
|
padding: 0.25rem 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.env-config dt {
|
||
|
|
min-width: 18rem;
|
||
|
|
color: #9aa0ab;
|
||
|
|
font-family: monospace;
|
||
|
|
}
|
||
|
|
|
||
|
|
.env-config dd {
|
||
|
|
margin: 0;
|
||
|
|
font-family: monospace;
|
||
|
|
overflow-wrap: anywhere;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Build + lint**
|
||
|
|
|
||
|
|
Run: `npm run build && npm run lint`
|
||
|
|
Expected: clean (pre-existing PaceField fast-refresh warnings are fine).
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add src/types/api.ts src/api/client.ts src/pages/Config.tsx src/App.tsx src/App.css
|
||
|
|
git commit -m "feat(frontend): /config page + icon-only header (profile/settings/logout)"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Task 5: end-to-end verification (live, no mocks)
|
||
|
|
|
||
|
|
**Files:** none (verification only; local `backend/geniusrun.db*` deleted).
|
||
|
|
|
||
|
|
- [ ] **Step 1: Recreate the local DB** — the schema gained a table and `Open()` skips schema application on existing DBs. ⚠️ Destructive to local synced data (re-syncable from Garmin); this is the CLAUDE.md-sanctioned remedy, but **confirm with the user before deleting**. Then: stop any running backend and `rm backend/geniusrun.db backend/geniusrun.db-shm backend/geniusrun.db-wal`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Start both servers** — `./backend/start.sh` (verify it boots with the renamed `.env` vars; a failure here means Task 2 Step 8 was missed) and `./frontend/start.sh`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Live click-through** (real login, no route mocking, per CLAUDE.md):
|
||
|
|
- Header shows 👤 ⚙️ ✕ icon-only buttons; hover shows titles; profile button still opens the Profile page.
|
||
|
|
- ⚙️ navigates to `/config` (URL bar shows `/config`); browser Back returns to the app; a direct load of `http://localhost:5173/config` lands on the page after login.
|
||
|
|
- Page shows `session.duration` = 720 (default), env list with `GENIUSRUN_OIDC_CLIENT_SECRET` and `GENIUSRUN_SESSION_SECRET` shown as `•••• (set)`.
|
||
|
|
- Set `session.duration` to `168`, Save → success banner mentioning restart; reload page → value 168.
|
||
|
|
- Enter `0` and Save → error banner (400), value unchanged after reload.
|
||
|
|
- Restart the backend and log in again — boot must succeed reading the override, and login must still work.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Full suites one last time** — `go build ./... && go vet ./... && gofmt -l . && go test ./...` (backend), `npm run build && npm run lint` (frontend). Report the known pre-existing failure separately if still present.
|