diff --git a/src/app/sessions/loading.tsx b/src/app/sessions/loading.tsx new file mode 100644 index 0000000..34bde6e --- /dev/null +++ b/src/app/sessions/loading.tsx @@ -0,0 +1,32 @@ +export default function SessionsLoading() { + return ( +
+ {/* PageHeader skeleton */} +
+
+
+
+
+
+
+
+
+
+ +
+ {/* Tabs skeleton */} +
+ {[120, 100, 110, 90, 105].map((w, i) => ( +
+ ))} +
+ {/* Cards grid skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/src/app/users/loading.tsx b/src/app/users/loading.tsx new file mode 100644 index 0000000..b756477 --- /dev/null +++ b/src/app/users/loading.tsx @@ -0,0 +1,42 @@ +export default function UsersLoading() { + return ( +
+ {/* PageHeader skeleton */} +
+
+
+
+
+
+
+ + {/* Stats grid skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ + {/* User rows skeleton */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/collaboration/BaseSessionLiveWrapper.tsx b/src/components/collaboration/BaseSessionLiveWrapper.tsx index 8b5a52e..0a159e7 100644 --- a/src/components/collaboration/BaseSessionLiveWrapper.tsx +++ b/src/components/collaboration/BaseSessionLiveWrapper.tsx @@ -1,10 +1,12 @@ 'use client'; import { useState, useCallback } from 'react'; +import dynamic from 'next/dynamic'; import { useLive, type LiveEvent } from '@/hooks/useLive'; import { CollaborationToolbar } from './CollaborationToolbar'; -import { ShareModal } from './ShareModal'; import type { ShareRole } from '@prisma/client'; + +const ShareModal = dynamic(() => import('./ShareModal').then((m) => m.ShareModal), { ssr: false }); import type { TeamWithMembers, Share } from '@/lib/share-utils'; export type LiveApiPath = 'sessions' | 'motivators' | 'weather' | 'year-review' | 'weekly-checkin' | 'gif-mood'; diff --git a/src/hooks/useLive.ts b/src/hooks/useLive.ts index 6229f39..32e0edb 100644 --- a/src/hooks/useLive.ts +++ b/src/hooks/useLive.ts @@ -38,6 +38,7 @@ export function useLive({ const router = useRouter(); const eventSourceRef = useRef(null); const reconnectTimeoutRef = useRef(null); + const refreshTimeoutRef = useRef(null); const reconnectAttemptsRef = useRef(0); const onEventRef = useRef(onEvent); const currentUserIdRef = useRef(currentUserId); @@ -88,8 +89,9 @@ export function useLive({ setLastEvent(data); onEventRef.current?.(data); - // Refresh the page data when we receive an event from another user - router.refresh(); + // Debounce refresh to group simultaneous SSE events (~300ms window) + if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); + refreshTimeoutRef.current = setTimeout(() => router.refresh(), 300); } catch (e) { console.error('Failed to parse SSE event:', e); } @@ -126,6 +128,10 @@ export function useLive({ clearTimeout(reconnectTimeoutRef.current); reconnectTimeoutRef.current = null; } + if (refreshTimeoutRef.current) { + clearTimeout(refreshTimeoutRef.current); + refreshTimeoutRef.current = null; + } }; }, [sessionId, apiPath, enabled, router]); diff --git a/src/services/auth.ts b/src/services/auth.ts index b4aeeeb..5e1780b 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -74,6 +74,48 @@ export interface ResolvedCollaborator { } | null; } +// Batch resolve multiple collaborator strings — 2 DB queries max regardless of count +export async function batchResolveCollaborators( + collaborators: string[] +): Promise> { + if (collaborators.length === 0) return new Map(); + + const unique = [...new Set(collaborators.map((c) => c.trim()))]; + const emails = unique.filter(isEmail).map((e) => e.toLowerCase()); + const names = unique.filter((c) => !isEmail(c)); + + const [byEmail, byName] = await Promise.all([ + emails.length > 0 + ? prisma.user.findMany({ + where: { email: { in: emails } }, + select: { id: true, email: true, name: true }, + }) + : [], + names.length > 0 + ? prisma.user.findMany({ + where: { OR: names.map((n) => ({ name: { contains: n } })) }, + select: { id: true, email: true, name: true }, + }) + : [], + ]); + + const emailMap = new Map(byEmail.map((u) => [u.email.toLowerCase(), u])); + const nameMap = new Map( + byName.filter((u) => u.name).map((u) => [u.name!.toLowerCase(), u]) + ); + + const result = new Map(); + for (const c of unique) { + if (isEmail(c)) { + result.set(c, { raw: c, matchedUser: emailMap.get(c.toLowerCase()) ?? null }); + } else { + const match = nameMap.get(c.toLowerCase()) ?? null; + result.set(c, { raw: c, matchedUser: match }); + } + } + return result; +} + // Resolve collaborator string to user - try email first, then name export async function resolveCollaborator(collaborator: string): Promise { const trimmed = collaborator.trim(); diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts index dca4111..5d59961 100644 --- a/src/services/moving-motivators.ts +++ b/src/services/moving-motivators.ts @@ -1,5 +1,5 @@ import { prisma } from '@/services/database'; -import { resolveCollaborator } from '@/services/auth'; +import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; import { createShareAndEventHandlers } from '@/services/session-share-events'; @@ -21,7 +21,7 @@ const motivatorInclude = { // ============================================ export async function getMotivatorSessionsByUserId(userId: string) { - return mergeSessionsByUserId( + const sessions = await mergeSessionsByUserId( (uid) => prisma.movingMotivatorsSession.findMany({ where: { userId: uid }, @@ -33,14 +33,18 @@ export async function getMotivatorSessionsByUserId(userId: string) { where: { userId: uid }, include: { session: { include: motivatorInclude } }, }), - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - return fetchTeamCollaboratorSessions( + const sessions = await fetchTeamCollaboratorSessions( (teamMemberIds, uid) => prisma.movingMotivatorsSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, @@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } const motivatorByIdInclude = { diff --git a/src/services/session-share-events.ts b/src/services/session-share-events.ts index d45ae02..e1ea7b7 100644 --- a/src/services/session-share-events.ts +++ b/src/services/session-share-events.ts @@ -131,14 +131,21 @@ export function createShareAndEventHandlers( type: TEventType, payload: Record ): Promise { - return eventModel.create({ + const event = await eventModel.create({ data: { sessionId, userId, type, payload: JSON.stringify(payload), }, - }) as Promise; + }); + + // Fire-and-forget: purge old events without blocking the response + eventModel.deleteMany({ where: { createdAt: { lt: new Date(Date.now() - 24 * 60 * 60 * 1000) } } }).catch((err: unknown) => { + console.error('[cleanupOldEvents] Failed to purge old events:', err); + }); + + return event as SessionEventWithUser; }, async getEvents(sessionId: string, since?: Date): Promise { diff --git a/src/services/sessions.ts b/src/services/sessions.ts index cb2a50a..b0a0fac 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -1,5 +1,5 @@ import { prisma } from '@/services/database'; -import { resolveCollaborator } from '@/services/auth'; +import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; import { createShareAndEventHandlers } from '@/services/session-share-events'; @@ -21,7 +21,7 @@ const sessionInclude = { // ============================================ export async function getSessionsByUserId(userId: string) { - return mergeSessionsByUserId( + const sessions = await mergeSessionsByUserId( (uid) => prisma.session.findMany({ where: { userId: uid }, @@ -33,14 +33,18 @@ export async function getSessionsByUserId(userId: string) { where: { userId: uid }, include: { session: { include: sessionInclude } }, }), - userId, - (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator)); + return sessions.map((s) => ({ + ...s, + resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null }, + })); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - return fetchTeamCollaboratorSessions( + const sessions = await fetchTeamCollaboratorSessions( (teamMemberIds, uid) => prisma.session.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, @@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, - userId, - (s) => resolveCollaborator(s.collaborator).then((r) => ({ resolvedCollaborator: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.collaborator)); + return sessions.map((s) => ({ + ...s, + resolvedCollaborator: resolved.get(s.collaborator.trim()) ?? { raw: s.collaborator, matchedUser: null }, + })); } const sessionByIdInclude = { diff --git a/src/services/weekly-checkin.ts b/src/services/weekly-checkin.ts index 7b68e6e..b4f56e7 100644 --- a/src/services/weekly-checkin.ts +++ b/src/services/weekly-checkin.ts @@ -1,5 +1,5 @@ import { prisma } from '@/services/database'; -import { resolveCollaborator } from '@/services/auth'; +import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; import { createShareAndEventHandlers } from '@/services/session-share-events'; @@ -21,7 +21,7 @@ const weeklyCheckInInclude = { // ============================================ export async function getWeeklyCheckInSessionsByUserId(userId: string) { - return mergeSessionsByUserId( + const sessions = await mergeSessionsByUserId( (uid) => prisma.weeklyCheckInSession.findMany({ where: { userId: uid }, @@ -33,14 +33,18 @@ export async function getWeeklyCheckInSessionsByUserId(userId: string) { where: { userId: uid }, include: { session: { include: weeklyCheckInInclude } }, }), - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - return fetchTeamCollaboratorSessions( + const sessions = await fetchTeamCollaboratorSessions( (teamMemberIds, uid) => prisma.weeklyCheckInSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, @@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } const weeklyCheckInByIdInclude = { diff --git a/src/services/year-review.ts b/src/services/year-review.ts index 851c828..a5dd104 100644 --- a/src/services/year-review.ts +++ b/src/services/year-review.ts @@ -1,5 +1,5 @@ import { prisma } from '@/services/database'; -import { resolveCollaborator } from '@/services/auth'; +import { resolveCollaborator, batchResolveCollaborators } from '@/services/auth'; import { getTeamMemberIdsForAdminTeams } from '@/services/teams'; import { createSessionPermissionChecks } from '@/services/session-permissions'; import { createShareAndEventHandlers } from '@/services/session-share-events'; @@ -21,7 +21,7 @@ const yearReviewInclude = { // ============================================ export async function getYearReviewSessionsByUserId(userId: string) { - return mergeSessionsByUserId( + const sessions = await mergeSessionsByUserId( (uid) => prisma.yearReviewSession.findMany({ where: { userId: uid }, @@ -33,14 +33,18 @@ export async function getYearReviewSessionsByUserId(userId: string) { where: { userId: uid }, include: { session: { include: yearReviewInclude } }, }), - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } /** Sessions owned by team members (where user is team admin) that are NOT shared with the user. */ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { - return fetchTeamCollaboratorSessions( + const sessions = await fetchTeamCollaboratorSessions( (teamMemberIds, uid) => prisma.yearReviewSession.findMany({ where: { userId: { in: teamMemberIds }, shares: { none: { userId: uid } } }, @@ -48,9 +52,13 @@ export async function getTeamCollaboratorSessionsForAdmin(userId: string) { orderBy: { updatedAt: 'desc' }, }), getTeamMemberIdsForAdminTeams, - userId, - (s) => resolveCollaborator(s.participant).then((r) => ({ resolvedParticipant: r })) + userId ); + const resolved = await batchResolveCollaborators(sessions.map((s) => s.participant)); + return sessions.map((s) => ({ + ...s, + resolvedParticipant: resolved.get(s.participant.trim()) ?? { raw: s.participant, matchedUser: null }, + })); } const yearReviewByIdInclude = {