From c2830056c3abac932e3ccbcfadd5a149b9b64ab4 Mon Sep 17 00:00:00 2001 From: Ryan AI Dev Date: Sat, 22 Nov 2025 13:20:23 -0500 Subject: [PATCH 1/3] Add TypeScript full-stack scaffolding with CI and deployment configs --- .env.example | 14 +++++ .eslintrc.cjs | 46 +++++++++++++++ .github/workflows/ci.yml | 50 +++++++++++++++++ .gitignore | 12 ++++ .prettierrc.json | 6 ++ README.md | 30 +++++++++- backend/Dockerfile | 14 +++++ backend/Procfile | 1 + backend/deployment/aws/Dockerrun.aws.json | 23 ++++++++ backend/deployment/aws/README.md | 6 ++ backend/jest.config.ts | 9 +++ backend/package.json | 37 ++++++++++++ backend/scripts/heroku-deploy.sh | 15 +++++ backend/src/__tests__/emotions.test.ts | 12 ++++ backend/src/config/environment.ts | 25 +++++++++ backend/src/index.ts | 43 ++++++++++++++ backend/src/monitoring/metrics.ts | 16 ++++++ backend/src/routes/emotions.ts | 31 +++++++++++ backend/tsconfig.json | 10 ++++ frontend/index.html | 12 ++++ frontend/jest.config.ts | 13 +++++ frontend/jest.setup.ts | 1 + frontend/package.json | 34 +++++++++++ frontend/public/service-worker.js | 34 +++++++++++ frontend/src/App.tsx | 62 +++++++++++++++++++++ frontend/src/__tests__/App.test.tsx | 51 +++++++++++++++++ frontend/src/__tests__/EmotionList.test.tsx | 21 +++++++ frontend/src/components/EmotionDetails.tsx | 21 +++++++ frontend/src/components/EmotionList.tsx | 30 ++++++++++ frontend/src/main.tsx | 19 +++++++ frontend/src/styles.css | 56 +++++++++++++++++++ frontend/src/types.ts | 5 ++ frontend/tsconfig.json | 10 ++++ frontend/vite.config.ts | 16 ++++++ package.json | 36 ++++++++++++ tsconfig.base.json | 13 +++++ 36 files changed, 833 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .prettierrc.json create mode 100644 backend/Dockerfile create mode 100644 backend/Procfile create mode 100644 backend/deployment/aws/Dockerrun.aws.json create mode 100644 backend/deployment/aws/README.md create mode 100644 backend/jest.config.ts create mode 100644 backend/package.json create mode 100644 backend/scripts/heroku-deploy.sh create mode 100644 backend/src/__tests__/emotions.test.ts create mode 100644 backend/src/config/environment.ts create mode 100644 backend/src/index.ts create mode 100644 backend/src/monitoring/metrics.ts create mode 100644 backend/src/routes/emotions.ts create mode 100644 backend/tsconfig.json create mode 100644 frontend/index.html create mode 100644 frontend/jest.config.ts create mode 100644 frontend/jest.setup.ts create mode 100644 frontend/package.json create mode 100644 frontend/public/service-worker.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/__tests__/App.test.tsx create mode 100644 frontend/src/__tests__/EmotionList.test.tsx create mode 100644 frontend/src/components/EmotionDetails.tsx create mode 100644 frontend/src/components/EmotionList.tsx create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/types.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 package.json create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..10d1526 --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Shared +NODE_ENV=development + +# Backend +PORT=4000 +API_BASE_URL=/api +LOG_LEVEL=info +TLS_CERT_PATH= +TLS_KEY_PATH= +PROMETHEUS_METRICS=true +ALLOWED_ORIGINS=http://localhost:5173 + +# Frontend +VITE_API_BASE_URL=http://localhost:4000/api diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..6fedb77 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,46 @@ +module.exports = { + root: true, + env: { + es2021: true, + node: true, + browser: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'prettier', 'react', 'react-hooks', 'jsx-a11y', 'import'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + 'plugin:import/recommended', + 'plugin:import/typescript', + 'plugin:prettier/recommended', + ], + settings: { + react: { + version: 'detect', + }, + 'import/resolver': { + node: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + }, + }, + }, + rules: { + 'prettier/prettier': 'error', + 'react/react-in-jsx-scope': 'off', + 'import/order': [ + 'warn', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always', + }, + ], + }, + ignorePatterns: ['dist', 'node_modules'], +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ef2e8c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,50 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm run lint + + test: + runs-on: ubuntu-latest + needs: lint + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm run test + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - run: npm run build + - name: Upload frontend build + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist + - name: Upload backend bundle + uses: actions/upload-artifact@v4 + with: + name: backend-dist + path: backend/dist diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..810c074 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules +npm-debug.log* +dist +coverage +.env +.env.local +.DS_Store +/frontend/node_modules +/backend/node_modules +/frontend/dist +/backend/dist +.husky/_ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e5ce635 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "semi": true +} diff --git a/README.md b/README.md index b8bff90..7287c7c 100644 --- a/README.md +++ b/README.md @@ -1 +1,29 @@ -# EmotionExplorerGame +# Emotion Explorer Game + +A monorepo with a React + TypeScript frontend and an Express + TypeScript backend. The project ships with ESLint/Prettier, Jest-based unit/integration tests, GitHub Actions CI, and deployment scripts for TLS-enabled hosting on AWS or Heroku. + +## Getting Started + +```bash +npm install +npm run lint +npm run test +npm run build +``` + +- Frontend dev server: `npm run dev --workspace frontend` +- Backend dev server: `npm run dev --workspace backend` + +Environment variables live in `.env` (see `.env.example`). Frontend expects `VITE_API_BASE_URL`; backend supports TLS via `TLS_CERT_PATH` and `TLS_KEY_PATH`. + +## Testing + +- Frontend unit & integration tests: `npm run test --workspace frontend` +- Backend API tests: `npm run test --workspace backend` + +## Deployment + +- **Heroku:** Use `backend/scripts/heroku-deploy.sh` to push the Dockerized backend. TLS is handled via platform certificates; the app also supports direct HTTPS when `TLS_CERT_PATH`/`TLS_KEY_PATH` are provided. +- **AWS Elastic Beanstalk:** Deploy with `backend/deployment/aws/Dockerrun.aws.json`, terminating TLS at the load balancer (ACM). CloudWatch and the `/metrics` endpoint enable monitoring. + +The frontend ships a service worker for caching and Vite code-splitting configuration with lazy-loaded UI components for better performance. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..2dec709 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +COPY --from=build /app/package*.json ./ +RUN npm install --omit=dev +COPY --from=build /app/dist ./dist +ENV PORT=4000 +CMD ["node", "dist/index.js"] diff --git a/backend/Procfile b/backend/Procfile new file mode 100644 index 0000000..f474a4e --- /dev/null +++ b/backend/Procfile @@ -0,0 +1 @@ +web: node dist/index.js diff --git a/backend/deployment/aws/Dockerrun.aws.json b/backend/deployment/aws/Dockerrun.aws.json new file mode 100644 index 0000000..7a6b514 --- /dev/null +++ b/backend/deployment/aws/Dockerrun.aws.json @@ -0,0 +1,23 @@ +{ + "AWSEBDockerrunVersion": 2, + "containerDefinitions": [ + { + "name": "emotion-explorer-api", + "image": "public.ecr.aws/docker/library/node:20-alpine", + "essential": true, + "memory": 256, + "portMappings": [ + { + "containerPort": 4000, + "hostPort": 80 + } + ], + "environment": [ + { "name": "PORT", "value": "4000" }, + { "name": "API_BASE_URL", "value": "/api" }, + { "name": "PROMETHEUS_METRICS", "value": "true" } + ], + "command": ["node", "dist/index.js"] + } + ] +} diff --git a/backend/deployment/aws/README.md b/backend/deployment/aws/README.md new file mode 100644 index 0000000..0a857d7 --- /dev/null +++ b/backend/deployment/aws/README.md @@ -0,0 +1,6 @@ +# AWS Deployment (Elastic Beanstalk) + +1. Build and bundle the backend image using the provided `backend/Dockerfile` and push to ECR. +2. Provision an Application Load Balancer with an ACM certificate attached for TLS termination. +3. Deploy the generated image with `Dockerrun.aws.json` to Elastic Beanstalk. Ensure environment variables (see `.env.example`) are provided and health checks point to `/health`. +4. Enable CloudWatch Logs and Metrics for monitoring, scraping `/metrics` for Prometheus-compatible collectors. diff --git a/backend/jest.config.ts b/backend/jest.config.ts new file mode 100644 index 0000000..fd723e2 --- /dev/null +++ b/backend/jest.config.ts @@ -0,0 +1,9 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], +}; + +export default config; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..206d900 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,37 @@ +{ + "name": "emotion-explorer-backend", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/index.ts", + "build": "tsc -p tsconfig.json", + "lint": "eslint src --ext .ts", + "test": "jest", + "start": "node dist/index.js" + }, + "dependencies": { + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.19.2", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "prom-client": "^15.1.3" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", + "@types/morgan": "^1.9.9", + "@types/node": "^20.12.7", + "@typescript-eslint/eslint-plugin": "^7.7.1", + "@typescript-eslint/parser": "^7.7.1", + "jest": "^29.7.0", + "supertest": "^6.4.2", + "ts-jest": "^29.1.2", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.5" + } +} diff --git a/backend/scripts/heroku-deploy.sh b/backend/scripts/heroku-deploy.sh new file mode 100644 index 0000000..5e8419f --- /dev/null +++ b/backend/scripts/heroku-deploy.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and deploy to Heroku using container registry. +# Requires HEROKU_APP_NAME and HEROKU_AUTH_TOKEN env vars. + +if [[ -z "${HEROKU_APP_NAME:-}" || -z "${HEROKU_AUTH_TOKEN:-}" ]]; then + echo "HEROKU_APP_NAME and HEROKU_AUTH_TOKEN must be set" >&2 + exit 1 +fi + +heroku container:login <<<"${HEROKU_AUTH_TOKEN}" +docker build -t registry.heroku.com/${HEROKU_APP_NAME}/web -f backend/Dockerfile backend +heroku container:push web --app ${HEROKU_APP_NAME} +heroku container:release web --app ${HEROKU_APP_NAME} diff --git a/backend/src/__tests__/emotions.test.ts b/backend/src/__tests__/emotions.test.ts new file mode 100644 index 0000000..17b5278 --- /dev/null +++ b/backend/src/__tests__/emotions.test.ts @@ -0,0 +1,12 @@ +import request from 'supertest'; +import app from '../index'; + +describe('GET /api/emotions', () => { + it('returns seeded emotions and metrics are accessible', async () => { + const response = await request(app).get('/api/emotions'); + + expect(response.status).toBe(200); + expect(response.body.length).toBeGreaterThan(0); + expect(response.body[0]).toHaveProperty('name'); + }); +}); diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts new file mode 100644 index 0000000..14e77ff --- /dev/null +++ b/backend/src/config/environment.ts @@ -0,0 +1,25 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +export interface Environment { + port: number; + apiBasePath: string; + logLevel: string; + tlsKeyPath?: string; + tlsCertPath?: string; + allowedOrigins: string[]; + enableMetrics: boolean; +} + +const parseBoolean = (value?: string) => value === 'true' || value === '1'; + +export const env: Environment = { + port: parseInt(process.env.PORT || '4000', 10), + apiBasePath: process.env.API_BASE_URL || '/api', + logLevel: process.env.LOG_LEVEL || 'info', + tlsKeyPath: process.env.TLS_KEY_PATH, + tlsCertPath: process.env.TLS_CERT_PATH, + allowedOrigins: (process.env.ALLOWED_ORIGINS || '').split(',').filter(Boolean), + enableMetrics: parseBoolean(process.env.PROMETHEUS_METRICS || 'true'), +}; diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..ff5f3f6 --- /dev/null +++ b/backend/src/index.ts @@ -0,0 +1,43 @@ +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import cors from 'cors'; +import express from 'express'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import { env } from './config/environment.js'; +import emotionsRouter from './routes/emotions.js'; +import { metricsHandler } from './monitoring/metrics.js'; + +const app = express(); + +app.use(express.json()); +app.use(helmet()); +app.use(cors({ origin: env.allowedOrigins.length ? env.allowedOrigins : undefined })); +app.use(morgan(env.logLevel)); + +app.get('/health', (_req, res) => res.json({ status: 'ok' })); + +if (env.enableMetrics) { + app.get('/metrics', metricsHandler); +} + +app.use(env.apiBasePath, emotionsRouter); + +const startServer = () => { + if (env.tlsCertPath && env.tlsKeyPath && fs.existsSync(env.tlsCertPath) && fs.existsSync(env.tlsKeyPath)) { + const key = fs.readFileSync(env.tlsKeyPath); + const cert = fs.readFileSync(env.tlsCertPath); + https.createServer({ key, cert }, app).listen(env.port, () => { + console.log(`HTTPS server listening on port ${env.port}`); + }); + } else { + http.createServer(app).listen(env.port, () => { + console.log(`HTTP server listening on port ${env.port}`); + }); + } +}; + +startServer(); + +export default app; diff --git a/backend/src/monitoring/metrics.ts b/backend/src/monitoring/metrics.ts new file mode 100644 index 0000000..2603f64 --- /dev/null +++ b/backend/src/monitoring/metrics.ts @@ -0,0 +1,16 @@ +import client from 'prom-client'; +import type { RequestHandler } from 'express'; + +const collectDefaultMetrics = client.collectDefaultMetrics; +collectDefaultMetrics(); + +export const requestCounter = new client.Counter({ + name: 'emotion_explorer_requests_total', + help: 'Total number of requests', + labelNames: ['method', 'route', 'status'], +}); + +export const metricsHandler: RequestHandler = async (_req, res) => { + res.set('Content-Type', client.register.contentType); + res.end(await client.register.metrics()); +}; diff --git a/backend/src/routes/emotions.ts b/backend/src/routes/emotions.ts new file mode 100644 index 0000000..7727d21 --- /dev/null +++ b/backend/src/routes/emotions.ts @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { requestCounter } from '../monitoring/metrics'; + +const router = Router(); + +const emotions = [ + { + name: 'Joy', + description: 'Feeling happy and light.', + responses: ['Share gratitude', 'Celebrate with friends'], + }, + { + name: 'Calm', + description: 'Peaceful and relaxed.', + responses: ['Breathe slowly', 'Go for a walk'], + }, + { + name: 'Anxiety', + description: 'Feeling uneasy or nervous about future events.', + responses: ['Practice grounding', 'Talk to someone supportive'], + }, +]; + +router.get('/', (req, res) => { + const { method, baseUrl } = req; + const status = 200; + requestCounter.labels(method, baseUrl, status.toString()).inc(); + res.status(status).json(emotions); +}); + +export default router; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..c4e4b8d --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "module": "CommonJS", + "types": ["node", "jest"] + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..2d49846 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Emotion Explorer + + +
+ + + diff --git a/frontend/jest.config.ts b/frontend/jest.config.ts new file mode 100644 index 0000000..71e4d21 --- /dev/null +++ b/frontend/jest.config.ts @@ -0,0 +1,13 @@ +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + roots: ['/src'], + moduleNameMapper: { + '\\.(css|less|scss|sass)$': 'identity-obj-proxy' + }, + setupFilesAfterEnv: ['/jest.setup.ts'] +}; + +export default config; diff --git a/frontend/jest.setup.ts b/frontend/jest.setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/frontend/jest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..9ed7b0f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,34 @@ +{ + "name": "emotion-explorer-frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "test": "jest", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.4.2", + "@testing-library/react": "^14.2.2", + "@types/jest": "^29.5.12", + "@types/node": "^20.12.7", + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.7.1", + "@typescript-eslint/parser": "^7.7.1", + "@vitejs/plugin-react": "^4.2.1", + "identity-obj-proxy": "^3.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.4.5", + "vite": "^5.2.0" + } +} diff --git a/frontend/public/service-worker.js b/frontend/public/service-worker.js new file mode 100644 index 0000000..5f3e70d --- /dev/null +++ b/frontend/public/service-worker.js @@ -0,0 +1,34 @@ +const CACHE_NAME = 'emotion-explorer-cache-v1'; +const ASSETS = [ + '/', + '/index.html', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(ASSETS); + }), + ); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.all( + cacheNames.map((cacheName) => { + if (cacheName !== CACHE_NAME) { + return caches.delete(cacheName); + } + return undefined; + }), + ), + ), + ); +}); + +self.addEventListener('fetch', (event) => { + event.respondWith( + caches.match(event.request).then((response) => response || fetch(event.request)), + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..143fc3c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,62 @@ +import React, { Suspense, useEffect, useState } from 'react'; +import EmotionList from './components/EmotionList'; +import type { Emotion } from './types'; + +const EmotionDetails = React.lazy(() => import('./components/EmotionDetails')); + +const App: React.FC = () => { + const [selectedEmotion, setSelectedEmotion] = useState(null); + const [emotions, setEmotions] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'; + fetch(`${apiBase}/emotions`) + .then(async (response) => { + if (!response.ok) { + throw new Error('Failed to load emotions'); + } + const payload = (await response.json()) as Emotion[]; + setEmotions(payload); + setSelectedEmotion(payload[0]); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setIsLoading(false)); + }, []); + + const handleSelect = (emotion: Emotion) => { + setSelectedEmotion(emotion); + }; + + if (isLoading) { + return

Loading emotions...

; + } + + if (error) { + return

{error}

; + } + + return ( +
+
+

Emotion Explorer

+

Discover emotional states and how to respond.

+
+
+ +
+ Loading details...

}> + {selectedEmotion ? ( + + ) : ( +

Select an emotion to learn more.

+ )} +
+
+
+
+ ); +}; + +export default App; diff --git a/frontend/src/__tests__/App.test.tsx b/frontend/src/__tests__/App.test.tsx new file mode 100644 index 0000000..0591581 --- /dev/null +++ b/frontend/src/__tests__/App.test.tsx @@ -0,0 +1,51 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; +import App from '../App'; +import type { Emotion } from '../types'; + +type FetchMock = jest.MockedFunction; + +const mockEmotions: Emotion[] = [ + { + name: 'Joy', + description: 'Feeling happy and light.', + responses: ['Share gratitude', 'Celebrate with friends'], + }, + { + name: 'Calm', + description: 'Peaceful and relaxed.', + responses: ['Enjoy nature', 'Meditate for five minutes'], + }, +]; + +describe('App core flows', () => { + beforeEach(() => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockEmotions, + }) as unknown as FetchMock; + }); + + it('loads and renders emotions then allows selection', async () => { + render(); + + expect(screen.getByText(/Loading emotions/)).toBeInTheDocument(); + + await waitFor(() => expect(screen.getByText('Joy')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Calm')); + + expect(screen.getByRole('heading', { name: 'Calm' })).toBeInTheDocument(); + expect(screen.getByText('Peaceful and relaxed.')).toBeInTheDocument(); + }); + + it('shows error message when API fails', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: false }); + + render(); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('Failed to load emotions'), + ); + }); +}); diff --git a/frontend/src/__tests__/EmotionList.test.tsx b/frontend/src/__tests__/EmotionList.test.tsx new file mode 100644 index 0000000..6c6cd5c --- /dev/null +++ b/frontend/src/__tests__/EmotionList.test.tsx @@ -0,0 +1,21 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import React from 'react'; +import EmotionList from '../components/EmotionList'; +import type { Emotion } from '../types'; + +const emotions: Emotion[] = [ + { name: 'Joy', description: 'Happiness', responses: ['Smile'] }, + { name: 'Fear', description: 'Anxiety', responses: ['Deep breaths'] }, +]; + +describe('EmotionList', () => { + it('renders and selects emotions', () => { + const onSelect = jest.fn(); + render(); + + const fearButton = screen.getByText('Fear'); + fireEvent.click(fearButton); + + expect(onSelect).toHaveBeenCalledWith(emotions[1]); + }); +}); diff --git a/frontend/src/components/EmotionDetails.tsx b/frontend/src/components/EmotionDetails.tsx new file mode 100644 index 0000000..02a4f04 --- /dev/null +++ b/frontend/src/components/EmotionDetails.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type { Emotion } from '../types'; + +interface EmotionDetailsProps { + emotion: Emotion; +} + +const EmotionDetails: React.FC = ({ emotion }) => ( +
+

{emotion.name}

+

{emotion.description}

+

Suggested Responses

+
    + {emotion.responses.map((response) => ( +
  • {response}
  • + ))} +
+
+); + +export default EmotionDetails; diff --git a/frontend/src/components/EmotionList.tsx b/frontend/src/components/EmotionList.tsx new file mode 100644 index 0000000..ad9effb --- /dev/null +++ b/frontend/src/components/EmotionList.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import type { Emotion } from '../types'; + +interface EmotionListProps { + emotions: Emotion[]; + selected: Emotion | null; + onSelect: (emotion: Emotion) => void; +} + +const EmotionList: React.FC = ({ emotions, selected, onSelect }) => { + return ( + + ); +}; + +export default EmotionList; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..6b2a418 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './styles.css'; + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + , +); + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/service-worker.js').catch((registrationError) => { + console.error('Service worker registration failed:', registrationError); + }); + }); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..88cbdc9 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,56 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + color-scheme: light dark; +} + +body { + margin: 0; + background: #0b1320; + color: #e9edf5; +} + +.app { + margin: 0 auto; + max-width: 960px; + padding: 1rem 1.5rem 2rem; +} + +.app__content { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1rem; +} + +.app__list ul { + list-style: none; + padding: 0; +} + +.app__list button { + width: 100%; + padding: 0.75rem; + margin-bottom: 0.5rem; + border-radius: 8px; + background: #162033; + color: inherit; + border: 1px solid #26334d; + cursor: pointer; +} + +.app__list button.selected { + background: linear-gradient(135deg, #2d8cff, #7a5dff); + border-color: #7a5dff; +} + +.app__details { + background: #121a2a; + padding: 1rem; + border-radius: 12px; + border: 1px solid #1e2a3d; +} + +@media (max-width: 768px) { + .app__content { + grid-template-columns: 1fr; + } +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..676bd16 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,5 @@ +export interface Emotion { + name: string; + description: string; + responses: string[]; +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..5a0c9e9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "outDir": "./dist", + "types": ["jest", "node"] + }, + "include": ["src", "vite.config.ts", "jest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..64f5901 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + build: { + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom'], + }, + }, + }, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..ce29a39 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "emotion-explorer-game", + "private": true, + "version": "0.1.0", + "workspaces": [ + "frontend", + "backend" + ], + "scripts": { + "lint": "npm run lint --workspaces", + "test": "npm run test --workspaces", + "build": "npm run build --workspaces", + "prepare": "husky install" + }, + "devDependencies": { + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.29.1", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-prettier": "^5.1.3", + "eslint-plugin-react": "^7.34.3", + "eslint-plugin-react-hooks": "^4.6.0", + "husky": "^9.0.11", + "lint-staged": "^15.2.2", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx,json,md,css}": [ + "prettier --write" + ], + "*.{ts,tsx,js,jsx}": [ + "eslint --fix" + ] + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..0804da3 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "baseUrl": "." + } +} From f554063f8bbae29cec234a30060ee2730ebaf8c5 Mon Sep 17 00:00:00 2001 From: Ryan AI Dev Date: Sat, 22 Nov 2025 13:29:50 -0500 Subject: [PATCH 2/3] Fix backend module configuration and test startup --- backend/package.json | 2 +- backend/src/index.ts | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/package.json b/backend/package.json index 206d900..94118ee 100644 --- a/backend/package.json +++ b/backend/package.json @@ -2,7 +2,7 @@ "name": "emotion-explorer-backend", "version": "0.1.0", "private": true, - "type": "module", + "type": "commonjs", "main": "dist/index.js", "scripts": { "dev": "ts-node-dev --respawn --transpile-only src/index.ts", diff --git a/backend/src/index.ts b/backend/src/index.ts index ff5f3f6..0869387 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -5,9 +5,9 @@ import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import morgan from 'morgan'; -import { env } from './config/environment.js'; -import emotionsRouter from './routes/emotions.js'; -import { metricsHandler } from './monitoring/metrics.js'; +import { env } from './config/environment'; +import emotionsRouter from './routes/emotions'; +import { metricsHandler } from './monitoring/metrics'; const app = express(); @@ -38,6 +38,8 @@ const startServer = () => { } }; -startServer(); +if (process.env.NODE_ENV !== 'test') { + startServer(); +} export default app; From 21a971b443e630b05c552d07766a05f10c63b8a1 Mon Sep 17 00:00:00 2001 From: Ryan AI Dev Date: Sat, 22 Nov 2025 20:11:12 -0500 Subject: [PATCH 3/3] Add merge-friendly attributes --- .gitattributes | 20 ++++++++++++++++++++ README.md | 5 +++++ 2 files changed, 25 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..e4257f1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +* text=auto eol=lf + +# Treat lockfiles and generated assets as union merges to reduce manual conflict resolution +package-lock.json merge=union +npm-shrinkwrap.json merge=union +yarn.lock merge=union +pnpm-lock.yaml merge=union + +# Preserve binaries +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.webp binary +*.ico binary +*.eot binary +*.ttf binary +*.woff binary +*.woff2 binary +*.zip binary diff --git a/README.md b/README.md index 7287c7c..8d17bd5 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,8 @@ Environment variables live in `.env` (see `.env.example`). Frontend expects `VIT - **AWS Elastic Beanstalk:** Deploy with `backend/deployment/aws/Dockerrun.aws.json`, terminating TLS at the load balancer (ACM). CloudWatch and the `/metrics` endpoint enable monitoring. The frontend ships a service worker for caching and Vite code-splitting configuration with lazy-loaded UI components for better performance. + +## Merge conflict tips +- The repo includes a `.gitattributes` that applies a union merge to common lockfiles to reduce churn when multiple contributors update dependencies. +- Keep environment files (`.env`, `.env.local`) untracked and regenerate build outputs instead of merging generated artifacts. +- If a conflict appears in compiled `dist` assets, prefer rebuilding locally from `ts`/`tsx` sources after resolving the merge.