mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user