mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18: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
@@ -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',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user