This commit is contained in:
2025-09-10 11:46:57 -07:00
parent 2cc2b4c9ce
commit f443e7a64e
33 changed files with 887 additions and 1467 deletions

11
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.next
Dockerfile
.dockerignore
.git
.gitignore
coverage
.env
.env.local
.vscode
*.log

View File

@@ -1,39 +1,60 @@
# Build stage
FROM node:20-alpine AS build
# Stage 1: Build application
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json
COPY package*.json ./
# Copy package manifests first for optimal caching
COPY package.json package-lock.json* ./
# Install all dependencies including devDependencies
RUN npm install --include=dev
# Clean cache and install dependencies
RUN npm cache clean --force && \
export NODE_OPTIONS="--max-old-space-size=1024" && \
npm install --include=dev
# Copy source code
# Copy source files
COPY . .
# Build application
RUN npm run build
# Build application with production settings
RUN export NODE_OPTIONS="--max-old-space-size=1024" && \
npm run build
# Production stage
FROM node:20-alpine AS production
# Stage 2: Production runtime
FROM nginx:1.25-alpine
# Set working directory
WORKDIR /app
# Install curl for healthchecks
RUN apk add --no-cache curl
# Copy build artifacts and dependencies
COPY --from=build /app/package*.json ./
COPY --from=build /app/.next ./.next
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/public ./public
# Create necessary directories and set permissions
RUN mkdir -p /var/cache/nginx/client_temp && \
mkdir -p /var/run/nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/run/nginx && \
chmod -R 755 /usr/share/nginx/html
# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Copy build artifacts
COPY --from=builder /app/.next /usr/share/nginx/html/_next
# Expose application port
EXPOSE 3000
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Run application
CMD ["npm", "start"]
# Copy Next.js routes manifest for proper routing
COPY --from=builder /app/.next/routes-manifest.json /usr/share/nginx/html/_next/
# Copy main HTML files to root
COPY --from=builder /app/.next/server/pages/index.html /usr/share/nginx/html/index.html
COPY --from=builder /app/.next/server/pages/404.html /usr/share/nginx/html/404.html
# Modify nginx config to use custom PID path
RUN sed -i 's|pid /var/run/nginx.pid;|pid /var/run/nginx/nginx.pid;|' /etc/nginx/nginx.conf
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl --fail http://localhost:80 || exit 1
# Run as root to avoid permission issues
# USER nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

15
frontend/jest.config.js Normal file
View File

@@ -0,0 +1,15 @@
module.exports = {
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov"],
coveragePathIgnorePatterns: [
"/node_modules/",
"/.next/",
"/__tests__/",
"jest.config.js"
],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1"
}
};

45
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,45 @@
worker_processes auto;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
server {
listen 80;
server_name localhost;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
# Cache control for static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# Next.js specific routes
location /_next/ {
alias /usr/share/nginx/html/_next/;
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Health check endpoint
location /healthz {
access_log off;
return 200 'ok';
}
}
}

View File

