diff --git a/.agents/skills/vercel-react-native-skills/AGENTS.md b/.agents/skills/vercel-react-native-skills/AGENTS.md deleted file mode 100644 index d263eb9..0000000 --- a/.agents/skills/vercel-react-native-skills/AGENTS.md +++ /dev/null @@ -1,2897 +0,0 @@ -# React Native Skills - -**Version 1.0.0** -Engineering -January 2026 - -> **Note:** -> This document is mainly for agents and LLMs to follow when maintaining, -> generating, or refactoring React Native codebases. Humans -> may also find it useful, but guidance here is optimized for automation -> and consistency by AI-assisted workflows. - ---- - -## Abstract - -Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation. - ---- - -## Table of Contents - -1. [Core Rendering](#1-core-rendering) — **CRITICAL** - - 1.1 [Never Use && with Potentially Falsy Values](#11-never-use--with-potentially-falsy-values) - - 1.2 [Wrap Strings in Text Components](#12-wrap-strings-in-text-components) -2. [List Performance](#2-list-performance) — **HIGH** - - 2.1 [Avoid Inline Objects in renderItem](#21-avoid-inline-objects-in-renderitem) - - 2.2 [Hoist callbacks to the root of lists](#22-hoist-callbacks-to-the-root-of-lists) - - 2.3 [Keep List Items Lightweight](#23-keep-list-items-lightweight) - - 2.4 [Optimize List Performance with Stable Object References](#24-optimize-list-performance-with-stable-object-references) - - 2.5 [Pass Primitives to List Items for Memoization](#25-pass-primitives-to-list-items-for-memoization) - - 2.6 [Use a List Virtualizer for Any List](#26-use-a-list-virtualizer-for-any-list) - - 2.7 [Use Compressed Images in Lists](#27-use-compressed-images-in-lists) - - 2.8 [Use Item Types for Heterogeneous Lists](#28-use-item-types-for-heterogeneous-lists) -3. [Animation](#3-animation) — **HIGH** - - 3.1 [Animate Transform and Opacity Instead of Layout Properties](#31-animate-transform-and-opacity-instead-of-layout-properties) - - 3.2 [Prefer useDerivedValue Over useAnimatedReaction](#32-prefer-usederivedvalue-over-useanimatedreaction) - - 3.3 [Use GestureDetector for Animated Press States](#33-use-gesturedetector-for-animated-press-states) -4. [Scroll Performance](#4-scroll-performance) — **HIGH** - - 4.1 [Never Track Scroll Position in useState](#41-never-track-scroll-position-in-usestate) -5. [Navigation](#5-navigation) — **HIGH** - - 5.1 [Use Native Navigators for Navigation](#51-use-native-navigators-for-navigation) -6. [React State](#6-react-state) — **MEDIUM** - - 6.1 [Minimize State Variables and Derive Values](#61-minimize-state-variables-and-derive-values) - - 6.2 [Use fallback state instead of initialState](#62-use-fallback-state-instead-of-initialstate) - - 6.3 [useState Dispatch updaters for State That Depends on Current Value](#63-usestate-dispatch-updaters-for-state-that-depends-on-current-value) -7. [State Architecture](#7-state-architecture) — **MEDIUM** - - 7.1 [State Must Represent Ground Truth](#71-state-must-represent-ground-truth) -8. [React Compiler](#8-react-compiler) — **MEDIUM** - - 8.1 [Destructure Functions Early in Render (React Compiler)](#81-destructure-functions-early-in-render-react-compiler) - - 8.2 [Use .get() and .set() for Reanimated Shared Values (not .value)](#82-use-get-and-set-for-reanimated-shared-values-not-value) -9. [User Interface](#9-user-interface) — **MEDIUM** - - 9.1 [Measuring View Dimensions](#91-measuring-view-dimensions) - - 9.2 [Modern React Native Styling Patterns](#92-modern-react-native-styling-patterns) - - 9.3 [Use contentInset for Dynamic ScrollView Spacing](#93-use-contentinset-for-dynamic-scrollview-spacing) - - 9.4 [Use contentInsetAdjustmentBehavior for Safe Areas](#94-use-contentinsetadjustmentbehavior-for-safe-areas) - - 9.5 [Use expo-image for Optimized Images](#95-use-expo-image-for-optimized-images) - - 9.6 [Use Galeria for Image Galleries and Lightbox](#96-use-galeria-for-image-galleries-and-lightbox) - - 9.7 [Use Native Menus for Dropdowns and Context Menus](#97-use-native-menus-for-dropdowns-and-context-menus) - - 9.8 [Use Native Modals Over JS-Based Bottom Sheets](#98-use-native-modals-over-js-based-bottom-sheets) - - 9.9 [Use Pressable Instead of Touchable Components](#99-use-pressable-instead-of-touchable-components) -10. [Design System](#10-design-system) — **MEDIUM** - - 10.1 [Use Compound Components Over Polymorphic Children](#101-use-compound-components-over-polymorphic-children) -11. [Monorepo](#11-monorepo) — **LOW** - - 11.1 [Install Native Dependencies in App Directory](#111-install-native-dependencies-in-app-directory) - - 11.2 [Use Single Dependency Versions Across Monorepo](#112-use-single-dependency-versions-across-monorepo) -12. [Third-Party Dependencies](#12-third-party-dependencies) — **LOW** - - 12.1 [Import from Design System Folder](#121-import-from-design-system-folder) -13. [JavaScript](#13-javascript) — **LOW** - - 13.1 [Hoist Intl Formatter Creation](#131-hoist-intl-formatter-creation) -14. [Fonts](#14-fonts) — **LOW** - - 14.1 [Load fonts natively at build time](#141-load-fonts-natively-at-build-time) - ---- - -## 1. Core Rendering - -**Impact: CRITICAL** - -Fundamental React Native rendering rules. Violations cause -runtime crashes or broken UI. - -### 1.1 Never Use && with Potentially Falsy Values - -**Impact: CRITICAL (prevents production crash)** - -Never use `{value && }` when `value` could be an empty string or - -`0`. These are falsy but JSX-renderable—React Native will try to render them as - -text outside a `` component, causing a hard crash in production. - -**Incorrect: crashes if count is 0 or name is ""** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name && {name}} - {count && {count} items} - - ) -} -// If name="" or count=0, renders the falsy value → crash -``` - -**Correct: ternary with null** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {name ? {name} : null} - {count ? {count} items : null} - - ) -} -``` - -**Correct: explicit boolean coercion** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - return ( - - {!!name && {name}} - {!!count && {count} items} - - ) -} -``` - -**Best: early return** - -```tsx -function Profile({ name, count }: { name: string; count: number }) { - if (!name) return null - - return ( - - {name} - {count > 0 ? {count} items : null} - - ) -} -``` - -Early returns are clearest. When using conditionals inline, prefer ternary or - -explicit boolean checks. - -**Lint rule:** Enable `react/jsx-no-leaked-render` from - -[eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/jsx-no-leaked-render.md) - -to catch this automatically. - -### 1.2 Wrap Strings in Text Components - -**Impact: CRITICAL (prevents runtime crash)** - -Strings must be rendered inside ``. React Native crashes if a string is a - -direct child of ``. - -**Incorrect: crashes** - -```tsx -import { View } from 'react-native' - -function Greeting({ name }: { name: string }) { - return Hello, {name}! -} -// Error: Text strings must be rendered within a component. -``` - -**Correct:** - -```tsx -import { View, Text } from 'react-native' - -function Greeting({ name }: { name: string }) { - return ( - - Hello, {name}! - - ) -} -``` - ---- - -## 2. List Performance - -**Impact: HIGH** - -Optimizing virtualized lists (FlatList, LegendList, FlashList) -for smooth scrolling and fast updates. - -### 2.1 Avoid Inline Objects in renderItem - -**Impact: HIGH (prevents unnecessary re-renders of memoized list items)** - -Don't create new objects inside `renderItem` to pass as props. Inline objects - -create new references on every render, breaking memoization. Pass primitive - -values directly from `item` instead. - -**Incorrect: inline object breaks memoization** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect: inline style object** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct: pass item directly or primitives** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct: pass primitives, derive inside child** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct: hoist static styles in module scope** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when - -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization - -automatically and these manual optimizations become less critical. - -### 2.2 Hoist callbacks to the root of lists - -**Impact: MEDIUM (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the - -callback at the root of the list. Items should then call it with a unique - -identifier. - -**Incorrect: creates a new callback on each render** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct: a single function instance passed to each item** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [https://example.com](https://example.com) - -### 2.3 Keep List Items Lightweight - -**Impact: HIGH (reduces render time for visible items during scroll)** - -List items should be as inexpensive as possible to render. Minimize hooks, avoid - -queries, and limit React Context access. Virtualized lists render many items - -during scroll—expensive items cause jank. - -**Incorrect: heavy list item** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct: lightweight list item** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching - -- No expensive computations (move to parent or memoize at parent level) - -- Prefer Zustand selectors over React Context - -- Minimize useState/useEffect hooks - -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and - -return JSX. - -### 2.4 Optimize List Performance with Stable Object References - -**Impact: CRITICAL (virtualization relies on reference stability)** - -Don't map or filter data before passing to virtualized lists. Virtualization - -relies on object reference stability to know what changed—new references cause - -full re-renders of all visible items. Attempt to prevent frequent renders at the - -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect: creates new object references on every keystroke** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct: stable references, transform inside items** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Creating a new array instance can be okay, as long as its inner object - -references are stable. For instance, if you sort a list of objects: - -Even though this creates a new array instance `sortedTlds`, the inner object - -references are stable. - -**With zustand for dynamic data: avoids parent re-renders** - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible - -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent - -re-renders):** - -For components where the data is conditional based on the parent state, this - -pattern is even more important. For example, if you are checking if an item is - -favorited, toggling favorites only re-renders one component if the item itself - -is in charge of accessing the state rather than the parent: - -Note: if you're using the React Compiler, you can read React Context values - -directly within list items. Although this is slightly slower than using a - -Zustand selector in most cases, the effect may be negligible. - -### 2.5 Pass Primitives to List Items for Memoization - -**Impact: HIGH (enables effective memo() comparison)** - -When possible, pass only primitive values (strings, numbers, booleans) as props - -to list item components. Primitives enable shallow comparison in `memo()` to - -work correctly, skipping re-renders when values haven't changed. - -**Incorrect: object prop requires deep comparison** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct: primitive props enable shallow comparison** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use - -`memo()` or `useCallback()`, but the object references still apply. - -### 2.6 Use a List Virtualizer for Any List - -**Impact: HIGH (reduced memory, faster mounts)** - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with - -mapped children—even for short lists. Virtualizers only render visible items, - -reducing memory usage and mount time. ScrollView renders all children upfront, - -which gets expensive quickly. - -**Incorrect: ScrollView renders all items at once** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct: virtualizer renders only visible items** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative: FlashList** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, - -search results. Default to virtualization. - -### 2.7 Use Compressed Images in Lists - -**Impact: HIGH (faster load times, less memory)** - -Always load compressed, appropriately-sized images in lists. Full-resolution - -images consume excessive memory and cause scroll jank. Request thumbnails from - -your server or use an image CDN with resize parameters. - -**Incorrect: full-resolution images** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct: request appropriately-sized image** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, - -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). - -Request images at 2x the display size for retina screens. - -### 2.8 Use Item Types for Heterogeneous Lists - -**Impact: HIGH (efficient recycling, less layout thrashing)** - -When a list has different item layouts (messages, images, headers, etc.), use a - -`type` field on each item and provide `getItemType` to the list. This puts items - -into separate recycling pools so a message component never gets recycled into an - -image component. - -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) - -**Incorrect: single component with conditionals** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct: typed items with separate components** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -- **Recycling efficiency**: Items with the same type share a recycling pool - -- **No layout thrashing**: A header never recycles into an image cell - -- **Type safety**: TypeScript can narrow the item type in each branch - -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - - accurate estimates per type - ---- - -## 3. Animation - -**Impact: HIGH** - -GPU-accelerated animations, Reanimated patterns, and avoiding -render thrashing during gestures. - -### 3.1 Animate Transform and Opacity Instead of Layout Properties - -**Impact: HIGH (GPU-accelerated animations, no layout recalculation)** - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect: animates height, triggers layout every frame** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct: animates scaleY, GPU-accelerated** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct: animates translateY for slide animations** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. - -### 3.2 Prefer useDerivedValue Over useAnimatedReaction - -**Impact: MEDIUM (cleaner code, automatic dependency tracking)** - -When deriving a shared value from another, use `useDerivedValue` instead of - -`useAnimatedReaction`. Derived values are declarative, automatically track - -dependencies, and return a value you can use directly. Animated reactions are - -for side effects, not derivations. - -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) - -**Incorrect: useAnimatedReaction for derivation** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct: useDerivedValue** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value - -(e.g., triggering haptics, logging, calling `runOnJS`). - -### 3.3 Use GestureDetector for Animated Press States - -**Impact: MEDIUM (UI thread animations, smoother press feedback)** - -For animated press states (scale, opacity on press), use `GestureDetector` with - -`Gesture.Tap()` and shared values instead of Pressable's - -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no - -JS thread round-trip for press animations. - -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) - -**Incorrect: Pressable with JS thread callbacks** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct: GestureDetector with UI thread worklets** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. - -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions - -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - ---- - -## 4. Scroll Performance - -**Impact: HIGH** - -Tracking scroll position without causing render thrashing. - -### 4.1 Never Track Scroll Position in useState - -**Impact: HIGH (prevents render thrashing during scroll)** - -Never store scroll position in `useState`. Scroll events fire rapidly—state - -updates cause render thrashing and dropped frames. Use a Reanimated shared value - -for animations or a ref for non-reactive tracking. - -**Incorrect: useState causes jank** - -```tsx -import { useState } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const [scrollY, setScrollY] = useState(0) - - const onScroll = (e: NativeSyntheticEvent) => { - setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame - } - - return -} -``` - -**Correct: Reanimated for animations** - -```tsx -import Animated, { - useSharedValue, - useAnimatedScrollHandler, -} from 'react-native-reanimated' - -function Feed() { - const scrollY = useSharedValue(0) - - const onScroll = useAnimatedScrollHandler({ - onScroll: (e) => { - scrollY.value = e.contentOffset.y // runs on UI thread, no re-render - }, - }) - - return ( - - ) -} -``` - -**Correct: ref for non-reactive tracking** - -```tsx -import { useRef } from 'react' -import { - ScrollView, - NativeSyntheticEvent, - NativeScrollEvent, -} from 'react-native' - -function Feed() { - const scrollY = useRef(0) - - const onScroll = (e: NativeSyntheticEvent) => { - scrollY.current = e.nativeEvent.contentOffset.y // no re-render - } - - return -} -``` - ---- - -## 5. Navigation - -**Impact: HIGH** - -Using native navigators for stack and tab navigation instead of -JS-based alternatives. - -### 5.1 Use Native Navigators for Navigation - -**Impact: HIGH (native performance, platform-appropriate UI)** - -Always use native navigators instead of JS-based ones. Native navigators use - -platform APIs (UINavigationController on iOS, Fragment on Android) for better - -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default - -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native - -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) - -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) - -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) - -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) - -**Incorrect: JS stack navigator** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native stack with react-navigation** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: expo-router uses native stack by default** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -**Incorrect: JS bottom tabs** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct: native bottom tabs with react-navigation** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct: expo-router native tabs** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the - -first `ScrollView` at the root of each tab screen, so content scrolls correctly - -behind the translucent tab bar. If you need to disable this, use - -`disableAutomaticContentInsets` on the trigger. - -**Incorrect: custom header component** - -```tsx - , - }} -/> -``` - -**Correct: native header options** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper - -safe area handling automatically. - -- **Performance**: Native transitions and gestures run on the UI thread - -- **Platform behavior**: Automatic iOS large titles, Android material design - -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - - areas - -- **Accessibility**: Platform accessibility features work automatically - ---- - -## 6. React State - -**Impact: MEDIUM** - -Patterns for managing React state to avoid stale closures and -unnecessary re-renders. - -### 6.1 Minimize State Variables and Derive Values - -**Impact: MEDIUM (fewer re-renders, less state drift)** - -Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync. - -**Incorrect: redundant state** - -```tsx -function Cart({ items }: { items: Item[] }) { - const [total, setTotal] = useState(0) - const [itemCount, setItemCount] = useState(0) - - useEffect(() => { - setTotal(items.reduce((sum, item) => sum + item.price, 0)) - setItemCount(items.length) - }, [items]) - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Correct: derived values** - -```tsx -function Cart({ items }: { items: Item[] }) { - const total = items.reduce((sum, item) => sum + item.price, 0) - const itemCount = items.length - - return ( - - {itemCount} items - Total: ${total} - - ) -} -``` - -**Another example:** - -```tsx -// Incorrect: storing both firstName, lastName, AND fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const [fullName, setFullName] = useState('') - -// Correct: derive fullName -const [firstName, setFirstName] = useState('') -const [lastName, setLastName] = useState('') -const fullName = `${firstName} ${lastName}` -``` - -State should be the minimal source of truth. Everything else is derived. - -Reference: [https://react.dev/learn/choosing-the-state-structure](https://react.dev/learn/choosing-the-state-structure) - -### 6.2 Use fallback state instead of initialState - -**Impact: MEDIUM (reactive fallbacks without syncing)** - -Use `undefined` as initial state and nullish coalescing (`??`) to fall back to - -parent or server values. State represents user intent only—`undefined` means - -"user hasn't chosen yet." This enables reactive fallbacks that update when the - -source changes, not just on initial render. - -**Incorrect: syncs state, loses reactivity** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [enabled, setEnabled] = useState(defaultEnabled) - // If fallbackEnabled changes, state is stale - // State mixes user intent with default value - - return -} -``` - -**Correct: state is user intent, reactive fallback** - -```tsx -type Props = { fallbackEnabled: boolean } - -function Toggle({ fallbackEnabled }: Props) { - const [_enabled, setEnabled] = useState(undefined) - const enabled = _enabled ?? defaultEnabled - // undefined = user hasn't touched it, falls back to prop - // If defaultEnabled changes, component reflects it - // Once user interacts, their choice persists - - return -} -``` - -**With server data:** - -```tsx -function ProfileForm({ data }: { data: User }) { - const [_theme, setTheme] = useState(undefined) - const theme = _theme ?? data.theme - // Shows server value until user overrides - // Server refetch updates the fallback automatically - - return -} -``` - -### 6.3 useState Dispatch updaters for State That Depends on Current Value - -**Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)** - -When the next state depends on the current state, use a dispatch updater - -(`setState(prev => ...)`) instead of reading the state variable directly in a - -callback. This avoids stale closures and ensures you're comparing against the - -latest value. - -**Incorrect: reads state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - // size may be stale in this closure - if (size?.width !== width || size?.height !== height) { - setSize({ width, height }) - } -} -``` - -**Correct: dispatch updater** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => { - if (prev?.width === width && prev?.height === height) return prev - return { width, height } - }) -} -``` - -Returning the previous value from the updater skips the re-render. - -For primitive states, you don't need to compare values before firing a - -re-render. - -**Incorrect: unnecessary comparison for primitive state** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize((prev) => (prev === width ? prev : width)) -} -``` - -**Correct: sets primitive state directly** - -```tsx -const [size, setSize] = useState(undefined) - -const onLayout = (e: LayoutChangeEvent) => { - const { width, height } = e.nativeEvent.layout - setSize(width) -} -``` - -However, if the next state depends on the current state, you should still use a - -dispatch updater. - -**Incorrect: reads state directly from the callback** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount(count + 1) -} -``` - -**Correct: dispatch updater** - -```tsx -const [count, setCount] = useState(0) - -const onTap = () => { - setCount((prev) => prev + 1) -} -``` - ---- - -## 7. State Architecture - -**Impact: MEDIUM** - -Ground truth principles for state variables and derived values. - -### 7.1 State Must Represent Ground Truth - -**Impact: HIGH (cleaner logic, easier debugging, single source of truth)** - -State variables—both React `useState` and Reanimated shared values—should - -represent the actual state of something (e.g., `pressed`, `progress`, `isOpen`), - -not derived visual values (e.g., `scale`, `opacity`, `translateY`). Derive - -visual values from state using computation or interpolation. - -**Incorrect: storing the visual output** - -```tsx -const scale = useSharedValue(1) - -const tap = Gesture.Tap() - .onBegin(() => { - scale.set(withTiming(0.95)) - }) - .onFinalize(() => { - scale.set(withTiming(1)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.get() }], -})) -``` - -**Correct: storing the state, deriving the visual** - -```tsx -const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed - -const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - -const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }], -})) -``` - -**Why this matters:** - -State variables should represent real "state", not necessarily a desired end - -result. - -1. **Single source of truth** — The state (`pressed`) describes what's - - happening; visuals are derived - -2. **Easier to extend** — Adding opacity, rotation, or other effects just - - requires more interpolations from the same state - -3. **Debugging** — Inspecting `pressed = 1` is clearer than `scale = 0.95` - -4. **Reusable logic** — The same `pressed` value can drive multiple visual - - properties - -**Same principle for React state:** - -```tsx -// Incorrect: storing derived values -const [isExpanded, setIsExpanded] = useState(false) -const [height, setHeight] = useState(0) - -useEffect(() => { - setHeight(isExpanded ? 200 : 0) -}, [isExpanded]) - -// Correct: derive from state -const [isExpanded, setIsExpanded] = useState(false) -const height = isExpanded ? 200 : 0 -``` - -State is the minimal truth. Everything else is derived. - ---- - -## 8. React Compiler - -**Impact: MEDIUM** - -Compatibility patterns for React Compiler with React Native and -Reanimated. - -### 8.1 Destructure Functions Early in Render (React Compiler) - -**Impact: HIGH (stable references, fewer re-renders)** - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into - -objects to call functions. Destructured functions are stable references; dotting - -creates new references and breaks memoization. - -**Incorrect: dotting into object** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct: destructure early** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` - -### 8.2 Use .get() and .set() for Reanimated Shared Values (not .value) - -**Impact: LOW (required for React Compiler compatibility)** - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or - -writing `.value` directly on Reanimated shared values. The compiler can't track - -property access—explicit methods ensure correct behavior. - -**Incorrect: breaks with React Compiler** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return - -``` - -**Correct: compound components** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` - ---- - -## 11. Monorepo - -**Impact: LOW** - -Dependency management and native module configuration in -monorepos. - -### 11.1 Install Native Dependencies in App Directory - -**Impact: CRITICAL (required for autolinking to work)** - -In a monorepo, packages with native code must be installed in the native app's - -directory directly. Autolinking only scans the app's `node_modules`—it won't - -find native dependencies installed in other packages. - -**Incorrect: native dep in shared package only** - -```typescript -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct: native dep in app directory** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it - -for autolinking to detect and link the native code. - -### 11.2 Use Single Dependency Versions Across Monorepo - -**Impact: MEDIUM (avoids duplicate bundles, version conflicts)** - -Use a single version of each dependency across all packages in your monorepo. - -Prefer exact versions over ranges. Multiple versions cause duplicate code in - -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions - -or npm overrides. - -**Incorrect: version ranges, multiple versions** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct: exact versions, single source of truth** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at - -the root. When adding dependencies, specify exact versions without `^` or `~`. - ---- - -## 12. Third-Party Dependencies - -**Impact: LOW** - -Wrapping and re-exporting third-party dependencies for -maintainability. - -### 12.1 Import from Design System Folder - -**Impact: LOW (enables global changes and easy refactoring)** - -Re-export dependencies from a design system folder. App code imports from there, - -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect: imports directly from package** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct: imports from design system** - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. - ---- - -## 13. JavaScript - -**Impact: LOW** - -Micro-optimizations like hoisting expensive object creation. - -### 13.1 Hoist Intl Formatter Creation - -**Impact: LOW-MEDIUM (avoids expensive object recreation)** - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or - -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to - -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect: new formatter every render** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct: hoisted to module scope** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain - -objects—each instantiation parses locale data and builds internal lookup tables. - ---- - -## 14. Fonts - -**Impact: LOW** - -Native font loading for improved performance. - -### 14.1 Load fonts natively at build time - -**Impact: LOW (fonts available at launch, no async loading)** - -Use the `expo-font` config plugin to embed fonts at build time instead of - -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) - -**Incorrect: async font loading** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct: config plugin, fonts embedded at build** - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the - -native app. - ---- - -## References - -1. [https://react.dev](https://react.dev) -2. [https://reactnative.dev](https://reactnative.dev) -3. [https://docs.swmansion.com/react-native-reanimated](https://docs.swmansion.com/react-native-reanimated) -4. [https://docs.swmansion.com/react-native-gesture-handler](https://docs.swmansion.com/react-native-gesture-handler) -5. [https://docs.expo.dev](https://docs.expo.dev) -6. [https://legendapp.com/open-source/legend-list](https://legendapp.com/open-source/legend-list) -7. [https://github.com/nandorojo/galeria](https://github.com/nandorojo/galeria) -8. [https://zeego.dev](https://zeego.dev) diff --git a/.agents/skills/vercel-react-native-skills/README.md b/.agents/skills/vercel-react-native-skills/README.md deleted file mode 100644 index 854db9f..0000000 --- a/.agents/skills/vercel-react-native-skills/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# React Native Guidelines - -A structured repository for creating and maintaining React Native Best Practices -optimized for agents and LLMs. - -## Structure - -- `rules/` - Individual rule files (one per rule) - - `_sections.md` - Section metadata (titles, impacts, descriptions) - - `_template.md` - Template for creating new rules - - `area-description.md` - Individual rule files -- `metadata.json` - Document metadata (version, organization, abstract) -- **`AGENTS.md`** - Compiled output (generated) - -## Rules - -### Core Rendering (CRITICAL) - -- `rendering-text-in-text-component.md` - Wrap strings in Text components -- `rendering-no-falsy-and.md` - Avoid falsy && operator in JSX - -### List Performance (HIGH) - -- `list-performance-virtualize.md` - Use virtualized lists (LegendList, - FlashList) -- `list-performance-function-references.md` - Keep stable object references -- `list-performance-callbacks.md` - Hoist callbacks to list root -- `list-performance-inline-objects.md` - Avoid inline objects in renderItem -- `list-performance-item-memo.md` - Pass primitives for memoization -- `list-performance-item-expensive.md` - Keep list items lightweight -- `list-performance-images.md` - Use compressed images in lists -- `list-performance-item-types.md` - Use item types for heterogeneous lists - -### Animation (HIGH) - -- `animation-gpu-properties.md` - Animate transform/opacity instead of layout -- `animation-gesture-detector-press.md` - Use GestureDetector for press - animations -- `animation-derived-value.md` - Prefer useDerivedValue over useAnimatedReaction - -### Scroll Performance (HIGH) - -- `scroll-position-no-state.md` - Never track scroll in useState - -### Navigation (HIGH) - -- `navigation-native-navigators.md` - Use native stack and native tabs - -### React State (MEDIUM) - -- `react-state-dispatcher.md` - Use functional setState updates -- `react-state-fallback.md` - State should represent user intent only -- `react-state-minimize.md` - Minimize state variables, derive values - -### State Architecture (MEDIUM) - -- `state-ground-truth.md` - State must represent ground truth - -### React Compiler (MEDIUM) - -- `react-compiler-destructure-functions.md` - Destructure functions early -- `react-compiler-reanimated-shared-values.md` - Use .get()/.set() for shared - values - -### User Interface (MEDIUM) - -- `ui-expo-image.md` - Use expo-image for optimized images -- `ui-image-gallery.md` - Use Galeria for lightbox/galleries -- `ui-menus.md` - Native dropdown and context menus with Zeego -- `ui-native-modals.md` - Use native Modal with formSheet -- `ui-pressable.md` - Use Pressable instead of TouchableOpacity -- `ui-measure-views.md` - Measuring view dimensions -- `ui-safe-area-scroll.md` - Use contentInsetAdjustmentBehavior -- `ui-scrollview-content-inset.md` - Use contentInset for dynamic spacing -- `ui-styling.md` - Modern styling patterns (gap, boxShadow, gradients) - -### Design System (MEDIUM) - -- `design-system-compound-components.md` - Use compound components - -### Monorepo (LOW) - -- `monorepo-native-deps-in-app.md` - Install native deps in app directory -- `monorepo-single-dependency-versions.md` - Single dependency versions - -### Third-Party Dependencies (LOW) - -- `imports-design-system-folder.md` - Import from design system folder - -### JavaScript (LOW) - -- `js-hoist-intl.md` - Hoist Intl formatter creation - -### Fonts (LOW) - -- `fonts-config-plugin.md` - Load fonts natively at build time - -## Creating a New Rule - -1. Copy `rules/_template.md` to `rules/area-description.md` -2. Choose the appropriate area prefix: - - `rendering-` for Core Rendering - - `list-performance-` for List Performance - - `animation-` for Animation - - `scroll-` for Scroll Performance - - `navigation-` for Navigation - - `react-state-` for React State - - `state-` for State Architecture - - `react-compiler-` for React Compiler - - `ui-` for User Interface - - `design-system-` for Design System - - `monorepo-` for Monorepo - - `imports-` for Third-Party Dependencies - - `js-` for JavaScript - - `fonts-` for Fonts -3. Fill in the frontmatter and content -4. Ensure you have clear examples with explanations - -## Rule File Structure - -Each rule file should follow this structure: - -````markdown ---- -title: Rule Title Here -impact: MEDIUM -impactDescription: Optional description -tags: tag1, tag2, tag3 ---- - -## Rule Title Here - -Brief explanation of the rule and why it matters. - -**Incorrect (description of what's wrong):** - -```tsx -// Bad code example -``` -```` - -**Correct (description of what's right):** - -```tsx -// Good code example -``` - -Reference: [Link](https://example.com) - -``` - -## File Naming Convention - -- Files starting with `_` are special (excluded from build) -- Rule files: `area-description.md` (e.g., `animation-gpu-properties.md`) -- Section is automatically inferred from filename prefix -- Rules are sorted alphabetically by title within each section - -## Impact Levels - -- `CRITICAL` - Highest priority, causes crashes or broken UI -- `HIGH` - Significant performance improvements -- `MEDIUM` - Moderate performance improvements -- `LOW` - Incremental improvements -``` diff --git a/.agents/skills/vercel-react-native-skills/SKILL.md b/.agents/skills/vercel-react-native-skills/SKILL.md deleted file mode 100644 index 7340186..0000000 --- a/.agents/skills/vercel-react-native-skills/SKILL.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -name: vercel-react-native-skills -description: - React Native and Expo best practices for building performant mobile apps. Use - when building React Native components, optimizing list performance, - implementing animations, or working with native modules. Triggers on tasks - involving React Native, Expo, mobile performance, or native platform APIs. -license: MIT -metadata: - author: vercel - version: '1.0.0' ---- - -# React Native Skills - -Comprehensive best practices for React Native and Expo applications. Contains -rules across multiple categories covering performance, animations, UI patterns, -and platform-specific optimizations. - -## When to Apply - -Reference these guidelines when: - -- Building React Native or Expo apps -- Optimizing list and scroll performance -- Implementing animations with Reanimated -- Working with images and media -- Configuring native modules or fonts -- Structuring monorepo projects with native dependencies - -## Rule Categories by Priority - -| Priority | Category | Impact | Prefix | -| -------- | ---------------- | -------- | -------------------- | -| 1 | List Performance | CRITICAL | `list-performance-` | -| 2 | Animation | HIGH | `animation-` | -| 3 | Navigation | HIGH | `navigation-` | -| 4 | UI Patterns | HIGH | `ui-` | -| 5 | State Management | MEDIUM | `react-state-` | -| 6 | Rendering | MEDIUM | `rendering-` | -| 7 | Monorepo | MEDIUM | `monorepo-` | -| 8 | Configuration | LOW | `fonts-`, `imports-` | - -## Quick Reference - -### 1. List Performance (CRITICAL) - -- `list-performance-virtualize` - Use FlashList for large lists -- `list-performance-item-memo` - Memoize list item components -- `list-performance-callbacks` - Stabilize callback references -- `list-performance-inline-objects` - Avoid inline style objects -- `list-performance-function-references` - Extract functions outside render -- `list-performance-images` - Optimize images in lists -- `list-performance-item-expensive` - Move expensive work outside items -- `list-performance-item-types` - Use item types for heterogeneous lists - -### 2. Animation (HIGH) - -- `animation-gpu-properties` - Animate only transform and opacity -- `animation-derived-value` - Use useDerivedValue for computed animations -- `animation-gesture-detector-press` - Use Gesture.Tap instead of Pressable - -### 3. Navigation (HIGH) - -- `navigation-native-navigators` - Use native stack and native tabs over JS navigators - -### 4. UI Patterns (HIGH) - -- `ui-expo-image` - Use expo-image for all images -- `ui-image-gallery` - Use Galeria for image lightboxes -- `ui-pressable` - Use Pressable over TouchableOpacity -- `ui-safe-area-scroll` - Handle safe areas in ScrollViews -- `ui-scrollview-content-inset` - Use contentInset for headers -- `ui-menus` - Use native context menus -- `ui-native-modals` - Use native modals when possible -- `ui-measure-views` - Use onLayout, not measure() -- `ui-styling` - Use StyleSheet.create or Nativewind - -### 5. State Management (MEDIUM) - -- `react-state-minimize` - Minimize state subscriptions -- `react-state-dispatcher` - Use dispatcher pattern for callbacks -- `react-state-fallback` - Show fallback on first render -- `react-compiler-destructure-functions` - Destructure for React Compiler -- `react-compiler-reanimated-shared-values` - Handle shared values with compiler - -### 6. Rendering (MEDIUM) - -- `rendering-text-in-text-component` - Wrap text in Text components -- `rendering-no-falsy-and` - Avoid falsy && for conditional rendering - -### 7. Monorepo (MEDIUM) - -- `monorepo-native-deps-in-app` - Keep native dependencies in app package -- `monorepo-single-dependency-versions` - Use single versions across packages - -### 8. Configuration (LOW) - -- `fonts-config-plugin` - Use config plugins for custom fonts -- `imports-design-system-folder` - Organize design system imports -- `js-hoist-intl` - Hoist Intl object creation - -## How to Use - -Read individual rule files for detailed explanations and code examples: - -``` -rules/list-performance-virtualize.md -rules/animation-gpu-properties.md -``` - -Each rule file contains: - -- Brief explanation of why it matters -- Incorrect code example with explanation -- Correct code example with explanation -- Additional context and references - -## Full Compiled Document - -For the complete guide with all rules expanded: `AGENTS.md` diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md b/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md deleted file mode 100644 index 310928a..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-derived-value.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Prefer useDerivedValue Over useAnimatedReaction -impact: MEDIUM -impactDescription: cleaner code, automatic dependency tracking -tags: animation, reanimated, derived-value ---- - -## Prefer useDerivedValue Over useAnimatedReaction - -When deriving a shared value from another, use `useDerivedValue` instead of -`useAnimatedReaction`. Derived values are declarative, automatically track -dependencies, and return a value you can use directly. Animated reactions are -for side effects, not derivations. - -**Incorrect (useAnimatedReaction for derivation):** - -```tsx -import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - const opacity = useSharedValue(1) - - useAnimatedReaction( - () => progress.value, - (current) => { - opacity.value = 1 - current - } - ) - - // ... -} -``` - -**Correct (useDerivedValue):** - -```tsx -import { useSharedValue, useDerivedValue } from 'react-native-reanimated' - -function MyComponent() { - const progress = useSharedValue(0) - - const opacity = useDerivedValue(() => 1 - progress.get()) - - // ... -} -``` - -Use `useAnimatedReaction` only for side effects that don't produce a value -(e.g., triggering haptics, logging, calling `runOnJS`). - -Reference: -[Reanimated useDerivedValue](https://docs.swmansion.com/react-native-reanimated/docs/core/useDerivedValue) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md b/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md deleted file mode 100644 index 87c6782..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gesture-detector-press.md +++ /dev/null @@ -1,95 +0,0 @@ ---- -title: Use GestureDetector for Animated Press States -impact: MEDIUM -impactDescription: UI thread animations, smoother press feedback -tags: animation, gestures, press, reanimated ---- - -## Use GestureDetector for Animated Press States - -For animated press states (scale, opacity on press), use `GestureDetector` with -`Gesture.Tap()` and shared values instead of Pressable's -`onPressIn`/`onPressOut`. Gesture callbacks run on the UI thread as worklets—no -JS thread round-trip for press animations. - -**Incorrect (Pressable with JS thread callbacks):** - -```tsx -import { Pressable } from 'react-native' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - const scale = useSharedValue(1) - - const animatedStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - })) - - return ( - (scale.value = withTiming(0.95))} - onPressOut={() => (scale.value = withTiming(1))} - > - - Press me - - - ) -} -``` - -**Correct (GestureDetector with UI thread worklets):** - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler' -import Animated, { - useSharedValue, - useAnimatedStyle, - withTiming, - interpolate, - runOnJS, -} from 'react-native-reanimated' - -function AnimatedButton({ onPress }: { onPress: () => void }) { - // Store the press STATE (0 = not pressed, 1 = pressed) - const pressed = useSharedValue(0) - - const tap = Gesture.Tap() - .onBegin(() => { - pressed.set(withTiming(1)) - }) - .onFinalize(() => { - pressed.set(withTiming(0)) - }) - .onEnd(() => { - runOnJS(onPress)() - }) - - // Derive visual values from the state - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) }, - ], - })) - - return ( - - - Press me - - - ) -} -``` - -Store the press **state** (0 or 1), then derive the scale via `interpolate`. -This keeps the shared value as ground truth. Use `runOnJS` to call JS functions -from worklets. Use `.set()` and `.get()` for React Compiler compatibility. - -Reference: -[Gesture Handler Tap Gesture](https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/tap-gesture) diff --git a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md b/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md deleted file mode 100644 index 5fda095..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/animation-gpu-properties.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Animate Transform and Opacity Instead of Layout Properties -impact: HIGH -impactDescription: GPU-accelerated animations, no layout recalculation -tags: animation, performance, reanimated, transform, opacity ---- - -## Animate Transform and Opacity Instead of Layout Properties - -Avoid animating `width`, `height`, `top`, `left`, `margin`, or `padding`. These trigger layout recalculation on every frame. Instead, use `transform` (scale, translate) and `opacity` which run on the GPU without triggering layout. - -**Incorrect (animates height, triggers layout every frame):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - height: withTiming(expanded ? 200 : 0), // triggers layout on every frame - overflow: 'hidden', - })) - - return {children} -} -``` - -**Correct (animates scaleY, GPU-accelerated):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function CollapsiblePanel({ expanded }: { expanded: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { scaleY: withTiming(expanded ? 1 : 0) }, - ], - opacity: withTiming(expanded ? 1 : 0), - })) - - return ( - - {children} - - ) -} -``` - -**Correct (animates translateY for slide animations):** - -```tsx -import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' - -function SlideIn({ visible }: { visible: boolean }) { - const animatedStyle = useAnimatedStyle(() => ({ - transform: [ - { translateY: withTiming(visible ? 0 : 100) }, - ], - opacity: withTiming(visible ? 1 : 0), - })) - - return {children} -} -``` - -GPU-accelerated properties: `transform` (translate, scale, rotate), `opacity`. Everything else triggers layout. diff --git a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md b/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md deleted file mode 100644 index d8239ee..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/design-system-compound-components.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -title: Use Compound Components Over Polymorphic Children -impact: MEDIUM -impactDescription: flexible composition, clearer API -tags: design-system, components, composition ---- - -## Use Compound Components Over Polymorphic Children - -Don't create components that can accept a string if they aren't a text node. If -a component can receive a string child, it must be a dedicated `*Text` -component. For components like buttons, which can have both a View (or -Pressable) together with text, use compound components, such a `Button`, -`ButtonText`, and `ButtonIcon`. - -**Incorrect (polymorphic children):** - -```tsx -import { Pressable, Text } from 'react-native' - -type ButtonProps = { - children: string | React.ReactNode - icon?: React.ReactNode -} - -function Button({ children, icon }: ButtonProps) { - return ( - - {icon} - {typeof children === 'string' ? {children} : children} - - ) -} - -// Usage is ambiguous - - -``` - -**Correct (compound components):** - -```tsx -import { Pressable, Text } from 'react-native' - -function Button({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonText({ children }: { children: React.ReactNode }) { - return {children} -} - -function ButtonIcon({ children }: { children: React.ReactNode }) { - return <>{children} -} - -// Usage is explicit and composable - - - -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md b/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md deleted file mode 100644 index 39aa014..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/fonts-config-plugin.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Load fonts natively at build time -impact: LOW -impactDescription: fonts available at launch, no async loading -tags: fonts, expo, performance, config-plugin ---- - -## Use Expo Config Plugin for Font Loading - -Use the `expo-font` config plugin to embed fonts at build time instead of -`useFonts` or `Font.loadAsync`. Embedded fonts are more efficient. - -**Incorrect (async font loading):** - -```tsx -import { useFonts } from 'expo-font' -import { Text, View } from 'react-native' - -function App() { - const [fontsLoaded] = useFonts({ - 'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'), - }) - - if (!fontsLoaded) { - return null - } - - return ( - - Hello - - ) -} -``` - -**Correct (config plugin, fonts embedded at build):** - -```json -// app.json -{ - "expo": { - "plugins": [ - [ - "expo-font", - { - "fonts": ["./assets/fonts/Geist-Bold.otf"] - } - ] - ] - } -} -``` - -```tsx -import { Text, View } from 'react-native' - -function App() { - // No loading state needed—font is already available - return ( - - Hello - - ) -} -``` - -After adding fonts to the config plugin, run `npx expo prebuild` and rebuild the -native app. - -Reference: -[Expo Font Documentation](https://docs.expo.dev/versions/latest/sdk/font/) diff --git a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md b/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md deleted file mode 100644 index 8466dcb..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/imports-design-system-folder.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -title: Import from Design System Folder -impact: LOW -impactDescription: enables global changes and easy refactoring -tags: imports, architecture, design-system ---- - -## Import from Design System Folder - -Re-export dependencies from a design system folder. App code imports from there, -not directly from packages. This enables global changes and easy refactoring. - -**Incorrect (imports directly from package):** - -```tsx -import { View, Text } from 'react-native' -import { Button } from '@ui/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -**Correct (imports from design system):** - -```tsx -// components/view.tsx -import { View as RNView } from 'react-native' - -// ideal: pick the props you will actually use to control implementation -export function View( - props: Pick, 'style' | 'children'> -) { - return -} -``` - -```tsx -// components/text.tsx -export { Text } from 'react-native' -``` - -```tsx -// components/button.tsx -export { Button } from '@ui/button' -``` - -```tsx -import { View } from '@/components/view' -import { Text } from '@/components/text' -import { Button } from '@/components/button' - -function Profile() { - return ( - - Hello - - - ) -} -``` - -Start by simply re-exporting. Customize later without changing app code. diff --git a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md b/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md deleted file mode 100644 index 9af1c35..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/js-hoist-intl.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -title: Hoist Intl Formatter Creation -impact: LOW-MEDIUM -impactDescription: avoids expensive object recreation -tags: javascript, intl, optimization, memoization ---- - -## Hoist Intl Formatter Creation - -Don't create `Intl.DateTimeFormat`, `Intl.NumberFormat`, or -`Intl.RelativeTimeFormat` inside render or loops. These are expensive to -instantiate. Hoist to module scope when the locale/options are static. - -**Incorrect (new formatter every render):** - -```tsx -function Price({ amount }: { amount: number }) { - const formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }) - return {formatter.format(amount)} -} -``` - -**Correct (hoisted to module scope):** - -```tsx -const currencyFormatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', -}) - -function Price({ amount }: { amount: number }) { - return {currencyFormatter.format(amount)} -} -``` - -**For dynamic locales, memoize:** - -```tsx -const dateFormatter = useMemo( - () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }), - [locale] -) -``` - -**Common formatters to hoist:** - -```tsx -// Module-level formatters -const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }) -const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }) -const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' }) -const relativeFormatter = new Intl.RelativeTimeFormat('en-US', { - numeric: 'auto', -}) -``` - -Creating `Intl` objects is significantly more expensive than `RegExp` or plain -objects—each instantiation parses locale data and builds internal lookup tables. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md deleted file mode 100644 index a0b3913..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-callbacks.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -title: Hoist callbacks to the root of lists -impact: MEDIUM -impactDescription: Fewer re-renders and faster lists -tags: tag1, tag2 ---- - -## List performance callbacks - -**Impact: HIGH (Fewer re-renders and faster lists)** - -When passing callback functions to list items, create a single instance of the -callback at the root of the list. Items should then call it with a unique -identifier. - -**Incorrect (creates a new callback on each render):** - -```typescript -return ( - { - // bad: creates a new callback on each render - const onPress = () => handlePress(item.id) - return - }} - /> -) -``` - -**Correct (a single function instance passed to each item):** - -```typescript -const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id]) - -return ( - ( - - )} - /> -) -``` - -Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md deleted file mode 100644 index 9721929..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-function-references.md +++ /dev/null @@ -1,132 +0,0 @@ ---- -title: Optimize List Performance with Stable Object References -impact: CRITICAL -impactDescription: virtualization relies on reference stability -tags: lists, performance, flatlist, virtualization ---- - -## Optimize List Performance with Stable Object References - -Don't map or filter data before passing to virtualized lists. Virtualization -relies on object reference stability to know what changed—new references cause -full re-renders of all visible items. Attempt to prevent frequent renders at the -list-parent level. - -Where needed, use context selectors within list items. - -**Incorrect (creates new object references on every keystroke):** - -```tsx -function DomainSearch() { - const { keyword, setKeyword } = useKeywordZustandState() - const { data: tlds } = useTlds() - - // Bad: creates new objects on every render, reparenting the entire list on every keystroke - const domains = tlds.map((tld) => ({ - domain: `${keyword}.${tld.name}`, - tld: tld.name, - price: tld.price, - })) - - return ( - <> - - } - /> - - ) -} -``` - -**Correct (stable references, transform inside items):** - -```tsx -const renderItem = ({ item }) => - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // good: transform within items, and don't pass the dynamic data as a prop - // good: use a selector function from zustand to receive a stable string back - const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name) - return {domain} -} -``` - -**Updating parent array reference:** - -Creating a new array instance can be okay, as long as its inner object -references are stable. For instance, if you sort a list of objects: - -```tsx -// good: creates a new array instance without mutating the inner objects -// good: parent array reference is unaffected by typing and updating "keyword" -const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name)) - -return -``` - -Even though this creates a new array instance `sortedTlds`, the inner object -references are stable. - -**With zustand for dynamic data (avoids parent re-renders):** - -```tsx -const useSearchStore = create<{ keyword: string }>(() => ({ keyword: '' })) - -function DomainSearch() { - const { data: tlds } = useTlds() - - return ( - <> - - } - /> - - ) -} - -function DomainItem({ tld }: { tld: Tld }) { - // Select only what you need—component only re-renders when keyword changes - const keyword = useSearchStore((s) => s.keyword) - const domain = `${keyword}.${tld.name}` - return {domain} -} -``` - -Virtualization can now skip items that haven't changed when typing. Only visible -items (~20) re-render on keystroke, rather than the parent. - -**Deriving state within list items based on parent data (avoids parent -re-renders):** - -For components where the data is conditional based on the parent state, this -pattern is even more important. For example, if you are checking if an item is -favorited, toggling favorites only re-renders one component if the item itself -is in charge of accessing the state rather than the parent: - -```tsx -function DomainItemFavoriteButton({ tld }: { tld: Tld }) { - const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id)) - return -} -``` - -Note: if you're using the React Compiler, you can read React Context values -directly within list items. Although this is slightly slower than using a -Zustand selector in most cases, the effect may be negligible. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md deleted file mode 100644 index 75a3baf..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-images.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Use Compressed Images in Lists -impact: HIGH -impactDescription: faster load times, less memory -tags: lists, images, performance, optimization ---- - -## Use Compressed Images in Lists - -Always load compressed, appropriately-sized images in lists. Full-resolution -images consume excessive memory and cause scroll jank. Request thumbnails from -your server or use an image CDN with resize parameters. - -**Incorrect (full-resolution images):** - -```tsx -function ProductItem({ product }: { product: Product }) { - return ( - - {/* 4000x3000 image loaded for a 100x100 thumbnail */} - - {product.name} - - ) -} -``` - -**Correct (request appropriately-sized image):** - -```tsx -function ProductItem({ product }: { product: Product }) { - // Request a 200x200 image (2x for retina) - const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover` - - return ( - - - {product.name} - - ) -} -``` - -Use an optimized image component with built-in caching and placeholder support, -such as `expo-image` or `SolitoImage` (which uses `expo-image` under the hood). -Request images at 2x the display size for retina screens. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md deleted file mode 100644 index d5b6514..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-inline-objects.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Avoid Inline Objects in renderItem -impact: HIGH -impactDescription: prevents unnecessary re-renders of memoized list items -tags: lists, performance, flatlist, virtualization, memo ---- - -## Avoid Inline Objects in renderItem - -Don't create new objects inside `renderItem` to pass as props. Inline objects -create new references on every render, breaking memoization. Pass primitive -values directly from `item` instead. - -**Incorrect (inline object breaks memoization):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - - )} - /> - ) -} -``` - -**Incorrect (inline style object):** - -```tsx -renderItem={({ item }) => ( - -)} -``` - -**Correct (pass item directly or primitives):** - -```tsx -function UserList({ users }: { users: User[] }) { - return ( - ( - // Good: pass the item directly - - )} - /> - ) -} -``` - -**Correct (pass primitives, derive inside child):** - -```tsx -renderItem={({ item }) => ( - -)} - -const UserRow = memo(function UserRow({ id, name, isActive }: Props) { - // Good: derive style inside memoized component - const backgroundColor = isActive ? 'green' : 'gray' - return {/* ... */} -}) -``` - -**Correct (hoist static styles in module scope):** - -```tsx -const activeStyle = { backgroundColor: 'green' } -const inactiveStyle = { backgroundColor: 'gray' } - -renderItem={({ item }) => ( - -)} -``` - -Passing primitives or stable references allows `memo()` to skip re-renders when -the actual values haven't changed. - -**Note:** If you have the React Compiler enabled, it handles memoization -automatically and these manual optimizations become less critical. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md deleted file mode 100644 index f617a76..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-expensive.md +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: Keep List Items Lightweight -impact: HIGH -impactDescription: reduces render time for visible items during scroll -tags: lists, performance, virtualization, hooks ---- - -## Keep List Items Lightweight - -List items should be as inexpensive as possible to render. Minimize hooks, avoid -queries, and limit React Context access. Virtualized lists render many items -during scroll—expensive items cause jank. - -**Incorrect (heavy list item):** - -```tsx -function ProductRow({ id }: { id: string }) { - // Bad: query inside list item - const { data: product } = useQuery(['product', id], () => fetchProduct(id)) - // Bad: multiple context accesses - const theme = useContext(ThemeContext) - const user = useContext(UserContext) - const cart = useContext(CartContext) - // Bad: expensive computation - const recommendations = useMemo( - () => computeRecommendations(product), - [product] - ) - - return {/* ... */} -} -``` - -**Correct (lightweight list item):** - -```tsx -function ProductRow({ name, price, imageUrl }: Props) { - // Good: receives only primitives, minimal hooks - return ( - - - {name} - {price} - - ) -} -``` - -**Move data fetching to parent:** - -```tsx -// Parent fetches all data once -function ProductList() { - const { data: products } = useQuery(['products'], fetchProducts) - - return ( - ( - - )} - /> - ) -} -``` - -**For shared values, use Zustand selectors instead of Context:** - -```tsx -// Incorrect: Context causes re-render when any cart value changes -function ProductRow({ id, name }: Props) { - const { items } = useContext(CartContext) - const inCart = items.includes(id) - // ... -} - -// Correct: Zustand selector only re-renders when this specific value changes -function ProductRow({ id, name }: Props) { - // use Set.has (created once at the root) instead of Array.includes() - const inCart = useCartStore((s) => s.items.has(id)) - // ... -} -``` - -**Guidelines for list items:** - -- No queries or data fetching -- No expensive computations (move to parent or memoize at parent level) -- Prefer Zustand selectors over React Context -- Minimize useState/useEffect hooks -- Pass pre-computed values as props - -The goal: list items should be simple rendering functions that take props and -return JSX. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md deleted file mode 100644 index 634935e..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-memo.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Pass Primitives to List Items for Memoization -impact: HIGH -impactDescription: enables effective memo() comparison -tags: lists, performance, memo, primitives ---- - -## Pass Primitives to List Items for Memoization - -When possible, pass only primitive values (strings, numbers, booleans) as props -to list item components. Primitives enable shallow comparison in `memo()` to -work correctly, skipping re-renders when values haven't changed. - -**Incorrect (object prop requires deep comparison):** - -```tsx -type User = { id: string; name: string; email: string; avatar: string } - -const UserRow = memo(function UserRow({ user }: { user: User }) { - // memo() compares user by reference, not value - // If parent creates new user object, this re-renders even if data is same - return {user.name} -}) - -renderItem={({ item }) => } -``` - -This can still be optimized, but it is harder to memoize properly. - -**Correct (primitive props enable shallow comparison):** - -```tsx -const UserRow = memo(function UserRow({ - id, - name, - email, -}: { - id: string - name: string - email: string -}) { - // memo() compares each primitive directly - // Re-renders only if id, name, or email actually changed - return {name} -}) - -renderItem={({ item }) => ( - -)} -``` - -**Pass only what you need:** - -```tsx -// Incorrect: passing entire item when you only need name - - -// Correct: pass only the fields the component uses - -``` - -**For callbacks, hoist or use item ID:** - -```tsx -// Incorrect: inline function creates new reference - handlePress(item.id)} /> - -// Correct: pass ID, handle in child - - -const UserRow = memo(function UserRow({ id, name }: Props) { - const handlePress = useCallback(() => { - // use id here - }, [id]) - return {name} -}) -``` - -Primitive props make memoization predictable and effective. - -**Note:** If you have the React Compiler enabled, you do not need to use -`memo()` or `useCallback()`, but the object references still apply. diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md deleted file mode 100644 index 1027e4e..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-item-types.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Use Item Types for Heterogeneous Lists -impact: HIGH -impactDescription: efficient recycling, less layout thrashing -tags: list, performance, recycling, heterogeneous, LegendList ---- - -## Use Item Types for Heterogeneous Lists - -When a list has different item layouts (messages, images, headers, etc.), use a -`type` field on each item and provide `getItemType` to the list. This puts items -into separate recycling pools so a message component never gets recycled into an -image component. - -**Incorrect (single component with conditionals):** - -```tsx -type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean } - -function ListItem({ item }: { item: Item }) { - if (item.isHeader) { - return - } - if (item.imageUrl) { - return - } - return -} - -function Feed({ items }: { items: Item[] }) { - return ( - } - recycleItems - /> - ) -} -``` - -**Correct (typed items with separate components):** - -```tsx -type HeaderItem = { id: string; type: 'header'; title: string } -type MessageItem = { id: string; type: 'message'; text: string } -type ImageItem = { id: string; type: 'image'; url: string } -type FeedItem = HeaderItem | MessageItem | ImageItem - -function Feed({ items }: { items: FeedItem[] }) { - return ( - item.id} - getItemType={(item) => item.type} - renderItem={({ item }) => { - switch (item.type) { - case 'header': - return - case 'message': - return - case 'image': - return - } - }} - recycleItems - /> - ) -} -``` - -**Why this matters:** - -- **Recycling efficiency**: Items with the same type share a recycling pool -- **No layout thrashing**: A header never recycles into an image cell -- **Type safety**: TypeScript can narrow the item type in each branch -- **Better size estimation**: Use `getEstimatedItemSize` with `itemType` for - accurate estimates per type - -```tsx - item.id} - getItemType={(item) => item.type} - getEstimatedItemSize={(index, item, itemType) => { - switch (itemType) { - case 'header': - return 48 - case 'message': - return 72 - case 'image': - return 300 - default: - return 72 - } - }} - renderItem={({ item }) => { - /* ... */ - }} - recycleItems -/> -``` - -Reference: -[LegendList getItemType](https://legendapp.com/open-source/list/api/props/#getitemtype-v2) diff --git a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md b/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md deleted file mode 100644 index 8a393ba..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/list-performance-virtualize.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: Use a List Virtualizer for Any List -impact: HIGH -impactDescription: reduced memory, faster mounts -tags: lists, performance, virtualization, scrollview ---- - -## Use a List Virtualizer for Any List - -Use a list virtualizer like LegendList or FlashList instead of ScrollView with -mapped children—even for short lists. Virtualizers only render visible items, -reducing memory usage and mount time. ScrollView renders all children upfront, -which gets expensive quickly. - -**Incorrect (ScrollView renders all items at once):** - -```tsx -function Feed({ items }: { items: Item[] }) { - return ( - - {items.map((item) => ( - - ))} - - ) -} -// 50 items = 50 components mounted, even if only 10 visible -``` - -**Correct (virtualizer renders only visible items):** - -```tsx -import { LegendList } from '@legendapp/list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - estimatedItemSize={80} - /> - ) -} -// Only ~10-15 visible items mounted at a time -``` - -**Alternative (FlashList):** - -```tsx -import { FlashList } from '@shopify/flash-list' - -function Feed({ items }: { items: Item[] }) { - return ( - } - keyExtractor={(item) => item.id} - /> - ) -} -``` - -Benefits apply to any screen with scrollable content—profiles, settings, feeds, -search results. Default to virtualization. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md deleted file mode 100644 index ff85d76..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-native-deps-in-app.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -title: Install Native Dependencies in App Directory -impact: CRITICAL -impactDescription: required for autolinking to work -tags: monorepo, native, autolinking, installation ---- - -## Install Native Dependencies in App Directory - -In a monorepo, packages with native code must be installed in the native app's -directory directly. Autolinking only scans the app's `node_modules`—it won't -find native dependencies installed in other packages. - -**Incorrect (native dep in shared package only):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # missing react-native-reanimated -``` - -Autolinking fails—native code not linked. - -**Correct (native dep in app directory):** - -``` -packages/ - ui/ - package.json # has react-native-reanimated - app/ - package.json # also has react-native-reanimated -``` - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Even if the shared package uses the native dependency, the app must also list it -for autolinking to detect and link the native code. diff --git a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md b/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md deleted file mode 100644 index 1087dfa..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/monorepo-single-dependency-versions.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -title: Use Single Dependency Versions Across Monorepo -impact: MEDIUM -impactDescription: avoids duplicate bundles, version conflicts -tags: monorepo, dependencies, installation ---- - -## Use Single Dependency Versions Across Monorepo - -Use a single version of each dependency across all packages in your monorepo. -Prefer exact versions over ranges. Multiple versions cause duplicate code in -bundles, runtime conflicts, and inconsistent behavior across packages. - -Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions -or npm overrides. - -**Incorrect (version ranges, multiple versions):** - -```json -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.0.0" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "^3.5.0" - } -} -``` - -**Correct (exact versions, single source of truth):** - -```json -// package.json (root) -{ - "pnpm": { - "overrides": { - "react-native-reanimated": "3.16.1" - } - } -} - -// packages/app/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} - -// packages/ui/package.json -{ - "dependencies": { - "react-native-reanimated": "3.16.1" - } -} -``` - -Use your package manager's override/resolution feature to enforce versions at -the root. When adding dependencies, specify exact versions without `^` or `~`. diff --git a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md b/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md deleted file mode 100644 index 035c5fd..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/navigation-native-navigators.md +++ /dev/null @@ -1,188 +0,0 @@ ---- -title: Use Native Navigators for Navigation -impact: HIGH -impactDescription: native performance, platform-appropriate UI -tags: navigation, react-navigation, expo-router, native-stack, tabs ---- - -## Use Native Navigators for Navigation - -Always use native navigators instead of JS-based ones. Native navigators use -platform APIs (UINavigationController on iOS, Fragment on Android) for better -performance and native behavior. - -**For stacks:** Use `@react-navigation/native-stack` or expo-router's default -stack (which uses native-stack). Avoid `@react-navigation/stack`. - -**For tabs:** Use `react-native-bottom-tabs` (native) or expo-router's native -tabs. Avoid `@react-navigation/bottom-tabs` when native feel matters. - -### Stack Navigation - -**Incorrect (JS stack navigator):** - -```tsx -import { createStackNavigator } from '@react-navigation/stack' - -const Stack = createStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native stack with react-navigation):** - -```tsx -import { createNativeStackNavigator } from '@react-navigation/native-stack' - -const Stack = createNativeStackNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (expo-router uses native stack by default):** - -```tsx -// app/_layout.tsx -import { Stack } from 'expo-router' - -export default function Layout() { - return -} -``` - -### Tab Navigation - -**Incorrect (JS bottom tabs):** - -```tsx -import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' - -const Tab = createBottomTabNavigator() - -function App() { - return ( - - - - - ) -} -``` - -**Correct (native bottom tabs with react-navigation):** - -```tsx -import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation' - -const Tab = createNativeBottomTabNavigator() - -function App() { - return ( - - ({ sfSymbol: 'house' }), - }} - /> - ({ sfSymbol: 'gear' }), - }} - /> - - ) -} -``` - -**Correct (expo-router native tabs):** - -```tsx -// app/(tabs)/_layout.tsx -import { NativeTabs } from 'expo-router/unstable-native-tabs' - -export default function TabLayout() { - return ( - - - Home - - - - Settings - - - - ) -} -``` - -On iOS, native tabs automatically enable `contentInsetAdjustmentBehavior` on the -first `ScrollView` at the root of each tab screen, so content scrolls correctly -behind the translucent tab bar. If you need to disable this, use -`disableAutomaticContentInsets` on the trigger. - -### Prefer Native Header Options Over Custom Components - -**Incorrect (custom header component):** - -```tsx - , - }} -/> -``` - -**Correct (native header options):** - -```tsx - -``` - -Native headers support iOS large titles, search bars, blur effects, and proper -safe area handling automatically. - -### Why Native Navigators - -- **Performance**: Native transitions and gestures run on the UI thread -- **Platform behavior**: Automatic iOS large titles, Android material design -- **System integration**: Scroll-to-top on tab tap, PiP avoidance, proper safe - areas -- **Accessibility**: Platform accessibility features work automatically - -Reference: - -- [React Navigation Native Stack](https://reactnavigation.org/docs/native-stack-navigator) -- [React Native Bottom Tabs with React Navigation](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-react-navigation) -- [React Native Bottom Tabs with Expo Router](https://oss.callstack.com/react-native-bottom-tabs/docs/guides/usage-with-expo-router) -- [Expo Router Native Tabs](https://docs.expo.dev/router/advanced/native-tabs) diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md deleted file mode 100644 index f76c25a..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-destructure-functions.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: Destructure Functions Early in Render (React Compiler) -impact: HIGH -impactDescription: stable references, fewer re-renders -tags: rerender, hooks, performance, react-compiler ---- - -## Destructure Functions Early in Render - -This rule is only applicable if you are using the React Compiler. - -Destructure functions from hooks at the top of render scope. Never dot into -objects to call functions. Destructured functions are stable references; dotting -creates new references and breaks memoization. - -**Incorrect (dotting into object):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton(props) { - const router = useRouter() - - // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render - const handlePress = () => { - props.onSave() - router.push('/success') // unstable reference - } - - return -} -``` - -**Correct (destructure early):** - -```tsx -import { useRouter } from 'expo-router' - -function SaveButton({ onSave }) { - const { push } = useRouter() - - // good: react-compiler will key on push and onSave - const handlePress = () => { - onSave() - push('/success') // stable reference - } - - return -} -``` diff --git a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md b/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md deleted file mode 100644 index 0dcbaf4..0000000 --- a/.agents/skills/vercel-react-native-skills/rules/react-compiler-reanimated-shared-values.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -title: Use .get() and .set() for Reanimated Shared Values (not .value) -impact: LOW -impactDescription: required for React Compiler compatibility -tags: reanimated, react-compiler, shared-values ---- - -## Use .get() and .set() for Shared Values with React Compiler - -With React Compiler enabled, use `.get()` and `.set()` instead of reading or -writing `.value` directly on Reanimated shared values. The compiler can't track -property access—explicit methods ensure correct behavior. - -**Incorrect (breaks with React Compiler):** - -```tsx -import { useSharedValue } from 'react-native-reanimated' - -function Counter() { - const count = useSharedValue(0) - - const increment = () => { - count.value = count.value + 1 // opts out of react compiler - } - - return ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + + it("blocks the click handler when disabled", async () => { + const handleClick = vi.fn(); + const user = userEvent.setup(); + const { container } = render( + , + ); + const button = container.querySelector("button") as HTMLButtonElement; + await user.click(button); + + expect(handleClick).not.toHaveBeenCalled(); + }); }); diff --git a/__tests__/calculator-layout.test.tsx b/__tests__/calculator-layout.test.tsx new file mode 100644 index 0000000..fa0f8a4 --- /dev/null +++ b/__tests__/calculator-layout.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CalculatorLayout } from "@/components/templates/calculator-layout"; + +describe("CalculatorLayout", () => { + it("renders both regions", () => { + render( + Sua Jornada} + aside={

