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

@@ -0,0 +1,48 @@
import { streakStatus } from '@/components/StreakDisplay';
import type { CoupleStreak } from '@/store/streakStore';
const streak = (overrides: Partial<CoupleStreak>): CoupleStreak => ({
couple_id: 'couple-1',
current_streak: 6,
longest_streak: 9,
last_active_date: '2026-08-28',
last_completed_date: '2026-08-27',
you_answered_today: false,
partner_answered_today: false,
partner_name: 'Alex',
timezone: 'Europe/London',
streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z',
updated_at: '2026-08-28T00:00:00.000Z',
...overrides,
});
describe('streakStatus', () => {
it('frames the count as the couple rather than as a personal score', () => {
expect(streakStatus(streak({})).headline).toBe('You and Alex — 6 days in a row');
expect(streakStatus(streak({ current_streak: 1 })).headline).toBe('You and Alex — 1 day in a row');
});
it('names the partner who moved first so the day has somebody to answer to', () => {
expect(streakStatus(streak({ partner_answered_today: true })).status)
.toBe('Alex has answered today — your turn');
expect(streakStatus(streak({ you_answered_today: true })).status)
.toBe('Answered. Waiting on Alex');
});
it('settles once both partners have answered', () => {
expect(streakStatus(streak({ you_answered_today: true, partner_answered_today: true })).status)
.toBe("You've both answered today");
});
it('invites rather than warns when the day is still untouched', () => {
expect(streakStatus(streak({})).status).toBe('One question each keeps it going');
expect(streakStatus(streak({ current_streak: 0 })).status).toBe('Answer together to start a streak');
});
it('falls back to a neutral label when the partner has no name yet', () => {
const anonymous = streak({ partner_name: null, partner_answered_today: true });
expect(streakStatus(anonymous).headline).toBe('You and your partner — 6 days in a row');
expect(streakStatus(anonymous).status).toBe('Your partner has answered today — your turn');
});
});
@@ -0,0 +1,40 @@
import { calculateEffectiveTotal } from "@/features/swipe/utils/useSwipeScreen-helpers";
import type { DailyLimitInfo } from "@/features/swipe/types";
const limit = (overrides: Partial<DailyLimitInfo> = {}): DailyLimitInfo => ({
responses_today: 0,
limit_value: 10,
remaining: 10,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: false,
...overrides,
});
describe("calculateEffectiveTotal", () => {
it("clamps the progress bar to the quota left today", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 8, remaining: 2 }), 3)).toBe(5);
});
it("shows the last card as the last card when one answer remains", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 9, remaining: 1 }), 7)).toBe(8);
});
it("leaves the total alone when the meter is disabled", () => {
expect(calculateEffectiveTotal(50, null, limit({ limit_value: 0, remaining: 0 }), 3)).toBe(50);
expect(calculateEffectiveTotal(50, null, null, 3)).toBe(50);
});
it("ignores the quota once blocked, since the limit screen takes over", () => {
expect(calculateEffectiveTotal(50, null, limit({ responses_today: 10, remaining: 0, is_blocked: true }), 3)).toBe(50);
});
it("takes whichever of the answer gap and the quota binds first", () => {
const gap = { unanswered: 8, threshold: 10 };
expect(calculateEffectiveTotal(50, gap, limit({ remaining: 5 }), 2)).toBe(4);
expect(calculateEffectiveTotal(50, gap, limit({ remaining: 1 }), 2)).toBe(3);
});
it("never runs fewer questions than the quota allows", () => {
expect(calculateEffectiveTotal(3, null, limit({ remaining: 10 }), 0)).toBe(3);
});
});
@@ -0,0 +1,168 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
import { ApiError, apiClient } from "@/lib/apiClient";
import * as swipeService from "@/features/swipe/services/swipeService";
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
let mockRouteParams: Record<string, string> = {};
const mockFetchStreak = jest.fn(async () => undefined);
jest.mock("expo-router", () => ({
useLocalSearchParams: () => mockRouteParams,
useFocusEffect: () => undefined,
}));
// Stable identities: the hook's effects key off these, so fresh objects per render
// would retrigger the fetch effect forever and the screen would never settle.
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
skipQuestion: jest.fn(),
getSkippedQuestionIds: jest.fn(),
}));
jest.mock("@/features/swipe/services/swipeService", () => ({
fetchRecommendedQuestions: jest.fn(),
fetchPendingQuestions: jest.fn(),
fetchAnswerGapStatus: jest.fn(),
fetchDailyLimitStatus: jest.fn(),
fetchPackContext: jest.fn(),
uploadResponseMedia: jest.fn(),
}));
const questions = [
{ id: "q1", pack_id: "pack-1", question_type: "swipe", text: "One" },
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
beforeEach(() => {
mockRouteParams = {};
jest.mocked(getSkippedQuestionIds).mockResolvedValue(new Set<string>());
jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue(questions as never);
jest.mocked(swipeService.fetchAnswerGapStatus).mockResolvedValue({
unanswered_by_partner: 0, threshold: 10, is_blocked: false,
});
jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue(openLimit);
});
const renderReady = async () => {
const view = renderHook(() => useSwipeScreen());
await waitFor(() => expect(view.result.current.isLoading).toBe(false));
await waitFor(() => expect(view.result.current.dailyLimitInfo).not.toBeNull());
return view;
};
describe("useSwipeScreen daily limit", () => {
it("holds the question and shows the wall when the server rejects with 429", async () => {
const { result } = await renderReady();
expect(result.current.currentIndex).toBe(0);
jest.spyOn(apiClient, "post").mockRejectedValueOnce(dailyLimit429());
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
// Advancing here would silently discard the question the server refused.
expect(result.current.currentIndex).toBe(0);
expect(result.current.dailyLimitInfo).toMatchObject({
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
is_blocked: true,
});
});
it("still advances past an unrelated submission failure", async () => {
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockRejectedValueOnce(new ApiError("Server error", 500, null));
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo?.is_blocked).toBe(false);
});
it("still reports the limit while browsing a single pack", async () => {
// Pack browsing used to skip the limit read, which showed "all caught up"
// to a capped user instead of the limit screen.
mockRouteParams = { packId: "pack-1" };
jest.mocked(swipeService.fetchPackContext).mockResolvedValue({ name: "Pack", icon: "layers" });
jest.mocked(swipeService.fetchRecommendedQuestions).mockResolvedValue([] as never);
jest.mocked(swipeService.fetchDailyLimitStatus).mockResolvedValue({
...openLimit, responses_today: 10, remaining: 0, is_blocked: true,
});
const { result } = renderHook(() => useSwipeScreen());
await waitFor(() => expect(result.current.isLoading).toBe(false));
await waitFor(() => expect(result.current.dailyLimitInfo?.is_blocked).toBe(true));
});
it("counts down the meter optimistically on a successful answer", async () => {
const { result } = await renderReady();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
jest.mocked(swipeService.fetchDailyLimitStatus)
.mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
it("refreshes the shared streak once the answer lands", async () => {
const { result } = await renderReady();
mockFetchStreak.mockClear();
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(mockFetchStreak).toHaveBeenCalled();
});
it("records the answer even when the streak refresh throws", async () => {
// A refresh that fails outright must not cost the user the answer they just gave.
const { result } = await renderReady();
mockFetchStreak.mockImplementationOnce(() => { throw new Error("offline"); });
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null });
jest.mocked(swipeService.fetchDailyLimitStatus)
.mockResolvedValue({ ...openLimit, responses_today: 5, remaining: 5 });
await act(async () => {
await result.current.handleAnswer("q1", "yes");
});
expect(result.current.currentIndex).toBe(1);
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
});
});
@@ -1,5 +1,5 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useDrawingSync } from '@/features/live-draw/hooks/useDrawingSync';
import { useDrawingSync, dedupeById, mergeDrawingChanges } from '@/features/live-draw/hooks/useDrawingSync';
import { appDataApi } from '@/lib/appDataApi';
import { ApiError } from '@/lib/apiClient';
@@ -47,3 +47,37 @@ describe('useDrawingSync API polling', () => {
expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
color: '#fff', width: 2, timestamp: 1, isEraser: false,
});
it('keeps the most complete copy of a stroke at its original z-position', () => {
// Mid-draw writes persist a truncated copy; touch-end appends the full one.
const out = dedupeById([at('a', 3), at('mine', 5), at('b', 3), at('mine', 40)]);
expect(out.map(s => s.id)).toEqual(['a', 'mine', 'b']);
expect(out[1].points).toHaveLength(40);
});
it('leaves a set of unique strokes untouched', () => {
const input = [at('a', 1), at('b', 1)];
expect(dedupeById(input)).toEqual(input);
});
it('never emits duplicate ids after a conflict merge', () => {
const merged = mergeDrawingChanges(
[at('a', 2)],
[at('a', 2), at('mine', 3), at('mine', 30)],
[at('a', 2), at('partner', 4)],
);
expect(merged.map(s => s.id)).toEqual(['a', 'partner', 'mine']);
expect(merged.find(s => s.id === 'mine')!.points).toHaveLength(30);
});
it('still drops strokes removed locally', () => {
const merged = mergeDrawingChanges([at('a', 2), at('gone', 2)], [at('a', 2)], [at('a', 2), at('gone', 2)]);
expect(merged.map(s => s.id)).toEqual(['a']);
});
});
@@ -0,0 +1,67 @@
import { profileSettingsApi } from "@/lib/profileSettingsApi";
import { syncTimezone } from "@/lib/reportedTimezone";
jest.mock("@/lib/profileSettingsApi", () => ({
profileSettingsApi: { updateProfile: jest.fn() },
}));
jest.mock("expo-secure-store", () => {
const store = new Map<string, string>();
return {
__store: store,
getItemAsync: jest.fn(async (key: string) => store.get(key) ?? null),
setItemAsync: jest.fn(async (key: string, value: string) => { store.set(key, value); }),
};
});
// eslint-disable-next-line @typescript-eslint/no-var-requires
const secureStore = require("expo-secure-store") as { __store: Map<string, string> };
const withZone = (zone: string | undefined) => {
jest.spyOn(Intl, "DateTimeFormat").mockReturnValue({
resolvedOptions: () => ({ timeZone: zone }),
} as never);
};
describe("syncTimezone", () => {
beforeEach(() => {
secureStore.__store.clear();
jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true });
});
it("reports the device zone once and not again until it changes", async () => {
withZone("Europe/London");
await syncTimezone("user-1");
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(1);
expect(profileSettingsApi.updateProfile).toHaveBeenCalledWith({ timezone: "Europe/London" });
withZone("Pacific/Auckland");
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenNthCalledWith(2, { timezone: "Pacific/Auckland" });
});
it("tracks zones per user, so a shared device does not skip the second account", async () => {
withZone("Europe/London");
await syncTimezone("user-1");
await syncTimezone("user-2");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2);
});
it("does nothing when the platform cannot resolve a zone", async () => {
withZone(undefined);
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).not.toHaveBeenCalled();
});
it("retries on the next launch when the report fails", async () => {
withZone("Europe/London");
jest.mocked(profileSettingsApi.updateProfile).mockRejectedValueOnce(new Error("offline"));
await syncTimezone("user-1");
jest.mocked(profileSettingsApi.updateProfile).mockResolvedValue({ updated: true });
await syncTimezone("user-1");
expect(profileSettingsApi.updateProfile).toHaveBeenCalledTimes(2);
});
});
@@ -21,6 +21,7 @@ const preferences = {
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
streak_reminders_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
+71 -37
View File
@@ -2,9 +2,9 @@ import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { useStreakStore } from '../store';
import { colors, spacing, radius, typography } from '../theme';
import type { CoupleStreak } from '../store';
import { colors, spacing, radius, typography, shadows } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
@@ -16,11 +16,34 @@ interface StreakDisplayProps {
}
/**
* Displays the couple's current streak with a flame icon.
* The flame pulses when there's an active streak.
* The count is deliberately framed as a couple's, not a personal score: a shared streak
* is the thing worth keeping. The status line is the working half of that framing - it
* says who the day is waiting on - and the copy stays an invitation, because guilt
* between partners is the failure mode for this mechanic rather than the goal.
*/
export function streakStatus(streak: CoupleStreak): { headline: string; status: string } {
const named = streak.partner_name?.trim() || null;
// The same fallback reads wrong in both positions, so keep a sentence-start form.
const subject = named ?? 'Your partner';
const object = named ?? 'your partner';
const days = streak.current_streak;
const both = streak.you_answered_today && streak.partner_answered_today;
const headline = days > 0
? `You and ${object} — ${days} day${days === 1 ? '' : 's'} in a row`
: `You and ${object}`;
if (both) return { headline, status: days > 0 ? "You've both answered today" : 'Answered together today' };
if (streak.partner_answered_today) return { headline, status: `${subject} has answered today — your turn` };
if (streak.you_answered_today) return { headline, status: `Answered. Waiting on ${object}` };
return { headline, status: days > 0 ? 'One question each keeps it going' : 'Answer together to start a streak' };
}
/**
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, isLoading, fetchStreak } = useStreakStore();
const { streak, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
@@ -29,9 +52,10 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
// Flame pulse animation
const scale = useSharedValue(1);
const hasStreak = (streak?.current_streak ?? 0) > 0;
useEffect(() => {
if (streak && streak.current_streak > 0) {
if (hasStreak) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
@@ -43,25 +67,32 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
} else {
scale.value = 1;
}
}, [streak?.current_streak, scale]);
}, [hasStreak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
// Don't show if loading or no couple
if (isLoading || !streak) {
if (!streak) {
return null;
}
const hasStreak = streak.current_streak > 0;
// A dormant couple gets no zero-state card. Showing "0 days" to a pair who simply
// have not played yet reads as a scolding for a streak they never started.
if (!hasStreak && !streak.you_answered_today && !streak.partner_answered_today) {
return null;
}
const { headline, status } = streakStatus(streak);
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
// The partner having moved first is the one state the couple can still act on today.
const awaitingYou = streak.partner_answered_today && !streak.you_answered_today;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<View style={[styles.compactContainer, awaitingYou && styles.compactAwaiting]}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
@@ -80,44 +111,35 @@ export function StreakDisplay({ showLongest = false, delay = 0, compact = false
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={[styles.card, shadows.sm, awaitingYou && styles.cardAwaiting]}>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
size={28}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
<Text style={styles.headline} numberOfLines={2}>
{headline}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
<Text style={[styles.status, awaitingYou && styles.statusAwaiting]} numberOfLines={2}>
{status}
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
Best together: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</View>
</Animated.View>
);
@@ -128,10 +150,14 @@ const styles = StyleSheet.create({
marginVertical: spacing.sm,
},
card: {
backgroundColor: colors.surface,
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
cardAwaiting: {
borderColor: colors.warning,
},
content: {
flexDirection: 'row',
@@ -139,23 +165,27 @@ const styles = StyleSheet.create({
gap: spacing.md,
},
iconContainer: {
width: 48,
height: 48,
width: 44,
height: 44,
borderRadius: radius.full,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
backgroundColor: colors.warningLight,
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
count: {
...typography.title1,
headline: {
...typography.headline,
color: colors.text,
},
label: {
status: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
statusAwaiting: {
color: colors.warning,
},
inactiveText: {
color: colors.textTertiary,
@@ -167,7 +197,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
borderTopColor: colors.border,
},
longestText: {
...typography.caption1,
@@ -178,11 +208,15 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
backgroundColor: colors.warningLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactAwaiting: {
borderWidth: 1,
borderColor: colors.warning,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
@@ -44,8 +44,9 @@ export const LiveDrawScreen: React.FC = () => {
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
const canvasRef = useRef<DrawingCanvasHandle>(null);
// Keep track of the current stroke id for sync broadcasts
const currentStrokeIdRef = useRef<string | null>(null);
// The stroke under the finger. Points are pushed in place — nothing about the
// in-progress stroke goes through React state until it is finished.
const currentStrokeRef = useRef<StrokeSegment | null>(null);
const canvasWidth = screenWidth - spacing.md * 2;
const canvasHeight = canvasWidth * 1.2;
@@ -64,6 +65,16 @@ export const LiveDrawScreen: React.FC = () => {
const { makeSnapshot, captureToFile } = useCanvasCapture();
// Mirrors of state read inside the touch handlers. Keeping them in refs means
// the handlers (and therefore the pan gesture) keep a stable identity instead
// of being rebuilt mid-stroke on every render.
const strokesRef = useRef(strokes);
strokesRef.current = strokes;
const strokeStyleRef = useRef({ color: selectedColor, width: brushWidth, isEraser });
strokeStyleRef.current = { color: selectedColor, width: brushWidth, isEraser };
const userIdRef = useRef(user?.id);
userIdRef.current = user?.id;
const handleStrokeStartFromPartner = useCallback(
(stroke: StrokeSegment) => addStroke(stroke),
[addStroke]
@@ -95,57 +106,66 @@ export const LiveDrawScreen: React.FC = () => {
const handleTouchStart = useCallback(
(point: StrokePoint) => {
if (!user?.id) return;
if (strokes.length >= MAX_STROKES) {
const userId = userIdRef.current;
// The gesture starts the live path on the UI thread before we get here,
// so a refused stroke has to be wiped rather than simply not started.
if (!userId) {
canvasRef.current?.clearLiveStroke();
return;
}
if (strokesRef.current.length >= MAX_STROKES) {
canvasRef.current?.clearLiveStroke();
Alert.alert('Canvas Full', 'Save your drawing and start fresh.');
return;
}
const style = strokeStyleRef.current;
const stroke: StrokeSegment = {
id: `${user.id}_${Date.now()}`,
userId: user.id,
id: `${userId}_${Date.now()}`,
userId,
points: [point],
color: selectedColor,
width: brushWidth,
color: style.color,
width: style.width,
timestamp: Date.now(),
isEraser,
isEraser: style.isEraser,
};
currentStrokeIdRef.current = stroke.id;
canvasRef.current?.startStroke(stroke);
sync.broadcastStrokeStart(stroke);
currentStrokeRef.current = stroke;
sync.beginLocalStroke(stroke);
},
[user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync]
[sync]
);
const handleTouchMove = useCallback(
(point: StrokePoint) => {
if (!currentStrokeIdRef.current) return;
canvasRef.current?.addPoint(point);
sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]);
const stroke = currentStrokeRef.current;
if (!stroke) return;
stroke.points.push(point);
sync.appendLocalPoint(point);
},
[sync]
);
const handleTouchEnd = useCallback(() => {
if (!currentStrokeIdRef.current) return;
const finished = canvasRef.current?.endStroke() ?? null;
currentStrokeIdRef.current = null;
if (finished) {
addStroke(finished);
sync.broadcastStrokeEnd(finished.id);
sync.persistStrokes([...strokes, finished]);
}
}, [addStroke, strokes, sync]);
const finished = currentStrokeRef.current;
currentStrokeRef.current = null;
if (!finished) return;
addStroke(finished);
void sync.endLocalStroke(finished);
// Drop the UI-thread copy only after React has painted the committed
// stroke, otherwise the line blinks out for a frame.
requestAnimationFrame(() => canvasRef.current?.clearLiveStroke());
}, [addStroke, sync]);
const handleUndo = useCallback(() => {
if (!user?.id) return;
const removed = undo(user.id);
if (removed) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
canvasRef.current?.clearLiveStroke();
sync.broadcastUndo(removed.id);
sync.persistStrokes(strokes.filter((s) => s.id !== removed.id));
void sync.persistStrokes(strokesRef.current.filter((s) => s.id !== removed.id));
}
}, [user?.id, undo, strokes, sync]);
}, [user?.id, undo, sync]);
const handleRedo = useCallback(() => {
if (!user?.id) return;
@@ -153,9 +173,9 @@ export const LiveDrawScreen: React.FC = () => {
if (restored) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
sync.broadcastRedo(restored);
sync.persistStrokes([...strokes, restored]);
void sync.persistStrokes([...strokesRef.current, restored]);
}
}, [user?.id, redo, strokes, sync]);
}, [user?.id, redo, sync]);
const handleClear = useCallback(() => {
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
@@ -165,8 +185,9 @@ export const LiveDrawScreen: React.FC = () => {
style: 'destructive',
onPress: () => {
clearAll();
canvasRef.current?.clearLiveStroke();
sync.broadcastClearCanvas();
sync.persistStrokes([]);
void sync.persistStrokes([]);
},
},
]);
@@ -203,6 +224,9 @@ export const LiveDrawScreen: React.FC = () => {
strokes={strokes}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
strokeColor={selectedColor}
strokeWidth={brushWidth}
isEraser={isEraser}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
@@ -1,26 +1,28 @@
import React, { useMemo, useRef, useEffect, useCallback, useState, useImperativeHandle, forwardRef } from 'react';
import React, { useMemo, useRef, useEffect, useImperativeHandle, forwardRef } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Canvas,
Path,
Skia,
} from '@shopify/react-native-skia';
import type { SkImage } from '@shopify/react-native-skia';
import type { SkImage, SkPath } from '@shopify/react-native-skia';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import { runOnJS, useDerivedValue, useSharedValue } from 'react-native-reanimated';
import type { StrokeSegment, StrokePoint } from '../types';
import { CANVAS_BACKGROUND } from '../constants';
export interface DrawingCanvasHandle {
startStroke: (stroke: StrokeSegment) => void;
addPoint: (point: StrokePoint) => void;
endStroke: () => StrokeSegment | null;
/** Drops the in-progress stroke rendered on the UI thread. */
clearLiveStroke: () => void;
}
interface DrawingCanvasProps {
strokes: StrokeSegment[];
canvasWidth: number;
canvasHeight: number;
strokeColor: string;
strokeWidth: number;
isEraser: boolean;
onTouchStart: (point: StrokePoint) => void;
onTouchMove: (point: StrokePoint) => void;
onTouchEnd: () => void;
@@ -85,41 +87,15 @@ const StrokePath = React.memo(function StrokePath({
);
});
// Renders the in-progress stroke from a ref, re-rendered only via rafTick
const CurrentStrokePath = React.memo(function CurrentStrokePath({
strokeRef,
canvasWidth,
canvasHeight,
_tick,
}: {
strokeRef: React.RefObject<StrokeSegment | null>;
canvasWidth: number;
canvasHeight: number;
_tick: number; // forces re-render at RAF cadence
}) {
const stroke = strokeRef.current;
if (!stroke) return null;
const path = buildPath(stroke.points, canvasWidth, canvasHeight);
return (
<Path
path={path}
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
style="stroke"
strokeWidth={stroke.width}
strokeCap="round"
strokeJoin="round"
/>
);
});
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
function DrawingCanvas(
{
strokes,
canvasWidth,
canvasHeight,
strokeColor,
strokeWidth,
isEraser,
onTouchStart,
onTouchMove,
onTouchEnd,
@@ -128,9 +104,23 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
ref
) {
const canvasRef = useRef<any>(null);
const currentStrokeRef = useRef<StrokeSegment | null>(null);
const rafScheduled = useRef(false);
const [rafTick, setRafTick] = useState(0);
// The in-progress stroke lives entirely on the UI thread: the pan gesture
// extends the path in place, so ink never waits on the JS thread.
const livePath = useSharedValue<SkPath | null>(null);
const livePrev = useSharedValue<StrokePoint | null>(null);
// Doubles as the invalidation signal: the path is mutated in place, so this
// counter is what tells the derived value to re-read it.
const livePointCount = useSharedValue(0);
const liveColor = isEraser ? CANVAS_BACKGROUND : strokeColor;
// Re-derived once per frame that the path changed — not once per point.
const renderedLivePath = useDerivedValue(() => {
const count = livePointCount.value;
const path = livePath.value;
return count > 0 && path ? path.copy() : Skia.Path.Make();
});
useEffect(() => {
if (onCanvasReady) {
@@ -140,52 +130,67 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
}
}, [onCanvasReady]);
const scheduleRafUpdate = useCallback(() => {
if (rafScheduled.current) return;
rafScheduled.current = true;
requestAnimationFrame(() => {
rafScheduled.current = false;
setRafTick((t) => t + 1);
});
}, []);
useImperativeHandle(ref, () => ({
startStroke(stroke: StrokeSegment) {
currentStrokeRef.current = stroke;
scheduleRafUpdate();
clearLiveStroke() {
livePath.value = null;
livePrev.value = null;
livePointCount.value = 0;
},
addPoint(point: StrokePoint) {
const s = currentStrokeRef.current;
if (!s) return;
// Mutate in place — no React state involved
s.points.push(point);
scheduleRafUpdate();
},
endStroke(): StrokeSegment | null {
const s = currentStrokeRef.current;
currentStrokeRef.current = null;
scheduleRafUpdate();
return s;
},
}), [scheduleRafUpdate]);
}), [livePath, livePrev, livePointCount]);
const pan = Gesture.Pan()
.minDistance(0)
.onStart((e) => {
runOnJS(onTouchStart)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onUpdate((e) => {
runOnJS(onTouchMove)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onEnd(() => {
runOnJS(onTouchEnd)();
});
const pan = useMemo(
() =>
Gesture.Pan()
.minDistance(0)
.onStart((e) => {
'worklet';
const path = Skia.Path.Make();
path.moveTo(e.x, e.y);
livePath.value = path;
livePrev.value = { x: e.x, y: e.y };
livePointCount.value = 1;
runOnJS(onTouchStart)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
})
.onUpdate((e) => {
'worklet';
const path = livePath.value;
const prev = livePrev.value;
if (path && prev) {
// Same quadratic smoothing as buildPath, applied incrementally.
const midX = (prev.x + e.x) / 2;
const midY = (prev.y + e.y) / 2;
if (livePointCount.value === 1) {
path.lineTo(midX, midY);
} else {
path.quadTo(prev.x, prev.y, midX, midY);
}
livePrev.value = { x: e.x, y: e.y };
livePointCount.value += 1;
}
runOnJS(onTouchMove)({ x: e.x / canvasWidth, y: e.y / canvasHeight });
})
.onEnd(() => {
'worklet';
const path = livePath.value;
const prev = livePrev.value;
if (path && prev) {
// Close the half-segment the incremental midpoints leave at the tip.
path.lineTo(prev.x, prev.y);
livePointCount.value += 1;
}
runOnJS(onTouchEnd)();
}),
[
canvasWidth,
canvasHeight,
onTouchStart,
onTouchMove,
onTouchEnd,
livePath,
livePrev,
livePointCount,
]
);
return (
<GestureDetector gesture={pan}>
@@ -199,11 +204,13 @@ export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>
canvasHeight={canvasHeight}
/>
))}
<CurrentStrokePath
strokeRef={currentStrokeRef}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
_tick={rafTick}
<Path
path={renderedLivePath}
color={liveColor}
style="stroke"
strokeWidth={strokeWidth}
strokeCap="round"
strokeJoin="round"
/>
</Canvas>
</View>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import type { StrokeSegment, StrokePoint } from '../types';
@@ -17,9 +17,9 @@ interface UseDrawingSyncConfig {
}
interface UseDrawingSyncReturn {
broadcastStrokeStart: (stroke: StrokeSegment) => void;
broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
broadcastStrokeEnd: (strokeId: string) => void;
beginLocalStroke: (stroke: StrokeSegment) => void;
appendLocalPoint: (point: StrokePoint) => void;
endLocalStroke: (stroke: StrokeSegment) => Promise<void>;
broadcastClearCanvas: () => void;
broadcastUndo: (strokeId: string) => void;
broadcastRedo: (stroke: StrokeSegment) => void;
@@ -32,6 +32,14 @@ function conflictState(error: unknown): LiveDrawState | null {
return state && Array.isArray(state.strokes) && Number.isInteger(state.revision) ? state : null;
}
// Keeps the position of the first occurrence (stable z-order) but the value of
// the last (the most complete copy of a stroke that was persisted mid-draw).
export function dedupeById(strokes: StrokeSegment[]): StrokeSegment[] {
const byId = new Map<string, StrokeSegment>();
for (const stroke of strokes) byId.set(stroke.id, stroke);
return [...byId.values()];
}
export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] {
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
const desiredIds = new Set(desiredById.keys());
@@ -40,7 +48,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
const merged = current
.filter(stroke => !removedIds.has(stroke.id))
.map(stroke => desiredById.get(stroke.id) ?? stroke);
return [...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))];
return dedupeById([...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))]);
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
@@ -50,13 +58,38 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
const workingRef = useRef<StrokeSegment[]>([]);
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
const pendingWritesRef = useRef(0);
const throttleRef = useRef<NodeJS.Timeout | null>(null);
const throttleRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// The stroke currently under the finger. Owned here and mutated in place so a
// touch move costs a push, not a rebuild of every stroke on the canvas.
const localStrokeRef = useRef<StrokeSegment | null>(null);
const onLoadStrokesRef = useRef(onLoadStrokes);
onLoadStrokesRef.current = onLoadStrokes;
// workingRef never holds the in-progress stroke; compose it in only when we
// are about to send, so the O(n) copy happens once per write, not per point.
const composeWorking = useCallback((): StrokeSegment[] => {
const local = localStrokeRef.current;
if (!local) return workingRef.current;
return dedupeById([
...workingRef.current,
{ ...local, points: [...local.points] },
]);
}, []);
const acceptState = useCallback((state: LiveDrawState) => {
revisionRef.current = state.revision;
baselineRef.current = state.strokes;
const local = localStrokeRef.current;
if (local) {
// Mid-stroke: adopt the remote strokes for the next write, but do NOT
// push them into React state. The server's copy of the stroke under the
// finger is always behind it, and handing it to setStrokes is what makes
// the line snap backwards while drawing.
workingRef.current = state.strokes.filter(stroke => stroke.id !== local.id);
return;
}
workingRef.current = state.strokes;
onLoadStrokesRef.current(state.strokes);
}, []);
@@ -71,26 +104,27 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
const merged = mergeDrawingChanges(base, desired, current.strokes);
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
}
updateWidget(workingRef.current);
}, [acceptState]);
updateWidget(composeWorking());
}, [acceptState, composeWorking]);
const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise<void> => {
workingRef.current = strokes;
const desired = dedupeById(strokes);
workingRef.current = desired.filter(stroke => stroke.id !== localStrokeRef.current?.id);
pendingWritesRef.current += 1;
const next = writeQueueRef.current.then(() => writeSnapshot(strokes));
const next = writeQueueRef.current.then(() => writeSnapshot(desired));
writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); });
return next.finally(() => { pendingWritesRef.current -= 1; });
}, [writeSnapshot]);
const scheduleWrite = useCallback((strokes: StrokeSegment[]) => {
workingRef.current = strokes;
if (!throttleRef.current) {
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
void enqueueWrite(workingRef.current);
}, 250);
}
}, [enqueueWrite]);
// Throttled: reads the latest state at fire time rather than capturing a
// freshly built array on every call.
const scheduleWrite = useCallback(() => {
if (throttleRef.current) return;
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
void enqueueWrite(composeWorking());
}, 250);
}, [enqueueWrite, composeWorking]);
useEffect(() => {
if (!coupleId) return;
@@ -112,21 +146,45 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
const broadcastStrokeStart = useCallback((stroke: StrokeSegment) => {
scheduleWrite([...workingRef.current.filter(item => item.id !== stroke.id), stroke]);
}, [scheduleWrite]);
const broadcastStrokeContinue = useCallback((strokeId: string, points: StrokePoint[]) => {
scheduleWrite(workingRef.current.map(stroke => stroke.id === strokeId ? { ...stroke, points: [...stroke.points, ...points] } : stroke));
}, [scheduleWrite]);
const broadcastStrokeEnd = useCallback((_strokeId: string) => undefined, []);
const broadcastClearCanvas = useCallback(() => { workingRef.current = []; }, []);
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = [...workingRef.current.filter(item => item.id !== stroke.id), stroke]; }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
throttleRef.current = null;
return enqueueWrite(strokes);
}, [enqueueWrite]);
return { broadcastStrokeStart, broadcastStrokeContinue, broadcastStrokeEnd, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes };
const beginLocalStroke = useCallback((stroke: StrokeSegment) => {
localStrokeRef.current = { ...stroke, points: [...stroke.points] };
workingRef.current = workingRef.current.filter(item => item.id !== stroke.id);
scheduleWrite();
}, [scheduleWrite]);
const appendLocalPoint = useCallback((point: StrokePoint) => {
localStrokeRef.current?.points.push(point);
scheduleWrite();
}, [scheduleWrite]);
const endLocalStroke = useCallback((stroke: StrokeSegment) => {
localStrokeRef.current = null;
// dedupeById drops the truncated copy the mid-draw writes left behind.
return persistStrokes([...workingRef.current, stroke]);
}, [persistStrokes]);
const broadcastClearCanvas = useCallback(() => {
localStrokeRef.current = null;
workingRef.current = [];
}, []);
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = dedupeById([...workingRef.current, stroke]); }, []);
// Stable identity: the screen's touch handlers depend on this object, and a
// new one every render rebuilt the pan gesture mid-stroke.
return useMemo(() => ({
beginLocalStroke,
appendLocalPoint,
endLocalStroke,
broadcastClearCanvas,
broadcastUndo,
broadcastRedo,
persistStrokes,
}), [beginLocalStroke, appendLocalPoint, endLocalStroke, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes]);
}
@@ -37,6 +37,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
onStreakRemindersToggle: (value: boolean) => updatePreference('streak_reminders_enabled', value),
onWeeklySummaryToggle: (value: boolean) => updatePreference('weekly_summary_enabled', value),
onUnpairedRemindersToggle: (value: boolean) => updatePreference('unpaired_reminders_enabled', value),
onCatchupRemindersToggle: (value: boolean) => updatePreference('catchup_reminders_enabled', value),
@@ -138,6 +139,21 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
<View style={styles.itemDivider} />
{partner && (
<>
<SwitchItem
icon="alarm-outline"
label="Streak Reminders"
description="An evening nudge while your streak is still open"
value={preferences.streak_reminders_enabled}
onValueChange={handlers.onStreakRemindersToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
</>
)}
<SwitchItem
icon="calendar-outline"
label="Weekly Recap"
@@ -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);
}
@@ -13,6 +13,7 @@ export interface ProfileUpdate {
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
timezone?: string | null;
}
type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>;
+68
View File
@@ -0,0 +1,68 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import { profileSettingsApi } from "./profileSettingsApi";
const STORAGE_KEY_PREFIX = "reported_timezone";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
/** The IANA zone this device is in, or null when the platform can't resolve one. */
export function resolveDeviceTimezone(): string | null {
try {
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
return zone && zone.length <= 64 ? zone : null;
} catch {
return null;
}
}
async function readStored(key: string): Promise<string | null> {
try {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key);
}
return null;
}
return await SecureStore.getItemAsync(key);
} catch (error) {
console.error("Error reading reported timezone:", error);
return null;
}
}
async function writeStored(key: string, zone: string): Promise<void> {
try {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, zone);
}
return;
}
await SecureStore.setItemAsync(key, zone);
} catch (error) {
console.error("Error saving reported timezone:", error);
}
}
/**
* Report the device timezone to the server so the daily response limit buckets on the
* user's local day. Cached locally, so this costs one write on install and one per
* actual travel event rather than one per foreground.
*/
export async function syncTimezone(userId: string): Promise<void> {
const zone = resolveDeviceTimezone();
if (!zone) return;
const key = buildKey(userId);
if (await readStored(key) === zone) return;
try {
await profileSettingsApi.updateProfile({ timezone: zone });
await writeStored(key, zone);
} catch (error) {
// Non-critical: the server falls back to UTC until this succeeds.
console.error("Failed to sync timezone:", error);
}
}
+5
View File
@@ -3,6 +3,7 @@ import { ApiError, apiClient } from "../lib/apiClient";
import { authClient } from "../lib/authClient";
import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { syncTimezone } from "../lib/reportedTimezone";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
@@ -200,6 +201,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
}
// Keep the server's idea of the user's timezone current so the daily
// response limit resets at their local midnight, not 00:00 UTC.
void syncTimezone(userId);
},
}));
@@ -11,6 +11,7 @@ export interface NotificationPreferences {
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
streak_reminders_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
+11 -2
View File
@@ -2,13 +2,22 @@ import { create } from "zustand";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "./authStore";
/**
* Resolved from the signed-in partner's point of view by the API. The stored row is
* positional (user1/user2 by member id), which a client cannot act on without
* re-deriving that ordering, so the endpoint answers in you/partner terms instead.
*/
export interface CoupleStreak {
couple_id: string;
current_streak: number;
longest_streak: number;
last_active_date: string | null;
user1_answered_today: boolean;
user2_answered_today: boolean;
last_completed_date: string | null;
you_answered_today: boolean;
partner_answered_today: boolean;
partner_name: string | null;
/** The couple's shared IANA zone, which is the day boundary the streak is counted in. */
timezone: string;
streak_celebrated_at: number;
created_at: string;
updated_at: string;