116 KiB
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.mdConventions: Every commit messages follow Conventional Commits. PT-BR visible UI; English code identifiers. Run
npm run typecheck && npm run lint && npm test -- --runInBandbefore each commit.
File Structure
New files
assets/fonts/JetBrainsMono-{Regular,Medium,Bold,ExtraBold}.ttf(already downloaded)src/theme/motion.tssrc/hooks/usePulseAnimation.tssrc/hooks/useMotionPreference.tssrc/components/LiveDot.tsxsrc/components/PressableSurface.tsxsrc/components/EmptyState.tsxsrc/components/DialTile.tsxsrc/components/TrackRow.tsxsrc/components/InsightRow.tsxsrc/__tests__/motion.test.tssrc/__tests__/useMotionPreference.test.tssrc/__tests__/usePulseAnimation.test.tsxsrc/__tests__/LiveDot.test.tsxsrc/__tests__/PressableSurface.test.tsxsrc/__tests__/EmptyState.test.tsxsrc/__tests__/DialTile.test.tsxsrc/__tests__/TrackRow.test.tsxsrc/__tests__/InsightRow.test.tsx
Modified files
react-native.config.jssrc/theme/index.tssrc/theme/typography.tssrc/theme/colors.tssrc/components/ActionButton.tsxsrc/components/StatusTag.tsxsrc/components/SectionPanel.tsxsrc/components/MetricTile.tsxsrc/components/TrainingChart.tsxsrc/screens/CaptureScreen.tsxsrc/screens/HistoryScreen.tsxsrc/screens/SensorScreen.tsxsrc/screens/InsightsScreen.tsxsrc/app/FightRateApp.tsxFIGHTRATE_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:
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:
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
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="[email protected]" 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:
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:
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:
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
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="[email protected]" 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):
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
cd /d/h && git add src/theme/typography.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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
cd /d/h && git add src/theme/colors.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
import { act, renderHook } from '@testing-library/react-native';
import {
useMotionPreference,
type MotionPreferenceListener,
} from '../hooks/useMotionPreference';
type AccessibilityInfoMock = {
isReduceMotionEnabled: jest.Mock<Promise<boolean>, []>;
addEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
removeEventListener: jest.Mock<void, [string, MotionPreferenceListener]>;
};
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:
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
cd /d/h && git add src/hooks/useMotionPreference.ts src/__tests__/useMotionPreference.test.ts
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
import { act, renderHook } from '@testing-library/react-native';
import { usePulseAnimation } from '../hooks/usePulseAnimation';
jest.mock('react-native-reanimated', () => {
const values: Record<number, { value: number }> = {};
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:
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<AnimatedStyle>(() => ({
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
cd /d/h && git add src/hooks/usePulseAnimation.ts src/__tests__/usePulseAnimation.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(<LiveDot live testID="dot" />);
const node = screen.getByTestId('dot');
expect(node.props.accessibilityLabel).toBe('AO VIVO');
});
it('renders with INATIVO label when live=false', () => {
render(<LiveDot live={false} testID="dot" />);
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:
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 (
<Animated.View
accessibilityLabel={live ? 'AO VIVO' : 'INATIVO'}
accessibilityRole="image"
style={[styles.base, dotStyle, animatedStyle]}
testID={testID}
/>
);
}
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
cd /d/h && git add src/components/LiveDot.tsx src/__tests__/LiveDot.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(
<PressableSurface onPress={() => undefined} testID="surface">
<Text>Hi</Text>
</PressableSurface>,
);
expect(screen.getByText('Hi')).toBeTruthy();
});
it('triggers onPress when pressed', () => {
const onPress = jest.fn();
render(
<PressableSurface onPress={onPress} testID="surface">
<Text>Hi</Text>
</PressableSurface>,
);
fireEvent.press(screen.getByTestId('surface'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('has accessibilityRole=button when accessibilityLabel is set', () => {
render(
<PressableSurface onPress={() => undefined} accessibilityLabel="Tap me" testID="surface">
<Text>Hi</Text>
</PressableSurface>,
);
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:
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<ViewStyle>;
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 (
<Animated.View style={animatedStyle}>
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessibilityLabel ? 'button' : undefined}
disabled={disabled}
onPress={onPress}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={[styles.base, style]}
testID={testID}
>
{children}
</Pressable>
</Animated.View>
);
}
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
cd /d/h && git add src/components/PressableSurface.tsx src/__tests__/PressableSurface.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(
<EmptyState
eyebrow="SEM USO"
title="Nenhum treino salvo"
copy="Comece uma janela para ver seus dados."
/>,
);
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(
<EmptyState eyebrow="X" title="Y" copy="Z" testID="es" />,
);
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:
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 (
<View
accessibilityLabel={`${eyebrow}. ${title}. ${copy}`}
style={[styles.wrap, { backgroundColor: toneBackground(tone) }]}
testID={testID}
>
<View style={styles.geom}>
<Svg height={64} viewBox="0 0 64 64" width={64}>
<Rect
x={6}
y={6}
width={52}
height={52}
stroke={toneColor(tone)}
strokeWidth={1}
fill="none"
/>
<Circle
cx={32}
cy={32}
r={20}
stroke={toneColor(tone)}
strokeWidth={1}
fill="none"
/>
<Line
x1={6}
y1={58}
x2={58}
y2={6}
stroke={toneColor(tone)}
strokeWidth={1}
/>
</Svg>
</View>
<Text style={[styles.eyebrow, { color: toneColor(tone) }]}>{eyebrow}</Text>
<Text style={styles.title}>{title}</Text>
<Text style={styles.copy}>{copy}</Text>
</View>
);
}
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
cd /d/h && git add src/components/EmptyState.tsx src/__tests__/EmptyState.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
expect(screen.getByText('RSSI')).toBeTruthy();
expect(screen.getByText('-61 dBm')).toBeTruthy();
});
it('combines label and value into accessibilityLabel', () => {
render(<DialTile label="RSSI" value="-61 dBm" testID="dial" />);
expect(screen.getByTestId('dial').props.accessibilityLabel).toBe('RSSI: -61 dBm');
});
it('uses action color when tone=red', () => {
const { toJSON } = render(
<DialTile label="X" value="Y" tone="red" testID="dial" />,
);
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:
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 (
<View
accessibilityLabel={`${label}: ${value}`}
style={styles.row}
testID={testID}
>
<View style={styles.markerWrap}>
<View style={[styles.marker, markerStyle]} />
</View>
<Text style={styles.value}>{value}</Text>
<Text style={styles.label}>{label}</Text>
</View>
);
}
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
cd /d/h && git add src/components/DialTile.tsx src/__tests__/DialTile.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(
<TrackRow {...baseProps} testID="row">
<Text />
</TrackRow>,
);
expect(screen.getByText('T03')).toBeTruthy();
});
it('renders day, hour, title, and meta', () => {
render(
<TrackRow {...baseProps} testID="row">
<Text />
</TrackRow>,
);
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(
<TrackRow {...baseProps} testID="row">
<Text />
</TrackRow>,
);
expect(screen.getByText('MÉD 152')).toBeTruthy();
expect(screen.getByText('PICO 184')).toBeTruthy();
});
it('calls onPress when pressed', () => {
render(
<TrackRow {...baseProps} testID="row">
<Text />
</TrackRow>,
);
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:
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<Metric>;
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 (
<View>
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [styles.row, pressed ? styles.pressed : null]}
testID={testID}
>
<View style={[styles.sideRail, sideRailStyle]} />
<View style={styles.trackNumberWrap}>
<Text style={styles.trackNumber}>{trackNumberLabel}</Text>
</View>
<View style={styles.dateBlock}>
<Text style={styles.day}>{day}</Text>
<Text style={styles.hour}>{hour}</Text>
</View>
<View style={styles.body}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.meta}>{meta}</Text>
<View style={styles.metricLine}>
{metrics.map((metric, index) => (
<Text
key={index}
style={[
styles.metricText,
metric.tone === 'hot' ? styles.metricTextHot : null,
]}
>
{metric.label}
</Text>
))}
</View>
</View>
<View style={styles.chevron}>
{isOpen ? (
<ChevronUp color={colors.warning} size={18} strokeWidth={2.6} />
) : (
<ChevronDown color={colors.textMuted} size={18} strokeWidth={2.6} />
)}
</View>
</Pressable>
{children}
</View>
);
}
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
cd /d/h && git add src/components/TrackRow.tsx src/__tests__/TrackRow.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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(
<InsightRow
index={3}
label="Carga"
statusLabel="ok"
summary="Carga alta mas dentro do padrão"
detail="TRIMP 145"
value="145"
unit="TRIMP"
tone="good"
testID="row"
/>,
);
expect(screen.getByText('03')).toBeTruthy();
});
it('renders label, status, summary, detail, and value', () => {
render(
<InsightRow
index={1}
label="Carga"
statusLabel="ok"
summary="Carga alta"
detail="TRIMP 145"
value="145"
unit="TRIMP"
tone="good"
testID="row"
/>,
);
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(
<InsightRow
index={1}
label="Carga"
statusLabel="ok"
summary="Carga alta"
detail="TRIMP 145"
value="145"
tone="good"
testID="row"
/>,
);
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:
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 (
<Animated.View
accessibilityLabel={`${label}, status ${statusLabel}, valor ${value}${unit ? ' ' + unit : ''}`}
style={[styles.row, animatedStyle]}
testID={testID}
>
<Text style={styles.index}>{indexLabel}</Text>
<View style={styles.body}>
<View style={styles.topLine}>
<Text style={styles.label}>{label}</Text>
<Text style={[styles.status, indexStyle]}>{statusLabel}</Text>
</View>
<Text style={styles.summary}>{summary}</Text>
<Text style={styles.detail}>{detail}</Text>
</View>
<View style={styles.valueBox}>
<Text style={[styles.value, indexStyle]}>{value}</Text>
{unit ? <Text style={styles.unit}>{unit}</Text> : null}
</View>
</Animated.View>
);
}
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
cd /d/h && git add src/components/InsightRow.tsx src/__tests__/InsightRow.test.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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 (
<View style={[styles.shell, fullWidth ? styles.fullWidth : null]}>
{variant === 'stopAlert' ? <AlertRing /> : null}
<Pressable
accessibilityRole="button"
onPress={onPress}
style={({ pressed }) => [
styles.base,
styles[variant],
fullWidth ? styles.fullWidth : null,
pressed ? styles.pressed : null,
]}
>
<Text style={[styles.label, styles[`${variant}Label`]]}>{label}</Text>
{variant === 'primary' && showIcon ? (
<View style={styles.iconBubble}>
<Text style={styles.iconText}>›</Text>
</View>
) : null}
</Pressable>
</View>
);
}
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 <Animated.View style={[styles.alertRing, ringStyle]} pointerEvents="none" />;
}
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
cd /d/h && git add src/components/ActionButton.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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 (
<View
accessibilityLabel={label}
style={[styles.base, styles[tone], size === 'sm' ? styles.sm : styles.md]}
>
<Text style={[styles.text, styles[`${tone}Text`], size === 'sm' ? styles.smText : null]}>
{label}
</Text>
</View>
);
}
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
cd /d/h && git add src/components/StatusTag.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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 (
<View style={styles.dividerWrap}>
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
<Text style={styles.title}>{title}</Text>
<View style={styles.dividerContent}>{children}</View>
</View>
);
}
return (
<View
style={[
styles.panel,
subtle ? styles.subtlePanel : styles.raisedPanel,
]}
>
{eyebrow ? <Text style={styles.eyebrow}>{eyebrow}</Text> : null}
<Text style={styles.title}>{title}</Text>
<View style={styles.content}>{children}</View>
</View>
);
}
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
cd /d/h && git add src/components/SectionPanel.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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 (
<View style={styles.tile}>
<Text style={[styles.value, numeric ? styles.numeric : null]}>{value}</Text>
<Text style={styles.label}>{label}</Text>
</View>
);
}
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
cd /d/h && git add src/components/MetricTile.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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 <Svg> block and add after the </SvgText> for the minBpm (or before closing the SVG), this new group of "furos" (holes), right before the closing </Svg>:
{ticks.map((tick, index) => (
<React.Fragment key={`${tick.timestampMs}-${index}`}>
<Line
stroke="rgba(255,255,255,0.06)"
strokeWidth="1"
x1={tick.x}
x2={tick.x}
y1={PAD_Y}
y2={CHART_HEIGHT - PAD_Y}
/>
<SvgText ...>{tick.label}</SvgText>
</React.Fragment>
))}
{/* Furos laterais — inspiração ECG paper */}
{[0, 1, 2, 3, 4, 5].map(index => {
const x = PAD_X + 12 + index * 48;
return (
<Rect
key={`hole-${index}`}
x={x}
y={CHART_HEIGHT - 4}
width={1.5}
height={4}
fill={colors.line}
/>
);
})}
Also, add Rect to the existing import from react-native-svg at the top of the file:
import Svg, { Circle, Line, Path, Rect, Text as SvgText } from 'react-native-svg';
Also, in the readout (the <View style={styles.readout}> near the end of the component), update it to add a LiveDot before the label. Replace the existing readout:
<View style={styles.readout}>
<Text style={styles.readoutLabel}>
{pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
</Text>
...
</View>
with:
<View style={styles.readout}>
{viewport.liveTail ? <LiveDot live={true} size={6} /> : <LiveDot live={false} size={6} />}
<Text style={styles.readoutLabel}>
{pinPoint ? 'AMOSTRA PINADA' : viewport.liveTail ? 'AO VIVO' : 'JANELA'}
</Text>
<Text style={styles.readoutValue}>
{pinPoint
? `${formatSampleTime(pinPoint.sample.timestamp)} · ${pinPoint.sample.bpm} BPM`
: `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${
renderable.lastPoint.sample.bpm
} BPM`}
</Text>
</View>
And add the import at the top:
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
cd /d/h && git add src/components/TrainingChart.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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<void>;
readonly onMonitorAction: () => Promise<void>;
};
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 (
<View style={styles.screen}>
<View style={styles.topRail}>
<View style={styles.stateBlock}>
<View style={styles.eyebrowRow}>
<Text
style={[
styles.eyebrowDot,
{ color: isReceiving ? colors.success : colors.textDim },
]}
>
•
</Text>
<Text style={styles.eyebrow}>{status.label}</Text>
</View>
<Text style={styles.sensorName}>{formatDeviceName(monitor)}</Text>
</View>
<View style={styles.timeStack}>
<View style={styles.timerRow}>
<LiveDot live={isLive} size={6} />
<Text style={styles.timerText}>
{monitor.isCapturing
? formatDuration(workoutElapsedMs)
: formatDuration(monitorElapsedMs)}
</Text>
</View>
<Text style={styles.timerLabel}>
{monitor.isCapturing ? 'janela' : 'monitor'}
</Text>
</View>
</View>
<View style={styles.bpmRow}>
<View style={styles.bpmBlock}>
<Text
style={[
styles.bpmValue,
isReceiving ? styles.bpmPulse : null,
]}
>
{monitor.currentBpm ?? BPM_EMPTY}
</Text>
</View>
<View style={styles.bpmSide}>
<Text style={styles.bpmUnit}>BPM</Text>
<Text style={styles.statusLine}>
{monitor.errorMessage ?? status.detail}
</Text>
</View>
</View>
<View style={styles.captureRail}>
<RailMetric
label="MÉDIA"
value={windowMetrics.averageBpm ?? BPM_EMPTY}
/>
<RailMetric
label="PICO"
value={windowMetrics.peakBpm ?? BPM_EMPTY}
tone="hot"
/>
<RailMetric
label="AMOSTRAS"
value={windowSamples.length || BPM_EMPTY}
/>
</View>
<TrainingChart samples={windowSamples} />
<View style={styles.dialColumn}>
<DialTile
label="BLE"
value={getBleShortState(monitor.phase, monitor.isMonitoring)}
tone={monitor.phase === 'receiving' ? 'green' : 'amber'}
/>
<DialTile label="BATERIA" value={getBatteryLabel(monitor)} />
<DialTile
label="FUNDO"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'otimizar'}
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
/>
</View>
{!monitor.batteryOptimizationIgnored ? (
<Pressable
onPress={() => {
monitor.openBatterySettings().catch(() => undefined);
}}
style={styles.batteryPrompt}
>
<Text style={styles.batteryPromptTitle}>
Manter com tela bloqueada
</Text>
<Text style={styles.batteryPromptText}>
Toque para abrir a configuração de bateria e remover restrições do
FightRate.
</Text>
</Pressable>
) : null}
<View style={styles.actionGrid}>
{!monitor.isMonitoring ? (
<ActionButton
fullWidth
label="Ativar monitor contínuo"
onPress={() => {
onMonitorAction().catch(() => undefined);
}}
/>
) : null}
<ActionButton
fullWidth
label={actionLabel}
onPress={() => {
onCaptureAction().catch(() => undefined);
}}
variant={monitor.isCapturing ? 'stopAlert' : 'primary'}
/>
</View>
<View style={styles.truthLine}>
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
<Text style={styles.truthText}>
Cinta reconhecida = cada BPM real é salvo. Toque em Iniciar apenas
para destacar uma janela para análise.
</Text>
</View>
</View>
);
}
function RailMetric({
label,
value,
tone = 'normal',
}: {
readonly label: string;
readonly value: string | number;
readonly tone?: 'normal' | 'hot';
}) {
return (
<View style={styles.railMetric}>
<Text
style={[styles.railValue, tone === 'hot' ? styles.railValueHot : null]}
>
{value}
</Text>
<Text style={styles.railLabel}>{label}</Text>
</View>
);
}
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
cd /d/h && git add src/screens/CaptureScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
- Add
TrackRowandEmptyStateimports. - Replace
EmptyHistoryfunction body with delegation toEmptyState. - Replace the inner
Pressable ... styles.sessionStripblocks in bothWindowHistoryandContinuousHistorywithTrackRowcomponents.
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):
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:
export function EmptyHistory({...} should stay) {
return (
<EmptyState
eyebrow={eyebrow}
title={title}
copy={copy}
tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
/>
);
}
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:
return (
<EmptyState
eyebrow={eyebrow}
title={title}
copy={copy}
tone={eyebrow === 'ANÁLISE BLOQUEADA' ? 'red' : 'amber'}
/>
);
Edit 3: replace sessionStrip with TrackRow in WindowHistory
Find the JSX inside WindowHistory's .map(session => { ... }) block. The current code is approximately:
<Pressable
accessibilityRole="button"
onPress={() => onOpenSession(session)}
style={({ pressed }) => [
styles.sessionStrip,
pressed ? styles.pressed : null,
]}
>
<View style={styles.dateBlock}>...</View>
<View style={styles.sessionBody}>...</View>
{isOpen ? <OpenIcon /> : <ClosedIcon />}
</Pressable>
{isOpen ? (
<WindowDetail ... />
) : null}
Replace the entire <Pressable ...>...</Pressable> block with:
<TrackRow
accessibilityLabel={`Janela ${formatSessionDay(session.startedAt)} ${formatSessionHour(session.startedAt)}`}
day={formatSessionDay(session.startedAt)}
hour={formatSessionHour(session.startedAt)}
isOpen={isOpen}
meta={`${formatDuration(session.durationMs)} · ${session.sampleCount} amostras`}
metrics={[
{ label: `MÉD ${session.averageBpm}` },
{ label: `PICO ${session.peakBpm}`, tone: 'hot' as const },
{ label: `MIN ${session.minBpm}` },
]}
onPress={() => onOpenSession(session)}
title={session.deviceName || 'Cinta cardíaca BLE'}
trackNumber={index + 1}
>
{isOpen ? (
<WindowDetail
loading={loadingId === session.id}
metrics={selectedMetrics}
samples={selectedSamples}
zones={zones}
/>
) : null}
</TrackRow>
Edit 4: replace sessionStrip with TrackRow in ContinuousHistory
Apply the same pattern as Edit 3, but for ContinuousHistory's map. Replace the <Pressable ...>...</Pressable> block with:
<TrackRow
accessibilityLabel={`Uso contínuo ${formatSessionDay(block.startedAt)}`}
day={formatSessionDay(block.startedAt)}
hour={formatSessionHour(block.startedAt)}
isOpen={isOpen}
meta={formatUsageRange(block) + ' · ' + block.sampleCount + ' amostras'}
metrics={[
{ label: `MÉD ${block.averageBpm}` },
{ label: `PICO ${block.peakBpm}`, tone: 'hot' as const },
{ label: `MIN ${block.minBpm}` },
]}
onPress={() => onOpenBlock(block)}
title={block.deviceName || 'Uso contínuo'}
trackNumber={index + 1}
>
{isOpen && selectedBlock ? (
<UsageDetail block={selectedBlock} />
) : null}
</TrackRow>
- 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
cd /d/h && git add src/screens/HistoryScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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<void>;
};
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 (
<View style={styles.screen}>
<View style={styles.sensorConsole}>
<View style={styles.consoleTopline}>
<View style={styles.consoleLeft}>
<View style={styles.eyebrowRow}>
<Text
style={[
styles.eyebrowDot,
{ color: isReceiving ? colors.success : colors.textDim },
]}
>
•
</Text>
<Text style={styles.eyebrow}>SENSOR REAL</Text>
</View>
<Text style={styles.title}>
{friendlyDeviceTitle(device?.name)}
</Text>
</View>
<View style={styles.phaseBadge}>
<Bluetooth color={colors.warning} size={14} strokeWidth={2.4} />
<Text style={styles.phaseText}>{phaseLabel(monitor.phase)}</Text>
{isReceiving ? <LiveDot live size={5} /> : null}
</View>
</View>
<View style={styles.signalColumn}>
<DialTile
label="RSSI"
value={formatRssi(device?.rssi)}
tone="amber"
/>
<DialTile
label="LEITURAS"
value={`${monitor.samples.length}`}
tone="amber"
/>
<DialTile
label="BATERIA"
value={formatBattery(device?.batteryPercent)}
tone="amber"
/>
<DialTile
label="FUNDO"
value={monitor.batteryOptimizationIgnored ? 'liberado' : 'restrito'}
tone={monitor.batteryOptimizationIgnored ? 'green' : 'amber'}
/>
</View>
<Text style={styles.explainer}>
{monitor.errorMessage ?? sensorExplanation(monitor)}
</Text>
<View style={styles.actionRow}>
{!monitor.isMonitoring ? (
<ActionButton
label="Buscar sensor"
onPress={() => {
onMonitorAction().catch(() => undefined);
}}
showIcon={false}
variant="primary"
/>
) : (
<View style={styles.livePill}>
<LiveDot live size={6} />
<Text style={styles.livePillText}>Monitor contínuo ativo</Text>
</View>
)}
{!monitor.batteryOptimizationIgnored ? (
<Pressable
onPress={() =>
monitor.openBatterySettings().catch(() => undefined)
}
style={styles.secondaryButton}
>
<Text style={styles.secondaryButtonText}>Bateria</Text>
</Pressable>
) : null}
</View>
</View>
{monitor.discoveredDevices.length > 0 ? (
<View style={styles.discoveryBlock}>
<Text style={styles.blockLabel}>ENCONTRADOS AGORA</Text>
{monitor.discoveredDevices.map((candidate) => (
<Pressable
key={candidate.id}
onPress={() =>
monitor.connectToDevice(candidate.id).catch(() => undefined)
}
style={styles.deviceRow}
>
<View style={styles.deviceDot} />
<View style={styles.deviceInfo}>
<Text style={styles.deviceName}>
{friendlyDeviceTitle(candidate.name)}
</Text>
<Text style={styles.deviceMeta}>
{candidate.name} · {formatRssi(candidate.rssi)}
</Text>
</View>
<View style={styles.connectPill}>
<Text style={styles.connectPillText}>Conectar</Text>
</View>
</Pressable>
))}
</View>
) : null}
<BleInventoryBlock title="SERVIÇOS BLE" rows={services} />
<BleInventoryBlock title="CARACTERÍSTICAS" rows={characteristics} />
</View>
);
}
function BleInventoryBlock({
title,
rows,
}: {
readonly title: string;
readonly rows: readonly string[];
}) {
return (
<View style={styles.diagnosticBlock}>
<Text style={styles.blockLabel}>{title}</Text>
{rows.slice(0, 8).map((row) => {
const parsed = parseBleRow(row);
return (
<View key={row} style={styles.diagnosticRow}>
<RadioReceiver color={colors.textDim} size={14} strokeWidth={2.2} />
<View style={styles.diagnosticBody}>
<Text style={styles.diagnosticTitle}>{parsed.name}</Text>
<Text style={styles.diagnosticText}>
{parsed.uuid} · {parsed.detail}
</Text>
</View>
</View>
);
})}
</View>
);
}
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<HeartRateMonitorState['phase'], string> = {
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
cd /d/h && git add src/screens/SensorScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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<readonly HeartRateSample[]>;
};
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 (
<View style={styles.screen}>
<EmptyState
eyebrow="ANÁLISE BLOQUEADA"
title="Sem dado real, sem conclusão"
copy="Ative o monitor contínuo. O FightRate não inventa pico, zona, carga, VFC ou recuperação."
tone="red"
/>
</View>
);
}
return (
<View style={styles.screen}>
<View style={styles.instrumentHeader}>
<View style={styles.headerRail} />
<View style={styles.headerCopy}>
<Text style={styles.eyebrow}>PAINEL DE CÁLCULO</Text>
<Text style={styles.headerTitle}>Matriz científica</Text>
<Text style={styles.copy}>
Leituras calculadas a partir de BPM timestampado real. VFC só é
liberada quando o sensor entrega RR intervals.
</Text>
</View>
<View style={styles.headerScore}>
<ChartNoAxesCombined
color={colors.warning}
size={20}
strokeWidth={2.4}
/>
<Text style={styles.scoreValue}>{loadRow?.value ?? '--'}</Text>
<Text style={styles.scoreLabel}>carga</Text>
</View>
</View>
<View style={styles.evidenceStrip}>
<EvidenceCell label="amostras" value={dashboard.sampleCount} />
<EvidenceCell
label="janela"
value={formatDuration(dashboard.durationMs)}
/>
<EvidenceCell label="range" value={formatTimeRange(dashboard)} />
</View>
<View style={styles.metricMatrix}>
<View style={styles.matrixHeader}>
<Text style={styles.sectionLabel}>CÁLCULOS</Text>
<Text style={styles.matrixNote}>valor · status · leitura</Text>
</View>
{dashboard.rows.map((row, index) => (
<InsightRow
key={row.key}
detail={row.detail}
index={index + 1}
label={row.label}
statusLabel={row.statusLabel}
summary={row.summary}
testID={`insight-${row.key}`}
tone={row.tone}
unit={row.unit}
value={row.value}
/>
))}
</View>
<View style={styles.zonePanel}>
<View style={styles.matrixHeader}>
<Text style={styles.sectionLabel}>ZONAS OPERACIONAIS</Text>
<Text style={styles.matrixNote}>tempo real em cada trilho</Text>
</View>
{dashboard.zones.map((zone) => (
<View key={zone.key} style={styles.zoneRow}>
<Text style={styles.zoneLabel}>{zone.label}</Text>
<View style={styles.zoneTrack}>
<View style={[styles.zoneFill, { width: `${zone.percent}%` }]} />
</View>
<Text style={styles.zoneValue}>
{zone.percent}% · {formatDuration(zone.seconds * 1000)}
</Text>
</View>
))}
</View>
<View style={styles.protocolPanel}>
<View style={styles.protocolIcon}>
{hrvRow?.status === 'ok' ? (
<ShieldCheck color={colors.success} size={18} strokeWidth={2.4} />
) : (
<Gauge color={colors.warning} size={18} strokeWidth={2.4} />
)}
</View>
<View style={styles.protocolCopy}>
<Text style={styles.protocolTitle}>Protocolo de honestidade</Text>
<Text style={styles.copy}>
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.
</Text>
</View>
</View>
<View style={styles.insightStack}>
<Text style={styles.sectionLabel}>LEITURAS OPERACIONAIS</Text>
{dashboard.narrative.length > 0 ? (
dashboard.narrative.map((insight, index) => (
<InsightNote
key={insight.title}
insight={insight}
index={index + 1}
/>
))
) : (
<View style={styles.waitingPanel}>
<Activity color={colors.warning} size={18} strokeWidth={2.5} />
<Text style={styles.copy}>
Receba mais amostras reais para gerar leitura de tendência e ação.
</Text>
</View>
)}
</View>
{latestSession ? (
<View style={styles.lastSessionStrip}>
<Text style={styles.eyebrow}>ÚLTIMA JANELA SALVA</Text>
<Text style={styles.lastSessionText}>
{formatDuration(latestSession.durationMs)} · média{' '}
{latestSession.averageBpm} · pico {latestSession.peakBpm} ·{' '}
{latestSession.sampleCount} amostras
</Text>
</View>
) : null}
</View>
);
}
function EvidenceCell({
label,
value,
}: {
readonly label: string;
readonly value: string | number;
}) {
return (
<View style={styles.evidenceCell}>
<Text style={styles.evidenceValue}>{value}</Text>
<Text style={styles.evidenceLabel}>{label}</Text>
</View>
);
}
function InsightNote({
insight,
index,
}: {
readonly insight: TrainingInsight;
readonly index: number;
}) {
return (
<View style={styles.insightNote}>
<Text style={styles.noteIndex}>
{index.toString().padStart(2, '0')}
</Text>
<View style={styles.noteBody}>
<Text style={styles.insightTitle}>{insight.title}</Text>
<Text style={styles.noteText}>{insight.why}</Text>
<Text style={styles.noteAction}>{insight.action}</Text>
<Text style={styles.confidence}>{insight.confidence}</Text>
</View>
</View>
);
}
function mergeSamples(
persistedSamples: readonly HeartRateSample[],
liveSamples: readonly HeartRateSample[],
) {
const byKey = new Map<string, HeartRateSample>();
[...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
cd /d/h && git add src/screens/InsightsScreen.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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<TabKey>('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 (
<View style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor={colors.canvas} />
<ScrollView
contentContainerStyle={[
styles.content,
{
paddingTop: insets.top + spacing.lg,
paddingBottom: insets.bottom + 118,
},
]}
showsVerticalScrollIndicator={false}
>
<View style={styles.brandRow}>
<Image
source={require('../assets/fightrate-logo.png')}
style={styles.logo}
/>
<View style={styles.titleColumn}>
<View style={styles.kickerRow}>
{isAnyLive ? <LiveDot live size={6} /> : null}
<Text style={styles.kicker}>FightRate</Text>
</View>
<Text style={styles.title}>{activeTitle}</Text>
</View>
</View>
<Animated.View style={fadeStyle} key={`tab-${activeTab}`}>
{activeTab === 'capture' ? (
<CaptureScreen
monitor={monitor}
onCaptureAction={onCaptureAction}
onMonitorAction={onMonitorAction}
/>
) : null}
{activeTab === 'history' ? (
<HistoryScreen
sessions={monitor.sessions}
getSessionSamples={monitor.getSessionSamples}
getContinuousSamples={monitor.getContinuousSamples}
liveSamples={monitor.samples}
/>
) : null}
{activeTab === 'sensor' ? (
<SensorScreen
monitor={monitor}
onMonitorAction={onMonitorAction}
/>
) : null}
{activeTab === 'insights' ? (
<InsightsScreen
liveSamples={monitor.samples}
sessions={monitor.sessions}
getContinuousSamples={monitor.getContinuousSamples}
/>
) : null}
</Animated.View>
</ScrollView>
<View style={[styles.navWrap, { bottom: insets.bottom + spacing.sm }]}>
<View style={styles.navRail}>
{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 (
<Pressable
accessibilityRole="tab"
accessibilityState={{ selected }}
key={tab.key}
onPress={() => setActiveTab(tab.key)}
style={[styles.navItem, selected ? styles.navItemActive : null]}
>
<View style={styles.navIconRow}>
<Icon
color={selected ? colors.text : colors.textMuted}
size={18}
strokeWidth={2.4}
/>
{isTabLive ? (
<View style={styles.tabLiveDot}>
<LiveDot live size={4} />
</View>
) : null}
</View>
<Text
style={[
styles.navLabel,
selected ? styles.navLabelActive : null,
]}
>
{tab.label}
</Text>
</Pressable>
);
})}
</View>
</View>
</View>
);
}
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
cd /d/h && git add src/app/FightRateApp.tsx
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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):
### 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 quandoreceivingliveDotPulse(loop, 1600ms) — LiveDot quandolive = truestopAlertRing(loop, 1600ms) — anel de aviso em volta do botão Parar quandoisCapturingtabFade(200ms cross-fade) — entre tabsrowEnter(240ms fade+translateY 8→0, stagger 50ms) — listas de Histórico e InsightspinEnter(500ms easeOutBack 1.4→1) — pin do TrainingChartpressDown/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="[email protected]" 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
cd /d/h && git add -A
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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:
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)
cd /d/h && git add android/app/build/outputs/apk/release/*.7z 2>/dev/null
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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:
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:
## 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
cd /d/h && git add .impeccable.md
git -c user.name="fightrate-bot" -c user.email="[email protected]" 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
LiveDotprops:live,size,testID— used consistently.PressableSurfaceprops:children,onPress,disabled,style,accessibilityLabel,testID— used consistently.EmptyStateprops:eyebrow,title,copy,tone,testID— used consistently.DialTileprops:label,value,tone,testID— used consistently.TrackRowprops:trackNumber,day,hour,title,meta,metrics,isOpen,onPress,accessibilityLabel,children,testID— used consistently.InsightRowprops: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.