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
7 changes: 6 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
"Bash(npx skills:*)",
"Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/typescript)",
"Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react)",
"Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react-native)"
"Bash(mkdir -p /Users/eduardograciano/Documents/laboratory/crypto-tracker/.claude/skills/react-native)",
"mcp__plugin_engram_engram__mem_search",
"Bash(npm install:*)",
"Bash(ls /Users/eduardograciano/Documents/laboratory/crypto-tracker/app/\\\\\\(tabs\\\\\\)/)",
"mcp__plugin_engram_engram__mem_save",
"Bash(ls /Users/eduardograciano/Documents/laboratory/crypto-tracker/app/\\\\\\(tabs\\\\\\))"
]
}
}
97 changes: 96 additions & 1 deletion .claude/skills/react-native/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ description: >
React Native and Expo conventions for this project.
Trigger: When writing React Native components, screens, styles, or Expo-specific code (.tsx).
For performance patterns (FlatList, animations, bundle size), also use react-native-best-practices.
version: "0.1.0"
---

> **Base skill — work in progress.** Add patterns here as conventions are established.
Expand Down Expand Up @@ -34,6 +33,35 @@ const styles = StyleSheet.create({
<View style={[styles.container, { opacity: isVisible ? 1 : 0 }]} />
```

## Component Definition Location (REQUIRED)

Components MUST always be defined at the top level of their file. Never define a component function inside another component's function body.

```typescript
// ✅ ALWAYS: Top-level definition in its own file, receives data via props
export function CoinRow({ coin }: CoinRowProps) {
return <View>...</View>;
}

// ❌ NEVER: Component defined inside parent
export default function HomeScreen() {
function CoinRow({ coin }: CoinRowProps) { // NO — new reference on every parent render
return <View>...</View>;
}
return <FlatList renderItem={({ item }) => <CoinRow coin={item} />} />;
}
```

**Why it matters:**
- React Compiler cannot treat an inner function as a stable component identity
- React DevTools cannot display a stable component name for debugging
- The inner component is untestable in isolation
- Preparing for bottom-up state management: state should live in or near the component that owns it, not bubble up

**Rule of thumb**: if you find yourself writing `function X()` inside `function Y()` and X returns JSX, X belongs in its own file under the nearest `components/` or `features/*/components/` folder.

**Note**: JSX *expressions* (e.g. `const listHeader = (<View>...</View>)`) are fine inside a component body — the rule applies to *component function definitions* only.

## Platform-Specific Files

Use file suffixes for platform variants instead of inline `Platform.OS` checks when the difference is substantial.
Expand Down Expand Up @@ -62,6 +90,73 @@ import { colors, spacing } from "@/constants/theme";
backgroundColor: "#1a1a2e"
```

## Component Naming — Use Base Names (REQUIRED)

Project-owned primitive components use the base name, not a prefixed name.

```typescript
// ✅ ALWAYS: import from project components
import { Text } from '@/components/text';
import { View } from '@/components/view';

// ❌ NEVER: prefixed names for your own primitives
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
```

Inside the component file itself, alias the React Native primitive to avoid the name collision:

```typescript
import { Text as RNText } from 'react-native';
import { View as RNView } from 'react-native';
```

## FlatList Pure Callbacks at Module Level (REQUIRED)

`keyExtractor` and `getItemLayout` are pure functions — they only depend on their arguments, never on component state or props. Define them at module level, not inside the component.

```typescript
// ✅ ALWAYS: module-level — no closure over component state
function keyExtractor(item: CmcCoin) {
return String(item.id);
}

function getItemLayout(_: ArrayLike<CmcCoin> | null | undefined, index: number) {
return { length: COIN_ITEM_HEIGHT, offset: COIN_ITEM_HEIGHT * index, index };
}

export default function CoinList() {
return <FlatList keyExtractor={keyExtractor} getItemLayout={getItemLayout} ... />;
}

// ❌ NEVER: inside the component — recreated on every render (even if Compiler handles it)
export default function CoinList() {
function keyExtractor(item: CmcCoin) { return String(item.id); }
return <FlatList keyExtractor={keyExtractor} ... />;
}
```

`renderItem` stays inside the component when it needs to reference other component-level functions or state. If it's also pure, move it out too.

## Percentage Heights in Scroll Containers

`height: '25%'` does NOT work inside `FlatList` / `ScrollView` content. React Native resolves percentage dimensions relative to the parent's size — scroll content has no defined height, so `25%` computes to `0`.

```typescript
// ❌ BROKEN inside ListHeaderComponent / ScrollView content
<View style={{ height: '25%' }} />

// ✅ CORRECT: get screen height from the screen and pass it down
import { useWindowDimensions } from 'react-native';
const { height } = useWindowDimensions();
<HeroSection height={height} />

// Inside HeroSection:
<View style={[styles.hero, { height: height * 0.25 }]} />
```

`height: '25%'` only works when the parent has a fixed/flex-defined height (e.g. a full-screen `View` with `flex: 1`, not a scroll container).

## Navigation (Expo Router)

```typescript
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20
20.15.0
6 changes: 4 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.anonymous.cryptotracker"
},
"android": {
"adaptiveIcon": {
Expand All @@ -19,7 +20,8 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
"predictiveBackGestureEnabled": false,
"package": "com.anonymous.cryptotracker"
},
"web": {
"output": "static",
Expand Down
8 changes: 7 additions & 1 deletion app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HomeHero } from "@/features/home/components/home-hero";
import { ListSectionHeader } from "@/features/home/components/list-section-header";
import { APP_NAME, BTC_SYMBOL, COIN_ITEM_HEIGHT } from "@/features/home/config";
import { useColorScheme } from "@/hooks/use-color-scheme";
import { useCoinLogos } from "@/hooks/use-coin-logos";
import { useCryptoListings } from "@/hooks/use-crypto-listings";
import type { CmcCoin } from "@/types/cmc";

Expand All @@ -33,10 +34,14 @@ export default function HomeScreen() {
const colorScheme = useColorScheme();
const colors = Colors[colorScheme ?? "light"];
const { data: coins, isLoading, isError } = useCryptoListings();
const ids = coins?.map((c) => c.id) ?? [];
const logoMap = useCoinLogos(ids);

const btc = coins?.find((c) => c.symbol === BTC_SYMBOL);

const renderCoin = ({ item }: { item: CmcCoin }) => <CoinRow coin={item} />;
const renderCoin = ({ item }: { item: CmcCoin }) => (
<CoinRow coin={item} logoUrl={logoMap[item.id]} />
);

const listHeader = (
<View>
Expand All @@ -48,6 +53,7 @@ export default function HomeScreen() {
isLoading={isLoading}
isError={isError}
height={height}
logoUrl={btc ? logoMap[btc.id] : undefined}
/>
<ListSectionHeader borderColor={colors.icon} />
</View>
Expand Down
23 changes: 23 additions & 0 deletions features/home/components/coin-logo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Image } from 'expo-image';
import { StyleSheet } from 'react-native';

interface CoinLogoProps {
uri?: string;
size?: number;
}

export const CoinLogo = ({ uri, size = 32 }: CoinLogoProps) => (
<Image
source={uri ? { uri } : null}
style={[styles.logo, { width: size, height: size, borderRadius: size / 2 }]}
contentFit="contain"
transition={150}
cachePolicy="disk"
/>
);

const styles = StyleSheet.create({
logo: {
backgroundColor: '#E5E7EB',
},
});
6 changes: 4 additions & 2 deletions features/home/components/coin-row.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { StyleSheet } from 'react-native';

import { View } from '@/components/view';

import { Text } from '@/components/text';
import { CoinLogo } from '@/features/home/components/coin-logo';
import { CHANGE_COLORS, COIN_ITEM_HEIGHT } from '@/features/home/config';
import { formatChange, formatPrice } from '@/features/home/utils';
import type { CmcCoin } from '@/types/cmc';

interface CoinRowProps {
coin: CmcCoin;
logoUrl?: string;
}

export function CoinRow({ coin }: CoinRowProps) {
export function CoinRow({ coin, logoUrl }: CoinRowProps) {
const change = coin.quote.USD.percent_change_24h;
const changeColor = change >= 0 ? CHANGE_COLORS.positive : CHANGE_COLORS.negative;

return (
<View style={styles.coinRow}>
<Text style={styles.rank}>#{coin.cmc_rank}</Text>
<CoinLogo uri={logoUrl} size={32} />
<View style={styles.coinInfo}>
<Text type="defaultSemiBold">{coin.name}</Text>
<Text style={styles.symbol}>{coin.symbol}</Text>
Expand Down
6 changes: 4 additions & 2 deletions features/home/components/home-hero.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { StyleSheet } from 'react-native';

import { View } from '@/components/view';

import { Text } from '@/components/text';
import { CoinLogo } from '@/features/home/components/coin-logo';
import { CHANGE_COLORS } from '@/features/home/config';
import { formatChange, formatPrice } from '@/features/home/utils';
import type { CmcCoin } from '@/types/cmc';
Expand All @@ -12,9 +12,10 @@ interface HomeHeroProps {
isLoading: boolean;
isError: boolean;
height: number;
logoUrl?: string;
}

export function HomeHero({ coin, isLoading, isError, height }: HomeHeroProps) {
export function HomeHero({ coin, isLoading, isError, height, logoUrl }: HomeHeroProps) {
if (isLoading) {
return (
<View style={[styles.hero, { height: height * 0.25 }]}>
Expand All @@ -38,6 +39,7 @@ export function HomeHero({ coin, isLoading, isError, height }: HomeHeroProps) {

return (
<View style={[styles.hero, { height: height * 0.25 }]}>
<CoinLogo uri={logoUrl} size={48} />
<Text style={styles.label}>{coin.name} ({coin.symbol})</Text>
<Text style={styles.price}>{formatPrice(coin.quote.USD.price)}</Text>
<Text style={[styles.change, { color: changeColor }]}>
Expand Down
27 changes: 27 additions & 0 deletions hooks/use-coin-logos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query';

import { fetchCoinMetadata } from '@/lib/cmc-client';
import { readLogoCache, writeLogoCache } from '@/lib/logo-cache';
import type { LogoMap } from '@/types/cmc';

export const useCoinLogos = (ids: number[]): LogoMap => {
const cached = readLogoCache();

const { data } = useQuery({
queryKey: ['crypto', 'logos'],
queryFn: async (): Promise<LogoMap> => {
const response = await fetchCoinMetadata(ids);
const map: LogoMap = {};
for (const [idStr, meta] of Object.entries(response.data)) {
map[Number(idStr)] = meta.logo;
}
writeLogoCache(map);
return map;
},
enabled: ids.length > 0 && cached === null,
staleTime: Infinity,
gcTime: Infinity,
});

return data ?? cached ?? {};
};
15 changes: 14 additions & 1 deletion lib/cmc-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CmcListingsResponse } from '@/types/cmc';
import type { CmcListingsResponse, CmcMetadataResponse } from '@/types/cmc';

const CMC_BASE_URL = 'https://pro-api.coinmarketcap.com';
const CMC_API_KEY = process.env.EXPO_PUBLIC_CMC_API_KEY;
Expand All @@ -24,3 +24,16 @@ export const fetchCryptoListings = async (limit = 100): Promise<CmcListingsRespo

return response.json() as Promise<CmcListingsResponse>;
};

export const fetchCoinMetadata = async (ids: number[]): Promise<CmcMetadataResponse> => {
const url = new URL(`${CMC_BASE_URL}/v1/cryptocurrency/info`);
url.searchParams.set('id', ids.join(','));

const response = await fetch(url.toString(), { headers: getCmcHeaders() });

if (!response.ok) {
throw new Error(`CMC metadata API error: ${response.status} ${response.statusText}`);
}

return response.json() as Promise<CmcMetadataResponse>;
};
26 changes: 26 additions & 0 deletions lib/logo-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { createMMKV } from 'react-native-mmkv';

import type { LogoMap } from '@/types/cmc';

const storage = createMMKV({ id: 'logo-cache' });

const LOGO_MAP_KEY = 'logoMap';
const TIMESTAMP_KEY = 'logoMapTimestamp';
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days

export const readLogoCache = (): LogoMap | null => {
const raw = storage.getString(LOGO_MAP_KEY);
const ts = storage.getNumber(TIMESTAMP_KEY);
if (!raw || !ts || Date.now() - ts > TTL_MS) return null;
return JSON.parse(raw) as LogoMap;
};

export const writeLogoCache = (map: LogoMap): void => {
storage.set(LOGO_MAP_KEY, JSON.stringify(map));
storage.set(TIMESTAMP_KEY, Date.now());
};

export const clearLogoCache = (): void => {
storage.remove(LOGO_MAP_KEY);
storage.remove(TIMESTAMP_KEY);
};
Loading
Loading