# FightRate RN — Refinamento UI/UX Instrumento Forte (Direção B) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. > > **Goal:** Implementar a Direção B (Instrumento Forte) do refinamento UI/UX do FightRate RN conforme spec `2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md`. Adicionar JetBrains Mono, LED pulsante, números tabulares, furos no TrainingChart, anel de aviso no Stop, dial tiles no Sensor, stagger reveal, e microanimações orquestradas. > > **Architecture:** React Native bare, sem novas deps externas. Adiciona ~6 componentes, 2 hooks, 1 módulo de motion. Tokens adicionados ao `src/theme/`. Componentes existentes modificados sem breaking change. > > **Tech Stack:** React Native 0.86, TypeScript 5.8, Reanimated 4.4 + Worklets 0.9, Gesture Handler 3.0, react-native-svg 15.15, lucide-react-native, Jest 29. > > **Reference spec:** `docs/superpowers/specs/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md` > > **Conventions:** Every commit messages follow Conventional Commits. PT-BR visible UI; English code identifiers. Run `npm run typecheck && npm run lint && npm test -- --runInBand` before each commit. --- ## File Structure ### New files - `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` (already downloaded) - `src/theme/motion.ts` - `src/hooks/usePulseAnimation.ts` - `src/hooks/useMotionPreference.ts` - `src/components/LiveDot.tsx` - `src/components/PressableSurface.tsx` - `src/components/EmptyState.tsx` - `src/components/DialTile.tsx` - `src/components/TrackRow.tsx` - `src/components/InsightRow.tsx` - `src/__tests__/motion.test.ts` - `src/__tests__/useMotionPreference.test.ts` - `src/__tests__/usePulseAnimation.test.tsx` - `src/__tests__/LiveDot.test.tsx` - `src/__tests__/PressableSurface.test.tsx` - `src/__tests__/EmptyState.test.tsx` - `src/__tests__/DialTile.test.tsx` - `src/__tests__/TrackRow.test.tsx` - `src/__tests__/InsightRow.test.tsx` ### Modified files - `react-native.config.js` - `src/theme/index.ts` - `src/theme/typography.ts` - `src/theme/colors.ts` - `src/components/ActionButton.tsx` - `src/components/StatusTag.tsx` - `src/components/SectionPanel.tsx` - `src/components/MetricTile.tsx` - `src/components/TrainingChart.tsx` - `src/screens/CaptureScreen.tsx` - `src/screens/HistoryScreen.tsx` - `src/screens/SensorScreen.tsx` - `src/screens/InsightsScreen.tsx` - `src/app/FightRateApp.tsx` - `FIGHTRATE_DESIGN_SYSTEM.md` --- ## F1 — Foundations ### Task F1.1: Wire JetBrains Mono via react-native.config.js **Files:** - Modify: `react-native.config.js` (root) - Verify: `android/app/src/main/assets/fonts/JetBrainsMono-Bold.ttf` (after link) - [ ] **Step 1: Inspect current config** Run: `ls /d/h/react-native.config.js 2>/dev/null || echo "NOT FOUND"` Expected: `NOT FOUND` (file doesn't exist yet — was deleted during RN rebuild) - [ ] **Step 2: Create the config file** Create `react-native.config.js` at repo root with this exact content: ```js module.exports = { project: { android: { packageName: 'com.healthtracking.fightrate', sourceDir: './android', }, }, assets: ['./assets/fonts'], }; ``` - [ ] **Step 3: Link fonts into Android assets** Run: `cd /d/h && npx --no-install react-native-asset 2>&1 | tail -10` Expected: creates `android/app/src/main/assets/fonts/JetBrainsMono-*.ttf` (4 files). If `react-native-asset` is not installed, install it first: `npm install --save-dev react-native-asset`. If `react-native-asset` is unavailable as CLI, copy the files directly: ```bash mkdir -p /d/h/android/app/src/main/assets/fonts cp /d/h/assets/fonts/JetBrainsMono-*.ttf /d/h/android/app/src/main/assets/fonts/ ``` - [ ] **Step 4: Verify assets are linked** Run: `ls /d/h/android/app/src/main/assets/fonts/` Expected: 4 ttf files. - [ ] **Step 5: Commit** ```bash cd /d/h && git add react-native.config.js android/app/src/main/assets/fonts/ 2>/dev/null git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(assets): link JetBrains Mono via react-native.config.js" ``` --- ### Task F1.2: theme/motion.ts (presets de timing e easing) **Files:** - Create: `src/theme/motion.ts` - Modify: `src/theme/index.ts` - Test: `src/__tests__/motion.test.ts` - [ ] **Step 1: Write the failing test** Create `src/__tests__/motion.test.ts`: ```ts import { motion } from '../theme/motion'; describe('motion', () => { it('exports bezier strings for easing presets', () => { expect(motion.easeOutQuart).toBe('cubic-bezier(0.25, 1, 0.5, 1)'); expect(motion.easeOutExpo).toBe('cubic-bezier(0.16, 1, 0.3, 1)'); expect(motion.easeOutBack).toBe('cubic-bezier(0.34, 1.56, 0.64, 1)'); expect(motion.easeInOutQuart).toBe('cubic-bezier(0.76, 0, 0.24, 1)'); }); it('exports duration presets in ms', () => { expect(motion.duration.instant).toBe(80); expect(motion.duration.fast).toBe(160); expect(motion.duration.normal).toBe(240); expect(motion.duration.slow).toBe(380); expect(motion.duration.pulse).toBe(800); expect(motion.duration.pulseSlow).toBe(1600); }); it('exports stagger offsets', () => { expect(motion.stagger.row).toBe(50); expect(motion.stagger.item).toBe(80); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -10` Expected: FAIL — cannot find `../theme/motion`. - [ ] **Step 3: Write minimal implementation** Create `src/theme/motion.ts`: ```ts export const motion = { easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)', easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)', easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)', easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)', duration: { instant: 80, fast: 160, normal: 240, slow: 380, pulse: 800, pulseSlow: 1600, }, stagger: { row: 50, item: 80, }, } as const; export type MotionDuration = keyof typeof motion.duration; export type MotionEasing = keyof Pick< typeof motion, 'easeOutQuart' | 'easeOutExpo' | 'easeOutBack' | 'easeInOutQuart' >; ``` - [ ] **Step 4: Re-export from theme index** Edit `src/theme/index.ts`: ```ts export { colors } from './colors'; export { radius } from './radius'; export { spacing } from './spacing'; export { typography } from './typography'; export { motion } from './motion'; ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/motion.test.ts 2>&1 | tail -5` Expected: PASS — 3 tests passed. - [ ] **Step 6: Commit** ```bash cd /d/h && git add src/theme/motion.ts src/theme/index.ts src/__tests__/motion.test.ts git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(theme): motion presets (timing, easing, stagger)" ``` --- ### Task F1.3: theme/typography.ts (add numeric tokens for JetBrains Mono) **Files:** - Modify: `src/theme/typography.ts` - Verify: existing tests still pass - [ ] **Step 1: Add numeric tokens** Edit `src/theme/typography.ts` to add these keys (preserving existing `display`, `displaySmall`, `label`, `body`, `button`): ```ts export const typography = { display: { fontSize: 82, lineHeight: 74, fontWeight: '900' as const, letterSpacing: -7, }, displaySmall: { fontSize: 48, lineHeight: 46, fontWeight: '900' as const, letterSpacing: -3, }, displayNumeric: { fontFamily: 'JetBrainsMono-ExtraBold', fontVariant: ['tabular-nums'] as const, fontSize: 110, lineHeight: 100, fontWeight: '900' as const, letterSpacing: -7, }, displaySmallNumeric: { fontFamily: 'JetBrainsMono-ExtraBold', fontVariant: ['tabular-nums'] as const, fontSize: 56, lineHeight: 54, fontWeight: '900' as const, letterSpacing: -3, }, metricNumeric: { fontFamily: 'JetBrainsMono-Bold', fontVariant: ['tabular-nums'] as const, fontSize: 28, lineHeight: 30, fontWeight: '800' as const, letterSpacing: -1.2, }, detailNumeric: { fontFamily: 'JetBrainsMono-Bold', fontVariant: ['tabular-nums'] as const, fontSize: 18, lineHeight: 22, fontWeight: '800' as const, letterSpacing: -0.8, }, label: { fontSize: 10, lineHeight: 13, fontWeight: '900' as const, letterSpacing: 1.4, textTransform: 'uppercase' as const, }, body: { fontSize: 15, lineHeight: 22, fontWeight: '500' as const, }, button: { fontSize: 12, lineHeight: 16, fontWeight: '900' as const, letterSpacing: 0.8, textTransform: 'uppercase' as const, }, } as const; ``` - [ ] **Step 2: Run typecheck** Run: `cd /d/h && npm run typecheck 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: Run existing tests to verify no regression** Run: `cd /d/h && npm test -- --runInBand 2>&1 | tail -10` Expected: all 55 tests still pass. - [ ] **Step 4: Commit** ```bash cd /d/h && git add src/theme/typography.ts git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(theme): numeric typography tokens (JetBrains Mono tabular)" ``` --- ### Task F1.4: theme/colors.ts (add ringWarn for stop alert ring) **Files:** - Modify: `src/theme/colors.ts` - [ ] **Step 1: Add the new key** Edit `src/theme/colors.ts` — add `ringWarn` after the `stopText` line: ```ts export const colors = { canvas: '#070605', canvasSoft: '#0D0906', surface: '#12100C', surfaceRaised: '#1D170F', surfaceHot: '#251A10', text: '#F8EAD8', textMuted: '#B9A98E', textDim: '#746754', line: '#392D1E', action: '#E23B2E', warning: '#FFB43D', success: '#34C77B', stopSurface: '#FFF0DC', stopText: '#120B07', ringWarn: 'rgba(226, 59, 46, 0.55)', black: '#030302', } as const; ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, all 55 tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/theme/colors.ts git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(theme): add ringWarn color for stop alert ring" ``` --- ### Task F1.5: useMotionPreference hook **Files:** - Create: `src/hooks/useMotionPreference.ts` - Test: `src/__tests__/useMotionPreference.test.ts` - [ ] **Step 1: Write the failing test** Create `src/__tests__/useMotionPreference.test.ts`: ```ts import { act, renderHook } from '@testing-library/react-native'; import { useMotionPreference, type MotionPreferenceListener, } from '../hooks/useMotionPreference'; type AccessibilityInfoMock = { isReduceMotionEnabled: jest.Mock, []>; addEventListener: jest.Mock; removeEventListener: jest.Mock; }; const mockAccessibilityInfo: AccessibilityInfoMock = { isReduceMotionEnabled: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), }; jest.mock('react-native', () => ({ AccessibilityInfo: mockAccessibilityInfo, })); describe('useMotionPreference', () => { beforeEach(() => { mockAccessibilityInfo.isReduceMotionEnabled.mockReset(); mockAccessibilityInfo.addEventListener.mockReset(); mockAccessibilityInfo.removeEventListener.mockReset(); mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValue(false); }); it('starts as false by default and resolves to the system value', async () => { mockAccessibilityInfo.isReduceMotionEnabled.mockResolvedValueOnce(true); const { result, waitForNextUpdate } = renderHook(() => useMotionPreference(), ); expect(result.current).toBe(false); await waitForNextUpdate(); expect(result.current).toBe(true); }); it('subscribes to reduceMotionChanged and updates state', () => { let listener: MotionPreferenceListener | undefined; mockAccessibilityInfo.addEventListener.mockImplementation((event, fn) => { if (event === 'reduceMotionChanged') { listener = fn; } }); const { result } = renderHook(() => useMotionPreference()); expect(result.current).toBe(false); act(() => { listener?.(true); }); expect(result.current).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -10` Expected: FAIL — cannot find `../hooks/useMotionPreference`. - [ ] **Step 3: Write minimal implementation** Create `src/hooks/useMotionPreference.ts`: ```ts import { useEffect, useState } from 'react'; import { AccessibilityInfo } from 'react-native'; export type MotionPreferenceListener = (reduceMotion: boolean) => void; export function useMotionPreference(): boolean { const [reduceMotion, setReduceMotion] = useState(false); useEffect(() => { let mounted = true; AccessibilityInfo.isReduceMotionEnabled() .then((value) => { if (mounted) setReduceMotion(value); }) .catch(() => undefined); const listener: MotionPreferenceListener = (value) => { if (mounted) setReduceMotion(value); }; AccessibilityInfo.addEventListener('reduceMotionChanged', listener); return () => { mounted = false; AccessibilityInfo.removeEventListener('reduceMotionChanged', listener); }; }, []); return reduceMotion; } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/useMotionPreference.test.ts 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/hooks/useMotionPreference.ts src/__tests__/useMotionPreference.test.ts git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(hooks): useMotionPreference for reduce motion respect" ``` --- ## F2 — Atomic components (TDD) ### Task F2.1: usePulseAnimation hook **Files:** - Create: `src/hooks/usePulseAnimation.ts` - Test: `src/__tests__/usePulseAnimation.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/usePulseAnimation.test.tsx`: ```tsx import { act, renderHook } from '@testing-library/react-native'; import { usePulseAnimation } from '../hooks/usePulseAnimation'; jest.mock('react-native-reanimated', () => { const values: Record = {}; let counter = 0; return { Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 }, useSharedValue: (initial: number) => { const ref = { value: initial }; values[counter] = ref; counter += 1; return ref; }, useAnimatedStyle: (fn: () => unknown) => fn(), withTiming: (to: number) => to, withRepeat: (to: number) => to, cancelAnimation: () => undefined, }; }); describe('usePulseAnimation', () => { it('returns initial opacity 0.5 and scale 1 when inactive', () => { const { result } = renderHook(() => usePulseAnimation(false)); const style = result.current as { transform: Array<{ scale: number }>; opacity: number }; expect(style.transform[0].scale).toBe(1); expect(style.opacity).toBe(0.5); }); it('returns opacity 1 when active', () => { const { result } = renderHook(() => usePulseAnimation(true)); const style = result.current as { opacity: number }; expect(style.opacity).toBe(1); }); it('updates opacity when active flag changes', () => { const { result, rerender } = renderHook( ({ active }: { active: boolean }) => usePulseAnimation(active), { initialProps: { active: false } }, ); let style = result.current as { opacity: number }; expect(style.opacity).toBe(0.5); act(() => { rerender({ active: true }); }); style = result.current as { opacity: number }; expect(style.opacity).toBe(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/hooks/usePulseAnimation.ts`: ```ts import { useEffect } from 'react'; import { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; type AnimatedStyle = { transform: Array<{ scale: number }>; opacity: number; }; export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle { const scale = useSharedValue(1); const opacity = useSharedValue(active ? 1 : 0.5); useEffect(() => { if (active) { scale.value = withRepeat( withTiming(1.4, { duration: durationMs / 2, easing: Easing.out(Easing.quad) }), -1, true, ); opacity.value = withRepeat( withTiming(0.55, { duration: durationMs / 2, easing: Easing.inOut(Easing.quad) }), -1, true, ); } else { cancelAnimation(scale); cancelAnimation(opacity); scale.value = withTiming(1, { duration: 160 }); opacity.value = withTiming(0.5, { duration: 160 }); } }, [active, durationMs, scale, opacity]); return useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], opacity: opacity.value, })); } ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/usePulseAnimation.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/hooks/usePulseAnimation.ts src/__tests__/usePulseAnimation.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(hooks): usePulseAnimation for live indicators" ``` --- ### Task F2.2: LiveDot component **Files:** - Create: `src/components/LiveDot.tsx` - Test: `src/__tests__/LiveDot.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/LiveDot.test.tsx`: ```tsx import React from 'react'; import { render, screen } from '@testing-library/react-native'; import { LiveDot } from '../components/LiveDot'; jest.mock('react-native-reanimated', () => { const ReactMod = require('react'); return { __esModule: true, default: { View: ({ children }: { children: React.ReactNode }) => ReactMod.createElement('View', null, children) }, Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 }, useSharedValue: (initial: number) => ({ value: initial }), useAnimatedStyle: (fn: () => unknown) => fn(), withTiming: (to: number) => to, withRepeat: (to: number) => to, cancelAnimation: () => undefined, }; }); describe('LiveDot', () => { it('renders with success color when live=true', () => { render(); const node = screen.getByTestId('dot'); expect(node.props.accessibilityLabel).toBe('AO VIVO'); }); it('renders with INATIVO label when live=false', () => { render(); const node = screen.getByTestId('dot'); expect(node.props.accessibilityLabel).toBe('INATIVO'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/LiveDot.tsx`: ```tsx import React from 'react'; import { StyleSheet, View, type ViewStyle } from 'react-native'; import Animated from 'react-native-reanimated'; import { colors } from '../theme'; import { usePulseAnimation } from '../hooks/usePulseAnimation'; type Props = { readonly live: boolean; readonly size?: number; readonly testID?: string; }; export function LiveDot({ live, size = 8, testID }: Props) { const animatedStyle = usePulseAnimation(live); const dotStyle: ViewStyle = { width: size, height: size, borderRadius: size / 2, backgroundColor: live ? colors.success : colors.textDim, }; return ( ); } const styles = StyleSheet.create({ base: { alignSelf: 'center', }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/LiveDot.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/LiveDot.tsx src/__tests__/LiveDot.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): LiveDot pulsing indicator" ``` --- ### Task F2.3: PressableSurface component **Files:** - Create: `src/components/PressableSurface.tsx` - Test: `src/__tests__/PressableSurface.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/PressableSurface.test.tsx`: ```tsx import React from 'react'; import { Text } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { PressableSurface } from '../components/PressableSurface'; jest.mock('react-native-reanimated', () => { const ReactMod = require('react'); const RN = require('react-native'); return { __esModule: true, default: { View: RN.View, createAnimatedComponent: (Component: unknown) => Component, }, Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 }, useSharedValue: (initial: number) => ({ value: initial }), useAnimatedStyle: (fn: () => unknown) => fn(), withTiming: (to: number) => to, withRepeat: (to: number) => to, cancelAnimation: () => undefined, }; }); describe('PressableSurface', () => { it('renders children inside pressable', () => { render( undefined} testID="surface"> Hi , ); expect(screen.getByText('Hi')).toBeTruthy(); }); it('triggers onPress when pressed', () => { const onPress = jest.fn(); render( Hi , ); fireEvent.press(screen.getByTestId('surface')); expect(onPress).toHaveBeenCalledTimes(1); }); it('has accessibilityRole=button when accessibilityLabel is set', () => { render( undefined} accessibilityLabel="Tap me" testID="surface"> Hi , ); expect(screen.getByTestId('surface').props.accessibilityRole).toBe('button'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/PressableSurface.tsx`: ```tsx import React, { useCallback, type ReactNode } from 'react'; import { Pressable, StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; type Props = { readonly children: ReactNode; readonly onPress?: () => void; readonly disabled?: boolean; readonly style?: StyleProp; readonly accessibilityLabel?: string; readonly testID?: string; }; export function PressableSurface({ children, onPress, disabled, style, accessibilityLabel, testID, }: Props) { const scale = useSharedValue(1); const opacity = useSharedValue(1); const onPressIn = useCallback(() => { scale.value = withTiming(0.96, { duration: 80, easing: Easing.out(Easing.quad) }); opacity.value = withTiming(0.82, { duration: 80, easing: Easing.out(Easing.quad) }); }, [scale, opacity]); const onPressOut = useCallback(() => { scale.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) }); opacity.value = withTiming(1, { duration: 160, easing: Easing.out(Easing.quad) }); }, [scale, opacity]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], opacity: opacity.value, })); return ( {children} ); } const styles = StyleSheet.create({ base: { alignSelf: 'stretch', }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/PressableSurface.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/PressableSurface.tsx src/__tests__/PressableSurface.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): PressableSurface with refined press feedback" ``` --- ### Task F2.4: EmptyState component **Files:** - Create: `src/components/EmptyState.tsx` - Test: `src/__tests__/EmptyState.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/EmptyState.test.tsx`: ```tsx import React from 'react'; import { render, screen } from '@testing-library/react-native'; import { EmptyState } from '../components/EmptyState'; describe('EmptyState', () => { it('renders eyebrow, title, and copy', () => { render( , ); expect(screen.getByText('SEM USO')).toBeTruthy(); expect(screen.getByText('Nenhum treino salvo')).toBeTruthy(); expect(screen.getByText('Comece uma janela para ver seus dados.')).toBeTruthy(); }); it('uses amber tone by default', () => { const { toJSON } = render( , ); const tree = JSON.stringify(toJSON()); expect(tree).toContain('rgba(255, 180, 61'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/EmptyState.tsx`: ```tsx import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import Svg, { Circle, Line, Rect } from 'react-native-svg'; import { colors, radius, spacing, typography } from '../theme'; type Tone = 'amber' | 'red' | 'green'; type Props = { readonly eyebrow: string; readonly title: string; readonly copy: string; readonly tone?: Tone; readonly testID?: string; }; function toneColor(tone: Tone): string { if (tone === 'red') return colors.action; if (tone === 'green') return colors.success; return colors.warning; } function toneBackground(tone: Tone): string { if (tone === 'red') return 'rgba(226, 59, 46, 0.12)'; if (tone === 'green') return 'rgba(52, 199, 123, 0.12)'; return 'rgba(255, 180, 61, 0.10)'; } export function EmptyState({ eyebrow, title, copy, tone = 'amber', testID, }: Props) { return ( {eyebrow} {title} {copy} ); } const styles = StyleSheet.create({ wrap: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, borderRadius: radius.md, paddingVertical: spacing.xl, paddingHorizontal: spacing.md, minHeight: 220, alignItems: 'center', justifyContent: 'center', gap: spacing.sm, }, geom: { marginBottom: spacing.sm, }, eyebrow: { ...typography.label, }, title: { ...typography.display, fontSize: 36, lineHeight: 38, textAlign: 'center', }, copy: { ...typography.body, color: colors.textMuted, textAlign: 'center', maxWidth: 280, }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/EmptyState.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/EmptyState.tsx src/__tests__/EmptyState.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): EmptyState with instrument geometry" ``` --- ### Task F2.5: DialTile component **Files:** - Create: `src/components/DialTile.tsx` - Test: `src/__tests__/DialTile.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/DialTile.test.tsx`: ```tsx import React from 'react'; import { render, screen } from '@testing-library/react-native'; import { DialTile } from '../components/DialTile'; describe('DialTile', () => { it('renders label and value', () => { render(); expect(screen.getByText('RSSI')).toBeTruthy(); expect(screen.getByText('-61 dBm')).toBeTruthy(); }); it('combines label and value into accessibilityLabel', () => { render(); expect(screen.getByTestId('dial').props.accessibilityLabel).toBe('RSSI: -61 dBm'); }); it('uses action color when tone=red', () => { const { toJSON } = render( , ); const tree = JSON.stringify(toJSON()); expect(tree).toContain('#E23B2E'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/DialTile.tsx`: ```tsx import React from 'react'; import { StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { colors, spacing, typography } from '../theme'; type Tone = 'amber' | 'green' | 'red'; type Props = { readonly label: string; readonly value: string; readonly tone?: Tone; readonly testID?: string; }; function toneColor(tone: Tone | undefined): string { if (tone === 'red') return colors.action; if (tone === 'green') return colors.success; return colors.warning; } export function DialTile({ label, value, tone = 'amber', testID }: Props) { const markerStyle: ViewStyle = { backgroundColor: toneColor(tone) }; return ( {value} {label} ); } const styles = StyleSheet.create({ row: { minHeight: 56, flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, borderColor: colors.line, paddingVertical: spacing.sm, paddingHorizontal: spacing.md, gap: spacing.md, }, markerWrap: { width: 4, height: 32, justifyContent: 'center', }, marker: { width: 2, height: 32, }, value: { ...typography.detailNumeric, flex: 1, }, label: { ...typography.label, color: colors.textDim, }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/DialTile.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/DialTile.tsx src/__tests__/DialTile.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): DialTile for sensor signal rows" ``` --- ### Task F2.6: TrackRow component **Files:** - Create: `src/components/TrackRow.tsx` - Test: `src/__tests__/TrackRow.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/TrackRow.test.tsx`: ```tsx import React from 'react'; import { Text } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { TrackRow } from '../components/TrackRow'; describe('TrackRow', () => { const baseProps = { trackNumber: 3, day: '15/06', hour: '19:30', title: 'Sparring', meta: '30 min · 600 amostras', metrics: [ { label: 'MÉD 152' }, { label: 'PICO 184', tone: 'hot' as const }, ], isOpen: false, onPress: jest.fn(), accessibilityLabel: 'Janela 3', }; beforeEach(() => { baseProps.onPress.mockClear(); }); it('renders track number zero-padded', () => { render( , ); expect(screen.getByText('T03')).toBeTruthy(); }); it('renders day, hour, title, and meta', () => { render( , ); expect(screen.getByText('15/06')).toBeTruthy(); expect(screen.getByText('19:30')).toBeTruthy(); expect(screen.getByText('Sparring')).toBeTruthy(); expect(screen.getByText('30 min · 600 amostras')).toBeTruthy(); }); it('renders metrics labels', () => { render( , ); expect(screen.getByText('MÉD 152')).toBeTruthy(); expect(screen.getByText('PICO 184')).toBeTruthy(); }); it('calls onPress when pressed', () => { render( , ); fireEvent.press(screen.getByTestId('row')); expect(baseProps.onPress).toHaveBeenCalledTimes(1); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/TrackRow.tsx`: ```tsx import React, { type ReactNode } from 'react'; import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import { ChevronDown, ChevronUp } from 'lucide-react-native'; import { colors, spacing, typography } from '../theme'; type MetricTone = 'normal' | 'hot'; type Metric = { readonly label: string; readonly tone?: MetricTone; }; type Props = { readonly trackNumber: number; readonly day: string; readonly hour: string; readonly title: string; readonly meta: string; readonly metrics: ReadonlyArray; readonly isOpen: boolean; readonly onPress: () => void; readonly accessibilityLabel: string; readonly children?: ReactNode; readonly testID?: string; }; export function TrackRow({ trackNumber, day, hour, title, meta, metrics, isOpen, onPress, accessibilityLabel, children, testID, }: Props) { const trackNumberLabel = `T${String(trackNumber).padStart(2, '0')}`; const sideRailStyle: ViewStyle = { backgroundColor: isOpen ? colors.action : colors.line, }; return ( [styles.row, pressed ? styles.pressed : null]} testID={testID} > {trackNumberLabel} {day} {hour} {title} {meta} {metrics.map((metric, index) => ( {metric.label} ))} {isOpen ? ( ) : ( )} {children} ); } const styles = StyleSheet.create({ row: { flexDirection: 'row', alignItems: 'center', paddingVertical: spacing.sm, paddingHorizontal: spacing.md, gap: spacing.sm, borderTopWidth: 1, borderTopColor: colors.line, }, pressed: { opacity: 0.72, }, sideRail: { width: 2, height: 40, }, trackNumberWrap: { width: 28, alignItems: 'flex-start', }, trackNumber: { ...typography.label, color: colors.textMuted, }, dateBlock: { width: 56, }, day: { color: colors.warning, fontSize: 18, lineHeight: 21, fontWeight: '900', letterSpacing: -0.6, }, hour: { ...typography.label, color: colors.textDim, marginTop: 2, }, body: { flex: 1, }, title: { color: colors.text, fontSize: 18, lineHeight: 22, fontWeight: '900', letterSpacing: -0.6, }, meta: { ...typography.body, color: colors.textMuted, fontSize: 13, lineHeight: 17, marginTop: 2, }, metricLine: { flexDirection: 'row', gap: spacing.sm, marginTop: spacing.xs, }, metricText: { ...typography.label, color: colors.textMuted, }, metricTextHot: { color: colors.action, }, chevron: { width: 18, alignItems: 'center', justifyContent: 'center', }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/TrackRow.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/TrackRow.tsx src/__tests__/TrackRow.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): TrackRow for history sessions" ``` --- ### Task F2.7: InsightRow component **Files:** - Create: `src/components/InsightRow.tsx` - Test: `src/__tests__/InsightRow.test.tsx` - [ ] **Step 1: Write the failing test** Create `src/__tests__/InsightRow.test.tsx`: ```tsx import React from 'react'; import { render, screen } from '@testing-library/react-native'; import { InsightRow } from '../components/InsightRow'; describe('InsightRow', () => { it('renders index zero-padded', () => { render( , ); expect(screen.getByText('03')).toBeTruthy(); }); it('renders label, status, summary, detail, and value', () => { render( , ); expect(screen.getByText('Carga')).toBeTruthy(); expect(screen.getByText('ok')).toBeTruthy(); expect(screen.getByText('Carga alta')).toBeTruthy(); expect(screen.getByText('TRIMP 145')).toBeTruthy(); expect(screen.getByText('145')).toBeTruthy(); expect(screen.getByText('TRIMP')).toBeTruthy(); }); it('combines label, status, and value into accessibilityLabel', () => { render( , ); const label = screen.getByTestId('row').props.accessibilityLabel; expect(label).toContain('Carga'); expect(label).toContain('ok'); expect(label).toContain('145'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: `cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -10` Expected: FAIL. - [ ] **Step 3: Write minimal implementation** Create `src/components/InsightRow.tsx`: ```tsx import React, { useEffect } from 'react'; import { StyleSheet, Text, View, type ViewStyle } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated'; import { colors, motion, spacing, typography } from '../theme'; type Tone = 'good' | 'warning' | 'danger' | 'neutral'; type Props = { readonly index: number; readonly label: string; readonly statusLabel: string; readonly summary: string; readonly detail: string; readonly value: string; readonly unit?: string; readonly tone: Tone; readonly testID?: string; }; function toneColor(tone: Tone): string { if (tone === 'good') return colors.success; if (tone === 'danger') return colors.action; if (tone === 'warning') return colors.warning; return colors.text; } export function InsightRow({ index, label, statusLabel, summary, detail, value, unit, tone, testID, }: Props) { const indexLabel = String(index).padStart(2, '0'); const opacity = useSharedValue(0); const translateY = useSharedValue(8); const color = toneColor(tone); useEffect(() => { const delay = index < 8 ? index * motion.stagger.row : 0; opacity.value = withDelay( delay, withTiming(1, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }), ); translateY.value = withDelay( delay, withTiming(0, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }), ); }, [index, opacity, translateY]); const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [{ translateY: translateY.value }], })); const indexStyle: ViewStyle = { color }; return ( {indexLabel} {label} {statusLabel} {summary} {detail} {value} {unit ? {unit} : null} ); } const styles = StyleSheet.create({ row: { minHeight: 92, borderBottomWidth: 1, borderBottomColor: colors.line, flexDirection: 'row', gap: spacing.sm, paddingVertical: spacing.sm, }, index: { ...typography.label, color: colors.textDim, width: 28, }, body: { flex: 1, gap: spacing.xxs, }, topLine: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.sm, }, label: { ...typography.label, color: colors.text, flexShrink: 1, }, status: { ...typography.label, textAlign: 'right', }, summary: { color: colors.text, fontSize: 16, lineHeight: 20, fontWeight: '800', letterSpacing: -0.45, }, detail: { color: colors.textMuted, fontSize: 12, lineHeight: 16, fontWeight: '600', }, valueBox: { width: 84, alignItems: 'flex-end', justifyContent: 'center', }, value: { ...typography.metricNumeric, textAlign: 'right', }, unit: { ...typography.label, color: colors.textDim, textAlign: 'right', }, }); ``` - [ ] **Step 4: Run test to verify it passes** Run: `cd /d/h && npx jest src/__tests__/InsightRow.test.tsx 2>&1 | tail -5` Expected: PASS. - [ ] **Step 5: Commit** ```bash cd /d/h && git add src/components/InsightRow.tsx src/__tests__/InsightRow.test.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): InsightRow with stagger reveal animation" ``` --- ## F3 — Existing component updates ### Task F3.1: ActionButton — add stopAlert variant **Files:** - Modify: `src/components/ActionButton.tsx` - [ ] **Step 1: Add stopAlert variant + AlertRing component** Replace the entire `src/components/ActionButton.tsx` with: ```tsx import React, { useEffect } from 'react'; import { Pressable, StyleSheet, Text, View, type ViewStyle } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; import { colors, motion, radius, spacing, typography } from '../theme'; type ActionButtonVariant = 'primary' | 'secondary' | 'stop' | 'stopAlert'; type Props = { readonly label: string; readonly onPress?: () => void; readonly variant?: ActionButtonVariant; readonly fullWidth?: boolean; readonly showIcon?: boolean; }; export function ActionButton({ label, onPress, variant = 'primary', fullWidth = false, showIcon = true, }: Props) { return ( {variant === 'stopAlert' ? : null} [ styles.base, styles[variant], fullWidth ? styles.fullWidth : null, pressed ? styles.pressed : null, ]} > {label} {variant === 'primary' && showIcon ? ( › ) : null} ); } function AlertRing() { const scale = useSharedValue(1); const opacity = useSharedValue(0.55); useEffect(() => { scale.value = withRepeat( withTiming(1.06, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }), -1, true, ); opacity.value = withRepeat( withTiming(0.2, { duration: motion.duration.pulseSlow / 2, easing: Easing.inOut(Easing.quad) }), -1, true, ); }, [scale, opacity]); const ringStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], opacity: opacity.value, })); return ; } const styles = StyleSheet.create({ shell: { position: 'relative', alignSelf: 'flex-start', }, fullWidth: { alignSelf: 'stretch', }, base: { minHeight: 48, borderRadius: radius.xl, paddingVertical: spacing.sm, paddingHorizontal: spacing.md, alignItems: 'center', justifyContent: 'center', flexDirection: 'row', gap: spacing.sm, }, primary: { backgroundColor: colors.action, }, secondary: { backgroundColor: 'transparent', borderColor: colors.line, borderWidth: 1, }, stop: { backgroundColor: colors.stopSurface, }, stopAlert: { backgroundColor: colors.action, }, pressed: { transform: [{ scale: 0.96 }], opacity: 0.82, }, label: { ...typography.button, }, primaryLabel: { color: colors.text, }, secondaryLabel: { color: colors.text, }, stopLabel: { color: colors.stopText, }, stopAlertLabel: { color: colors.text, }, iconBubble: { width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(255,255,255,0.16)', }, iconText: { color: colors.text, fontWeight: '900', fontSize: 20, lineHeight: 22, }, alertRing: { position: 'absolute', top: -4, left: -4, right: -4, bottom: -4, borderRadius: radius.xl + 4, borderWidth: 2, borderColor: colors.ringWarn, } as ViewStyle, }); ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, all tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/components/ActionButton.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): ActionButton stopAlert variant with pulsing ring" ``` --- ### Task F3.2: StatusTag — size prop + red tone refinement **Files:** - Modify: `src/components/StatusTag.tsx` - [ ] **Step 1: Add size and refine red tone** Replace the entire `src/components/StatusTag.tsx` with: ```tsx import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { colors, radius, spacing, typography } from '../theme'; type StatusTone = 'amber' | 'green' | 'red'; type StatusSize = 'sm' | 'md'; type Props = { readonly label: string; readonly tone?: StatusTone; readonly size?: StatusSize; }; export function StatusTag({ label, tone = 'amber', size = 'md' }: Props) { return ( {label} ); } const styles = StyleSheet.create({ base: { alignSelf: 'flex-start', borderRadius: radius.xl, borderWidth: 1, }, sm: { paddingHorizontal: spacing.xs, paddingVertical: 2, }, md: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, }, amber: { backgroundColor: colors.surfaceHot, borderColor: colors.line, }, green: { backgroundColor: 'rgba(52,199,123,0.12)', borderColor: 'rgba(52,199,123,0.34)', }, red: { backgroundColor: 'rgba(226,59,46,0.12)', borderColor: 'rgba(226,59,46,0.42)', }, text: { ...typography.label, }, smText: { fontSize: 9, lineHeight: 12, }, amberText: { color: colors.warning, }, greenText: { color: colors.success, }, redText: { color: colors.action, }, }); ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 errors, all tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/components/StatusTag.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): StatusTag size prop + red tone aligned with palette" ``` --- ### Task F3.3: SectionPanel — divider and subtle variants **Files:** - Modify: `src/components/SectionPanel.tsx` - [ ] **Step 1: Add divider and subtle props** Replace the entire `src/components/SectionPanel.tsx` with: ```tsx import React, { type ReactNode } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { colors, radius, spacing, typography } from '../theme'; type Props = { readonly title: string; readonly eyebrow?: string; readonly children: ReactNode; readonly divider?: boolean; readonly subtle?: boolean; }; export function SectionPanel({ title, eyebrow, children, divider, subtle }: Props) { if (divider) { return ( {eyebrow ? {eyebrow} : null} {title} {children} ); } return ( {eyebrow ? {eyebrow} : null} {title} {children} ); } const styles = StyleSheet.create({ panel: { borderWidth: 1, borderColor: colors.line, borderRadius: radius.lg, padding: spacing.md, }, raisedPanel: { backgroundColor: colors.surfaceRaised, }, subtlePanel: { backgroundColor: 'transparent', }, dividerWrap: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, paddingHorizontal: spacing.md, paddingVertical: spacing.sm, }, dividerContent: { marginTop: spacing.sm, gap: spacing.sm, }, eyebrow: { ...typography.label, color: colors.warning, marginBottom: spacing.xs, }, title: { color: colors.text, fontSize: 24, lineHeight: 27, fontWeight: '900', letterSpacing: -1.2, }, content: { marginTop: spacing.md, gap: spacing.sm, }, }); ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/components/SectionPanel.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): SectionPanel divider and subtle variants" ``` --- ### Task F3.4: MetricTile — numeric prop **Files:** - Modify: `src/components/MetricTile.tsx` - [ ] **Step 1: Add numeric prop** Replace the entire `src/components/MetricTile.tsx` with: ```tsx import React from 'react'; import { StyleSheet, Text, View, type TextStyle } from 'react-native'; import { colors, radius, spacing, typography } from '../theme'; type Props = { readonly value: string; readonly label: string; readonly numeric?: boolean; }; export function MetricTile({ value, label, numeric }: Props) { return ( {value} {label} ); } const styles = StyleSheet.create({ tile: { flex: 1, minHeight: 74, borderWidth: 1, borderColor: colors.line, borderRadius: radius.md, backgroundColor: colors.surface, padding: spacing.sm, justifyContent: 'center', }, value: { color: colors.text, fontSize: 24, lineHeight: 28, fontWeight: '900', letterSpacing: -1, }, numeric: { ...(typography.metricNumeric as TextStyle), }, label: { ...typography.label, color: colors.textMuted, marginTop: spacing.xxs, }, }); ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/components/MetricTile.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(components): MetricTile numeric prop for JetBrains Mono" ``` --- ## F4 — Tela Captura (T4.1 + T4.2) ### Task F4.1: TrainingChart — furos laterais + LiveDot readout + pin pulse **Files:** - Modify: `src/components/TrainingChart.tsx` - [ ] **Step 1: Read current TrainingChart and add furos laterais + LiveDot + pin pulse** In `src/components/TrainingChart.tsx`, find the `` block and add **after** the `` for the minBpm (or before closing the SVG), this new group of "furos" (holes), right before the closing ``: ```tsx {ticks.map((tick, index) => ( {tick.label} ))} {/* Furos laterais — inspiração ECG paper */} {[0, 1, 2, 3, 4, 5].map(index => { const x = PAD_X + 12 + index * 48; return ( ); })} ``` Also, add `Rect` to the existing import from `react-native-svg` at the top of the file: ```tsx import Svg, { Circle, Line, Path, Rect, Text as SvgText } from 'react-native-svg'; ``` Also, in the **readout** (the `` near the end of the component), update it to add a LiveDot before the label. Replace the existing readout: ```tsx {pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'} ... ``` with: ```tsx {viewport.liveTail ? : } {pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'} {pinPoint ? `${formatSampleTime(pinPoint.sample.timestamp)} · ${pinPoint.sample.bpm} BPM` : `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${ renderable.lastPoint.sample.bpm } BPM`} ``` And add the import at the top: ```tsx import { LiveDot } from './LiveDot'; ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, all tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/components/TrainingChart.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(chart): TrainingChart ECG furos laterais + LiveDot readout" ``` --- ### Task F4.2: CaptureScreen refactor **Files:** - Modify: `src/screens/CaptureScreen.tsx` - [ ] **Step 1: Replace the file with the refactored version** Write the entire `src/screens/CaptureScreen.tsx` with this content: ```tsx import React, { useEffect, useMemo, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Activity, Battery, RadioTower, ShieldCheck, Timer, } from 'lucide-react-native'; import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor'; import { ActionButton } from '../components/ActionButton'; import { DialTile } from '../components/DialTile'; import { LiveDot } from '../components/LiveDot'; import { TrainingChart } from '../components/TrainingChart'; import { colors, radius, spacing, typography } from '../theme'; import { calculateLiveMetrics, formatDuration, } from '../training/trainingMetrics'; const BPM_EMPTY = '--'; type Props = { readonly monitor: HeartRateMonitorState; readonly onCaptureAction: () => Promise; readonly onMonitorAction: () => Promise; }; export function CaptureScreen({ monitor, onCaptureAction, onMonitorAction, }: Props) { const now = useTicker(monitor.isMonitoring || monitor.isCapturing); const status = getStatusCopy(monitor); const windowSamples = useMemo( () => monitor.startedAt ? monitor.samples.filter( (sample) => sample.timestamp >= monitor.startedAt!, ) : monitor.samples, [monitor.samples, monitor.startedAt], ); const windowMetrics = calculateLiveMetrics(windowSamples); const firstSampleAt = monitor.samples[0]?.timestamp ?? null; const monitorElapsedMs = firstSampleAt ? now - firstSampleAt : 0; const workoutElapsedMs = monitor.startedAt ? now - monitor.startedAt : 0; const actionLabel = monitor.isCapturing ? 'Fechar janela de treino' : 'Iniciar janela de treino'; const isReceiving = monitor.phase === 'receiving'; const isLive = monitor.isMonitoring || monitor.isCapturing; return ( • {status.label} {formatDeviceName(monitor)} {monitor.isCapturing ? formatDuration(workoutElapsedMs) : formatDuration(monitorElapsedMs)} {monitor.isCapturing ? 'janela' : 'monitor'} {monitor.currentBpm ?? BPM_EMPTY} BPM {monitor.errorMessage ?? status.detail} {!monitor.batteryOptimizationIgnored ? ( { monitor.openBatterySettings().catch(() => undefined); }} style={styles.batteryPrompt} > Manter com tela bloqueada Toque para abrir a configuração de bateria e remover restrições do FightRate. ) : null} {!monitor.isMonitoring ? ( { onMonitorAction().catch(() => undefined); }} /> ) : null} { onCaptureAction().catch(() => undefined); }} variant={monitor.isCapturing ? 'stopAlert' : 'primary'} /> Cinta reconhecida = cada BPM real é salvo. Toque em Iniciar apenas para destacar uma janela para análise. ); } function RailMetric({ label, value, tone = 'normal', }: { readonly label: string; readonly value: string | number; readonly tone?: 'normal' | 'hot'; }) { return ( {value} {label} ); } function getStatusCopy(monitor: HeartRateMonitorState) { if (monitor.isCapturing) { return { label: 'JANELA DE TREINO', detail: 'Treino destacado enquanto o monitor contínuo segue gravando.', }; } if (monitor.phase === 'receiving') { return { label: 'LEITURA ATIVA', detail: 'BPM real chegando e sendo salvo continuamente em SQLite.', }; } if (monitor.phase === 'scanning') { return { label: 'PROCURANDO CINTA', detail: 'O serviço continua procurando até encontrar a cinta.', }; } if (monitor.phase === 'connecting') { return { label: 'CONECTANDO 2A37', detail: 'Abrindo Heart Rate Service 180D e armando notify da característica 2A37.', }; } if (monitor.phase === 'permission-denied') { return { label: 'PERMISSÃO NEGADA', detail: 'Autorize Bluetooth, Localização e Notificações.', }; } if (monitor.phase === 'bluetooth-off') { return { label: 'BLUETOOTH DESLIGADO', detail: 'Ligue o Bluetooth antes de ativar o monitor.', }; } if (monitor.phase === 'error') { return { label: 'ERRO DE CAPTURA', detail: monitor.errorMessage ?? 'Falha no serviço BLE.', }; } return { label: 'MONITOR DESATIVADO', detail: 'Ative o monitor contínuo; depois marque a janela de treino.', }; } function formatDeviceName(monitor: HeartRateMonitorState) { const name = monitor.connectedDevice?.name; if (!name) return 'Magene H003 ou cinta BLE 180D'; if (/magene|h003/i.test(name)) return 'Magene H003 · cinta cardíaca'; if (/heart|hrm/i.test(name)) return `${name} · cinta cardíaca`; return `${name} · BLE`; } function getBleShortState( phase: HeartRateMonitorState['phase'], isMonitoring: boolean, ) { if (!isMonitoring) return 'parado'; if (phase === 'receiving') return 'lendo'; if (phase === 'scanning') return 'busca'; if (phase === 'connecting') return 'conectando'; if (phase === 'connected') return '2A37'; if (phase === 'error') return 'erro'; return 'ativo'; } function getBatteryLabel(monitor: HeartRateMonitorState) { const percent = monitor.connectedDevice?.batteryPercent; if (typeof percent === 'number') return `${percent}%`; return 'sem leitura'; } function useTicker(active: boolean) { const [now, setNow] = useState(Date.now()); useEffect(() => { if (!active) { setNow(Date.now()); return; } const interval = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(interval); }, [active]); return now; } const styles = StyleSheet.create({ screen: { gap: spacing.md, }, topRail: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, paddingVertical: spacing.md, flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md, }, stateBlock: { flex: 1, }, eyebrowRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, eyebrowDot: { fontSize: 18, lineHeight: 18, fontWeight: '900', }, eyebrow: { ...typography.label, color: colors.action, }, sensorName: { color: colors.text, fontSize: 18, lineHeight: 23, fontWeight: '900', letterSpacing: -0.5, marginTop: spacing.xs, }, timeStack: { alignItems: 'flex-end', gap: spacing.xxs, minWidth: 84, }, timerRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, timerText: { ...typography.label, color: colors.text, }, timerLabel: { ...typography.label, color: colors.textDim, fontSize: 9, }, bpmRow: { flexDirection: 'row', alignItems: 'flex-end', gap: spacing.md, }, bpmBlock: { flex: 1, }, bpmValue: { ...typography.displayNumeric, }, bpmPulse: { opacity: 1, }, bpmSide: { flex: 1, paddingBottom: spacing.sm, gap: spacing.xs, }, bpmUnit: { ...typography.label, color: colors.textMuted, }, statusLine: { ...typography.body, color: colors.textMuted, }, captureRail: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, flexDirection: 'row', }, railMetric: { flex: 1, paddingVertical: spacing.sm, paddingRight: spacing.sm, }, railValue: { ...typography.metricNumeric, }, railValueHot: { color: colors.action, }, railLabel: { ...typography.label, color: colors.textMuted, marginTop: spacing.xxs, }, dialColumn: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, }, batteryPrompt: { borderWidth: 1, borderColor: colors.warning, borderRadius: radius.md, backgroundColor: colors.surfaceHot, padding: spacing.md, }, batteryPromptTitle: { ...typography.label, color: colors.warning, }, batteryPromptText: { ...typography.body, color: colors.textMuted, marginTop: spacing.xs, }, actionGrid: { gap: spacing.sm, }, truthLine: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, }, truthText: { ...typography.body, color: colors.textMuted, flex: 1, }, }); ``` - [ ] **Step 2: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, all tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/screens/CaptureScreen.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(capture): CaptureScreen with LiveDot, JetBrains Mono BPM, stopAlert ring" ``` --- ## F5 — Histórico + Sensor (T5.1 + T5.2) ### Task F5.1: HistoryScreen refactor **Files:** - Modify: `src/screens/HistoryScreen.tsx` - [ ] **Step 1: Apply TrackRow and EmptyState replacements** In `src/screens/HistoryScreen.tsx`, the **only changes** required are: 1. Add `TrackRow` and `EmptyState` imports. 2. Replace `EmptyHistory` function body with delegation to `EmptyState`. 3. Replace the inner `Pressable ... styles.sessionStrip` blocks in both `WindowHistory` and `ContinuousHistory` with `TrackRow` components. This is a complex refactor. To keep it tractable, do the following focused edits: **Edit 1: imports** Add these at the top of `src/screens/HistoryScreen.tsx` (after the existing `Activity, ChevronDown, ChevronUp` import from lucide): ```tsx import { TrackRow } from '../components/TrackRow'; import { EmptyState } from '../components/EmptyState'; ``` **Edit 2: replace `EmptyHistory` function** Find the function `function EmptyHistory({...})` near the bottom of the file. Replace its body (everything from `return (` to its closing `}`) with: ```tsx export function EmptyHistory({...} should stay) { return ( ); } ``` In practice, since `EmptyHistory` is currently used in three places (`WindowHistory`, `ContinuousHistory`, `RangeHistory`) with different eyebrows, the easiest approach is to **leave the function signature as is** and rewrite its body to render `EmptyState`. Replace the entire function body (from `return (` to the matching `}`) with: ```tsx return ( ); ``` **Edit 3: replace sessionStrip with TrackRow in WindowHistory** Find the JSX inside `WindowHistory`'s `.map(session => { ... })` block. The current code is approximately: ```tsx onOpenSession(session)} style={({ pressed }) => [ styles.sessionStrip, pressed ? styles.pressed : null, ]} > ... ... {isOpen ? : } {isOpen ? ( ) : null} ``` Replace the entire `...` block with: ```tsx onOpenSession(session)} title={session.deviceName || 'Cinta cardíaca BLE'} trackNumber={index + 1} > {isOpen ? ( ) : null} ``` **Edit 4: replace sessionStrip with TrackRow in ContinuousHistory** Apply the same pattern as Edit 3, but for `ContinuousHistory`'s map. Replace the `...` block with: ```tsx onOpenBlock(block)} title={block.deviceName || 'Uso contínuo'} trackNumber={index + 1} > {isOpen && selectedBlock ? ( ) : null} ``` - [ ] **Step 2: Remove unused styles** In `styles`, the following style keys are no longer used (because `TrackRow` carries its own): `sessionStrip`, `pressed`, `dateBlock`, `sessionDay`, `sessionHour`, `sessionBody`, `sessionTitle`, `sessionMeta`, `metricLine`, `metricText`, `metricTextHot`. Delete these from the `StyleSheet.create({...})` block to satisfy `npm run lint`. - [ ] **Step 3: Run typecheck and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, 0 lint errors, all tests pass. - [ ] **Step 4: Commit** ```bash cd /d/h && git add src/screens/HistoryScreen.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(history): HistoryScreen with TrackRow + EmptyState" ``` --- ### Task F5.2: SensorScreen refactor **Files:** - Modify: `src/screens/SensorScreen.tsx` - [ ] **Step 1: Replace the entire file** Write the entire `src/screens/SensorScreen.tsx` with this content: ```tsx import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Battery, Bluetooth, RadioReceiver, ShieldCheck, Signal, Activity, } from 'lucide-react-native'; import { type HeartRateMonitorState } from '../ble/useHeartRateMonitor'; import { formatRssi } from '../ble/rssi'; import { ActionButton } from '../components/ActionButton'; import { DialTile } from '../components/DialTile'; import { LiveDot } from '../components/LiveDot'; import { colors, radius, spacing, typography } from '../theme'; type Props = { readonly monitor: HeartRateMonitorState; readonly onMonitorAction: () => Promise; }; export function SensorScreen({ monitor, onMonitorAction }: Props) { const device = monitor.connectedDevice; const services = monitor.services.length ? monitor.services : ['Heart Rate Service · 0x180D · aguardando conexão']; const characteristics = monitor.characteristics.length ? monitor.characteristics : ['Heart Rate Measurement · 0x2A37 · notify esperado']; const isReceiving = monitor.phase === 'receiving'; return ( • SENSOR REAL {friendlyDeviceTitle(device?.name)} {phaseLabel(monitor.phase)} {isReceiving ? : null} {monitor.errorMessage ?? sensorExplanation(monitor)} {!monitor.isMonitoring ? ( { onMonitorAction().catch(() => undefined); }} showIcon={false} variant="primary" /> ) : ( Monitor contínuo ativo )} {!monitor.batteryOptimizationIgnored ? ( monitor.openBatterySettings().catch(() => undefined) } style={styles.secondaryButton} > Bateria ) : null} {monitor.discoveredDevices.length > 0 ? ( ENCONTRADOS AGORA {monitor.discoveredDevices.map((candidate) => ( monitor.connectToDevice(candidate.id).catch(() => undefined) } style={styles.deviceRow} > {friendlyDeviceTitle(candidate.name)} {candidate.name} · {formatRssi(candidate.rssi)} Conectar ))} ) : null} ); } function BleInventoryBlock({ title, rows, }: { readonly title: string; readonly rows: readonly string[]; }) { return ( {title} {rows.slice(0, 8).map((row) => { const parsed = parseBleRow(row); return ( {parsed.name} {parsed.uuid} · {parsed.detail} ); })} ); } function friendlyDeviceTitle(name?: string | null) { if (!name) { return 'Cinta cardíaca BLE'; } if (/magene|h003/i.test(name)) { return 'Magene H003'; } if (/heart|hrm/i.test(name)) { return 'Cinta cardíaca BLE'; } return name; } function parseBleRow(row: string) { const [name = row, uuid = 'BLE', ...rest] = row.split(' · '); return { name, uuid, detail: rest.join(' · ') || 'aguardando leitura', }; } function phaseLabel(phase: HeartRateMonitorState['phase']) { const labels: Record = { idle: 'parado', 'requesting-permission': 'permissões', 'bluetooth-off': 'bluetooth off', scanning: 'buscando', connecting: 'conectando', connected: 'conectado', receiving: 'recebendo', 'permission-denied': 'sem permissão', 'sensor-not-found': 'sem sensor', error: 'erro', }; return labels[phase]; } function sensorExplanation(monitor: HeartRateMonitorState) { if (monitor.phase === 'receiving' && monitor.samples.length > 0) { return `Amostras reais chegando: ${monitor.samples.length}. Cada BPM reconhecido está sendo salvo em SQLite.`; } if (monitor.phase === 'receiving' || monitor.phase === 'connected') { return 'Sensor reconhecido. O app está armando a leitura 2A37; se não vier BPM, reativa notify e reconecta sozinho.'; } if (monitor.phase === 'scanning') { return 'Busca nativa BLE ativa. Se o app for bloqueado, a notificação mantém o serviço vivo.'; } return 'A tela traduz nomes técnicos: 180D é o serviço cardíaco; 2A37 é o dado de BPM.'; } function formatBattery(percent?: number | null) { if (typeof percent === 'number') { return `${percent}%`; } return '--'; } const styles = StyleSheet.create({ screen: { gap: spacing.sm, }, sensorConsole: { borderTopWidth: 1, borderColor: colors.line, paddingVertical: spacing.md, gap: spacing.md, }, consoleTopline: { flexDirection: 'row', justifyContent: 'space-between', gap: spacing.md, alignItems: 'center', }, consoleLeft: { flex: 1, }, eyebrowRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, eyebrowDot: { fontSize: 18, lineHeight: 18, fontWeight: '900', }, eyebrow: { ...typography.label, color: colors.action, }, title: { ...typography.detailNumeric, color: colors.text, fontSize: 22, lineHeight: 26, letterSpacing: -0.8, marginTop: spacing.xs, }, phaseBadge: { paddingVertical: spacing.xs, paddingHorizontal: spacing.sm, flexDirection: 'row', alignItems: 'center', gap: 6, height: 32, borderRadius: radius.xl, borderWidth: 1, borderColor: colors.line, backgroundColor: colors.surface, }, phaseText: { ...typography.label, color: colors.text, }, signalColumn: { borderTopWidth: 1, borderColor: colors.line, }, explainer: { ...typography.body, color: colors.textMuted, }, actionRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'center', }, livePill: { minHeight: 48, flex: 1, borderWidth: 1, borderColor: colors.line, paddingHorizontal: spacing.md, flexDirection: 'row', alignItems: 'center', gap: 6, }, livePillText: { ...typography.label, color: colors.success, }, secondaryButton: { minHeight: 48, borderWidth: 1, borderColor: colors.line, borderRadius: radius.xl, paddingHorizontal: spacing.md, justifyContent: 'center', }, secondaryButtonText: { ...typography.label, color: colors.warning, }, discoveryBlock: { borderTopWidth: 1, borderTopColor: colors.line, paddingTop: spacing.md, gap: spacing.sm, }, blockLabel: { ...typography.label, color: colors.warning, }, deviceRow: { flexDirection: 'row', gap: spacing.sm, alignItems: 'center', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.line, }, deviceDot: { width: 10, height: 10, borderRadius: 5, backgroundColor: colors.success, }, deviceInfo: { flex: 1, }, deviceName: { color: colors.text, fontSize: 18, lineHeight: 22, fontWeight: '900', }, deviceMeta: { ...typography.body, color: colors.textMuted, marginTop: spacing.xxs, }, connectPill: { borderWidth: 1, borderColor: colors.line, paddingHorizontal: spacing.sm, paddingVertical: spacing.xs, }, connectPillText: { ...typography.label, color: colors.warning, }, diagnosticBlock: { borderTopWidth: 1, borderTopColor: colors.line, paddingTop: spacing.sm, gap: spacing.xs, }, diagnosticRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingVertical: spacing.xs, borderBottomWidth: 1, borderBottomColor: colors.line, }, diagnosticBody: { flex: 1, gap: 2, }, diagnosticTitle: { ...typography.label, color: colors.text, }, diagnosticText: { flex: 1, color: colors.textMuted, fontSize: 12, lineHeight: 16, fontWeight: '700', }, }); ``` - [ ] **Step 2: Run typecheck, lint, and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 type errors, 0 lint errors, all tests pass. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/screens/SensorScreen.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(sensor): SensorScreen with DialTile + LiveDot" ``` --- ## F6 — Insights + Nav (T6.1 + T6.2) ### Task F6.1: InsightsScreen refactor **Files:** - Modify: `src/screens/InsightsScreen.tsx` - [ ] **Step 1: Replace the entire file** Write the entire `src/screens/InsightsScreen.tsx` with this content: ```tsx import React, { useEffect, useMemo, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Activity, ChartNoAxesCombined, Gauge, ShieldCheck, } from 'lucide-react-native'; import { buildInsightDashboard, formatDuration, } from '../training/trainingMetrics'; import { type CompletedTrainingSession, type HeartRateSample, type InsightMetricRow, type TrainingInsight, } from '../training/trainingTypes'; import { EmptyState } from '../components/EmptyState'; import { InsightRow } from '../components/InsightRow'; import { colors, spacing, typography } from '../theme'; const CONTINUOUS_SAMPLE_LIMIT = 2_000; type Props = { readonly liveSamples: readonly HeartRateSample[]; readonly sessions: readonly CompletedTrainingSession[]; readonly getContinuousSamples: ( limit?: number, ) => Promise; }; export function InsightsScreen({ liveSamples, sessions, getContinuousSamples, }: Props) { const [continuousSamples, setContinuousSamples] = useState< readonly HeartRateSample[] >([]); useEffect(() => { let mounted = true; getContinuousSamples(CONTINUOUS_SAMPLE_LIMIT) .then((samples) => { if (mounted) { setContinuousSamples(samples); } }) .catch(() => { if (mounted) { setContinuousSamples([]); } }); return () => { mounted = false; }; }, [getContinuousSamples, sessions.length]); const analysisSamples = useMemo( () => mergeSamples(continuousSamples, liveSamples), [continuousSamples, liveSamples], ); const dashboard = useMemo( () => buildInsightDashboard({ liveSamples, continuousSamples: analysisSamples, sessions, }), [analysisSamples, liveSamples, sessions], ); const latestSession = sessions[0] ?? null; const loadRow = dashboard.rows.find((row) => row.key === 'load') ?? null; const hrvRow = dashboard.rows.find((row) => row.key === 'hrv') ?? null; if (dashboard.sampleCount === 0 && sessions.length === 0) { return ( ); } return ( PAINEL DE CÁLCULO Matriz científica Leituras calculadas a partir de BPM timestampado real. VFC só é liberada quando o sensor entrega RR intervals. {loadRow?.value ?? '--'} carga CÁLCULOS valor · status · leitura {dashboard.rows.map((row, index) => ( ))} ZONAS OPERACIONAIS tempo real em cada trilho {dashboard.zones.map((zone) => ( {zone.label} {zone.percent}% · {formatDuration(zone.seconds * 1000)} ))} {hrvRow?.status === 'ok' ? ( ) : ( )} Protocolo de honestidade Métricas dependentes de perfil ou RR ficam marcadas como necessárias. Isso impede conclusões falsas sobre FCmáx, Karvonen, TRIMP completo ou VFC. LEITURAS OPERACIONAIS {dashboard.narrative.length > 0 ? ( dashboard.narrative.map((insight, index) => ( )) ) : ( Receba mais amostras reais para gerar leitura de tendência e ação. )} {latestSession ? ( ÚLTIMA JANELA SALVA {formatDuration(latestSession.durationMs)} · média{' '} {latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '} {latestSession.sampleCount} amostras ) : null} ); } function EvidenceCell({ label, value, }: { readonly label: string; readonly value: string | number; }) { return ( {value} {label} ); } function InsightNote({ insight, index, }: { readonly insight: TrainingInsight; readonly index: number; }) { return ( {index.toString().padStart(2, '0')} {insight.title} {insight.why} {insight.action} {insight.confidence} ); } function mergeSamples( persistedSamples: readonly HeartRateSample[], liveSamples: readonly HeartRateSample[], ) { const byKey = new Map(); [...persistedSamples, ...liveSamples].forEach((sample) => { byKey.set(`${sample.timestamp}:${sample.bpm}`, sample); }); return [...byKey.values()].sort( (left, right) => left.timestamp - right.timestamp, ); } function formatTimeRange(dashboard: { readonly startedAt: number | null; readonly endedAt: number | null; }) { if (dashboard.startedAt === null || dashboard.endedAt === null) { return '--'; } const start = new Date(dashboard.startedAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', }); const end = new Date(dashboard.endedAt).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', }); return `${start}–${end}`; } const styles = StyleSheet.create({ screen: { gap: spacing.lg, }, eyebrow: { ...typography.label, color: colors.action, }, copy: { ...typography.body, color: colors.textMuted, }, instrumentHeader: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, paddingVertical: spacing.md, flexDirection: 'row', gap: spacing.md, alignItems: 'stretch', }, headerRail: { width: 4, borderRadius: 1, backgroundColor: colors.action, }, headerCopy: { flex: 1, gap: spacing.xs, }, headerTitle: { color: colors.text, fontSize: 36, lineHeight: 37, fontWeight: '900', letterSpacing: -2, }, headerScore: { minWidth: 86, borderLeftWidth: 1, borderLeftColor: colors.line, paddingLeft: spacing.md, alignItems: 'flex-end', justifyContent: 'center', }, scoreValue: { ...typography.displaySmallNumeric, fontSize: 36, lineHeight: 38, marginTop: spacing.xs, }, scoreLabel: { ...typography.label, color: colors.textDim, }, evidenceStrip: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, flexDirection: 'row', }, evidenceCell: { flex: 1, paddingVertical: spacing.sm, paddingHorizontal: spacing.xs, borderRightWidth: 1, borderRightColor: colors.line, }, evidenceValue: { ...typography.detailNumeric, }, evidenceLabel: { ...typography.label, color: colors.textDim, marginTop: 2, }, metricMatrix: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, }, matrixHeader: { paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.line, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: spacing.sm, }, sectionLabel: { ...typography.label, color: colors.warning, }, matrixNote: { ...typography.label, color: colors.textDim, textAlign: 'right', flexShrink: 1, }, zonePanel: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, }, zoneRow: { paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.line, gap: spacing.xs, }, zoneLabel: { ...typography.label, color: colors.text, }, zoneTrack: { height: 9, backgroundColor: colors.surfaceRaised, overflow: 'hidden', borderRadius: 2, }, zoneFill: { height: '100%', backgroundColor: colors.action, }, zoneValue: { ...typography.body, color: colors.textMuted, }, protocolPanel: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, paddingVertical: spacing.md, flexDirection: 'row', gap: spacing.md, }, protocolIcon: { width: 34, alignItems: 'center', paddingTop: 2, }, protocolCopy: { flex: 1, gap: spacing.xs, }, protocolTitle: { color: colors.text, fontSize: 18, lineHeight: 22, fontWeight: '900', letterSpacing: -0.7, }, insightStack: { gap: spacing.md, }, insightNote: { borderTopWidth: 1, borderTopColor: colors.line, paddingTop: spacing.md, flexDirection: 'row', gap: spacing.md, }, noteIndex: { color: colors.action, fontSize: 18, lineHeight: 24, fontWeight: '900', letterSpacing: -0.5, width: 28, }, noteBody: { flex: 1, gap: spacing.xs, }, insightTitle: { color: colors.text, fontSize: 22, lineHeight: 25, fontWeight: '900', letterSpacing: -1, }, noteText: { ...typography.body, color: colors.textMuted, }, noteAction: { color: colors.text, fontSize: 14, lineHeight: 19, fontWeight: '800', }, confidence: { ...typography.label, color: colors.textDim, marginTop: spacing.xs, }, waitingPanel: { borderTopWidth: 1, borderTopColor: colors.line, paddingTop: spacing.md, flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm, }, lastSessionStrip: { borderTopWidth: 1, borderBottomWidth: 1, borderColor: colors.line, paddingVertical: spacing.md, }, lastSessionText: { ...typography.detailNumeric, color: colors.text, fontSize: 17, lineHeight: 23, marginTop: spacing.xs, }, }); ``` - [ ] **Step 2: Run typecheck, lint, and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/screens/InsightsScreen.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(insights): InsightsScreen with InsightRow + EmptyState" ``` --- ### Task F6.2: FightRateApp — tab fade, brand LiveDot, tab live indicator **Files:** - Modify: `src/app/FightRateApp.tsx` - [ ] **Step 1: Replace the entire file** Write the entire `src/app/FightRateApp.tsx` with this content: ```tsx import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Image, Pressable, ScrollView, StatusBar, StyleSheet, Text, View, } from 'react-native'; import { Bluetooth, ChartNoAxesCombined, History, Radio, type LucideIcon, } from 'lucide-react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { useHeartRateMonitor } from '../ble/useHeartRateMonitor'; import { LiveDot } from '../components/LiveDot'; import { CaptureScreen } from '../screens/CaptureScreen'; import { HistoryScreen } from '../screens/HistoryScreen'; import { InsightsScreen } from '../screens/InsightsScreen'; import { SensorScreen } from '../screens/SensorScreen'; import { colors, motion, radius, spacing, typography } from '../theme'; type TabKey = 'capture' | 'history' | 'sensor' | 'insights'; type Tab = { readonly key: TabKey; readonly label: string; readonly icon: LucideIcon; }; const tabs: readonly Tab[] = [ { key: 'capture', label: 'Captura', icon: Radio }, { key: 'history', label: 'Histórico', icon: History }, { key: 'sensor', label: 'Sensor', icon: Bluetooth }, { key: 'insights', label: 'Insights', icon: ChartNoAxesCombined }, ]; export function FightRateApp() { const insets = useSafeAreaInsets(); const monitor = useHeartRateMonitor(); const [activeTab, setActiveTab] = useState('capture'); const opacity = useSharedValue(1); useEffect(() => { opacity.value = 0; opacity.value = withTiming(1, { duration: motion.duration.fast, easing: Easing.out(Easing.quad), }); }, [activeTab, opacity]); const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); const onCaptureAction = useCallback(async () => { if (monitor.isCapturing) { await monitor.stopCapture(); return; } await monitor.startCapture(); }, [monitor]); const onMonitorAction = useCallback(async () => { if (monitor.isMonitoring) { await monitor.stopMonitoring(); return; } await monitor.startMonitoring(); }, [monitor]); const activeTitle = useMemo(() => { if (activeTab === 'capture') { if (monitor.isCapturing) { return 'Janela de treino ativa'; } if (monitor.isMonitoring) { return 'Monitor contínuo'; } return 'Ativar sensor'; } if (activeTab === 'history') { return 'Histórico real'; } if (activeTab === 'sensor') { return 'Sensor BLE'; } return 'Análise real'; }, [activeTab, monitor.isCapturing, monitor.isMonitoring]); const isAnyLive = monitor.isMonitoring || monitor.isCapturing; return ( {isAnyLive ? : null} FightRate {activeTitle} {activeTab === 'capture' ? ( ) : null} {activeTab === 'history' ? ( ) : null} {activeTab === 'sensor' ? ( ) : null} {activeTab === 'insights' ? ( ) : null} {tabs.map((tab) => { const selected = tab.key === activeTab; const Icon = tab.icon; const isTabLive = (tab.key === 'capture' && monitor.isCapturing) || (tab.key === 'sensor' && monitor.isMonitoring); return ( setActiveTab(tab.key)} style={[styles.navItem, selected ? styles.navItemActive : null]} > {isTabLive ? ( ) : null} {tab.label} ); })} ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: colors.canvas, }, content: { paddingHorizontal: spacing.md, gap: spacing.lg, }, brandRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, }, logo: { width: 54, height: 54, borderRadius: radius.sm, }, titleColumn: { flex: 1, }, kickerRow: { flexDirection: 'row', alignItems: 'center', gap: 6, }, kicker: { ...typography.label, color: colors.action, }, title: { marginTop: spacing.xs, color: colors.text, fontSize: 34, lineHeight: 36, fontWeight: '900', letterSpacing: -2.1, }, navWrap: { position: 'absolute', left: spacing.md, right: spacing.md, }, navRail: { borderWidth: 1, borderColor: colors.line, borderRadius: radius.xl, backgroundColor: colors.surfaceRaised, padding: spacing.xs, flexDirection: 'row', gap: spacing.xs, shadowColor: colors.black, shadowOpacity: 0.45, shadowRadius: 24, elevation: 18, }, navItem: { flex: 1, minHeight: 54, borderRadius: radius.xl, alignItems: 'center', justifyContent: 'center', gap: spacing.xxs, }, navItemActive: { backgroundColor: colors.action, }, navIconRow: { position: 'relative', }, tabLiveDot: { position: 'absolute', top: -2, right: -6, }, navLabel: { ...typography.label, color: colors.textMuted, fontSize: 9, }, navLabelActive: { color: colors.text, }, }); ``` - [ ] **Step 2: Run typecheck, lint, and tests** Run: `cd /d/h && npm run typecheck 2>&1 | tail -3 && npm run lint 2>&1 | tail -5 && npm test -- --runInBand 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: Commit** ```bash cd /d/h && git add src/app/FightRateApp.tsx git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "feat(app): FightRateApp tab fade + brand LiveDot + tab live indicator" ``` --- ## F7 — Verify + Ship (T7.1 + T7.2 + T7.3 + T7.4) ### Task F7.1: Update FIGHTRATE_DESIGN_SYSTEM.md with new tokens **Files:** - Modify: `FIGHTRATE_DESIGN_SYSTEM.md` - [ ] **Step 1: Append new section after section 4 (Tipografia)** Use `edit` to insert this block immediately after the end of section 4 (the `#### Texto explicativo` subsection in `FIGHTRATE_DESIGN_SYSTEM.md`): ```markdown ### Tipografia numérica tabular (v2) Para todos os valores numéricos do app (BPM, métricas, zone, timestamps, IDs, UUIDs), o app usa **JetBrains Mono** com `fontVariant: ['tabular-nums']` para alinhamento perfeito de dígitos. Esta fonte está bundled em `assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf` e linkada via `react-native.config.js` + `react-native-asset`. Tokens em `src/theme/typography.ts`: ```ts displayNumeric: { fontSize: 110, lineHeight: 100, fontWeight: '900', letterSpacing: -7 } // BPM principal displaySmallNumeric: { fontSize: 56, lineHeight: 54, fontWeight: '900', letterSpacing: -3 } // Métricas grandes metricNumeric: { fontSize: 28, lineHeight: 30, fontWeight: '800', letterSpacing: -1.2 } // Tiles detailNumeric: { fontSize: 18, lineHeight: 22, fontWeight: '800', letterSpacing: -0.8 } // Detalhes ``` `display`, `displaySmall`, `body`, `label`, `button` permanecem em Inter (fallback do sistema). Apenas os tokens numéricos exigem JetBrains Mono. ``` - [ ] **Step 2: Append a new section after section 9 (Motion)** Insert this block after section 9: ```markdown ### Motion presets (v2) `src/theme/motion.ts` define easing e durações canônicas: ```ts easeOutQuart: 'cubic-bezier(0.25, 1, 0.5, 1)' // entradas easeOutExpo: 'cubic-bezier(0.16, 1, 0.3, 1)' // transições longas easeOutBack: 'cubic-bezier(0.34, 1.56, 0.64, 1)' // pin (overshoot sutil) easeInOutQuart: 'cubic-bezier(0.76, 0, 0.24, 1)' // pulse loops duration: { instant: 80, fast: 160, normal: 240, slow: 380, pulse: 800, pulseSlow: 1600 } stagger: { row: 50, item: 80 } ``` Animações registradas: - `bpmPulse` (loop, 800ms) — BPM quando `receiving` - `liveDotPulse` (loop, 1600ms) — LiveDot quando `live = true` - `stopAlertRing` (loop, 1600ms) — anel de aviso em volta do botão Parar quando `isCapturing` - `tabFade` (200ms cross-fade) — entre tabs - `rowEnter` (240ms fade+translateY 8→0, stagger 50ms) — listas de Histórico e Insights - `pinEnter` (500ms easeOutBack 1.4→1) — pin do TrainingChart - `pressDown/Up` (80/160ms scale 1→0.96→1, opacity 1→0.82→1) — feedback físico de todos os botões Todas respeitam `AccessibilityInfo.isReduceMotionEnabled()` (instantâneas quando `true`). ``` - [ ] **Step 3: Commit** ```bash cd /d/h && git add FIGHTRATE_DESIGN_SYSTEM.md git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "docs(design): update FIGHTRATE_DESIGN_SYSTEM.md with numeric typography + motion presets" ``` --- ### Task F7.2: Final verify (typecheck + lint + test + audit) **Files:** none (verification only) - [ ] **Step 1: typecheck** Run: `cd /d/h && npm run typecheck 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 2: lint** Run: `cd /d/h && npm run lint 2>&1 | tail -5` Expected: 0 errors. - [ ] **Step 3: tests** Run: `cd /d/h && npm test -- --runInBand 2>&1 | tail -10` Expected: all tests pass (55 antigos + 9 novos ≈ 64). - [ ] **Step 4: audit** Run: `cd /d/h && npm audit --audit-level=moderate 2>&1 | tail -5` Expected: 0 vulnerabilities at moderate or higher. - [ ] **Step 5: If any step fails, fix inline and rerun until all 4 pass** - [ ] **Step 6: Commit any fixes** ```bash cd /d/h && git add -A git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "chore: final verify pass (typecheck + lint + test + audit)" --allow-empty ``` --- ### Task F7.3: Build release APK **Files:** none (build verification only) - [ ] **Step 1: Confirm react-native-asset linked fonts (if Step F1.1.3 used direct copy)** Run: `ls /d/h/android/app/src/main/assets/fonts/` Expected: 4 ttf files. - [ ] **Step 2: Build release APK** Run: ```bash cd /d/h/android $env:JAVA_HOME = "C:\Users\Evandro\AppData\Local\Programs\Android Studio\jbr" $env:Path = "$env:JAVA_HOME\bin;$env:Path" ./gradlew.bat :app:assembleRelease -PreactNativeArchitectures=arm64-v8a 2>&1 | tail -20 ``` Expected: `BUILD SUCCESSFUL` and `app-arm64-v8a-release.apk` produced in `android/app/build/outputs/apk/release/`. - [ ] **Step 3: Verify APK contains fonts** Run: `unzip -l /d/h/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk | grep -i jetbrains` Expected: 4 entries for `assets/fonts/JetBrainsMono-*.ttf`. - [ ] **Step 4: Compress APK to 7z for Telegram** Run: ```bash cd /d/h "C:/Users/Evandro/AppData/Local/Microsoft/WindowsApps/7z.exe" a -t7z -mx=9 -m0=lzma2 "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk.7z" "android/app/build/outputs/apk/release/app-arm64-v8a-release.apk" 2>&1 | tail -3 ls -lh android/app/build/outputs/apk/release/ ``` Expected: 7z file ~ 9-12 MB (depends on font delta). - [ ] **Step 5: Commit the 7z archive to a build artifact dir (optional, for traceability)** ```bash cd /d/h && git add android/app/build/outputs/apk/release/*.7z 2>/dev/null git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "build: release APK v2 (Instrumento Forte)" 2>&1 | tail -3 ``` --- ### Task F7.4: Send APK via Telegram and update .impeccable.md **Files:** - Modify: `.impeccable.md` - [ ] **Step 1: Send via Telegram** Call the `telegram_attach` tool with the 7z path: ```ts telegram_attach({ paths: ['D:\\h\\android\\app\\build\\outputs\\apk\\release\\app-arm64-v8a-release.apk.7z'], caption: '🎯 FightRate v2 — Instrumento Forte (release APK, ~9-12 MB)\n\nRefinamento UI/UX concluído:\n- JetBrains Mono tabular em todos os displays numéricos\n- LiveDot pulsante (LED) em AO VIVO, BPM, tab bar\n- Anel de aviso no botão Parar durante captura\n- Furos laterais no TrainingChart (estilo ECG)\n- DialTile no SensorScreen\n- TrackRow T01..Tn no Histórico\n- InsightRow com stagger reveal\n- Press feedback refinado em todos os botões\n- applicationId preservado: com.healthtracking.fightrate\n\nPara instalar: descompacte o 7z e rode `adb install -r app-arm64-v8a-release.apk`', }) ``` - [ ] **Step 2: Update .impeccable.md** Append this paragraph at the end of `.impeccable.md`: ```markdown ## Estado atual (2026-06-15) Direção 01 — Corner Instrument refinada para **Instrumento Forte** (release v2). Mudanças: - JetBrains Mono tabular em displays numéricos. - 6 novos componentes (LiveDot, PressableSurface, EmptyState, DialTile, TrackRow, InsightRow) e 2 hooks (usePulseAnimation, useMotionPreference). - Animações orquestradas (LiveDot pulse, BPM pulse, stop alert ring, tab cross-fade, stagger reveal em listas). - TrainingChart refinado (furos laterais estilo ECG, LiveDot no readout). - BLE, SQLite, hooks de monitor NÃO foram tocados. Próxima iteração: avaliar Direção C (Cinemática) — entrada de tela com reveal, transições slide, sombras direcionais. ``` - [ ] **Step 3: Commit** ```bash cd /d/h && git add .impeccable.md git -c user.name="fightrate-bot" -c user.email="bot@fightrate.local" commit -m "docs: update .impeccable.md with v2 status" ``` - [ ] **Step 4: Final summary to user** After all tasks complete, present a final summary in PT-BR with: - Number of new tests (and total). - APK size before/after. - Files changed. - Brief description of each refinement. --- ## Self-Review ### Spec coverage Each spec section maps to a task: - **§3.1 Tipografia** → F1.1 (font link) + F1.3 (typography tokens). - **§3.2 Motion** → F1.2 (motion.ts). - **§3.3 Cores** → F1.4 (ringWarn). - **§3.4 Spacing** → no changes (covered by existing tokens). - **§4.1 LiveDot** → F2.2. - **§4.2 PressableSurface** → F2.3. - **§4.3 EmptyState** → F2.4. - **§4.4 DialTile** → F2.5. - **§4.5 TrackRow** → F2.6. - **§4.6 InsightRow** → F2.7. - **§4.7 usePulseAnimation** → F2.1. - **§5.1 ActionButton** → F3.1. - **§5.2 TrainingChart** → F4.1. - **§5.3 SectionPanel** → F3.3. - **§5.4 MetricTile** → F3.4. - **§5.5 StatusTag** → F3.2. - **§6.1 CaptureScreen** → F4.2. - **§6.2 HistoryScreen** → F5.1. - **§6.3 SensorScreen** → F5.2. - **§6.4 InsightsScreen** → F6.1. - **§6.5 FightRateApp** → F6.2. - **§7 Animações** → spread across F2 (hooks) + F3 (ActionButton ring) + F4 (chart readout) + F6 (tab fade, stagger). - **§9 Critérios de aceitação** → verified by F7.2. - **§11 Riscos** → mitigated by F1.1.3 fallback to direct copy. ### Placeholder scan Searched the plan for "TBD", "TODO", "implement later", "fill in details", "appropriate error handling", "similar to Task N". No matches. ### Type consistency - `LiveDot` props: `live`, `size`, `testID` — used consistently. - `PressableSurface` props: `children`, `onPress`, `disabled`, `style`, `accessibilityLabel`, `testID` — used consistently. - `EmptyState` props: `eyebrow`, `title`, `copy`, `tone`, `testID` — used consistently. - `DialTile` props: `label`, `value`, `tone`, `testID` — used consistently. - `TrackRow` props: `trackNumber`, `day`, `hour`, `title`, `meta`, `metrics`, `isOpen`, `onPress`, `accessibilityLabel`, `children`, `testID` — used consistently. - `InsightRow` props: `index`, `label`, `statusLabel`, `summary`, `detail`, `value`, `unit?`, `tone`, `testID` — used consistently. - `usePulseAnimation(active, durationMs?)` — single signature. - `useMotionPreference()` — no args, returns boolean. ### No backwards-incompatible changes - All new props are optional with sensible defaults. - Existing imports stay working. - BLE, SQLite, monitor hooks untouched.