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
This commit is contained in:
501
backend/src/__tests__/dashboard.test.js
Normal file
501
backend/src/__tests__/dashboard.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -111,6 +111,7 @@ app.use('/api/', apiLimiter);
|
|||||||
// API routes
|
// API routes
|
||||||
app.use('/api/auth', require('./routes/auth'));
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
app.use('/api/users', require('./routes/users'));
|
app.use('/api/users', require('./routes/users'));
|
||||||
|
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||||
app.use('/api/events', require('./routes/events'));
|
app.use('/api/events', require('./routes/events'));
|
||||||
app.use('/api/wsdc', require('./routes/wsdc'));
|
app.use('/api/wsdc', require('./routes/wsdc'));
|
||||||
app.use('/api/divisions', require('./routes/divisions'));
|
app.use('/api/divisions', require('./routes/divisions'));
|
||||||
|
|||||||
340
backend/src/routes/dashboard.js
Normal file
340
backend/src/routes/dashboard.js
Normal file
@@ -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;
|
||||||
613
docs/DASHBOARD_PLAN.md
Normal file
613
docs/DASHBOARD_PLAN.md
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user