diff --git a/src/app/motivators/page.tsx b/src/app/motivators/page.tsx
index db474fc..686de42 100644
--- a/src/app/motivators/page.tsx
+++ b/src/app/motivators/page.tsx
@@ -1,7 +1,7 @@
import Link from 'next/link';
import { auth } from '@/lib/auth';
import { getMotivatorSessionsByUserId } from '@/services/moving-motivators';
-import { Card, CardContent, Badge, Button } from '@/components/ui';
+import { Card, CardContent, Badge, Button, CollaboratorDisplay } from '@/components/ui';
export default async function MotivatorsPage() {
const session = await auth();
@@ -95,7 +95,9 @@ function SessionCard({ session: s }: { session: SessionWithMeta }) {
{s.title}
- {s.participant}
+
+
+
{!s.isOwner && (
Par {s.user.name || s.user.email}
diff --git a/src/app/sessions/WorkshopTabs.tsx b/src/app/sessions/WorkshopTabs.tsx
index 9f3bda6..e695340 100644
--- a/src/app/sessions/WorkshopTabs.tsx
+++ b/src/app/sessions/WorkshopTabs.tsx
@@ -2,7 +2,7 @@
import { useState, useTransition } from 'react';
import Link from 'next/link';
-import { Card, Badge, Button, Modal, ModalFooter, Input } from '@/components/ui';
+import { Card, Badge, Button, Modal, ModalFooter, Input, CollaboratorDisplay } from '@/components/ui';
import { deleteSwotSession, updateSwotSession } from '@/actions/session';
import { deleteMotivatorSession, updateMotivatorSession } from '@/actions/moving-motivators';
@@ -20,10 +20,20 @@ interface Share {
user: ShareUser;
}
+interface ResolvedCollaborator {
+ raw: string;
+ matchedUser: {
+ id: string;
+ email: string;
+ name: string | null;
+ } | null;
+}
+
interface SwotSession {
id: string;
title: string;
collaborator: string;
+ resolvedCollaborator: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -37,6 +47,7 @@ interface MotivatorSession {
id: string;
title: string;
participant: string;
+ resolvedParticipant: ResolvedCollaborator;
updatedAt: Date;
isOwner: boolean;
role: 'OWNER' | 'VIEWER' | 'EDITOR';
@@ -60,20 +71,45 @@ function getParticipant(session: AnySession): string {
: (session as MotivatorSession).participant;
}
-// Group sessions by participant
+// Helper to get resolved collaborator from any session
+function getResolvedCollaborator(session: AnySession): ResolvedCollaborator {
+ return session.workshopType === 'swot'
+ ? (session as SwotSession).resolvedCollaborator
+ : (session as MotivatorSession).resolvedParticipant;
+}
+
+// Get display name for grouping - prefer matched user name
+function getDisplayName(session: AnySession): string {
+ const resolved = getResolvedCollaborator(session);
+ if (resolved.matchedUser?.name) {
+ return resolved.matchedUser.name;
+ }
+ return resolved.raw;
+}
+
+// Get grouping key - use matched user ID if available, otherwise normalized raw string
+function getGroupKey(session: AnySession): string {
+ const resolved = getResolvedCollaborator(session);
+ // If we have a matched user, use their ID as key (ensures same person = same group)
+ if (resolved.matchedUser) {
+ return `user:${resolved.matchedUser.id}`;
+ }
+ // Otherwise, normalize the raw string
+ return `raw:${resolved.raw.trim().toLowerCase()}`;
+}
+
+// Group sessions by participant (using matched user ID when available)
function groupByPerson(sessions: AnySession[]): Map {
const grouped = new Map();
sessions.forEach((session) => {
- const participant = getParticipant(session).trim().toLowerCase();
- const displayName = getParticipant(session).trim();
+ const key = getGroupKey(session);
- // Use normalized key but store with original display name
- const existing = grouped.get(participant);
+ const existing = grouped.get(key);
if (existing) {
existing.push(session);
} else {
- grouped.set(participant, [session]);
+ grouped.set(key, [session]);
}
});
@@ -155,16 +191,13 @@ export function WorkshopTabs({ swotSessions, motivatorSessions }: WorkshopTabsPr
) : (
{sortedPersons.map(([personKey, sessions]) => {
- const displayName = getParticipant(sessions[0]);
+ const resolved = getResolvedCollaborator(sessions[0]);
return (
-
-
- {displayName.charAt(0).toUpperCase()}
-
- {displayName}
-
- {sessions.length}
+
+
+
+ {sessions.length} atelier{sessions.length > 1 ? 's' : ''}
@@ -336,12 +369,15 @@ function SessionCard({ session }: { session: AnySession }) {
{/* Participant + Owner info */}
-
- 👤 {participant}
+
+
{!session.isOwner && (
- · par {session.user.name || session.user.email}
+ · par {session.user.name || session.user.email}
)}
-
+
{/* Footer: Stats + Avatars + Date */}
diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx
index 98ac952..2f015ba 100644
--- a/src/app/sessions/[id]/page.tsx
+++ b/src/app/sessions/[id]/page.tsx
@@ -5,7 +5,7 @@ import { getSessionById } from '@/services/sessions';
import { SwotBoard } from '@/components/swot/SwotBoard';
import { SessionLiveWrapper } from '@/components/collaboration';
import { EditableTitle } from '@/components/session';
-import { Badge } from '@/components/ui';
+import { Badge, CollaboratorDisplay } from '@/components/ui';
interface SessionPageProps {
params: Promise<{ id: string }>;
@@ -49,9 +49,13 @@ export default async function SessionPage({ params }: SessionPageProps) {
initialTitle={session.title}
isOwner={session.isOwner}
/>
-
- 👤 {session.collaborator}
-
+
+
+
{session.items.length} items
diff --git a/src/components/ui/CollaboratorDisplay.tsx b/src/components/ui/CollaboratorDisplay.tsx
new file mode 100644
index 0000000..245e2cc
--- /dev/null
+++ b/src/components/ui/CollaboratorDisplay.tsx
@@ -0,0 +1,82 @@
+import { getGravatarUrl } from '@/lib/gravatar';
+
+interface CollaboratorDisplayProps {
+ collaborator: {
+ raw: string;
+ matchedUser: {
+ id: string;
+ email: string;
+ name: string | null;
+ } | null;
+ };
+ size?: 'sm' | 'md' | 'lg';
+ showEmail?: boolean;
+}
+
+const sizeConfig = {
+ sm: { avatar: 24, text: 'text-sm', gap: 'gap-1.5' },
+ md: { avatar: 32, text: 'text-base', gap: 'gap-2' },
+ lg: { avatar: 40, text: 'text-lg', gap: 'gap-3' },
+};
+
+export function CollaboratorDisplay({
+ collaborator,
+ size = 'md',
+ showEmail = false,
+}: CollaboratorDisplayProps) {
+ const { raw, matchedUser } = collaborator;
+ const config = sizeConfig[size];
+
+ // If we have a matched user, show their avatar and name
+ if (matchedUser) {
+ const displayName = matchedUser.name || matchedUser.email.split('@')[0];
+
+ return (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+
+ {displayName}
+
+ {showEmail && matchedUser.name && (
+ {matchedUser.email}
+ )}
+
+
+ );
+ }
+
+ // No match - just show the raw name with a default person icon
+ return (
+
+ );
+}
+
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index d30105a..bedced6 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -2,6 +2,7 @@ export { Avatar } from './Avatar';
export { Badge } from './Badge';
export { Button } from './Button';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
+export { CollaboratorDisplay } from './CollaboratorDisplay';
export { Input } from './Input';
export { Modal, ModalFooter } from './Modal';
export { Textarea } from './Textarea';
diff --git a/src/services/auth.ts b/src/services/auth.ts
index a22361c..2361fbd 100644
--- a/src/services/auth.ts
+++ b/src/services/auth.ts
@@ -60,6 +60,53 @@ export async function getUserByEmail(email: string) {
});
}
+// Check if string looks like an email
+function isEmail(str: string): boolean {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
+}
+
+export interface ResolvedCollaborator {
+ raw: string; // Original value (name or email)
+ matchedUser: {
+ id: string;
+ email: string;
+ name: string | null;
+ } | null;
+}
+
+// Resolve collaborator string to user - try email first, then name
+export async function resolveCollaborator(collaborator: string): Promise
{
+ const trimmed = collaborator.trim();
+
+ // 1. Try email match first
+ if (isEmail(trimmed)) {
+ const user = await prisma.user.findUnique({
+ where: { email: trimmed.toLowerCase() },
+ select: { id: true, email: true, name: true },
+ });
+
+ if (user) {
+ return { raw: collaborator, matchedUser: user };
+ }
+ }
+
+ // 2. Fallback: try matching by name (case-insensitive via raw query for SQLite)
+ // SQLite LIKE is case-insensitive by default for ASCII
+ const users = await prisma.user.findMany({
+ where: {
+ name: { not: null },
+ },
+ select: { id: true, email: true, name: true },
+ });
+
+ const normalizedSearch = trimmed.toLowerCase();
+ const userByName = users.find(
+ (u) => u.name?.toLowerCase() === normalizedSearch
+ ) || null;
+
+ return { raw: collaborator, matchedUser: userByName };
+}
+
export async function getUserById(id: string) {
return prisma.user.findUnique({
where: { id },
diff --git a/src/services/moving-motivators.ts b/src/services/moving-motivators.ts
index 362b979..6118d73 100644
--- a/src/services/moving-motivators.ts
+++ b/src/services/moving-motivators.ts
@@ -1,4 +1,5 @@
import { prisma } from '@/services/database';
+import { resolveCollaborator } from '@/services/auth';
import type { ShareRole, MotivatorType } from '@prisma/client';
// ============================================
@@ -56,9 +57,19 @@ export async function getMotivatorSessionsByUserId(userId: string) {
sharedAt: s.createdAt,
}));
- return [...ownedWithRole, ...sharedWithRole].sort(
+ const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
+
+ // Resolve participants to users
+ const sessionsWithResolved = await Promise.all(
+ allSessions.map(async (s) => ({
+ ...s,
+ resolvedParticipant: await resolveCollaborator(s.participant),
+ }))
+ );
+
+ return sessionsWithResolved;
}
export async function getMotivatorSessionById(sessionId: string, userId: string) {
@@ -92,7 +103,10 @@ export async function getMotivatorSessionById(sessionId: string, userId: string)
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
- return { ...session, isOwner, role, canEdit };
+ // Resolve participant to user if it's an email
+ const resolvedParticipant = await resolveCollaborator(session.participant);
+
+ return { ...session, isOwner, role, canEdit, resolvedParticipant };
}
// Check if user can access session (owner or shared)
diff --git a/src/services/sessions.ts b/src/services/sessions.ts
index b174bc4..466b7e5 100644
--- a/src/services/sessions.ts
+++ b/src/services/sessions.ts
@@ -1,4 +1,5 @@
import { prisma } from '@/services/database';
+import { resolveCollaborator } from '@/services/auth';
import type { SwotCategory, ShareRole } from '@prisma/client';
// ============================================
@@ -58,9 +59,19 @@ export async function getSessionsByUserId(userId: string) {
sharedAt: s.createdAt,
}));
- return [...ownedWithRole, ...sharedWithRole].sort(
+ const allSessions = [...ownedWithRole, ...sharedWithRole].sort(
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
+
+ // Resolve collaborators to users
+ const sessionsWithResolved = await Promise.all(
+ allSessions.map(async (s) => ({
+ ...s,
+ resolvedCollaborator: await resolveCollaborator(s.collaborator),
+ }))
+ );
+
+ return sessionsWithResolved;
}
export async function getSessionById(sessionId: string, userId: string) {
@@ -104,7 +115,10 @@ export async function getSessionById(sessionId: string, userId: string) {
const role = isOwner ? ('OWNER' as const) : share?.role || ('VIEWER' as const);
const canEdit = isOwner || role === 'EDITOR';
- return { ...session, isOwner, role, canEdit };
+ // Resolve collaborator to user if it's an email
+ const resolvedCollaborator = await resolveCollaborator(session.collaborator);
+
+ return { ...session, isOwner, role, canEdit, resolvedCollaborator };
}
// Check if user can access session (owner or shared)