From 901b046a34468659ddfa7a435e428cbb022b6ee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Gierwia=C5=82o?= Date: Fri, 21 Nov 2025 21:00:50 +0100 Subject: [PATCH] feat(backend): implement dashboard API endpoint - Add GET /api/dashboard endpoint for authenticated users - Returns active events with user heats - Returns accepted matches with partner info - Detects video exchange status from message parsing - Tracks rating completion status (rated by me/partner) - Returns incoming/outgoing pending match requests - Add comprehensive test suite (12 tests, 93% coverage) - Add DASHBOARD_PLAN.md with full design documentation --- backend/src/__tests__/dashboard.test.js | 501 +++++++++++++++++++ backend/src/app.js | 1 + backend/src/routes/dashboard.js | 340 +++++++++++++ docs/DASHBOARD_PLAN.md | 613 ++++++++++++++++++++++++ 4 files changed, 1455 insertions(+) create mode 100644 backend/src/__tests__/dashboard.test.js create mode 100644 backend/src/routes/dashboard.js create mode 100644 docs/DASHBOARD_PLAN.md diff --git a/backend/src/__tests__/dashboard.test.js b/backend/src/__tests__/dashboard.test.js new file mode 100644 index 0000000..d6f3a43 --- /dev/null +++ b/backend/src/__tests__/dashboard.test.js @@ -0,0 +1,501 @@ +/** + * Dashboard API Tests + * Tests for GET /api/dashboard endpoint + * + * @group dashboard + */ + +const request = require('supertest'); +const app = require('../app'); +const { PrismaClient } = require('@prisma/client'); +const { generateToken } = require('../utils/auth'); + +const prisma = new PrismaClient(); + +describe('Dashboard API', () => { + let testUser; + let testUser2; + let testEvent; + let authToken; + let authToken2; + + beforeAll(async () => { + // Clean up test data + await prisma.rating.deleteMany({ + where: { + OR: [ + { rater: { username: { startsWith: 'dashboard_' } } }, + { rated: { username: { startsWith: 'dashboard_' } } } + ] + } + }); + await prisma.eventUserHeat.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.match.deleteMany({ + where: { + OR: [ + { user1: { username: { startsWith: 'dashboard_' } } }, + { user2: { username: { startsWith: 'dashboard_' } } } + ] + } + }); + await prisma.message.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.eventParticipant.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.event.deleteMany({ + where: { slug: { startsWith: 'dashboard-' } } + }); + await prisma.user.deleteMany({ + where: { username: { startsWith: 'dashboard_' } } + }); + + // Create test users + testUser = await prisma.user.create({ + data: { + username: 'dashboard_user1', + email: 'dashboard_user1@test.com', + passwordHash: 'hash123', + avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dashboard1', + }, + }); + + testUser2 = await prisma.user.create({ + data: { + username: 'dashboard_user2', + email: 'dashboard_user2@test.com', + passwordHash: 'hash456', + firstName: 'Sarah', + lastName: 'Martinez', + avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dashboard2', + }, + }); + + // Create test event + testEvent = await prisma.event.create({ + data: { + slug: 'dashboard-test-event', + name: 'Dashboard Test Event', + location: 'Test City', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + participantsCount: 2, + }, + }); + + // Create chat room for the event + await prisma.chatRoom.create({ + data: { + eventId: testEvent.id, + type: 'event', + }, + }); + + // Generate auth tokens + authToken = generateToken({ userId: testUser.id }); + authToken2 = generateToken({ userId: testUser2.id }); + }); + + afterAll(async () => { + // Clean up test data + await prisma.rating.deleteMany({ + where: { + OR: [ + { rater: { username: { startsWith: 'dashboard_' } } }, + { rated: { username: { startsWith: 'dashboard_' } } } + ] + } + }); + await prisma.eventUserHeat.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.match.deleteMany({ + where: { + OR: [ + { user1: { username: { startsWith: 'dashboard_' } } }, + { user2: { username: { startsWith: 'dashboard_' } } } + ] + } + }); + await prisma.message.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.eventParticipant.deleteMany({ + where: { user: { username: { startsWith: 'dashboard_' } } } + }); + await prisma.event.deleteMany({ + where: { slug: { startsWith: 'dashboard-' } } + }); + await prisma.user.deleteMany({ + where: { username: { startsWith: 'dashboard_' } } + }); + + await prisma.$disconnect(); + }); + + describe('GET /api/dashboard', () => { + it('should require authentication', async () => { + const res = await request(app) + .get('/api/dashboard'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('Unauthorized'); + }); + + it('should return empty dashboard for new user', async () => { + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + expect(res.body.data.activeEvents).toEqual([]); + expect(res.body.data.activeMatches).toEqual([]); + expect(res.body.data.matchRequests.incoming).toEqual([]); + expect(res.body.data.matchRequests.outgoing).toEqual([]); + }); + + it('should return active events when user is participant', async () => { + // Check in user to event + await prisma.eventParticipant.create({ + data: { + userId: testUser.id, + eventId: testEvent.id, + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.activeEvents).toHaveLength(1); + expect(res.body.data.activeEvents[0]).toMatchObject({ + id: testEvent.id, + slug: testEvent.slug, + name: testEvent.name, + location: testEvent.location, + participantsCount: expect.any(Number), + myHeats: [], + }); + }); + + it('should include user heats in active events', async () => { + // Get divisions and competition types + const division = await prisma.division.findFirst(); + const competitionType = await prisma.competitionType.findFirst(); + + // Create heat for user + await prisma.eventUserHeat.create({ + data: { + userId: testUser.id, + eventId: testEvent.id, + divisionId: division.id, + competitionTypeId: competitionType.id, + heatNumber: 1, + role: 'Leader', + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeEvents[0].myHeats).toHaveLength(1); + expect(res.body.data.activeEvents[0].myHeats[0]).toMatchObject({ + heatNumber: 1, + role: 'Leader', + competitionType: expect.objectContaining({ + name: expect.any(String), + abbreviation: expect.any(String), + }), + division: expect.objectContaining({ + name: expect.any(String), + abbreviation: expect.any(String), + }), + }); + }); + + it('should return active matches with partner info', async () => { + // Create match room + const matchRoom = await prisma.chatRoom.create({ + data: { + eventId: testEvent.id, + type: 'private', + }, + }); + + // Create accepted match + const match = await prisma.match.create({ + data: { + user1Id: testUser.id, + user2Id: testUser2.id, + eventId: testEvent.id, + roomId: matchRoom.id, + status: 'accepted', + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeMatches).toHaveLength(1); + expect(res.body.data.activeMatches[0]).toMatchObject({ + id: match.id, + slug: match.slug, + partner: { + id: testUser2.id, + username: testUser2.username, + firstName: testUser2.firstName, + lastName: testUser2.lastName, + avatar: testUser2.avatar, + }, + event: { + id: testEvent.id, + name: testEvent.name, + }, + status: 'accepted', + }); + }); + + it('should detect video exchange status from messages', async () => { + // Get the match + const match = await prisma.match.findFirst({ + where: { + user1Id: testUser.id, + user2Id: testUser2.id, + }, + include: { room: true }, + }); + + // User1 sent video to User2 + await prisma.message.create({ + data: { + roomId: match.roomId, + userId: testUser.id, + content: '📹 Video sent: dance.mp4 (50.00 MB)', + type: 'text', + }, + }); + + // User2 sent video to User1 + await prisma.message.create({ + data: { + roomId: match.roomId, + userId: testUser2.id, + content: '📹 Video sent: performance.mp4 (75.50 MB)', + type: 'text', + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeMatches[0].videoExchange).toMatchObject({ + sentByMe: true, + receivedFromPartner: true, + }); + }); + + it('should detect rating status', async () => { + const match = await prisma.match.findFirst({ + where: { + user1Id: testUser.id, + user2Id: testUser2.id, + }, + }); + + // User1 rated User2 + await prisma.rating.create({ + data: { + matchId: match.id, + raterId: testUser.id, + ratedId: testUser2.id, + score: 5, + wouldCollaborateAgain: true, + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeMatches[0].ratings).toMatchObject({ + ratedByMe: true, + ratedByPartner: false, + }); + }); + + it('should return incoming match requests', async () => { + // Create pending match where testUser is user2 (recipient) + const pendingMatch = await prisma.match.create({ + data: { + user1Id: testUser2.id, + user2Id: testUser.id, + eventId: testEvent.id, + status: 'pending', + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.matchRequests.incoming).toHaveLength(1); + expect(res.body.data.matchRequests.incoming[0]).toMatchObject({ + id: pendingMatch.id, + slug: pendingMatch.slug, + requester: { + id: testUser2.id, + username: testUser2.username, + firstName: testUser2.firstName, + lastName: testUser2.lastName, + }, + event: { + id: testEvent.id, + name: testEvent.name, + }, + }); + }); + + it('should return outgoing match requests', async () => { + // Create another user for outgoing request + const testUser3 = await prisma.user.create({ + data: { + username: 'dashboard_user3', + email: 'dashboard_user3@test.com', + passwordHash: 'hash789', + firstName: 'John', + lastName: 'Dancer', + }, + }); + + // Create pending match where testUser is user1 (requester) + const outgoingMatch = await prisma.match.create({ + data: { + user1Id: testUser.id, + user2Id: testUser3.id, + eventId: testEvent.id, + status: 'pending', + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.matchRequests.outgoing).toHaveLength(1); + expect(res.body.data.matchRequests.outgoing[0]).toMatchObject({ + id: outgoingMatch.id, + slug: outgoingMatch.slug, + recipient: { + id: testUser3.id, + username: testUser3.username, + firstName: testUser3.firstName, + lastName: testUser3.lastName, + }, + event: { + id: testEvent.id, + name: testEvent.name, + }, + }); + }); + + it('should sort events by start date (upcoming first)', async () => { + // Create another event with earlier start date + const earlierEvent = await prisma.event.create({ + data: { + slug: 'dashboard-earlier-event', + name: 'Earlier Event', + location: 'Test City 2', + startDate: new Date('2025-11-25'), + endDate: new Date('2025-11-27'), + }, + }); + + await prisma.chatRoom.create({ + data: { + eventId: earlierEvent.id, + type: 'event', + }, + }); + + // Check in user to both events + await prisma.eventParticipant.create({ + data: { + userId: testUser.id, + eventId: earlierEvent.id, + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeEvents).toHaveLength(2); + // Earlier event should be first + expect(res.body.data.activeEvents[0].slug).toBe('dashboard-earlier-event'); + expect(res.body.data.activeEvents[1].slug).toBe('dashboard-test-event'); + }); + + it('should handle user as user2 in match (partner detection)', async () => { + // Get testUser2's dashboard (they are user2 in the match) + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken2}`); + + expect(res.status).toBe(200); + const matches = res.body.data.activeMatches; + + if (matches.length > 0) { + // Partner should be testUser (user1) + expect(matches[0].partner.id).toBe(testUser.id); + expect(matches[0].partner.username).toBe(testUser.username); + } + }); + + it('should calculate lastMessageAt correctly', async () => { + const match = await prisma.match.findFirst({ + where: { + user1Id: testUser.id, + user2Id: testUser2.id, + status: 'accepted', + }, + include: { room: true }, + }); + + // Add a regular message + const now = new Date(); + await prisma.message.create({ + data: { + roomId: match.roomId, + userId: testUser.id, + content: 'Hey, ready to practice?', + type: 'text', + createdAt: now, + }, + }); + + const res = await request(app) + .get('/api/dashboard') + .set('Authorization', `Bearer ${authToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.activeMatches[0].lastMessageAt).toBeDefined(); + }); + }); +}); diff --git a/backend/src/app.js b/backend/src/app.js index 7f2d885..de85812 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -111,6 +111,7 @@ app.use('/api/', apiLimiter); // API routes app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); +app.use('/api/dashboard', require('./routes/dashboard')); app.use('/api/events', require('./routes/events')); app.use('/api/wsdc', require('./routes/wsdc')); app.use('/api/divisions', require('./routes/divisions')); diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..a65e92e --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,340 @@ +/** + * Dashboard Routes + * Aggregated data for user dashboard + */ + +const express = require('express'); +const router = express.Router(); +const { authenticate } = require('../middleware/auth'); +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +/** + * GET /api/dashboard + * Get dashboard data for authenticated user + * + * Returns: + * - activeEvents: Events user is checked in to + * - activeMatches: Accepted matches with video/rating status + * - matchRequests: Pending incoming/outgoing match requests + */ +router.get('/', authenticate, async (req, res, next) => { + try { + const userId = req.user.id; + + // 1. Get active events (user is participant) + const eventParticipants = await prisma.eventParticipant.findMany({ + where: { + userId: userId, + }, + include: { + event: true, + }, + orderBy: { + event: { + startDate: 'asc', // Upcoming events first + }, + }, + }); + + // Get user's heats for each event + const activeEvents = await Promise.all( + eventParticipants.map(async (ep) => { + const heats = await prisma.eventUserHeat.findMany({ + where: { + userId: userId, + eventId: ep.event.id, + }, + include: { + division: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + competitionType: { + select: { + id: true, + name: true, + abbreviation: true, + }, + }, + }, + }); + + return { + id: ep.event.id, + slug: ep.event.slug, + name: ep.event.name, + location: ep.event.location, + startDate: ep.event.startDate, + endDate: ep.event.endDate, + participantsCount: ep.event.participantsCount, + myHeats: heats.map((h) => ({ + id: h.id, + competitionType: h.competitionType, + division: h.division, + heatNumber: h.heatNumber, + role: h.role, + })), + }; + }) + ); + + // 2. Get active matches (accepted status) + const matches = await prisma.match.findMany({ + where: { + OR: [ + { user1Id: userId }, + { user2Id: userId }, + ], + status: 'accepted', + }, + include: { + user1: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + user2: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + event: { + select: { + id: true, + name: true, + slug: true, + }, + }, + room: { + include: { + messages: { + orderBy: { + createdAt: 'desc', + }, + take: 50, // Get recent messages to check for videos + }, + }, + }, + ratings: true, + }, + }); + + const activeMatches = matches.map((match) => { + // Determine partner (the other user) + const isUser1 = match.user1Id === userId; + const partner = isUser1 ? match.user2 : match.user1; + const partnerId = partner.id; + + // Check video exchange status from messages + const myVideoMessages = match.room?.messages.filter( + (m) => m.userId === userId && m.content.includes('📹 Video sent:') + ) || []; + const partnerVideoMessages = match.room?.messages.filter( + (m) => m.userId === partnerId && m.content.includes('📹 Video sent:') + ) || []; + + const videoExchange = { + sentByMe: myVideoMessages.length > 0, + receivedFromPartner: partnerVideoMessages.length > 0, + lastVideoTimestamp: myVideoMessages.length > 0 || partnerVideoMessages.length > 0 + ? new Date(Math.max( + ...[...myVideoMessages, ...partnerVideoMessages].map(m => m.createdAt.getTime()) + )) + : null, + }; + + // Check rating status + const myRating = match.ratings.find((r) => r.raterId === userId); + const partnerRating = match.ratings.find((r) => r.raterId === partnerId); + + const ratings = { + ratedByMe: !!myRating, + ratedByPartner: !!partnerRating, + }; + + // Get last message timestamp + const lastMessage = match.room?.messages[0]; + const lastMessageAt = lastMessage ? lastMessage.createdAt : match.createdAt; + + return { + id: match.id, + slug: match.slug, + partner: { + id: partner.id, + username: partner.username, + firstName: partner.firstName, + lastName: partner.lastName, + avatar: partner.avatar, + }, + event: { + id: match.event.id, + name: match.event.name, + slug: match.event.slug, + }, + videoExchange, + ratings, + lastMessageAt, + status: match.status, + }; + }); + + // Sort by lastMessageAt (most recent first) + activeMatches.sort((a, b) => new Date(b.lastMessageAt) - new Date(a.lastMessageAt)); + + // 3. Get match requests + // Incoming: user is user2, status is pending + const incomingRequests = await prisma.match.findMany({ + where: { + user2Id: userId, + status: 'pending', + }, + include: { + user1: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + event: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + // Get requester heats for incoming requests + const incoming = await Promise.all( + incomingRequests.map(async (req) => { + const requesterHeats = await prisma.eventUserHeat.findMany({ + where: { + userId: req.user1Id, + eventId: req.eventId, + }, + include: { + division: { + select: { + name: true, + abbreviation: true, + }, + }, + competitionType: { + select: { + name: true, + abbreviation: true, + }, + }, + }, + }); + + return { + id: req.id, + slug: req.slug, + requester: { + id: req.user1.id, + username: req.user1.username, + firstName: req.user1.firstName, + lastName: req.user1.lastName, + avatar: req.user1.avatar, + }, + event: { + id: req.event.id, + name: req.event.name, + slug: req.event.slug, + }, + requesterHeats: requesterHeats.map((h) => ({ + competitionType: h.competitionType, + division: h.division, + heatNumber: h.heatNumber, + role: h.role, + })), + createdAt: req.createdAt, + }; + }) + ); + + // Outgoing: user is user1, status is pending + const outgoingRequests = await prisma.match.findMany({ + where: { + user1Id: userId, + status: 'pending', + }, + include: { + user2: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatar: true, + }, + }, + event: { + select: { + id: true, + name: true, + slug: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + const outgoing = outgoingRequests.map((req) => ({ + id: req.id, + slug: req.slug, + recipient: { + id: req.user2.id, + username: req.user2.username, + firstName: req.user2.firstName, + lastName: req.user2.lastName, + avatar: req.user2.avatar, + }, + event: { + id: req.event.id, + name: req.event.name, + slug: req.event.slug, + }, + createdAt: req.createdAt, + })); + + res.json({ + success: true, + data: { + activeEvents, + activeMatches, + matchRequests: { + incoming, + outgoing, + }, + }, + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/docs/DASHBOARD_PLAN.md b/docs/DASHBOARD_PLAN.md new file mode 100644 index 0000000..c6c0ec1 --- /dev/null +++ b/docs/DASHBOARD_PLAN.md @@ -0,0 +1,613 @@ +# Dashboard Plan - spotlight.cam + +**Created:** 2025-11-21 +**Status:** Planning Phase +**Priority:** HIGH - Core UX improvement + +--- + +## 🎯 Overview + +Create a centralized dashboard for logged-in users to: +- View checked-in events with quick access to chats +- Manage active matches and conversations +- Track video exchange status +- Monitor rating completion +- See pending match requests + +**Route:** `/dashboard` (default landing page after login) + +--- + +## 📊 Dashboard Sections + +### 1. Active Events Section +**Purpose:** Show events user is currently participating in + +**Data Source:** `EventParticipant` joined with `Event` + +**Card Content:** +``` +┌─────────────────────────────────────────┐ +│ 🎉 Warsaw Dance Festival │ +│ 📍 Warsaw, Poland │ +│ 📅 Nov 23-25, 2025 │ +│ │ +│ Your heats: J&J NOV 1 L, STR INT 2 F │ +│ 👥 45 participants • 12 online │ +│ │ +│ [Enter Event Chat] ──────────────────► │ +└─────────────────────────────────────────┘ +``` + +**Features:** +- Event name, location, dates +- User's declared heats (from `EventUserHeat`) +- Participant count, online count (from Socket.IO active users) +- **Primary action:** "Enter Event Chat" → `/events/:slug/chat` +- Sort: Upcoming events first, then by start date + +**Empty State:** +``` +┌─────────────────────────────────────────┐ +│ 📅 No Active Events │ +│ │ +│ Check in at an event to start │ +│ connecting with other dancers! │ +│ │ +│ [Browse Events] ────────────────────► │ +└─────────────────────────────────────────┘ +``` + +--- + +### 2. Active Matches Section +**Purpose:** Show ongoing match conversations and their status + +**Data Source:** `Match` joined with `User`, `Event`, `Rating`, `Message` + +**Card Content:** +``` +┌─────────────────────────────────────────┐ +│ 👤 Sarah Martinez │ +│ @sarah_swings │ +│ 📍 Warsaw Dance Festival │ +│ │ +│ Video Exchange: │ +│ ✅ You sent • ✅ Received │ +│ │ +│ Ratings: │ +│ ✅ You rated • ⏳ Waiting for partner │ +│ │ +│ 💬 3 new messages │ +│ │ +│ [Open Chat] [Rate Partner] ──────────► │ +└─────────────────────────────────────────┘ +``` + +**Status Indicators:** + +**Video Exchange:** +- ✅ You sent video (check for message: "📹 Video sent:") +- ✅ You received video (check for message from partner) +- ⏳ No videos yet +- 🔗 Link shared (message type: 'link') + +**Ratings:** +- ✅ You rated (Rating exists with raterId = currentUser) +- ✅ Partner rated you (Rating exists with ratedId = currentUser) +- ⏳ Not rated yet +- ⚠️ Complete collaboration to rate + +**Actions:** +- **Primary:** "Open Chat" → `/matches/:slug` +- **Secondary:** "Rate Partner" → `/matches/:slug/rate` (only if video exchanged) + +**Match States:** +- **Pending** (status='pending'): Show "Waiting for acceptance" +- **Accepted** (status='accepted'): Active conversation +- **Completed** (status='completed'): Both rated, show summary + +**Sort Order:** +1. Unread messages (new messages first) +2. Pending ratings (video sent but not rated) +3. Recently active (latest message timestamp) + +**Empty State:** +``` +┌─────────────────────────────────────────┐ +│ 🤝 No Active Matches │ +│ │ +│ Join an event chat and send a match │ +│ request to start collaborating! │ +└─────────────────────────────────────────┘ +``` + +--- + +### 3. Match Requests Section +**Purpose:** Show pending incoming/outgoing match requests + +**Data Source:** `Match` where `status='pending'` + +**Incoming Request Card:** +``` +┌─────────────────────────────────────────┐ +│ 📨 Match Request from John Dancer │ +│ @john_dancer │ +│ 📍 Warsaw Dance Festival │ +│ Heats: J&J NOV 1 L, STR INT 2 F │ +│ │ +│ [Accept] [Decline] ──────────────────► │ +└─────────────────────────────────────────┘ +``` + +**Outgoing Request Card:** +``` +┌─────────────────────────────────────────┐ +│ 📤 Request sent to Sarah Martinez │ +│ @sarah_swings │ +│ 📍 Warsaw Dance Festival │ +│ │ +│ ⏳ Waiting for response... │ +│ │ +│ [Cancel Request] ────────────────────► │ +└─────────────────────────────────────────┘ +``` + +**Logic:** +- **Incoming:** `Match.user2Id = currentUser.id` AND `status='pending'` +- **Outgoing:** `Match.user1Id = currentUser.id` AND `status='pending'` + +**Empty State:** +``` +┌─────────────────────────────────────────┐ +│ 📭 No Pending Requests │ +└─────────────────────────────────────────┘ +``` + +--- + +### 4. Recent Activity Feed (Optional - Phase 2) +**Purpose:** Show recent notifications and updates + +**Content:** +- 🎉 New match accepted +- 💬 New message in match chat +- ⭐ Partner rated you +- 📹 Video received +- 📨 New match request + +--- + +## 🔧 Technical Implementation + +### Backend - New API Endpoint + +**Endpoint:** `GET /api/dashboard` + +**Response Structure:** +```json +{ + "success": true, + "data": { + "activeEvents": [ + { + "id": 420, + "slug": "another-dance-event", + "name": "Another Dance Event", + "location": "Warsaw, Poland", + "startDate": "2025-11-23", + "endDate": "2025-11-25", + "participantsCount": 45, + "onlineCount": 12, + "myHeats": [ + { + "id": 1, + "competitionType": { "name": "Jack & Jill", "abbreviation": "J&J" }, + "division": { "name": "Novice", "abbreviation": "NOV" }, + "heatNumber": 1, + "role": "Leader" + } + ] + } + ], + "activeMatches": [ + { + "id": 123, + "slug": "match-abc123", + "partner": { + "id": 456, + "username": "sarah_swings", + "firstName": "Sarah", + "lastName": "Martinez", + "avatar": "https://..." + }, + "event": { + "id": 420, + "name": "Warsaw Dance Festival" + }, + "videoExchange": { + "sentByMe": true, + "receivedFromPartner": true, + "lastVideoTimestamp": "2025-11-21T15:30:00Z" + }, + "ratings": { + "ratedByMe": true, + "ratedByPartner": false + }, + "unreadCount": 3, + "lastMessageAt": "2025-11-21T16:00:00Z", + "status": "accepted" + } + ], + "matchRequests": { + "incoming": [ + { + "id": 124, + "slug": "match-def456", + "requester": { + "id": 789, + "username": "john_dancer", + "firstName": "John", + "lastName": "Dancer", + "avatar": "https://..." + }, + "event": { + "id": 420, + "name": "Warsaw Dance Festival" + }, + "requesterHeats": [ /* heats array */ ], + "createdAt": "2025-11-21T14:00:00Z" + } + ], + "outgoing": [ + { + "id": 125, + "slug": "match-ghi789", + "recipient": { + "id": 101, + "username": "anna_swing", + "firstName": "Anna", + "lastName": "Swing", + "avatar": "https://..." + }, + "event": { + "id": 420, + "name": "Warsaw Dance Festival" + }, + "createdAt": "2025-11-21T13:00:00Z" + } + ] + } + } +} +``` + +**Implementation Notes:** + +1. **Video Exchange Detection:** + - Query `Message` table for match's chat room + - Check for messages containing "📹 Video sent:" or type='link' + - Track sender to determine who sent/received + +2. **Unread Message Count:** + - Option A: Add `lastReadAt` field to Match model (requires migration) + - Option B: Client-side tracking with localStorage + - Option C: Skip for MVP, add later + +3. **Online Count:** + - Socket.IO activeUsers map (real-time, not persistent) + - Return from backend when available + +--- + +### Frontend Components + +**New Components to Create:** + +1. **`DashboardPage.jsx`** - Main dashboard page +2. **`components/dashboard/`** + - `EventCard.jsx` - Active event card (reuse/extend existing EventCard) + - `MatchCard.jsx` - Active match card with status indicators + - `MatchRequestCard.jsx` - Incoming/outgoing request card + - `VideoExchangeStatus.jsx` - Visual indicator for video status + - `RatingStatus.jsx` - Visual indicator for rating status + - `EmptyState.jsx` - Generic empty state component + +**Component Hierarchy:** +``` +DashboardPage +├── Layout +│ ├── Header +│ └── Content +│ ├── Section: Active Events +│ │ ├── EventCard (multiple) +│ │ └── EmptyState (if no events) +│ ├── Section: Active Matches +│ │ ├── MatchCard (multiple) +│ │ │ ├── VideoExchangeStatus +│ │ │ └── RatingStatus +│ │ └── EmptyState (if no matches) +│ └── Section: Match Requests +│ ├── Subsection: Incoming +│ │ └── MatchRequestCard (multiple) +│ └── Subsection: Outgoing +│ └── MatchRequestCard (multiple) +``` + +--- + +## 📋 Implementation Roadmap + +### Phase 1: Backend Foundation (4-5 hours) + +**Step 1: Create Dashboard API Endpoint (2h)** +- [ ] Create `GET /api/dashboard` route +- [ ] Implement data aggregation logic: + - Query user's active events with heats + - Query accepted matches with partner info + - Detect video exchange from messages + - Check rating completion + - Query pending match requests +- [ ] Add unit tests +- [ ] Test with real data + +**Step 2: Enhance Match Model (Optional, 1h)** +- [ ] Consider adding fields to Match: + - `videosSent` (JSON: { user1Sent: bool, user2Sent: bool }) + - `lastReadBy` (JSON: { user1: timestamp, user2: timestamp }) +- [ ] Create migration if needed +- [ ] Update API to use new fields + +**Step 3: Socket.IO Enhancement (1h)** +- [ ] Add event: `dashboard_update` for real-time updates +- [ ] Emit when: + - New match request received + - Match accepted + - New message in match chat + - Partner rated + +### Phase 2: Frontend Implementation (6-8 hours) + +**Step 1: Create Dashboard Components (3h)** +- [ ] Create `pages/DashboardPage.jsx` +- [ ] Create `components/dashboard/EventCard.jsx` +- [ ] Create `components/dashboard/MatchCard.jsx` +- [ ] Create `components/dashboard/MatchRequestCard.jsx` +- [ ] Create `components/dashboard/VideoExchangeStatus.jsx` +- [ ] Create `components/dashboard/RatingStatus.jsx` +- [ ] Create `components/dashboard/EmptyState.jsx` + +**Step 2: API Integration (2h)** +- [ ] Add `dashboardAPI.getData()` to services/api.js +- [ ] Fetch dashboard data on mount +- [ ] Handle loading and error states + +**Step 3: Routing & Navigation (1h)** +- [ ] Add `/dashboard` route +- [ ] Redirect to `/dashboard` after login (instead of `/events`) +- [ ] Update navbar to highlight dashboard when active +- [ ] Add "Dashboard" link to main navigation + +**Step 4: Real-time Updates (1h)** +- [ ] Listen to Socket.IO `dashboard_update` event +- [ ] Update dashboard data when events occur +- [ ] Show toast notifications for important events + +**Step 5: Polish & Responsive Design (1h)** +- [ ] Mobile-responsive layout (stack cards vertically) +- [ ] Loading skeletons +- [ ] Smooth animations +- [ ] Empty states with helpful CTAs + +### Phase 3: Testing & Refinement (2h) +- [ ] Manual testing of all dashboard features +- [ ] Test edge cases (no events, no matches, all completed) +- [ ] Test real-time updates +- [ ] Fix bugs and polish UX + +--- + +## 🎨 Design Mockup + +``` +┌─────────────────────────────────────────────────────────────┐ +│ [Logo] Dashboard Events Matches Profile [👤] │ +└─────────────────────────────────────────────────────────────┘ + + 🏠 Dashboard + +┌─────────────────────────────────────────────────────────────┐ +│ 📅 Your Events [View All]│ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────┐ ┌──────────────────────┐ │ +│ │ 🎉 Warsaw Festival │ │ 🎉 Swing Camp BCN │ │ +│ │ Nov 23-25 │ │ Dec 10-12 │ │ +│ │ 45 participants │ │ 120 participants │ │ +│ │ [Enter Chat] ───────►│ │ [Enter Chat] ───────►│ │ +│ └──────────────────────┘ └──────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 💬 Active Matches [View All]│ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 👤 Sarah Martinez @sarah_swings │ │ +│ │ 📍 Warsaw Dance Festival │ │ +│ │ │ │ +│ │ Videos: ✅ Sent • ✅ Received │ │ +│ │ Ratings: ✅ You rated • ⏳ Waiting │ │ +│ │ │ │ +│ │ 💬 3 new messages │ │ +│ │ │ │ +│ │ [Open Chat] ──────────────────────────────────────► │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 👤 John Dancer @john_dancer │ │ +│ │ 📍 Swing Camp Barcelona │ │ +│ │ │ │ +│ │ Videos: ✅ Sent • ⏳ Waiting │ │ +│ │ Ratings: ⏳ Not ready to rate │ │ +│ │ │ │ +│ │ [Open Chat] [Send Video] ─────────────────────────► │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ 📨 Match Requests │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Incoming (2) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 👤 Anna Swing @anna_swing │ │ +│ │ 📍 Warsaw Dance Festival │ │ +│ │ Heats: J&J NOV 1 L │ │ +│ │ │ │ +│ │ [Accept] [Decline] ───────────────────────────────► │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ Outgoing (1) │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ 👤 Mike Lead @mike_lead │ │ +│ │ 📍 Swing Camp Barcelona │ │ +│ │ │ │ +│ │ ⏳ Waiting for response... [Cancel] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 📊 Success Metrics + +**User Engagement:** +- % of users who visit dashboard after login (target: 80%+) +- Average time spent on dashboard (target: 2-3 min) +- Click-through rate to event chats (target: 60%+) +- Click-through rate to match chats (target: 80%+) + +**Feature Adoption:** +- % of users who complete video exchange (track via dashboard) +- % of users who complete ratings (track via dashboard) +- Match acceptance rate from dashboard (target: 70%+) + +**Technical Performance:** +- Dashboard load time (target: < 500ms) +- API response time (target: < 200ms) +- Real-time update latency (target: < 100ms) + +--- + +## 🚧 Future Enhancements (Phase 4+) + +1. **Activity Feed** + - Timeline of all user activities + - Filterable by type (matches, messages, ratings) + +2. **Statistics Dashboard** + - Total matches + - Average rating received + - Events attended + - Videos exchanged + +3. **Calendar Integration** + - Show upcoming events on calendar + - Add to Google Calendar / iCal + +4. **Quick Actions** + - "Find matches in my next event" + - "Review past collaborations" + - "Update my competition heats" + +5. **Notifications Center** + - Centralized notification inbox + - Mark as read/unread + - Notification preferences + +6. **Mobile-First Enhancements** + - Swipe gestures for actions + - Bottom navigation bar + - Native app feel + +--- + +## ⚠️ Technical Considerations + +### Video Exchange Tracking + +**Current State:** +- Videos sent via WebRTC P2P (don't touch server) +- Only confirmation message saved: "📹 Video sent: {filename} ({size} MB)" +- No explicit tracking in database + +**Options:** + +**Option A: Parse messages (MVP approach)** +- ✅ No database changes +- ✅ Works with existing implementation +- ❌ Fragile (depends on message format) +- ❌ Can't distinguish between re-sends + +**Option B: Add tracking fields to Match model** +- ✅ Explicit, reliable tracking +- ✅ Can track timestamps, file info +- ❌ Requires migration +- ❌ Needs frontend changes to send tracking events + +**Recommendation:** Start with Option A (parse messages) for MVP, migrate to Option B in Phase 2. + +### Unread Message Count + +**Options:** + +**Option A: Client-side with localStorage** +- ✅ No backend changes +- ✅ Simple implementation +- ❌ Not synced across devices +- ❌ Lost on cache clear + +**Option B: Add lastReadAt to Match model** +- ✅ Synced across devices +- ✅ Persistent +- ❌ Requires migration +- ❌ More complex logic + +**Option C: Separate ReadReceipt table** +- ✅ Most flexible +- ✅ Can support group chats later +- ❌ Most complex +- ❌ Additional queries + +**Recommendation:** Start with Option A for MVP, migrate to Option B in Phase 2. + +--- + +## 📝 Notes + +- Dashboard should be the default landing page after login +- Current flow: Login → `/events` (should become → `/dashboard`) +- Keep existing navigation working (direct links to /events, /matches still accessible) +- Dashboard is read-only (no actions except navigation) +- Actions (accept match, send message) happen on dedicated pages +- Focus on information density without overwhelming the user +- Use consistent design language with existing pages + +--- + +**Total Estimated Effort:** 12-15 hours +**Priority:** HIGH (Core UX improvement) +**Dependencies:** None (works with existing backend) +**Risk:** LOW (additive feature, doesn't break existing functionality) + +--- + +**Created by:** Claude Code +**Date:** 2025-11-21 +**Version:** 1.0