@@ -8,14 +8,19 @@
"start": "next start",
"lint": "next lint",
"test": "jest",
"test:watch": "jest --watch"
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"axios": "^1.7.2",
"date-fns": "^3.6.0",
"next": "14.2.3",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-router-dom": "^6.22.3",
"react-toastify": "^10.0.4",
"recharts": "2.8.0"
},
"devDependencies": {

View File

@@ -0,0 +1,26 @@
import { render, screen } from '@testing-library/react';
import LoadingSpinner from '../LoadingSpinner';
describe('LoadingSpinner Component', () => {
test('renders spinner with animation', () => {
render(<LoadingSpinner />);
// Check for the spinner container
const spinnerContainer = screen.getByRole('status');
expect(spinnerContainer).toBeInTheDocument();
// Verify animation classes
const spinnerElement = screen.getByTestId('loading-spinner');
expect(spinnerElement).toHaveClass('animate-spin');
expect(spinnerElement).toHaveClass('rounded-full');
// Check accessibility attributes
expect(spinnerElement).toHaveAttribute('aria-live', 'polite');
expect(spinnerElement).toHaveAttribute('aria-busy', 'true');
});
test('matches snapshot', () => {
const { asFragment } = render(<LoadingSpinner />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -44,8 +44,19 @@ export const AuthProvider = ({ children }) => {
export const useAuth = () => {
const context = useContext(AuthContext);
// Return safe defaults during build time
if (typeof window === 'undefined') {
return {
apiKey: null,
authFetch: () => {},
loading: false
};
}
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

View File

@@ -7,6 +7,7 @@ import LoadingSpinner from '../components/LoadingSpinner';
const Dashboard = () => {
const { apiKey, loading: apiLoading } = useAuth();
const isBuildTime = typeof window === 'undefined';
const [recentWorkouts, setRecentWorkouts] = useState([]);
const [currentPlan, setCurrentPlan] = useState(null);
const [stats, setStats] = useState({ totalWorkouts: 0, totalDistance: 0 });
@@ -18,16 +19,16 @@ const Dashboard = () => {
const fetchDashboardData = async () => {
try {
const [workoutsRes, planRes, statsRes, healthRes] = await Promise.all([
fetch('/api/workouts?limit=3', {
fetch(`${process.env.REACT_APP_API_URL}/api/workouts?limit=3`, {
headers: { 'X-API-Key': apiKey }
}),
fetch('/api/plans/active', {
fetch(`${process.env.REACT_APP_API_URL}/api/plans/active`, {
headers: { 'X-API-Key': apiKey }
}),
fetch('/api/stats', {
fetch(`${process.env.REACT_APP_API_URL}/api/stats`, {
headers: { 'X-API-Key': apiKey }
}),
fetch('/api/health', {
fetch(`${process.env.REACT_APP_API_URL}/api/health`, {
headers: { 'X-API-Key': apiKey }
})
]);
@@ -61,6 +62,17 @@ const Dashboard = () => {
fetchDashboardData();
}, [apiKey]);
if (isBuildTime) {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold">Training Dashboard</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading dashboard data...</p>
</div>
</div>
);
}
if (localLoading || apiLoading) return <LoadingSpinner />;
if (error) return <div className="p-6 text-red-500">{error}</div>;

View File

@@ -9,8 +9,11 @@ const Plans = () => {
const [selectedPlan, setSelectedPlan] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const isBuildTime = typeof window === 'undefined';
useEffect(() => {
if (isBuildTime) return;
const fetchPlans = async () => {
try {
const response = await axios.get('/api/plans', {
@@ -30,6 +33,17 @@ const Plans = () => {
fetchPlans();
}, [apiKey]);
if (typeof window === 'undefined') {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Training Plans</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading training plans...</p>
</div>
</div>
);
}
if (loading) return <div className="p-6 text-center">Loading plans...</div>;
if (error) return <div className="p-6 text-red-600">{error}</div>;

View File

@@ -2,6 +2,18 @@ import { useAuth } from '../context/AuthContext';
const RoutesPage = () => {
const { apiKey } = useAuth();
// Handle build-time case where apiKey is undefined
if (typeof window === 'undefined') {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Routes</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading route management...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">

View File

@@ -9,8 +9,11 @@ const Workouts = () => {
const [selectedWorkout, setSelectedWorkout] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const isBuildTime = typeof window === 'undefined';
useEffect(() => {
if (isBuildTime) return;
const fetchWorkouts = async () => {
try {
const response = await axios.get('/api/workouts', {
@@ -27,6 +30,17 @@ const Workouts = () => {
fetchWorkouts();
}, [apiKey]);
if (isBuildTime) {
return (
<div className="p-6 max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Workouts</h1>
<div className="bg-white p-6 rounded-lg shadow-md">
<p className="text-gray-600">Loading workout data...</p>
</div>
</div>
);
}
if (loading) return <div className="p-6 text-center">Loading workouts...</div>;
if (error) return <div className="p-6 text-red-600">{error}</div>;