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

+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 }];
}