Painel destaque

} + />, + ); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByText("Painel destaque")).toBeInTheDocument(); + }); + + it("applies the calculator accent class", () => { + const { container } = render( + Formulário

} + aside={

Destaque

} + />, + ); + + expect(container.firstElementChild).toHaveClass("selection:bg-blue-500/30"); + }); +}); diff --git a/__tests__/card.test.tsx b/__tests__/card.test.tsx new file mode 100644 index 0000000..cfe9d71 --- /dev/null +++ b/__tests__/card.test.tsx @@ -0,0 +1,90 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/atoms/card"; + +describe("Card", () => { + it("renders its children", () => { + const { getByText } = render(card content); + expect(getByText("card content")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(card); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render(card); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); + +describe("CardHeader", () => { + it("renders its children", () => { + const { getByText } = render(header content); + expect(getByText("header content")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(header); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render( + header, + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); + +describe("CardTitle", () => { + it("renders its children as an h3", () => { + const { container, getByText } = render( + title content, + ); + expect(getByText("title content")).toBeInTheDocument(); + expect(container.querySelector("h3")).not.toBeNull(); + }); + + it("forwards the ref to the underlying heading", () => { + const ref = createRef(); + render(title); + expect(ref.current).toBeInstanceOf(HTMLHeadingElement); + }); + + it("merges custom className", () => { + const { container } = render( + title, + ); + expect(container.querySelector("h3")?.className).toContain("my-custom"); + }); +}); + +describe("CardContent", () => { + it("renders its children", () => { + const { getByText } = render(content body); + expect(getByText("content body")).toBeInTheDocument(); + }); + + it("forwards the ref to the underlying div", () => { + const ref = createRef(); + render(content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("merges custom className", () => { + const { container } = render( + content, + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/collapsible-panel.test.tsx b/__tests__/collapsible-panel.test.tsx new file mode 100644 index 0000000..7d3af7f --- /dev/null +++ b/__tests__/collapsible-panel.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { CollapsiblePanel } from "@/components/molecules/collapsible-panel"; + +describe("CollapsiblePanel", () => { + it("hides its content while closed", () => { + render( + +

