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.
This commit is contained in:
jjbrunton committed 2026-08-29 23:24:19 +01:00
1 parent 9da44217f7
commit 2bc69c2790
7 files changed
+383 -13

No files matched your search

@@ -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(
<PendingQuestionsCard questions={[]} partnerName="Sam" onPress={jest.fn()} />,
);
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(
<PendingQuestionsCard questions={questions} partnerName="Sam" onPress={jest.fn()} />,
);
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(
<PendingQuestionsCard
questions={[pendingQuestion('q1', '2026-08-29T09:00:00.000Z')]}
partnerName="Sam"
onPress={onPress}
/>,
);
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');
});
});
@@ -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();
});
});
@@ -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<PendingQuestion | null>((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 (
<Animated.View
entering={FadeInDown.delay(delay).duration(450).springify()}
style={styles.container}
testID="pending-questions-card"
>
<View style={[styles.card, shadows.md]}>
<View style={styles.accent} />
<View style={styles.labelRow}>
<View style={styles.labelGroup}>
<View style={styles.statusDot} />
<Text style={styles.label}>Your turn</Text>
</View>
<View style={styles.countBadge} accessibilityLabel={`${count} ${questionLabel} waiting`}>
<Text style={styles.countText}>{count}</Text>
</View>
</View>
<Text style={styles.title}>
{subject} has answered {count} {questionLabel}
</Text>
<Text style={styles.description}>
Answer yours to see where you match. Their answers stay private until you respond.
</Text>
<TouchableOpacity
onPress={onPress}
activeOpacity={0.85}
accessibilityRole="button"
accessibilityLabel={`Answer ${count} waiting ${questionLabel}`}
testID="pending-questions-answer"
>
<LinearGradient
colors={gradients.primary as [string, string, ...string[]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.button}
>
<Text style={styles.buttonText}>Answer now</Text>
<Ionicons name="arrow-forward" size={17} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
<View style={styles.waitingRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
<Text style={styles.waitingText}>
Oldest waiting · {formatWaitingSince(oldestQuestion.partnerAnsweredAt)}
</Text>
</View>
</View>
</Animated.View>
);
}
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,
},
});
@@ -4,3 +4,4 @@ export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
export { DaresTile } from './DaresTile';
export { PendingQuestionsCard } from './PendingQuestionsCard';
+4
View File
@@ -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"),
+10 -3
View File
@@ -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<NotificationData["type"]>([
"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;