Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions apps/mobile/__tests__/lib/persistent-reminder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

import expoNotificationsMock, {
dismissNotificationAsync,
getPermissionsAsync,
requestPermissionsAsync,
resetExpoNotificationsMocks,
scheduleNotificationAsync,
setNotificationChannelAsync,
} from '@/test-mocks/expo-notifications'
import { i18n } from '@/lib/i18n'
import {
__setPersistentReminderModuleForTests,
buildReminderContent,
cancelPersistentReminder,
extractReminderFeed,
refreshPersistentReminder,
requestPersistentReminderPermission,
} from '@/lib/persistent-reminder'
import { usePersistentReminderStore } from '@/stores/persistent-reminder-store'

interface ScheduledRequest {
identifier?: string
content: {
title: string
body: string
sticky: boolean
autoDismiss: boolean
color: string
data: { url: string }
}
trigger: { channelId: string } | null
}

function lastScheduledRequest(): ScheduledRequest {
const calls = scheduleNotificationAsync.mock.calls
const lastCall = calls[calls.length - 1]
if (!lastCall) throw new Error('expected a scheduled notification')
return lastCall[0] as ScheduledRequest
}

const fakeTranslate = (key: string, params?: Record<string, unknown>) =>
params ? `${key}:${JSON.stringify(params)}` : key

