From edb2cedda010ef2835c2f61a2fa5c3d6e96604f5 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 31 Jan 2026 22:52:21 +0000 Subject: [PATCH] feat(admin): add outstanding questions tab and gap status to user detail Shows questions the partner has answered that this user hasn't yet, with intensity levels and partner's answer. Adds outstanding count badge and gap-blocked indicator to the profile header. Co-Authored-By: Claude Opus 4.5 --- apps/admin/src/pages/users/UserDetailPage.tsx | 214 +++++++++++++++++- 1 file changed, 212 insertions(+), 2 deletions(-) diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index 7c29053..792f4fc 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -37,7 +37,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; @@ -130,6 +130,20 @@ interface FeatureInterest { created_at: string; } +interface OutstandingQuestion { + id: string; + text: string; + intensity: number | null; + pack: { + id: string; + name: string; + }; + partner_response: { + answer: 'yes' | 'no' | 'maybe'; + created_at: string | null; + }; +} + interface CoupleStreak { id: string; couple_id: string; @@ -277,6 +291,13 @@ export function UserDetailPage() { const [enabledPacks, setEnabledPacks] = useState([]); const [featureInterests, setFeatureInterests] = useState([]); const [streak, setStreak] = useState(null); + const [outstandingQuestions, setOutstandingQuestions] = useState([]); + const [outstandingPage, setOutstandingPage] = useState(1); + const [outstandingPageSize, setOutstandingPageSize] = useState(10); + const [outstandingTotal, setOutstandingTotal] = useState(0); + const [gapDisabled, setGapDisabled] = useState(false); + const [gapThreshold, setGapThreshold] = useState(0); + const [netGap, setNetGap] = useState(0); const [responsesPage, setResponsesPage] = useState(1); const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesTotal, setResponsesTotal] = useState(0); @@ -467,19 +488,124 @@ export function UserDetailPage() { } else { setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]); } + + // Fetch outstanding questions (partner answered, user hasn't) + // Use partner from state (set earlier in the first couple_id block) + const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data; + if (partnerProfile) { + const enabledPackIds = (enabledPacksData || []).map((ep: any) => ep.pack_id); + + if (enabledPackIds.length > 0) { + // Get question IDs user has already answered + const { data: userResponseIds } = await supabase + .from('responses') + .select('question_id') + .eq('user_id', userId); + const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id)); + + // Get all partner responses in this couple + const { data: partnerResponses } = await supabase + .from('responses') + .select(` + question_id, + answer, + created_at + `) + .eq('user_id', partnerProfile.id) + .eq('couple_id', profileData.couple_id) + .order('created_at', { ascending: false }); + + // Filter to only questions user hasn't answered + const unansweredPartnerResponses = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id)); + const unansweredQuestionIds = unansweredPartnerResponses.map((r: any) => r.question_id); + + if (unansweredQuestionIds.length > 0) { + // Fetch question details, filtered to enabled packs + const { data: questionDetails } = await supabase + .from('questions') + .select('id, text, intensity, pack:question_packs(id, name)') + .in('id', unansweredQuestionIds) + .in('pack_id', enabledPackIds); + + const questionMap = new Map((questionDetails || []).map((q: any) => [q.id, q])); + const partnerResponseMap = new Map(unansweredPartnerResponses.map((r: any) => [r.question_id, r])); + + const outstanding: OutstandingQuestion[] = []; + for (const qId of unansweredQuestionIds) { + const q = questionMap.get(qId); + const pr = partnerResponseMap.get(qId); + if (q && pr) { + outstanding.push({ + id: qId, + text: q.text || '', + intensity: q.intensity || null, + pack: { id: q.pack?.id || '', name: q.pack?.name || '' }, + partner_response: { answer: pr.answer, created_at: pr.created_at }, + }); + } + } + + setOutstandingTotal(outstanding.length); + const outFrom = (outstandingPage - 1) * outstandingPageSize; + setOutstandingQuestions(outstanding.slice(outFrom, outFrom + outstandingPageSize)); + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + } + + // Calculate gap status + const { data: appConfig } = await supabase + .from('app_config') + .select('answer_gap_threshold') + .limit(1) + .maybeSingle(); + + const threshold = appConfig?.answer_gap_threshold ?? 10; + setGapThreshold(threshold); + + if (threshold > 0) { + // Count questions user answered that partner hasn't + const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id)); + const { data: userResponses } = await supabase + .from('responses') + .select('question_id') + .eq('user_id', userId) + .eq('couple_id', profileData.couple_id); + const userAhead = (userResponses || []).filter((r: any) => !partnerAllAnswered.has(r.question_id)).length; + const partnerAhead = (partnerResponses || []).filter((r: any) => !answeredIds.has(r.question_id)).length; + const gap = Math.max(0, userAhead - partnerAhead); + setNetGap(gap); + setGapDisabled(gap >= threshold); + } else { + setNetGap(0); + setGapDisabled(false); + } + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); + } + } else { + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); + } } else { setMatches([]); setMatchesTotal(0); setMediaMessages([]); setMediaTotal(0); setEnabledPacks([]); + setOutstandingQuestions([]); + setOutstandingTotal(0); + setGapDisabled(false); } } catch (error) { console.error('Failed to load user data:', error); } finally { setLoading(false); } - }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]); + }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize, outstandingPage, outstandingPageSize]); const { status: profileStatus } = useRealtimeSubscription({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: responsesStatus } = useRealtimeSubscription({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); @@ -490,6 +616,7 @@ export function UserDetailPage() { setResponsesPage(1); setMatchesPage(1); setMediaPage(1); + setOutstandingPage(1); }, [userId]); useEffect(() => { @@ -526,6 +653,8 @@ export function UserDetailPage() {

