From b217747ab61662c1c42801c8bdf905e22c84cb2a Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:01:58 -0800 Subject: [PATCH] Add Summit Stride 5K training tracker at /running Node/Express backend with SQLite storage, WebAuthn passkey auth, and React frontend built via Vite. Caddy routes /running/* via handle_path labels on docker-compose. --- apps/running/Dockerfile | 18 + apps/running/package.json | 15 + apps/running/server.js | 301 +++++++++++++ apps/running/src/App.tsx | 440 ++++++++++++++++++++ apps/running/src/components/WorkoutCard.tsx | 104 +++++ apps/running/src/constants.ts | 89 ++++ apps/running/src/index.html | 21 + apps/running/src/index.tsx | 15 + apps/running/src/package.json | 24 ++ apps/running/src/tsconfig.json | 14 + apps/running/src/types.ts | 31 ++ apps/running/src/vite.config.ts | 21 + docker-compose.yml | 16 + 13 files changed, 1109 insertions(+) create mode 100644 apps/running/Dockerfile create mode 100644 apps/running/package.json create mode 100644 apps/running/server.js create mode 100644 apps/running/src/App.tsx create mode 100644 apps/running/src/components/WorkoutCard.tsx create mode 100644 apps/running/src/constants.ts create mode 100644 apps/running/src/index.html create mode 100644 apps/running/src/index.tsx create mode 100644 apps/running/src/package.json create mode 100644 apps/running/src/tsconfig.json create mode 100644 apps/running/src/types.ts create mode 100644 apps/running/src/vite.config.ts diff --git a/apps/running/Dockerfile b/apps/running/Dockerfile new file mode 100644 index 0000000..46c27ec --- /dev/null +++ b/apps/running/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: Build frontend +FROM node:20-alpine AS builder +WORKDIR /app/src +COPY src/package.json ./ +RUN npm install +COPY src/ ./ +RUN npm run build + +# Stage 2: Production server +FROM node:20-alpine +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev +COPY server.js ./ +COPY --from=builder /app/src/dist ./public +RUN mkdir -p /app/data +EXPOSE 8080 +CMD ["node", "server.js"] diff --git a/apps/running/package.json b/apps/running/package.json new file mode 100644 index 0000000..0796c22 --- /dev/null +++ b/apps/running/package.json @@ -0,0 +1,15 @@ +{ + "name": "running-server", + "private": true, + "version": "1.0.0", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@simplewebauthn/server": "^11.0.0", + "better-sqlite3": "^11.7.0", + "connect-sqlite3": "^0.9.15", + "express": "^4.21.0", + "express-session": "^1.18.1" + } +} diff --git a/apps/running/server.js b/apps/running/server.js new file mode 100644 index 0000000..ee2a493 --- /dev/null +++ b/apps/running/server.js @@ -0,0 +1,301 @@ +const express = require("express"); +const path = require("path"); +const Database = require("better-sqlite3"); +const session = require("express-session"); +const SQLiteStoreFactory = require("connect-sqlite3"); +const { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} = require("@simplewebauthn/server"); + +const app = express(); +const PORT = 8080; + +const RP_NAME = "James Van Boxtel"; +const RP_ID = process.env.RP_ID || "jamesvanboxtel.com"; +const ORIGIN = process.env.ORIGIN || "https://jamesvanboxtel.com"; + +// Database setup +const dataDir = path.join(__dirname, "data"); +const db = new Database(path.join(dataDir, "running.db")); +db.pragma("journal_mode = WAL"); +db.pragma("foreign_keys = ON"); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + approved INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS credentials ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + public_key BLOB NOT NULL, + counter INTEGER DEFAULT 0, + transports TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP + ); + + CREATE TABLE IF NOT EXISTS progress ( + key TEXT PRIMARY KEY, + completed INTEGER DEFAULT 1, + updated_by INTEGER REFERENCES users(id), + updated_at TEXT DEFAULT CURRENT_TIMESTAMP + ); +`); + +// Session setup +const SQLiteStore = SQLiteStoreFactory(session); +app.use( + session({ + store: new SQLiteStore({ dir: dataDir, db: "sessions.db" }), + secret: process.env.SESSION_SECRET || "summit-stride-secret-change-me", + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days + sameSite: "lax", + }, + }) +); + +app.use(express.json()); + +// In-memory challenge store (keyed by session ID, short-lived) +const challengeStore = new Map(); + +function requireAuth(req, res, next) { + if (!req.session.userId) { + return res.status(401).json({ error: "Not authenticated" }); + } + const user = db.prepare("SELECT * FROM users WHERE id = ?").get(req.session.userId); + if (!user || !user.approved) { + return res.status(403).json({ error: "Not approved" }); + } + req.user = user; + next(); +} + +// --- Progress API --- + +app.get("/api/progress", (req, res) => { + const rows = db.prepare("SELECT key, completed FROM progress").all(); + const state = {}; + for (const row of rows) { + state[row.key] = !!row.completed; + } + res.json(state); +}); + +app.post("/api/progress/:key", requireAuth, (req, res) => { + const { key } = req.params; + if (!/^\d+-\d+$/.test(key)) { + return res.status(400).json({ error: "Invalid key format" }); + } + db.prepare( + `INSERT INTO progress (key, completed, updated_by, updated_at) + VALUES (?, 1, ?, CURRENT_TIMESTAMP) + ON CONFLICT(key) DO UPDATE SET completed=1, updated_by=?, updated_at=CURRENT_TIMESTAMP` + ).run(key, req.user.id, req.user.id); + res.json({ ok: true }); +}); + +app.delete("/api/progress/:key", requireAuth, (req, res) => { + const { key } = req.params; + db.prepare("DELETE FROM progress WHERE key = ?").run(key); + res.json({ ok: true }); +}); + +// --- Auth API --- + +app.get("/api/auth/me", (req, res) => { + if (!req.session.userId) { + return res.json({ authenticated: false }); + } + const user = db.prepare("SELECT id, username, approved FROM users WHERE id = ?").get(req.session.userId); + if (!user) { + return res.json({ authenticated: false }); + } + res.json({ authenticated: true, user }); +}); + +app.post("/api/auth/logout", (req, res) => { + req.session.destroy(() => { + res.json({ ok: true }); + }); +}); + +// Registration +app.post("/api/auth/register-options", async (req, res) => { + try { + const { username } = req.body; + if (!username || typeof username !== "string" || username.length > 64) { + return res.status(400).json({ error: "Invalid username" }); + } + + let user = db.prepare("SELECT * FROM users WHERE username = ?").get(username); + if (!user) { + const result = db.prepare("INSERT INTO users (username, approved) VALUES (?, ?)").run( + username, + db.prepare("SELECT COUNT(*) as count FROM users").get().count === 0 ? 1 : 0 + ); + user = db.prepare("SELECT * FROM users WHERE id = ?").get(result.lastInsertRowid); + } + + const existingCreds = db + .prepare("SELECT id, transports FROM credentials WHERE user_id = ?") + .all(user.id); + + const options = await generateRegistrationOptions({ + rpName: RP_NAME, + rpID: RP_ID, + userName: username, + userID: Buffer.from(String(user.id)), + attestationType: "none", + excludeCredentials: existingCreds.map((c) => ({ + id: c.id, + transports: c.transports ? JSON.parse(c.transports) : undefined, + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + challengeStore.set(req.sessionID, { challenge: options.challenge, userId: user.id }); + setTimeout(() => challengeStore.delete(req.sessionID), 5 * 60 * 1000); + + res.json(options); + } catch (err) { + console.error("register-options error:", err); + res.status(500).json({ error: "Registration failed" }); + } +}); + +app.post("/api/auth/register-verify", async (req, res) => { + try { + const stored = challengeStore.get(req.sessionID); + if (!stored) { + return res.status(400).json({ error: "No registration in progress" }); + } + + const verification = await verifyRegistrationResponse({ + response: req.body, + expectedChallenge: stored.challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + }); + + if (!verification.verified || !verification.registrationInfo) { + return res.status(400).json({ error: "Verification failed" }); + } + + const { credential } = verification.registrationInfo; + + db.prepare( + "INSERT INTO credentials (id, user_id, public_key, counter, transports) VALUES (?, ?, ?, ?, ?)" + ).run( + credential.id, + stored.userId, + Buffer.from(credential.publicKey), + credential.counter, + JSON.stringify(req.body.response?.transports || []) + ); + + challengeStore.delete(req.sessionID); + + const user = db.prepare("SELECT id, username, approved FROM users WHERE id = ?").get(stored.userId); + req.session.userId = user.id; + + res.json({ verified: true, user }); + } catch (err) { + console.error("register-verify error:", err); + res.status(500).json({ error: "Verification failed" }); + } +}); + +// Login +app.post("/api/auth/login-options", async (req, res) => { + try { + const options = await generateAuthenticationOptions({ + rpID: RP_ID, + userVerification: "preferred", + }); + + challengeStore.set(req.sessionID, { challenge: options.challenge }); + setTimeout(() => challengeStore.delete(req.sessionID), 5 * 60 * 1000); + + res.json(options); + } catch (err) { + console.error("login-options error:", err); + res.status(500).json({ error: "Login failed" }); + } +}); + +app.post("/api/auth/login-verify", async (req, res) => { + try { + const stored = challengeStore.get(req.sessionID); + if (!stored) { + return res.status(400).json({ error: "No login in progress" }); + } + + const credentialId = req.body.id; + const cred = db.prepare("SELECT * FROM credentials WHERE id = ?").get(credentialId); + if (!cred) { + return res.status(400).json({ error: "Unknown credential" }); + } + + const verification = await verifyAuthenticationResponse({ + response: req.body, + expectedChallenge: stored.challenge, + expectedOrigin: ORIGIN, + expectedRPID: RP_ID, + credential: { + id: cred.id, + publicKey: cred.public_key, + counter: cred.counter, + transports: cred.transports ? JSON.parse(cred.transports) : undefined, + }, + }); + + if (!verification.verified) { + return res.status(400).json({ error: "Verification failed" }); + } + + db.prepare("UPDATE credentials SET counter = ? WHERE id = ?").run( + verification.authenticationInfo.newCounter, + credentialId + ); + + challengeStore.delete(req.sessionID); + + const user = db + .prepare("SELECT id, username, approved FROM users WHERE id = ?") + .get(cred.user_id); + req.session.userId = user.id; + + res.json({ verified: true, user }); + } catch (err) { + console.error("login-verify error:", err); + res.status(500).json({ error: "Verification failed" }); + } +}); + +// Serve static frontend +app.use(express.static(path.join(__dirname, "public"))); + +// SPA fallback +app.get("*", (req, res) => { + if (req.path.startsWith("/api/")) { + return res.status(404).json({ error: "Not found" }); + } + res.sendFile(path.join(__dirname, "public", "index.html")); +}); + +app.listen(PORT, () => console.log(`Running app on :${PORT}`)); diff --git a/apps/running/src/App.tsx b/apps/running/src/App.tsx new file mode 100644 index 0000000..861fa53 --- /dev/null +++ b/apps/running/src/App.tsx @@ -0,0 +1,440 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Mountain, LogIn, LogOut, UserPlus, Loader2 } from "lucide-react"; +import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; +import { SCHEDULE_DATA, DAYS_OF_WEEK, BACKGROUND_IMAGE } from "./constants"; +import { CompletionState, RunType, AuthUser } from "./types"; +import WorkoutCard from "./components/WorkoutCard"; + +const API_BASE = "/running/api"; + +async function apiFetch(path: string, opts?: RequestInit) { + const res = await fetch(`${API_BASE}${path}`, { + ...opts, + headers: { "Content-Type": "application/json", ...opts?.headers }, + }); + return res; +} + +const App: React.FC = () => { + const [completed, setCompleted] = useState({}); + const [user, setUser] = useState(null); + const [authLoading, setAuthLoading] = useState(true); + const [showRegister, setShowRegister] = useState(false); + const [registerName, setRegisterName] = useState(""); + const [authError, setAuthError] = useState(""); + + const canEdit = !!user && user.approved === 1; + + const loadProgress = useCallback(async () => { + try { + const res = await apiFetch("/progress"); + if (res.ok) { + setCompleted(await res.json()); + } + } catch { + // Silently fail - will show empty progress + } + }, []); + + const checkAuth = useCallback(async () => { + try { + const res = await apiFetch("/auth/me"); + const data = await res.json(); + if (data.authenticated) { + setUser(data.user); + } + } catch { + // Not authenticated + } finally { + setAuthLoading(false); + } + }, []); + + useEffect(() => { + loadProgress(); + checkAuth(); + }, [loadProgress, checkAuth]); + + const toggleDay = async (weekIdx: number, dayIdx: number) => { + if (!canEdit) return; + const key = `${weekIdx}-${dayIdx}`; + const isCurrentlyCompleted = completed[key]; + + // Optimistic update + setCompleted((prev) => ({ + ...prev, + [key]: !isCurrentlyCompleted, + })); + + try { + if (isCurrentlyCompleted) { + await apiFetch(`/progress/${key}`, { method: "DELETE" }); + } else { + await apiFetch(`/progress/${key}`, { method: "POST" }); + } + } catch { + // Revert on failure + setCompleted((prev) => ({ + ...prev, + [key]: isCurrentlyCompleted, + })); + } + }; + + const handleLogin = async () => { + setAuthError(""); + try { + const optionsRes = await apiFetch("/auth/login-options", { + method: "POST", + }); + const options = await optionsRes.json(); + const authResp = await startAuthentication({ optionsJSON: options }); + const verifyRes = await apiFetch("/auth/login-verify", { + method: "POST", + body: JSON.stringify(authResp), + }); + const result = await verifyRes.json(); + if (result.verified) { + setUser(result.user); + } else { + setAuthError("Login failed"); + } + } catch (err: any) { + if (err.name !== "NotAllowedError") { + setAuthError("Login failed"); + } + } + }; + + const handleRegister = async () => { + if (!registerName.trim()) return; + setAuthError(""); + try { + const optionsRes = await apiFetch("/auth/register-options", { + method: "POST", + body: JSON.stringify({ username: registerName.trim() }), + }); + const options = await optionsRes.json(); + const regResp = await startRegistration({ optionsJSON: options }); + const verifyRes = await apiFetch("/auth/register-verify", { + method: "POST", + body: JSON.stringify(regResp), + }); + const result = await verifyRes.json(); + if (result.verified) { + setUser(result.user); + setShowRegister(false); + setRegisterName(""); + } else { + setAuthError("Registration failed"); + } + } catch (err: any) { + if (err.name !== "NotAllowedError") { + setAuthError("Registration failed"); + } + } + }; + + const handleLogout = async () => { + await apiFetch("/auth/logout", { method: "POST" }); + setUser(null); + }; + + const calculateProgress = () => { + let actionableTotal = 0; + let actionableCompleted = 0; + + SCHEDULE_DATA.forEach((week, weekIndex) => { + week.days.forEach((workout, dayIndex) => { + const isRest = + workout.type === RunType.REST || workout.type === RunType.SLEEP; + if (!isRest) { + actionableTotal++; + if (completed[`${weekIndex}-${dayIndex}`]) { + actionableCompleted++; + } + } + }); + }); + + if (actionableTotal === 0) return 0; + return Math.round((actionableCompleted / actionableTotal) * 100); + }; + + return ( +
+ {/* Background */} +
+ Mountain Sunrise +
+
+
+ + {/* Main Content */} +
+ {/* Header */} +
+
+
+ +
+
+