describe('persistent reminder', () => {
beforeEach(async () => {
resetExpoNotificationsMocks()
__setPersistentReminderModuleForTests(expoNotificationsMock)
usePersistentReminderStore.setState({ enabled: false })
await i18n.changeLanguage('en')
})

describe('extractReminderFeed', () => {
it('projects streak and top-level progress, mirroring the widget math', () => {
const feed = extractReminderFeed({
currentStreak: 12,
items: [
{ isCompleted: true, children: [] },
{ isCompleted: false, children: [{ isCompleted: true }, { isCompleted: true }] },
{ isCompleted: false, children: [{ isCompleted: false }] },
{ isCompleted: false, children: [] },
{ isCompleted: false },
],
})

expect(feed).toEqual({ streak: 12, completed: 2, total: 5 })
})

it('defaults the streak to zero and rejects non-feed payloads', () => {
expect(extractReminderFeed({ items: [] })).toEqual({ streak: 0, completed: 0, total: 0 })
expect(extractReminderFeed(null)).toBeNull()
expect(extractReminderFeed('not-a-feed')).toBeNull()
})
})

describe('buildReminderContent', () => {
it('uses the streak title and progress body when both are present', () => {
expect(buildReminderContent({ streak: 12, completed: 3, total: 5 }, fakeTranslate)).toEqual({
title: 'persistentReminder.titleStreak:{"streak":12}',
body: 'persistentReminder.body:{"completed":3,"total":5}',
})
})

it('falls back when there is no streak and nothing scheduled', () => {
expect(buildReminderContent({ streak: 0, completed: 0, total: 0 }, fakeTranslate)).toEqual({
title: 'persistentReminder.titleNoStreak',
body: 'persistentReminder.bodyEmpty',
})
})
})

describe('refreshPersistentReminder', () => {
it('posts nothing while the toggle is off', async () => {
await refreshPersistentReminder({ currentStreak: 5, items: [{ isCompleted: true }] })
expect(scheduleNotificationAsync).not.toHaveBeenCalled()
})

it('posts a quiet ongoing notification with the feed streak and progress when on', async () => {
usePersistentReminderStore.setState({ enabled: true })

await refreshPersistentReminder({
currentStreak: 12,
items: [
{ isCompleted: true },
{ isCompleted: true },
{ isCompleted: true },
{ isCompleted: false },
{ isCompleted: false },
],
})

expect(scheduleNotificationAsync).toHaveBeenCalledTimes(1)
expect(setNotificationChannelAsync).toHaveBeenCalledWith(
'persistent-reminder',
expect.objectContaining({ importance: expoNotificationsMock.AndroidImportance.LOW }),
)

const request = lastScheduledRequest()
expect(request.identifier).toBe('orbit-persistent-reminder')
expect(request.content.title).toBe('12-day streak')
expect(request.content.body).toBe('3/5 done today')
expect(request.content.sticky).toBe(true)
expect(request.content.autoDismiss).toBe(false)
expect(request.content.data).toEqual({ url: '/' })
expect(request.trigger).toEqual({ channelId: 'persistent-reminder' })
})

it('re-posts in place with the same identifier when the feed updates', async () => {
usePersistentReminderStore.setState({ enabled: true })

await refreshPersistentReminder({ currentStreak: 1, items: [{ isCompleted: false }] })
await refreshPersistentReminder({ currentStreak: 2, items: [{ isCompleted: true }] })

const calls = scheduleNotificationAsync.mock.calls
expect(calls).toHaveLength(2)
const first = calls[0]?.[0] as ScheduledRequest | undefined
const second = calls[1]?.[0] as ScheduledRequest | undefined
expect(first?.identifier).toBe('orbit-persistent-reminder')
expect(first?.content.body).toBe('0/1 done today')
expect(second?.identifier).toBe('orbit-persistent-reminder')
expect(second?.content.body).toBe('1/1 done today')
})

it('dismisses the notification when the feed is unavailable while enabled', async () => {
usePersistentReminderStore.setState({ enabled: true })

await refreshPersistentReminder(null)

expect(dismissNotificationAsync).toHaveBeenCalledWith('orbit-persistent-reminder')
expect(scheduleNotificationAsync).not.toHaveBeenCalled()
})
})

describe('cancelPersistentReminder', () => {
it('dismisses the ongoing notification', async () => {
await cancelPersistentReminder()
expect(dismissNotificationAsync).toHaveBeenCalledWith('orbit-persistent-reminder')
})
})

describe('requestPersistentReminderPermission', () => {
it('prompts and resolves true once permission is granted', async () => {
await expect(requestPersistentReminderPermission()).resolves.toBe(true)
expect(requestPermissionsAsync).toHaveBeenCalled()
})

it('resolves false when permission stays blocked', async () => {
getPermissionsAsync.mockResolvedValue({
status: 'denied',
granted: false,
canAskAgain: false,
})

await expect(requestPersistentReminderPermission()).resolves.toBe(false)
expect(requestPermissionsAsync).not.toHaveBeenCalled()
})
})
})
31 changes: 31 additions & 0 deletions apps/mobile/__tests__/stores/auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const {
fetchMock,
setQueryCacheScopeMock,
cancelScheduledFlushMock,
cancelPersistentReminderMock,
} = vi.hoisted(() => ({
replaceMock: vi.fn(),
getTokenMock: vi.fn(),
Expand All @@ -51,6 +52,7 @@ const {
fetchMock: vi.fn(),
setQueryCacheScopeMock: vi.fn(),
cancelScheduledFlushMock: vi.fn(),
cancelPersistentReminderMock: vi.fn(),
}))

vi.mock('expo-router', () => ({
Expand All @@ -73,6 +75,10 @@ vi.mock('@/lib/orbit-widget', () => ({
saveWidgetToken: saveWidgetTokenMock,
}))

vi.mock('@/lib/persistent-reminder', () => ({
cancelPersistentReminder: cancelPersistentReminderMock,
}))

vi.mock('@/lib/api-client', () => ({
apiClient: apiClientMock,
}))
Expand Down Expand Up @@ -143,6 +149,8 @@ describe('mobile auth store security paths', () => {
fetchMock.mockReset()
setQueryCacheScopeMock.mockReset()
cancelScheduledFlushMock.mockReset()
cancelPersistentReminderMock.mockReset()
cancelPersistentReminderMock.mockResolvedValue(undefined)
setQueryCacheScopeMock.mockResolvedValue(undefined)

clearWidgetTokenMock.mockResolvedValue(undefined)
Expand Down Expand Up @@ -404,6 +412,29 @@ describe('mobile auth store security paths', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(false)
})

it('dismisses the persistent reminder on logout so a signed-out tray shows no streak data', async () => {
getRefreshTokenMock.mockResolvedValue(null)
useAuthStore.setState({
isAuthenticated: true,
user: { userId: 'user-1', email: 'user@example.com', name: 'User' },
isLoading: false,
expiresAt: Date.now() + 3600_000,
})

await useAuthStore.getState().logout()

expect(cancelPersistentReminderMock).toHaveBeenCalledTimes(1)
})

it('dismisses the persistent reminder when checkAuth finds no token', async () => {
getTokenMock.mockResolvedValue(null)

const isValid = await useAuthStore.getState().checkAuth()

expect(isValid).toBe(false)
expect(cancelPersistentReminderMock).toHaveBeenCalledTimes(1)
})

it('clears the offline queue and offline state before establishing a new session on login', async () => {
const order: string[] = []
offlineQueueClearMock.mockImplementation(() => {
Expand Down
53 changes: 53 additions & 0 deletions apps/mobile/__tests__/stores/persistent-reminder-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { usePersistentReminderStore } from '@/stores/persistent-reminder-store'

const asyncStorageState = vi.hoisted(() => ({
data: new Map<string, string>(),
}))

vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => asyncStorageState.data.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
asyncStorageState.data.set(key, value)
}),
removeItem: vi.fn(async (key: string) => {
asyncStorageState.data.delete(key)
}),
},
}))

describe('persistent reminder store', () => {
beforeEach(() => {
asyncStorageState.data.clear()
usePersistentReminderStore.setState({ enabled: false })
})

afterEach(() => {
asyncStorageState.data.clear()
})

it('defaults the ongoing reminder to off', () => {
expect(usePersistentReminderStore.getState().enabled).toBe(false)
})

it('flips the flag through setEnabled', () => {
usePersistentReminderStore.getState().setEnabled(true)
expect(usePersistentReminderStore.getState().enabled).toBe(true)

usePersistentReminderStore.getState().setEnabled(false)
expect(usePersistentReminderStore.getState().enabled).toBe(false)
})

it('rehydrates a previously enabled flag from storage', async () => {
asyncStorageState.data.set(
'orbit-persistent-reminder',
JSON.stringify({ state: { enabled: true }, version: 0 }),
)

await usePersistentReminderStore.persist.rehydrate()

expect(usePersistentReminderStore.getState().enabled).toBe(true)
})
})
40 changes: 40 additions & 0 deletions apps/mobile/app/preferences-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,36 @@ export function PushNotificationSection({
)
}

interface PersistentReminderControls {
isSupported: boolean
enabled: boolean
isLoading: boolean
onToggle: () => void
}

function PersistentReminderRow({
t,
enabled,
isLoading,
onToggle,
}: Readonly<{ t: TranslationFn } & Omit<PersistentReminderControls, 'isSupported'>>) {
return (
<SettingsRow
label={t('persistentReminder.label')}
desc={t('persistentReminder.description')}
accessory="none"
divider={false}
>
<Switch
on={enabled}
onToggle={onToggle}
disabled={isLoading}
accessibilityLabel={t('persistentReminder.label')}
/>
</SettingsRow>
)
}

interface PreferenceSettingsListProps {
tokens: Tokens
t: TranslationFn
Expand All @@ -136,6 +166,7 @@ interface PreferenceSettingsListProps {
onOpenPicker: (picker: PreferencePicker) => void
onToggleShowGeneral: () => void
push: Omit<PushNotificationSectionProps, 'tokens' | 't'>
persistentReminder: PersistentReminderControls
}

export function PreferenceSettingsList({
Expand All @@ -150,6 +181,7 @@ export function PreferenceSettingsList({
onOpenPicker,
onToggleShowGeneral,
push,
persistentReminder,
}: Readonly<PreferenceSettingsListProps>) {
return (
<>
Expand Down Expand Up @@ -210,6 +242,14 @@ export function PreferenceSettingsList({

<Animated.View entering={sectionEntrance(2)}>
<PushNotificationSection tokens={tokens} t={t} {...push} />
{persistentReminder.isSupported ? (
<PersistentReminderRow
t={t}
enabled={persistentReminder.enabled}
isLoading={persistentReminder.isLoading}
onToggle={persistentReminder.onToggle}
/>
) : null}
</Animated.View>
</>
)
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/app/preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
import { buildWeekStartOptions } from '@orbit/shared/utils'
import type { ThemeMode } from '@orbit/shared/types/profile'
import { usePushNotifications } from '@/hooks/use-push-notifications'
import { usePersistentReminder } from '@/hooks/use-persistent-reminder'
import { TrialBanner } from '@/components/ui/trial-banner'
import { createTokensV2 } from '@/lib/theme'
import { AppBar } from '@/components/ui/app-bar'
Expand Down Expand Up @@ -50,6 +51,7 @@ export default function PreferencesScreen() {
requestPermission,
refreshPermissionStatus,
} = usePushNotifications()
const persistentReminder = usePersistentReminder()

useEffect(() => {
if (!pushSupported) return
Expand Down Expand Up @@ -153,6 +155,14 @@ export default function PreferencesScreen() {
void Linking.openSettings().catch(() => {})
},
}}
persistentReminder={{
isSupported: persistentReminder.isSupported,
enabled: persistentReminder.enabled,
isLoading: persistentReminder.isLoading,
onToggle: () => {
void persistentReminder.toggle()
},
}}
/>

<View style={{ height: 24 }} />
Expand Down
Loading