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',
};