Improve mobile data retrieval and state management

- Add race protection and packId normalization to swipe screen
- Add loading/error states to matchStore with couple_id filter
- Sync per-match unread counts in realtime
- Use useFocusEffect for data freshness on Home screen
- Wire RefreshControl to actual loading state on Matches

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
jjbrunton committed 2026-01-03 20:10:58 +00:00
1 parent 994dd2a295
commit ae91602921
21 files changed
+2453 -237

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
# Mobile data retrieval review: task list
This task list targets the **Home**, **Swipe**, and **Matches** flows in `apps/mobile`, focusing on correctness, performance, and realtime consistency.
## Overview (current state)
- Data fetching is split between Zustand stores (`useMatchStore`, `usePacksStore`) and screen-local Supabase RPC calls (notably `SwipeScreen`).
- Realtime updates are centralized in `apps/mobile/app/(app)/_layout.tsx` (matches/messages/profile/pack-settings subscriptions).
- Main risks today: missing query filters & error/loading states, redundant fetches, state races on param changes, and unread-count drift between realtime and the matches list.
## Scope
- Screens
- Home: `apps/mobile/app/(app)/index.tsx`
- Swipe: `apps/mobile/app/(app)/swipe.tsx`
- Matches: `apps/mobile/app/(app)/matches.tsx`
- Stores
- Matches: `apps/mobile/src/store/matchStore.ts`
- Packs: `apps/mobile/src/store/packsStore.ts`
- Messages: `apps/mobile/src/store/messageStore.ts`
- Realtime orchestration
- `apps/mobile/app/(app)/_layout.tsx`
## Priority 0 — correctness (must fix)
- [ ] **Home: fix packs count source-of-truth**
- Current: `enabledPacksCount = packs.length` (counts available packs).
- Decide + implement:
- If label means *enabled*: use `enabledPackIds.length`.
- If label means *available*: rename the variable + label.
- Acceptance: packs number matches user expectations and store state.
- [ ] **Swipe: normalize `packId` and reset state on param changes**
- Normalize `packId` from `useLocalSearchParams()` (`string | string[]`) before sending to RPC.
- Reset `currentIndex`, `isLoading`, and any derived UI state when `packId` changes.
- Acceptance: switching packs never shows stale index/loading/UI.
- [ ] **Swipe: add race protection for overlapping async fetches**
- Use a request-id guard (or abort pattern) so older `fetchQuestions()` results cannot overwrite newer results.
- Acceptance: rapid navigation/focus changes never clobber newer state.
- [ ] **Swipe: prevent stale “blocked” state on answer-gap errors**
- Ensure `checkAnswerGap()` clears/updates `isBlocked` + `gapInfo` deterministically even when RPC errors.
- Acceptance: blocked UI never “sticks” due to transient failures.
## Priority 1 — UX and data freshness
- [ ] **Home: ensure data freshness on revisit**
- Add `useFocusEffect` or a simple staleness TTL in stores.
- Acceptance: counts (matches/new/packs) refresh when returning to Home.
- [ ] **Home: align “Recent Matches” semantics**
- `useMatchStore.fetchMatches()` sorts unread first; Home slices top 3.
- Decide:
- True recent by `created_at`, or
- “Top matches” by unread-first.
- Acceptance: section name and behavior match.
- [ ] **Matches screen: wire up pull-to-refresh correctly**
- Add `isLoading`/`isRefreshing` + `error` to `useMatchStore`.
- Use those flags for `RefreshControl.refreshing`.
- Acceptance: pull-to-refresh shows spinner and recovers from errors.
## Priority 2 — performance and query efficiency
- [ ] **MatchStore: add explicit filtering and guard empty `in(...)` lists**
- Filter matches by `couple_id` (in addition to RLS) for clarity/perf.
- If no matches, avoid calling `.in('question_id', [])`.
- Acceptance: fewer noisy requests; no empty-list edge behavior.
- [ ] **MatchStore: reduce unread-count work**
- Current approach fetches one row per unread message and counts on client.
- Options:
- Add RPC/view that returns per-match unread counts.
- Or store a denormalized `unread_count` per match (if acceptable).
- Acceptance: fetching matches scales with large message histories.
- [ ] **Swipe: remove heavy dependency on `fetchPacks()`**
- Swipe often only needs `enabledPackIds`.
- Add lightweight `ensureEnabledPacksLoaded()` or call `fetchEnabledPacks()` directly.
- Acceptance: less data fetched when entering Swipe.
- [ ] **Swipe: replace random comparator sort**
- Replace `Array.sort()` comparator using `Math.random()` with a stable score/shuffle pass.
- Acceptance: consistent ordering logic; avoids comparator contract violations.
## Priority 3 — realtime correctness and consistency
- [ ] **Realtime messages: tighten subscription filter**
- Current `messages` subscription listens without a `filter`.
- Improve by filtering to the user’s couple scope (schema permitting) or alternative channel strategy.
- Acceptance: avoids noise; reduces risk of unintended cross-couple events.
- [ ] **Sync per-match unread counts with realtime events**
- Currently global unread count updates live; per-match counts update only on `fetchMatches()`.
- Options:
- Update `useMatchStore` unreadCount per match when realtime message arrives.
- Or trigger a targeted refresh when new partner message arrives.
- Acceptance: Matches list ordering/badges update as messages arrive.
## Optional: structural improvements (bigger refactors)
- [ ] Consolidate fetching into a query layer (e.g., TanStack Query) with caching, invalidation, retries, and focus refetch.
- [ ] Create a single “matches list” RPC that returns:
- match + question
- who-answered-first info (for two-part display)
- per-match unread counts
- new match flag
- [ ] Add lightweight integration tests around:
- `packId` parsing
- request race guards
- unread count updates
## Suggested implementation order
1. P0 correctness (Home packs count, Swipe param/reset/races, gap-state)
2. Matches loading/refresh UX
3. Query efficiency (couple filters, unread-count optimization)
4. Realtime tightening + unread sync
5. Optional refactors
+25 -16
View File
@@ -50,21 +50,21 @@ function MessageToastPreview({
currentUserId,
});
if (isDecrypting) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Waiting for message...
</Text>
);
}
if (error) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Encrypted message
</Text>
);
}
if (isDecrypting) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Waiting for message...
</Text>
);
}
if (error) {
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
Encrypted message
</Text>
);
}
return (
<Text style={styles.messageToastBody} numberOfLines={1}>
@@ -175,7 +175,7 @@ export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
const { matches, newMatchesCount, addMatch } = useMatchStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
@@ -477,6 +477,9 @@ export default function AppLayout() {
delivered_at: newMessage.delivered_at || new Date().toISOString(),
match: { id: match.id, question: { text: questionText || "" } },
});
// Sync per-match unread count for realtime updates
updateMatchUnreadCount(match.id, 1);
}
}
)
@@ -697,6 +700,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="my-answers"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Radial Menu Overlay */}
+17 -14
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useCallback } from "react";
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
import Animated, {
FadeInDown,
@@ -13,7 +13,7 @@ import Animated, {
import { LinearGradient } from "expo-linear-gradient";
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { router, useFocusEffect } from "expo-router";
import { GradientBackground, GlassCard } from "../../src/components/ui";
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
@@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary;
export default function HomeScreen() {
const { user, partner, couple } = useAuthStore();
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
const { packs, fetchPacks } = usePacksStore();
const { enabledPackIds, fetchPacks } = usePacksStore();
const { width } = useWindowDimensions();
useEffect(() => {
fetchMatches();
fetchPacks();
}, []);
// Refresh data when screen gains focus for freshness
useFocusEffect(
useCallback(() => {
fetchMatches();
fetchPacks();
}, [])
);
const recentMatches = matches.slice(0, 3);
const topMatches = matches.slice(0, 3);
const isWideScreen = width > MAX_CONTENT_WIDTH;
const enabledPacksCount = packs.length;
const enabledPacksCount = enabledPackIds.length;
// Subtle pulsating animation for the CTA icon
const pulseScale = useSharedValue(1);
@@ -219,8 +222,8 @@ export default function HomeScreen() {
</TouchableOpacity>
</Animated.View>
{/* Recent Matches */}
{recentMatches.length > 0 && (
{/* Top Matches (sorted by unread first, then recent) */}
{topMatches.length > 0 && (
<Animated.View
entering={FadeInDown.delay(400).duration(500)}
style={styles.section}
@@ -230,7 +233,7 @@ export default function HomeScreen() {
<View style={styles.sectionIcon}>
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
</View>
<Text style={styles.sectionTitle}>Recent Matches</Text>
<Text style={styles.sectionTitle}>Top Matches</Text>
</View>
<TouchableOpacity
onPress={() => router.push("/(app)/matches")}
@@ -240,7 +243,7 @@ export default function HomeScreen() {
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
</TouchableOpacity>
</View>
{recentMatches.map((match, index) => (
{topMatches.map((match, index) => (
<Animated.View
key={match.id}
entering={FadeInRight.delay(450 + index * 100).duration(400)}
@@ -272,7 +275,7 @@ export default function HomeScreen() {
)}
{/* Tips when no matches */}
{recentMatches.length === 0 && (
{topMatches.length === 0 && (
<Animated.View
entering={FadeInDown.delay(400).duration(500)}
style={styles.section}
+24 -2
View File
@@ -26,7 +26,7 @@ const ROSE_RGBA = 'rgba(232, 164, 174, ';
const MAX_CONTENT_WIDTH = 500;
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
@@ -205,6 +205,14 @@ export default function MatchesScreen() {
entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide]}
>
{/* My Answers button */}
<TouchableOpacity
style={styles.myAnswersButton}
onPress={() => router.push("/(app)/my-answers")}
>
<Ionicons name="list-outline" size={20} color={ACCENT} />
</TouchableOpacity>
<View style={styles.headerContent}>
{/* Premium label */}
<Text style={styles.headerLabel}>DISCOVER</Text>
@@ -229,7 +237,7 @@ export default function MatchesScreen() {
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={false}
refreshing={isLoading}
onRefresh={fetchMatches}
tintColor={colors.primary}
colors={[colors.primary]}
@@ -323,6 +331,20 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
myAnswersButton: {
position: 'absolute',
top: 60,
right: spacing.md,
zIndex: 10,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
},
headerContent: {
alignItems: 'center',
},
+386
View File
@@ -0,0 +1,386 @@
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,
},
});
+113 -50
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
FadeIn,
@@ -20,25 +20,28 @@ import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuesti
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
// import SwipeCard from "../../src/components/SwipeCard";
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
import { SwipeTutorial } from "../../src/components/tutorials";
import { QuestionFeedbackModal } from "../../src/components/feedback";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
import { SwipeTutorial } from "../../src/components/tutorials";
import { QuestionFeedbackModal } from "../../src/components/feedback";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
export default function SwipeScreen() {
const { packId } = useLocalSearchParams();
const [questions, setQuestions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showTutorial, setShowTutorial] = useState(false);
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const { enabledPackIds, fetchPacks } = usePacksStore();
const params = useLocalSearchParams();
// Normalize packId - useLocalSearchParams can return string | string[]
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
const [questions, setQuestions] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showTutorial, setShowTutorial] = useState(false);
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
const { partner, couple } = useAuthStore();
const hasTrackedExhausted = useRef(false);
const fetchRequestId = useRef(0);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
@@ -61,12 +64,34 @@ export default function SwipeScreen() {
],
}));
const isFirstMount = useRef(true);
useEffect(() => {
fetchPacks().then(() => fetchQuestions());
// Reset state when pack changes
setCurrentIndex(0);
setIsLoading(true);
setIsBlocked(false);
setGapInfo(null);
hasTrackedExhausted.current = false;
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
ensureEnabledPacksLoaded().then(() => fetchQuestions());
checkTutorial();
hasTrackedExhausted.current = false; // Reset when pack changes
}, [packId]);
// Refresh questions when screen gains focus (handles partner answering while away)
useFocusEffect(
useCallback(() => {
// Skip on first mount since useEffect already fetches
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
// Silently refetch to get updated partner_answered status
fetchQuestions();
}, [packId])
);
// Check if user is too far ahead of partner (called when questions empty or after swipe)
const checkAnswerGap = async (): Promise<boolean> => {
if (!couple || !partner) {
@@ -80,6 +105,9 @@ export default function SwipeScreen() {
if (error) {
console.error('Error checking answer gap:', error);
// Clear blocked state on error so user isn't stuck
setIsBlocked(false);
setGapInfo(null);
return false;
}
@@ -97,10 +125,18 @@ export default function SwipeScreen() {
}
return result.is_blocked;
}
// No data returned - clear blocked state
setIsBlocked(false);
setGapInfo(null);
return false;
} catch (error) {
console.error('Failed to check answer gap:', error);
// Clear blocked state on error so user isn't stuck
setIsBlocked(false);
setGapInfo(null);
return false;
}
return false;
};
// Calculate effective total questions (accounting for gap limit)
@@ -150,7 +186,20 @@ 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
const currentRequestId = ++fetchRequestId.current;
try {
const [{ data, error }, skippedIds] = await Promise.all([
supabase.rpc("get_recommended_questions", {
@@ -159,6 +208,11 @@ export default function SwipeScreen() {
getSkippedQuestionIds()
]);
// Race guard: if a newer request started, discard this result
if (currentRequestId !== fetchRequestId.current) {
return;
}
if (error) {
console.error("Error fetching recommended questions:", error);
throw error;
@@ -177,9 +231,11 @@ export default function SwipeScreen() {
setGapInfo(null);
}
const sorted = filtered.sort((a: any, b: any) => {
let scoreA = Math.random();
let scoreB = Math.random();
// 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;
@@ -192,11 +248,18 @@ export default function SwipeScreen() {
return scoreB - scoreA;
});
setQuestions(sorted);
// Only update state if still the current request
if (currentRequestId === fetchRequestId.current) {
setQuestions(sorted);
}
} catch (error) {
console.error(error);
if (currentRequestId === fetchRequestId.current) {
console.error(error);
}
} finally {
setIsLoading(false);
if (currentRequestId === fetchRequestId.current) {
setIsLoading(false);
}
}
};
@@ -574,17 +637,17 @@ export default function SwipeScreen() {
<View style={[styles.stackCard, styles.stackCardSecond]} />
)}
{/* Active card */}
<SwipeCard
key={questions[currentIndex].id}
question={questions[currentIndex]}
onSwipe={handleSwipe}
onReport={() => setFeedbackQuestion({
id: questions[currentIndex].id,
text: questions[currentIndex].text
})}
/>
</View>
{/* Active card */}
<SwipeCard
key={questions[currentIndex].id}
question={questions[currentIndex]}
onSwipe={handleSwipe}
onReport={() => setFeedbackQuestion({
id: questions[currentIndex].id,
text: questions[currentIndex].text
})}
/>
</View>
{/* Hint - Premium styling */}
<Animated.View
@@ -598,21 +661,21 @@ export default function SwipeScreen() {
<View style={styles.bottomSpacer} />
</View>
{/* Tutorial Overlay */}
{showTutorial && (
<SwipeTutorial onComplete={handleTutorialComplete} />
)}
{/* Question Feedback Modal */}
<QuestionFeedbackModal
visible={!!feedbackQuestion}
onClose={() => setFeedbackQuestion(null)}
questionId={feedbackQuestion?.id || ''}
questionText={feedbackQuestion?.text || ''}
/>
</GradientBackground>
);
}
{/* Tutorial Overlay */}
{showTutorial && (
<SwipeTutorial onComplete={handleTutorialComplete} />
)}
{/* Question Feedback Modal */}
<QuestionFeedbackModal
visible={!!feedbackQuestion}
onClose={() => setFeedbackQuestion(null)}
questionId={feedbackQuestion?.id || ''}
questionText={feedbackQuestion?.text || ''}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
@@ -18,8 +18,8 @@ function createThenableQuery(result: any) {
describe('matchStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({ matches: [], newMatchesCount: 0 } as any);
useAuthStore.setState({ user: { id: 'me' } } as any);
useMatchStore.setState({ matches: [], newMatchesCount: 0, isLoading: false, error: null } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('sorts matches with unread messages first', async () => {
@@ -42,12 +42,14 @@ describe('messageStore', () => {
});
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
const updateQuery = createThenableQuery({});
const countQuery = createThenableQuery({ count: 0 });
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
(supabase.from as jest.Mock)
.mockReturnValueOnce(updateQuery)
.mockReturnValueOnce(countQuery);
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
.mockReturnValueOnce(updateQuery) // update messages
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -0,0 +1,204 @@
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, ZoomIn, ZoomOut } from "react-native-reanimated";
import { colors, spacing, typography, radius } from "../../theme";
interface DeleteMatchConfirmModalProps {
visible: boolean;
messageCount: number;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteMatchConfirmModal({
visible,
messageCount,
onConfirm,
onCancel,
}: DeleteMatchConfirmModalProps) {
const messageText =
messageCount === 0
? "This will delete your match."
: messageCount === 1
? "This will delete your match and 1 message."
: `This will delete your match and ${messageCount} messages.`;
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onCancel}
statusBarTranslucent
>
<Animated.View
style={styles.overlay}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
>
<TouchableWithoutFeedback onPress={onCancel}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.modalContainer}
entering={ZoomIn.springify().damping(20)}
exiting={ZoomOut.duration(150)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.modal}>
<ModalContent
messageText={messageText}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</BlurView>
) : (
<View style={[styles.modal, styles.modalAndroid]}>
<ModalContent
messageText={messageText}
onConfirm={onConfirm}
onCancel={onCancel}
/>
</View>
)}
</Animated.View>
</Animated.View>
</Modal>
);
}
function ModalContent({
messageText,
onConfirm,
onCancel,
}: {
messageText: string;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<>
{/* Warning icon */}
<View style={styles.iconContainer}>
<Ionicons name="warning" size={32} color={colors.error} />
</View>
{/* Title */}
<Text style={styles.title}>Delete Match?</Text>
{/* Message */}
<Text style={styles.message}>
Changing your answer to "No" will remove this match permanently.{"\n\n"}
{messageText}
</Text>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity style={styles.cancelButton} onPress={onCancel} activeOpacity={0.7}>
<Text style={styles.cancelButtonText}>Keep Match</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.deleteButton} onPress={onConfirm} activeOpacity={0.7}>
<LinearGradient
colors={[colors.error, "#c0392b"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.deleteButtonText}>Delete Match</Text>
</TouchableOpacity>
</View>
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
modalContainer: {
width: "100%",
maxWidth: 340,
},
modal: {
borderRadius: radius.xl,
overflow: "hidden",
padding: spacing.xl,
alignItems: "center",
},
modalAndroid: {
backgroundColor: colors.backgroundLight,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: "rgba(231, 76, 60, 0.15)",
borderWidth: 1,
borderColor: "rgba(231, 76, 60, 0.25)",
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
title: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
message: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
lineHeight: 22,
},
buttons: {
width: "100%",
gap: spacing.sm,
},
cancelButton: {
width: "100%",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
alignItems: "center",
},
cancelButtonText: {
...typography.callout,
color: colors.text,
fontWeight: "600",
},
deleteButton: {
width: "100%",
paddingVertical: spacing.md,
borderRadius: radius.md,
overflow: "hidden",
alignItems: "center",
justifyContent: "center",
},
deleteButtonText: {
...typography.callout,
color: colors.text,
fontWeight: "700",
},
});
@@ -0,0 +1,397 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
import { useResponsesStore } from "../../store";
import type { AnswerType } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
// Answer button config
const ANSWER_CONFIG = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
icon: "checkmark-circle" as const,
label: "Yes",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
icon: "close-circle" as const,
label: "No",
},
};
interface EditResponseSheetProps {
visible: boolean;
response: ResponseWithQuestion | null;
onClose: () => void;
onSuccess: () => void;
}
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
}
}, [visible]);
if (!response) return null;
const handleAnswerPress = async (newAnswer: AnswerType) => {
if (newAnswer === response.answer || isLoading) return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false);
if (result.requires_confirmation && result.match_id) {
// Show confirmation modal
setConfirmModalData({
matchId: result.match_id,
messageCount: result.message_count || 0,
});
setIsLoading(false);
} else if (result.success) {
// Success - close sheet
setIsLoading(false);
onSuccess();
onClose();
} else {
// Error
setIsLoading(false);
setPendingAnswer(null);
// Could show an error toast here
console.error("Failed to update response:", result.error);
}
};
const handleConfirmDelete = async () => {
if (!pendingAnswer) return;
setIsLoading(true);
setConfirmModalData(null);
const result = await updateResponse(response.question_id, pendingAnswer, true);
setIsLoading(false);
if (result.success) {
onSuccess();
onClose();
} else {
setPendingAnswer(null);
console.error("Failed to delete match:", result.error);
}
};
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isLoading}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
<Ionicons
name={config.icon}
size={28}
color={isSelected ? config.color : colors.textSecondary}
/>
)}
<Text
style={[
styles.answerButtonText,
isSelected && { color: config.color, fontWeight: "700" },
]}
>
{config.label}
</Text>
{isSelected && (
<View style={styles.selectedIndicator}>
<Ionicons name="checkmark" size={14} color={config.color} />
</View>
)}
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<Animated.View style={styles.overlay} entering={FadeIn.duration(200)} exiting={FadeOut.duration(200)}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.backdrop} />
</TouchableWithoutFeedback>
<Animated.View
style={styles.sheetContainer}
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</View>
)}
</Animated.View>
</Animated.View>
{/* Confirmation Modal */}
<DeleteMatchConfirmModal
visible={confirmModalData !== null}
messageCount={confirmModalData?.messageCount || 0}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</Modal>
);
}
// Sheet content extracted for reuse between iOS blur and Android solid background
function SheetContent({
response,
AnswerButton,
onClose,
}: {
response: ResponseWithQuestion;
AnswerButton: React.FC<{ answer: AnswerType }>;
onClose: () => void;
}) {
return (
<>
{/* Handle */}
<View style={styles.handle} />
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerTitle}>Edit Answer</Text>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
{/* Question text */}
<View style={styles.questionContainer}>
<Text style={styles.questionText}>{response.question.text}</Text>
<View style={styles.packBadge}>
<Ionicons name="layers-outline" size={12} color={colors.textTertiary} />
<Text style={styles.packText}>{response.question.pack.name}</Text>
</View>
</View>
{/* Warning for matched questions */}
{response.has_match && (
<View style={styles.warningBanner}>
<Ionicons name="warning" size={16} color={colors.warning} />
<Text style={styles.warningText}>
This question has a match. Changing to "No" will delete the match and
all messages.
</Text>
</View>
)}
{/* Answer buttons */}
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="maybe" />
<AnswerButton answer="no" />
</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: "flex-end",
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.6)",
},
sheetContainer: {
maxHeight: "80%",
},
sheet: {
borderTopLeftRadius: radius.xl,
borderTopRightRadius: radius.xl,
overflow: "hidden",
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
},
sheetAndroid: {
backgroundColor: colors.backgroundLight,
},
handle: {
width: 40,
height: 4,
backgroundColor: "rgba(255, 255, 255, 0.3)",
borderRadius: 2,
alignSelf: "center",
marginBottom: spacing.md,
},
header: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginBottom: spacing.lg,
},
headerTitle: {
...typography.title3,
color: colors.text,
},
closeButton: {
position: "absolute",
right: 0,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
questionContainer: {
marginBottom: spacing.lg,
},
questionText: {
...typography.body,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
packBadge: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
packText: {
...typography.caption1,
color: colors.textTertiary,
},
warningBanner: {
flexDirection: "row",
alignItems: "flex-start",
backgroundColor: "rgba(243, 156, 18, 0.1)",
borderWidth: 1,
borderColor: "rgba(243, 156, 18, 0.2)",
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.lg,
gap: spacing.sm,
},
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
marginBottom: spacing.lg,
},
answerButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.lg,
borderRadius: radius.lg,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
gap: spacing.xs,
},
answerButtonText: {
...typography.callout,
color: colors.textSecondary,
fontWeight: "600",
},
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -0,0 +1,252 @@
import { View, Text, StyleSheet, TouchableOpacity } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInRight } from "react-native-reanimated";
import type { ResponseWithQuestion } from "../../store";
import { colors, spacing, typography, radius } from "../../theme";
// 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, ";
// Answer colors (semantic)
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
label: "NO",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
label: "MAYBE",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
}
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
return (
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
<View style={styles.card}>
{/* Subtle gradient background */}
<LinearGradient
colors={["rgba(22, 33, 62, 0.6)", "rgba(13, 13, 26, 0.8)"]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.row}>
{/* Answer badge */}
<View
style={[
styles.answerBadge,
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
]}
>
<Ionicons
name={
response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help"
}
size={18}
color={answerStyle.color}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${answerStyle.rgba}0.1)`,
borderColor: `${answerStyle.rgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: answerStyle.color }]}>
{answerStyle.label}
</Text>
</View>
{/* Pack name */}
<View style={styles.packBadge}>
<Ionicons name="layers-outline" size={10} color={colors.textTertiary} />
<Text style={styles.packText} numberOfLines={1}>
{response.question.pack.name}
</Text>
</View>
{/* Partner status indicator */}
{response.partner_answered && (
<View style={styles.partnerBadge}>
<Ionicons name="person" size={10} color={colors.textTertiary} />
</View>
)}
</View>
</View>
{/* Right section */}
<View style={styles.rightSection}>
{/* Match indicator with chat link */}
{response.has_match && onChatPress && (
<TouchableOpacity style={styles.matchButton} onPress={onChatPress}>
<Ionicons name="heart" size={14} color={colors.primary} />
</TouchableOpacity>
)}
{/* Edit button */}
<TouchableOpacity style={styles.editButton} onPress={onEditPress}>
<Ionicons name="pencil" size={14} color={ACCENT} />
</TouchableOpacity>
</View>
</View>
{/* Premium border */}
<View style={styles.cardBorder} pointerEvents="none" />
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
card: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: "hidden",
padding: spacing.md,
},
cardSilkHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: 50,
},
cardBorder: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
},
row: {
flexDirection: "row",
alignItems: "center",
},
answerBadge: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
marginRight: spacing.md,
},
content: {
flex: 1,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
flexWrap: "wrap",
},
tag: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.sm,
borderWidth: 1,
},
tagText: {
...typography.caption2,
fontWeight: "700",
letterSpacing: 1,
},
packBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
maxWidth: 120,
},
packText: {
...typography.caption2,
color: colors.textTertiary,
},
partnerBadge: {
width: 18,
height: 18,
borderRadius: 9,
backgroundColor: "rgba(255, 255, 255, 0.08)",
justifyContent: "center",
alignItems: "center",
},
rightSection: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginLeft: spacing.sm,
},
matchButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: "rgba(233, 69, 96, 0.15)",
borderWidth: 1,
borderColor: "rgba(233, 69, 96, 0.25)",
justifyContent: "center",
alignItems: "center",
},
editButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: "center",
alignItems: "center",
},
});
@@ -0,0 +1,3 @@
export { ResponseCard } from "./ResponseCard";
export { EditResponseSheet } from "./EditResponseSheet";
export { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
@@ -52,6 +52,13 @@ const STEPS: TutorialStep[] = [
icon: "images",
gradient: gradients.premiumRose as [string, string],
},
{
key: "edit",
title: "Change Your Mind?",
description: "Go to My Answers to review and edit any of your responses. Changed your mind? No problem — update your answer anytime.",
icon: "create-outline",
gradient: gradients.premiumGold as [string, string],
},
];
export default function MatchesTutorial({ onComplete }: Props) {
@@ -216,6 +223,40 @@ export default function MatchesTutorial({ onComplete }: Props) {
</View>
</View>
)}
{/* Edit answers demo - show on fourth step */}
{currentStep === 3 && (
<View style={styles.editDemo}>
<View style={styles.answerCard}>
<View style={styles.answerCardContent}>
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={styles.answerCardBadge}>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.answerCardBadgeText}>YES</Text>
</View>
</View>
<View style={styles.answerCardEdit}>
<Ionicons name="create-outline" size={18} color={colors.premium.gold} />
</View>
</View>
<View style={styles.answerArrow}>
<Ionicons name="arrow-down" size={20} color={colors.textTertiary} />
</View>
<View style={[styles.answerCard, styles.answerCardUpdated]}>
<View style={styles.answerCardContent}>
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
</View>
</View>
</View>
</View>
)}
</Animated.View>
</View>
@@ -425,6 +466,68 @@ const styles = StyleSheet.create({
color: colors.success,
fontWeight: "600",
},
editDemo: {
marginTop: spacing.lg,
alignItems: "center",
gap: spacing.xs,
},
answerCard: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
width: 260,
},
answerCardUpdated: {
borderColor: colors.premium.gold,
backgroundColor: "rgba(212, 175, 55, 0.08)",
},
answerCardContent: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: spacing.sm,
},
answerCardQuestion: {
...typography.footnote,
color: colors.text,
fontStyle: "italic",
flex: 1,
},
answerCardBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.success,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
gap: 2,
},
answerCardBadgeMaybe: {
backgroundColor: "rgba(243, 156, 18, 0.2)",
borderWidth: 1,
borderColor: colors.warning,
},
answerCardBadgeText: {
...typography.caption2,
fontWeight: "700",
color: colors.text,
},
answerCardBadgeTextMaybe: {
color: colors.warning,
},
answerCardEdit: {
marginLeft: spacing.sm,
padding: spacing.xs,
},
answerArrow: {
padding: spacing.xs,
},
textContainer: {
alignItems: "center",
marginBottom: spacing.xl,
@@ -137,51 +137,64 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
marginVertical={spacing.md}
/>
{/* Question Text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
{match.question.partner_text && (
<Text style={styles.matchQuestionPartnerText}>
"{match.question.partner_text}"
</Text>
)}
{/* Response Pills */}
{match.responses && match.responses.length > 0 && (
<View style={styles.responsePillsRow}>
{match.responses.map((response: any) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
return (
<View key={response.user_id} style={styles.responsePillWrapper}>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.responsePill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.responsePillText}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</LinearGradient>
) : (
<View style={styles.responsePillMaybe}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={styles.responsePillTextMaybe}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</View>
)}
</View>
{/* Question and Responses - Show who answered what */}
{match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}>
{(() => {
// Sort responses by created_at to determine who answered first
const sortedResponses = [...match.responses].sort(
(a: any, b: any) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
})}
const isTwoPart = !!match.question.partner_text;
return sortedResponses.map((response: any, index: number) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
const name = isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner');
// First responder sees main text, second sees partner_text
const questionText = isTwoPart && index === 1
? match.question.partner_text
: match.question.text;
return (
<View key={response.user_id} style={styles.responseCard}>
{/* Name and Answer Badge */}
<View style={styles.responseHeader}>
<Text style={styles.responseName}>{name}</Text>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.answerBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={10} color={colors.text} />
<Text style={styles.answerBadgeText}>YES</Text>
</LinearGradient>
) : (
<View style={styles.answerBadgeMaybe}>
<Ionicons name="help" size={10} color={colors.warning} />
<Text style={styles.answerBadgeTextMaybe}>MAYBE</Text>
</View>
)}
</View>
{/* Question they saw */}
<Text style={styles.responseQuestionText}>
"{questionText}"
</Text>
</View>
);
});
})()}
</View>
) : (
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
)}
{/* Premium border */}
@@ -322,50 +335,66 @@ const styles = StyleSheet.create({
fontStyle: 'italic',
lineHeight: 20,
},
matchQuestionPartnerText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
marginTop: 2,
},
responsePillsRow: {
flexDirection: 'row',
justifyContent: 'center',
responsesContainer: {
width: '100%',
gap: spacing.sm,
marginTop: spacing.sm,
},
responsePillWrapper: {
overflow: 'hidden',
borderRadius: radius.full,
},
responsePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
},
responsePillText: {
...typography.caption2,
fontWeight: '600',
color: colors.text,
},
responsePillMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
backgroundColor: `rgba(243, 156, 18, 0.15)`,
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: `rgba(243, 156, 18, 0.3)`,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
responseHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.xs,
},
responseName: {
...typography.caption1,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
answerBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
gap: 3,
borderRadius: radius.full,
},
responsePillTextMaybe: {
answerBadgeText: {
...typography.caption2,
fontWeight: '600',
fontWeight: '700',
color: colors.text,
letterSpacing: 0.5,
},
answerBadgeMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
gap: 3,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
borderRadius: radius.full,
},
answerBadgeTextMaybe: {
...typography.caption2,
fontWeight: '700',
color: colors.warning,
letterSpacing: 0.5,
},
responseQuestionText: {
...typography.footnote,
color: colors.text,
fontStyle: 'italic',
lineHeight: 18,
},
matchCardBorder: {
position: 'absolute',
+2
View File
@@ -4,3 +4,5 @@ export { useMatchStore } from "./matchStore";
export { usePacksStore } from "./packsStore";
export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption } from "./responsesStore";
+91 -53
View File
@@ -6,77 +6,105 @@ 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;
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.order("created_at", { ascending: false });
if (!matches) return;
// Fetch all responses for these questions to determine who answered first
const questionIds = matches.map(m => m.question_id);
const { data: responses } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
// 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;
});
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
const data = matches.map(match => ({
...match,
responses: responses?.filter(r => r.question_id === match.question_id) || [],
unreadCount: unreadCounts[match.id] || 0
}));
set({ isLoading: true, error: null });
// 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;
try {
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
if (matchError) throw matchError;
if (!matches) {
set({ matches: [], newMatchesCount: 0, isLoading: false });
return;
}
const newCount = sortedData.filter((m) => m.is_new).length;
set({ matches: sortedData, newMatchesCount: newCount });
// 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) => {
@@ -115,7 +143,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
}));
},
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 });
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
},
}));
+15 -1
View File
@@ -2,6 +2,7 @@ import { create } from "zustand";
import { supabase } from "../lib/supabase";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -72,6 +73,14 @@ export const useMessageStore = create<MessageState>((set, get) => ({
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Get count of unread messages before marking them read
const { count: unreadInMatch } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
@@ -79,8 +88,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
.neq("user_id", userId)
.is("read_at", null);
// Refetch unread count
// Refetch global unread count
await get().fetchUnreadCount();
// Sync per-match unread count in matchStore
if (unreadInMatch && unreadInMatch > 0) {
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
clearMessages: () => {
+11
View File
@@ -11,6 +11,7 @@ interface PacksState {
isLoading: boolean;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
}
@@ -74,6 +75,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
set({ enabledPackIds: ids });
},
ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
if (get().enabledPackIds.length === 0) {
await get().fetchEnabledPacks();
}
},
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) {
+278
View File
@@ -0,0 +1,278 @@
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 }];
}
+17 -16
View File
@@ -1,22 +1,22 @@
// User profile (extends Supabase auth.users)
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
export interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
/** RSA public key for E2EE (JWK format) */
public_key_jwk?: Record<string, unknown> | null;
}
export interface Profile {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Gender | null;
show_explicit_content: boolean;
onboarding_completed: boolean;
created_at: string;
updated_at: string;
/** RSA public key for E2EE (JWK format) */
public_key_jwk?: Record<string, unknown> | null;
}
// Couple pairing
export interface Couple {
@@ -87,6 +87,7 @@ export interface Match {
match_type: MatchType;
is_new: boolean;
created_at: string;
unreadCount?: number;
}
// Extended match with question details
@@ -0,0 +1,279 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
type Answer = "yes" | "no" | "maybe";
type MatchType = "yes_yes" | "yes_maybe" | "maybe_maybe";
interface UpdateResponseBody {
question_id: string;
new_answer: Answer;
confirm_delete_match?: boolean;
}
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;
}
function calculateMatch(a1: Answer, a2: Answer): MatchType | null {
if (a1 === "no" || a2 === "no") return null;
if (a1 === "yes" && a2 === "yes") return "yes_yes";
if (a1 === "maybe" && a2 === "maybe") return "maybe_maybe";
return "yes_maybe";
}
Deno.serve(async (req) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
// Get user from JWT
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
return new Response(
JSON.stringify({ error: "Missing authorization header" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser(
authHeader.replace("Bearer ", "")
);
if (authError || !user) {
return new Response(
JSON.stringify({ error: "Invalid token" }),
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Parse request body
const { question_id, new_answer, confirm_delete_match }: UpdateResponseBody = await req.json();
if (!question_id || !new_answer) {
return new Response(
JSON.stringify({ error: "Missing question_id or new_answer" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Validate answer
if (!["yes", "no", "maybe"].includes(new_answer)) {
return new Response(
JSON.stringify({ error: "Invalid answer. Must be yes, no, or maybe" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get user's profile and couple
const { data: profile, error: profileError } = await supabase
.from("profiles")
.select("couple_id")
.eq("id", user.id)
.single();
if (profileError || !profile?.couple_id) {
return new Response(
JSON.stringify({ error: "User is not in a couple" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get current response
const { data: currentResponse, error: currentResponseError } = await supabase
.from("responses")
.select("answer")
.eq("user_id", user.id)
.eq("question_id", question_id)
.maybeSingle();
if (currentResponseError) {
return new Response(
JSON.stringify({ error: "Failed to get current response" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (!currentResponse) {
return new Response(
JSON.stringify({ error: "No existing response found for this question" }),
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// If answer hasn't changed, return early
if (currentResponse.answer === new_answer) {
return new Response(
JSON.stringify({ success: true, message: "Answer unchanged" } as UpdateResponseResult),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Check if a match exists for this question
const { data: existingMatch } = await supabase
.from("matches")
.select("id")
.eq("couple_id", profile.couple_id)
.eq("question_id", question_id)
.maybeSingle();
// Get partner's response
const { data: partnerResponse } = await supabase
.from("responses")
.select("answer")
.eq("couple_id", profile.couple_id)
.eq("question_id", question_id)
.neq("user_id", user.id)
.maybeSingle();
const result: UpdateResponseResult = { success: true };
// Case 1: Changing to "no" AND match exists - requires confirmation
if (new_answer === "no" && existingMatch) {
// Get message count for confirmation dialog
const { count: messageCount } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.eq("match_id", existingMatch.id);
if (!confirm_delete_match) {
// Return confirmation required
return new Response(
JSON.stringify({
success: false,
requires_confirmation: true,
match_id: existingMatch.id,
message_count: messageCount || 0,
} as UpdateResponseResult),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// User confirmed deletion - delete media from storage
const { data: files } = await supabase.storage
.from("chat-media")
.list(existingMatch.id);
if (files && files.length > 0) {
const filePaths = files.map(f => `${existingMatch.id}/${f.name}`);
await supabase.storage
.from("chat-media")
.remove(filePaths);
}
// Delete the match (messages cascade automatically via FK)
const { error: deleteMatchError } = await supabase
.from("matches")
.delete()
.eq("id", existingMatch.id);
if (deleteMatchError) {
console.error("Failed to delete match:", deleteMatchError);
return new Response(
JSON.stringify({ error: "Failed to delete match" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
result.match_deleted = true;
}
// Case 2: Changing FROM "no" to yes/maybe - may create a new match
if (currentResponse.answer === "no" && new_answer !== "no" && partnerResponse) {
const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer);
if (newMatchType) {
// Create new match
const { data: newMatch, error: matchError } = await supabase
.from("matches")
.upsert(
{
couple_id: profile.couple_id,
question_id,
match_type: newMatchType,
is_new: true,
},
{ onConflict: "couple_id,question_id" }
)
.select(`*, question:questions(*)`)
.single();
if (!matchError && newMatch) {
result.new_match = newMatch;
// Trigger push notification for new match
try {
await supabase.functions.invoke("send-notification", {
body: { couple_id: profile.couple_id, match_id: newMatch.id },
});
} catch (notifyError) {
console.error("Failed to send notification:", notifyError);
}
}
}
}
// Case 3: Changing between yes/maybe - may update match type
if (
currentResponse.answer !== "no" &&
new_answer !== "no" &&
existingMatch &&
partnerResponse
) {
const newMatchType = calculateMatch(new_answer, partnerResponse.answer as Answer);
if (newMatchType) {
const { error: updateMatchError } = await supabase
.from("matches")
.update({ match_type: newMatchType })
.eq("id", existingMatch.id);
if (!updateMatchError) {
result.match_type_updated = true;
}
}
}
// Update the response
const { error: updateError } = await supabase
.from("responses")
.update({ answer: new_answer })
.eq("user_id", user.id)
.eq("question_id", question_id);
if (updateError) {
return new Response(
JSON.stringify({ error: "Failed to update response", details: updateError }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
return new Response(
JSON.stringify(result),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});