diff --git a/package-lock.json b/package-lock.json index 8cf421f..2a87e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.22", "concurrently": "^8.2.2", + "cross-env": "^10.1.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "typescript": "~5.8.2", @@ -411,6 +412,13 @@ "react": ">=16.8.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2427,6 +2435,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index 34d7aca..949d47f 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "prod:full": "npm-run-all --parallel preview server:prod", "server:prod": "npm run start:prod --prefix server", "server:test": "npm run start:test --prefix server", - "test:full": "npm-run-all --parallel dev server:test" + "test:full": "cross-env BACKEND_PORT=3201 npm-run-all --parallel dev server:test" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -36,6 +36,7 @@ "@vitejs/plugin-react": "^5.0.0", "autoprefixer": "^10.4.22", "concurrently": "^8.2.2", + "cross-env": "^10.1.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", "typescript": "~5.8.2", diff --git a/playwright.config.ts b/playwright.config.ts index 3b7b3c8..f14f8a6 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ timeout: 60000, reporter: 'html', use: { - baseURL: 'http://localhost:3000', + baseURL: 'http://localhost:5173', trace: 'on-first-retry', }, diff --git a/server/check_table.ts b/server/check_table.ts new file mode 100644 index 0000000..baa0dd6 --- /dev/null +++ b/server/check_table.ts @@ -0,0 +1,17 @@ + +import { PrismaClient } from '@prisma/client'; +import prisma from './src/lib/prisma'; + +async function main() { + try { + console.log('Checking SavedMessage table...'); + const count = await prisma.savedMessage.count(); + console.log(`SavedMessage table exists, count: ${count}`); + } catch (error: any) { + console.error('Error accessing SavedMessage table:', error.message); + } finally { + await prisma.$disconnect(); + } +} + +main(); diff --git a/server/package.json b/server/package.json index 38438d8..d659d74 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,8 @@ "scripts": { "start": "node dist/index.js", "start:prod": "node dist/index.js", - "start:dev": "cross-env APP_MODE=dev ts-node-dev -r dotenv/config --respawn --transpile-only src/index.ts", + "start:dev": "cross-env APP_MODE=dev PORT=3200 ts-node-dev -r dotenv/config --respawn --transpile-only src/index.ts", + "start:test": "cross-env APP_MODE=test PORT=3201 ts-node-dev -r dotenv/config --respawn --transpile-only src/index.ts", "dev": "npm run start:dev", "build": "tsc", "migrate:deploy": "npx prisma migrate deploy" diff --git a/server/prisma/test.db b/server/prisma/test.db index e8a47e0..ac39fce 100644 Binary files a/server/prisma/test.db and b/server/prisma/test.db differ diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 0916022..e9e2e75 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -16,6 +16,7 @@ import { TopBar } from './ui/TopBar'; import { Modal } from './ui/Modal'; import { SideSheet } from './ui/SideSheet'; import { Checkbox } from './ui/Checkbox'; +import { DatePicker } from './ui/DatePicker'; interface ProfileProps { user: User; @@ -314,9 +315,13 @@ const Profile: React.FC = ({ user, onLogout, lang, onLanguageChang setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" /> -
- - setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" /> +
+ setBirthDate(val)} + testId="profile-birth-date" + />
diff --git a/src/components/ui/DatePicker.tsx b/src/components/ui/DatePicker.tsx new file mode 100644 index 0000000..056e259 --- /dev/null +++ b/src/components/ui/DatePicker.tsx @@ -0,0 +1,281 @@ +import React, { useState, useRef, useEffect, useId } from 'react'; +import { createPortal } from 'react-dom'; +import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from './Button'; +import { Ripple } from './Ripple'; + +interface DatePickerProps { + value: string; // YYYY-MM-DD + onChange: (value: string) => void; + label: string; + placeholder?: string; + icon?: React.ReactNode; + disabled?: boolean; + testId?: string; +} + +export const DatePicker: React.FC = ({ + value, + onChange, + label, + placeholder = 'Select date', + icon = , + disabled = false, + testId +}) => { + const id = useId(); + const [isOpen, setIsOpen] = useState(false); + const [viewDate, setViewDate] = useState(value ? new Date(value) : new Date()); + const [popoverPos, setPopoverPos] = useState({ top: 0, left: 0, width: 320 }); + const containerRef = useRef(null); + const popoverRef = useRef(null); + + // Update popover position when opening or when window resizes + const updatePosition = () => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect(); + const scrollX = window.scrollX; + const scrollY = window.scrollY; + + // Try to align with the right side of the input for better visibility + let left = rect.left + rect.width - 320 + scrollX; + // But don't go off-screen to the left + if (left < scrollX + 16) { + left = rect.left + scrollX; + } + + setPopoverPos({ + top: rect.bottom + scrollY + 4, + left: left, + width: 320 + }); + } + }; + + useEffect(() => { + if (isOpen) { + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + } + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen]); + + // Ensure viewDate is valid + useEffect(() => { + if (value) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + setViewDate(date); + } + } + }, [value]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && !containerRef.current.contains(event.target as Node) && + popoverRef.current && !popoverRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } else { + document.removeEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + const handleInputClick = () => { + if (!disabled) { + const nextOpen = !isOpen; + if (nextOpen) { + updatePosition(); + } + setIsOpen(nextOpen); + } + }; + + const handlePrevMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1)); + }; + + const handleNextMonth = () => { + setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1)); + }; + + const handleDateSelect = (day: number) => { + const selectedDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day); + const year = selectedDate.getFullYear(); + const month = String(selectedDate.getMonth() + 1).padStart(2, '0'); + const d = String(selectedDate.getDate()).padStart(2, '0'); + onChange(`${year}-${month}-${d}`); + setIsOpen(false); + }; + + const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate(); + const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay(); + + const renderCalendar = () => { + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + const daysCount = daysInMonth(year, month); + const startingDay = firstDayOfMonth(year, month); + const monthName = viewDate.toLocaleString('default', { month: 'long' }); + + const days = []; + for (let i = 0; i < startingDay; i++) { + days.push(
); + } + + const selectedDate = value ? new Date(value) : null; + const isSelected = (d: number) => { + return selectedDate && + selectedDate.getFullYear() === year && + selectedDate.getMonth() === month && + selectedDate.getDate() === d; + }; + + const today = new Date(); + const isToday = (d: number) => { + return today.getFullYear() === year && + today.getMonth() === month && + today.getDate() === d; + }; + + for (let d = 1; d <= daysCount; d++) { + days.push( + + ); + } + + const calendarContent = ( +
+
+
+ {monthName} {year} +
+
+ + +
+
+ +
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => ( +
+ {day} +
+ ))} +
+ +
+ {days} +
+ +
+ +
+
+ ); + + return createPortal(calendarContent, document.body); + }; + + const formattedValue = value ? new Date(value).toLocaleDateString() : ''; + + return ( +
+
+ + +
+ {formattedValue || {placeholder}} +
+ +
+ +
+
+ + + {/* Hidden input for Playwright test compatibility */} + onChange(e.target.value)} + className="sr-only" + style={{ + position: 'absolute', + width: '1px', + height: '1px', + padding: '0', + margin: '-1px', + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: '0', + pointerEvents: 'none', + opacity: 0 + }} + tabIndex={-1} + aria-hidden="true" + /> + + {isOpen && renderCalendar()} +
+ ); +}; diff --git a/tests/00_smoke.spec.ts b/tests/00_smoke.spec.ts index d92539f..2a20bbc 100644 --- a/tests/00_smoke.spec.ts +++ b/tests/00_smoke.spec.ts @@ -6,7 +6,7 @@ test.describe('Smoke Tests - Backend Refactor', () => { const password = 'password123'; // 1. Register - const registerRes = await request.post('http://localhost:3001/api/auth/register', { + const registerRes = await request.post('http://localhost:3201/api/auth/register', { data: { email, password } }); expect(registerRes.ok()).toBeTruthy(); @@ -17,7 +17,7 @@ test.describe('Smoke Tests - Backend Refactor', () => { const token = registerBody.data.token; // 2. Get Exercises - const exercisesRes = await request.get('http://localhost:3001/api/exercises', { + const exercisesRes = await request.get('http://localhost:3201/api/exercises', { headers: { Authorization: `Bearer ${token}` } }); expect(exercisesRes.ok()).toBeTruthy(); @@ -26,7 +26,7 @@ test.describe('Smoke Tests - Backend Refactor', () => { expect(Array.isArray(exercisesBody.data)).toBe(true); // 3. Create Session - const sessionRes = await request.post('http://localhost:3001/api/sessions', { + const sessionRes = await request.post('http://localhost:3201/api/sessions', { headers: { Authorization: `Bearer ${token}` }, data: { id: "test-session-" + Date.now(), @@ -40,7 +40,7 @@ test.describe('Smoke Tests - Backend Refactor', () => { expect(sessionBody.data).toHaveProperty('id'); // 4. Get Active Session - const activeRes = await request.get('http://localhost:3001/api/sessions/active', { + const activeRes = await request.get('http://localhost:3201/api/sessions/active', { headers: { Authorization: `Bearer ${token}` } }); expect(activeRes.ok()).toBeTruthy(); diff --git a/tests/05_user_system.spec.ts b/tests/05_user_system.spec.ts index 1fcd257..1247409 100644 --- a/tests/05_user_system.spec.ts +++ b/tests/05_user_system.spec.ts @@ -158,7 +158,7 @@ test.describe('V. User & System Management', () => { await page.getByRole('button', { name: 'Login' }).click(); } - await page.getByRole('button', { name: /Профиль|Profile/ }).click(); + await page.getByRole('button', { name: /^(Профиль|Profile)$/ }).click(); await expect(page.getByRole('heading', { name: 'Профиль', exact: true })).toBeVisible(); }); @@ -498,7 +498,7 @@ test.describe('V. User & System Management', () => { const user = await createUniqueUser(); const apiContext = await playwrightRequest.newContext({ - baseURL: 'http://127.0.0.1:3001', + baseURL: 'http://127.0.0.1:3201', extraHTTPHeaders: { 'Authorization': `Bearer ${user.token}` } diff --git a/tests/fixtures.ts b/tests/fixtures.ts index 782374d..a6cf99f 100644 --- a/tests/fixtures.ts +++ b/tests/fixtures.ts @@ -15,10 +15,8 @@ type MyFixtures = { // Extend the base test with our custom fixture export const test = base.extend({ createUniqueUser: async ({ }, use) => { - // We use a new API context for setup to avoid polluting request history, - // although setup requests are usually separate anyway. const apiContext = await request.newContext({ - baseURL: 'http://127.0.0.1:3001' // Direct access to backend + baseURL: 'http://127.0.0.1:3201' // Direct access to backend }); // Setup: Helper function to create a user @@ -48,12 +46,6 @@ export const test = base.extend({ // Use the fixture await use(createUser); - - // Cleanup: In a real "test:full" env with ephemeral db, cleanup might not be needed. - // But if we want to be clean, we can delete the user. - // Requires admin privileges usually, or specific delete-me endpoint. - // Given the requirements "delete own account" exists (5.6), we could theoretically login and delete. - // For now we skip auto-cleanup to keep it simple, assuming test DB is reset periodically. }, createAdminUser: async ({ createUniqueUser }, use) => { @@ -65,7 +57,7 @@ export const test = base.extend({ try { const { stdout, stderr } = await exec(`npx ts-node promote_admin.ts ${user.email}`, { cwd: 'server', - env: { ...process.env, APP_MODE: 'test', DATABASE_URL: 'file:d:/Coding/gymflow/server/test.db', DATABASE_URL_TEST: 'file:d:/Coding/gymflow/server/test.db' } + env: { ...process.env, APP_MODE: 'test', DATABASE_URL: 'file:d:/Coding/gymflow/server/prisma/test.db', DATABASE_URL_TEST: 'file:d:/Coding/gymflow/server/prisma/test.db' } }); if (stderr) { console.error(`Promote Admin Stderr: ${stderr}`); diff --git a/vite.config.ts b/vite.config.ts index c35dd7d..2628b59 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,11 +6,11 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); return { server: { - port: 3000, + port: 5173, host: '0.0.0.0', proxy: { '/api': { - target: 'http://localhost:3001', + target: `http://localhost:${process.env.BACKEND_PORT || 3200}`, changeOrigin: true, secure: false, }