diff --git a/docs/superpowers/plans/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md b/docs/superpowers/plans/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md new file mode 100644 index 0000000..32e610d --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-fightrate-ui-ux-refinamento-instrumento-forte.md @@ -0,0 +1,4451 @@ +# 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.