+ Summit Stride 5K +

+

+ 6-Week Preparation +

+
+
+ +
+
+
+ Progress + {calculateProgress()}% +
+
+
+
+
+ + {/* Auth controls */} +
+ {authLoading ? ( + + ) : user ? ( + <> + + {user.username} + {!user.approved && " (pending)"} + + + + ) : ( + <> + + + + )} +
+
+
+ + {/* Auth error */} + {authError && ( +
+ {authError} + +
+ )} + + {/* Training Zones Legend */} +
+
+

+ Training Zones +

+
+ +
+
+
+
+ Speed +
+
+ Zone 5 +
+
+ Max Effort (90%+) +
+
+ +
+
+
+ + Threshold + +
+
+ Zone 4 +
+
+ Hard Effort (80-90%) +
+
+ +
+
+
+ + Long Run + +
+
+ Zone 2 +
+
+ Conversational (60-70%) +
+
+ +
+
+
+ Easy +
+
+ Zone 2 +
+
+ Conversational (60-70%) +
+
+ +
+
+
+ + Recovery + +
+
+ Zone 1 +
+
+ Active Recovery (<60%) +
+
+
+
+ + {/* Schedule Grid */} +
+
+
+
+ Week +
+ {DAYS_OF_WEEK.map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {SCHEDULE_DATA.map((week, weekIndex) => ( +
+
+ + {week.weekNum} + + + Week + +
+ + {week.days.map((workout, dayIndex) => ( + toggleDay(weekIndex, dayIndex)} + /> + ))} +
+ ))} +
+
+
+
+ + {/* Register Modal */} + {showRegister && ( +
+
{ + setShowRegister(false); + setAuthError(""); + }} + >
+
+

+ Register Passkey +

+ setRegisterName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleRegister()} + placeholder="Your name" + className="w-full px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-orange-500 mb-4" + autoFocus + /> +
+ + +
+
+
+ )} +
+ ); +}; + +export default App; diff --git a/apps/running/src/components/WorkoutCard.tsx b/apps/running/src/components/WorkoutCard.tsx new file mode 100644 index 0000000..a4f4d27 --- /dev/null +++ b/apps/running/src/components/WorkoutCard.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { Check } from "lucide-react"; +import { RunType, Workout } from "../types"; + +interface WorkoutCardProps { + workout: Workout; + isCompleted: boolean; + canEdit: boolean; + onToggle: () => void; + dayName?: string; +} + +const getColors = (type: RunType): string => { + switch (type) { + case RunType.SPEED: + return "bg-red-500/20 border-red-500/30 text-red-100 hover:bg-red-500/30"; + case RunType.THRESHOLD: + return "bg-amber-500/20 border-amber-500/30 text-amber-100 hover:bg-amber-500/30"; + case RunType.LONG: + return "bg-purple-500/20 border-purple-500/30 text-purple-100 hover:bg-purple-500/30"; + case RunType.EASY: + return "bg-sky-500/20 border-sky-500/30 text-sky-100 hover:bg-sky-500/30"; + case RunType.ACTIVE_REST: + case RunType.CROSS_TRAINING: + return "bg-emerald-500/20 border-emerald-500/30 text-emerald-100 hover:bg-emerald-500/30"; + case RunType.REST: + case RunType.SLEEP: + return "bg-gray-700/30 border-gray-600/30 text-gray-400 hover:bg-gray-700/40"; + case RunType.EVENT: + return "bg-yellow-400/40 border-yellow-400/50 text-white font-bold shadow-[0_0_15px_rgba(250,204,21,0.3)] hover:bg-yellow-400/50"; + default: + return "bg-gray-800/50 border-gray-700 text-gray-300"; + } +}; + +const WorkoutCard: React.FC = ({ + workout, + isCompleted, + canEdit, + onToggle, + dayName, +}) => { + const colorClass = getColors(workout.type); + const isRestDay = + workout.type === RunType.REST || workout.type === RunType.SLEEP; + + return ( +
+
+ {dayName && ( + + {dayName} + + )} +
+ +
+ {workout.duration && ( +
+ {workout.duration} +
+ )} +
+ {workout.note || workout.type} +
+
+ + {!isRestDay && ( +
+ {canEdit ? ( + + ) : ( + isCompleted && ( +
+ +
+ ) + )} +
+ )} +
+ ); +}; + +export default WorkoutCard; diff --git a/apps/running/src/constants.ts b/apps/running/src/constants.ts new file mode 100644 index 0000000..dad241c --- /dev/null +++ b/apps/running/src/constants.ts @@ -0,0 +1,89 @@ +import { RunType, WeekSchedule } from "./types"; + +export const SCHEDULE_DATA: WeekSchedule[] = [ + { + weekNum: 1, + days: [ + { type: RunType.SPEED, duration: "5 min", isKeyWorkout: true }, + { type: RunType.ACTIVE_REST }, + { type: RunType.EASY, duration: "20 min" }, + { type: RunType.ACTIVE_REST }, + { type: RunType.CROSS_TRAINING }, + { type: RunType.LONG, duration: "40 min" }, + { type: RunType.REST }, + ], + }, + { + weekNum: 2, + days: [ + { type: RunType.SPEED, duration: "7.5 min", isKeyWorkout: true }, + { type: RunType.ACTIVE_REST }, + { type: RunType.EASY, duration: "20 min" }, + { type: RunType.ACTIVE_REST }, + { type: RunType.CROSS_TRAINING }, + { type: RunType.LONG, duration: "45 min" }, + { type: RunType.REST }, + ], + }, + { + weekNum: 3, + days: [ + { type: RunType.SPEED, duration: "7.5 min", isKeyWorkout: true }, + { type: RunType.ACTIVE_REST }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.CROSS_TRAINING }, + { type: RunType.LONG, duration: "50 min" }, + { type: RunType.REST }, + ], + }, + { + weekNum: 4, + days: [ + { type: RunType.SPEED, duration: "10 min", isKeyWorkout: true }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.EASY, duration: "15 min" }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.CROSS_TRAINING }, + { type: RunType.LONG, duration: "60 min" }, + { type: RunType.REST }, + ], + }, + { + weekNum: 5, + days: [ + { type: RunType.SPEED, duration: "12 min", isKeyWorkout: true }, + { type: RunType.EASY, duration: "25 min" }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.EASY, duration: "30 min" }, + { type: RunType.CROSS_TRAINING }, + { type: RunType.LONG, duration: "45 min" }, + { type: RunType.REST }, + ], + }, + { + weekNum: 6, + days: [ + { type: RunType.SPEED, duration: "6 min", isKeyWorkout: true }, + { type: RunType.ACTIVE_REST }, + { type: RunType.EASY, duration: "20 min" }, + { type: RunType.EASY, duration: "20 min" }, + { type: RunType.REST }, + { type: RunType.EVENT, duration: "", note: "Event day!" }, + { type: RunType.SLEEP }, + ], + }, +]; + +export const DAYS_OF_WEEK = [ + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", +]; + +export const BACKGROUND_IMAGE = + "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b"; diff --git a/apps/running/src/index.html b/apps/running/src/index.html new file mode 100644 index 0000000..ad11640 --- /dev/null +++ b/apps/running/src/index.html @@ -0,0 +1,21 @@ + + + + + + Summit Stride 5K + + + + + +
+ + + diff --git a/apps/running/src/index.tsx b/apps/running/src/index.tsx new file mode 100644 index 0000000..3304cf1 --- /dev/null +++ b/apps/running/src/index.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; + +const rootElement = document.getElementById("root"); +if (!rootElement) { + throw new Error("Could not find root element to mount to"); +} + +const root = ReactDOM.createRoot(rootElement); +root.render( + + + +); diff --git a/apps/running/src/package.json b/apps/running/src/package.json new file mode 100644 index 0000000..4d29f99 --- /dev/null +++ b/apps/running/src/package.json @@ -0,0 +1,24 @@ +{ + "name": "summit-stride-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@simplewebauthn/browser": "^11.0.0", + "lucide-react": "^0.563.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "~5.6.0", + "vite": "^6.0.0" + } +} diff --git a/apps/running/src/tsconfig.json b/apps/running/src/tsconfig.json new file mode 100644 index 0000000..f94f76a --- /dev/null +++ b/apps/running/src/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/apps/running/src/types.ts b/apps/running/src/types.ts new file mode 100644 index 0000000..982a421 --- /dev/null +++ b/apps/running/src/types.ts @@ -0,0 +1,31 @@ +export enum RunType { + SPEED = "Speed Work", + THRESHOLD = "Threshold Work", + LONG = "Long Run", + EASY = "Easy Run", + ACTIVE_REST = "Active Rest", + CROSS_TRAINING = "Cross-training", + REST = "Rest", + EVENT = "Event Day", + SLEEP = "Sleep", +} + +export interface Workout { + type: RunType; + duration?: string; + note?: string; + isKeyWorkout?: boolean; +} + +export interface WeekSchedule { + weekNum: number; + days: Workout[]; +} + +export type CompletionState = Record; + +export interface AuthUser { + id: number; + username: string; + approved: number; +} diff --git a/apps/running/src/vite.config.ts b/apps/running/src/vite.config.ts new file mode 100644 index 0000000..f645ab5 --- /dev/null +++ b/apps/running/src/vite.config.ts @@ -0,0 +1,21 @@ +import path from "path"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + base: "/running/", + plugins: [react()], + build: { + outDir: "dist", + }, + server: { + proxy: { + "/api": "http://localhost:8080", + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, +}); diff --git a/docker-compose.yml b/docker-compose.yml index 4b27090..d4f50d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,22 @@ services: caddy.reverse_proxy: "{{upstreams 8080}}" restart: unless-stopped + running-app: + build: ./apps/running + container_name: running-app + networks: + - server-network + volumes: + - ./apps/running/data:/app/data + environment: + - NODE_ENV=production + - SESSION_SECRET=${RUNNING_SESSION_SECRET:-change-me-in-production} + labels: + caddy: jamesvanboxtel.com + caddy.handle_path: /running/* + caddy.handle_path.reverse_proxy: "{{upstreams 8080}}" + restart: unless-stopped + networks: server-network: external: false