Conteúdo

+
, + ); + + expect(screen.queryByText("Conteúdo")).toBeNull(); + }); + + it("reveals its content while open", () => { + render( + +

Conteúdo

+
, + ); + + expect(screen.getByText("Conteúdo")).toBeInTheDocument(); + }); + + it("exposes the id so a trigger can reference it", () => { + render( + +

Conteúdo

+
, + ); + + const panel = document.getElementById("painel"); + expect(panel).toHaveClass("overflow-hidden", "mt-6"); + }); +}); diff --git a/__tests__/consent.test.ts b/__tests__/consent.test.ts new file mode 100644 index 0000000..a38e3d4 --- /dev/null +++ b/__tests__/consent.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { readTelemetryConsent, writeTelemetryConsent } from "@/lib/consent"; + +const CONSENT_KEY = "workload_cookie_consent"; + +describe("readTelemetryConsent", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when the key is missing", () => { + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns true when telemetry is true", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + expect(readTelemetryConsent()).toBe(true); + }); + + it("returns false when telemetry is false", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); + expect(readTelemetryConsent()).toBe(false); + }); + + it("returns null when the stored value is malformed JSON", () => { + localStorage.setItem(CONSENT_KEY, "{not-json"); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when the stored value is JSON null", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify(null)); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when the stored value is a JSON array", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify([1, 2, 3])); + expect(readTelemetryConsent()).toBeNull(); + }); + + it("returns null when telemetry is not a boolean", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: "yes" })); + expect(readTelemetryConsent()).toBeNull(); + }); +}); + +describe("writeTelemetryConsent", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("writes both telemetry and a numeric timestamp", () => { + writeTelemetryConsent(true); + const stored = JSON.parse(localStorage.getItem(CONSENT_KEY) as string); + expect(stored.telemetry).toBe(true); + expect(typeof stored.timestamp).toBe("number"); + }); +}); diff --git a/__tests__/cookie-consent.test.tsx b/__tests__/cookie-consent.test.tsx new file mode 100644 index 0000000..b29f55c --- /dev/null +++ b/__tests__/cookie-consent.test.tsx @@ -0,0 +1,200 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CookieConsent } from "@/components/molecules/cookie-consent"; + +const CONSENT_KEY = "workload_cookie_consent"; +const BANNER_DELAY_MS = 1500; + +const mockReload = vi.fn(); +Object.defineProperty(window, "location", { + configurable: true, + value: { ...window.location, reload: mockReload }, +}); + +const openSettingsFromShieldButton = () => { + fireEvent.click( + screen.getByRole("button", { name: "Configurações de Privacidade" }), + ); +}; + +const getTelemetryToggleButton = () => { + const label = screen.getByText("Telemetria (Google Analytics)"); + return label.parentElement?.parentElement?.querySelector("button"); +}; + +describe("CookieConsent", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not render the banner before the delay elapses", () => { + render(); + expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); + }); + + it("renders the banner after the delay elapses when no consent is stored", () => { + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + expect(screen.getByText("Respeitamos sua privacidade")).toBeDefined(); + }); + + it("clears the mount timer on unmount so it never fires", () => { + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + const { unmount } = render(); + unmount(); + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it("does not show the banner and seeds the toggle as enabled when consent was stored as true", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: true })); + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); + + openSettingsFromShieldButton(); + fireEvent.click(screen.getByText("Salvar Preferências")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(true); + expect(mockReload).toHaveBeenCalledOnce(); + }); + + it("does not show the banner and seeds the toggle as disabled when consent was stored as false", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + expect(screen.queryByText("Respeitamos sua privacidade")).toBeNull(); + + openSettingsFromShieldButton(); + fireEvent.click(screen.getByText("Salvar Preferências")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(false); + expect(mockReload).toHaveBeenCalledOnce(); + }); + + it("persists accepting all cookies and reloads the page", () => { + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + + fireEvent.click(screen.getByText("Aceitar Tudo")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(true); + expect(mockReload).toHaveBeenCalledOnce(); + }); + + it("persists refusing cookies and reloads the page", () => { + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + + fireEvent.click(screen.getByText("Recusar")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(false); + expect(mockReload).toHaveBeenCalledOnce(); + }); + + it("opens the settings dialog from the banner's Configurar link", () => { + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + + fireEvent.click(screen.getByText("Configurar")); + + expect(screen.getByText("Privacidade")).toBeDefined(); + }); + + it("closes the settings dialog from the backdrop without persisting anything", () => { + const { container } = render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + fireEvent.click(screen.getByText("Configurar")); + + const backdrop = container.querySelector('[class*="backdrop-blur-sm"]'); + expect(backdrop).not.toBeNull(); + fireEvent.click(backdrop as Element); + + expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); + expect(mockReload).not.toHaveBeenCalled(); + }); + + it("closes the settings dialog from the X button without persisting anything", () => { + const { container } = render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + fireEvent.click(screen.getByText("Configurar")); + + const closeButton = container.querySelector( + '[class*="right-6"][class*="top-6"]', + ); + expect(closeButton).not.toBeNull(); + fireEvent.click(closeButton as Element); + + expect(localStorage.getItem(CONSENT_KEY)).toBeNull(); + expect(mockReload).not.toHaveBeenCalled(); + }); + + it("toggles telemetry off and saves the toggled value", () => { + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + fireEvent.click(screen.getByText("Configurar")); + + const toggleButton = getTelemetryToggleButton(); + expect(toggleButton).not.toBeUndefined(); + fireEvent.click(toggleButton as Element); + + fireEvent.click(screen.getByText("Salvar Preferências")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(false); + expect(mockReload).toHaveBeenCalledOnce(); + }); + + it("toggles telemetry back on from a stored disabled consent and saves the toggled value", () => { + localStorage.setItem(CONSENT_KEY, JSON.stringify({ telemetry: false })); + render(); + act(() => { + vi.advanceTimersByTime(BANNER_DELAY_MS); + }); + + openSettingsFromShieldButton(); + + const toggleButton = getTelemetryToggleButton(); + expect(toggleButton).not.toBeUndefined(); + fireEvent.click(toggleButton as Element); + + fireEvent.click(screen.getByText("Salvar Preferências")); + + expect( + JSON.parse(localStorage.getItem(CONSENT_KEY) ?? "{}").telemetry, + ).toBe(true); + expect(mockReload).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/copy-button.test.tsx b/__tests__/copy-button.test.tsx new file mode 100644 index 0000000..f6a5eca --- /dev/null +++ b/__tests__/copy-button.test.tsx @@ -0,0 +1,108 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CopyButton } from "@/components/molecules/copy-button"; + +function setClipboard(clipboard: unknown) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: clipboard, + }); +} + +describe("CopyButton", () => { + afterEach(() => { + setClipboard(undefined); + vi.useRealTimers(); + }); + + it("copies the value and reports success", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const onCopied = vi.fn(); + const user = userEvent.setup(); + setClipboard({ writeText }); + + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(writeText).toHaveBeenCalledWith("18:48"); + expect(onCopied).toHaveBeenCalledOnce(); + expect(screen.getByRole("status")).toHaveTextContent("Copiado!"); + }); + + it("returns to the idle state after the confirmation delay", async () => { + vi.useFakeTimers(); + setClipboard({ writeText: vi.fn().mockResolvedValue(undefined) }); + render(); + + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copiar horário" })); + }); + expect(screen.getByRole("status")).toHaveTextContent("Copiado!"); + + act(() => { + vi.advanceTimersByTime(2000); + }); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("warns the user when the clipboard API is unavailable", async () => { + const user = userEvent.setup(); + setClipboard(undefined); + + render(); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(screen.getByRole("status")).toHaveTextContent( + "Não foi possível copiar", + ); + }); + + it("warns the user when writing to the clipboard is rejected", async () => { + const onCopied = vi.fn(); + const user = userEvent.setup(); + setClipboard({ writeText: vi.fn().mockRejectedValue(new Error("denied")) }); + + render( + , + ); + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(screen.getByRole("status")).toHaveTextContent( + "Não foi possível copiar", + ); + expect(onCopied).not.toHaveBeenCalled(); + }); + + it("clears the failure warning after its own delay", async () => { + vi.useFakeTimers(); + setClipboard(undefined); + + render(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Copiar horário" })); + }); + + act(() => { + vi.advanceTimersByTime(4000); + }); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("can be triggered from the keyboard", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + setClipboard({ writeText }); + + render(); + await user.tab(); + await user.keyboard("{Enter}"); + + expect(writeText).toHaveBeenCalledWith("18:48"); + }); +}); diff --git a/__tests__/date-time-input.test.tsx b/__tests__/date-time-input.test.tsx new file mode 100644 index 0000000..3e54a10 --- /dev/null +++ b/__tests__/date-time-input.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { format } from "date-fns"; +import { LogIn } from "lucide-react"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { DateTimeInput } from "@/components/molecules/date-time-input"; + +function InputHarness({ + initialValue = "2026-02-01T08:00", + onChange, +}: { + initialValue?: string; + onChange?: (value: string) => void; +}) { + const [value, setValue] = useState(initialValue); + + return ( + { + setValue(next); + onChange?.(next); + }} + /> + ); +} + +describe("DateTimeInput", () => { + it("labels the date field through the visible label and the time field explicitly", () => { + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue("01/02/2026"); + expect(screen.getByLabelText("Hora para Entrada")).toHaveValue("08:00"); + }); + + it("reports a new date keeping the current time", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const dateField = screen.getByLabelText("Entrada"); + await user.clear(dateField); + await user.type(dateField, "15032026"); + + expect(onChange).toHaveBeenCalledWith("2026-03-15T08:00"); + }); + + it("reports a new time keeping the current date", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const timeField = screen.getByLabelText("Hora para Entrada"); + await user.clear(timeField); + await user.type(timeField, "0930"); + + expect(onChange).toHaveBeenCalledWith("2026-02-01T09:30"); + }); + + it("restores the previous date when an impossible one is typed", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const dateField = screen.getByLabelText("Entrada"); + await user.clear(dateField); + await user.type(dateField, "31022026"); + await user.tab(); + + expect(dateField).toHaveValue("01/02/2026"); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("restores the previous time when an impossible one is typed", async () => { + const user = userEvent.setup(); + render(); + + const timeField = screen.getByLabelText("Hora para Entrada"); + await user.clear(timeField); + await user.type(timeField, "2599"); + await user.tab(); + + expect(timeField).toHaveValue("08:00"); + }); + + it("falls back to today when there is no date yet", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue(""); + + await user.type(screen.getByLabelText("Hora para Entrada"), "0715"); + + expect(onChange).toHaveBeenCalledWith( + `${format(new Date(), "yyyy-MM-dd")}T07:15`, + ); + }); + + it("falls back to midnight when there is no time yet", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Entrada"), "10032026"); + + expect(onChange).toHaveBeenCalledWith("2026-03-10T00:00"); + }); + + it("keeps an unrecognised date part visible instead of blanking it", () => { + render(); + + expect(screen.getByLabelText("Entrada")).toHaveValue("indefinido"); + }); + + it("uses the provided id for the date field", () => { + render( + , + ); + + expect(screen.getByLabelText("Saída Real")).toHaveAttribute( + "id", + "saida-real", + ); + }); +}); diff --git a/__tests__/duration-row.test.tsx b/__tests__/duration-row.test.tsx new file mode 100644 index 0000000..f5750e5 --- /dev/null +++ b/__tests__/duration-row.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from "@testing-library/react"; +import { Zap } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { DurationRow } from "@/components/molecules/duration-row"; + +describe("DurationRow", () => { + it("shows hours and minutes for the given amount", () => { + render( + , + ); + + expect(screen.getByText("Extra 50%")).toBeInTheDocument(); + expect(screen.getByText("2h 30m")).toBeInTheDocument(); + }); + + it("shows a zeroed duration when there is nothing to report", () => { + render( + , + ); + + expect(screen.getByText("0h 0m")).toBeInTheDocument(); + }); + + it("rounds fractional minutes", () => { + render( + , + ); + + expect(screen.getByText("0h 60m")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/extra-entry-list.test.tsx b/__tests__/extra-entry-list.test.tsx new file mode 100644 index 0000000..6a9314b --- /dev/null +++ b/__tests__/extra-entry-list.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ExtraEntryList } from "@/components/molecules/extra-entry-list"; + +describe("ExtraEntryList", () => { + it("renders its title, its entries and the add action", () => { + render( + +

Plano de Saúde

+
, + ); + + expect(screen.getByText("Outros Descontos")).toBeInTheDocument(); + expect(screen.getByText("Plano de Saúde")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Adicionar desconto" }), + ).toHaveTextContent("Adicionar"); + expect(document.getElementById("extra-deductions-list")).toContainElement( + screen.getByText("Plano de Saúde"), + ); + }); + + it("adds an entry when the add action is pressed", async () => { + const onAdd = vi.fn(); + const user = userEvent.setup(); + render( + + {null} + , + ); + + const addButton = screen.getByRole("button", { name: "Adicionar ganho" }); + expect(addButton).toHaveClass("text-emerald-600"); + + await user.click(addButton); + expect(onAdd).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/extra-entry-row.test.tsx b/__tests__/extra-entry-row.test.tsx new file mode 100644 index 0000000..fdab87c --- /dev/null +++ b/__tests__/extra-entry-row.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { ExtraEntryRow } from "@/components/molecules/extra-entry-row"; + +function RowHarness({ onRemove }: { onRemove?: () => void }) { + const [name, setName] = useState(""); + const [value, setValue] = useState(0); + + return ( + onRemove?.()} + /> + ); +} + +describe("ExtraEntryRow", () => { + it("labels every control for assistive technology", () => { + render(); + + expect(screen.getByLabelText("Descrição do desconto")).toBeInTheDocument(); + expect(screen.getByLabelText("Valor do desconto")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Remover desconto" }), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"), + ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Valor")).toBeInTheDocument(); + }); + + it("keeps the typed description", async () => { + const user = userEvent.setup(); + render(); + + await user.type( + screen.getByLabelText("Descrição do desconto"), + "Plano Odonto", + ); + + expect(screen.getByLabelText("Descrição do desconto")).toHaveValue( + "Plano Odonto", + ); + }); + + it("formats the typed amount as currency", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Valor do desconto"), "5000"); + + expect(screen.getByLabelText("Valor do desconto")).toHaveValue("50,00"); + }); + + it("asks to be removed when the remove button is pressed", async () => { + const onRemove = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Remover desconto" })); + + expect(onRemove).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/form-field.test.tsx b/__tests__/form-field.test.tsx new file mode 100644 index 0000000..575fcdc --- /dev/null +++ b/__tests__/form-field.test.tsx @@ -0,0 +1,32 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { FormField } from "@/components/molecules/form-field"; + +describe("FormField", () => { + it("associates the label with the input by id", () => { + const { getByLabelText } = render( + , + ); + expect(getByLabelText("Hours")).toBeInTheDocument(); + }); + + it("renders the labelIcon and icon", () => { + const { getByTestId } = render( + } + icon={} + />, + ); + expect(getByTestId("label-icon")).toBeInTheDocument(); + expect(getByTestId("input-icon")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/google-ad.test.tsx b/__tests__/google-ad.test.tsx index 1d05ae9..6b81b6e 100644 --- a/__tests__/google-ad.test.tsx +++ b/__tests__/google-ad.test.tsx @@ -1,4 +1,5 @@ import { render } from "@testing-library/react"; +import { StrictMode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GoogleAd } from "@/components/molecules/google-ad"; @@ -50,4 +51,28 @@ describe("GoogleAd", () => { const { container } = render(); expect(container.querySelector(".my-custom-class")).toBeTruthy(); }); + + it("swallows the error when pushing to adsbygoogle throws", () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + (window as Record).adsbygoogle = { + push: () => { + throw new Error("blocked"); + }, + }; + expect(() => render()).not.toThrow(); + (window as Record).adsbygoogle = undefined; + }); + + it("pushes the ad only once when StrictMode re-invokes the mount effect", () => { + vi.stubEnv("NEXT_PUBLIC_ADSENSE_ID", MOCK_ADSENSE_ID); + render( + + + , + ); + const adsbygoogle = (window as Record) + .adsbygoogle as Array>; + expect(adsbygoogle).toHaveLength(1); + (window as Record).adsbygoogle = undefined; + }); }); diff --git a/__tests__/hero-panel.test.tsx b/__tests__/hero-panel.test.tsx new file mode 100644 index 0000000..23c2c1e --- /dev/null +++ b/__tests__/hero-panel.test.tsx @@ -0,0 +1,47 @@ +import { render, screen } from "@testing-library/react"; +import { Clock } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { HeroPanel } from "@/components/molecules/hero-panel"; + +describe("HeroPanel", () => { + it("renders the label and the highlighted value", () => { + render( + , + ); + + expect(screen.getByText("FALTAM")).toBeInTheDocument(); + expect(screen.getByText("01:23:45")).toBeInTheDocument(); + }); + + it("renders badge, children and footer when provided", () => { + render( + 10:00:00} + footer={

Resumo Financeiro

} + > +

por minuto

+
, + ); + + expect(screen.getByText("10:00:00")).toBeInTheDocument(); + expect(screen.getByText("por minuto")).toBeInTheDocument(); + expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument(); + }); + + it("omits the footer separator when there is no footer", () => { + render( + , + ); + + expect(document.querySelector(".border-t")).toBeNull(); + }); +}); diff --git a/__tests__/input.test.tsx b/__tests__/input.test.tsx new file mode 100644 index 0000000..12dd8da --- /dev/null +++ b/__tests__/input.test.tsx @@ -0,0 +1,42 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { Input } from "@/components/atoms/input"; + +describe("Input", () => { + it("renders without an icon", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeNull(); + const input = container.querySelector("input"); + expect(input?.className).not.toContain("pl-12"); + }); + + it("renders with an icon and applies the icon padding class", () => { + const { container } = render( + } placeholder="with icon" />, + ); + expect( + container.querySelector('[data-testid="input-icon"]'), + ).not.toBeNull(); + const input = container.querySelector("input"); + expect(input?.className).toContain("pl-12"); + }); + + it("forwards the ref to the underlying input", () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + + it("merges custom className", () => { + const { container } = render(); + expect(container.querySelector("input")?.className).toContain("my-custom"); + }); + + it("passes the type prop through", () => { + const { container } = render(); + expect(container.querySelector("input")?.getAttribute("type")).toBe( + "email", + ); + }); +}); diff --git a/__tests__/journey-form.test.tsx b/__tests__/journey-form.test.tsx new file mode 100644 index 0000000..9a0cd19 --- /dev/null +++ b/__tests__/journey-form.test.tsx @@ -0,0 +1,162 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { JourneyForm } from "@/components/organisms/journey-form"; + +const DEFAULT_WORK_MINUTES = 8 * 60 + 48; + +function JourneyHarness({ + onReset = vi.fn(), + onManualExitChange, +}: { + onReset?: () => void; + onManualExitChange?: (manual: boolean) => void; +}) { + const [workMinutes, setWorkMinutes] = useState(DEFAULT_WORK_MINUTES); + const [firstTierRate, setFirstTierRate] = useState(50); + const [extraTierRate, setExtraTierRate] = useState(100); + const [entry, setEntry] = useState("2026-02-02T08:00"); + const [lunchStart, setLunchStart] = useState("2026-02-02T12:00"); + const [lunchEnd, setLunchEnd] = useState("2026-02-02T13:00"); + const [exitValue, setExitValue] = useState("2026-02-02T17:48"); + const [isManualExit, setIsManualExit] = useState(false); + + return ( + { + setIsManualExit(manual); + onManualExitChange?.(manual); + }} + onReset={onReset} + /> + ); +} + +describe("JourneyForm", () => { + it("renders every journey moment as a labelled field", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Entrada")).toBeInTheDocument(); + expect(screen.getByLabelText("Saída Almoço")).toBeInTheDocument(); + expect(screen.getByLabelText("Volta Almoço")).toBeInTheDocument(); + expect(screen.getByLabelText("Saída Real")).toBeInTheDocument(); + }); + + it("keeps the settings panel collapsed until requested", async () => { + const user = userEvent.setup(); + render(); + + const settingsToggle = screen.getByRole("button", { + name: "Configurações da Jornada", + }); + expect(settingsToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("Tempo de Trabalho Diário")).toBeNull(); + + await user.click(settingsToggle); + + expect(settingsToggle).toHaveAttribute("aria-expanded", "true"); + expect( + screen.getByLabelText("Tempo de Trabalho Diário"), + ).toBeInTheDocument(); + }); + + it("shows the daily journey as a masked duration and accepts a new one", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + const journeyField = screen.getByLabelText("Tempo de Trabalho Diário"); + expect(journeyField).toHaveValue("08:48"); + + await user.clear(journeyField); + await user.type(journeyField, "0800"); + await user.tab(); + + expect(journeyField).toHaveValue("08:00"); + }); + + it("restores the daily journey when an incomplete duration is left behind", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + const journeyField = screen.getByLabelText("Tempo de Trabalho Diário"); + await user.clear(journeyField); + await user.type(journeyField, "9"); + await user.tab(); + + expect(journeyField).toHaveValue("08:48"); + }); + + it("lets both overtime rates be adjusted", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Configurações da Jornada" }), + ); + + const firstTierField = screen.getByLabelText("Adicional até 2h extras (%)"); + await user.clear(firstTierField); + await user.type(firstTierField, "75"); + expect(firstTierField).toHaveValue(75); + + const extraTierField = screen.getByLabelText("Adicional acima de 2h (%)"); + await user.clear(extraTierField); + await user.type(extraTierField, "120"); + expect(extraTierField).toHaveValue(120); + }); + + it("switches between automatic and manual exit", async () => { + const onManualExitChange = vi.fn(); + const user = userEvent.setup(); + render(); + + const autoButton = screen.getByRole("button", { name: "AUTO" }); + const manualButton = screen.getByRole("button", { name: "MANUAL" }); + expect(autoButton).toHaveAttribute("aria-pressed", "true"); + expect(manualButton).toHaveAttribute("aria-pressed", "false"); + + await user.click(manualButton); + + expect(onManualExitChange).toHaveBeenCalledWith(true); + expect(manualButton).toHaveAttribute("aria-pressed", "true"); + + await user.click(autoButton); + + expect(onManualExitChange).toHaveBeenLastCalledWith(false); + }); + + it("asks for a reset when the reset action is used", async () => { + const onReset = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + + expect(onReset).toHaveBeenCalledOnce(); + }); +}); diff --git a/__tests__/label.test.tsx b/__tests__/label.test.tsx new file mode 100644 index 0000000..457e36c --- /dev/null +++ b/__tests__/label.test.tsx @@ -0,0 +1,27 @@ +import { render } from "@testing-library/react"; +import { createRef } from "react"; +import { describe, expect, it } from "vitest"; +import { Label } from "@/components/atoms/label"; + +describe("Label", () => { + it("renders its children", () => { + const { getByText } = render(); + expect(getByText("label content")).toBeInTheDocument(); + }); + + it("associates with a control via htmlFor", () => { + const { getByText } = render(); + expect(getByText("field label")).toHaveAttribute("for", "field-id"); + }); + + it("forwards the ref to the underlying label", () => { + const ref = createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLLabelElement); + }); + + it("merges custom className", () => { + const { getByText } = render(); + expect(getByText("label").className).toContain("my-custom"); + }); +}); diff --git a/__tests__/layout.test.tsx b/__tests__/layout.test.tsx new file mode 100644 index 0000000..d878c14 --- /dev/null +++ b/__tests__/layout.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import RootLayout, { metadata, viewport } from "@/app/layout"; + +describe("RootLayout", () => { + it("renders its children", () => { + render( + +
layout child
+
, + ); + expect(screen.getByText("layout child")).toBeInTheDocument(); + }); +}); + +describe("metadata", () => { + it("exposes the expected title, description and social metadata", () => { + expect(metadata.title).toEqual({ + default: "WorkLoad | Calculadora Inteligente de Horas e Salário", + template: "%s | WorkLoad", + }); + expect(metadata.description).toBe( + "Calcule sua jornada de trabalho, horas extras, adicional noturno e salário CLT de forma simples, rápida e precisa.", + ); + expect(metadata.alternates).toEqual({ canonical: "/" }); + expect(metadata.openGraph?.url).toBe("https://workload.devrma.com"); + expect(metadata.twitter).toMatchObject({ card: "summary_large_image" }); + }); +}); + +describe("viewport", () => { + it("does not disable pinch-to-zoom", () => { + expect(viewport).not.toHaveProperty("maximumScale"); + expect(viewport).not.toHaveProperty("userScalable"); + }); + + it("exposes the expected width, initial scale and theme colors", () => { + expect(viewport.width).toBe("device-width"); + expect(viewport.initialScale).toBe(1); + expect(viewport.themeColor).toEqual([ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0a0a0a" }, + ]); + }); +}); diff --git a/__tests__/manifest.test.ts b/__tests__/manifest.test.ts new file mode 100644 index 0000000..7e1e806 --- /dev/null +++ b/__tests__/manifest.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import manifest from "@/app/manifest"; + +describe("manifest", () => { + it("returns the expected web app manifest", () => { + expect(manifest()).toEqual({ + name: "WorkLoad - Calculadora de Horas", + short_name: "WorkLoad", + description: + "Calcule sua jornada de trabalho de forma simples e intuitiva.", + start_url: "/", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#6366f1", + icons: [ + { + src: "/icon-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/icon-512x512.png", + sizes: "512x512", + type: "image/png", + }, + ], + }); + }); +}); diff --git a/__tests__/masked-input.test.tsx b/__tests__/masked-input.test.tsx new file mode 100644 index 0000000..992c211 --- /dev/null +++ b/__tests__/masked-input.test.tsx @@ -0,0 +1,161 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { MaskedInput } from "@/components/atoms/masked-input"; + +const TIME_GROUPS = [2, 2] as const; +const DATE_GROUPS = [2, 2, 4] as const; + +const isRealTime = (masked: string) => { + const [hours, minutes] = masked.split(":").map(Number); + return hours >= 0 && hours < 24 && minutes >= 0 && minutes < 60; +}; + +function TimeHarness({ onCommit }: { onCommit?: (value: string) => void }) { + const [value, setValue] = useState("08:00"); + + return ( + <> + { + setValue(committed); + onCommit?.(committed); + }} + /> + + + ); +} + +describe("MaskedInput", () => { + it("inserts the separator while digits are typed", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "0"); + expect(input).toHaveValue("0"); + + await user.type(input, "8"); + expect(input).toHaveValue("08"); + + await user.type(input, "4"); + expect(input).toHaveValue("08:4"); + + await user.type(input, "5"); + expect(input).toHaveValue("08:45"); + }); + + it("ignores non-digit characters and extra digits", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1a2b3c49"); + + expect(input).toHaveValue("12:34"); + }); + + it("commits the value as soon as the mask is complete and valid", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1015"); + + expect(onCommit).toHaveBeenCalledWith("10:15"); + }); + + it("never commits an invalid complete value", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "9999"); + + expect(input).toHaveValue("99:99"); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("restores the committed value when blurred while incomplete", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "07"); + await user.tab(); + + expect(input).toHaveValue("08:00"); + }); + + it("restores the committed value when blurred while invalid", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "2588"); + await user.tab(); + + expect(input).toHaveValue("08:00"); + }); + + it("keeps a valid value after blurring", async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByLabelText("Hora"); + + await user.clear(input); + await user.type(input, "1830"); + await user.tab(); + + expect(input).toHaveValue("18:30"); + }); + + it("follows the value when it changes outside the field", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Definir externamente" }), + ); + + expect(screen.getByLabelText("Hora")).toHaveValue("23:59"); + }); + + it("supports masks with more than one separator", async () => { + const onCommit = vi.fn(); + const user = userEvent.setup(); + render( + true} + onCommit={onCommit} + />, + ); + const input = screen.getByLabelText("Data"); + + await user.type(input, "01022026"); + + expect(input).toHaveValue("01/02/2026"); + expect(onCommit).toHaveBeenCalledWith("01/02/2026"); + }); +}); diff --git a/__tests__/page.test.tsx b/__tests__/page.test.tsx new file mode 100644 index 0000000..e266d39 --- /dev/null +++ b/__tests__/page.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderToString } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Home from "@/app/page"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const themeState: { resolvedTheme: string | undefined; setTheme: () => void } = + { + resolvedTheme: undefined, + setTheme: vi.fn(), + }; + +vi.mock("next-themes", () => ({ + useTheme: () => themeState, +})); + +vi.mock("@/components/organisms/work-calculator", () => ({ + WorkCalculator: () =>

Painel da jornada

, +})); + +vi.mock("@/components/organisms/salary-calculator", () => ({ + SalaryCalculator: () =>

Painel do custo da hora

, +})); + +describe("Home", () => { + beforeEach(() => { + vi.clearAllMocks(); + themeState.resolvedTheme = undefined; + }); + + it("renders the whole shell on the server instead of a blank document", () => { + const markup = renderToString(); + + expect(markup).toContain("WorkLoad"); + expect(markup).toContain("Jornada"); + expect(markup).toContain("Custo da Hora"); + expect(markup).toContain("Pular para o conteúdo principal"); + expect(markup).toContain("--:--:--"); + }); + + it("describes the application for search engines", () => { + const markup = renderToString(); + + expect(markup).toContain("application/ld+json"); + expect(markup).toContain("WebApplication"); + }); + + it("shows the live clock once the client takes over", () => { + render(); + + expect(screen.getByText(/^\d{2}:\d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it("reports the session metadata on mount", () => { + render(); + + expect(safeGAEvent).toHaveBeenCalledWith( + "session_metadata", + expect.objectContaining({ viewport_width: window.innerWidth }), + ); + }); + + it("starts on the journey view", () => { + render(); + + expect(screen.getByText("Painel da jornada")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Jornada" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("switches to the hourly cost view and tracks it", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Custo da Hora" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "salary" }); + expect( + await screen.findByText("Painel do custo da hora"), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Jornada" })); + + expect(safeGAEvent).toHaveBeenCalledWith("switch_tab", { tab: "work" }); + }); + + it("offers the dark theme while the light one is active", async () => { + themeState.resolvedTheme = "light"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle("Alternar tema")); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { + theme: "dark", + }); + }); + + it("offers the light theme while the dark one is active", async () => { + themeState.resolvedTheme = "dark"; + const user = userEvent.setup(); + render(); + + await user.click(screen.getByTitle("Alternar tema")); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_theme", { + theme: "light", + }); + }); +}); diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts new file mode 100644 index 0000000..20cbaf3 --- /dev/null +++ b/__tests__/payroll.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { + calculateIncomeTax, + calculateSocialSecurity, + sanitizeAmount, +} from "@/lib/payroll"; + +describe("sanitizeAmount", () => { + it("keeps positive finite amounts untouched", () => { + expect(sanitizeAmount(1234.56)).toBe(1234.56); + }); + + it("collapses zero, negatives and non-finite values to zero", () => { + expect(sanitizeAmount(0)).toBe(0); + expect(sanitizeAmount(-500)).toBe(0); + expect(sanitizeAmount(Number.NaN)).toBe(0); + expect(sanitizeAmount(Number.POSITIVE_INFINITY)).toBe(0); + }); +}); + +describe("calculateSocialSecurity", () => { + it("applies the first bracket rate below the minimum wage", () => { + expect(calculateSocialSecurity(1000)).toBe(75); + }); + + it("matches the official contribution at every bracket boundary", () => { + expect(calculateSocialSecurity(1621)).toBe(121.58); + expect(calculateSocialSecurity(2902.84)).toBe(236.94); + expect(calculateSocialSecurity(4354.27)).toBe(411.11); + expect(calculateSocialSecurity(8475.55)).toBe(988.09); + }); + + it("caps the contribution at the ceiling", () => { + expect(calculateSocialSecurity(20000)).toBe(988.09); + expect(calculateSocialSecurity(8475.55)).toBe( + calculateSocialSecurity(100000), + ); + }); + + it("charges each bracket only on the portion inside it", () => { + expect(calculateSocialSecurity(5000)).toBe(501.51); + }); + + it("returns zero for empty or invalid salaries", () => { + expect(calculateSocialSecurity(0)).toBe(0); + expect(calculateSocialSecurity(-1000)).toBe(0); + expect(calculateSocialSecurity(Number.NaN)).toBe(0); + }); +}); + +describe("calculateIncomeTax", () => { + it("exempts salaries up to the exemption ceiling", () => { + expect(calculateIncomeTax(3000, calculateSocialSecurity(3000))).toBe(0); + expect(calculateIncomeTax(5000, calculateSocialSecurity(5000))).toBe(0); + }); + + it("clamps to zero when the reduction outgrows the tax", () => { + expect(calculateIncomeTax(5000.01, calculateSocialSecurity(5000.01))).toBe( + 0, + ); + }); + + it("reduces the tax partially inside the transition range", () => { + expect(calculateIncomeTax(5200, calculateSocialSecurity(5200))).toBe(71.62); + }); + + it("phases the reduction out linearly across the transition range", () => { + expect(calculateIncomeTax(6000, calculateSocialSecurity(6000))).toBe(385.1); + }); + + it("stops reducing once the phase-out ceiling is reached", () => { + expect(calculateIncomeTax(7350, calculateSocialSecurity(7350))).toBe( + 884.13, + ); + }); + + it("applies the top bracket above the phase-out ceiling", () => { + expect(calculateIncomeTax(10000, calculateSocialSecurity(10000))).toBe( + 1569.55, + ); + }); + + it("prefers the simplified deduction when it beats the contribution", () => { + const contribution = calculateSocialSecurity(5500); + expect(contribution).toBeLessThan(607.2); + expect(calculateIncomeTax(5500, contribution)).toBe( + calculateIncomeTax(5500, 0), + ); + }); + + it("returns zero for empty or invalid salaries", () => { + expect(calculateIncomeTax(0, 0)).toBe(0); + expect(calculateIncomeTax(-5000, 0)).toBe(0); + expect(calculateIncomeTax(Number.NaN, Number.NaN)).toBe(0); + }); +}); diff --git a/__tests__/robots.test.ts b/__tests__/robots.test.ts new file mode 100644 index 0000000..7c08c46 --- /dev/null +++ b/__tests__/robots.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import robots from "@/app/robots"; + +describe("robots", () => { + it("returns the expected robots rules", () => { + expect(robots()).toEqual({ + rules: { + userAgent: "*", + allow: "/", + }, + sitemap: "https://workload.devrma.com/sitemap.xml", + }); + }); +}); diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx new file mode 100644 index 0000000..0ad69d3 --- /dev/null +++ b/__tests__/salary-calculator.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SalaryCalculator } from "@/components/organisms/salary-calculator"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +describe("SalaryCalculator", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("shows the stored salary, the workload and the resulting hourly value", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Custo da Hora" }), + ).toBeInTheDocument(); + expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("5.000,00"); + expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(220); + expect(screen.getByText("Valor da Hora")).toBeInTheDocument(); + expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument(); + }); + + it("summarises net salary, total received and total deductions", () => { + render(); + + expect(screen.getByText("Salário Líquido")).toBeInTheDocument(); + expect(screen.getByText("Total Recebido")).toBeInTheDocument(); + expect(screen.getByText("Total Descontos")).toBeInTheDocument(); + }); + + it("recalculates the hourly value when the salary changes", async () => { + const user = userEvent.setup(); + render(); + + const salaryField = screen.getByLabelText("Salário Bruto (R$)"); + await user.clear(salaryField); + await user.type(salaryField, "100000"); + + expect(salaryField).toHaveValue("1.000,00"); + }); + + it("leaves the workload field empty when no hours are stored", () => { + localStorage.setItem("monthlyHours", "0"); + + render(); + + expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(null); + }); + + it("accepts a new workload", async () => { + const user = userEvent.setup(); + render(); + + const hoursField = screen.getByLabelText("Carga Horária Mensal"); + await user.clear(hoursField); + await user.type(hoursField, "200"); + + expect(hoursField).toHaveValue(200); + }); + + it("reveals the taxes and deductions panel on demand", async () => { + const user = userEvent.setup(); + render(); + + const detailsToggle = screen.getByRole("button", { + name: "Impostos e Descontos", + }); + expect(detailsToggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByLabelText("INSS (R$)")).toBeNull(); + + await user.click(detailsToggle); + + expect(detailsToggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByLabelText("INSS (R$)")).toBeInTheDocument(); + expect(screen.getByText("Outros Descontos")).toBeInTheDocument(); + expect(screen.getByText("Ganhos Extras (Líquido)")).toBeInTheDocument(); + }); + + it("adds a deduction row through the panel", async () => { + const user = userEvent.setup(); + render(); + + await user.click( + screen.getByRole("button", { name: "Impostos e Descontos" }), + ); + await user.click( + screen.getByRole("button", { name: "Adicionar desconto" }), + ); + + expect( + screen.getByPlaceholderText("Nome (ex: Plano de Saúde)"), + ).toBeInTheDocument(); + }); +}); diff --git a/__tests__/sitemap.test.ts b/__tests__/sitemap.test.ts new file mode 100644 index 0000000..b625ac1 --- /dev/null +++ b/__tests__/sitemap.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import sitemap from "@/app/sitemap"; + +describe("sitemap", () => { + it("returns the expected sitemap entries", () => { + const result = sitemap(); + expect(result).toHaveLength(1); + expect(result[0]?.url).toBe("https://workload.devrma.com"); + expect(result[0]?.changeFrequency).toBe("weekly"); + expect(result[0]?.priority).toBe(1); + expect(result[0]?.lastModified).toBeInstanceOf(Date); + }); +}); diff --git a/__tests__/stat-box.test.tsx b/__tests__/stat-box.test.tsx new file mode 100644 index 0000000..6f1d4f6 --- /dev/null +++ b/__tests__/stat-box.test.tsx @@ -0,0 +1,76 @@ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { StatBox } from "@/components/molecules/stat-box"; + +describe("StatBox", () => { + it("renders with the default variant", () => { + const { container, getByText } = render( + , + ); + expect(getByText("Total")).toBeInTheDocument(); + expect(getByText("10h")).toBeInTheDocument(); + expect(container.firstElementChild?.className).toContain("border-blue-100"); + }); + + it("renders with the success variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-emerald-100", + ); + }); + + it("renders with the warning variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-amber-100", + ); + }); + + it("renders with the danger variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("border-red-100"); + }); + + it("renders with the purple variant", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain( + "border-purple-100", + ); + }); + + it("renders subValue only when provided", () => { + const { queryByText, rerender } = render( + , + ); + expect(queryByText("extra info")).toBeNull(); + + rerender(); + expect(queryByText("extra info")).not.toBeNull(); + }); + + it("renders the icon", () => { + const { getByTestId } = render( + } + />, + ); + expect(getByTestId("stat-icon")).toBeInTheDocument(); + }); + + it("merges custom className", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.className).toContain("my-custom"); + }); +}); diff --git a/__tests__/storage.test.ts b/__tests__/storage.test.ts new file mode 100644 index 0000000..c0ed1a3 --- /dev/null +++ b/__tests__/storage.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { readStoredList, readStoredNumber } from "@/lib/storage"; + +describe("readStoredNumber", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns the fallback when the key is missing", () => { + expect(readStoredNumber("missing-key", 42)).toBe(42); + }); + + it("returns the parsed value when it is valid", () => { + localStorage.setItem("valid-key", "10"); + expect(readStoredNumber("valid-key", 0)).toBe(10); + }); + + it("returns the fallback when the value is non-numeric", () => { + localStorage.setItem("non-numeric-key", "not-a-number"); + expect(readStoredNumber("non-numeric-key", 5)).toBe(5); + }); + + it("returns the fallback when the value is negative", () => { + localStorage.setItem("negative-key", "-1"); + expect(readStoredNumber("negative-key", 7)).toBe(7); + }); + + it("returns zero when the stored value is zero", () => { + localStorage.setItem("zero-key", "0"); + expect(readStoredNumber("zero-key", 99)).toBe(0); + }); + + it("falls back when the stored value is blank", () => { + localStorage.setItem("empty-key", ""); + expect(readStoredNumber("empty-key", 3)).toBe(3); + + localStorage.setItem("blank-key", " "); + expect(readStoredNumber("blank-key", 3)).toBe(3); + }); +}); + +describe("readStoredList", () => { + beforeEach(() => { + localStorage.clear(); + }); + + const isString = (candidate: unknown): candidate is string => + typeof candidate === "string"; + + it("returns an empty array when the key is missing", () => { + expect(readStoredList("missing-list", isString)).toEqual([]); + }); + + it("returns the parsed array when it is valid", () => { + localStorage.setItem("valid-list", JSON.stringify(["a", "b"])); + expect(readStoredList("valid-list", isString)).toEqual(["a", "b"]); + }); + + it("returns an empty array when the JSON is invalid", () => { + localStorage.setItem("invalid-json", "{not-json"); + expect(readStoredList("invalid-json", isString)).toEqual([]); + }); + + it("returns an empty array when the JSON is not an array", () => { + localStorage.setItem("not-an-array", JSON.stringify({ a: 1 })); + expect(readStoredList("not-an-array", isString)).toEqual([]); + }); + + it("filters out array items that fail the predicate", () => { + localStorage.setItem("mixed-list", JSON.stringify(["a", 1, "b", null])); + expect(readStoredList("mixed-list", isString)).toEqual(["a", "b"]); + }); +}); diff --git a/__tests__/tax-details-panel.test.tsx b/__tests__/tax-details-panel.test.tsx new file mode 100644 index 0000000..a74cb2e --- /dev/null +++ b/__tests__/tax-details-panel.test.tsx @@ -0,0 +1,212 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { TaxDetailsPanel } from "@/components/organisms/tax-details-panel"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const baseProps = { + manualInss: null, + manualIrrf: null, + autoInss: 500, + autoIrrf: 250, + extraDeductions: [], + extraGains: [], +}; + +describe("TaxDetailsPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("suggests the calculated taxes as placeholders", () => { + render( + , + ); + + expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute( + "placeholder", + "500,00", + ); + expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute( + "placeholder", + "250,00", + ); + }); + + it("shows the manual taxes when they override the calculated ones", () => { + render( + , + ); + + expect(screen.getByLabelText("INSS (R$)")).toHaveValue("400,00"); + expect(screen.getByLabelText("IRRF (R$)")).toHaveValue("180,00"); + }); + + it("reports a manual tax amount and clears it back to automatic", async () => { + const onManualInssChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + const inssField = screen.getByLabelText("INSS (R$)"); + expect(inssField).toHaveValue("400,00"); + + await user.clear(inssField); + expect(onManualInssChange).toHaveBeenCalledWith(null); + + await user.type(inssField, "1"); + expect(onManualInssChange).toHaveBeenLastCalledWith(4000.01); + }); + + it("reports a manual income tax amount", async () => { + const onManualIrrfChange = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("IRRF (R$)"), "7"); + + expect(onManualIrrfChange).toHaveBeenLastCalledWith(0.07); + }); + + it("tracks the creation of a deduction", async () => { + const onAddExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click( + screen.getByRole("button", { name: "Adicionar desconto" }), + ); + + expect(onAddExtra).toHaveBeenCalledWith("deduction"); + expect(safeGAEvent).toHaveBeenCalledWith("add_deduction"); + }); + + it("tracks the creation of a gain", async () => { + const onAddExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole("button", { name: "Adicionar ganho" })); + + expect(onAddExtra).toHaveBeenCalledWith("gain"); + expect(safeGAEvent).toHaveBeenCalledWith("add_gain"); + }); + + it("edits and removes an existing deduction", async () => { + const onUpdateExtra = vi.fn(); + const onRemoveExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("Descrição do desconto"), "V"); + expect(onUpdateExtra).toHaveBeenCalledWith("one", "deduction", "name", "V"); + + await user.type(screen.getByLabelText("Valor do desconto"), "5"); + expect(onUpdateExtra).toHaveBeenLastCalledWith( + "one", + "deduction", + "value", + 0.05, + ); + + await user.click(screen.getByRole("button", { name: "Remover desconto" })); + expect(onRemoveExtra).toHaveBeenCalledWith("one", "deduction"); + }); + + it("edits and removes an existing gain", async () => { + const onUpdateExtra = vi.fn(); + const onRemoveExtra = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByLabelText("Descrição do ganho"), "V"); + expect(onUpdateExtra).toHaveBeenCalledWith("two", "gain", "name", "V"); + + await user.type(screen.getByLabelText("Valor do ganho"), "5"); + expect(onUpdateExtra).toHaveBeenLastCalledWith( + "two", + "gain", + "value", + 0.05, + ); + + await user.click(screen.getByRole("button", { name: "Remover ganho" })); + expect(onRemoveExtra).toHaveBeenCalledWith("two", "gain"); + }); +}); diff --git a/__tests__/theme-provider.test.tsx b/__tests__/theme-provider.test.tsx new file mode 100644 index 0000000..296a2a6 --- /dev/null +++ b/__tests__/theme-provider.test.tsx @@ -0,0 +1,14 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ThemeProvider } from "@/components/theme-provider"; + +describe("ThemeProvider", () => { + it("renders its children through next-themes", () => { + render( + +
child content
+
, + ); + expect(screen.getByText("child content")).toBeInTheDocument(); + }); +}); diff --git a/__tests__/use-current-time.test.ts b/__tests__/use-current-time.test.ts new file mode 100644 index 0000000..11b5510 --- /dev/null +++ b/__tests__/use-current-time.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useCurrentTime } from "@/hooks/use-current-time"; + +describe("useCurrentTime", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("exposes the current date once mounted", () => { + const { result } = renderHook(() => useCurrentTime()); + + expect(result.current).toBeInstanceOf(Date); + }); + + it("advances every second", () => { + const { result } = renderHook(() => useCurrentTime()); + const firstTick = result.current; + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(Number(result.current)).toBeGreaterThan(Number(firstTick)); + }); + + it("stops ticking after unmount", () => { + const { result, unmount } = renderHook(() => useCurrentTime()); + const lastTick = result.current; + + unmount(); + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(lastTick); + }); +}); diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts index 333682e..ec59366 100644 --- a/__tests__/use-salary-calculator.test.ts +++ b/__tests__/use-salary-calculator.test.ts @@ -1,97 +1,193 @@ import { act, renderHook } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { - calculateInss, - calculateIrrf, - useSalaryCalculator, -} from "../hooks/use-salary-calculator"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useSalaryCalculator } from "@/hooks/use-salary-calculator"; -describe("Salary Calculator Logic", () => { - it("calculates INSS correctly for low bracket", () => { - // Salary <= 1518 - expect(calculateInss(1500)).toBe(112.5); // 1500 * 0.075 +describe("useSalaryCalculator", () => { + beforeEach(() => { + localStorage.clear(); }); - it("calculates INSS correctly for higher brackets", () => { - // Salary 5000: - // 1518 * 0.075 = 113.85 - // (2793.88 - 1518) * 0.09 = 114.83 - // (4190.83 - 2793.88) * 0.12 = 167.63 - // (5000 - 4190.83) * 0.14 = 113.28 - // Total approx: 509.59 - expect(calculateInss(5000)).toBeCloseTo(509.59, 1); - }); + it("starts from the default salary and monthly hours", () => { + const { result } = renderHook(() => useSalaryCalculator()); - it("calculates INSS correctly above ceiling", () => { - // Ceiling is 8157.41 - const maxInss = calculateInss(8157.41); - expect(calculateInss(10000)).toBe(maxInss); + expect(result.current.grossSalary).toBe(5000); + expect(result.current.monthlyHours).toBe(220); }); - it("calculates IRRF correctly with deduction", () => { - // Salary 5000, INSS 509.59 -> Base 4490.41 - // Bracket 4664.68 -> 22.5% rate, 662.77 deduction - // IRRF = 4490.41 * 0.225 - 662.77 = 1010.34 - 662.77 = 347.57 - expect(calculateIrrf(5000, 509.59)).toBeCloseTo(347.57, 1); + it("derives net salary and rates from the current tax tables", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.autoInss).toBe(501.51); + expect(result.current.autoIrrf).toBe(0); + expect(result.current.stats.netSalary).toBe(4498.49); + expect(result.current.stats.hourlyRate).toBeCloseTo(20.4477, 4); + expect(result.current.stats.minuteRate).toBeCloseTo(0.3408, 4); }); - it("calculates IRRF correctly below exempt limit", () => { - // Salary 2000, INSS 160 -> Base 1840 (Exempt) - expect(calculateIrrf(2000, 160)).toBe(0); + it("restores previously stored values", () => { + localStorage.setItem("grossSalary", "9000"); + localStorage.setItem("monthlyHours", "180"); + localStorage.setItem( + "extraGains", + JSON.stringify([{ id: "gain-1", name: "Vale", value: 600 }]), + ); + localStorage.setItem( + "extraDeductions", + JSON.stringify([{ id: "deduction-1", name: "Plano", value: 250 }]), + ); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.grossSalary).toBe(9000); + expect(result.current.monthlyHours).toBe(180); + expect(result.current.stats.totalExtraGains).toBe(600); + expect(result.current.stats.totalExtraDeductions).toBe(250); }); -}); -describe("useSalaryCalculator Hook", () => { - it("initializes with default values", () => { + it("falls back to defaults when stored numbers are unusable", () => { + localStorage.setItem("grossSalary", "not-a-number"); + localStorage.setItem("monthlyHours", "-40"); + const { result } = renderHook(() => useSalaryCalculator()); + expect(result.current.grossSalary).toBe(5000); expect(result.current.monthlyHours).toBe(220); - expect(result.current.stats.hourlyRate).toBeCloseTo(18.83, 1); }); - it("adds and removes extra deductions", () => { + it("ignores stored lists that are corrupted or wrongly shaped", () => { + localStorage.setItem("extraGains", "{not json"); + localStorage.setItem("extraDeductions", JSON.stringify({ nope: true })); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.extraGains).toEqual([]); + expect(result.current.extraDeductions).toEqual([]); + }); + + it("drops individual stored items that fail validation", () => { + localStorage.setItem( + "extraGains", + JSON.stringify([ + { id: "valid", name: "Bônus", value: 100 }, + { id: "missing-value", name: "Quebrado" }, + { id: 42, name: "Id errado", value: 10 }, + { id: "nan", name: "NaN", value: Number.NaN }, + null, + ]), + ); + + const { result } = renderHook(() => useSalaryCalculator()); + + expect(result.current.extraGains).toEqual([ + { id: "valid", name: "Bônus", value: 100 }, + ]); + }); + + it("does not overwrite stored values before restoring them", () => { + localStorage.setItem("grossSalary", "7777"); + + renderHook(() => useSalaryCalculator()); + + expect(localStorage.getItem("grossSalary")).toBe("7777"); + }); + + it("persists changes made after the initial restore", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { - result.current.addExtra("deduction"); + result.current.setGrossSalary(12000); + result.current.setMonthlyHours(200); }); - expect(result.current.extraDeductions.length).toBe(1); - const id = result.current.extraDeductions[0].id; + expect(localStorage.getItem("grossSalary")).toBe("12000"); + expect(localStorage.getItem("monthlyHours")).toBe("200"); + }); + + it("adds, updates and removes extra deductions", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("deduction"); + }); + const { id } = result.current.extraDeductions[0]; act(() => { - result.current.updateExtra(id, "deduction", "value", 100); + result.current.updateExtra(id, "deduction", "name", "Plano de Saúde"); + result.current.updateExtra(id, "deduction", "value", 320); }); - expect(result.current.extraDeductions[0].value).toBe(100); + expect(result.current.extraDeductions[0]).toEqual({ + id, + name: "Plano de Saúde", + value: 320, + }); + expect(result.current.stats.totalExtraDeductions).toBe(320); act(() => { result.current.removeExtra(id, "deduction"); }); - expect(result.current.extraDeductions.length).toBe(0); + expect(result.current.extraDeductions).toEqual([]); }); - it("adds and updates extra gains", () => { + it("adds, updates and removes extra gains", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { result.current.addExtra("gain"); }); - - expect(result.current.extraGains.length).toBe(1); - const id = result.current.extraGains[0].id; + const { id } = result.current.extraGains[0]; act(() => { result.current.updateExtra(id, "gain", "value", 500); }); - expect(result.current.extraGains[0].value).toBe(500); - // Stats totalValue should increase - expect(result.current.stats.totalValue).toBeGreaterThan(4500); + expect(result.current.stats.totalExtraGains).toBe(500); + expect(result.current.stats.totalValue).toBe( + result.current.stats.netSalary + 500, + ); + + act(() => { + result.current.removeExtra(id, "gain"); + }); + + expect(result.current.extraGains).toEqual([]); }); - it("allows manual override for INSS and IRRF", () => { + it("coerces unusable extra values to zero instead of poisoning the totals", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("gain"); + }); + const { id } = result.current.extraGains[0]; + + act(() => { + result.current.updateExtra(id, "gain", "value", "abc"); + }); + + expect(result.current.extraGains[0].value).toBe(0); + expect(result.current.stats.totalValue).toBe( + result.current.stats.netSalary, + ); + }); + + it("leaves the list untouched when updating an unknown id", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.addExtra("gain"); + }); + const before = result.current.extraGains; + + act(() => { + result.current.updateExtra("does-not-exist", "gain", "value", 999); + }); + + expect(result.current.extraGains).toEqual(before); + }); + + it("honours manual INSS and IRRF overrides", () => { const { result } = renderHook(() => useSalaryCalculator()); act(() => { @@ -103,4 +199,32 @@ describe("useSalaryCalculator Hook", () => { expect(result.current.stats.irrf).toBe(0); expect(result.current.stats.netSalary).toBe(5000); }); + + it("recomputes the income tax from a manual INSS override", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.setGrossSalary(10000); + }); + const withAutoInss = result.current.autoIrrf; + + act(() => { + result.current.setManualInss(0); + }); + + expect(result.current.autoIrrf).toBeGreaterThan(withAutoInss); + }); + + it("avoids dividing by zero when the monthly hours are cleared", () => { + const { result } = renderHook(() => useSalaryCalculator()); + + act(() => { + result.current.setMonthlyHours(0); + }); + + expect(result.current.stats.hourlyRate).toBe( + result.current.stats.totalValue, + ); + expect(Number.isFinite(result.current.stats.hourlyRate)).toBe(true); + }); }); diff --git a/__tests__/use-work-calculator.test.ts b/__tests__/use-work-calculator.test.ts index dd008b9..cff877e 100644 --- a/__tests__/use-work-calculator.test.ts +++ b/__tests__/use-work-calculator.test.ts @@ -4,126 +4,312 @@ import { calculateSuggestedExit, calculateWorkStats, useWorkCalculator, -} from "../hooks/use-work-calculator"; - -describe("Work Calculator Logic", () => { - const workMins = 8 * 60 + 48; // 528 - // Use a fixed Monday for testing to avoid weekend logic interference - const mockDate = "2025-01-06"; // Jan 6, 2025 is a Monday - - it("calculates suggested exit correctly", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - // 4 hours morning (240 mins) - // 528 - 240 = 288 mins (4h 48m) afternoon - // 13:00 + 4h 48m = 17:48 - const exit = calculateSuggestedExit(entry, lunchStart, lunchEnd, workMins); - expect(exit).toBe(`${mockDate}T17:48`); - }); - - it("calculates regular work stats with exact exit", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - const exit = `${mockDate}T17:48`; +} from "@/hooks/use-work-calculator"; - const stats = calculateWorkStats( +const FULL_DAY_MINUTES = 8 * 60 + 48; +const MONDAY = "2025-01-06"; +const SATURDAY = "2025-01-11"; + +describe("calculateSuggestedExit", () => { + it("pushes the remaining minutes past the end of lunch", () => { + expect( + calculateSuggestedExit( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + FULL_DAY_MINUTES, + ), + ).toBe(`${MONDAY}T17:48`); + }); + + it("returns the entry unchanged when the times are out of order", () => { + expect( + calculateSuggestedExit( + `${MONDAY}T14:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + FULL_DAY_MINUTES, + ), + ).toBe(`${MONDAY}T14:00`); + }); + + it("returns the entry unchanged when a timestamp is unparseable", () => { + expect(calculateSuggestedExit("not-a-date", "", "", FULL_DAY_MINUTES)).toBe( + "not-a-date", + ); + }); + + it("never suggests an exit before the end of lunch", () => { + expect( + calculateSuggestedExit( + `${MONDAY}T08:00`, + `${MONDAY}T18:00`, + `${MONDAY}T19:00`, + FULL_DAY_MINUTES, + ), + ).toBe(`${MONDAY}T19:00`); + }); + + const balanceAtSuggestedExit = ( + entry: string, + lunchStart: string, + lunchEnd: string, + workMinutes: number, + ) => + calculateWorkStats( entry, lunchStart, lunchEnd, + calculateSuggestedExit(entry, lunchStart, lunchEnd, workMinutes), + workMinutes, + ).balance; + + it("lands on a zero balance for a daytime journey", () => { + expect( + balanceAtSuggestedExit( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + FULL_DAY_MINUTES, + ), + ).toBe(0); + }); + + it("credits the reduced night hour so a night journey also lands on zero", () => { + expect( + balanceAtSuggestedExit( + `${MONDAY}T21:00`, + "2025-01-07T01:00", + "2025-01-07T02:00", + FULL_DAY_MINUTES, + ), + ).toBe(0); + }); + + it("gets within a minute when the reduced night hour makes zero unreachable", () => { + const balance = balanceAtSuggestedExit( + `${MONDAY}T22:00`, + "2025-01-07T00:00", + "2025-01-07T00:30", + 420, + ); + + expect(Math.abs(balance)).toBeLessThanOrEqual(1); + }); +}); + +describe("calculateWorkStats", () => { + const statsFor = (exit: string, workMinutes = FULL_DAY_MINUTES) => + calculateWorkStats( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, exit, - workMins, + workMinutes, ); + + it("balances to zero on an exact day", () => { + const stats = statsFor(`${MONDAY}T17:48`); + expect(stats.balance).toBe(0); expect(stats.totalWorked).toBe(528); - expect(stats.overtime75).toBe(0); - expect(stats.overtime100).toBe(0); + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(0); + }); + + it("reports a negative balance when leaving early", () => { + const stats = statsFor(`${MONDAY}T16:48`); + + expect(stats.balance).toBe(-60); + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(0); }); - it("calculates overtime", () => { - const entry = `${mockDate}T08:00`; - const lunchStart = `${mockDate}T12:00`; - const lunchEnd = `${mockDate}T13:00`; - const exit = `${mockDate}T20:00`; // 7h afternoon = 420 mins. Total = 660 mins. Overtime = 132 mins. + it("fills the first overtime tier before the next one", () => { + const stats = statsFor(`${MONDAY}T19:00`); + + expect(stats.balance).toBe(72); + expect(stats.firstTierMinutes).toBe(72); + expect(stats.extraTierMinutes).toBe(0); + }); + + it("caps the first tier at two hours and spills the rest over", () => { + const stats = statsFor(`${MONDAY}T20:00`); + + expect(stats.balance).toBe(132); + expect(stats.firstTierMinutes).toBe(120); + expect(stats.extraTierMinutes).toBe(12); + }); + it("pays all weekend overtime at the higher tier", () => { const stats = calculateWorkStats( - entry, - lunchStart, - lunchEnd, - exit, - workMins, + `${SATURDAY}T08:00`, + `${SATURDAY}T12:00`, + `${SATURDAY}T13:00`, + `${SATURDAY}T20:00`, + FULL_DAY_MINUTES, ); - expect(stats.balance).toBe(132); - expect(stats.overtime75).toBe(120); // First 2h - expect(stats.overtime100).toBe(12); // Remaining - }); - - it("calculates night shift reduction correctly", () => { - const entry = `${mockDate}T20:00`; - const lunchStart = `2025-01-07T00:00`; // Next day - const lunchEnd = `2025-01-07T01:00`; - const exit = `2025-01-07T05:00`; - - // Total clock time worked: - // 20:00 to 00:00 = 4h - // 01:00 to 05:00 = 4h - // Total = 8h (480 mins) - // Night hours: 22:00-00:00 (2h) + 01:00-05:00 (4h) = 6h. - // 6h night = 6 * 60 = 360 mins. - // Equivalent: 360 * (60 / 52.5) = 411 mins approx. - // Bonus: 411 - 360 = 51 mins. - // Total worked = 480 + 51 = 531 mins. - - const stats = calculateWorkStats(entry, lunchStart, lunchEnd, exit, 480); + + expect(stats.firstTierMinutes).toBe(0); + expect(stats.extraTierMinutes).toBe(132); + }); + + it("converts night minutes with the reduced night hour", () => { + const stats = calculateWorkStats( + `${MONDAY}T20:00`, + "2025-01-07T00:00", + "2025-01-07T01:00", + "2025-01-07T05:00", + 480, + ); + expect(stats.nightMinutes).toBe(411); expect(stats.totalWorked).toBe(531); expect(stats.balance).toBe(51); }); + + it("excludes a lunch break taken inside the night window", () => { + const stats = calculateWorkStats( + `${MONDAY}T21:00`, + `${MONDAY}T23:00`, + "2025-01-07T00:00", + "2025-01-07T04:00", + 480, + ); + + const minutesInsideNightWindow = 360; + const lunchMinutesInsideNightWindow = 60; + const paidNightMinutes = + minutesInsideNightWindow - lunchMinutesInsideNightWindow; + + expect(stats.nightMinutes).toBe(Math.round(paidNightMinutes * (60 / 52.5))); + expect(stats.nightMinutes).toBe(343); + }); + + it("counts no night minutes for a purely daytime shift", () => { + expect(statsFor(`${MONDAY}T17:48`).nightMinutes).toBe(0); + }); + + it("returns zeroed stats when the times are out of order", () => { + const stats = calculateWorkStats( + `${MONDAY}T08:00`, + `${MONDAY}T12:00`, + `${MONDAY}T13:00`, + `${MONDAY}T09:00`, + FULL_DAY_MINUTES, + ); + + expect(stats).toEqual({ + balance: 0, + nightMinutes: 0, + firstTierMinutes: 0, + extraTierMinutes: 0, + totalWorked: 0, + }); + }); + + it("returns zeroed stats when a timestamp is unparseable", () => { + expect( + calculateWorkStats("nope", "nope", "nope", "nope", FULL_DAY_MINUTES) + .totalWorked, + ).toBe(0); + }); }); -describe("useWorkCalculator Hook", () => { +describe("useWorkCalculator", () => { beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-01-06T10:00:00Z")); + vi.setSystemTime(new Date(`${MONDAY}T10:00:00`)); }); - it("initializes with default values and saves to localStorage", () => { + it("starts from the legal overtime defaults", () => { const { result } = renderHook(() => useWorkCalculator()); - expect(result.current.workMinutes).toBe(528); - expect(localStorage.getItem("workMinutes")).toBe("528"); + + expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES); + expect(result.current.firstTierRate).toBe(50); + expect(result.current.extraTierRate).toBe(100); }); - it("loads values from localStorage", () => { + it("restores stored values", () => { localStorage.setItem("workMinutes", "480"); - localStorage.setItem("entry", "2025-01-06T09:00"); + localStorage.setItem("firstTierRate", "75"); + localStorage.setItem("entry", `${MONDAY}T09:00`); const { result } = renderHook(() => useWorkCalculator()); + expect(result.current.workMinutes).toBe(480); - expect(result.current.entry).toBe("2025-01-06T09:00"); + expect(result.current.firstTierRate).toBe(75); + expect(result.current.entry).toBe(`${MONDAY}T09:00`); }); - it("handles manual exit override", () => { + it("ignores stored timestamps that are not usable", () => { + localStorage.setItem("entry", "08:00"); + localStorage.setItem("lunchStart", `${MONDAY}Tnonsense`); + const { result } = renderHook(() => useWorkCalculator()); + expect(result.current.entry).toBe(`${MONDAY}T08:00`); + expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`); + }); + + it("does not overwrite stored values before restoring them", () => { + localStorage.setItem("workMinutes", "400"); + + renderHook(() => useWorkCalculator()); + + expect(localStorage.getItem("workMinutes")).toBe("400"); + }); + + it("persists changes made after the restore", () => { + const { result } = renderHook(() => useWorkCalculator()); + + act(() => { + result.current.setWorkMinutes(480); + result.current.setFirstTierRate(75); + result.current.setExtraTierRate(110); + }); + + expect(localStorage.getItem("workMinutes")).toBe("480"); + expect(localStorage.getItem("firstTierRate")).toBe("75"); + expect(localStorage.getItem("extraTierRate")).toBe("110"); + }); + + it("prefers the manual exit over the suggested one", () => { + const { result } = renderHook(() => useWorkCalculator()); + + expect(result.current.displayExit).toBe(result.current.suggestedExit); + act(() => { result.current.setIsManualExit(true); - result.current.setExitOverride("2025-01-06T18:00"); + result.current.setExitOverride(`${MONDAY}T18:00`); }); - expect(result.current.displayExit).toBe("2025-01-06T18:00"); + expect(result.current.displayExit).toBe(`${MONDAY}T18:00`); }); - it("resets to defaults", () => { + it("restores every default, including the overtime rates", () => { const { result } = renderHook(() => useWorkCalculator()); act(() => { result.current.setWorkMinutes(480); + result.current.setFirstTierRate(75); + result.current.setExtraTierRate(120); + result.current.setIsManualExit(true); + result.current.setExitOverride(`${MONDAY}T22:00`); + }); + + act(() => { result.current.resetDefaults(); }); - expect(result.current.workMinutes).toBe(528); + expect(result.current.workMinutes).toBe(FULL_DAY_MINUTES); + expect(result.current.firstTierRate).toBe(50); + expect(result.current.extraTierRate).toBe(100); expect(result.current.isManualExit).toBe(false); + expect(result.current.exitOverride).toBe(""); + expect(result.current.entry).toBe(`${MONDAY}T08:00`); + expect(result.current.lunchStart).toBe(`${MONDAY}T12:00`); + expect(result.current.lunchEnd).toBe(`${MONDAY}T13:00`); }); }); diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts index 9d6d541..4839e7f 100644 --- a/__tests__/utils.test.ts +++ b/__tests__/utils.test.ts @@ -3,7 +3,6 @@ import { cn, formatCurrency, formatCurrencySimple, - formatMinutes, parseCurrency, } from "@/lib/utils"; @@ -66,26 +65,8 @@ describe("parseCurrency", () => { it("strips non-digit characters", () => { expect(parseCurrency("R$ 1.000,00")).toBe(1000); }); -}); - -describe("formatMinutes", () => { - it("formats positive minutes", () => { - expect(formatMinutes(130)).toBe("02:10"); - }); - - it("formats zero", () => { - expect(formatMinutes(0)).toBe("00:00"); - }); - - it("formats negative minutes with sign", () => { - expect(formatMinutes(-90)).toBe("-01:30"); - }); - - it("pads single-digit hours and minutes", () => { - expect(formatMinutes(5)).toBe("00:05"); - }); - it("handles exact hours", () => { - expect(formatMinutes(120)).toBe("02:00"); + it("returns zero instead of Infinity for absurdly long input", () => { + expect(parseCurrency("9".repeat(400))).toBe(0); }); }); diff --git a/__tests__/work-calculator.test.tsx b/__tests__/work-calculator.test.tsx new file mode 100644 index 0000000..3b98af5 --- /dev/null +++ b/__tests__/work-calculator.test.tsx @@ -0,0 +1,262 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + calculateTimerData, + WorkCalculator, +} from "@/components/organisms/work-calculator"; +import { safeGAEvent } from "@/lib/analytics"; + +vi.mock("@/lib/analytics", () => ({ + safeGAEvent: vi.fn(), +})); + +const ENTRY = "2099-06-01T08:00"; +const LUNCH_START = "2099-06-01T12:00"; +const LUNCH_END = "2099-06-01T13:00"; +const SUGGESTED_EXIT = "2099-06-01T17:48"; + +function storeJourney() { + localStorage.setItem("entry", ENTRY); + localStorage.setItem("lunchStart", LUNCH_START); + localStorage.setItem("lunchEnd", LUNCH_END); +} + +function setClipboard(clipboard: unknown) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + writable: true, + value: clipboard, + }); +} + +describe("calculateTimerData", () => { + const baseInput = { + currentTime: new Date("2099-06-01T10:00:00"), + displayExit: SUGGESTED_EXIT, + entry: ENTRY, + workMinutes: 528, + isManualExit: false, + balanceMinutes: 0, + balanceSign: 0, + totalWorkedMinutes: 528, + }; + + it("counts down to the exit", () => { + const timer = calculateTimerData(baseInput); + + expect(timer).toMatchObject({ + label: "FALTAM", + time: "07:48:00", + isOvertime: false, + entryLabel: "08:00", + exitLabel: "17:48", + }); + expect(timer.progress).toBeCloseTo((120 / 528) * 100); + }); + + it("counts up once the exit has passed", () => { + const timer = calculateTimerData({ + ...baseInput, + currentTime: new Date("2099-06-01T18:48:00"), + }); + + expect(timer).toMatchObject({ + label: "HORA EXTRA", + time: "01:00:00", + isOvertime: true, + }); + expect(timer.progress).toBe(100); + }); + + it("waits for the client clock before counting", () => { + const timer = calculateTimerData({ ...baseInput, currentTime: null }); + + expect(timer).toMatchObject({ + label: "FALTAM", + time: "--:--:--", + progress: 0, + }); + }); + + it("shows the final balance in manual mode", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: 75, + balanceSign: 1, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "+01:15:00", + isOvertime: true, + progress: 100, + }); + }); + + it("shows a negative final balance in manual mode", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: -30, + balanceSign: -1, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "-00:30:00", + isOvertime: false, + }); + }); + + it("treats an exactly balanced day as on target, not overtime", () => { + const timer = calculateTimerData({ + ...baseInput, + isManualExit: true, + balanceMinutes: 0, + balanceSign: 0, + }); + + expect(timer).toMatchObject({ + label: "BALANÇO FINAL", + time: "+00:00:00", + isOvertime: false, + }); + }); + + it("never lets the manual progress leave the 0-100 range", () => { + const overworked = calculateTimerData({ + ...baseInput, + isManualExit: true, + totalWorkedMinutes: 900, + }); + expect(overworked.progress).toBe(100); + + const withoutJourney = calculateTimerData({ + ...baseInput, + isManualExit: true, + workMinutes: 0, + }); + expect(withoutJourney.progress).toBe(0); + }); + + it("never lets the countdown progress leave the 0-100 range", () => { + const withoutJourney = calculateTimerData({ ...baseInput, workMinutes: 0 }); + expect(withoutJourney.progress).toBe(0); + + const beforeEntry = calculateTimerData({ + ...baseInput, + currentTime: new Date("2099-06-01T06:00:00"), + }); + expect(beforeEntry.progress).toBe(0); + }); + + it("waits when the exit is not a real moment", () => { + const timer = calculateTimerData({ ...baseInput, displayExit: "" }); + + expect(timer).toMatchObject({ + label: "Aguardando...", + time: "00:00:00", + progress: 0, + exitLabel: "--:--", + }); + }); + + it("keeps the countdown without progress when the entry is unusable", () => { + const timer = calculateTimerData({ ...baseInput, entry: "invalido" }); + + expect(timer).toMatchObject({ + label: "FALTAM", + progress: 0, + entryLabel: "--:--", + }); + }); +}); + +describe("WorkCalculator", () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + storeJourney(); + }); + + it("renders the journey form and the countdown side by side", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Sua Jornada" }), + ).toBeInTheDocument(); + expect(screen.getByText("FALTAM")).toBeInTheDocument(); + expect(screen.getByText("Saída Sugerida")).toBeInTheDocument(); + expect(screen.getAllByText("17:48").length).toBeGreaterThan(0); + }); + + it("shows the day balance and the overtime tiers", () => { + render(); + + expect( + screen.getByRole("heading", { name: "Balanço do Dia" }), + ).toBeInTheDocument(); + expect(screen.getByText("+0h 0m")).toBeInTheDocument(); + expect(screen.getByText("Extra 50%")).toBeInTheDocument(); + expect(screen.getByText("Extra 100%")).toBeInTheDocument(); + }); + + it("copies the exit time and tracks the event", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + const user = userEvent.setup(); + setClipboard({ writeText }); + render(); + + await user.click(screen.getByRole("button", { name: "Copiar horário" })); + + expect(writeText).toHaveBeenCalledWith("17:48"); + expect(safeGAEvent).toHaveBeenCalledWith("copy_to_clipboard", { + value: "17:48", + }); + setClipboard(undefined); + }); + + it("switches to the final balance when manual mode is chosen", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "MANUAL" })); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { + value: "manual", + }); + expect(screen.getByText("BALANÇO FINAL")).toBeInTheDocument(); + expect(screen.getAllByText("Saída Real").length).toBeGreaterThan(0); + + await user.click(screen.getByRole("button", { name: "AUTO" })); + + expect(safeGAEvent).toHaveBeenCalledWith("toggle_manual_mode", { + value: "auto", + }); + }); + + it("turns manual as soon as the real exit is edited", async () => { + const user = userEvent.setup(); + render(); + + const exitTimeField = screen.getByLabelText("Hora para Saída Real"); + await user.clear(exitTimeField); + await user.type(exitTimeField, "1900"); + + expect(screen.getByRole("button", { name: "MANUAL" })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("restores the defaults and tracks the reset", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Resetar Horários" })); + + expect(safeGAEvent).toHaveBeenCalledWith("reset_defaults"); + }); +}); diff --git a/__tests__/work-summary.test.tsx b/__tests__/work-summary.test.tsx new file mode 100644 index 0000000..8c51ed6 --- /dev/null +++ b/__tests__/work-summary.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { WorkSummary } from "@/components/organisms/work-summary"; + +describe("WorkSummary", () => { + it("celebrates a positive balance", () => { + render( + , + ); + + expect(screen.getByText("+2h 15m")).toBeInTheDocument(); + expect(screen.getByText("Horas extras acumuladas")).toBeInTheDocument(); + }); + + it("reports a negative balance as a debt", () => { + render( + , + ); + + expect(screen.getByText("-0h 45m")).toBeInTheDocument(); + expect(screen.getByText("Horas em débito hoje")).toBeInTheDocument(); + }); + + it("names the overtime tiers after the configured rates", () => { + render( + , + ); + + expect(screen.getByText("Extra 75%")).toBeInTheDocument(); + expect(screen.getByText("Extra 110%")).toBeInTheDocument(); + expect(screen.getByText("Adic. Noturno")).toBeInTheDocument(); + expect(screen.getByText("1h 0m")).toBeInTheDocument(); + expect(screen.getByText("0h 30m")).toBeInTheDocument(); + expect(screen.getByText("1h 30m")).toBeInTheDocument(); + }); + + it("does not present the overtime tiers as statutory rates", () => { + render( + , + ); + + expect(screen.queryByText("Extras (CLT)")).toBeNull(); + expect( + screen.getByRole("heading", { name: "Extras e Adicionais" }), + ).toBeInTheDocument(); + }); +}); + +describe("WorkSummary balance sign", () => { + it("reads an exactly balanced day as on target", () => { + render( + , + ); + + expect(screen.getByText("+0h 0m")).toBeInTheDocument(); + expect(screen.queryByText("Horas em débito hoje")).toBeNull(); + }); +}); diff --git a/app/layout.tsx b/app/layout.tsx index 9401d2a..60531a6 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -58,7 +58,6 @@ export const viewport = { ], width: "device-width", initialScale: 1, - maximumScale: 1, }; export default function RootLayout({ diff --git a/app/page.tsx b/app/page.tsx index 8f6a8d7..60d6613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,20 +6,21 @@ import { AnimatePresence, motion } from "motion/react"; import { useTheme } from "next-themes"; import { useEffect, useState } from "react"; import { Button } from "@/components/atoms/button"; -import SalaryCalculator from "@/components/salary-calculator"; -import WorkCalculator from "@/components/work-calculator"; +import { SalaryCalculator } from "@/components/organisms/salary-calculator"; +import { WorkCalculator } from "@/components/organisms/work-calculator"; +import { useCurrentTime } from "@/hooks/use-current-time"; import { safeGAEvent } from "@/lib/analytics"; type View = "work" | "salary"; +const PLACEHOLDER_CLOCK = "--:--:--"; + export default function Home() { const [activeView, setActiveView] = useState("work"); - const [mounted, setMounted] = useState(false); - const [currentTime, setCurrentTime] = useState(new Date()); + const currentTime = useCurrentTime(); const { setTheme, resolvedTheme } = useTheme(); useEffect(() => { - requestAnimationFrame(() => setMounted(true)); safeGAEvent("session_metadata", { screen_width: window.screen.width, screen_height: window.screen.height, @@ -31,14 +32,6 @@ export default function Home() { }); }, []); - useEffect(() => { - if (!mounted) return; - const timer = setInterval(() => setCurrentTime(new Date()), 1000); - return () => clearInterval(timer); - }, [mounted]); - - if (!mounted) return null; - return ( <>