{profile.name || 'Unnamed User'}

{profile.is_premium && Premium} + {outstandingTotal > 0 && {outstandingTotal} outstanding} + {gapDisabled && Gap blocked ({netGap}/{gapThreshold})} {!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && Gift Premium AccessManually upgrade this user to premium status.
{expiryType === 'custom' && (
setCustomDate(e.target.value)} />
)}
}
@@ -633,6 +762,7 @@ export function UserDetailPage() { {canViewResponses && Responses ({responsesTotal})} {canViewMatches && Matches ({matchesTotal})} {canViewMedia && Media ({mediaTotal})} + {canViewResponses && Outstanding ({outstandingTotal}){gapDisabled && }} Enabled Packs ({enabledPacks.length}) {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} @@ -644,6 +774,86 @@ export function UserDetailPage() { )}
{message.media_type === 'video' ? 'Video' : 'Image'}
{canViewChats && message.match_id && View Chat}
{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}
))} { setMediaPage(1); setMediaPageSize(size); }} />}} + {canViewResponses && + {gapDisabled && ( + + +
+ + Gap disabled + - This user is {netGap} questions ahead of their partner (threshold: {gapThreshold}). They won't see new questions until their partner catches up. +
+
+
+ )} + {outstandingQuestions.length === 0 ? ( + + +

No outstanding questions - all caught up!

+
+ ) : ( +
+
+ + + + Question + Pack + Intensity + Partner's Answer + Partner Answered + + + + {outstandingQuestions.map((q) => ( + + + {q.pack?.id ? ( + {q.text} + ) : ( + {q.text} + )} + + + {q.pack?.id ? ( + {q.pack.name} + ) : ( + — + )} + + + {q.intensity ? ( +
+ {Array.from({ length: q.intensity }, (_, i) => ( + + ))} +
+ ) : '—'} +
+ +
+ {answerIcons[q.partner_response.answer]} + {q.partner_response.answer} +
+
+ + {q.partner_response.created_at ? format(new Date(q.partner_response.created_at), 'MMM d, yyyy') : '—'} + +
+ ))} +
+
+
+ { setOutstandingPage(1); setOutstandingPageSize(size); }} + /> +
+ )} +
} {enabledPacks.length === 0 ? (