diff --git a/src/__tests__/InsightRow.test.tsx b/src/__tests__/InsightRow.test.tsx new file mode 100644 index 0000000..5800c57 --- /dev/null +++ b/src/__tests__/InsightRow.test.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +jest.mock('react-native-reanimated', () => { + return { + __esModule: true, + default: { View: require('react-native').View, Text: require('react-native').Text }, + Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 }, + useSharedValue: (initial: number) => ({ value: initial }), + useAnimatedStyle: (fn: () => unknown) => fn(), + withTiming: (to: number) => to, + withDelay: (delay: number, value: any) => value, + withRepeat: (to: number) => to, + cancelAnimation: () => undefined, + }; +}); + +import { InsightRow } from '../components/InsightRow'; + +describe('InsightRow', () => { + it('renders index zero-padded', () => { + let tree: any; + TestRenderer.act(() => { + tree = TestRenderer.create(React.createElement(InsightRow, { + index: 3, label: 'Carga', statusLabel: 'ok', summary: 'Alta', detail: 'TRIMP 145', value: '145', tone: 'good', testID: 'row', + })); + }); + const json = JSON.stringify(tree.toJSON()); + expect(json).toContain('03'); + }); + + it('renders label, status, summary, detail, and value', () => { + let tree: any; + TestRenderer.act(() => { + tree = TestRenderer.create(React.createElement(InsightRow, { + index: 1, label: 'Carga', statusLabel: 'ok', summary: 'Carga alta', detail: 'TRIMP 145', value: '145', unit: 'TRIMP', tone: 'good', testID: 'row', + })); + }); + const json = JSON.stringify(tree.toJSON()); + expect(json).toContain('Carga'); + expect(json).toContain('ok'); + expect(json).toContain('Carga alta'); + expect(json).toContain('TRIMP 145'); + expect(json).toContain('145'); + expect(json).toContain('TRIMP'); + }); +}); diff --git a/src/components/InsightRow.tsx b/src/components/InsightRow.tsx new file mode 100644 index 0000000..3b0cf84 --- /dev/null +++ b/src/components/InsightRow.tsx @@ -0,0 +1,149 @@ +import React, { useEffect } from 'react'; +import { StyleSheet, Text, View, type ViewStyle } from 'react-native'; +import Animated, { + Easing, + useAnimatedStyle, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated'; +import { colors, motion, spacing, typography } from '../theme'; + +type Tone = 'good' | 'warning' | 'danger' | 'neutral'; + +type Props = { + readonly index: number; + readonly label: string; + readonly statusLabel: string; + readonly summary: string; + readonly detail: string; + readonly value: string; + readonly unit?: string; + readonly tone: Tone; + readonly testID?: string; +}; + +function toneColor(tone: Tone): string { + if (tone === 'good') return colors.success; + if (tone === 'danger') return colors.action; + if (tone === 'warning') return colors.warning; + return colors.text; +} + +export function InsightRow({ + index, + label, + statusLabel, + summary, + detail, + value, + unit, + tone, + testID, +}: Props) { + const indexLabel = String(index).padStart(2, '0'); + const opacity = useSharedValue(0); + const translateY = useSharedValue(8); + const color = toneColor(tone); + + useEffect(() => { + const delay = index < 8 ? index * motion.stagger.row : 0; + opacity.value = withDelay( + delay, + withTiming(1, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }), + ); + translateY.value = withDelay( + delay, + withTiming(0, { duration: motion.duration.normal, easing: Easing.out(Easing.quad) }), + ); + }, [index, opacity, translateY]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ translateY: translateY.value }], + })); + + const indexStyle: ViewStyle = { color }; + return ( + + {indexLabel} + + + {label} + {statusLabel} + + {summary} + {detail} + + + {value} + {unit ? {unit} : null} + + + ); +} + +const styles = StyleSheet.create({ + row: { + minHeight: 92, + borderBottomWidth: 1, + borderBottomColor: colors.line, + flexDirection: 'row', + gap: spacing.sm, + paddingVertical: spacing.sm, + }, + index: { + ...typography.label, + color: colors.textDim, + width: 28, + }, + body: { + flex: 1, + gap: spacing.xxs, + }, + topLine: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: spacing.sm, + }, + label: { + ...typography.label, + color: colors.text, + flexShrink: 1, + }, + status: { + ...typography.label, + textAlign: 'right', + }, + summary: { + color: colors.text, + fontSize: 16, + lineHeight: 20, + fontWeight: '800', + letterSpacing: -0.45, + }, + detail: { + color: colors.textMuted, + fontSize: 12, + lineHeight: 16, + fontWeight: '600', + }, + valueBox: { + width: 84, + alignItems: 'flex-end', + justifyContent: 'center', + }, + value: { + ...typography.metricNumeric, + textAlign: 'right', + }, + unit: { + ...typography.label, + color: colors.textDim, + textAlign: 'right', + }, +});