Skip to content

Commit b798a5e

Browse files
maximcodingclaude
andcommitted
refactor(home): expert-level cleanup — tokens, memo, shared utils, dead code
- Replace magic numbers in StoryScreen with design token constants (HEADER_HEIGHT=spacing.xxxxxl, ICON_SIZE=spacing.lg, PROGRESS_BAR_HEIGHT=spacing.xxs) - Extract formatRelativeTime to shared/utils to eliminate duplication between hn.mappers and useFeedQuery - Rename ActivityType → AccentVariant; align values with theme color keys ('primary'/'info'/'warning' replacing 'task'/'message'/'alert') - Add useMemo/useCallback throughout StoryScreen and useFeedQuery - Add attachLogging(hnClient) for dev HTTP visibility - Remove dead code: TAB_COMPONENTS route, UIKitScreen, redundant subtitle??undefined - Add 14 unit tests for parseDomain and mapHnHitToFeedItem - All 41 tests pass, zero TypeScript errors Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9113efe commit b798a5e

File tree

11 files changed

+265
-400
lines changed

11 files changed

+265
-400
lines changed

src/features/home/hooks/useFeedQuery.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,25 @@
11
import { useQuery } from '@tanstack/react-query'
2+
import { useMemo } from 'react'
23
import { homeKeys } from '@/features/home/api/keys'
34
import { fetchHnFeed } from '@/features/home/services/hn/hn.service'
45
import type { FeedItem } from '@/features/home/types'
56
import { Freshness } from '@/shared/services/api/query/policy/freshness'
6-
7-
function formatSyncedAt(ts: number): string {
8-
if (!ts) return ''
9-
const diffMs = Date.now() - ts
10-
const m = Math.floor(diffMs / 60_000)
11-
if (m < 1) return 'just now'
12-
if (m < 60) return `${m}m ago`
13-
const h = Math.floor(m / 60)
14-
return `${h}h ago`
15-
}
7+
import { formatRelativeTime } from '@/shared/utils/format-relative-time'
168

