feat(pairing): solo sealed answers that unlock on pairing

Unpaired users could only reach a waiting-room dead end and could not answer
questions until a partner joined. Responses.couple_id is now nullable so a
solo user answers immediately and banks "sealed answers" (couple_id IS NULL).
Pairing claims both members' sealed answers and computes matches for every
question both have now answered. Invite copy and the unpaired push nudge
escalate with the sealed answer count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:34:42 +01:00
1 parent f22d4a3649
commit 09255da7db
26 files changed
+510 -75

No files matched your search

@@ -48,7 +48,7 @@ export const useSwipeScreen = () => {
const [countdown, setCountdown] = useState<string>("");
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
const { user, partner, couple, sealedCount } = useAuthStore();
const filteredQuestions = filterSupportedQuestions(questions);
@@ -128,20 +128,15 @@ export const useSwipeScreen = () => {
filtered = data;
}
if (partner) {
// The daily limit applies to pack browsing too, so it is read in both
// modes; the answer gap is deliberately only enforced outside packs.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
} else {
// The daily limit applies solo too, since a sealed answer still counts
// against it; the answer gap only makes sense once there is a partner
// to be ahead of, and checkAnswerGap already no-ops without one.
if (packId) {
setIsBlocked(false);
setGapInfo(null);
setDailyLimitInfo(null);
await checkDailyLimit();
} else {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
}
if (currentRequestId === fetchRequestId.current) {
@@ -156,7 +151,7 @@ export const useSwipeScreen = () => {
setIsLoading(false);
}
}
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
}, [packId, checkAnswerGap, checkDailyLimit]);
const fetchPending = useCallback(async () => {
const currentRequestId = ++fetchRequestId.current;
@@ -315,7 +310,7 @@ export const useSwipeScreen = () => {
uploadResponseMedia,
});
const data = await apiClient.post<{ match: unknown | null }>("/v1/responses", {
const data = await apiClient.post<{ match: unknown | null; sealed_count?: number }>("/v1/responses", {
question_id: questionId,
answer,
response_data: finalResponseData,
@@ -323,6 +318,13 @@ export const useSwipeScreen = () => {
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
// A solo answer has no partner to match against; the sealed count is
// this answer's only visible feedback, so the banner updates instantly
// instead of waiting for the couple state to refresh next time round.
if (typeof data?.sealed_count === 'number') {
useAuthStore.setState({ sealedCount: data.sealed_count });
}
const hasMatch = data?.match != null;
if (hasMatch) {
@@ -423,6 +425,7 @@ export const useSwipeScreen = () => {
user,
partner,
couple,
sealedCount,
enabledPackIds,
effectiveTotal,
fetchQuestions,