diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml new file mode 100644 index 0000000..d9ed767 --- /dev/null +++ b/.github/workflows/e2e-tests.yml @@ -0,0 +1,121 @@ +name: E2E Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + # Add shardIndex: [1, 2, 3, 4, 5, 6, 7, 8] and shardTotal: [8] for more shards + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Browsers + run: npx playwright install --with-deps + + - name: Setup Supabase + run: | + npx supabase start + sleep 10 + npx supabase db reset + + + - name: Build project + run: npm run build + + - name: Run Playwright tests + # PLAYWRIGHT_JSON_OUTPUT_NAME=json-report-${{ matrix.shardIndex }}.json + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob,json + env: + TEST_SUPABASE_URL: http://localhost:54321 + TEST_SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCN9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report + retention-days: 1 + + # - uses: actions/upload-artifact@v4 + # if: ${{ !cancelled() }} + # with: + # name: json-report-${{ matrix.shardIndex }}.json + # path: json-report-${{ matrix.shardIndex }}.json + # retention-days: 1 + + report: + needs: test + runs-on: ubuntu-latest + if: always() + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + # - name: Merge into HTML Report + # run: npx playwright merge-reports --reporter html ./all-blob-reports + + # - name: Download JSON reports + # uses: actions/download-artifact@v4 + # with: + # path: all-json-reports + # pattern: json-report-* + # merge-multiple: true + + # - name: Debug JSON reports directory + # run: | + # echo "=== Contents of all-json-reports ===" + # ls -la all-json-reports/ + # echo "=== Recursive listing ===" + # find all-json-reports -type f -name "*.json" + # echo "=== Current working directory ===" + # pwd + # echo "=== Directory structure ===" + # tree all-json-reports || find all-json-reports -type f + + - name: Merge JSON reports + run: npx playwright merge-reports --reporter json ./all-blob-reports > ./all-json-reports.json + + - name: Generate summary comment + uses: daun/playwright-report-summary@v3 + if: always() + with: + report-file: all-json-reports.json + + - uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d362a7a..ff9a8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,13 @@ dist-ssr *.sw? supabase/.temp + +# Test artifacts +playwright-report/ +test-results/ +tests/screenshots/ + +.env + +supabase/.temp +supabase/.branches diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1814bb1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Essential Commands + +### Development +- `npm run dev` - Start development server on port 8080 +- `npm run build` - Production build +- `npm run build:dev` - Development build +- `npm run lint` - Run ESLint +- `npm run preview` - Preview production build + +### Testing +- `npm run test:e2e` - Run Playwright end-to-end tests +- `npm run test:e2e:ui` - Run tests with Playwright UI +- `npm run test:e2e:headed` - Run tests in headed mode +- `npm run test:e2e:debug` - Debug tests +- `npm run test:e2e:report` - Show test report +- `npm run test:setup` - Setup test environment +- `npm run test:setup:full` - Setup full local Supabase + +## Architecture Overview + +### Tech Stack +- **Frontend**: React 18 + TypeScript + Vite +- **UI**: shadcn/ui components with Radix UI primitives +- **Styling**: Tailwind CSS with custom design system +- **Database**: Supabase PostgreSQL with Row Level Security +- **State Management**: TanStack Query for server state +- **Authentication**: Supabase Auth (magic links + OTP) +- **Testing**: Playwright for E2E tests + +### Project Structure +``` +src/ +├── components/ # Reusable UI components +│ ├── ui/ # shadcn/ui base components +│ ├── Admin/ # Admin dashboard components +│ ├── ArtistDetail/ # Artist detail page components +│ ├── Index/ # Main page components +│ └── legal/ # Legal pages (privacy, terms) +├── hooks/ # Custom React hooks +│ ├── queries/ # TanStack Query hooks +│ └── useAuth.ts # Authentication logic +├── integrations/ +│ └── supabase/ # Supabase client and types +├── lib/ # Utility functions +├── pages/ # Route components +├── services/ # Business logic services +└── types/ # TypeScript type definitions +``` + +### Core Application Concepts + +**VibeTribe** is a collaborative festival voting platform with these key features: + +1. **Artist Management**: Core team can add/edit artists with genres, stages, and metadata +2. **Voting System**: Three vote types - "Must Go" (2), "Interested" (1), "Won't Go" (-1) +3. **Group System**: Users can create groups for collaborative decision-making +4. **Real-time Updates**: Live vote counts and artist changes via Supabase subscriptions +5. **Authentication**: Passwordless login with magic links and OTP backup + +### Database Schema +- `artists` - Artist information with genres, stages, performance times +- `votes` - User votes linked to artists and groups +- `artist_notes` - Collaborative notes visible to group members +- `groups` - Voting groups with invite system +- `group_members` - Group membership with roles +- `profiles` - Extended user information + +### Key Architectural Patterns + +1. **Component Architecture**: Functional components with TypeScript, using custom hooks for business logic +2. **State Management**: Server state via TanStack Query, URL state for filters, local state for UI +3. **Real-time Features**: Supabase subscriptions for live updates +4. **Security**: Row Level Security policies, role-based permissions (Anonymous, Authenticated, Group Creator, Core Team) +5. **Offline Support**: PWA configuration with service worker caching + +### Development Guidelines + +1. **TypeScript**: Full type coverage with generated Supabase types +2. **Styling**: Use Tailwind utilities and existing component patterns +3. **Components**: Follow shadcn/ui patterns for new UI components +4. **Data Fetching**: Use TanStack Query hooks in `hooks/queries/` +5. **Authentication**: Use `useAuth` hook for auth state and actions +6. **Routing**: Add new routes to `App.tsx` above the catch-all "*" route + +### Testing Setup +- E2E tests use Playwright with local Supabase instance +- Tests run against port 8080 (development server) +- Test data setup via `scripts/setup-test-env.sh` +- CI/CD runs tests in parallel across multiple browsers +- Detailed testing documentation in `tests/README.md` + +### Code Conventions +- ESLint configuration disables unused variable warnings +- React hooks follow standard patterns +- Component files use PascalCase +- Custom hooks use camelCase with "use" prefix +- Services and utilities use camelCase + +### Important Notes +- Server runs on port 8080 (not standard 3000) +- Authentication uses magic links primarily, OTP as backup +- All database operations go through Supabase RLS policies +- Group-based permissions affect data visibility +- PWA manifest configured for "UpLine" branding \ No newline at end of file diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..0fbc5e6 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,63 @@ +# Testing Setup for Boom Voter + +> For step-by-step E2E test instructions, test data, commands, coverage, debugging, troubleshooting, and next steps, see [tests/README.md](../tests/README.md). + +## 🎯 What We've Added + +### 1. **Playwright E2E Testing** +- **Framework**: Playwright for end-to-end testing +- **Browsers**: Chrome, Firefox, Safari, Mobile Chrome, Mobile Safari +- **Features**: Screenshots, videos, traces, parallel execution +- **Configuration**: `playwright.config.ts` +- **How to run E2E tests, setup test data, and available commands:** See [tests/README.md](../tests/README.md) + +### 2. **Local Supabase Testing Environment** +- **Local Database**: Supabase running on Docker +- **Test Data**: Automated setup with sample artists, genres, groups +- **Isolation**: Separate from production environment +- **Scripts**: Easy setup and teardown + +### 3. **Test Infrastructure** +- **Test Helpers**: Common utilities for authentication, navigation, etc. +- **Test Data**: Consistent test scenarios +- **Environment Config**: Flexible configuration for different environments +- **CI/CD Integration**: GitHub Actions workflow + + +## 🚀 Getting Started + +For E2E test setup, test data, and available commands, see [tests/README.md](../tests/README.md). + +## 🧪 Available Commands + +See [tests/README.md](../tests/README.md) for all E2E test commands and usage. + +## 🔧 Configuration + +For E2E environment variables and test data details, see [tests/README.md](../tests/README.md). + +## 🔄 CI/CD Integration + +### GitHub Actions Workflow +- **Triggers**: Push to main/develop, PRs +- **Parallel Execution**: 4 shards for faster runs +- **Artifacts**: Test reports, screenshots, videos +- **Environment**: Local Supabase in CI + +### Workflow Steps +1. Setup Node.js and dependencies +2. Install Playwright browsers +3. Start local Supabase +4. Populate test data +5. Build application +6. Run tests in parallel +7. Generate and upload reports + +## 🗄️ Local Supabase + +For local Supabase usage in E2E, see [tests/README.md](../tests/README.md). + +## 📚 Resources + +- [Supabase Local Development](https://supabase.com/docs/guides/cli/local-development) +- [GitHub Actions Documentation](https://docs.github.com/en/actions) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c62e66f..0808519 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@playwright/test": "^1.54.1", "@tailwindcss/typography": "^0.5.15", "@types/node": "^22.5.5", "@types/react": "^18.3.3", @@ -82,7 +83,9 @@ "globals": "^15.9.0", "lovable-tagger": "^1.1.7", "postcss": "^8.4.47", + "supabase": "^2.30.4", "tailwindcss": "^3.4.11", + "tsx": "^4.20.3", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.1" @@ -2346,6 +2349,19 @@ "node": ">=12" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", @@ -2436,6 +2452,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.1.tgz", + "integrity": "sha512-FS8hQ12acieG2dYSksmLOF7BNxnVf2afRJdCuM1eMSxj6QTSE6G4InGF7oApGgDb65MX7AwMVlIkpru0yZA4Xw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -4985,6 +5017,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5241,6 +5283,23 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5451,6 +5510,16 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5472,6 +5541,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -5711,6 +5790,16 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -6431,6 +6520,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6555,6 +6668,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6720,6 +6846,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -6917,6 +7056,20 @@ "node": ">= 0.4" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/idb": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", @@ -7806,6 +7959,35 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7858,6 +8040,46 @@ "react-dom": "^16.8 || ^17 || ^18" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", @@ -7883,6 +8105,16 @@ "node": ">=0.10.0" } }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -8123,6 +8355,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.1.tgz", + "integrity": "sha512-peWpSwIBmSLi6aW2auvrUtf2DqY16YYcCMO8rTVx486jKmDTJg7UAhyrraP98GB8BoPURZP8+nxO7TSd4cPr5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.1.tgz", + "integrity": "sha512-Nbjs2zjj0htNhzgiy5wu+3w09YetDx5pkrpI/kZotDlDUaYk0HVA5xrBVPdow4SAUIlhgKcJeJg4GRKW6xHusA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -8311,6 +8590,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8585,6 +8874,16 @@ "pify": "^2.3.0" } }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8775,6 +9074,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9440,6 +9749,26 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supabase": { + "version": "2.30.4", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.30.4.tgz", + "integrity": "sha512-AOCyd2vmBBMTXbnahiCU0reRNxKS4n5CrPciUF2tcTrQ8dLzl1HwcLfe5DrG8E0QRcKHPDdzprmh/2+y4Ta5MA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^5.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.4.3" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9533,6 +9862,34 @@ "node": ">=4" } }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/temp-dir": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", @@ -9696,6 +10053,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -10555,6 +10932,16 @@ "@esbuild/win32-x64": "0.21.5" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -11139,6 +11526,20 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index f6b92f0..1c656fe 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,14 @@ "build": "vite build", "build:dev": "vite build --mode development", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "test:e2e:report": "playwright show-report", + "test:setup": "bash scripts/setup-test-env.sh", + "test:setup:full": "bash scripts/setup-local-supabase.sh" }, "dependencies": { "@hookform/resolvers": "^3.9.0", @@ -73,6 +80,7 @@ }, "devDependencies": { "@eslint/js": "^9.9.0", + "@playwright/test": "^1.54.1", "@tailwindcss/typography": "^0.5.15", "@types/node": "^22.5.5", "@types/react": "^18.3.3", @@ -85,7 +93,9 @@ "globals": "^15.9.0", "lovable-tagger": "^1.1.7", "postcss": "^8.4.47", + "supabase": "^2.30.4", "tailwindcss": "^3.4.11", + "tsx": "^4.20.3", "typescript": "^5.5.3", "typescript-eslint": "^8.0.1", "vite": "^5.4.1" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5bdbb49 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,78 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: './tests/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? [['blob'], ['html']] : 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8080', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + + /* Take screenshot on failure */ + screenshot: 'only-on-failure', + + /* Record video on failure */ + video: 'retain-on-failure', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + { + name: 'Mobile Chrome', + use: { ...devices['Pixel 5'] }, + }, + { + name: 'Mobile Safari', + use: { ...devices['iPhone 12'] }, + }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'npm run dev', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, + }, +}); \ No newline at end of file diff --git a/scripts/rename-migrations.sh b/scripts/rename-migrations.sh new file mode 100755 index 0000000..258dc74 --- /dev/null +++ b/scripts/rename-migrations.sh @@ -0,0 +1,76 @@ +#!/bin/bash + +# Script to rename migration files with descriptive names + +echo "🔄 Renaming migration files to follow Supabase naming convention..." + +cd supabase/migrations + +# Function to get descriptive name based on file content +get_migration_name() { + local file="$1" + local content=$(head -20 "$file" | tr '[:upper:]' '[:lower:]') + + # Check for common patterns in the migration content + if echo "$content" | grep -q "create table.*artists"; then + echo "create_artists_table" + elif echo "$content" | grep -q "create table.*votes"; then + echo "create_votes_table" + elif echo "$content" | grep -q "create table.*profiles"; then + echo "create_profiles_table" + elif echo "$content" | grep -q "create table.*music_genres"; then + echo "create_music_genres_table" + elif echo "$content" | grep -q "create table.*groups"; then + echo "create_groups_table" + elif echo "$content" | grep -q "create table.*admin_roles"; then + echo "create_admin_roles_table" + elif echo "$content" | grep -q "alter table.*add column"; then + echo "add_columns" + elif echo "$content" | grep -q "insert into.*artists"; then + echo "seed_artists_data" + elif echo "$content" | grep -q "insert into.*music_genres"; then + echo "seed_genres_data" + elif echo "$content" | grep -q "create.*function"; then + echo "create_functions" + elif echo "$content" | grep -q "create.*policy"; then + echo "create_rls_policies" + elif echo "$content" | grep -q "enable.*row level security"; then + echo "enable_rls" + elif echo "$content" | grep -q "create.*trigger"; then + echo "create_triggers" + elif echo "$content" | grep -q "alter.*replica identity"; then + echo "setup_realtime" + elif echo "$content" | grep -q "update.*artists"; then + echo "update_artists_data" + elif echo "$content" | grep -q "fix.*search_path"; then + echo "fix_search_path_security" + else + echo "migration" + fi +} + +# Rename each migration file +for file in *.sql; do + if [[ -f "$file" ]]; then + # Extract timestamp (first 14 characters) + timestamp=$(echo "$file" | cut -c1-14) + + # Get descriptive name + name=$(get_migration_name "$file") + + # Create new filename + new_filename="${timestamp}_${name}.sql" + + # Rename the file + if [[ "$file" != "$new_filename" ]]; then + echo "Renaming: $file -> $new_filename" + mv "$file" "$new_filename" + fi + fi +done + +echo "✅ Migration files renamed successfully!" +echo "" +echo "📋 New migration files:" +ls -la *.sql | head -10 +echo "..." \ No newline at end of file diff --git a/scripts/setup-local-supabase.sh b/scripts/setup-local-supabase.sh new file mode 100755 index 0000000..dfb7486 --- /dev/null +++ b/scripts/setup-local-supabase.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# Comprehensive local Supabase setup script + +echo "🚀 Setting up local Supabase environment..." + +# Check if Supabase CLI is installed +if ! command -v supabase &> /dev/null; then + echo "❌ Supabase CLI not found. Please install it first:" + echo " npm install -g supabase" + exit 1 +fi + +# Check if Docker is running +if ! docker info &> /dev/null; then + echo "❌ Docker is not running. Please start Docker first." + exit 1 +fi + +# Stop any existing Supabase instance +echo "🛑 Stopping any existing Supabase instances..." +supabase stop 2>/dev/null || true + +# Start local Supabase +echo "📦 Starting local Supabase..." +supabase start + +# Wait for Supabase to be ready +echo "⏳ Waiting for Supabase to be ready..." +sleep 15 + +# Check if Supabase is running +if ! supabase status &> /dev/null; then + echo "❌ Failed to start Supabase. Please check Docker and try again." + exit 1 +fi + +# Run migrations +echo "🔄 Running database migrations..." +supabase db reset + +# Verify migrations +echo "✅ Verifying database schema..." +supabase db diff --schema public diff --git a/scripts/setup-test-env.sh b/scripts/setup-test-env.sh new file mode 100755 index 0000000..9f1dcbc --- /dev/null +++ b/scripts/setup-test-env.sh @@ -0,0 +1,28 @@ +#!/bin/bash + +# Setup script for local Supabase test environment + +echo "🚀 Setting up local Supabase test environment..." + +# Check if Supabase CLI is installed +if ! command -v supabase &> /dev/null; then + echo "❌ Supabase CLI not found. Please install it first:" + echo " npm install -g supabase" + exit 1 +fi + +# Start local Supabase +echo "📦 Starting local Supabase..." +supabase start + +# Wait for Supabase to be ready +echo "⏳ Waiting for Supabase to be ready..." +sleep 10 + +# Run migrations +echo "🔄 Running database migrations..." +supabase db reset + +# Get the local credentials +echo "🔑 Getting local Supabase credentials..." +supabase status diff --git a/src/components/ArtistDetail/ArtistInfoCard.tsx b/src/components/ArtistDetail/ArtistInfoCard.tsx index e77d3d5..3b252e3 100644 --- a/src/components/ArtistDetail/ArtistInfoCard.tsx +++ b/src/components/ArtistDetail/ArtistInfoCard.tsx @@ -21,7 +21,6 @@ interface ArtistInfoCardProps { netVoteScore: number; onVote: (voteType: number) => void; getVoteCount: (voteType: number) => number; - onArtistUpdate: () => void; onArchiveArtist?: () => Promise; use24Hour?: boolean; } @@ -33,7 +32,6 @@ export const ArtistInfoCard = ({ netVoteScore, onVote, getVoteCount, - onArtistUpdate, onArchiveArtist, use24Hour = false, }: ArtistInfoCardProps) => { @@ -98,7 +96,6 @@ export const ArtistInfoCard = ({
{ @@ -11,37 +9,14 @@ export const useArtistDetail = (id: string | undefined) => { const { data: canEdit = false, isLoading: isLoadingPermissions } = useUserPermissionsQuery(user?.id, "edit_artists"); - const [artist, setArtist] = useState(null); - const [loading, setLoading] = useState(true); - const { toast } = useToast(); - - const { archiveArtist: archiveArtistOffline } = useOfflineArtistData(); + const { artists, loading: artistsLoading, archiveArtist: archiveArtistOffline } = useOfflineArtistData(); const { userVotes, handleVote } = useOfflineVoting(user); - useEffect(() => { - if (id) { - loadArtist(); - } - }, [id]); - - const loadArtist = async () => { - if (!id) return; - - setLoading(true); - try { - const artistData = await offlineStorage.getArtist(id); - setArtist(artistData); - } catch (error) { - console.error("Error loading artist:", error); - toast({ - title: "Error", - description: "Failed to load artist data", - variant: "destructive", - }); - } finally { - setLoading(false); - } - }; + // Find the artist from offline-first data + const artist = useMemo(() => { + if (!id || !artists.length) return null; + return artists.find((a) => a.id === id) || null; + }, [id, artists]); const handleVoteAction = async (voteType: number) => { if (!id) return; @@ -50,7 +25,7 @@ export const useArtistDetail = (id: string | undefined) => { const getVoteCount = (voteType: number) => { if (!artist) return 0; - return artist.votes.filter((vote) => vote.vote_type === voteType).length; + return artist.votes?.filter((vote) => vote.vote_type === voteType).length || 0; }; const netVoteScore = artist ? getVoteCount(1) - getVoteCount(-1) : 0; @@ -66,12 +41,11 @@ export const useArtistDetail = (id: string | undefined) => { artist, user, userVote, - loading: authLoading || isLoadingPermissions || loading, + loading: authLoading || isLoadingPermissions || artistsLoading, canEdit, handleVote: handleVoteAction, getVoteCount, netVoteScore, - fetchArtist: loadArtist, archiveArtist, }; }; diff --git a/src/components/EditArtistDialog.tsx b/src/components/EditArtistDialog.tsx index b19868f..0b39103 100644 --- a/src/components/EditArtistDialog.tsx +++ b/src/components/EditArtistDialog.tsx @@ -21,7 +21,7 @@ import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import { useGroups } from "@/hooks/useGroups"; import { Edit } from "lucide-react"; -import { Artist } from "@/services/queries"; +import { Artist, artistQueries } from "@/services/queries"; import { useGenres } from "@/hooks/queries/useGenresQuery"; import { toZonedTime, fromZonedTime } from "date-fns-tz"; import { StageSelector } from "./StageSelector"; @@ -29,14 +29,14 @@ import { formatISO } from "date-fns"; import { useUserPermissionsQuery } from "@/hooks/queries/useGroupsQuery"; import { useAuth } from "@/hooks/useAuth"; import { toDatetimeLocal, toISOString } from "@/lib/timeUtils"; - +import { useQueryClient } from "@tanstack/react-query"; // Helper function to subtract one hour from datetime-local string const subtractOneHour = (datetimeLocal: string): string => { if (!datetimeLocal) return ""; const date = new Date(datetimeLocal); date.setHours(date.getHours() - 1); - + const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); @@ -54,17 +54,13 @@ interface EditArtistDialogProps { export const EditArtistDialog = ({ artist, - onSuccess, trigger, }: EditArtistDialogProps) => { + const queryClient = useQueryClient(); const { user, loading: authLoading } = useAuth(); - const { data: canEdit = false, isLoading: isLoadingPermissions } = useUserPermissionsQuery( - user?.id, - "edit_artists" - ); + const { data: canEdit = false, isLoading: isLoadingPermissions } = + useUserPermissionsQuery(user?.id, "edit_artists"); - - const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [formData, setFormData] = useState({ @@ -127,7 +123,7 @@ export const EditArtistDialog = ({ }); setOpen(false); - onSuccess?.(); + queryClient.invalidateQueries({ queryKey: artistQueries.all() }); } catch (error) { console.error("Error updating artist:", error); toast({ @@ -216,8 +212,12 @@ export const EditArtistDialog = ({ if (formData.time_start || formData.time_end) { setFormData({ ...formData, - time_start: formData.time_start ? subtractOneHour(formData.time_start) : "", - time_end: formData.time_end ? subtractOneHour(formData.time_end) : "", + time_start: formData.time_start + ? subtractOneHour(formData.time_start) + : "", + time_end: formData.time_end + ? subtractOneHour(formData.time_end) + : "", }); } }} diff --git a/src/components/Index/ArtistListItem.tsx b/src/components/Index/ArtistListItem.tsx index fbae84c..1bc2e83 100644 --- a/src/components/Index/ArtistListItem.tsx +++ b/src/components/Index/ArtistListItem.tsx @@ -69,7 +69,7 @@ export const ArtistListItem = ({ artist, userVote, userKnowledge, votingLoading, }; return ( -
+
{/* Mobile Layout (sm and below) */}
{/* Top Row: Image + Basic Info */} diff --git a/src/components/Index/ArtistsPanel.tsx b/src/components/Index/ArtistsPanel.tsx index 9e0032f..17a0722 100644 --- a/src/components/Index/ArtistsPanel.tsx +++ b/src/components/Index/ArtistsPanel.tsx @@ -1,9 +1,7 @@ import { User } from "@supabase/supabase-js"; -import { useOfflineArtistData } from "@/hooks/useOfflineArtistData"; import { useOfflineVoting } from "@/hooks/useOfflineVoting"; import { Artist } from "@/services/queries"; -import { FilterSortState } from "@/hooks/useUrlState"; import { ArtistCard } from "./ArtistCard"; import { ArtistListItem } from "./ArtistListItem"; @@ -15,8 +13,7 @@ export function ArtistsPanel({ user, use24Hour, openAuthDialog, - fetchArtists, - archiveArtist, + onLockSort, }: { items: Array; @@ -24,8 +21,6 @@ export function ArtistsPanel({ user: User; use24Hour: boolean; openAuthDialog(): void; - fetchArtists(): void; - archiveArtist(artistId: string): Promise; onLockSort: () => void; }) { const handleVoteWithLock = async (artistId: string, voteType: number) => { @@ -47,7 +42,7 @@ export function ArtistsPanel({ if (isGrid) { return ( -
+
{items.map((artist) => ( +
{items.map((artist) => ( - Artists + Artists
@@ -109,7 +109,7 @@ export const FilterSortControls = ({ className="text-orange-300 border-orange-400/50 hover:bg-orange-400/20 hover:text-orange-200 flex items-center gap-2" > - Refresh + Refresh )} @@ -126,7 +126,7 @@ export const FilterSortControls = ({ }`} > - {groupDisplayText} + {groupDisplayText} @@ -168,7 +168,7 @@ export const FilterSortControls = ({ }`} > - Filters + Filters {hasActiveFilters && ( { - - - - - - - -
-

Sort Options Explained

- -
-
- -
- Name (A-Z): -

Sort artists alphabetically from A to Z

-
-
- -
- -
- Name (Z-A): -

Sort artists alphabetically from Z to A

-
-
- -
- -
- Highest Rated: -

Sort by weighted average rating based on votes (Must go = 2 points, Interested = 1 point, Won't go = -1 point)

-
-
- -
- -
- Most Popular: -

Sort by weighted popularity score (Must go = 2 points, Interested = 1 point)

-
-
- -
- -
- By Date: -

Sort by estimated performance date (earliest performances first)

-
-
+ + + + + +
+

Sort Options Explained

+ +
+
+ +
+ Name (A-Z): +

Sort artists alphabetically from A to Z

- - - - -

Click for sorting help

-
- + +
+ +
+ Name (Z-A): +

Sort artists alphabetically from Z to A

+
+
+ +
+ +
+ Highest Rated: +

Sort by weighted average rating based on votes (Must go = 2 points, Interested = 1 point, Won't go = -1 point)

+
+
+ +
+ +
+ Most Popular: +

Sort by weighted popularity score (Must go = 2 points, Interested = 1 point)

+
+
+ +
+ +
+ By Date: +

Sort by estimated performance date (earliest performances first)

+
+
+
+
+
+
); }; diff --git a/src/components/Index/useArtistFiltering.ts b/src/components/Index/useArtistFiltering.ts index 24d7105..ff54333 100644 --- a/src/components/Index/useArtistFiltering.ts +++ b/src/components/Index/useArtistFiltering.ts @@ -1,9 +1,8 @@ -import { useEffect, useState, useMemo } from "react"; +import { useEffect, useState, useMemo, useCallback } from "react"; import { supabase } from "@/integrations/supabase/client"; import type { Artist } from "@/hooks/useOfflineArtistData"; import type { FilterSortState } from "../../hooks/useUrlState"; -import { STAGES } from "./filters/constants"; export const useArtistFiltering = (artists: Artist[], filterSortState?: FilterSortState) => { const [groupMemberIds, setGroupMemberIds] = useState([]); @@ -133,66 +132,23 @@ export const useArtistFiltering = (artists: Artist[], filterSortState?: FilterSo } return filtered; - }, [artists, filterSortState, groupMemberIds]); + }, [artists, filterSortState, groupMemberIds, lockedOrder]); - // Manage locked order snapshot separately to avoid circular dependency + // Update locked order when sort is unlocked useEffect(() => { if (!filterSortState?.sortLocked) { - // When sort is not locked, update the locked order with current results - const filtered = artists.map(artist => { - let filteredVotes = artist.votes; - if (filterSortState?.groupId && groupMemberIds.length > 0) { - filteredVotes = artist.votes.filter(vote => groupMemberIds.includes(vote.user_id)); - } - return { ...artist, votes: filteredVotes }; - }).filter(artist => { - if (filterSortState?.stages.length > 0 && artist.stage) { - if (!filterSortState.stages.includes(artist.stage)) return false; - } - if (filterSortState?.genres.length > 0 && artist.music_genres) { - if (!filterSortState.genres.includes(artist.genre_id)) return false; - } - if (filterSortState?.minRating > 0) { - const rating = calculateRating(artist); - if (rating < filterSortState.minRating) return false; - } - return true; - }); - - // Apply sorting - filtered.sort((a, b) => { - let primarySort = 0; - switch (filterSortState?.sort) { - case 'name-asc': - return a.name.localeCompare(b.name); - case 'name-desc': - return b.name.localeCompare(a.name); - case 'rating-desc': - primarySort = calculateRating(b) - calculateRating(a); - break; - case 'popularity-desc': - primarySort = getWeightedPopularityScore(b) - getWeightedPopularityScore(a); - break; - case 'date-asc': - if (!a.time_start && !b.time_start) { - primarySort = 0; - break; - } - if (!a.time_start) return 1; - if (!b.time_start) return -1; - primarySort = new Date(a.time_start).getTime() - new Date(b.time_start).getTime(); - break; - default: - primarySort = 0; - } - return primarySort !== 0 ? primarySort : a.name.localeCompare(b.name); - }); - - setLockedOrder(filtered); + setLockedOrder([]); } - }, [artists, filterSortState, groupMemberIds]); + }, [filterSortState?.sortLocked]); + + // Function to lock the current order and update URL state + const lockCurrentOrder = useCallback((updateUrlState: (state: Partial) => void) => { + setLockedOrder([...filteredAndSortedArtists]); + updateUrlState({ sortLocked: true }); + }, [filteredAndSortedArtists]); return { filteredAndSortedArtists, + lockCurrentOrder, }; }; diff --git a/src/components/schedule/ScheduleTimelineView.tsx b/src/components/schedule/ScheduleTimelineView.tsx deleted file mode 100644 index b7b9ba1..0000000 --- a/src/components/schedule/ScheduleTimelineView.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { useEffect, useRef, useState, useCallback } from "react"; -import { ArtistScheduleBlock } from "./ArtistScheduleBlock"; -import { DayDivider } from "./DayDivider"; -import { FloatingDateIndicator } from "./FloatingDateIndicator"; -import { TimelineProgress } from "./TimelineProgress"; -import { DateNavigation } from "./DateNavigation"; -import { useStreamingTimeline } from "@/hooks/useStreamingTimeline"; -import { useScheduleData } from "@/hooks/useScheduleData"; -import { format } from "date-fns"; -import ErrorBoundary from "../ErrorBoundary"; - -interface ScheduleTimelineViewProps { - userVotes: Record; - onVote: (artistId: string, voteType: number) => void; -} - -export const ScheduleTimelineView = ({ userVotes, onVote }: ScheduleTimelineViewProps) => { - const { streamingItems, totalArtists, loading, error } = useStreamingTimeline(); - const { scheduleDays } = useScheduleData(); - const containerRef = useRef(null); - const observerRef = useRef(null); - const [currentDateIndex, setCurrentDateIndex] = useState(0); - const [showFloatingDate, setShowFloatingDate] = useState(false); - const [visibleItemIndex, setVisibleItemIndex] = useState(0); - - // Enhanced Intersection Observer with proper cleanup - useEffect(() => { - const container = containerRef.current; - if (!container || !streamingItems || streamingItems.length === 0) { - - return; - } - - // Clean up existing observer - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - - try { - const observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - const itemIndex = parseInt(entry.target.getAttribute('data-index') || '0'); - - // Defensive check for item existence - if (itemIndex >= 0 && itemIndex < streamingItems.length) { - const item = streamingItems[itemIndex]; - - setVisibleItemIndex(itemIndex); - - if (item?.type === 'day-divider') { - setCurrentDateIndex(itemIndex); - setShowFloatingDate(true); - } - } - } - }); - }, - { - root: container, - rootMargin: '-20% 0px -70% 0px', - threshold: 0.1, - } - ); - - observerRef.current = observer; - - // Observe all items with defensive checks - const items = container.querySelectorAll('[data-index]'); - if (items.length > 0) { - items.forEach((item) => observer.observe(item)); - - } - } catch (err) { - console.error('ScheduleTimelineView - Error setting up intersection observer:', err); - } - - return () => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - }; - }, [streamingItems]); - - const scrollToNow = useCallback(() => { - if (!streamingItems || streamingItems.length === 0) return; - - const now = new Date(); - const nowItem = streamingItems.find(item => { - if (item.type !== 'artist' || !item.artist?.startTime) return false; - return item.artist.startTime <= now; - }); - - if (nowItem && containerRef.current) { - const element = containerRef.current.querySelector(`[data-index="${nowItem.position}"]`); - element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [streamingItems]); - - const scrollToDate = useCallback((dateIndex: number) => { - if (!streamingItems || streamingItems.length === 0 || !scheduleDays || dateIndex >= scheduleDays.length) return; - - const dayDividerItem = streamingItems.find(item => - item.type === 'day-divider' && - item.date === scheduleDays[dateIndex]?.date - ); - - if (dayDividerItem && containerRef.current) { - const element = containerRef.current.querySelector(`[data-index="${dayDividerItem.position}"]`); - element?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [streamingItems, scheduleDays]); - - if (loading) { - return ( -
-

Loading festival timeline...

-
- ); - } - - if (error) { - console.error('ScheduleTimelineView - Error:', error); - return ( -
-

Error loading schedule. Please try refreshing.

- -
- ); - } - - if (!streamingItems || streamingItems.length === 0) { - - return ( -
-

No performances scheduled.

-
- ); - } - - const currentDate = streamingItems[currentDateIndex]?.displayDate || ''; - - return ( - -
- 0} - /> - - - -
-
- {streamingItems.map((item, index) => { - if (!item) { - - return null; - } - - return ( -
- {item.type === 'day-divider' ? ( - - ) : item.artist ? ( -
-
-
- {item.artist.startTime ? format(item.artist.startTime, 'HH:mm') : '--:--'} -
- {item.artist.endTime && ( -
- {format(item.artist.endTime, 'HH:mm')} -
- )} -
- -
-
-
- -
- -
-
- ) : null} -
- ); - })} -
-
- - 10} - /> -
-
- ); -}; \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index fd841ba..3682fab 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useProfileQuery } from "@/hooks/queries/useProfileQuery"; import { profileOfflineService } from "@/services/profileOfflineService"; @@ -95,13 +95,15 @@ export const useAuth = () => { await supabase.auth.signOut(); }; - function hasUsername() { + + + const hasUsername = useMemo(() => { return ( // loading || // profileQuery.isLoading || (profile?.username && profile?.username.trim() !== "") ); - } + }, [profile]); return { user, diff --git a/src/hooks/useOfflineArtistData.ts b/src/hooks/useOfflineArtistData.ts index 4a87959..433a6f2 100644 --- a/src/hooks/useOfflineArtistData.ts +++ b/src/hooks/useOfflineArtistData.ts @@ -1,21 +1,26 @@ import { useEffect, useRef, useState, useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { supabase } from "@/integrations/supabase/client"; -import { useArtistsQuery, useArchiveArtistMutation } from "./queries/useArtistsQuery"; +import { + useArtistsQuery, + useArchiveArtistMutation, +} from "./queries/useArtistsQuery"; import { artistQueries, voteQueries } from "@/services/queries"; import { useOnlineStatus, useOfflineData } from "./useOffline"; import { offlineStorage } from "@/lib/offlineStorage"; import type { Artist } from "@/services/queries"; +import { RealtimeChannel } from "@supabase/supabase-js"; export const useOfflineArtistData = () => { const queryClient = useQueryClient(); const { data: artists = [], isLoading, error, refetch } = useArtistsQuery(); const archiveArtistMutation = useArchiveArtistMutation(); - const channelRef = useRef(null); + const channelRef = useRef(null); const isOnline = useOnlineStatus(); - const { offlineReady, saveArtistsOffline, getArtistsOffline } = useOfflineData(); + const { offlineReady, saveArtistsOffline, getArtistsOffline } = + useOfflineData(); const [offlineArtists, setOfflineArtists] = useState([]); - const [dataSource, setDataSource] = useState<'online' | 'offline'>('online'); + const [dataSource, setDataSource] = useState<"online" | "offline">("online"); // Load offline data when ready useEffect(() => { @@ -37,45 +42,50 @@ export const useOfflineArtistData = () => { // Clean up any existing channel first if (channelRef.current) { - supabase.removeChannel(channelRef.current); channelRef.current = null; } // Create unique channel name to prevent conflicts - const channelName = `artists-changes-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - - + const channelName = `artists-changes-${Date.now()}-${Math.random() + .toString(36) + .substr(2, 9)}`; + try { const artistsChannel = supabase .channel(channelName) - .on('postgres_changes', { event: '*', schema: 'public', table: 'artists' }, (payload) => { - - queryClient.invalidateQueries({ queryKey: artistQueries.lists() }); - }) - .on('postgres_changes', { event: '*', schema: 'public', table: 'votes' }, (payload) => { - - queryClient.invalidateQueries({ queryKey: artistQueries.lists() }); - queryClient.invalidateQueries({ queryKey: voteQueries.all() }); - }) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "artists" }, + (payload) => { + queryClient.invalidateQueries({ queryKey: artistQueries.lists() }); + } + ) + .on( + "postgres_changes", + { event: "*", schema: "public", table: "votes" }, + (payload) => { + queryClient.invalidateQueries({ queryKey: artistQueries.lists() }); + queryClient.invalidateQueries({ queryKey: voteQueries.all() }); + } + ) .subscribe((status, err) => { if (err) { - console.error('Subscription error:', err); + console.error("Subscription error:", err); } }); channelRef.current = artistsChannel; } catch (err) { - console.error('Failed to create subscription channel:', err); + console.error("Failed to create subscription channel:", err); } return () => { if (channelRef.current) { - try { supabase.removeChannel(channelRef.current); } catch (err) { - console.error('Error cleaning up channel:', err); + console.error("Error cleaning up channel:", err); } channelRef.current = null; } @@ -87,11 +97,10 @@ export const useOfflineArtistData = () => { const cachedArtists = await getArtistsOffline(); if (cachedArtists.length > 0) { setOfflineArtists(cachedArtists); - setDataSource('offline'); - + setDataSource("offline"); } } catch (error) { - console.error('Error loading offline artists:', error); + console.error("Error loading offline artists:", error); } }, [getArtistsOffline]); @@ -109,14 +118,14 @@ export const useOfflineArtistData = () => { } else { // When offline, queue the action await offlineStorage.saveSetting(`archive_${artistId}`, { - action: 'archive', + action: "archive", artistId, timestamp: Date.now(), synced: false, }); - + // Update local data - const updatedArtists = offlineArtists.map(artist => + const updatedArtists = offlineArtists.map((artist) => artist.id === artistId ? { ...artist, archived: true } : artist ); setOfflineArtists(updatedArtists); @@ -140,4 +149,4 @@ export const useOfflineArtistData = () => { }; }; -export type { Artist }; \ No newline at end of file +export type { Artist }; diff --git a/src/hooks/useOfflineVoting.ts b/src/hooks/useOfflineVoting.ts index 479eba6..0b30946 100644 --- a/src/hooks/useOfflineVoting.ts +++ b/src/hooks/useOfflineVoting.ts @@ -3,8 +3,9 @@ import { supabase } from "@/integrations/supabase/client"; import { useToast } from "@/components/ui/use-toast"; import { offlineStorage } from '@/lib/offlineStorage'; import { useOnlineStatus, useOfflineQueue } from './useOffline'; +import { User } from '@supabase/supabase-js'; -export const useOfflineVoting = (user: any, onVoteUpdate?: () => void) => { +export const useOfflineVoting = (user: User, onVoteUpdate?: () => void) => { const [userVotes, setUserVotes] = useState>({}); const [votingLoading, setVotingLoading] = useState>({}); const { toast } = useToast(); diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 6e5e94b..295c8d7 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -23,7 +23,6 @@ const ArtistDetail = () => { handleVote, getVoteCount, netVoteScore, - fetchArtist, archiveArtist, } = useArtistDetail(id); @@ -60,7 +59,6 @@ const ArtistDetail = () => { netVoteScore={netVoteScore} onVote={handleVote} getVoteCount={getVoteCount} - onArtistUpdate={fetchArtist} onArchiveArtist={canEdit ? handleArchiveArtist : undefined} use24Hour={urlState.use24Hour} /> diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index bac16d2..138b8a2 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { useAuth } from "@/hooks/useAuth"; import { useProfileQuery } from "@/hooks/queries/useProfileQuery"; @@ -22,27 +22,20 @@ export default function Index() { const { inviteValidation, isValidating, hasValidInvite } = useInviteValidation(); const [showAuthDialog, setShowAuthDialog] = useState(false); - const [showUsernameSetup, setShowUsernameSetup] = useState(false); const { state: urlState, updateUrlState, clearFilters } = useUrlState(); - const { artists, fetchArtists, archiveArtist } = useOfflineArtistData(); + const { artists, loading: artistsLoading } = useOfflineArtistData(); const { userVotes, handleVote } = useOfflineVoting(user); - const { filteredAndSortedArtists } = useArtistFiltering(artists, urlState); + const { filteredAndSortedArtists, lockCurrentOrder } = useArtistFiltering(artists, urlState); // Get profile loading state to prevent dialog flashing const { isLoading: profileLoading } = useProfileQuery(user?.id); - // Check if username setup is needed after authentication - useEffect(() => { - // Only show dialog when all data is loaded and user definitely needs username setup - if (user && !authLoading && !profileLoading && !hasUsername()) { - setShowUsernameSetup(true); - } - // Hide dialog when user gets a username or logs out or data is still loading - if (!user || (user && !authLoading && !profileLoading && hasUsername())) { - setShowUsernameSetup(false); - } + + + const showUsernameSetup = useMemo(() => { + return user && !authLoading && !profileLoading && !hasUsername; }, [user, authLoading, profileLoading, hasUsername]); // Show loading while validating invite @@ -85,7 +78,7 @@ export default function Index() { ); } - if (authLoading) { + if (authLoading || artistsLoading) { return (
Loading...
@@ -128,9 +121,7 @@ export default function Index() { user={user} use24Hour={urlState.use24Hour} openAuthDialog={() => setShowAuthDialog(true)} - fetchArtists={fetchArtists} - archiveArtist={archiveArtist} - onLockSort={() => updateUrlState({ sortLocked: true })} + onLockSort={() => lockCurrentOrder(updateUrlState)} /> )} {urlState.mainView === 'timeline' && ( @@ -155,7 +146,7 @@ export default function Index() { open={showUsernameSetup} user={user} onSuccess={() => { - setShowUsernameSetup(false); + // setShowUsernameSetup(false); }} />
diff --git a/src/services/queries.ts b/src/services/queries.ts index d4a9318..d5ad4b8 100644 --- a/src/services/queries.ts +++ b/src/services/queries.ts @@ -21,7 +21,7 @@ type ArtistNote = { export const artistQueries = { all: () => ['artists'] as const, lists: () => [...artistQueries.all(), 'list'] as const, - list: (filters?: any) => [...artistQueries.lists(), filters] as const, + list: (filters?: unknown) => [...artistQueries.lists(), filters] as const, details: () => [...artistQueries.all(), 'detail'] as const, detail: (id: string) => [...artistQueries.details(), id] as const, notes: (artistId: string) => [...artistQueries.detail(artistId), 'notes'] as const, @@ -527,7 +527,7 @@ export const mutationFunctions = { return true; }, - async updateProfile(variables: { userId: string; updates: any }) { + async updateProfile(variables: { userId: string; updates: unknown }) { const { userId, updates } = variables; const { data, error } = await supabase diff --git a/supabase/migrations/20250620065433-e1f45078-3943-49f0-ba19-8ffead5ea774.sql b/supabase/migrations/20250620065433_create_artists_table.sql similarity index 100% rename from supabase/migrations/20250620065433-e1f45078-3943-49f0-ba19-8ffead5ea774.sql rename to supabase/migrations/20250620065433_create_artists_table.sql diff --git a/supabase/migrations/20250620080928-4e724988-f7f5-49ea-9f51-e16c1d708d6c.sql b/supabase/migrations/20250620080928-4e724988-f7f5-49ea-9f51-e16c1d708d6c.sql deleted file mode 100644 index 5674d97..0000000 --- a/supabase/migrations/20250620080928-4e724988-f7f5-49ea-9f51-e16c1d708d6c.sql +++ /dev/null @@ -1,52 +0,0 @@ - --- First, add the missing "Tribal/bass" genre (only if it doesn't exist) -INSERT INTO public.music_genres (name, created_by) -SELECT 'Tribal/bass', NULL -WHERE NOT EXISTS (SELECT 1 FROM public.music_genres WHERE name = 'Tribal/bass'); - --- Get the first available user ID from the profiles table for adding artists --- Now insert all the artists with proper genre mapping using the first available user -INSERT INTO public.artists (name, description, genre_id, added_by) VALUES --- Artists with Downtempo genre -('Merkaba', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Twofold', 'Up Downtempo. Maybe house? I have no idea', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Imanu', 'D&B', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Bayawaka', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('SensoRythm', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Goopsteppa', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Drrtywulvz', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), -('Kalya Scintilla', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), (SELECT id FROM public.profiles LIMIT 1)), - --- Artists with Techno genre -('Carbon', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), (SELECT id FROM public.profiles LIMIT 1)), -('Frida Darko', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), (SELECT id FROM public.profiles LIMIT 1)), -('Richie Hawtin', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), (SELECT id FROM public.profiles LIMIT 1)), - --- Artists with Trance genre -('Digicult', 'Can be aggressive', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Ace Ventura', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Astrix', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Atmos', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Tristan', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Tsuyoshi Suzuki', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('John Fleming', 'I think a bit pop but fun', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Akari System', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Miles from Mars', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), -('Neurolabz', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), (SELECT id FROM public.profiles LIMIT 1)), - --- Artists with Tribal/bass genre -('Tor', '', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), (SELECT id FROM public.profiles LIMIT 1)), -('Liquid Bloom', 'Can be chillout', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), (SELECT id FROM public.profiles LIMIT 1)), -('Nyrus', '', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), (SELECT id FROM public.profiles LIMIT 1)), - --- Artists with Psytrance genre (researched missing genres) -('Kliment', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Prometheus', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Atia', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Dnox and Becker', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Freedom Fighters', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Krumelur', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), -('Ritmo', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), (SELECT id FROM public.profiles LIMIT 1)), - --- Artists with Progressive genre -('Boundless', '', (SELECT id FROM public.music_genres WHERE name = 'Progressive'), (SELECT id FROM public.profiles LIMIT 1)); diff --git a/supabase/migrations/20250620080928_seed_artists_data.sql b/supabase/migrations/20250620080928_seed_artists_data.sql new file mode 100644 index 0000000..16f821f --- /dev/null +++ b/supabase/migrations/20250620080928_seed_artists_data.sql @@ -0,0 +1,73 @@ + +-- First, add the missing "Tribal/bass" genre (only if it doesn't exist) +INSERT INTO public.music_genres (name, created_by) +SELECT 'Tribal/bass', NULL +WHERE NOT EXISTS (SELECT 1 FROM public.music_genres WHERE name = 'Tribal/bass'); + +-- Create a system user in auth.users if no profiles exist +INSERT INTO auth.users (id, email, encrypted_password, email_confirmed_at, created_at, updated_at, raw_user_meta_data, is_super_admin, confirmation_token, recovery_token) +SELECT + '00000000-0000-0000-0000-000000000000'::uuid, + 'system@boom-voter.local', + crypt('system-password', gen_salt('bf')), + now(), + now(), + now(), + '{"username": "system"}'::jsonb, + false, + '', + '' +WHERE NOT EXISTS (SELECT 1 FROM public.profiles LIMIT 1); + +-- Create the corresponding profile for the system user +INSERT INTO public.profiles (id, username) +SELECT + '00000000-0000-0000-0000-000000000000'::uuid, + 'system' +WHERE NOT EXISTS (SELECT 1 FROM public.profiles LIMIT 1); + +-- Now insert all the artists with proper genre mapping using the system user +INSERT INTO public.artists (name, description, genre_id, added_by) VALUES +-- Artists with Downtempo genre +('Merkaba', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Twofold', 'Up Downtempo. Maybe house? I have no idea', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Imanu', 'D&B', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Bayawaka', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('SensoRythm', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Goopsteppa', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Drrtywulvz', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), +('Kalya Scintilla', '', (SELECT id FROM public.music_genres WHERE name = 'Downtempo'), '00000000-0000-0000-0000-000000000000'::uuid), + +-- Artists with Techno genre +('Carbon', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), '00000000-0000-0000-0000-000000000000'::uuid), +('Frida Darko', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), '00000000-0000-0000-0000-000000000000'::uuid), +('Richie Hawtin', '', (SELECT id FROM public.music_genres WHERE name = 'Techno'), '00000000-0000-0000-0000-000000000000'::uuid), + +-- Artists with Trance genre +('Digicult', 'Can be aggressive', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Ace Ventura', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Astrix', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Atmos', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Tristan', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Tsuyoshi Suzuki', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('John Fleming', 'I think a bit pop but fun', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Akari System', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Miles from Mars', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Neurolabz', '', (SELECT id FROM public.music_genres WHERE name = 'Trance'), '00000000-0000-0000-0000-000000000000'::uuid), + +-- Artists with Tribal/bass genre +('Tor', '', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), '00000000-0000-0000-0000-000000000000'::uuid), +('Liquid Bloom', 'Can be chillout', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), '00000000-0000-0000-0000-000000000000'::uuid), +('Nyrus', '', (SELECT id FROM public.music_genres WHERE name = 'Tribal/bass'), '00000000-0000-0000-0000-000000000000'::uuid), + +-- Artists with Psytrance genre (researched missing genres) +('Kliment', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Prometheus', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Atia', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Dnox and Becker', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Freedom Fighters', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Krumelur', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), +('Ritmo', '', (SELECT id FROM public.music_genres WHERE name = 'Psytrance'), '00000000-0000-0000-0000-000000000000'::uuid), + +-- Artists with Progressive genre +('Boundless', '', (SELECT id FROM public.music_genres WHERE name = 'Progressive'), '00000000-0000-0000-0000-000000000000'::uuid); diff --git a/supabase/migrations/20250620101328-8cc5335f-3fa0-4e5a-b4dd-e6817ef139ec.sql b/supabase/migrations/20250620101328_update_artists_data.sql similarity index 100% rename from supabase/migrations/20250620101328-8cc5335f-3fa0-4e5a-b4dd-e6817ef139ec.sql rename to supabase/migrations/20250620101328_update_artists_data.sql diff --git a/supabase/migrations/20250620104828-a53d52a1-01ce-486a-80a4-6e3eb7887ede.sql b/supabase/migrations/20250620104828_update_artists_data.sql similarity index 100% rename from supabase/migrations/20250620104828-a53d52a1-01ce-486a-80a4-6e3eb7887ede.sql rename to supabase/migrations/20250620104828_update_artists_data.sql diff --git a/supabase/migrations/20250620120553-85bb003f-0c5d-474e-bcd6-972e831d9f8c.sql b/supabase/migrations/20250620120553_migration.sql similarity index 100% rename from supabase/migrations/20250620120553-85bb003f-0c5d-474e-bcd6-972e831d9f8c.sql rename to supabase/migrations/20250620120553_migration.sql diff --git a/supabase/migrations/20250620121419-d69f0339-d3f5-4902-b17e-a66b005d4241.sql b/supabase/migrations/20250620121419_update_artists_data.sql similarity index 100% rename from supabase/migrations/20250620121419-d69f0339-d3f5-4902-b17e-a66b005d4241.sql rename to supabase/migrations/20250620121419_update_artists_data.sql diff --git a/supabase/migrations/20250620121752-63ca65fd-cc42-4a74-9cd9-2689c97f7da4.sql b/supabase/migrations/20250620121752_migration.sql similarity index 100% rename from supabase/migrations/20250620121752-63ca65fd-cc42-4a74-9cd9-2689c97f7da4.sql rename to supabase/migrations/20250620121752_migration.sql diff --git a/supabase/migrations/20250620124207-ce5e5d71-e73d-44c5-9b5b-4063e276c99d.sql b/supabase/migrations/20250620124207_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250620124207-ce5e5d71-e73d-44c5-9b5b-4063e276c99d.sql rename to supabase/migrations/20250620124207_create_rls_policies.sql diff --git a/supabase/migrations/20250620140128-121ae6c3-5683-4e3d-a493-514f58ae3eac.sql b/supabase/migrations/20250620140128_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250620140128-121ae6c3-5683-4e3d-a493-514f58ae3eac.sql rename to supabase/migrations/20250620140128_create_rls_policies.sql diff --git a/supabase/migrations/20250620144024-2d610f4f-9403-412c-b591-03ee0d42135f.sql b/supabase/migrations/20250620144024_migration.sql similarity index 100% rename from supabase/migrations/20250620144024-2d610f4f-9403-412c-b591-03ee0d42135f.sql rename to supabase/migrations/20250620144024_migration.sql diff --git a/supabase/migrations/20250620144942-2b4fdd7c-a5c2-4d3d-b775-f6747ad02898.sql b/supabase/migrations/20250620144942_migration.sql similarity index 100% rename from supabase/migrations/20250620144942-2b4fdd7c-a5c2-4d3d-b775-f6747ad02898.sql rename to supabase/migrations/20250620144942_migration.sql diff --git a/supabase/migrations/20250620150443-22890c5f-7261-46ee-8bbd-ea5d618a28e2.sql b/supabase/migrations/20250620150443_migration.sql similarity index 100% rename from supabase/migrations/20250620150443-22890c5f-7261-46ee-8bbd-ea5d618a28e2.sql rename to supabase/migrations/20250620150443_migration.sql diff --git a/supabase/migrations/20250620150938-ecfcf638-44d5-478e-96c9-12d10036b93b.sql b/supabase/migrations/20250620150938_migration.sql similarity index 100% rename from supabase/migrations/20250620150938-ecfcf638-44d5-478e-96c9-12d10036b93b.sql rename to supabase/migrations/20250620150938_migration.sql diff --git a/supabase/migrations/20250620152102-be540ea3-97fa-4d67-b6d6-4d785cb2a9ff.sql b/supabase/migrations/20250620152102_create_groups_table.sql similarity index 100% rename from supabase/migrations/20250620152102-be540ea3-97fa-4d67-b6d6-4d785cb2a9ff.sql rename to supabase/migrations/20250620152102_create_groups_table.sql diff --git a/supabase/migrations/20250621073509-db815051-e170-4580-af7d-a776b479597b.sql b/supabase/migrations/20250621073509_create_functions.sql similarity index 100% rename from supabase/migrations/20250621073509-db815051-e170-4580-af7d-a776b479597b.sql rename to supabase/migrations/20250621073509_create_functions.sql diff --git a/supabase/migrations/20250621073724-f8406649-493b-4e7e-8cde-c91d4d33e58d.sql b/supabase/migrations/20250621073724_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250621073724-f8406649-493b-4e7e-8cde-c91d4d33e58d.sql rename to supabase/migrations/20250621073724_create_rls_policies.sql diff --git a/supabase/migrations/20250621074013-e2672162-160e-471c-a4a5-a5575f98d0f4.sql b/supabase/migrations/20250621074013_add_columns.sql similarity index 100% rename from supabase/migrations/20250621074013-e2672162-160e-471c-a4a5-a5575f98d0f4.sql rename to supabase/migrations/20250621074013_add_columns.sql diff --git a/supabase/migrations/20250621090428-e766e82c-d0b4-4e52-85b3-30e90d50cde1.sql b/supabase/migrations/20250621090428_create_functions.sql similarity index 100% rename from supabase/migrations/20250621090428-e766e82c-d0b4-4e52-85b3-30e90d50cde1.sql rename to supabase/migrations/20250621090428_create_functions.sql diff --git a/supabase/migrations/20250621090745-debae17c-3d32-496f-a0b9-f040e9e0df88.sql b/supabase/migrations/20250621090745_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250621090745-debae17c-3d32-496f-a0b9-f040e9e0df88.sql rename to supabase/migrations/20250621090745_create_rls_policies.sql diff --git a/supabase/migrations/20250621102522-02c403c3-d223-48ab-b999-a53dc74e0974.sql b/supabase/migrations/20250621102522_create_functions.sql similarity index 100% rename from supabase/migrations/20250621102522-02c403c3-d223-48ab-b999-a53dc74e0974.sql rename to supabase/migrations/20250621102522_create_functions.sql diff --git a/supabase/migrations/20250621123057-c3f84bbb-201e-4627-b0e7-3724d79febbf.sql b/supabase/migrations/20250621123057_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250621123057-c3f84bbb-201e-4627-b0e7-3724d79febbf.sql rename to supabase/migrations/20250621123057_create_rls_policies.sql diff --git a/supabase/migrations/20250621125212-4eec42a8-7f25-48b5-90e2-eaffe2b82a0b.sql b/supabase/migrations/20250621125212_create_functions.sql similarity index 100% rename from supabase/migrations/20250621125212-4eec42a8-7f25-48b5-90e2-eaffe2b82a0b.sql rename to supabase/migrations/20250621125212_create_functions.sql diff --git a/supabase/migrations/20250622081538-e032edff-06a4-4b8a-9c2d-138ee95afb9f.sql b/supabase/migrations/20250622081538_update_artists_data.sql similarity index 100% rename from supabase/migrations/20250622081538-e032edff-06a4-4b8a-9c2d-138ee95afb9f.sql rename to supabase/migrations/20250622081538_update_artists_data.sql diff --git a/supabase/migrations/20250622090351-886b099e-d041-4e57-b977-d6747d5a90ae.sql b/supabase/migrations/20250622090351_migration.sql similarity index 100% rename from supabase/migrations/20250622090351-886b099e-d041-4e57-b977-d6747d5a90ae.sql rename to supabase/migrations/20250622090351_migration.sql diff --git a/supabase/migrations/20250623102838-6a39f191-b0fd-4e1c-bca0-3a444cd32e8b.sql b/supabase/migrations/20250623102838_migration.sql similarity index 100% rename from supabase/migrations/20250623102838-6a39f191-b0fd-4e1c-bca0-3a444cd32e8b.sql rename to supabase/migrations/20250623102838_migration.sql diff --git a/supabase/migrations/20250702170434-77406ce2-1f37-43a7-bafc-ab1035dc58b6.sql b/supabase/migrations/20250702170434_create_functions.sql similarity index 100% rename from supabase/migrations/20250702170434-77406ce2-1f37-43a7-bafc-ab1035dc58b6.sql rename to supabase/migrations/20250702170434_create_functions.sql diff --git a/supabase/migrations/20250702171021-960893c6-99c3-4282-8f78-50220e431b85.sql b/supabase/migrations/20250702171021_add_columns.sql similarity index 100% rename from supabase/migrations/20250702171021-960893c6-99c3-4282-8f78-50220e431b85.sql rename to supabase/migrations/20250702171021_add_columns.sql diff --git a/supabase/migrations/20250709094304-82d62de8-95be-4eb3-b4f7-d26b47e72697.sql b/supabase/migrations/20250709094304_create_admin_roles_table.sql similarity index 100% rename from supabase/migrations/20250709094304-82d62de8-95be-4eb3-b4f7-d26b47e72697.sql rename to supabase/migrations/20250709094304_create_admin_roles_table.sql diff --git a/supabase/migrations/20250709105808-4f5c63f1-5579-4b44-ab86-73483ed13626.sql b/supabase/migrations/20250709105808_create_functions.sql similarity index 100% rename from supabase/migrations/20250709105808-4f5c63f1-5579-4b44-ab86-73483ed13626.sql rename to supabase/migrations/20250709105808_create_functions.sql diff --git a/supabase/migrations/20250709114513-4feafdd7-2114-4e8d-8f60-3c43e89b6acc.sql b/supabase/migrations/20250709114513_create_functions.sql similarity index 100% rename from supabase/migrations/20250709114513-4feafdd7-2114-4e8d-8f60-3c43e89b6acc.sql rename to supabase/migrations/20250709114513_create_functions.sql diff --git a/supabase/migrations/20250713203107-58e24b4b-71a7-4c8d-842b-b1bdf0e68b3e.sql b/supabase/migrations/20250713203107_create_rls_policies.sql similarity index 100% rename from supabase/migrations/20250713203107-58e24b4b-71a7-4c8d-842b-b1bdf0e68b3e.sql rename to supabase/migrations/20250713203107_create_rls_policies.sql diff --git a/supabase/migrations/20250729173025_add_artist_dates.sql b/supabase/migrations/20250729173025_add_artist_dates.sql new file mode 100644 index 0000000..930dd04 --- /dev/null +++ b/supabase/migrations/20250729173025_add_artist_dates.sql @@ -0,0 +1,13 @@ +-- Add time_start and time_end columns to artists table (for performance scheduling) +ALTER TABLE public.artists +ADD COLUMN time_start TIMESTAMP WITH TIME ZONE, +ADD COLUMN time_end TIMESTAMP WITH TIME ZONE; + +-- Create index for better query performance on time ranges +CREATE INDEX idx_artists_time_start ON public.artists(time_start); +CREATE INDEX idx_artists_time_end ON public.artists(time_end); + +-- Add constraint to ensure time_end is after time_start when both are set +ALTER TABLE public.artists +ADD CONSTRAINT check_artist_time_order +CHECK (time_start IS NULL OR time_end IS NULL OR time_start <= time_end); \ No newline at end of file diff --git a/supabase/seed.sql b/supabase/seed.sql index b74c3df..915e289 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -25,14 +25,8 @@ INSERT INTO auth.users ( ('55555555-5555-5555-5555-555555555555', '00000000-0000-0000-0000-000000000000', 'diana@example.com', '$2a$10$example_hash', now(), now(), now(), '{"username": "diana_dance"}', false, 'authenticated'), ('66666666-6666-6666-6666-666666666666', '00000000-0000-0000-0000-000000000000', 'eve@example.com', '$2a$10$example_hash', now(), now(), now(), '{"username": "eve_electronic"}', false, 'authenticated'); --- Insert profiles for all test users -INSERT INTO public.profiles (id, username, email, created_at) VALUES - ('11111111-1111-1111-1111-111111111111', 'admin', 'admin@festival.com', now()), - ('22222222-2222-2222-2222-222222222222', 'alice_music', 'alice@example.com', now()), - ('33333333-3333-3333-3333-333333333333', 'bob_beats', 'bob@example.com', now()), - ('44444444-4444-4444-4444-444444444444', 'charlie_vibes', 'charlie@example.com', now()), - ('55555555-5555-5555-5555-555555555555', 'diana_dance', 'diana@example.com', now()), - ('66666666-6666-6666-6666-666666666666', 'eve_electronic', 'eve@example.com', now()); +-- Profiles are automatically created by the handle_new_user() trigger +-- when users are inserted into auth.users, so no manual insertion needed -- Create admin role for the admin user INSERT INTO public.admin_roles (user_id, role, created_by, created_at) VALUES diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..8e5c139 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,230 @@ +# E2E Testing with Playwright + +> For a full overview of all testing infrastructure, CI/CD, and general troubleshooting, see [docs/TESTING.md](../docs/TESTING.md). +> All E2E test coverage, debugging, troubleshooting, and next steps are documented here. + +This directory contains end-to-end tests for the Boom Voter application using Playwright. + +## 🚀 Quick Start + +### Prerequisites + +1. **Node.js 18+** installed +2. **Supabase CLI** installed globally: + ```bash + npm install -g supabase + ``` + +### Setup + +1. **Install dependencies:** + ```bash + npm install + ``` + +2. **Setup local Supabase test environment:** + ```bash + npm run test:setup + ``` + +3. **Setup test data:** + ```bash + npm run test:data + ``` + +4. **Run tests:** + ```bash + npm run test:e2e + ``` + +## 📁 Directory Structure + +``` +tests/ +├── e2e/ # E2E test files +│ ├── auth.spec.ts # Authentication tests +│ ├── navigation.spec.ts # Navigation tests +│ └── artists.spec.ts # Artist functionality tests +├── config/ +│ └── test-env.ts # Test environment configuration +├── utils/ +│ └── test-helpers.ts # Common test utilities +└── README.md # This file +``` + +## 🧪 Available Test Commands + +| Command | Description | +|---------|-------------| +| `npm run test:e2e` | Run all tests in headless mode | +| `npm run test:e2e:ui` | Run tests with Playwright UI | +| `npm run test:e2e:headed` | Run tests in headed mode (visible browser) | +| `npm run test:e2e:debug` | Run tests in debug mode | +| `npm run test:e2e:report` | Open test report | +| `npm run test:setup` | Setup local Supabase environment | +| `npm run test:data` | Setup test data in local Supabase | + +## 🔧 Configuration + +### Environment Variables + +The tests use the following environment variables: + +- `TEST_SUPABASE_URL`: Local Supabase URL (default: `http://localhost:54321`) +- `TEST_SUPABASE_ANON_KEY`: Local Supabase anon key +- `PLAYWRIGHT_BASE_URL`: App base URL (default: `http://localhost:5173`) +- `TEST_USER_EMAIL`: Test user email (default: `test@example.com`) +- `TEST_USER_PASSWORD`: Test user password (default: `testpassword123`) + +### Test Data + +The test data setup script creates: + +- **Artists**: 3 test artists with different genres and schedules +- **Genres**: 5 test genres (Rock, Pop, Jazz, Electronic, Hip Hop) +- **Groups**: 3 test groups +- **Users**: 2 test users (regular user and admin) + +## 🎯 Writing Tests + +### Basic Test Structure + +```typescript +import { test, expect } from '@playwright/test'; +import { TestHelpers } from '../utils/test-helpers'; + +test.describe('Feature Name', () => { + let testHelpers: TestHelpers; + + test.beforeEach(async ({ page }) => { + testHelpers = new TestHelpers(page); + }); + + test('should do something', async ({ page }) => { + await testHelpers.navigateTo('/'); + // Your test logic here + }); +}); +``` + +### Using Test Helpers + +The `TestHelpers` class provides common utilities: + +```typescript +// Sign in +await testHelpers.signIn('user@example.com', 'password'); + +// Navigate to page +await testHelpers.navigateTo('/artists'); + +// Wait for page load +await testHelpers.waitForPageLoad(); + +// Check authentication status +const isAuth = await testHelpers.isAuthenticated(); + +// Take screenshot +await testHelpers.takeScreenshot('test-name'); +``` + +### Best Practices + +1. **Use semantic selectors**: Prefer `getByRole`, `getByLabel`, `getByText` over CSS selectors +2. **Handle async operations**: Always wait for network requests and UI updates +3. **Test user flows**: Focus on complete user journeys rather than isolated components +4. **Use test data**: Leverage the test data setup for consistent scenarios +5. **Handle conditional elements**: Use `.isVisible()` checks for optional UI elements + +## 📋 Test Coverage + +### Current Tests +1. **Authentication** + - Sign in dialog display + - Page title verification +2. **Navigation** + - Page routing + - 404 handling +3. **Artists** + - Artists list display + - Filtering functionality + - Artist detail navigation + - Empty state handling + +### Planned Tests +- User registration +- Voting functionality +- Group management +- Schedule viewing +- Admin features +- Mobile responsiveness +- Offline functionality + +## 📊 Test Reports + +### HTML Report +After running tests, view the interactive report: +```bash +npm run test:e2e:report +``` + +### Report Features +- Test results and timing +- Screenshots and videos +- Traces for debugging +- Error details and stack traces +- Filtering and search + +## 🐛 Debugging + +### Debug Mode + +Run tests in debug mode to step through them: + +```bash +npm run test:e2e:debug +``` + +### UI Mode + +Use Playwright's UI for interactive debugging: + +```bash +npm run test:e2e:ui +``` + +### Screenshots +Tests automatically capture screenshots on failure in `tests/screenshots/` + +## 🔮 Next Steps + +### Immediate +1. **Run initial tests** to verify setup +2. **Add more test scenarios** based on app features +3. **Configure test users** in local Supabase +4. **Add data-testid attributes** to components + +### Future Enhancements +1. **Visual regression testing** +2. **Performance testing** +3. **Accessibility testing** +4. **API testing** with separate test suite +5. **Load testing** for critical user flows + +## 🚨 Troubleshooting + +### Common Issues +1. **Supabase not starting**: Check Docker is running +2. **Tests timing out**: Increase timeouts in config +3. **Element not found**: Update selectors to match UI +4. **Authentication failing**: Verify test user setup + +### Getting Help +1. Check Playwright docs: https://playwright.dev/ +2. Review test logs and screenshots +3. Use debug mode for step-by-step debugging +4. Check Supabase logs: `supabase logs` + +## 🏆 Playwright & Testing Best Practices +- [Playwright Documentation](https://playwright.dev/) +- [Testing Best Practices](https://playwright.dev/docs/best-practices) \ No newline at end of file diff --git a/tests/config/test-env.ts b/tests/config/test-env.ts new file mode 100644 index 0000000..1769833 --- /dev/null +++ b/tests/config/test-env.ts @@ -0,0 +1,6 @@ +// Test environment configuration +export const TEST_CONFIG = { + // Test user credentials + TEST_USER_EMAIL: process.env.TEST_USER_EMAIL || "test@example.com", + TEST_USER_PASSWORD: process.env.TEST_USER_PASSWORD || "testpassword123", +}; diff --git a/tests/e2e/artists.spec.ts b/tests/e2e/artists.spec.ts new file mode 100644 index 0000000..da6c55a --- /dev/null +++ b/tests/e2e/artists.spec.ts @@ -0,0 +1,85 @@ +import { test, expect } from "@playwright/test"; +import { TestHelpers } from "../utils/test-helpers"; + +test.describe("Artists", () => { + let testHelpers: TestHelpers; + + test.beforeEach(async ({ page }) => { + testHelpers = new TestHelpers(page); + }); + + test("should display artists list", async ({ page }) => { + await testHelpers.navigateTo("/"); + + // Look for artists content + const artistsContent = page + .locator('[data-testid="artists-list"]') + .or(page.locator('[data-testid="artists-grid"]')); + + // If artists are loaded, they should be visible + if (await artistsContent.isVisible()) { + await expect(artistsContent).toBeVisible(); + } + }); + + test("should filter artists", async ({ page }) => { + await testHelpers.navigateTo("/"); + + // Look for filter controls + const filterInput = page + .getByPlaceholder(/search/i) + .or(page.getByLabel(/search/i)); + + if (await filterInput.isVisible()) { + await filterInput.fill("test"); + await testHelpers.waitForPageLoad(); + + // Should show filtered results + const results = page + .locator('[data-testid="artist-item"]') + .or(page.locator(".artist-card")); + + if ((await results.count()) > 0) { + await expect(results.first()).toBeVisible(); + } + } + }); + + test("should navigate to artist detail page", async ({ page }) => { + await testHelpers.navigateTo("/"); + + // Look for artist cards/items + const artistCard = page + .getByTestId("artist-item").first(); + + if (await artistCard.isVisible()) { + + const artistName = await artistCard.getByRole("heading").textContent(); + const link = artistCard.getByRole("link"); + await link.click(); + + // Should navigate to artist detail page + await expect(page).toHaveURL(/\/artist\//); + + // Should show artist name in detail page + if (artistName) { + await expect(page.getByText(artistName.trim())).toBeVisible(); + } + } + }); + + test("should handle empty artists state", async ({ page }) => { + await testHelpers.navigateTo("/"); + + // Look for empty state message + const emptyState = page + .getByText(/no artists/i) + .or(page.getByText(/empty/i)) + .or(page.locator('[data-testid="empty-state"]')); + + // If no artists are loaded, should show empty state + if (await emptyState.isVisible()) { + await expect(emptyState).toBeVisible(); + } + }); +}); diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..b69f167 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,26 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Authentication', () => { + test('should show login dialog when clicking sign in', async ({ page }) => { + await page.goto('/'); + + // Look for a sign in button or link + const signInButton = page.getByRole('button', { name: /sign in/i }).or( + page.getByRole('link', { name: /sign in/i }) + ).or( + page.getByText(/sign in/i) + ).first();; + + await expect(signInButton).toBeVisible(); + await signInButton.click(); + + // Should show auth dialog + const authDialog = page.getByRole('dialog'); + await expect(authDialog).toBeVisible(); + }); + + test('should have proper page title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/UpLine/i); + }); +}); \ No newline at end of file diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts new file mode 100644 index 0000000..ce6af8d --- /dev/null +++ b/tests/e2e/navigation.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Navigation", () => { + test("should navigate to different pages", async ({ page }) => { + await page.goto("/"); + + // Test navigation to different pages if they exist + const navigationLinks = [ + { name: /artists/i, path: "/" }, + { name: /schedule/i, path: "/schedule" }, + { name: /groups/i, path: "/groups" }, + ]; + + for (const link of navigationLinks) { + const navLink = page + .getByRole("link", { name: link.name }) + .or(page.getByRole("button", { name: link.name })); + + if (await navLink.isVisible()) { + await navLink.click(); + await expect(page).toHaveURL(new RegExp(link.path)); + } + } + }); + + test("should handle 404 page", async ({ page }) => { + await page.goto("/non-existent-page"); + + // Should show 404 or error page + const errorContent = page + .getByText(/not found/i) + .or(page.getByText(/404/i)) + .or(page.getByText(/page not found/i)) + .first(); + + await expect(errorContent).toBeVisible(); + }); +}); diff --git a/tests/utils/test-helpers.ts b/tests/utils/test-helpers.ts new file mode 100644 index 0000000..03d98e2 --- /dev/null +++ b/tests/utils/test-helpers.ts @@ -0,0 +1,107 @@ +import { Page, expect } from '@playwright/test'; +import { TEST_CONFIG } from '../config/test-env'; + +export class TestHelpers { + constructor(private page: Page) {} + + /** + * Sign in with test credentials + */ + async signIn(email = TEST_CONFIG.TEST_USER_EMAIL, password = TEST_CONFIG.TEST_USER_PASSWORD) { + await this.page.goto('/'); + + // Click sign in button + const signInButton = this.page.getByRole('button', { name: /sign in/i }).or( + this.page.getByRole('link', { name: /sign in/i }) + ).or( + this.page.getByText(/sign in/i) + ); + + await signInButton.click(); + + // Wait for auth dialog + const authDialog = this.page.getByRole('dialog'); + await expect(authDialog).toBeVisible(); + + // Fill in credentials + const emailInput = this.page.getByLabel(/email/i).or(this.page.getByPlaceholder(/email/i)); + const passwordInput = this.page.getByLabel(/password/i).or(this.page.getByPlaceholder(/password/i)); + + await emailInput.fill(email); + await passwordInput.fill(password); + + // Submit form + const submitButton = this.page.getByRole('button', { name: /sign in/i }).or( + this.page.getByRole('button', { name: /login/i }) + ); + + await submitButton.click(); + + // Wait for successful sign in (user menu or avatar should appear) + await expect(this.page.getByRole('button', { name: /user/i }).or( + this.page.locator('[data-testid="user-avatar"]') + )).toBeVisible({ timeout: 10000 }); + } + + /** + * Sign out + */ + async signOut() { + const userMenu = this.page.getByRole('button', { name: /user/i }).or( + this.page.locator('[data-testid="user-avatar"]') + ); + + if (await userMenu.isVisible()) { + await userMenu.click(); + + const signOutButton = this.page.getByRole('button', { name: /sign out/i }).or( + this.page.getByText(/sign out/i) + ); + + await signOutButton.click(); + + // Wait for sign out to complete + await expect(this.page.getByRole('button', { name: /sign in/i })).toBeVisible(); + } + } + + /** + * Wait for page to be fully loaded + */ + async waitForPageLoad() { + await this.page.waitForLoadState('networkidle'); + } + + /** + * Check if user is authenticated + */ + async isAuthenticated(): Promise { + const userMenu = this.page.getByRole('button', { name: /user/i }).or( + this.page.locator('[data-testid="user-avatar"]') + ); + + return await userMenu.isVisible(); + } + + /** + * Navigate to a specific page + */ + async navigateTo(path: string) { + await this.page.goto(path); + await this.waitForPageLoad(); + } + + /** + * Wait for a specific element to be visible + */ + async waitForElement(selector: string, timeout = 5000) { + await this.page.waitForSelector(selector, { timeout }); + } + + /** + * Take a screenshot for debugging + */ + async takeScreenshot(name: string) { + await this.page.screenshot({ path: `tests/screenshots/${name}.png` }); + } +} \ No newline at end of file