179
export function useFeedQuery() {
1810
const query = useQuery<FeedItem[]>({
1911
queryKey: homeKeys.feed(),
2012
queryFn: fetchHnFeed,
2113
staleTime: Freshness.nearRealtime.staleTime,
2214
gcTime: Freshness.nearRealtime.gcTime,
23-
meta: {
24-
persistence: 'nearRealtime',
25-
},
15+
meta: { persistence: 'nearRealtime' },
2616
placeholderData: prev => prev,
2717
})
2818

29-
const syncedAt = query.dataUpdatedAt ?? 0
30-
const syncedAtLabel = syncedAt ? formatSyncedAt(syncedAt) : null
19+
const syncedAtLabel = useMemo(() => {
20+
const ts = query.dataUpdatedAt
21+
return ts ? formatRelativeTime(ts) : null
22+
}, [query.dataUpdatedAt])
3123

3224
return {
3325
feed: query.data ?? [],

src/features/home/screens/HomeScreen.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,15 +123,16 @@ function useGreetingKey():
123123
return 'home.greeting_evening'
124124
}
125125

126-
function dotColor(
126+
/** Maps AccentVariant → theme colour token for the feed card dot. */
127+
function accentColor(
127128
type: FeedItem['type'],
128129
c: ReturnType<typeof useTheme>['theme']['colors'],
129130
): string {
130131
switch (type) {
131132
case 'success': return c.success
132-
case 'task': return c.primary
133-
case 'message': return c.info
134-
case 'alert': return c.warning
133+
case 'primary': return c.primary
134+
case 'info': return c.info
135+
case 'warning': return c.warning
135136
}
136137
}
137138

@@ -222,7 +223,7 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) {
222223
const sp = theme.spacing
223224
const r = theme.radius
224225
const ty = theme.typography
225-
const accent = dotColor(item.type, c)
226+
const accent = accentColor(item.type, c)
226227
const navigation = useNavigation<HomeNavProp>()
227228

228229
const onPress = useCallback(() => {
@@ -234,7 +235,7 @@ const StoryCard = memo(function StoryCard({ item }: { item: FeedItem }) {
234235
author: item.author,
235236
numComments: item.numComments,
236237
time: item.time,
237-
domain: item.subtitle ?? undefined,
238+
domain: item.subtitle,
238239
})
239240
}, [navigation, item])
240241

Lines changed: 113 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
// src/features/home/screens/StoryScreen.tsx
22

33
import type { NativeStackScreenProps } from '@react-navigation/native-stack'
4-
import React, { useRef, useState } from 'react'
5-
import { ActivityIndicator, Pressable, View } from 'react-native'
4+
import React, { useCallback, useMemo, useRef, useState } from 'react'
5+
import { Animated, Pressable, StyleSheet, View } from 'react-native'
66
import Svg, { Path, Polyline } from 'react-native-svg'
77
import WebView from 'react-native-webview'
8+
import type { WebViewNavigation, WebViewProgressEvent } from 'react-native-webview/lib/WebViewTypes'
89
import type { RootStackParamList } from '@/navigation/root-param-list'
910
import { ROUTES } from '@/navigation/routes'
1011
import { ScreenWrapper } from '@/shared/components/ui/ScreenWrapper'
1112
import { Text } from '@/shared/components/ui/Text'
13+
import { spacing } from '@/shared/theme/tokens/spacing'
1214
import { useTheme } from '@/shared/theme/useTheme'
1315

1416
type Props = NativeStackScreenProps<RootStackParamList, typeof ROUTES.HOME_STORY>
1517

18+
// ─── Layout constants (derived from design tokens, never raw numbers) ─────────
19+
const HEADER_HEIGHT = spacing.xxxxxl // 56 — matches ScreenHeader
20+
const ICON_SIZE = spacing.lg // 20
21+
const ICON_STROKE = 2.2
22+
const PROGRESS_BAR_HEIGHT = spacing.xxs // 4
23+
1624
const HN_ITEM_BASE = 'https://news.ycombinator.com/item?id='
1725

1826
export default function StoryScreen({ route, navigation }: Props) {
@@ -26,59 +34,89 @@ export default function StoryScreen({ route, navigation }: Props) {
2634
const uri = url ?? `${HN_ITEM_BASE}${id}`
2735
const displayHost = domain ?? 'news.ycombinator.com'
2836

29-
const [loading, setLoading] = useState(true)
37+
const [isLoading, setIsLoading] = useState(true)
3038
const [canGoBack, setCanGoBack] = useState(false)
3139
const webViewRef = useRef<WebView>(null)
40+
const loadProgress = useRef(new Animated.Value(0)).current
41+
42+
const iconProps = useMemo(
43+
() => ({
44+
width: ICON_SIZE,
45+
height: ICON_SIZE,
46+
viewBox: '0 0 24 24',
47+
fill: 'none',
48+
stroke: c.textPrimary,
49+
strokeWidth: ICON_STROKE,
50+
strokeLinecap: 'round' as const,
51+
strokeLinejoin: 'round' as const,
52+
}),
53+
[c.textPrimary],
54+
)
3255

33-
const iconProps = {
34-
width: 20,
35-
height: 20,
36-
viewBox: '0 0 24 24',
37-
fill: 'none',
38-
stroke: c.textPrimary,
39-
strokeWidth: 2.2,
40-
strokeLinecap: 'round' as const,
41-
strokeLinejoin: 'round' as const,
42-
}
43-
44-
function handleBack() {
56+
const handleBack = useCallback(() => {
4557
if (canGoBack) {
4658
webViewRef.current?.goBack()
4759
} else {
4860
navigation.goBack()
4961
}
50-
}
62+
}, [canGoBack, navigation])
63+
64+
const handleNavigationStateChange = useCallback(
65+
(state: WebViewNavigation) => setCanGoBack(state.canGoBack),
66+
[],
67+
)
68+
69+
const handleLoadStart = useCallback(() => {
70+
loadProgress.setValue(0)
71+
setIsLoading(true)
72+
}, [loadProgress])
73+
74+
const handleLoadEnd = useCallback(() => setIsLoading(false), [])
75+
76+
const handleLoadProgress = useCallback(
77+
({ nativeEvent }: WebViewProgressEvent) => {
78+
Animated.timing(loadProgress, {
79+
toValue: nativeEvent.progress,
80+
duration: 80,
81+
useNativeDriver: false,
82+
}).start()
83+
},
84+
[loadProgress],
85+
)
86+
87+
const progressWidth = loadProgress.interpolate({
88+
inputRange: [0, 1],
89+
outputRange: ['0%', '100%'],
90+
})
5191

5292
return (
5393
<ScreenWrapper
5494
header={
5595
<View
56-
style={{
57-
height: 52,
58-
flexDirection: 'row',
59-
alignItems: 'center',
60-
paddingHorizontal: sp.md,
61-
backgroundColor: c.background,
62-
borderBottomWidth: 1,
63-
borderBottomColor: c.border,
64-
gap: sp.xs,
65-
}}
96+
style={[
97+
styles.header,
98+
{
99+
height: HEADER_HEIGHT,
100+
paddingHorizontal: sp.md,
101+
backgroundColor: c.background,
102+
borderBottomColor: c.border,
103+
gap: sp.xs,
104+
},
105+
]}
66106
>
67-
{/* Back / WebView back */}
68107
<Pressable
69108
onPress={handleBack}
70-
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
109+
hitSlop={{ top: sp.xs, bottom: sp.xs, left: sp.xs, right: sp.xs }}
71110
accessibilityRole="button"
72111
accessibilityLabel={canGoBack ? 'Go back in page' : 'Back to feed'}
73-
style={{ padding: sp.xs }}
112+
style={styles.iconBtn}
74113
>
75114
<Svg {...iconProps}>
76115
<Polyline points="15 18 9 12 15 6" />
77116
</Svg>
78117
</Pressable>
79118

80-
{/* Close (always goes to feed) */}
81-
<View style={{ flex: 1, alignItems: 'center' }}>
119+
<View style={styles.titleSlot}>
82120
<Text
83121
style={[ty.labelMedium, { color: c.textSecondary }]}
84122
numberOfLines={1}
@@ -87,17 +125,15 @@ export default function StoryScreen({ route, navigation }: Props) {
87125
</Text>
88126
</View>
89127

90-
{/* Close button */}
91128
<Pressable
92129
onPress={() => navigation.goBack()}
93-
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
130+
hitSlop={{ top: sp.xs, bottom: sp.xs, left: sp.xs, right: sp.xs }}
94131
accessibilityRole="button"
95132
accessibilityLabel="Close article"
96-
style={{
97-
padding: sp.xs,
98-
backgroundColor: c.surfaceSecondary,
99-
borderRadius: r.pill,
100-
}}
133+
style={[
134+
styles.iconBtn,
135+
{ backgroundColor: c.surfaceSecondary, borderRadius: r.pill },
136+
]}
101137
>
102138
<Svg {...iconProps} stroke={c.textTertiary}>
103139
<Path d="M18 6 6 18M6 6l12 12" />
@@ -109,47 +145,54 @@ export default function StoryScreen({ route, navigation }: Props) {
109145
<WebView
110146
ref={webViewRef}
111147
source={{ uri }}
148+
accessibilityLabel={title}
112149
style={{ flex: 1, backgroundColor: c.background }}
113-
onLoadStart={() => setLoading(true)}
114-
onLoadEnd={() => setLoading(false)}
115-
onNavigationStateChange={state => setCanGoBack(state.canGoBack)}
150+
onLoadStart={handleLoadStart}
151+
onLoadEnd={handleLoadEnd}
152+
onLoadProgress={handleLoadProgress}
153+
onNavigationStateChange={handleNavigationStateChange}
116154
allowsBackForwardNavigationGestures
117155
allowsInlineMediaPlayback
118156
mediaPlaybackRequiresUserAction
119157
startInLoadingState={false}
120158
/>
121159

122-
{/* Loading bar */}
123-
{loading && (
124-
<View
125-
style={{
126-
position: 'absolute',
127-
top: 0,
128-
left: 0,
129-
right: 0,
130-
height: 3,
131-
backgroundColor: c.primaryAmbient,
132-
overflow: 'hidden',
133-
}}
134-
>
135-
<View
136-
style={{
137-
position: 'absolute',
138-
top: 0,
139-
left: 0,
140-
right: 0,
141-
bottom: 0,
160+
{isLoading && (
161+
<Animated.View
162+
pointerEvents="none"
163+
style={[
164+
styles.progressBar,
165+
{
166+
height: PROGRESS_BAR_HEIGHT,
142167
backgroundColor: c.primary,
143-
opacity: 0.8,
144-
}}
145-
/>
146-
<ActivityIndicator
147-
size="small"
148-
color={c.primary}
149-
style={{ position: 'absolute', right: sp.md, top: -8 }}
150-
/>
151-
</View>
168+
width: progressWidth,
169+
},
170+
]}
171+
/>
152172
)}
153173
</ScreenWrapper>
154174
)
155175
}
176+
177+
const styles = StyleSheet.create({
178+
header: {
179+
flexDirection: 'row',
180+
alignItems: 'center',
181+
borderBottomWidth: StyleSheet.hairlineWidth,
182+
},
183+
titleSlot: {
184+
flex: 1,
185+
alignItems: 'center',
186+
},
187+
iconBtn: {
188+
width: spacing.xxxl,
189+
height: spacing.xxxl,
190+
alignItems: 'center',
191+
justifyContent: 'center',
192+
},
193+
progressBar: {
194+
position: 'absolute',
195+
top: 0,
196+
left: 0,
197+
},
198+
})

0 commit comments

Comments
 (0)