Implement infinite scroll for Matches and Answers

Add pagination support to stores and update list screens to load data in batches for improved performance with large datasets.
This commit is contained in:
jjbrunton committed 2026-01-03 22:34:41 +00:00
1 parent ae91602921
commit bb2ce5a6a6
8 files changed
+1853 -1395

No files matched your search

File diff suppressed because it is too large. Load diff
+518 -386
View File
@@ -1,386 +1,518 @@
import {
View,
Text,
StyleSheet,
SectionList,
RefreshControl,
TouchableOpacity,
ActivityIndicator,
Platform,
useWindowDimensions,
} from "react-native";
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
import type { ResponseWithQuestion } from "../../src/store";
import { useCallback, useState } from "react";
import { useFocusEffect, useRouter } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, spacing, typography, radius } from "../../src/theme";
import { ResponseCard } from "../../src/components/responses/ResponseCard";
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ROSE = colors.premium.rose;
const ROSE_RGBA = "rgba(232, 164, 174, ";
const MAX_CONTENT_WIDTH = 500;
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
// State for edit sheet
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Fetch responses on focus
useFocusEffect(
useCallback(() => {
fetchResponses();
}, [])
);
const sections = groupResponses(responses, groupBy);
const handleEditPress = (response: ResponseWithQuestion) => {
setSelectedResponse(response);
setIsEditSheetVisible(true);
};
const handleEditClose = () => {
setIsEditSheetVisible(false);
setSelectedResponse(null);
};
const handleEditSuccess = () => {
// Refetch responses to get updated data
fetchResponses();
};
const renderSectionHeader = ({ section }: { section: { title: string } }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
);
const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
<ResponseCard
response={item}
index={index}
onEditPress={() => handleEditPress(item)}
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
/>
);
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
<TouchableOpacity
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
onPress={() => setGroupBy(option)}
>
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
{label}
</Text>
</TouchableOpacity>
);
if (!user) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
{/* Ambient Orbs */}
<Animated.View
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
pointerEvents="none"
>
<LinearGradient
colors={[colors.premium.goldGlow, "transparent"]}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
pointerEvents="none"
>
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide]}
>
{/* Back button */}
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<View style={styles.headerContent}>
<Text style={styles.headerLabel}>REVIEW</Text>
<Text style={styles.headerTitle}>My Answers</Text>
<DecorativeSeparator variant="gold" />
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>
{responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
</Text>
</View>
</View>
{/* Group by buttons */}
<View style={styles.groupByContainer}>
<GroupByButton option="pack" label="Pack" />
<GroupByButton option="date" label="Date" />
<GroupByButton option="answer" label="Answer" />
</View>
</Animated.View>
{isLoading && responses.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : (
<SectionList
sections={sections}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={(item) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchResponses}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
ListEmptyComponent={
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
<View style={styles.emptyIcon}>
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
</View>
<Text style={styles.emptyTitle}>No Answers Yet</Text>
<Text style={styles.emptyDescription}>
Start swiping on questions to build your answer history. You can
come back here to review and change your answers anytime.
</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
style={{ marginTop: spacing.lg }}
>
Start Swiping
</GlassButton>
</Animated.View>
}
/>
)}
</View>
{/* Edit Response Sheet */}
<EditResponseSheet
visible={isEditSheetVisible}
response={selectedResponse}
onClose={handleEditClose}
onSuccess={handleEditSuccess}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
// Ambient orbs
ambientOrb: {
position: "absolute",
width: 300,
height: 300,
borderRadius: 150,
},
orbTopRight: {
top: 60,
right: -40,
},
orbBottomLeft: {
bottom: 180,
left: -40,
},
orbGradient: {
width: "100%",
height: "100%",
borderRadius: 150,
},
// Header
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerWide: {
alignSelf: "center",
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
backButton: {
position: "absolute",
top: 60,
left: spacing.md,
zIndex: 10,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
headerContent: {
alignItems: "center",
},
headerLabel: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 3,
color: ACCENT,
marginBottom: spacing.xs,
},
headerTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: "center",
},
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countTextPremium: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 2,
color: ACCENT,
},
// Group by buttons
groupByContainer: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.sm,
marginTop: spacing.md,
},
groupByButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
groupByButtonActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
borderColor: `${ACCENT_RGBA}0.3)`,
},
groupByText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: "500",
},
groupByTextActive: {
color: ACCENT,
fontWeight: "600",
},
// Section header
sectionHeader: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.xs,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
sectionTitle: {
...typography.title3,
color: colors.text,
},
// List
list: {
padding: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: Platform.OS === "ios" ? 120 : 100,
},
listWide: {
alignSelf: "center",
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
// Empty state
emptyContent: {
width: "100%",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.xxl,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
paddingHorizontal: spacing.md,
},
});
import {
View,
Text,
StyleSheet,
SectionList,
RefreshControl,
TouchableOpacity,
ActivityIndicator,
Platform,
useWindowDimensions,
} from "react-native";
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
import type { ResponseWithQuestion } from "../../src/store";
import { useCallback, useState, useEffect } from "react";
import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
FadeIn,
FadeInUp,
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, spacing, typography, radius } from "../../src/theme";
import { ResponseCard } from "../../src/components/responses/ResponseCard";
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ROSE = colors.premium.rose;
const ROSE_RGBA = "rgba(232, 164, 174, ";
const MAX_CONTENT_WIDTH = 500;
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60;
const HEADER_SCROLL_DISTANCE = 100;
const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
const { user } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const handleBack = () => {
const returnTo = (params.returnTo as string) || "/(app)/swipe";
router.push(returnTo as any);
};
// State for edit sheet
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
// Scroll animation
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const heroStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.7],
[1, 0],
Extrapolation.CLAMP
);
const scale = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[1, 0.95],
Extrapolation.CLAMP
);
return { opacity, transform: [{ scale }] };
});
const compactHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const navBarBackgroundStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Fetch responses on focus
useFocusEffect(
useCallback(() => {
fetchResponses(true);
}, [])
);
const handleLoadMore = useCallback(() => {
if (!isLoading && !isLoadingMore && hasMore) {
fetchResponses(false);
}
}, [isLoading, isLoadingMore, hasMore]);
const sections = groupResponses(responses, groupBy);
const handleEditPress = (response: ResponseWithQuestion) => {
setSelectedResponse(response);
setIsEditSheetVisible(true);
};
const handleEditClose = () => {
setIsEditSheetVisible(false);
setSelectedResponse(null);
};
const handleEditSuccess = () => {
// Refetch responses to get updated data
fetchResponses(true);
};
const renderSectionHeader = ({ section }: { section: any }) => (
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{section.title}</Text>
</View>
);
const renderItem = ({ item, index }: { item: any; index: number }) => (
<ResponseCard
response={item}
index={index}
onEditPress={() => handleEditPress(item)}
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
/>
);
const renderFooter = () => {
if (!isLoadingMore) return null;
return (
<View style={styles.footerLoader}>
<ActivityIndicator color={colors.primary} size="small" />
</View>
);
};
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
<TouchableOpacity
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
onPress={() => setGroupBy(option)}
>
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
{label}
</Text>
</TouchableOpacity>
);
if (!user) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
{/* Ambient Orbs */}
<Animated.View
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
pointerEvents="none"
>
<LinearGradient
colors={[colors.premium.goldGlow, "transparent"]}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
pointerEvents="none"
>
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<View style={styles.container}>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
My Answers
</Animated.Text>
</View>
{isLoading && responses.length === 0 ? (
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
) : (
<AnimatedSectionList
sections={sections}
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
keyExtractor={(item: any) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
showsVerticalScrollIndicator={false}
stickySectionHeadersEnabled={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={() => fetchResponses(true)}
tintColor={colors.primary}
colors={[colors.primary]}
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
/>
}
onEndReached={handleLoadMore}
onEndReachedThreshold={2}
ListFooterComponent={renderFooter}
ListHeaderComponent={
<Animated.View
entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide, heroStyle]}
>
<View style={styles.headerContent}>
<Text style={styles.headerLabel}>REVIEW</Text>
<Text style={styles.headerTitle}>My Answers</Text>
<DecorativeSeparator variant="gold" />
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>
{totalCount ?? responses.length} {(totalCount ?? responses.length) === 1 ? "ANSWER" : "ANSWERS"}
</Text>
</View>
</View>
{/* Group by buttons */}
<View style={styles.groupByContainer}>
<GroupByButton option="pack" label="Pack" />
<GroupByButton option="date" label="Date" />
<GroupByButton option="answer" label="Answer" />
</View>
</Animated.View>
}
ListEmptyComponent={
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
<View style={styles.emptyIcon}>
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
</View>
<Text style={styles.emptyTitle}>No Answers Yet</Text>
<Text style={styles.emptyDescription}>
Start swiping on questions to build your answer history. You can
come back here to review and change your answers anytime.
</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
style={{ marginTop: spacing.lg }}
>
Start Swiping
</GlassButton>
</Animated.View>
}
/>
)}
</View>
{/* Edit Response Sheet */}
<EditResponseSheet
visible={isEditSheetVisible}
response={selectedResponse}
onClose={handleEditClose}
onSuccess={handleEditSuccess}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
// Ambient orbs
ambientOrb: {
position: "absolute",
width: 300,
height: 300,
borderRadius: 150,
},
orbTopRight: {
top: 60,
right: -40,
},
orbBottomLeft: {
bottom: 180,
left: -40,
},
orbGradient: {
width: "100%",
height: "100%",
borderRadius: 150,
},
// Fixed Nav Bar
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
overflow: "hidden",
},
navBarBackButton: {
position: "absolute",
top: STATUS_BAR_HEIGHT - 5,
left: spacing.md,
zIndex: 10,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
navBarTitle: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
// Header (Hero)
header: {
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
},
headerWide: {
alignSelf: "center",
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
headerContent: {
alignItems: "center",
},
headerLabel: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 3,
color: ACCENT,
marginBottom: spacing.xs,
},
headerTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: "center",
},
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countTextPremium: {
...typography.caption2,
fontWeight: "600",
letterSpacing: 2,
color: ACCENT,
},
// Group by buttons
groupByContainer: {
flexDirection: "row",
justifyContent: "center",
gap: spacing.sm,
marginTop: spacing.md,
},
groupByButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
groupByButtonActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
borderColor: `${ACCENT_RGBA}0.3)`,
},
groupByText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: "500",
},
groupByTextActive: {
color: ACCENT,
fontWeight: "600",
},
// Section header
sectionHeader: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.xs,
marginTop: spacing.md,
marginBottom: spacing.xs,
},
sectionTitle: {
...typography.title3,
color: colors.text,
},
// List
list: {
padding: spacing.lg,
paddingTop: 0, // Header handles top spacing
paddingBottom: Platform.OS === "ios" ? 120 : 100,
},
listWide: {
alignSelf: "center",
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
},
// Empty state
emptyContent: {
width: "100%",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.xxl,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
paddingHorizontal: spacing.md,
},
footerLoader: {
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
},
});
+23 -28
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
@@ -186,15 +186,6 @@ export default function SwipeScreen() {
}
}, [enabledPackIds]);
// Fisher-Yates shuffle for stable randomization
const shuffleArray = <T,>(array: T[]): T[] => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const fetchQuestions = async () => {
// Race protection: increment request ID and track current request
@@ -231,26 +222,10 @@ export default function SwipeScreen() {
setGapInfo(null);
}
// First shuffle to randomize, then stable sort by priority scores
const shuffled = shuffleArray(filtered);
const sorted = shuffled.sort((a: any, b: any) => {
let scoreA = 0;
let scoreB = 0;
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
else if (a.partner_answered) scoreA += 0.7;
else if (a.is_two_part) scoreA += 0.4;
if (b.partner_answered && b.is_two_part) scoreB += 1.5;
else if (b.partner_answered) scoreB += 0.7;
else if (b.is_two_part) scoreB += 0.4;
return scoreB - scoreA;
});
// Server provides ordering (daily seeded random with priority)
// Only update state if still the current request
if (currentRequestId === fetchRequestId.current) {
setQuestions(sorted);
setQuestions(filtered);
}
} catch (error) {
if (currentRequestId === fetchRequestId.current) {
@@ -655,6 +630,14 @@ export default function SwipeScreen() {
style={styles.hintContainer}
>
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
<TouchableOpacity
onPress={() => router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
style={styles.editLinkButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Text style={styles.editLinkText}>Edit previous answers</Text>
</TouchableOpacity>
</Animated.View>
{/* Bottom spacing for tab bar */}
@@ -799,6 +782,18 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
letterSpacing: 0.5,
},
editLinkButton: {
marginTop: spacing.md,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
},
editLinkText: {
...typography.caption2,
color: colors.premium.gold,
textDecorationLine: 'underline',
opacity: 0.8,
letterSpacing: 0.5,
},
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},
@@ -10,6 +10,7 @@ function createThenableQuery(result: any) {
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
@@ -1,8 +1,16 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import Animated, {
FadeInDown,
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { GradientBackground, GlassCard } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
@@ -22,6 +30,11 @@ import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings
const MAX_CONTENT_WIDTH = 500;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60;
const HEADER_SCROLL_DISTANCE = 100;
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
export function ProfileScreen() {
const { width } = useWindowDimensions();
@@ -56,20 +69,81 @@ export function ProfileScreen() {
});
};
// Scroll animation
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const heroStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.7],
[1, 0],
Extrapolation.CLAMP
);
const scale = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[1, 0.95],
Extrapolation.CLAMP
);
return { opacity, transform: [{ scale }] };
});
const compactHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
const navBarBackgroundStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.8],
[0, 1],
Extrapolation.CLAMP
);
return { opacity };
});
return (
<GradientBackground>
<ScrollView
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
Settings
</Animated.Text>
</View>
<AnimatedScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
]}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
{/* Header title */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
style={[styles.header, heroStyle]}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
@@ -305,7 +379,7 @@ export function ProfileScreen() {
</Animated.View>
<View style={styles.bottomSpacer} />
</ScrollView>
</AnimatedScrollView>
{/* Modals */}
<FeedbackModal
@@ -324,7 +398,33 @@ const styles = StyleSheet.create({
container: {
flex: 1,
},
// Fixed Nav Bar
navBar: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile
overflow: "hidden",
},
navBarTitle: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
contentContainer: {
// Remove top padding here as the header adds it
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
contentContainerWide: {
@@ -333,7 +433,7 @@ const styles = StyleSheet.create({
maxWidth: MAX_CONTENT_WIDTH,
},
header: {
paddingTop: 60,
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
+215 -159
View File
@@ -1,159 +1,215 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Match } from "@/types";
import { useAuthStore } from "./authStore";
interface MatchState {
matches: Match[];
newMatchesCount: number;
isLoading: boolean;
error: string | null;
fetchMatches: () => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
updateMatchUnreadCount: (matchId: string, delta: number) => void;
clearMatches: () => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
isLoading: false,
error: null,
fetchMatches: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
set({ isLoading: true, error: null });
try {
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (matchError) throw matchError;
if (!matches) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
responses = data ?? [];
}
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = matches.map(match => ({
...match,
responses: responses.filter(r => r.question_id === match.question_id),
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount, isLoading: false });
} catch (err) {
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false });
}
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
);
const newCount = matches.filter((m) => m.is_new).length;
set({ matches, newMatchesCount: newCount });
},
markAllAsSeen: async () => {
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
);
set({ matches, newMatchesCount: 0 });
},
addMatch: (match) => {
set((state) => ({
matches: [match, ...state.matches],
newMatchesCount: state.newMatchesCount + 1,
}));
},
updateMatchUnreadCount: (matchId, delta) => {
set((state) => ({
matches: state.matches.map((m) =>
m.id === matchId
? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
: m
),
}));
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
},
}));
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Match } from "@/types";
import { useAuthStore } from "./authStore";
const BATCH_SIZE = 20;
interface MatchState {
matches: Match[];
newMatchesCount: number;
totalCount: number | null;
isLoading: boolean;
isLoadingMore: boolean;
page: number;
hasMore: boolean;
error: string | null;
fetchMatches: (refresh?: boolean) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
updateMatchUnreadCount: (matchId: string, delta: number) => void;
clearMatches: () => void;
}
export const useMatchStore = create<MatchState>((set, get) => ({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isLoadingMore: false,
page: 0,
hasMore: true,
error: null,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
return;
}
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (refresh) {
set({ isLoading: true, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
try {
const currentPage = refresh ? 0 : state.page;
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch total count on refresh
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
.from("matches")
.select("*", { count: "exact", head: true })
.eq("couple_id", coupleId);
if (!countError) {
totalCount = count ?? 0;
}
}
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false })
.range(from, to);
if (matchError) throw matchError;
if (!matches || matches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
} else {
set({ isLoadingMore: false, hasMore: false });
}
return;
}
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
responses = data ?? [];
}
// Fetch unread message counts per match
const matchIds = matches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = matches.map(match => ({
...match,
responses: responses.filter(r => r.question_id === match.question_id),
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
// Note: Since we fetch by created_at desc, and then sort by unread in memory,
// pagination might be slightly inconsistent if unread messages are scattered.
// However, implementing complex sort in Supabase with computed columns is harder.
// For now, we accept that the sort is applied within the fetched batch.
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
// Calculate new matches count - this is tricky with pagination
// Ideally we should have a separate query for count, but we'll just count in current batch
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
set((state) => ({
matches: refresh ? sortedData : [...state.matches, ...sortedData],
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: matches.length === BATCH_SIZE
}));
} catch (err) {
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
}
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
);
const newCount = matches.filter((m) => m.is_new).length;
set({ matches, newMatchesCount: newCount });
},
markAllAsSeen: async () => {
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
);
set({ matches, newMatchesCount: 0 });
},
addMatch: (match) => {
set((state) => ({
matches: [match, ...state.matches],
newMatchesCount: state.newMatchesCount + 1,
totalCount: (state.totalCount ?? 0) + 1,
}));
},
updateMatchUnreadCount: (matchId, delta) => {
set((state) => ({
matches: state.matches.map((m) =>
m.id === matchId
? { ...m, unreadCount: Math.max(0, (m.unreadCount || 0) + delta) }
: m
),
}));
},
clearMatches: () => {
set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
},
}));
+327 -278
View File
@@ -1,278 +1,327 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import type { Question, QuestionPack, AnswerType } from "@/types";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
// Response with joined question and pack data
export interface ResponseWithQuestion {
id: string;
question_id: string;
answer: AnswerType;
created_at: string;
question: Question & {
pack: Pick<QuestionPack, "id" | "name" | "icon">;
};
has_match: boolean;
match_id?: string;
partner_answered: boolean;
}
// Result from update-response edge function
export interface UpdateResponseResult {
success: boolean;
requires_confirmation?: boolean;
match_id?: string;
message_count?: number;
new_match?: Record<string, unknown> | null;
match_deleted?: boolean;
match_type_updated?: boolean;
error?: string;
}
export type GroupByOption = "pack" | "date" | "answer";
interface ResponsesState {
responses: ResponseWithQuestion[];
isLoading: boolean;
groupBy: GroupByOption;
fetchResponses: () => Promise<void>;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
confirmDelete?: boolean
) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void;
clearResponses: () => void;
}
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
groupBy: "pack",
fetchResponses: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!userId || !coupleId) return;
set({ isLoading: true });
try {
// Fetch all user's responses with questions and packs
const { data: responses, error: responsesError } = await supabase
.from("responses")
.select(`
id,
question_id,
answer,
created_at,
question:questions(
id,
text,
partner_text,
intensity,
pack_id,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.order("created_at", { ascending: false });
if (responsesError) {
console.error("Error fetching responses:", responsesError);
set({ isLoading: false });
return;
}
if (!responses) {
set({ responses: [], isLoading: false });
return;
}
// Get question IDs for match lookup
const questionIds = responses.map((r) => r.question_id);
// Fetch matches for these questions
const { data: matches } = await supabase
.from("matches")
.select("id, question_id")
.eq("couple_id", coupleId)
.in("question_id", questionIds);
// Create a map of question_id -> match_id
const matchMap = new Map<string, string>();
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
// Fetch partner's responses to check if they've answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("couple_id", coupleId)
.neq("user_id", userId)
.in("question_id", questionIds);
// Create a set of question IDs that partner has answered
const partnerAnsweredSet = new Set<string>(
partnerResponses?.map((r) => r.question_id) || []
);
// Transform responses with match and partner info
const transformedResponses: ResponseWithQuestion[] = responses
.filter((r) => {
// Supabase returns single relations as objects, but TypeScript may infer arrays
const question = r.question as any;
return question && question.pack;
})
.map((r) => {
// Cast to any to handle Supabase's nested select typing
const question = r.question as any;
const pack = question.pack as any;
return {
id: r.id,
question_id: r.question_id,
answer: r.answer as AnswerType,
created_at: r.created_at,
question: {
id: question.id,
text: question.text,
partner_text: question.partner_text,
intensity: question.intensity,
pack_id: question.pack_id,
created_at: question.created_at || "",
pack: {
id: pack.id,
name: pack.name,
icon: pack.icon,
},
},
has_match: matchMap.has(r.question_id),
match_id: matchMap.get(r.question_id),
partner_answered: partnerAnsweredSet.has(r.question_id),
};
});
set({ responses: transformedResponses, isLoading: false });
} catch (error) {
console.error("Error in fetchResponses:", error);
set({ isLoading: false });
}
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false
): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", {
body: {
question_id: questionId,
new_answer: newAnswer,
confirm_delete_match: confirmDelete,
},
});
if (error) {
console.error("Error updating response:", error);
return { success: false, error: error.message || "Failed to update response" };
}
const result = data as UpdateResponseResult;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
set((state) => ({
responses: state.responses.map((r) => {
if (r.question_id === questionId) {
return {
...r,
answer: newAnswer,
// Update match status based on result
has_match: result.match_deleted
? false
: result.new_match
? true
: r.has_match,
match_id: result.match_deleted
? undefined
: result.new_match
? (result.new_match.id as string)
: r.match_id,
};
}
return r;
}),
}));
// Refresh matches store if match was created, deleted, or type updated
if (result.match_deleted || result.new_match || result.match_type_updated) {
useMatchStore.getState().fetchMatches();
}
}
return result;
},
setGroupBy: (groupBy: GroupByOption) => {
set({ groupBy });
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "pack" });
},
}));
// Helper function to group responses
export function groupResponses(
responses: ResponseWithQuestion[],
groupBy: GroupByOption
): { title: string; data: ResponseWithQuestion[] }[] {
if (groupBy === "pack") {
// Group by pack name
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const packName = r.question.pack.name;
if (!groups.has(packName)) {
groups.set(packName, []);
}
groups.get(packName)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
if (groupBy === "answer") {
// Group by answer type
const yesResponses = responses.filter((r) => r.answer === "yes");
const maybeResponses = responses.filter((r) => r.answer === "maybe");
const noResponses = responses.filter((r) => r.answer === "no");
const sections = [];
if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
return sections;
}
if (groupBy === "date") {
// Group by date (day)
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const date = new Date(r.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
if (!groups.has(date)) {
groups.set(date, []);
}
groups.get(date)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
return [{ title: "All Responses", data: responses }];
}
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import type { Question, QuestionPack, AnswerType } from "@/types";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
// Response with joined question and pack data
export interface ResponseWithQuestion {
id: string;
question_id: string;
answer: AnswerType;
created_at: string;
question: Question & {
pack: Pick<QuestionPack, "id" | "name" | "icon">;
};
has_match: boolean;
match_id?: string;
partner_answered: boolean;
}
// Result from update-response edge function
export interface UpdateResponseResult {
success: boolean;
requires_confirmation?: boolean;
match_id?: string;
message_count?: number;
new_match?: Record<string, unknown> | null;
match_deleted?: boolean;
match_type_updated?: boolean;
error?: string;
}
export type GroupByOption = "pack" | "date" | "answer";
const BATCH_SIZE = 20;
interface ResponsesState {
responses: ResponseWithQuestion[];
isLoading: boolean;
isLoadingMore: boolean;
groupBy: GroupByOption;
hasMore: boolean;
page: number;
totalCount: number | null;
fetchResponses: (refresh?: boolean) => Promise<void>;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
confirmDelete?: boolean
) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void;
clearResponses: () => void;
}
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
isLoadingMore: false,
groupBy: "pack",
hasMore: true,
page: 0,
totalCount: null,
fetchResponses: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!userId || !coupleId) return;
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (refresh) {
set({ isLoading: true, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
try {
const currentPage = refresh ? 0 : state.page;
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch total count on refresh
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
.from("responses")
.select("*", { count: "exact", head: true })
.eq("user_id", userId);
if (!countError) {
totalCount = count ?? 0;
}
}
// Fetch all user's responses with questions and packs
const { data: responses, error: responsesError } = await supabase
.from("responses")
.select(`
id,
question_id,
answer,
created_at,
question:questions(
id,
text,
partner_text,
intensity,
pack_id,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.order("created_at", { ascending: false })
.range(from, to);
if (responsesError) {
console.error("Error fetching responses:", responsesError);
set({ isLoading: false, isLoadingMore: false });
return;
}
if (!responses || responses.length === 0) {
if (refresh) {
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
} else {
set({ isLoadingMore: false, hasMore: false });
}
return;
}
// Get question IDs for match lookup
const questionIds = responses.map((r) => r.question_id);
// Fetch matches for these questions
const { data: matches } = await supabase
.from("matches")
.select("id, question_id")
.eq("couple_id", coupleId)
.in("question_id", questionIds);
// Create a map of question_id -> match_id
const matchMap = new Map<string, string>();
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
// Fetch partner's responses to check if they've answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("couple_id", coupleId)
.neq("user_id", userId)
.in("question_id", questionIds);
// Create a set of question IDs that partner has answered
const partnerAnsweredSet = new Set<string>(
partnerResponses?.map((r) => r.question_id) || []
);
// Transform responses with match and partner info
const transformedResponses: ResponseWithQuestion[] = responses
.filter((r) => {
// Supabase returns single relations as objects, but TypeScript may infer arrays
const question = r.question as any;
return question && question.pack;
})
.map((r) => {
// Cast to any to handle Supabase's nested select typing
const question = r.question as any;
const pack = question.pack as any;
return {
id: r.id,
question_id: r.question_id,
answer: r.answer as AnswerType,
created_at: r.created_at,
question: {
id: question.id,
text: question.text,
partner_text: question.partner_text,
intensity: question.intensity,
pack_id: question.pack_id,
created_at: question.created_at || "",
pack: {
id: pack.id,
name: pack.name,
icon: pack.icon,
},
},
has_match: matchMap.has(r.question_id),
match_id: matchMap.get(r.question_id),
partner_answered: partnerAnsweredSet.has(r.question_id),
};
});
set((state) => ({
responses: refresh
? transformedResponses
: [...state.responses, ...transformedResponses],
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: responses.length === BATCH_SIZE
}));
} catch (error) {
console.error("Error in fetchResponses:", error);
set({ isLoading: false, isLoadingMore: false });
}
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false
): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", {
body: {
question_id: questionId,
new_answer: newAnswer,
confirm_delete_match: confirmDelete,
},
});
if (error) {
console.error("Error updating response:", error);
return { success: false, error: error.message || "Failed to update response" };
}
const result = data as UpdateResponseResult;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
set((state) => ({
responses: state.responses.map((r) => {
if (r.question_id === questionId) {
return {
...r,
answer: newAnswer,
// Update match status based on result
has_match: result.match_deleted
? false
: result.new_match
? true
: r.has_match,
match_id: result.match_deleted
? undefined
: result.new_match
? (result.new_match.id as string)
: r.match_id,
};
}
return r;
}),
}));
// Refresh matches store if match was created, deleted, or type updated
if (result.match_deleted || result.new_match || result.match_type_updated) {
useMatchStore.getState().fetchMatches();
}
}
return result;
},
setGroupBy: (groupBy: GroupByOption) => {
set({ groupBy });
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
},
}));
// Helper function to group responses
export function groupResponses(
responses: ResponseWithQuestion[],
groupBy: GroupByOption
): { title: string; data: ResponseWithQuestion[] }[] {
if (groupBy === "pack") {
// Group by pack name
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const packName = r.question.pack.name;
if (!groups.has(packName)) {
groups.set(packName, []);
}
groups.get(packName)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
if (groupBy === "answer") {
// Group by answer type
const yesResponses = responses.filter((r) => r.answer === "yes");
const maybeResponses = responses.filter((r) => r.answer === "maybe");
const noResponses = responses.filter((r) => r.answer === "no");
const sections = [];
if (yesResponses.length > 0) sections.push({ title: "Yes", data: yesResponses });
if (maybeResponses.length > 0) sections.push({ title: "Maybe", data: maybeResponses });
if (noResponses.length > 0) sections.push({ title: "No", data: noResponses });
return sections;
}
if (groupBy === "date") {
// Group by date (day)
const groups = new Map<string, ResponseWithQuestion[]>();
responses.forEach((r) => {
const date = new Date(r.created_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
if (!groups.has(date)) {
groups.set(date, []);
}
groups.get(date)!.push(r);
});
return Array.from(groups.entries()).map(([title, data]) => ({ title, data }));
}
return [{ title: "All Responses", data: responses }];
}
+1
View File
@@ -39,6 +39,7 @@ jest.mock('@supabase/supabase-js', () => {
is: jest.fn(),
in: jest.fn(),
order: jest.fn(),
range: jest.fn(),
update: jest.fn(),
upsert: jest.fn(),
insert: jest.fn(),