feat(engagement): localize limits and streaks

Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
This commit is contained in:
jjbrunton committed 2026-08-28 20:28:31 +01:00
1 parent 61ec3692b7
commit b60c788362
42 files changed
+1308 -217

No files matched your search

@@ -115,6 +115,7 @@ const SwipeScreen = () => {
packContext={packContext}
showBackButton={!!packId || mode === 'pending'}
onBack={() => router.back()}
dailyLimitInfo={dailyLimitInfo}
/>
<SwipeCardStack
@@ -1,3 +1,4 @@
import { useEffect } from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
@@ -6,6 +7,7 @@ import { LinearGradient } from "expo-linear-gradient";
import { DecorativeSeparator, GradientBackground } from "../../../components/ui";
import { Paywall } from "../../../components/paywall";
import { colors, radius, spacing, typography } from "../../../theme";
import { Events } from "../../../lib/analytics";
import type { DailyLimitInfo } from "../types";
interface SwipeDailyLimitStateProps {
@@ -27,6 +29,11 @@ export const SwipeDailyLimitState = ({
}: SwipeDailyLimitStateProps) => {
const accent = colors.premium.gold;
// The meter exists to convert; without these the funnel is unmeasurable.
useEffect(() => {
Events.paywallShown('daily_limit');
}, []);
return (
<GradientBackground>
<View style={styles.centerContainer}>
@@ -59,7 +66,7 @@ export const SwipeDailyLimitState = ({
</Animated.View>
<Text style={styles.dailyLimitDescription}>
You've answered {dailyLimitInfo.limit_value} questions today!{'\n'}
You've answered {dailyLimitInfo.responses_today} questions today!{'\n'}
Let the anticipation build while you wait.
</Text>
@@ -100,8 +107,14 @@ export const SwipeDailyLimitState = ({
<Paywall
visible={showPaywall}
onClose={onClosePaywall}
onSuccess={onPaywallSuccess}
onClose={() => {
Events.paywallClosed('daily_limit', 'dismissed');
onClosePaywall();
}}
onSuccess={() => {
Events.paywallClosed('daily_limit', 'purchased');
onPaywallSuccess();
}}
/>
</GradientBackground>
);
@@ -4,7 +4,12 @@ import Animated, { FadeIn } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { colors, spacing, typography } from "../../../theme";
import { StreakDisplay } from "../../../components/StreakDisplay";
import { useProgressShimmer } from "../hooks/useProgressShimmer";
import type { DailyLimitInfo } from "../types";
/** Remaining answers at or below this switch the meter to its "running out" treatment. */
const SCARCITY_THRESHOLD = 3;
interface SwipeHeaderProps {
currentIndex: number;
@@ -14,6 +19,7 @@ interface SwipeHeaderProps {
packContext: { name: string } | null;
showBackButton: boolean;
onBack: () => void;
dailyLimitInfo?: DailyLimitInfo | null;
}
export const SwipeHeader = ({
@@ -24,11 +30,20 @@ export const SwipeHeader = ({
packContext,
showBackButton,
onBack,
dailyLimitInfo,
}: SwipeHeaderProps) => {
const shimmerStyle = useProgressShimmer();
const total = effectiveTotal || totalQuestions;
const progressPercent = total > 0 ? ((currentIndex + 1) / total) * 100 : 0;
// Catch-up answers are exempt from the cap, so the meter is hidden in pending mode.
const showMeter = mode !== 'pending'
&& !!dailyLimitInfo
&& dailyLimitInfo.limit_value > 0
&& !dailyLimitInfo.is_blocked;
const remaining = dailyLimitInfo?.remaining ?? 0;
const isRunningOut = showMeter && remaining <= SCARCITY_THRESHOLD;
return (
<Animated.View
entering={FadeIn.duration(400)}
@@ -47,6 +62,16 @@ export const SwipeHeader = ({
<Text style={styles.progressText}>
{currentIndex + 1} of {total}
</Text>
{showMeter && (
<View style={[styles.meter, isRunningOut && styles.meterUrgent]}>
{isRunningOut && (
<Ionicons name="hourglass-outline" size={12} color={colors.premium.gold} />
)}
<Text style={[styles.meterText, isRunningOut && styles.meterTextUrgent]}>
{remaining === 1 ? '1 left today' : `${remaining} left today`}
</Text>
</View>
)}
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
@@ -66,6 +91,11 @@ export const SwipeHeader = ({
</Animated.View>
</View>
</View>
{/* The shared streak sits opposite the back button so the centred
progress column keeps its position. */}
<View style={styles.streakBadge} pointerEvents="none">
<StreakDisplay compact delay={200} />
</View>
</View>
</Animated.View>
);
@@ -87,6 +117,10 @@ const styles = StyleSheet.create({
left: 0,
padding: spacing.sm,
},
streakBadge: {
position: 'absolute',
right: 0,
},
progressContainer: {
alignItems: "center",
},
@@ -100,8 +134,31 @@ const styles = StyleSheet.create({
progressText: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
meter: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
meterUrgent: {
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: 999,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
meterText: {
...typography.caption2,
letterSpacing: 0.5,
color: colors.textTertiary,
},
meterTextUrgent: {
color: colors.premium.gold,
fontWeight: '600',
marginLeft: spacing.xs,
},
progressBar: {
width: 160,
height: 6,
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore } from "../../../store";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { apiClient } from "../../../lib/apiClient";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
@@ -128,8 +128,16 @@ export const useSwipeScreen = () => {
filtered = data;
}
if (partner && !packId) {
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
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 {
setIsBlocked(false);
setGapInfo(null);
@@ -334,8 +342,40 @@ export const useSwipeScreen = () => {
}
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
} else {
// Catch-up answers are exempt from the cap, but the partner may have
// answered after this screen loaded, so re-read rather than assume.
await checkDailyLimit();
}
// The shared streak turns on today's answers, so a stale badge would show the
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.
if (error instanceof ApiError && error.status === 429) {
// ApiError.details carries the whole response body: { error, details }.
const body = (error.details ?? {}) as {
details?: {
daily_limit?: number;
responses_today?: number;
remaining?: number;
reset_at?: string;
};
};
const details = body.details ?? {};
setDailyLimitInfo(prev => ({
responses_today: details.responses_today ?? prev?.responses_today ?? 0,
limit_value: details.daily_limit ?? prev?.limit_value ?? 0,
remaining: details.remaining ?? 0,
reset_at: details.reset_at ?? prev?.reset_at ?? '',
is_blocked: true,
}));
return;
}
console.error("Failed to submit response", error);
setCurrentIndex(prev => prev + 1);
}