mirror of
https://github.com/sstent/AICyclingCoach.git
synced 2026-02-14 03:12:00 +00:00
sync
This commit is contained in:
11
frontend/.dockerignore
Normal file
11
frontend/.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
.next
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
.vscode
|
||||
*.log
|
||||
@@ -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
15
frontend/jest.config.js
Normal 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
45
frontend/nginx.conf
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
26
frontend/src/components/__tests__/LoadingSpinner.test.jsx
Normal file
26
frontend/src/components/__tests__/LoadingSpinner.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user