From 2bc69c279073bb0eada96ee3f240eee5d044663d Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sat, 29 Aug 2026 23:23:01 +0100 Subject: [PATCH] feat(mobile): surface pending questions Add a conditional Home cue for partner-answered questions and route both the card and catch-up reminders into the pending answer flow. Track prompt engagement and cover hidden, visible, CTA, and notification behavior. --- apps/mobile/app/(app)/index.tsx | 58 ++++- .../components/PendingQuestionsCard.test.tsx | 65 ++++++ .../src/__tests__/lib/notifications.test.ts | 47 ++++ .../discovery/PendingQuestionsCard.tsx | 208 ++++++++++++++++++ apps/mobile/src/components/discovery/index.ts | 1 + apps/mobile/src/lib/analytics.ts | 4 + apps/mobile/src/lib/notifications.ts | 13 +- 7 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx create mode 100644 apps/mobile/src/__tests__/lib/notifications.test.ts create mode 100644 apps/mobile/src/components/discovery/PendingQuestionsCard.tsx diff --git a/apps/mobile/app/(app)/index.tsx b/apps/mobile/app/(app)/index.tsx index e28fcc6..cc7d527 100755 --- a/apps/mobile/app/(app)/index.tsx +++ b/apps/mobile/app/(app)/index.tsx @@ -1,15 +1,17 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native'; -import { useFocusEffect } from 'expo-router'; +import { useFocusEffect, useRouter } from 'expo-router'; import Animated, { FadeInDown } from 'react-native-reanimated'; import { Ionicons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; +import * as Haptics from 'expo-haptics'; +import { useAuthStore, useMatchStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { GradientBackground } from '../../src/components/ui'; import { Paywall } from '../../src/components/paywall'; -import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery'; +import { CompactHeader, ContentRow, DaresTile, LiveDrawTile, PendingQuestionsCard } from '../../src/components/discovery'; import { StreakDisplay } from '../../src/components/StreakDisplay'; -import { colors, spacing, typography, radius } from '../../src/theme'; +import { Events } from '../../src/lib/analytics'; +import { colors, spacing, typography } from '../../src/theme'; import type { QuestionPack, Category } from '../../src/types'; const MAX_CONTENT_WIDTH = 600; @@ -69,14 +71,21 @@ function groupPacksByCategory( export default function DiscoveryScreen() { const { user, partner, couple } = useAuthStore(); const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore(); + const { pendingQuestions, fetchPendingQuestions } = useMatchStore(); const { subscription } = useSubscriptionStore(); + const router = useRouter(); const { width } = useWindowDimensions(); const [showPaywall, setShowPaywall] = useState(false); const [headerHeight, setHeaderHeight] = useState(130); + const lastPromptImpression = useRef(null); // Check all sources of premium access: user flag, partner flag, or active subscription const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser; const isWideScreen = width > MAX_CONTENT_WIDTH; + const pendingQuestionIds = useMemo( + () => pendingQuestions.map(question => question.id).join(','), + [pendingQuestions], + ); // Load the catalog once. Returning to this tab shows what is cached; answering a // question invalidates it, and StreakDisplay owns the streak, so neither needs a @@ -84,7 +93,17 @@ export default function DiscoveryScreen() { useFocusEffect( useCallback(() => { void ensurePacksLoaded(); - }, [ensurePacksLoaded]) + void fetchPendingQuestions(); + + if (pendingQuestionIds && lastPromptImpression.current !== pendingQuestionIds) { + Events.pendingQuestionsPromptViewed(pendingQuestions.length); + lastPromptImpression.current = pendingQuestionIds; + } + + return () => { + lastPromptImpression.current = null; + }; + }, [ensurePacksLoaded, fetchPendingQuestions, pendingQuestionIds, pendingQuestions.length]) ); // Group packs by category @@ -96,6 +115,18 @@ export default function DiscoveryScreen() { // Check if user has no packs enabled (empty state) const hasNoPacks = packs.length === 0; + const handlePendingQuestionsPress = useCallback(() => { + const firstQuestion = pendingQuestions[0]; + if (!firstQuestion) return; + + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + Events.pendingQuestionsPromptTapped(pendingQuestions.length); + router.push({ + pathname: '/(app)/swipe', + params: { mode: 'pending', startQuestionId: firstQuestion.question.id }, + }); + }, [pendingQuestions, router]); + return ( + + {/* Shared streak - hidden entirely for couples with nothing running */} - + {/* Live Draw Activity Tile */} - + {/* Dares */} - + {/* Content Rows by Category */} {packsByCategory.map((group, index) => ( @@ -136,7 +174,7 @@ export default function DiscoveryScreen() { packs={group.packs} isPremiumUser={isPremiumUser} categoryId={group.category.id} - delay={200 + index * 100} + delay={250 + index * 100} getPackProgress={getPackProgress} onShowPaywall={() => setShowPaywall(true)} /> diff --git a/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx b/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx new file mode 100644 index 0000000..f8d8bce --- /dev/null +++ b/apps/mobile/src/__tests__/components/PendingQuestionsCard.test.tsx @@ -0,0 +1,65 @@ +import { fireEvent, render } from '@/test/test-utils'; +import { PendingQuestionsCard, formatWaitingSince } from '@/components/discovery/PendingQuestionsCard'; +import type { PendingQuestion } from '@/store'; + +const pendingQuestion = (id: string, partnerAnsweredAt: string): PendingQuestion => ({ + id: `response-${id}`, + partnerAnsweredAt, + question: { + id, + pack_id: 'pack-1', + text: `Question ${id}`, + intensity: 1, + created_at: '2026-08-01T00:00:00.000Z', + pack: { id: 'pack-1', name: 'Connection', icon: 'heart' }, + }, +}); + +describe('PendingQuestionsCard', () => { + it('stays out of Home when there is nothing to answer', () => { + const { queryByTestId } = render( + , + ); + + expect(queryByTestId('pending-questions-card')).toBeNull(); + }); + + it('names the partner, shows the count, and preserves answer privacy', () => { + const questions = [ + pendingQuestion('q1', '2026-08-29T09:00:00.000Z'), + pendingQuestion('q2', '2026-08-29T10:00:00.000Z'), + ]; + const { getByText, getByLabelText } = render( + , + ); + + expect(getByText('Sam has answered 2 questions')).toBeTruthy(); + expect(getByText('Answer yours to see where you match. Their answers stay private until you respond.')).toBeTruthy(); + expect(getByLabelText('2 questions waiting')).toBeTruthy(); + }); + + it('starts the answer action from its accessible CTA', () => { + const onPress = jest.fn(); + const { getByTestId } = render( + , + ); + + fireEvent.press(getByTestId('pending-questions-answer')); + expect(onPress).toHaveBeenCalledTimes(1); + }); +}); + +describe('formatWaitingSince', () => { + const now = new Date('2026-08-29T12:00:00.000Z'); + + it('formats useful compact waiting ages', () => { + expect(formatWaitingSince('2026-08-29T11:59:30.000Z', now)).toBe('just now'); + expect(formatWaitingSince('2026-08-29T11:42:00.000Z', now)).toBe('18m ago'); + expect(formatWaitingSince('2026-08-29T09:00:00.000Z', now)).toBe('3h ago'); + expect(formatWaitingSince('2026-08-27T12:00:00.000Z', now)).toBe('2d ago'); + }); +}); diff --git a/apps/mobile/src/__tests__/lib/notifications.test.ts b/apps/mobile/src/__tests__/lib/notifications.test.ts new file mode 100644 index 0000000..fb235d0 --- /dev/null +++ b/apps/mobile/src/__tests__/lib/notifications.test.ts @@ -0,0 +1,47 @@ +import { router } from 'expo-router'; +import { handleNotificationResponse } from '@/lib/notifications'; +import { useMatchStore } from '@/store/matchStore'; + +jest.mock('expo-router', () => ({ + router: { push: jest.fn() }, +})); + +jest.mock('expo-notifications', () => ({ + setNotificationHandler: jest.fn(), + addNotificationResponseReceivedListener: jest.fn(), + getLastNotificationResponseAsync: jest.fn(), + getPermissionsAsync: jest.fn(), + requestPermissionsAsync: jest.fn(), + getExpoPushTokenAsync: jest.fn(), +})); + +jest.mock('@/lib/crashlytics', () => ({ + captureError: jest.fn(), +})); + +const notificationResponse = (type: string) => ({ + notification: { + request: { + content: { data: { type } }, + }, + }, +}) as never; + +describe('handleNotificationResponse', () => { + beforeEach(() => { + useMatchStore.setState({ currentView: 'active' }); + }); + + it('opens the answer queue for a catch-up reminder', () => { + handleNotificationResponse(notificationResponse('catchup_reminder')); + + expect(useMatchStore.getState().currentView).toBe('pending'); + expect(router.push).toHaveBeenCalledWith('/(app)/swipe?mode=pending'); + }); + + it('ignores unknown notification types', () => { + handleNotificationResponse(notificationResponse('unknown')); + + expect(router.push).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx b/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx new file mode 100644 index 0000000..dda388b --- /dev/null +++ b/apps/mobile/src/components/discovery/PendingQuestionsCard.tsx @@ -0,0 +1,208 @@ +import { useMemo } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import type { PendingQuestion } from '../../store'; +import { colors, gradients, radius, shadows, spacing, typography } from '../../theme'; + +interface PendingQuestionsCardProps { + questions: PendingQuestion[]; + partnerName?: string | null; + onPress: () => void; + delay?: number; +} + +export function formatWaitingSince(dateString: string, now = new Date()): string { + const answeredAt = new Date(dateString); + const diffMs = Math.max(0, now.getTime() - answeredAt.getTime()); + const minutes = Math.floor(diffMs / 60_000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return answeredAt.toLocaleDateString(); +} + +export function PendingQuestionsCard({ + questions, + partnerName, + onPress, + delay = 0, +}: PendingQuestionsCardProps) { + const oldestQuestion = useMemo( + () => questions.reduce((oldest, question) => { + if (!oldest) return question; + return new Date(question.partnerAnsweredAt) < new Date(oldest.partnerAnsweredAt) + ? question + : oldest; + }, null), + [questions], + ); + + if (!oldestQuestion) return null; + + const count = questions.length; + const subject = partnerName?.trim() || 'Your partner'; + const questionLabel = count === 1 ? 'question' : 'questions'; + + return ( + + + + + + + + Your turn + + + {count} + + + + + {subject} has answered {count} {questionLabel} + + + Answer yours to see where you match. Their answers stay private until you respond. + + + + + Answer now + + + + + + + + Oldest waiting ยท {formatWaitingSince(oldestQuestion.partnerAnsweredAt)} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: spacing.lg, + marginTop: spacing.sm, + marginBottom: spacing.sm, + }, + card: { + position: 'relative', + overflow: 'hidden', + padding: spacing.md, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: 'rgba(232, 164, 174, 0.36)', + backgroundColor: colors.surface, + }, + accent: { + position: 'absolute', + top: 0, + right: 0, + width: 96, + height: 96, + borderBottomLeftRadius: 96, + backgroundColor: colors.primaryLight, + }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + labelGroup: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: radius.full, + backgroundColor: colors.primary, + shadowColor: colors.primary, + shadowOpacity: 0.55, + shadowRadius: 6, + shadowOffset: { width: 0, height: 0 }, + }, + label: { + ...typography.caption1, + color: colors.premium.rose, + fontWeight: '700', + letterSpacing: 1.2, + textTransform: 'uppercase', + }, + countBadge: { + minWidth: 32, + height: 26, + paddingHorizontal: spacing.sm, + borderRadius: radius.full, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.primary, + }, + countText: { + ...typography.caption1, + color: colors.text, + fontWeight: '800', + }, + title: { + ...typography.title3, + color: colors.text, + marginTop: spacing.md, + paddingRight: spacing.xl, + }, + description: { + ...typography.subhead, + color: colors.textSecondary, + marginTop: spacing.sm, + marginBottom: spacing.md, + }, + button: { + minHeight: 46, + borderRadius: radius.md, + paddingHorizontal: spacing.md, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.sm, + }, + buttonText: { + ...typography.callout, + color: colors.text, + fontWeight: '700', + }, + waitingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + }, + waitingText: { + ...typography.caption2, + color: colors.textTertiary, + }, +}); diff --git a/apps/mobile/src/components/discovery/index.ts b/apps/mobile/src/components/discovery/index.ts index e1c5f2e..f118af2 100644 --- a/apps/mobile/src/components/discovery/index.ts +++ b/apps/mobile/src/components/discovery/index.ts @@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader'; export { CircularProgress } from './CircularProgress'; export { LiveDrawTile } from './LiveDrawTile'; export { DaresTile } from './DaresTile'; +export { PendingQuestionsCard } from './PendingQuestionsCard'; diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index fd3f486..7406655 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -216,6 +216,10 @@ export const Events = { questionSkipped: () => logEvent("question_skipped"), matchCreated: (matchType: string) => logEvent("match_created", { match_type: matchType }), allQuestionsExhausted: () => logEvent("all_questions_exhausted"), + pendingQuestionsPromptViewed: (count: number) => + logEvent("pending_questions_prompt_viewed", { question_count: count }), + pendingQuestionsPromptTapped: (count: number) => + logEvent("pending_questions_prompt_tapped", { question_count: count }), // Chat events matchViewed: () => logEvent("match_viewed"), diff --git a/apps/mobile/src/lib/notifications.ts b/apps/mobile/src/lib/notifications.ts index 485c3b3..bbf6d21 100644 --- a/apps/mobile/src/lib/notifications.ts +++ b/apps/mobile/src/lib/notifications.ts @@ -23,7 +23,7 @@ Notifications.setNotificationHandler({ // Types export interface NotificationData { - type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary"; + type: "match" | "message" | "match_digest" | "partner_activity" | "nudge" | "weekly_summary" | "catchup_reminder"; match_id?: string; message_id?: string; count?: number; @@ -36,6 +36,7 @@ const notificationTypes = new Set([ "partner_activity", "nudge", "weekly_summary", + "catchup_reminder", ]); function isNotificationData(value: unknown): value is NotificationData { @@ -254,12 +255,18 @@ export function handleNotificationResponse( return; } - if (data.type === "partner_activity") { + if (data.type === "partner_activity") { // Navigate to Matches > Your Turn tab (questions to answer) useMatchStore.getState().setCurrentView('pending'); router.push("/(app)/matches"); return; - } + } + + if (data.type === "catchup_reminder") { + useMatchStore.getState().setCurrentView('pending'); + router.push("/(app)/swipe?mode=pending"); + return; + } if (!data.match_id) { return;