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
27 changes: 27 additions & 0 deletions apps/mobile/__tests__/lib/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,33 @@ describe('mobile apiClient', () => {
)
})

it('retries with the newest stored token after a mid-flight rotation instead of clearing the session', async () => {
getTokenMock
.mockResolvedValueOnce('stale-token')
.mockResolvedValue('fresh-token')
fetchMock
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({
ok: true,
status: 200,
text: () => Promise.resolve(JSON.stringify({ ok: true })),
})

await expect(apiClient('/secure')).resolves.toEqual({ ok: true })

expect(refreshSessionTokenMock).not.toHaveBeenCalled()
expect(clearSessionAndResetAuthMock).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'https://api.useorbit.org/secure',
expect.objectContaining({
headers: expect.objectContaining({
Authorization: 'Bearer fresh-token',
}),
}),
)
})

it('clears auth state when refresh cannot recover a 401', async () => {
getTokenMock.mockResolvedValue('token-123')
refreshSessionTokenMock.mockResolvedValue(null)
Expand Down
23 changes: 23 additions & 0 deletions apps/mobile/__tests__/stores/auth-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,29 @@ describe('mobile auth store security paths', () => {
expect(useAuthStore.getState().isAuthenticated).toBe(true)
})

it('persists the new tokens before clearing cached query data on login', async () => {
const callOrder: string[] = []
setTokenMock.mockImplementation(async () => {
callOrder.push('setToken')
})
setRefreshTokenMock.mockImplementation(async () => {
callOrder.push('setRefreshToken')
})
queryClientClearMock.mockImplementation(() => {
callOrder.push('queryClient.clear')
})

await useAuthStore.getState().login('access-token', 'refresh-token', {
userId: 'user-1',
email: 'user@example.com',
name: 'User',
})

expect(callOrder.indexOf('setToken')).toBeGreaterThanOrEqual(0)
expect(callOrder.indexOf('setToken')).toBeLessThan(callOrder.indexOf('queryClient.clear'))
expect(callOrder.indexOf('setRefreshToken')).toBeLessThan(callOrder.indexOf('queryClient.clear'))
})

it('resets local auth state when profile refresh returns unauthorized', async () => {
const validToken = makeJwt(Math.floor(Date.now() / 1000) + 3600)
getTokenMock.mockResolvedValueOnce(validToken)
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/app/(tabs)/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,10 @@ export default function CalendarScreen() {
borderWidth: 1.5,
borderColor: tokens.primary,
},
selected && { backgroundColor: tokens.selectionBg },
selected && {
backgroundColor: tokens.selectionBg,
borderRadius: 14,
},
]}
>
<Text
Expand Down Expand Up @@ -617,7 +620,7 @@ function createStyles(tokens: Tokens) {
dayNumPill: {
width: 28,
height: 28,
borderRadius: 999,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
Expand Down
111 changes: 76 additions & 35 deletions apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,10 @@ interface TodaySearchBarProps {
initialValue: string;
onChange: (value: string) => void;
onFocusChange: (focused: boolean) => void;
onCancel: () => void;
placeholder: string;
clearLabel: string;
cancelLabel: string;
focused: boolean;
tokens: ReturnType<typeof createTokensV2>;
styles: ReturnType<typeof createStyles>;
Expand All @@ -143,8 +145,10 @@ const TodaySearchBar = memo(function TodaySearchBar({
initialValue,
onChange,
onFocusChange,
onCancel,
placeholder,
clearLabel,
cancelLabel,
focused,
tokens,
styles,
Expand Down Expand Up @@ -192,39 +196,53 @@ const TodaySearchBar = memo(function TodaySearchBar({
);

return (
<Animated.View
style={[
styles.searchWrap,
{ borderColor: focused ? tokens.hairlineStrong : tokens.hairline },
focusAnimatedStyle,
]}
>
<Search size={18} color={tokens.fg3} strokeWidth={1.8} />
<AppTextInput
style={[styles.searchInput, { color: tokens.fg1 }]}
value={draft}
onChangeText={setDraft}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
placeholder={placeholder}
placeholderTextColor={tokens.fg3}
returnKeyType="search"
selectionColor={tokens.primary}
/>
{draft.length > 0 ? (
<Pressable
onPress={() => setDraft("")}
accessibilityRole="button"
accessibilityLabel={clearLabel}
hitSlop={6}
style={({ pressed }) => [
styles.searchClear,
pressed ? { backgroundColor: tokens.bgSunk } : null,
]}
>
<X size={16} color={tokens.fg3} strokeWidth={1.8} />
</Pressable>
) : null}
<Animated.View style={[styles.searchRow, focusAnimatedStyle]}>
<View
style={[
styles.searchWrap,
{ borderColor: focused ? tokens.hairlineStrong : tokens.hairline },
]}
>
<Search size={18} color={tokens.fg3} strokeWidth={1.8} />
<AppTextInput
style={[styles.searchInput, { color: tokens.fg1 }]}
value={draft}
onChangeText={setDraft}
onFocus={() => onFocusChange(true)}
onBlur={() => onFocusChange(false)}
placeholder={placeholder}
placeholderTextColor={tokens.fg3}
returnKeyType="search"
selectionColor={tokens.primary}
/>
{draft.length > 0 ? (
<Pressable
onPress={() => setDraft("")}
accessibilityRole="button"
accessibilityLabel={clearLabel}
hitSlop={6}
style={({ pressed }) => [
styles.searchClear,
pressed ? { backgroundColor: tokens.bgSunk } : null,
]}
>
<X size={16} color={tokens.fg3} strokeWidth={1.8} />
</Pressable>
) : null}
</View>
<Pressable
onPress={onCancel}
accessibilityRole="button"
hitSlop={6}
style={({ pressed }) => [
styles.searchCancel,
pressed ? { backgroundColor: tokens.bgElev } : null,
]}
>
<Text style={[styles.searchCancelText, { color: tokens.fg2 }]}>
{cancelLabel}
</Text>
</Pressable>
</Animated.View>
);
});
Expand Down Expand Up @@ -1140,8 +1158,10 @@ export default function TodayScreen() {
initialValue={searchQueryStore}
onChange={setSearchQueryStore}
onFocusChange={setIsSearchFocused}
onCancel={handleToggleSearch}
placeholder={t("habits.searchPlaceholder")}
clearLabel={t("common.clear")}
cancelLabel={t("common.cancel")}
focused={isSearchFocused}
tokens={tokens}
styles={styles}
Expand Down Expand Up @@ -1573,12 +1593,19 @@ function createStyles(tokens: ReturnType<typeof createTokensV2>) {
filtersShell: {
paddingBottom: 8,
},
searchWrap: {
searchRow: {
flexDirection: "row",
alignItems: "center",
gap: 10,
gap: 8,
marginHorizontal: 20,
marginVertical: 8,
},
searchWrap: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: 10,
minHeight: 44,
borderRadius: 999,
borderWidth: 1,
Expand All @@ -1596,10 +1623,24 @@ function createStyles(tokens: ReturnType<typeof createTokensV2>) {
searchInput: {
flex: 1,
minWidth: 0,
minHeight: 0,
borderWidth: 0,
borderRadius: 0,
backgroundColor: "transparent",
paddingHorizontal: 0,
paddingVertical: 0,
fontFamily: 'Rubik_400Regular',
fontSize: 15,
},
searchCancel: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 999,
},
searchCancelText: {
fontFamily: 'Rubik_500Medium',
fontSize: 13,
},
filtersContent: {
flexDirection: "row",
alignItems: "center",
Expand Down
21 changes: 18 additions & 3 deletions apps/mobile/app/achievements-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type AchievementCategoryView = {

const GRID_PADDING = 20
const GRID_GAP = 12
const GRID_COLUMNS = 3
const GRID_COLUMNS = 2

interface AchievementCategorySectionProps {
category: AchievementCategoryView
Expand Down Expand Up @@ -97,9 +97,16 @@ function AchievementTile({
</Text>
<Text
style={[styles.name, { color: earned ? tokens.fg1 : tokens.fg2 }]}
numberOfLines={2}
>
{name}
</Text>
<Text
style={[styles.description, { color: tokens.fg3 }]}
numberOfLines={3}
>
{description}
</Text>
</View>
)
}
Expand All @@ -112,11 +119,12 @@ const styles = StyleSheet.create({
paddingHorizontal: GRID_PADDING,
},
tile: {
minHeight: 156,
borderRadius: 16,
borderWidth: 1,
paddingTop: 18,
paddingBottom: 14,
paddingHorizontal: 8,
paddingHorizontal: 12,
alignItems: 'center',
},
tileLocked: {
Expand All @@ -128,8 +136,15 @@ const styles = StyleSheet.create({
marginBottom: 8,
},
name: {
fontFamily: 'Rubik_400Regular',
fontFamily: 'Rubik_500Medium',
fontSize: 12,
lineHeight: 16,
textAlign: 'center',
marginBottom: 4,
},
description: {
fontFamily: 'Rubik_400Regular',
fontSize: 11,
lineHeight: 15,
textAlign: 'center',
},
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/app/streak-sections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type StreakDayView = {
dateStr: string
dayLabel: string
dayNum: string
status: 'active' | 'frozen' | 'missed' | 'today' | 'future'
status: 'active' | 'frozen' | 'missed' | 'today'
}

function useTokens(): AppTokensV2 {
Expand Down
50 changes: 11 additions & 39 deletions apps/mobile/app/streak.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Animated, { FadeInDown, ReduceMotion } from 'react-native-reanimated'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { subDays, isToday, format, parseISO } from 'date-fns'
import { buildStreakWeekDays } from '@orbit/shared/utils'
import { Snowflake } from 'lucide-react-native'
import { createTokensV2 } from '@/lib/theme'
import { useAppTheme } from '@/lib/use-app-theme'
Expand Down Expand Up @@ -78,44 +78,16 @@ export default function StreakScreen() {
return ''
}, [streak, t])

const weekDays = useMemo(() => {
const today = new Date()
const freezeDates = new Set(streakInfo?.recentFreezeDates ?? [])
const lastActive = streakInfo?.lastActiveDate
const lastActiveDate = lastActive ? parseISO(lastActive) : null
const currentStreak = streak

return Array.from({ length: 7 }, (_, i) => {
const date = subDays(today, 6 - i)
const dateStr = format(date, 'yyyy-MM-dd')
const dayLabel = displayDate(date, { weekday: 'short' }).slice(0, 3)
const dayNum = String(date.getDate())
const isTodayDate = isToday(date)

let status: 'active' | 'frozen' | 'missed' | 'today' | 'future' = 'missed'

if (isTodayDate) {
if (isFrozenToday) status = 'frozen'
else if (lastActiveDate && isToday(lastActiveDate)) status = 'active'
else status = 'today'
} else if (freezeDates.has(dateStr)) {
status = 'frozen'
} else if (lastActiveDate && currentStreak > 0) {
const streakStart = subDays(lastActiveDate, currentStreak - 1)
if (date >= streakStart && date <= lastActiveDate) {
status = 'active'
} else if (date < today) {
status = 'missed'
}
} else if (date > today) {
status = 'future'
}

if (date > today && !isTodayDate) status = 'future'

return { date, dateStr, dayLabel, dayNum, status, isTodayDate }
})
}, [streakInfo, streak, isFrozenToday, displayDate])
const weekDays = useMemo(
() =>
buildStreakWeekDays(streakInfo, streak, isFrozenToday).map((day) => ({
dateStr: day.dateStr,
dayLabel: displayDate(day.date, { weekday: 'short' }).slice(0, 3),
dayNum: day.dayNum,
status: day.status,
})),
[streakInfo, streak, isFrozenToday, displayDate],
)

const heroEyebrow = isFrozenToday
? t('streakDisplay.freeze.activeToday')
Expand Down
Loading
Loading