Compare commits
4 Commits
db2e8e487d
...
65a2dc90d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 65a2dc90d0 | |||
| 3b2b5d0735 | |||
| d308201803 | |||
| 099e746119 |
@@ -36,7 +36,7 @@ Beyond the login gate, every authenticated+authorized OIDC subject maps 1:1 to i
|
|||||||
|
|
||||||
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
A brand-new OIDC subject with no `users` row yet is routed to a "Create your profile" screen (`frontend/src/CreateProfile.tsx`) instead of the app. `POST /api/setup` (display name only) provisions it — a `users` row, a default `profile` row, the 8-kind workout taxonomy, and an initial `sync_state` row, all in one transaction (`store.ProvisionUser`). Garmin credentials, HR zones, etc. are filled in afterward via the normal Profile screen, same as any fresh install.
|
||||||
|
|
||||||
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_PUBLIC_BASE_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
Required env vars (`config.Load()` fails fast if any are unset, same pattern as the Garmin paths above): `GENIUSRUN_BACKEND_URL`, `GENIUSRUN_OIDC_ISSUER_URL`, `GENIUSRUN_OIDC_CLIENT_ID`, `GENIUSRUN_OIDC_CLIENT_SECRET`, `GENIUSRUN_SESSION_SECRET` (>=32 characters). Optional: `GENIUSRUN_FRONTEND_URL` (defaults to `GENIUSRUN_BACKEND_URL` -- set this when the frontend and backend are different origins, e.g. local dev), `GENIUSRUN_OIDC_REQUIRED_ROLE`, `GENIUSRUN_SESSION_DURATION` (default `720h`). See `docs/superpowers/specs/2026-07-24-oidc-authentication-design.md` for the original login-gate design and `internal/auth` for its implementation; the per-user data model on top of it lives in `internal/store` (schema/queries) and `internal/api/usercontext.go`+`setup.go` (HTTP layer).
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ func main() {
|
|||||||
}, appsync.Config{
|
}, appsync.Config{
|
||||||
MinConfidence: cfg.MinConfidence,
|
MinConfidence: cfg.MinConfidence,
|
||||||
}, authVerifier, api.SessionConfig{
|
}, authVerifier, api.SessionConfig{
|
||||||
Secret: cfg.SessionSecret,
|
Secret: cfg.SessionSecret,
|
||||||
Duration: cfg.SessionDuration,
|
Duration: cfg.SessionDuration,
|
||||||
Secure: cfg.SessionSecure,
|
Secure: cfg.SessionSecure,
|
||||||
PublicBaseURL: cfg.PublicBaseURL,
|
BackendURL: cfg.BackendURL,
|
||||||
FrontendURL: cfg.FrontendURL,
|
FrontendURL: cfg.FrontendURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ import (
|
|||||||
func newCtx() context.Context { return context.Background() }
|
func newCtx() context.Context { return context.Background() }
|
||||||
|
|
||||||
var testSessionConfig = SessionConfig{
|
var testSessionConfig = SessionConfig{
|
||||||
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
Secret: []byte("test-session-secret-at-least-32-bytes-long"),
|
||||||
Duration: time.Hour,
|
Duration: time.Hour,
|
||||||
Secure: false,
|
Secure: false,
|
||||||
PublicBaseURL: "https://geniusrun.example.com",
|
BackendURL: "https://geniusrun.example.com",
|
||||||
FrontendURL: "https://app.geniusrun.example.com",
|
FrontendURL: "https://app.geniusrun.example.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
func newTestServer(t *testing.T) (*Server, *store.DB, int64) {
|
||||||
@@ -923,3 +923,13 @@ func TestSessionLogout_ClearsSessionCookieAndRedirectsToEndSession(t *testing.T)
|
|||||||
t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
|
t.Fatalf("expected session cookie to be cleared, got %+v", cleared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSessionLogout_PostLogoutRedirectUsesFrontendURL(t *testing.T) {
|
||||||
|
verifier := &authmock.Verifier{} // EndSessionResult unset: echoes back postLogoutRedirectURL unchanged
|
||||||
|
s, _ := newTestServerWithAuth(t, verifier)
|
||||||
|
rec := doJSON(t, s.Router(), http.MethodPost, "/api/session/logout", nil)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound || rec.Header().Get("Location") != testSessionConfig.FrontendURL+"/" {
|
||||||
|
t.Fatalf("status = %d, Location = %q, want post_logout_redirect_uri = %q", rec.Code, rec.Header().Get("Location"), testSessionConfig.FrontendURL+"/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,15 +15,19 @@ type SessionConfig struct {
|
|||||||
Secret []byte
|
Secret []byte
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Secure bool
|
Secure bool
|
||||||
// PublicBaseURL is this app's own externally reachable origin (e.g.
|
// BackendURL is this app's own externally reachable origin (e.g.
|
||||||
// "https://geniusrun.example.com", no trailing slash), used to build an
|
// "https://geniusrun.example.com", no trailing slash) -- derives
|
||||||
// absolute post_logout_redirect_uri for the identity provider -- some
|
// OIDCRedirectURL (config.Config), the only thing that must stay pointed
|
||||||
// providers, including Keycloak, require this to be an absolute URL
|
// at the backend itself, since that's where /api/session/callback is
|
||||||
// matching one registered on the client, not a bare relative path.
|
// actually served.
|
||||||
PublicBaseURL string
|
BackendURL string
|
||||||
// FrontendURL is the origin the browser should land on after the OIDC
|
// FrontendURL is the origin the browser should land on after any
|
||||||
// callback (success or failure) -- see config.Config.FrontendURL for why
|
// user-facing redirect: the OIDC callback (success or failure) and the
|
||||||
// this can differ from PublicBaseURL in a split-origin deployment.
|
// post_logout_redirect_uri sent to the identity provider on logout. Some
|
||||||
|
// providers, including Keycloak, require an absolute URL matching one
|
||||||
|
// registered on the client, not a bare relative path -- see
|
||||||
|
// config.Config.FrontendURL for why this can differ from BackendURL in a
|
||||||
|
// split-origin deployment.
|
||||||
FrontendURL string
|
FrontendURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||||||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.PublicBaseURL+"/"), http.StatusFound)
|
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/"), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -38,20 +38,20 @@ type Config struct {
|
|||||||
MinConfidence float64
|
MinConfidence float64
|
||||||
IncrementalSyncEvery time.Duration
|
IncrementalSyncEvery time.Duration
|
||||||
|
|
||||||
// OIDC login gate (Keycloak). PublicBaseURL is this app's own externally
|
// OIDC login gate (Keycloak). BackendURL is this app's own externally
|
||||||
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
// reachable origin (e.g. "https://geniusrun.example.com") -- it derives
|
||||||
// OIDCRedirectURL and whether session cookies can be marked Secure,
|
// OIDCRedirectURL and whether session cookies can be marked Secure,
|
||||||
// instead of requiring both to be configured separately and risking them
|
// instead of requiring both to be configured separately and risking them
|
||||||
// drifting out of sync.
|
// drifting out of sync.
|
||||||
PublicBaseURL string
|
BackendURL string
|
||||||
// FrontendURL is the origin the browser should land on after the OIDC
|
// FrontendURL is the origin the browser should land on after the OIDC
|
||||||
// callback (both success and failure) -- e.g. "http://localhost:5173" in
|
// callback (both success and failure) -- e.g. "http://localhost:5173" in
|
||||||
// local dev, where the frontend and backend are different origins
|
// local dev, where the frontend and backend are different origins
|
||||||
// bridged by CORS (see internal/api's corsMiddleware and
|
// bridged by CORS (see internal/api's corsMiddleware and
|
||||||
// frontend/src/api/client.ts's BASE_URL). Defaults to PublicBaseURL when
|
// frontend/src/api/client.ts's BASE_URL). Defaults to BackendURL when
|
||||||
// unset, which is correct for the common production topology where a
|
// unset, which is correct for the common production topology where a
|
||||||
// reverse proxy unifies frontend and backend under one origin.
|
// reverse proxy unifies frontend and backend under one origin.
|
||||||
// PublicBaseURL itself must stay pointed at the backend's own origin
|
// BackendURL itself must stay pointed at the backend's own origin
|
||||||
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
|
// regardless -- it derives OIDCRedirectURL and post_logout_redirect_uri,
|
||||||
// which must match wherever those routes are actually served.
|
// which must match wherever those routes are actually served.
|
||||||
FrontendURL string
|
FrontendURL string
|
||||||
@@ -75,7 +75,7 @@ func Load() (Config, error) {
|
|||||||
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
GarminTokenStoreRoot: os.Getenv("GARMIN_TOKENSTORE"),
|
||||||
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
MinConfidence: getEnvFloat("GENIUSRUN_MIN_CONFIDENCE", 0.6),
|
||||||
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
IncrementalSyncEvery: getEnvDuration("GENIUSRUN_INCREMENTAL_SYNC_EVERY", 6*time.Hour),
|
||||||
PublicBaseURL: strings.TrimRight(os.Getenv("GENIUSRUN_PUBLIC_BASE_URL"), "/"),
|
BackendURL: strings.TrimRight(os.Getenv("GENIUSRUN_BACKEND_URL"), "/"),
|
||||||
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
OIDCIssuerURL: os.Getenv("GENIUSRUN_OIDC_ISSUER_URL"),
|
||||||
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("GENIUSRUN_OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("GENIUSRUN_OIDC_CLIENT_SECRET"),
|
||||||
@@ -88,10 +88,10 @@ func Load() (Config, error) {
|
|||||||
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
log.Printf("GARMIN_TOKENSTORE not set, defaulting to %q", cfg.GarminTokenStoreRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.PublicBaseURL == "" {
|
if cfg.BackendURL == "" {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_PUBLIC_BASE_URL is required (e.g. https://geniusrun.example.com)")
|
return cfg, fmt.Errorf("GENIUSRUN_BACKEND_URL is required (e.g. https://geniusrun.example.com)")
|
||||||
}
|
}
|
||||||
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.PublicBaseURL), "/")
|
cfg.FrontendURL = strings.TrimRight(getEnvDefault("GENIUSRUN_FRONTEND_URL", cfg.BackendURL), "/")
|
||||||
if cfg.OIDCIssuerURL == "" {
|
if cfg.OIDCIssuerURL == "" {
|
||||||
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
return cfg, fmt.Errorf("GENIUSRUN_OIDC_ISSUER_URL is required")
|
||||||
}
|
}
|
||||||
@@ -106,8 +106,8 @@ func Load() (Config, error) {
|
|||||||
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
return cfg, fmt.Errorf("GENIUSRUN_SESSION_SECRET is required and must be at least 32 characters")
|
||||||
}
|
}
|
||||||
cfg.SessionSecret = []byte(sessionSecret)
|
cfg.SessionSecret = []byte(sessionSecret)
|
||||||
cfg.OIDCRedirectURL = cfg.PublicBaseURL + "/api/session/callback"
|
cfg.OIDCRedirectURL = cfg.BackendURL + "/api/session/callback"
|
||||||
cfg.SessionSecure = strings.HasPrefix(cfg.PublicBaseURL, "https://")
|
cfg.SessionSecure = strings.HasPrefix(cfg.BackendURL, "https://")
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
|
|
||||||
func setRequiredEnv(t *testing.T) {
|
func setRequiredEnv(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "https://geniusrun.example.com")
|
t.Setenv("GENIUSRUN_BACKEND_URL", "https://geniusrun.example.com")
|
||||||
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
|
t.Setenv("GENIUSRUN_OIDC_ISSUER_URL", "https://keycloak.example.com/realms/myrealm")
|
||||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
|
t.Setenv("GENIUSRUN_OIDC_CLIENT_ID", "geniusrun")
|
||||||
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
|
t.Setenv("GENIUSRUN_OIDC_CLIENT_SECRET", "client-secret")
|
||||||
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
|
t.Setenv("GENIUSRUN_SESSION_SECRET", "a-session-secret-that-is-at-least-32-bytes-long")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) {
|
func TestLoad_DerivesOIDCSettingsFromBackendURL(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
@@ -38,7 +38,7 @@ func TestLoad_DerivesOIDCSettingsFromPublicBaseURL(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_PUBLIC_BASE_URL", "http://localhost:8080")
|
t.Setenv("GENIUSRUN_BACKEND_URL", "http://localhost:8080")
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -54,7 +54,7 @@ func TestLoad_HTTPBaseURLYieldsInsecureCookies(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
func TestLoad_MissingRequiredOIDCVars(t *testing.T) {
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"GENIUSRUN_PUBLIC_BASE_URL",
|
"GENIUSRUN_BACKEND_URL",
|
||||||
"GENIUSRUN_OIDC_ISSUER_URL",
|
"GENIUSRUN_OIDC_ISSUER_URL",
|
||||||
"GENIUSRUN_OIDC_CLIENT_ID",
|
"GENIUSRUN_OIDC_CLIENT_ID",
|
||||||
"GENIUSRUN_OIDC_CLIENT_SECRET",
|
"GENIUSRUN_OIDC_CLIENT_SECRET",
|
||||||
@@ -134,7 +134,7 @@ func TestLoad_GarminPythonPathExplicitOverridesDefault(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
|
func TestLoad_FrontendURLDefaultsToBackendURL(t *testing.T) {
|
||||||
setRequiredEnv(t)
|
setRequiredEnv(t)
|
||||||
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
t.Setenv("GENIUSRUN_FRONTEND_URL", "")
|
||||||
|
|
||||||
@@ -142,8 +142,8 @@ func TestLoad_FrontendURLDefaultsToPublicBaseURL(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if cfg.FrontendURL != cfg.PublicBaseURL {
|
if cfg.FrontendURL != cfg.BackendURL {
|
||||||
t.Errorf("FrontendURL = %q, want it to default to PublicBaseURL %q", cfg.FrontendURL, cfg.PublicBaseURL)
|
t.Errorf("FrontendURL = %q, want it to default to BackendURL %q", cfg.FrontendURL, cfg.BackendURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,8 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
# GARMIN_WRAPPER_PYTHON is optional (config.Load() defaults it to "python3"
|
set -a
|
||||||
# on PATH). Export it yourself only if you want a specific interpreter, e.g.
|
source .env
|
||||||
# a local venv with garminconnect installed.
|
set +a
|
||||||
export GENIUSRUN_DB_PATH="${GENIUSRUN_DB_PATH:-geniusrun.db}"
|
|
||||||
|
|
||||||
# OIDC login gate -- no safe default exists for a client secret or a
|
|
||||||
# session-signing key, so these must come from your own shell/CI secrets.
|
|
||||||
export GENIUSRUN_PUBLIC_BASE_URL="${GENIUSRUN_PUBLIC_BASE_URL:?set GENIUSRUN_PUBLIC_BASE_URL, e.g. https://geniusrun.example.com}"
|
|
||||||
export GENIUSRUN_OIDC_ISSUER_URL="${GENIUSRUN_OIDC_ISSUER_URL:?set GENIUSRUN_OIDC_ISSUER_URL, e.g. https://keycloak.example.com/realms/myrealm}"
|
|
||||||
export GENIUSRUN_OIDC_CLIENT_ID="${GENIUSRUN_OIDC_CLIENT_ID:?set GENIUSRUN_OIDC_CLIENT_ID}"
|
|
||||||
export GENIUSRUN_OIDC_CLIENT_SECRET="${GENIUSRUN_OIDC_CLIENT_SECRET:?set GENIUSRUN_OIDC_CLIENT_SECRET}"
|
|
||||||
export GENIUSRUN_SESSION_SECRET="${GENIUSRUN_SESSION_SECRET:?set GENIUSRUN_SESSION_SECRET to a random string >=32 characters}"
|
|
||||||
|
|
||||||
exec go run ./cmd/geniusrund
|
exec go run ./cmd/geniusrund
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ for that history) and remove it from here once a spec exists.
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
- add dedicated workouts in database (instead of hard linking them in activities)
|
- improve first connection page: in CreateProfile.tsx, in addition to the display name, we should directly ask garmin login/password and manage MFA from there, so the synchronization experience in profile page will be easier later on
|
||||||
- better UX for MFA management
|
- profile deletion: add a way to delete our profile with a dedicated button on the profile page (which by extension will also logout the user, to be consistent with the logic that login will trigger the profile creation)
|
||||||
- better UX for activities/workout download (progress bar, error management)
|
- dedicated workouts section: add workouts table in database and create a dedicated page in UI (use an "objective" icon)
|
||||||
|
- improve activities/workouts download: make it modal, add progress bar, error management
|
||||||
- better UX when backend is not available (instead of "TypeError failed to fetch")
|
- better UX when backend is not available (instead of "TypeError failed to fetch")
|
||||||
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
|
- better UX for activities (list layout without the graphs, detailed view with different graphs for each phase incl. delta with workout)
|
||||||
- add icon to indicate if an activity has an associated workout
|
- add icon to indicate if an activity has an associated workout
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api, BASE_URL } from "./api/client";
|
import { api, BASE_URL } from "./api/client";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Activities } from "./pages/Activities";
|
||||||
|
import { Analysis } from "./pages/Analysis";
|
||||||
import { Plan } from "./pages/Plan";
|
import { Plan } from "./pages/Plan";
|
||||||
import { Profile } from "./pages/Profile";
|
import { Profile } from "./pages/Profile";
|
||||||
import { ReviewQueue } from "./pages/ReviewQueue";
|
|
||||||
import type { SessionInfo } from "./types/api";
|
import type { SessionInfo } from "./types/api";
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ key: "activities", label: "Activities", icon: "☰", Component: ReviewQueue },
|
{ key: "activities", label: "Activities", icon: "☰", Component: Activities },
|
||||||
{ key: "dashboard", label: "Progression", icon: "↗", Component: Dashboard },
|
{ key: "analysis", label: "Analysis", icon: "🔍", Component: Analysis },
|
||||||
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
{ key: "plan", label: "Training plan", icon: "✎", Component: Plan },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ function App({ session }: { session: SessionInfo }) {
|
|||||||
const [tab, setTab] = useState<TabKey>("activities");
|
const [tab, setTab] = useState<TabKey>("activities");
|
||||||
// Profile isn't a tab: it's reached via the profile name in the top-right
|
// Profile isn't a tab: it's reached via the profile name in the top-right
|
||||||
// corner instead, since (for now, single-profile) it's account settings,
|
// corner instead, since (for now, single-profile) it's account settings,
|
||||||
// not a content view alongside Activities/Progression/Training plan.
|
// not a content view alongside Activities/Analysis/Training plan.
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
const [profileName, setProfileName] = useState<string | null>(null);
|
const [profileName, setProfileName] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ function matchesFilter(item: ReviewQueueItem, filter: string): boolean {
|
|||||||
return item.WorkoutKindID === Number(filter);
|
return item.WorkoutKindID === Number(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewQueue() {
|
export function Activities() {
|
||||||
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
const [items, setItems] = useState<ReviewQueueItem[]>([]);
|
||||||
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
const [grandTotal, setGrandTotal] = useState(0); // unfiltered count, for the "All" pill
|
||||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||||
@@ -12,7 +12,7 @@ const METRICS: { key: ProgressionMetric; label: string }[] = [
|
|||||||
{ key: "efficiency_factor", label: "Efficiency Factor" },
|
{ key: "efficiency_factor", label: "Efficiency Factor" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Analysis() {
|
||||||
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
const [kinds, setKinds] = useState<WorkoutKind[]>([]);
|
||||||
const [selectedKindId, setSelectedKindId] = useState<number | null>(null);
|
const [selectedKindId, setSelectedKindId] = useState<number | null>(null);
|
||||||
const [metric, setMetric] = useState<ProgressionMetric>("pace");
|
const [metric, setMetric] = useState<ProgressionMetric>("pace");
|
||||||
Reference in New Issue
Block a user