From 41b49ba4428a1598283c702bacb8c9e19814858c Mon Sep 17 00:00:00 2001 From: FightRate Agent Date: Mon, 15 Jun 2026 00:10:55 -0300 Subject: [PATCH] =?UTF-8?q?feat(chart):=20TrainingChart=20din=C3=A2mico=20?= =?UTF-8?q?com=20viewport,=20zoom,=20pan=20e=20pin=20sobrevivente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitui o gráfico estático (últimos 120 samples + selectedIndex por posição) por um modelo de viewport temporal que resolve os problemas de qualidade em tempo real e abre zoom/pan nos 3 call sites (Captura, Janela salva e Faixa customizada). Adições: - src/training/chartViewport.ts: funções puras (granularityForSpan, formatTickLabel, buildChartViewport, applyZoomIn/Out, applyPan, isLiveTailSnap, pickSampleAtTimestamp, xFromTimestampMs, timestampMsFromX, tipos Viewport/ChartPin/Granularity). - __tests__/chartViewport.test.ts: 34 testes cobrindo limites, ordenação defensiva, snap zone, hit-test e granularidade adaptativa. - src/components/TrainingChart.tsx: reescrito para consumir viewport. - Pin por timestampMs (sobrevive a novas amostras em tempo real). - Pinch (Gesture.Pinch) + Pan (Gesture.Pan) + Tap (Gesture.Tap) via react-native-gesture-handler v3. - Auto-follow: endMs estende com novos samples enquanto liveTail. - Snap zone (5% da duração visível) reanexa o live ao final do pan. - Eixo X com ticks adaptativos: minute/halfMinute/quarterMinute/ tenSeconds/second/halfSecond, conforme duração visível. - Botões +/- (zoom 2x) e ↺ (reset) na lateral. - Indicador 'dados mais novos disponíveis' quando !liveTail. - Estado vazio preservado (samples < 2). - jest.setup.js + jest.config.js: mocks de gesture-handler e reanimated para o App.test rodar em Jest (módulos nativos não disponíveis no host). Modificações de setup (dependências aprovadas): - package.json + package-lock.json: adiciona react-native-gesture-handler ^3.0.1, react-native-reanimated ^4.4.1, react-native-worklets ^0.9.2. - index.js: import 'react-native-gesture-handler' no topo. - App.tsx: envolvendo SafeAreaProvider. - babel.config.js: plugin react-native-worklets/plugin. Verificações: - npm run typecheck: 0 errors. - npm run lint: 0 errors, 0 warnings. - npm test -- --runInBand: 55/55 passam. - Build Android: não verificado nesta sessão (timeout); o usuário valida localmente antes de distribuir. Não comitado neste commit (mudanças pré-existentes não relacionadas): - android/* (migração fightratern → healthtracking/fightrate, mipmaps) - README.md - app.json --- App.tsx | 46 +- __tests__/App.test.tsx | 55 +- __tests__/chartViewport.test.ts | 312 ++++++++++ __tests__/continuousUsage.test.ts | 121 ++++ __tests__/heartRateParser.test.ts | 29 + __tests__/rssi.test.ts | 14 + __tests__/trainingMetrics.test.ts | 191 +++++++ babel.config.js | 1 + index.js | 3 + jest.config.js | 4 + jest.setup.js | 55 ++ package-lock.json | 917 ++++++++++++++++++++---------- package.json | 20 +- src/components/TrainingChart.tsx | 811 ++++++++++++++++++++++++++ src/training/chartViewport.ts | 253 +++++++++ 15 files changed, 2492 insertions(+), 340 deletions(-) create mode 100644 __tests__/chartViewport.test.ts create mode 100644 __tests__/continuousUsage.test.ts create mode 100644 __tests__/heartRateParser.test.ts create mode 100644 __tests__/rssi.test.ts create mode 100644 __tests__/trainingMetrics.test.ts create mode 100644 jest.setup.js create mode 100644 src/components/TrainingChart.tsx create mode 100644 src/training/chartViewport.ts diff --git a/App.tsx b/App.tsx index 5e963b1..b36bd21 100644 --- a/App.tsx +++ b/App.tsx @@ -1,45 +1,21 @@ -/** - * Sample React Native App - * https://github.com/facebook/react-native - * - * @format - */ - -import { NewAppScreen } from '@react-native/new-app-screen'; -import { StatusBar, StyleSheet, useColorScheme, View } from 'react-native'; -import { - SafeAreaProvider, - useSafeAreaInsets, -} from 'react-native-safe-area-context'; +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { FightRateApp } from './src/app/FightRateApp'; function App() { - const isDarkMode = useColorScheme() === 'dark'; - return ( - - - - - ); -} - -function AppContent() { - const safeAreaInsets = useSafeAreaInsets(); - - return ( - - - + + + + + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - }, + root: { flex: 1 }, }); export default App; diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx index e532f70..2c2ac84 100644 --- a/__tests__/App.test.tsx +++ b/__tests__/App.test.tsx @@ -3,8 +3,61 @@ */ import React from 'react'; +import { NativeModules } from 'react-native'; import ReactTestRenderer from 'react-test-renderer'; -import App from '../App'; + +const emptySnapshot = { + phase: 'idle', + isCapturing: false, + isMonitoring: false, + startedAt: 0, + statusMessage: 'Captura parada.', + connectedDevice: null, + discoveredDevices: [], + samples: [], + sessions: [], + services: [], + characteristics: [], + batteryOptimizationIgnored: true, +}; + +NativeModules.FightRateCapture = { + addListener: jest.fn(), + removeListeners: jest.fn(), + getSnapshot: jest.fn(async () => emptySnapshot), + startMonitoring: jest.fn(async () => emptySnapshot), + startCapture: jest.fn(async () => emptySnapshot), + startWorkout: jest.fn(async () => ({ + ...emptySnapshot, + isMonitoring: true, + isCapturing: true, + })), + connectToDevice: jest.fn(async () => emptySnapshot), + stopCapture: jest.fn(async () => emptySnapshot), + stopWorkout: jest.fn(async () => ({ + ...emptySnapshot, + isMonitoring: true, + })), + stopMonitoring: jest.fn(async () => emptySnapshot), + getSessionSamples: jest.fn(async () => []), + getContinuousSamples: jest.fn(async () => []), + isIgnoringBatteryOptimizations: jest.fn(async () => true), + openBatteryOptimizationSettings: jest.fn(async () => true), +}; + +// react-native-gesture-handler requires a native module; mock it for tests. +NativeModules.RNGestureHandlerModule = { + attachGestureHandler: jest.fn(), + createGestureHandler: jest.fn(), + dropGestureHandler: jest.fn(), + updateGestureHandler: jest.fn(), + flushOperations: jest.fn(), + install: jest.fn(), + State: { UNDETERMINED: 0, FAILED: 1, BEGAN: 2, CANCELLED: 3, ACTIVE: 4, END: 5 }, + Directions: { RIGHT: 1, LEFT: 2, UP: 4, DOWN: 8 }, +}; + +const App = require('../App').default; test('renders correctly', async () => { await ReactTestRenderer.act(() => { diff --git a/__tests__/chartViewport.test.ts b/__tests__/chartViewport.test.ts new file mode 100644 index 0000000..b8f737d --- /dev/null +++ b/__tests__/chartViewport.test.ts @@ -0,0 +1,312 @@ +import { type HeartRateSample } from '../src/training/trainingTypes'; +import { + applyPan, + applyZoomIn, + applyZoomOut, + buildChartViewport, + formatTickLabel, + granularityForSpan, + isLiveTailSnap, + pickSampleAtTimestamp, + xFromTimestampMs, + timestampMsFromX, + type Viewport, +} from '../src/training/chartViewport'; + +const MIN_SPAN = 1_000; +const MAX_SPAN = 24 * 60 * 60 * 1_000; + +function sample(timestamp: number, bpm: number): HeartRateSample { + return { + timestamp, + bpm, + contactDetected: null, + rrIntervalsMs: [], + deviceName: 'Magene H003', + }; +} + +function viewport(startMs: number, endMs: number, liveTail = true): Viewport { + return { startMs, endMs, liveTail }; +} + +describe('granularityForSpan', () => { + test('returns minute granularity for spans >= 5 minutes', () => { + const g = granularityForSpan(5 * 60 * 1_000); + expect(g.kind).toBe('minute'); + expect(g.stepMs).toBe(60_000); + }); + + test('returns halfMinute granularity for spans >= 1 minute and < 5 minutes', () => { + expect(granularityForSpan(60 * 1_000).kind).toBe('halfMinute'); + expect(granularityForSpan(4 * 60 * 1_000).kind).toBe('halfMinute'); + expect(granularityForSpan(5 * 60 * 1_000).kind).toBe('minute'); + }); + + test('returns quarterMinute granularity for spans >= 30s and < 1 minute', () => { + expect(granularityForSpan(30 * 1_000).kind).toBe('quarterMinute'); + expect(granularityForSpan(59 * 1_000).kind).toBe('quarterMinute'); + }); + + test('returns tenSeconds granularity for spans >= 10s and < 30s', () => { + expect(granularityForSpan(10 * 1_000).kind).toBe('tenSeconds'); + expect(granularityForSpan(29 * 1_000).kind).toBe('tenSeconds'); + }); + + test('returns second granularity for spans >= 2s and < 10s', () => { + expect(granularityForSpan(2 * 1_000).kind).toBe('second'); + expect(granularityForSpan(9 * 1_000).kind).toBe('second'); + }); + + test('returns halfSecond granularity for spans < 2s', () => { + expect(granularityForSpan(1_999).kind).toBe('halfSecond'); + expect(granularityForSpan(0).kind).toBe('halfSecond'); + }); +}); + +describe('formatTickLabel', () => { + const T = new Date('2026-06-14T09:30:45.500Z').getTime(); + + test('minute granularity formats HH:mm in local time', () => { + const g = granularityForSpan(5 * 60 * 1_000); + const label = formatTickLabel(T, g); + expect(label).toMatch(/^\d{2}:\d{2}$/); + }); + + test('second granularity formats HH:mm:ss', () => { + const g = granularityForSpan(2 * 1_000); + const label = formatTickLabel(T, g); + expect(label).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + test('halfSecond granularity formats HH:mm:ss.S', () => { + const g = granularityForSpan(500); + const label = formatTickLabel(T, g); + expect(label).toMatch(/^\d{2}:\d{2}:\d{2}\.\d$/); + }); +}); + +describe('buildChartViewport', () => { + test('returns null for fewer than 2 samples', () => { + expect(buildChartViewport([], { defaultSpanMs: 60_000 })).toBeNull(); + expect( + buildChartViewport([sample(1_000, 90)], { defaultSpanMs: 60_000 }), + ).toBeNull(); + }); + + test('initial viewport fits the dataset when total span is smaller than defaultSpan', () => { + const samples = [ + sample(1_000, 90), + sample(11_000, 100), + sample(21_000, 110), + ]; + const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 }); + expect(vp).not.toBeNull(); + expect(vp!.startMs).toBe(1_000); + expect(vp!.endMs).toBe(21_000); + expect(vp!.liveTail).toBe(true); + }); + + test('initial viewport keeps defaultSpan ending at the last sample when dataset is larger', () => { + const samples = [ + sample(0, 90), + sample(10_000, 100), + sample(20_000, 110), + sample(30_000, 120), + sample(40_000, 130), + ]; + const vp = buildChartViewport(samples, { defaultSpanMs: 30_000 }); + expect(vp).not.toBeNull(); + expect(vp!.endMs).toBe(40_000); + expect(vp!.endMs - vp!.startMs).toBe(30_000); + expect(vp!.liveTail).toBe(true); + }); + + test('initial viewport is non-live when liveTail: false is passed', () => { + const samples = [sample(0, 90), sample(10_000, 100)]; + const vp = buildChartViewport(samples, { + defaultSpanMs: 60_000, + liveTail: false, + }); + expect(vp!.liveTail).toBe(false); + }); + + test('sorts samples defensively before computing viewport', () => { + const samples = [sample(30_000, 130), sample(0, 90), sample(15_000, 110)]; + const vp = buildChartViewport(samples, { defaultSpanMs: 60_000 }); + expect(vp!.startMs).toBe(0); + expect(vp!.endMs).toBe(30_000); + }); +}); + +describe('applyZoomIn', () => { + test('halves the span around the focus point', () => { + const start = viewport(0, 60_000); + const z = applyZoomIn(start, 30_000, { + minSpanMs: MIN_SPAN, + maxSpanMs: MAX_SPAN, + }); + expect(z.endMs - z.startMs).toBe(30_000); + expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(30_000, 0); + expect(z.liveTail).toBe(false); + }); + + test('never goes below minSpanMs', () => { + const start = viewport(0, 2_000); + const z = applyZoomIn(start, 1_000, { + minSpanMs: MIN_SPAN, + maxSpanMs: MAX_SPAN, + }); + expect(z.endMs - z.startMs).toBe(MIN_SPAN); + }); + + test('preserves focus when minSpan is hit', () => { + const start = viewport(0, 2_000); + const z = applyZoomIn(start, 1_000, { + minSpanMs: MIN_SPAN, + maxSpanMs: MAX_SPAN, + }); + expect(z.startMs).toBeLessThanOrEqual(1_000); + expect(z.endMs).toBeGreaterThanOrEqual(1_000); + }); + + test('forces liveTail=false (zoom is always a user interaction)', () => { + const start = viewport(0, 60_000, true); + expect(applyZoomIn(start, 30_000).liveTail).toBe(false); + }); +}); + +describe('applyZoomOut', () => { + test('doubles the span around the focus point', () => { + const start = viewport(0, 30_000, false); + const z = applyZoomOut(start, 15_000, { + minSpanMs: MIN_SPAN, + maxSpanMs: MAX_SPAN, + }); + expect(z.endMs - z.startMs).toBe(60_000); + expect(z.startMs + (z.endMs - z.startMs) / 2).toBeCloseTo(15_000, 0); + }); + + test('never goes above maxSpanMs', () => { + const start = viewport(0, 12 * 60 * 60 * 1_000, false); + const z = applyZoomOut(start, 6 * 60 * 60 * 1_000, { + minSpanMs: MIN_SPAN, + maxSpanMs: MAX_SPAN, + }); + expect(z.endMs - z.startMs).toBe(MAX_SPAN); + }); + + test('forces liveTail=false', () => { + expect(applyZoomOut(viewport(0, 30_000, true), 15_000).liveTail).toBe( + false, + ); + }); +}); + +describe('applyPan', () => { + test('dx > 0 shifts the viewport forward in time and disables liveTail', () => { + const start = viewport(0, 60_000, true); + const p = applyPan(start, 100, 1); // 1 pixel per ms + expect(p.startMs).toBe(100); + expect(p.endMs).toBe(60_100); + expect(p.liveTail).toBe(false); + }); + + test('dx < 0 shifts the viewport backward in time', () => { + const start = viewport(0, 60_000, true); + const p = applyPan(start, -50, 1); + expect(p.startMs).toBe(-50); + expect(p.endMs).toBe(59_950); + expect(p.liveTail).toBe(false); + }); + + test('keeps liveTail false even if input was live (any pan detaches)', () => { + const start = viewport(0, 60_000, true); + expect(applyPan(start, 0.5, 1).liveTail).toBe(false); + }); +}); + +describe('isLiveTailSnap', () => { + test('returns true when endMs is within 5% of last sample timestamp', () => { + const vp = viewport(0, 100_000, false); + expect(isLiveTailSnap(vp, 104_000)).toBe(true); + expect(isLiveTailSnap(vp, 105_000, 0.05)).toBe(true); + }); + + test('returns false when endMs is more than 5% behind last sample timestamp', () => { + const vp = viewport(0, 100_000, false); + expect(isLiveTailSnap(vp, 110_000, 0.05)).toBe(false); + }); + + test('uses a custom snap fraction', () => { + const vp = viewport(0, 100_000, false); + expect(isLiveTailSnap(vp, 150_000, 0.5)).toBe(true); + expect(isLiveTailSnap(vp, 250_000, 0.5)).toBe(false); + }); +}); + +describe('pickSampleAtTimestamp', () => { + const samples = [ + sample(0, 80), + sample(1_000, 90), + sample(2_500, 100), + sample(4_000, 110), + sample(5_000, 120), + ]; + + test('returns the closest sample within tolerance', () => { + expect(pickSampleAtTimestamp(samples, 2_400, 500)).toEqual(samples[2]); + expect(pickSampleAtTimestamp(samples, 600, 500)).toEqual(samples[1]); + }); + + test('returns null when no sample is within tolerance', () => { + expect(pickSampleAtTimestamp(samples, 3_500, 100)).toBeNull(); + }); + + test('tolerates unsorted samples by considering the global closest', () => { + const shuffled = [ + samples[3], + samples[0], + samples[2], + samples[4], + samples[1], + ]; + expect(pickSampleAtTimestamp(shuffled, 2_400, 500)).toEqual(samples[2]); + }); + + test('returns null for empty input', () => { + expect(pickSampleAtTimestamp([], 0, 1_000)).toBeNull(); + }); +}); + +describe('xFromTimestampMs and timestampMsFromX', () => { + const vp = viewport(0, 100_000, false); + const chartWidth = 320; + const padX = 14; + + test('xFromTimestampMs maps edge timestamps to padX and chartWidth-padX', () => { + expect(xFromTimestampMs(0, vp, chartWidth, padX)).toBeCloseTo(padX, 5); + expect(xFromTimestampMs(100_000, vp, chartWidth, padX)).toBeCloseTo( + chartWidth - padX, + 5, + ); + expect(xFromTimestampMs(50_000, vp, chartWidth, padX)).toBeCloseTo( + chartWidth / 2, + 5, + ); + }); + + test('timestampMsFromX is the inverse of xFromTimestampMs', () => { + const ts = 73_421; + const x = xFromTimestampMs(ts, vp, chartWidth, padX); + const back = timestampMsFromX(x, vp, chartWidth, padX); + expect(back).toBeCloseTo(ts, 0); + }); + + test('timestampMsFromX clamps to viewport bounds', () => { + const before = timestampMsFromX(-100, vp, chartWidth, padX); + expect(before).toBeLessThanOrEqual(vp.startMs); + const after = timestampMsFromX(500, vp, chartWidth, padX); + expect(after).toBeGreaterThanOrEqual(vp.endMs); + }); +}); diff --git a/__tests__/continuousUsage.test.ts b/__tests__/continuousUsage.test.ts new file mode 100644 index 0000000..fd1622e --- /dev/null +++ b/__tests__/continuousUsage.test.ts @@ -0,0 +1,121 @@ +import { + buildContinuousUsageHistory, + filterSamplesByRange, +} from '../src/training/continuousUsage'; +import { type HeartRateSample } from '../src/training/trainingTypes'; + +function sample( + timestamp: number, + bpm: number, + deviceName = 'Magene H003', +): HeartRateSample { + return { + timestamp, + bpm, + contactDetected: null, + rrIntervalsMs: [], + deviceName, + }; +} + +describe('buildContinuousUsageHistory', () => { + it('groups continuous samples into usage blocks separated by long gaps', () => { + const blocks = buildContinuousUsageHistory( + [ + sample(1_000, 120), + sample(2_000, 130), + sample(10 * 60_000, 140), + sample(10 * 60_000 + 1_000, 150), + ], + { gapMs: 5 * 60_000 }, + ); + + expect(blocks).toHaveLength(2); + expect(blocks[0]).toMatchObject({ + startedAt: 10 * 60_000, + endedAt: 10 * 60_000 + 1_000, + sampleCount: 2, + averageBpm: 145, + peakBpm: 150, + minBpm: 140, + }); + expect(blocks[1]).toMatchObject({ + startedAt: 1_000, + endedAt: 2_000, + sampleCount: 2, + averageBpm: 125, + peakBpm: 130, + minBpm: 120, + }); + }); + + it('sorts samples and ignores invalid BPM values', () => { + const blocks = buildContinuousUsageHistory( + [sample(5_000, 150), sample(1_000, 0), sample(3_000, 120)], + { gapMs: 10_000 }, + ); + + expect(blocks).toHaveLength(1); + expect(blocks[0].startedAt).toBe(3_000); + expect(blocks[0].endedAt).toBe(5_000); + expect(blocks[0].sampleCount).toBe(2); + expect(blocks[0].samples.map(item => item.bpm)).toEqual([120, 150]); + }); + + it('uses the most recent block first for history display', () => { + const blocks = buildContinuousUsageHistory( + [sample(1_000, 110), sample(30 * 60_000, 160)], + { gapMs: 5 * 60_000 }, + ); + + expect(blocks.map(block => block.startedAt)).toEqual([30 * 60_000, 1_000]); + }); + + it('keeps a stable block id while live samples extend the same usage block', () => { + const initial = buildContinuousUsageHistory( + [sample(1_000, 120), sample(2_000, 130)], + { gapMs: 5 * 60_000 }, + ); + const updated = buildContinuousUsageHistory( + [sample(1_000, 120), sample(2_000, 130), sample(3_000, 140)], + { gapMs: 5 * 60_000 }, + ); + + expect(updated[0].id).toBe(initial[0].id); + expect(updated[0].sampleCount).toBe(3); + }); +}); + +describe('filterSamplesByRange', () => { + it('returns samples inside an inclusive custom date/time range', () => { + const samples = [ + sample(Date.UTC(2026, 5, 10, 8, 59), 110), + sample(Date.UTC(2026, 5, 10, 9, 0), 120), + sample(Date.UTC(2026, 5, 10, 9, 30), 130), + sample(Date.UTC(2026, 5, 10, 10, 0), 140), + sample(Date.UTC(2026, 5, 10, 10, 1), 150), + ]; + + const filtered = filterSamplesByRange(samples, { + start: Date.UTC(2026, 5, 10, 9, 0), + end: Date.UTC(2026, 5, 10, 10, 0), + }); + + expect(filtered.map(item => item.bpm)).toEqual([120, 130, 140]); + }); + + it('supports ranges that cross multiple days', () => { + const samples = [ + sample(Date.UTC(2026, 5, 10, 23, 50), 120), + sample(Date.UTC(2026, 5, 11, 0, 10), 130), + sample(Date.UTC(2026, 5, 11, 1, 0), 140), + ]; + + const filtered = filterSamplesByRange(samples, { + start: Date.UTC(2026, 5, 10, 23, 45), + end: Date.UTC(2026, 5, 11, 0, 15), + }); + + expect(filtered.map(item => item.bpm)).toEqual([120, 130]); + }); +}); diff --git a/__tests__/heartRateParser.test.ts b/__tests__/heartRateParser.test.ts new file mode 100644 index 0000000..3056b1e --- /dev/null +++ b/__tests__/heartRateParser.test.ts @@ -0,0 +1,29 @@ +import { parseHeartRateMeasurement } from '../src/ble/heartRateParser'; + +describe('parseHeartRateMeasurement', () => { + it('parses 8-bit BPM payloads', () => { + expect(parseHeartRateMeasurement([0x00, 154])).toMatchObject({ + bpm: 154, + contactSupported: false, + contactDetected: null, + energyExpended: null, + rrIntervalsMs: [], + }); + }); + + it('parses 16-bit BPM payloads', () => { + expect(parseHeartRateMeasurement([0x01, 0x2c, 0x01]).bpm).toBe(300); + }); + + it('parses contact, energy and RR interval data', () => { + const parsed = parseHeartRateMeasurement([ + 0x1e, 160, 0x10, 0x00, 0x00, 0x04, + ]); + + expect(parsed.bpm).toBe(160); + expect(parsed.contactSupported).toBe(true); + expect(parsed.contactDetected).toBe(true); + expect(parsed.energyExpended).toBe(16); + expect(parsed.rrIntervalsMs).toEqual([1000]); + }); +}); diff --git a/__tests__/rssi.test.ts b/__tests__/rssi.test.ts new file mode 100644 index 0000000..3d6eb74 --- /dev/null +++ b/__tests__/rssi.test.ts @@ -0,0 +1,14 @@ +import { formatRssi, normalizeRssi } from '../src/ble/rssi'; + +describe('RSSI dBm helpers', () => { + it('keeps valid BLE RSSI values as dBm', () => { + expect(normalizeRssi(-63)).toBe(-63); + expect(formatRssi(-63)).toBe('-63 dBm'); + }); + + it('hides Android sentinel and impossible RSSI values', () => { + expect(normalizeRssi(-2147483648)).toBeNull(); + expect(normalizeRssi(127)).toBeNull(); + expect(formatRssi(-2147483648)).toBe('sem leitura'); + }); +}); diff --git a/__tests__/trainingMetrics.test.ts b/__tests__/trainingMetrics.test.ts new file mode 100644 index 0000000..6b06428 --- /dev/null +++ b/__tests__/trainingMetrics.test.ts @@ -0,0 +1,191 @@ +import { + buildInsightDashboard, + calculateBpmVariability, + calculateHeartRateDrift, + calculateLiveMetrics, + calculatePostSessionRecovery, + filterReliableHeartRateSamples, + isReliableHeartRateSample, + summarizeRrAvailability, +} from '../src/training/trainingMetrics'; +import { + type CompletedTrainingSession, + type HeartRateSample, +} from '../src/training/trainingTypes'; + +function sample( + timestamp: number, + bpm: number, + rrIntervalsMs: readonly number[] = [], +): HeartRateSample { + return { + timestamp, + bpm, + contactDetected: null, + rrIntervalsMs, + deviceName: 'Magene H003', + }; +} + +function session( + startedAt: number, + endedAt: number, + peakBpm = 180, +): CompletedTrainingSession { + return { + id: `session-${startedAt}-${endedAt}`, + startedAt, + endedAt, + durationMs: endedAt - startedAt, + sampleCount: 12, + averageBpm: 150, + peakBpm, + minBpm: 112, + deviceName: 'Magene H003', + }; +} + +describe('sample reliability guards', () => { + it('rejects zero BPM and explicit no-contact packets', () => { + expect(isReliableHeartRateSample(sample(1_000, 0))).toBe(false); + expect( + isReliableHeartRateSample({ + ...sample(2_000, 128), + contactDetected: false, + }), + ).toBe(false); + expect(isReliableHeartRateSample(sample(3_000, 128))).toBe(true); + }); + + it('removes invalid readings before metrics are calculated', () => { + const reliable = filterReliableHeartRateSamples([ + sample(1_000, 0), + { ...sample(2_000, 125), contactDetected: false }, + sample(3_000, 130), + ]); + + expect(reliable.map(item => item.bpm)).toEqual([130]); + }); + + it('marks live BPM as unavailable when the latest valid reading is stale', () => { + const metrics = calculateLiveMetrics([sample(1_000, 140)], { + now: 20_000, + staleAfterMs: 12_000, + }); + + expect(metrics.currentBpm).toBeNull(); + expect(metrics.averageBpm).toBe(140); + }); +}); + +describe('calculateBpmVariability', () => { + it('summarizes BPM range, standard deviation and coefficient of variation', () => { + const variability = calculateBpmVariability([ + sample(1_000, 100), + sample(2_000, 120), + sample(3_000, 140), + ]); + + expect(variability).toMatchObject({ + averageBpm: 120, + minBpm: 100, + maxBpm: 140, + rangeBpm: 40, + standardDeviationBpm: 16.3, + coefficientOfVariation: 0.136, + classification: 'oscilante', + }); + }); +}); + +describe('calculateHeartRateDrift', () => { + it('compares the first and last fifth of a long enough sample window', () => { + const start = Date.UTC(2026, 5, 10, 9, 0); + const samples = Array.from({ length: 12 }, (_, index) => + sample(start + index * 60_000, index < 6 ? 130 + index : 150 + index), + ); + + expect(calculateHeartRateDrift(samples)).toMatchObject({ + status: 'ok', + driftBpm: 30, + driftPercent: 23, + initialAverageBpm: 131, + finalAverageBpm: 161, + }); + }); + + it('does not over-interpret short windows as cardiovascular drift', () => { + expect( + calculateHeartRateDrift([sample(1_000, 130), sample(60_000, 140)]), + ).toMatchObject({ status: 'short-window' }); + }); +}); + +describe('calculatePostSessionRecovery', () => { + it('calculates one and two minute recovery from post-window continuous samples', () => { + const startedAt = Date.UTC(2026, 5, 10, 10, 0); + const endedAt = startedAt + 10 * 60_000; + + expect( + calculatePostSessionRecovery(session(startedAt, endedAt, 182), [ + sample(endedAt + 5_000, 176), + sample(endedAt + 61_000, 160), + sample(endedAt + 119_000, 148), + ]), + ).toMatchObject({ + status: 'ok', + oneMinuteDropBpm: 22, + twoMinuteDropBpm: 34, + }); + }); +}); + +describe('summarizeRrAvailability', () => { + it('reports RR availability without calculating fake HRV', () => { + expect( + summarizeRrAvailability([ + sample(1_000, 120, [1000, 980]), + sample(2_000, 121, [990]), + ]), + ).toMatchObject({ status: 'ok', intervalCount: 3, sampleCount: 2 }); + + expect(summarizeRrAvailability([sample(1_000, 120)])).toMatchObject({ + status: 'needs-rr', + intervalCount: 0, + }); + }); +}); + +describe('buildInsightDashboard', () => { + it('builds compact scientific rows using persisted samples before live fallback', () => { + const start = Date.UTC(2026, 5, 10, 9, 0); + const persisted = [ + sample(start, 120), + sample(start + 60_000, 140), + sample(start + 11 * 60_000, 160), + ]; + + const dashboard = buildInsightDashboard({ + liveSamples: [sample(start + 12 * 60_000, 170)], + continuousSamples: persisted, + sessions: [], + }); + + expect(dashboard.sampleCount).toBe(3); + expect(dashboard.rows.map(row => row.key)).toEqual([ + 'current-bpm', + 'average-bpm', + 'bpm-range', + 'bpm-variability', + 'zones', + 'load', + 'drift', + 'recovery', + 'profile', + 'hrv', + ]); + expect(dashboard.rows.find(row => row.key === 'hrv')).toMatchObject({ + status: 'needs-rr', + }); + }); +}); diff --git a/babel.config.js b/babel.config.js index f7b3da3..8ba8eb6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], }; diff --git a/index.js b/index.js index 9b73932..07e209c 100644 --- a/index.js +++ b/index.js @@ -2,6 +2,9 @@ * @format */ +// Gesture Handler must be imported before any other React Native code so the +// native gesture system is initialized before our screens mount. +import 'react-native-gesture-handler'; import { AppRegistry } from 'react-native'; import App from './App'; import { name as appName } from './app.json'; diff --git a/jest.config.js b/jest.config.js index 294be30..c1e419f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,7 @@ module.exports = { preset: '@react-native/jest-preset', + setupFiles: ['/jest.setup.js'], + transformIgnorePatterns: [ + 'node_modules/(?!(jest-)?@?react-native|@react-native-community|react-native-gesture-handler|react-native-reanimated|react-native-worklets|react-native-svg|react-native-safe-area-context)', + ], }; diff --git a/jest.setup.js b/jest.setup.js new file mode 100644 index 0000000..bc6a7a4 --- /dev/null +++ b/jest.setup.js @@ -0,0 +1,55 @@ +/* eslint-env jest */ + +// react-native-gesture-handler and react-native-reanimated both depend on +// native modules that are not present in the Jest environment. Mock the +// TurboModuleRegistry calls so importing our screens does not throw. + +jest.mock('react-native-gesture-handler', () => { + const View = require('react-native').View; + const React = require('react'); + const passthrough = component => + React.forwardRef((props, ref) => { + const Comp = component; + return React.createElement(Comp, { ...props, ref }); + }); + return { + __esModule: true, + GestureHandlerRootView: passthrough(View), + GestureDetector: passthrough(View), + Gesture: { + Pan: () => ({ + onBegin: () => ({ + onUpdate: () => ({ + onEnd: () => ({ + onFinalize: () => ({ minDistance: () => ({}) }), + }), + }), + }), + }), + Pinch: () => ({ + onBegin: () => ({ + onUpdate: () => ({ + onEnd: () => ({ onFinalize: () => ({}) }), + }), + }), + }), + Tap: () => ({ maxDuration: () => ({ onEnd: () => ({}) }) }), + Race: (...args) => ({ _args: args }), + Simultaneous: (...args) => ({ _args: args }), + }, + }; +}); + +jest.mock('react-native-reanimated', () => { + const View = require('react-native').View; + return { + __esModule: true, + default: { View, createAnimatedComponent: c => c, Value: function () {} }, + runOnJS: fn => fn, + useSharedValue: initial => ({ value: initial }), + useAnimatedStyle: () => ({}), + withTiming: v => v, + withSpring: v => v, + Easing: { inOut: () => ({}), linear: () => ({}) }, + }; +}); diff --git a/package-lock.json b/package-lock.json index c4cfbb8..2a1f804 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,20 +8,25 @@ "name": "FightRateRN", "version": "0.0.1", "dependencies": { - "@react-native/new-app-screen": "0.86.0", + "lucide-react-native": "^1.17.0", "react": "19.2.3", "react-native": "0.86.0", - "react-native-safe-area-context": "^5.5.2" + "react-native-gesture-handler": "^3.0.1", + "react-native-reanimated": "^4.4.1", + "react-native-safe-area-context": "^5.5.2", + "react-native-svg": "^15.15.5", + "react-native-worklets": "^0.9.2" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "20.1.0", - "@react-native-community/cli-platform-android": "20.1.0", - "@react-native-community/cli-platform-ios": "20.1.0", + "@react-native-community/cli": "^20.1.3", + "@react-native-community/cli-platform-android": "^20.1.3", + "@react-native-community/cli-platform-ios": "^20.1.3", "@react-native/babel-preset": "0.86.0", "@react-native/eslint-config": "0.86.0", + "@react-native/jest-preset": "^0.86.0", "@react-native/metro-config": "0.86.0", "@react-native/typescript-config": "0.86.0", "@types/jest": "^29.5.13", @@ -129,7 +134,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -158,7 +162,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -180,7 +183,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -198,7 +200,6 @@ "version": "0.6.8", "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", @@ -224,7 +225,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -268,7 +268,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.7" @@ -281,7 +280,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -291,7 +289,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -309,7 +306,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", @@ -327,7 +323,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.7", @@ -368,7 +363,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", @@ -512,7 +506,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -541,7 +534,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -554,7 +547,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -567,7 +560,7 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" @@ -580,7 +573,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -596,7 +589,6 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -609,7 +601,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -625,7 +616,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -657,7 +647,7 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -673,7 +663,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -686,7 +676,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -699,7 +689,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -715,7 +704,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -728,7 +717,6 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -741,7 +729,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" @@ -754,7 +742,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -767,7 +755,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -780,7 +768,6 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -793,7 +780,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -809,7 +796,7 @@ "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" @@ -825,7 +812,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -858,7 +844,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -874,7 +859,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -892,7 +876,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -926,7 +909,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -942,7 +924,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -976,7 +957,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -1014,7 +994,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1146,7 +1125,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1163,7 +1141,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1279,7 +1256,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-transforms": "^7.29.7", @@ -1332,7 +1308,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1365,7 +1340,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1434,7 +1408,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1450,7 +1423,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", @@ -1483,7 +1455,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", @@ -1500,7 +1471,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -1534,7 +1504,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1550,7 +1519,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -1570,7 +1538,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1586,7 +1553,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1602,7 +1568,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1651,7 +1616,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", @@ -1672,7 +1636,6 @@ "version": "0.13.0", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.5", @@ -1686,7 +1649,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1735,7 +1697,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" @@ -1767,7 +1728,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", @@ -1820,7 +1780,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", @@ -1951,6 +1910,25 @@ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -2228,7 +2206,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "camelcase": "^5.3.1", @@ -2245,7 +2223,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -2255,7 +2233,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^5.0.0", @@ -2269,7 +2247,7 @@ "version": "3.14.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -2283,7 +2261,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^4.1.0" @@ -2296,7 +2274,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-try": "^2.0.0" @@ -2312,7 +2290,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^2.2.0" @@ -2325,7 +2303,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -2335,7 +2313,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -2407,11 +2385,24 @@ } } }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/fake-timers": "^29.7.0", @@ -2454,7 +2445,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -2591,7 +2582,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.11.6", @@ -2696,6 +2687,19 @@ "eslint-scope": "5.1.1" } }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2735,18 +2739,18 @@ } }, "node_modules/@react-native-community/cli": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.0.tgz", - "integrity": "sha512-441WsVtRe4nGJ9OzA+QMU1+22lA6Q2hRWqqIMKD0wjEMLqcSfOZyu2UL9a/yRpL/dRpyUsU4n7AxqKfTKO/Csg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.3.tgz", + "integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-clean": "20.1.0", - "@react-native-community/cli-config": "20.1.0", - "@react-native-community/cli-doctor": "20.1.0", - "@react-native-community/cli-server-api": "20.1.0", - "@react-native-community/cli-tools": "20.1.0", - "@react-native-community/cli-types": "20.1.0", + "@react-native-community/cli-clean": "20.1.3", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-doctor": "20.1.3", + "@react-native-community/cli-server-api": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", + "@react-native-community/cli-types": "20.1.3", "commander": "^9.4.1", "deepmerge": "^4.3.0", "execa": "^5.0.0", @@ -2765,26 +2769,26 @@ } }, "node_modules/@react-native-community/cli-clean": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.0.tgz", - "integrity": "sha512-77L4DifWfxAT8ByHnkypge7GBMYpbJAjBGV+toowt5FQSGaTBDcBHCX+FFqFRukD5fH6i8sZ41Gtw+nbfCTTIA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.3.tgz", + "integrity": "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.0.tgz", - "integrity": "sha512-1x9rhLLR/dKKb92Lb5O0l0EmUG08FHf+ZVyVEf9M+tX+p5QIm52MRiy43R0UAZ2jJnFApxRk+N3sxoYK4Dtnag==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.3.tgz", + "integrity": "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-tools": "20.1.3", "cosmiconfig": "^9.0.0", "deepmerge": "^4.3.0", "fast-glob": "^3.3.2", @@ -2793,43 +2797,43 @@ } }, "node_modules/@react-native-community/cli-config-android": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.0.tgz", - "integrity": "sha512-3A01ZDyFeCALzzPcwP/fleHoP3sGNq1UX7FzxkTrOFX8RRL9ntXNXQd27E56VU4BBxGAjAJT4Utw8pcOjJceIA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.3.tgz", + "integrity": "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-tools": "20.1.3", "fast-glob": "^3.3.2", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-config-apple": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.0.tgz", - "integrity": "sha512-n6JVs8Q3yxRbtZQOy05ofeb1kGtspGN3SgwPmuaqvURF9fsuS7c4/9up2Kp9C+1D2J1remPJXiZLNGOcJvfpOA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.3.tgz", + "integrity": "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "fast-glob": "^3.3.2", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-doctor": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.0.tgz", - "integrity": "sha512-QfJF1GVjA4PBrIT3SJ0vFFIu0km1vwOmLDlOYVqfojajZJ+Dnvl0f94GN1il/jT7fITAxom///XH3/URvi7YTQ==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.3.tgz", + "integrity": "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config": "20.1.0", - "@react-native-community/cli-platform-android": "20.1.0", - "@react-native-community/cli-platform-apple": "20.1.0", - "@react-native-community/cli-platform-ios": "20.1.0", - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-config": "20.1.3", + "@react-native-community/cli-platform-android": "20.1.3", + "@react-native-community/cli-platform-apple": "20.1.3", + "@react-native-community/cli-platform-ios": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "command-exists": "^1.2.8", "deepmerge": "^4.3.0", "envinfo": "^7.13.0", @@ -2843,9 +2847,9 @@ } }, "node_modules/@react-native-community/cli-doctor/node_modules/semver": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", - "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "devOptional": true, "license": "ISC", "bin": { @@ -2856,52 +2860,52 @@ } }, "node_modules/@react-native-community/cli-platform-android": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.0.tgz", - "integrity": "sha512-TeHPDThOwDppQRpndm9kCdRCBI8AMy3HSIQ+iy7VYQXL5BtZ5LfmGdusoj7nVN/ZGn0Lc6Gwts5qowyupXdeKg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.3.tgz", + "integrity": "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-android": "20.1.0", - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-config-android": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", "logkitty": "^0.7.1", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-apple": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.0.tgz", - "integrity": "sha512-0ih1hrYezSM2cuOlVnwBEFtMwtd8YgpTLmZauDJCv50rIumtkI1cQoOgLoS4tbPCj9U/Vn2a9BFH0DLFOOIacg==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.3.tgz", + "integrity": "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-config-apple": "20.1.0", - "@react-native-community/cli-tools": "20.1.0", + "@react-native-community/cli-config-apple": "20.1.3", + "@react-native-community/cli-tools": "20.1.3", "execa": "^5.0.0", - "fast-xml-parser": "^4.4.1", + "fast-xml-parser": "^5.3.6", "picocolors": "^1.1.1" } }, "node_modules/@react-native-community/cli-platform-ios": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.0.tgz", - "integrity": "sha512-XN7Da9z4WsJxtqVtEzY8q2bv22OsvzaFP5zy5+phMWNoJlU4lf7IvBSxqGYMpQ9XhYP7arDw5vmW4W34s06rnA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.3.tgz", + "integrity": "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-platform-apple": "20.1.0" + "@react-native-community/cli-platform-apple": "20.1.3" } }, "node_modules/@react-native-community/cli-server-api": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.0.tgz", - "integrity": "sha512-Tb415Oh8syXNT2zOzLzFkBXznzGaqKCiaichxKzGCDKg6JGHp3jSuCmcTcaPeYC7oc32n/S3Psw7798r4Q/7lA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.3.tgz", + "integrity": "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA==", "devOptional": true, "license": "MIT", "dependencies": { - "@react-native-community/cli-tools": "20.1.0", - "body-parser": "^1.20.3", + "@react-native-community/cli-tools": "20.1.3", + "body-parser": "^2.2.2", "compression": "^1.7.1", "connect": "^3.6.5", "errorhandler": "^1.5.1", @@ -2909,13 +2913,14 @@ "open": "^6.2.0", "pretty-format": "^29.7.0", "serve-static": "^1.13.1", + "strict-url-sanitise": "0.0.1", "ws": "^6.2.3" } }, "node_modules/@react-native-community/cli-tools": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.0.tgz", - "integrity": "sha512-/YmzHGOkY6Bgrv4OaA1L8rFqsBlQd1EB2/ipAoKPiieV0EcB5PUamUSuNeFU3sBZZTYQCUENwX4wgOHgFUlDnQ==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.3.tgz", + "integrity": "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2932,9 +2937,9 @@ } }, "node_modules/@react-native-community/cli-tools/node_modules/semver": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", - "integrity": "sha512-wnilbGyMxzbY7dNOl7jpKbLSjcfeweJWU5j4+u5qW+6/wuGD9KzIGOyZnQVSBM9E7DtWaaH3CyHkppYrKYoxwg==", + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "devOptional": true, "license": "ISC", "bin": { @@ -2945,9 +2950,9 @@ } }, "node_modules/@react-native-community/cli-types": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.0.tgz", - "integrity": "sha512-D0kDspcwgbVXyNjwicT7Bb1JgXjijTw1JJd+qxyF/a9+sHv7TU4IchV+gN38QegeXqVyM4Ym7YZIvXMFBmyJqA==", + "version": "20.1.3", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.3.tgz", + "integrity": "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2980,7 +2985,6 @@ "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.0.tgz", "integrity": "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.29.0", @@ -2994,7 +2998,6 @@ "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.0.tgz", "integrity": "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", @@ -3243,6 +3246,26 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, + "node_modules/@react-native/jest-preset": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.86.0.tgz", + "integrity": "sha512-KA+xpIP3DvJy7PQJ9c6ZdEKkOPChl+Rk/rV2MhQACEAzfhWU84407KZQv4ccyO3B4caD0gPrFjE96a4P993nsQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/js-polyfills": "0.86.0", + "babel-jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "regenerator-runtime": "^0.13.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, "node_modules/@react-native/js-polyfills": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", @@ -3256,7 +3279,6 @@ "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.0.tgz", "integrity": "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", @@ -3275,7 +3297,6 @@ "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.0.tgz", "integrity": "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw==", - "devOptional": true, "license": "MIT", "dependencies": { "@react-native/js-polyfills": "0.86.0", @@ -3287,25 +3308,6 @@ "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, - "node_modules/@react-native/new-app-screen": { - "version": "0.86.0", - "resolved": "https://registry.npmjs.org/@react-native/new-app-screen/-/new-app-screen-0.86.0.tgz", - "integrity": "sha512-hgZYg2I6gk+U0PWpV/JWg02KaS+FYYQ7OpFPVyG5RbFRAKNJw38mp8up3JqWFCMHeUH/kjWLEbJsCA0O5Qx2MA==", - "license": "MIT", - "engines": { - "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" - }, - "peerDependencies": { - "@types/react": "^19.1.0", - "react": "*", - "react-native": "0.86.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@react-native/normalize-colors": { "version": "0.86.0", "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", @@ -3376,7 +3378,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" @@ -3386,7 +3388,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.0" @@ -3396,7 +3398,7 @@ "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", @@ -3410,7 +3412,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" @@ -3420,7 +3422,7 @@ "version": "7.4.4", "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", @@ -3431,7 +3433,7 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/types": "^7.28.2" @@ -3441,7 +3443,7 @@ "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -3495,7 +3497,6 @@ "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3505,7 +3506,6 @@ "version": "19.1.0", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/react": "*" @@ -3515,7 +3515,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/yargs": { @@ -3975,7 +3975,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -3985,6 +3985,19 @@ "node": ">= 8" } }, + "node_modules/anynum": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.0.tgz", + "integrity": "sha512-xjR9/zBVnUOP6ztMIIgShjsxui80nQUQH+5xJnvrYLs+90bF25/KJqaAi8mk+B4RDtX1Nspi6fmp4YTEts8SfA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/appdirsjs": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", @@ -4190,7 +4203,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/transform": "^29.7.0", @@ -4212,7 +4225,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", @@ -4229,7 +4242,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@babel/core": "^7.12.3", @@ -4246,7 +4259,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", @@ -4262,7 +4275,6 @@ "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/compat-data": "^7.28.6", @@ -4291,7 +4303,6 @@ "version": "0.6.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" @@ -4313,7 +4324,6 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/plugin-syntax-flow": "^7.12.1" @@ -4323,7 +4333,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", @@ -4350,7 +4360,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "babel-plugin-jest-hoist": "^29.6.3", @@ -4418,46 +4428,35 @@ } }, "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "devOptional": true, "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "devOptional": true, - "license": "MIT" + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" }, "node_modules/brace-expansion": { "version": "5.0.6", @@ -4923,7 +4922,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/connect": { @@ -4976,7 +4975,6 @@ "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "devOptional": true, "license": "MIT", "dependencies": { "browserslist": "^4.28.1" @@ -5049,11 +5047,51 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -5277,6 +5315,61 @@ "node": ">=6.0.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5332,6 +5425,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5475,7 +5580,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6052,7 +6156,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -6223,7 +6327,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -6233,10 +6337,10 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-xml-parser": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.6.tgz", - "integrity": "sha512-Yd4vkROfJf8AuJrDIVMVmYfULKmIJszVsMv7Vo71aocsKgFxpdlpSHXSaInvyYfgw2PRuObQSW2GFpVMUjxu9A==", + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", "devOptional": true, "funding": [ { @@ -6246,7 +6350,28 @@ ], "license": "MIT", "dependencies": { - "strnum": "^1.0.5" + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" @@ -6442,7 +6567,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/fsevents": { @@ -6464,7 +6589,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6558,7 +6682,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8.0.0" @@ -6614,7 +6738,7 @@ "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "fs.realpath": "^1.0.0", @@ -6648,14 +6772,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6666,7 +6790,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6818,7 +6942,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "devOptional": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6908,16 +7031,20 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "devOptional": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -7007,7 +7134,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -7018,7 +7145,7 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -7150,7 +7277,6 @@ "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "devOptional": true, "license": "MIT", "dependencies": { "hasown": "^2.0.3" @@ -7561,7 +7687,7 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=8" @@ -7863,7 +7989,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/environment": "^29.7.0", @@ -7890,7 +8016,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -7946,7 +8072,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.12.13", @@ -7967,7 +8093,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jest/types": "^29.6.3", @@ -8000,7 +8126,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" @@ -8489,7 +8615,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "devOptional": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -8685,6 +8810,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react-native": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-1.17.0.tgz", + "integrity": "sha512-H5eM7dZkXJbYcrsjczlDC6Sq1/siWcM5O5BLLx6ljT0XDIGorZFjul+AdThuMs0I614nwowv5qbDivuQ+349Xw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -8739,14 +8875,20 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/memoize-one": { @@ -9342,7 +9484,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9361,6 +9503,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -9513,7 +9667,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9701,11 +9855,27 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9724,7 +9894,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -9749,7 +9918,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6" @@ -10011,19 +10180,19 @@ } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "devOptional": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/react": { @@ -10131,6 +10300,57 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-3.0.1.tgz", + "integrity": "sha512-fu9X6vLiDy197CUcOphmq6PnV5dDPpDltFjJDnq2mAkSxB24veqmYxHyEAfS7IsG8v8jYkvfgihp2UAojgYVNg==", + "license": "MIT", + "dependencies": { + "@types/react-test-renderer": "^19.1.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", + "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-reanimated": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.4.1.tgz", + "integrity": "sha512-WCVBfhLE+AYI2l4inL6PC1vcfNOfmVYRSVSBkPiD12N3jvzByipnygwVpmunyhaNqbiSEDrFYcl7cOJnbHKykw==", + "license": "MIT", + "dependencies": { + "react-native-is-edge-to-edge": "^1.3.1", + "semver": "^7.7.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "0.83 - 0.86", + "react-native-worklets": "0.9.x" + } + }, + "node_modules/react-native-reanimated/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native-safe-area-context": { "version": "5.8.0", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", @@ -10141,6 +10361,58 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.9.2.tgz", + "integrity": "sha512-bCBV4dwcoLnqpk0bbL92nNuv6km37DRpamCX/nqNxsRs6LmLeFoFe/a7OKL1pVDi3PY97C0BEhmq8Qq/iNvpVg==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.4" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.83 - 0.86" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -10255,14 +10527,12 @@ "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "devOptional": true, "license": "MIT" }, "node_modules/regenerate-unicode-properties": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", - "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2" @@ -10302,7 +10572,6 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", - "devOptional": true, "license": "MIT", "dependencies": { "regenerate": "^1.4.2", @@ -10320,14 +10589,12 @@ "version": "0.8.0", "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "devOptional": true, "license": "MIT" }, "node_modules/regjsparser": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", - "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -10356,7 +10623,6 @@ "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -10872,7 +11138,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -10947,14 +11213,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -10967,7 +11233,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11023,6 +11289,13 @@ "node": ">= 0.4" } }, + "node_modules/strict-url-sanitise": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz", + "integrity": "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11222,9 +11495,9 @@ } }, "node_modules/strnum": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", - "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.0.tgz", + "integrity": "sha512-sHrVyWWdq28RbhjuJdZsA1SnGRJV6NiXbk6AXBxDOsgAcA+lmpUZCYjOdLBxkXMwis6RRe7dlZt4VlIWFVzkmg==", "devOptional": true, "funding": [ { @@ -11232,7 +11505,10 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "anynum": "^1.0.0" + } }, "node_modules/supports-color": { "version": "7.2.0", @@ -11250,7 +11526,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -11297,7 +11572,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", @@ -11312,14 +11587,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/test-exclude/node_modules/brace-expansion": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11330,7 +11605,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11454,7 +11729,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -11474,17 +11749,53 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "devOptional": true, "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typed-array-buffer": { @@ -11608,7 +11919,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -11618,7 +11928,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "devOptional": true, "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", @@ -11632,7 +11941,6 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -11642,7 +11950,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -11921,14 +12228,14 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/write-file-atomic": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", @@ -11948,6 +12255,22 @@ "async-limiter": "~1.0.0" } }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 1deb104..0dc0f91 100644 --- a/package.json +++ b/package.json @@ -7,23 +7,29 @@ "ios": "react-native run-ios", "lint": "eslint .", "start": "react-native start", - "test": "jest" + "test": "jest", + "typecheck": "tsc --noEmit" }, "dependencies": { + "lucide-react-native": "^1.17.0", "react": "19.2.3", "react-native": "0.86.0", - "@react-native/new-app-screen": "0.86.0", - "react-native-safe-area-context": "^5.5.2" + "react-native-gesture-handler": "^3.0.1", + "react-native-reanimated": "^4.4.1", + "react-native-safe-area-context": "^5.5.2", + "react-native-svg": "^15.15.5", + "react-native-worklets": "^0.9.2" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "20.1.0", - "@react-native-community/cli-platform-android": "20.1.0", - "@react-native-community/cli-platform-ios": "20.1.0", + "@react-native-community/cli": "^20.1.3", + "@react-native-community/cli-platform-android": "^20.1.3", + "@react-native-community/cli-platform-ios": "^20.1.3", "@react-native/babel-preset": "0.86.0", "@react-native/eslint-config": "0.86.0", + "@react-native/jest-preset": "^0.86.0", "@react-native/metro-config": "0.86.0", "@react-native/typescript-config": "0.86.0", "@types/jest": "^29.5.13", @@ -38,4 +44,4 @@ "engines": { "node": ">= 22.11.0" } -} \ No newline at end of file +} diff --git a/src/components/TrainingChart.tsx b/src/components/TrainingChart.tsx new file mode 100644 index 0000000..6ce20b9 --- /dev/null +++ b/src/components/TrainingChart.tsx @@ -0,0 +1,811 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + type LayoutChangeEvent, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import { runOnJS } from 'react-native-reanimated'; +import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; +import { Minus, Plus, RotateCcw } from 'lucide-react-native'; +import { type HeartRateSample } from '../training/trainingTypes'; +import { + applyPan, + applyZoomIn, + applyZoomOut, + buildChartViewport, + formatTickLabel, + granularityForSpan, + isLiveTailSnap, + pickSampleAtTimestamp, + timestampMsFromX, + xFromTimestampMs, + type Granularity, + type Viewport, +} from '../training/chartViewport'; +import { colors, radius, spacing, typography } from '../theme'; + +type Props = { + readonly samples: readonly HeartRateSample[]; + readonly defaultSpanMs?: number; + readonly liveTail?: boolean; +}; + +type ChartPoint = { + readonly x: number; + readonly y: number; + readonly sample: HeartRateSample; +}; + +const CHART_WIDTH = 320; +const CHART_HEIGHT = 148; +const PAD_X = 14; +const PAD_Y = 18; +const DEFAULT_SPAN_MS = 5 * 60_000; +const MIN_SPAN_MS = 1_000; +const MAX_SPAN_MS = 24 * 60 * 60 * 1_000; +const TICK_COUNT_TARGET = 4; + +export function TrainingChart({ + samples, + defaultSpanMs = DEFAULT_SPAN_MS, + liveTail = true, +}: Props) { + const [layoutWidth, setLayoutWidth] = useState(CHART_WIDTH); + const [viewport, setViewport] = useState(() => + buildChartViewport(samples, { defaultSpanMs, liveTail }), + ); + const [pinTimestampMs, setPinTimestampMs] = useState(null); + + // Recompute viewport when samples shrink or when initial dataset changes + // shape. We do not recompute on every new sample in live mode: the auto-follow + // effect below handles that without resetting the user's pan/zoom state. + useEffect(() => { + setViewport(current => { + if (current === null) { + return buildChartViewport(samples, { defaultSpanMs, liveTail }); + } + return current; + }); + }, [samples, defaultSpanMs, liveTail]); + + const lastSampleTimestamp = useMemo(() => { + if (samples.length === 0) return null; + return samples[samples.length - 1].timestamp; + }, [samples]); + + // Auto-follow: when liveTail is on and the dataset extends past the current + // viewport end, slide endMs forward. Pin is untouched. + useEffect(() => { + if (!liveTail) return; + if (lastSampleTimestamp === null) return; + setViewport(current => { + if (current === null) return current; + if (!current.liveTail) return current; + if (lastSampleTimestamp <= current.endMs) return current; + const span = current.endMs - current.startMs; + return { + startMs: lastSampleTimestamp - span, + endMs: lastSampleTimestamp, + liveTail: true, + }; + }); + }, [lastSampleTimestamp, liveTail]); + + // Re-evaluate the liveTail flag whenever the user finishes a pan: if the + // viewport's end is back inside the snap zone, re-attach to live. + const tryResumeLiveTail = useCallback( + (candidate: Viewport) => { + if (lastSampleTimestamp === null) { + setViewport(candidate); + return; + } + const resume = isLiveTailSnap(candidate, lastSampleTimestamp); + setViewport({ ...candidate, liveTail: resume }); + }, + [lastSampleTimestamp], + ); + + const onLayout = useCallback((event: LayoutChangeEvent) => { + setLayoutWidth(Math.max(1, event.nativeEvent.layout.width)); + }, []); + + // -------- Gestures -------- + + // Track the viewport snapshot captured at gesture start so incremental deltas + // compose against a stable baseline (avoids drift across multi-frame moves). + const viewportAtGestureStart = useRef(null); + + const onPanBegin = useCallback(() => { + viewportAtGestureStart.current = viewport; + }, [viewport]); + + const onPanUpdate = useCallback( + (dxPixels: number) => { + const baseline = viewportAtGestureStart.current; + if (baseline === null) return; + const usableWidth = Math.max(1, layoutWidth - PAD_X * 2); + const pixelsPerMs = + usableWidth / Math.max(1, baseline.endMs - baseline.startMs); + setViewport(applyPan(baseline, dxPixels, pixelsPerMs)); + }, + [layoutWidth], + ); + + const onPanEnd = useCallback(() => { + setViewport(current => { + if (current === null) return current; + tryResumeLiveTail(current); + return current; + }); + }, [tryResumeLiveTail]); + + const onPinchBegin = useCallback(() => { + viewportAtGestureStart.current = viewport; + }, [viewport]); + + const onPinchUpdate = useCallback( + (scale: number, focusX: number) => { + const baseline = viewportAtGestureStart.current; + if (baseline === null) return; + const usableWidth = Math.max(1, layoutWidth - PAD_X * 2); + const focusTimestamp = timestampMsFromX( + focusX, + baseline, + CHART_WIDTH, + PAD_X, + ); + // Map focusX from layout space to viewBox space. + const focusXInViewBox = (focusX / layoutWidth) * CHART_WIDTH; + const focusTimestampFromLayout = timestampMsFromX( + focusXInViewBox, + baseline, + CHART_WIDTH, + PAD_X, + ); + const target = (baseline.endMs - baseline.startMs) / Math.max(0.1, scale); + const next = applyZoom(baseline, focusTimestamp, target, { + minSpanMs: MIN_SPAN_MS, + maxSpanMs: MAX_SPAN_MS, + }); + // Keep the focus point visually anchored by re-aiming the result so the + // timestamp under the pinch focus lands at the same x as before. + const afterX = xFromTimestampMs( + focusTimestampFromLayout, + next, + CHART_WIDTH, + PAD_X, + ); + const pixelDelta = + (afterX - focusXInViewBox) * (usableWidth / (CHART_WIDTH - PAD_X * 2)); + setViewport( + applyPan( + next, + -pixelDelta, + usableWidth / Math.max(1, next.endMs - next.startMs), + ), + ); + }, + [layoutWidth], + ); + + const onPinchEnd = useCallback(() => { + viewportAtGestureStart.current = null; + onPanEnd(); + }, [onPanEnd]); + + const onZoomInPress = useCallback(() => { + setViewport(current => { + if (current === null) return current; + const focusMs = (current.startMs + current.endMs) / 2; + return applyZoomIn(current, focusMs, { + minSpanMs: MIN_SPAN_MS, + maxSpanMs: MAX_SPAN_MS, + }); + }); + }, []); + + const onZoomOutPress = useCallback(() => { + setViewport(current => { + if (current === null) return current; + const focusMs = (current.startMs + current.endMs) / 2; + return applyZoomOut(current, focusMs, { + minSpanMs: MIN_SPAN_MS, + maxSpanMs: MAX_SPAN_MS, + }); + }); + }, []); + + const onResetPress = useCallback(() => { + const fresh = buildChartViewport(samples, { defaultSpanMs, liveTail }); + if (fresh !== null) { + setViewport(fresh); + setPinTimestampMs(null); + } + }, [samples, defaultSpanMs, liveTail]); + + const onTapAtX = useCallback( + (xInViewBox: number) => { + if (viewport === null) return; + const tapTimestamp = timestampMsFromX( + xInViewBox, + viewport, + CHART_WIDTH, + PAD_X, + ); + const tolerance = (viewport.endMs - viewport.startMs) * 0.1; + const sample = pickSampleAtTimestamp(samples, tapTimestamp, tolerance); + if (sample === null) { + setPinTimestampMs(null); + return; + } + setPinTimestampMs(previous => { + if (previous === sample.timestamp) return null; + return sample.timestamp; + }); + }, + [samples, viewport], + ); + + // Pan uses absolute deltas via the JS bridge: each frame, runOnJS forwards + // the cumulative translationX to applyPan over the captured baseline. + const panGesture = useMemo( + () => + Gesture.Pan() + .onBegin(() => { + runOnJS(onPanBegin)(); + }) + .onUpdate(event => { + runOnJS(onPanUpdate)(event.translationX); + }) + .onEnd(() => { + runOnJS(onPanEnd)(); + }) + .onFinalize(() => { + runOnJS(onPanEnd)(); + }) + .minDistance(4), + [onPanBegin, onPanUpdate, onPanEnd], + ); + + const pinchGesture = useMemo( + () => + Gesture.Pinch() + .onBegin(() => { + runOnJS(onPinchBegin)(); + }) + .onUpdate(event => { + runOnJS(onPinchUpdate)(event.scale, event.focalX); + }) + .onEnd(() => { + runOnJS(onPinchEnd)(); + }) + .onFinalize(() => { + runOnJS(onPinchEnd)(); + }), + [onPinchBegin, onPinchUpdate, onPinchEnd], + ); + + const tapGesture = useMemo( + () => + Gesture.Tap() + .maxDuration(220) + .onEnd(event => { + // Convert focalX (layout space) to viewBox space before hit-testing. + const xInViewBox = (event.x / Math.max(1, layoutWidth)) * CHART_WIDTH; + runOnJS(onTapAtX)(xInViewBox); + }), + [layoutWidth, onTapAtX], + ); + + const composed = useMemo( + () => + Gesture.Race(tapGesture, Gesture.Simultaneous(panGesture, pinchGesture)), + [tapGesture, panGesture, pinchGesture], + ); + + // -------- Render data -------- + + const renderable = useMemo(() => { + if (viewport === null) return null; + if (samples.length < 2) return null; + + const visible = samples.filter( + sample => + sample.timestamp >= viewport.startMs && + sample.timestamp <= viewport.endMs, + ); + if (visible.length < 1) return null; + + const bpms = visible.map(sample => sample.bpm); + const minBpm = Math.min(...bpms); + const maxBpm = Math.max(...bpms); + const bpmRange = Math.max(1, maxBpm - minBpm); + const span = Math.max(1, viewport.endMs - viewport.startMs); + + const points: ChartPoint[] = visible.map(sample => { + const x = xFromTimestampMs( + sample.timestamp, + viewport, + CHART_WIDTH, + PAD_X, + ); + const y = + CHART_HEIGHT - + PAD_Y - + ((sample.bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2); + return { x, y, sample }; + }); + + const path = points + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' '); + + return { + visible, + minBpm, + maxBpm, + lastPoint: points[points.length - 1], + points, + path, + span, + }; + }, [samples, viewport]); + + const granularity: Granularity = useMemo( + () => + granularityForSpan( + viewport ? viewport.endMs - viewport.startMs : DEFAULT_SPAN_MS, + ), + [viewport], + ); + + const ticks = useMemo(() => { + if (viewport === null) return []; + return buildTicks(viewport, granularity, TICK_COUNT_TARGET); + }, [viewport, granularity]); + + const pinPoint = useMemo(() => { + if (renderable === null || pinTimestampMs === null) return null; + const sample = samples.find(s => s.timestamp === pinTimestampMs); + if (sample === undefined) return null; + if ( + sample.timestamp < viewport!.startMs || + sample.timestamp > viewport!.endMs + ) { + return { sample, offViewport: true }; + } + return { sample, offViewport: false }; + }, [renderable, pinTimestampMs, samples, viewport]); + + // -------- JSX -------- + + if (viewport === null) { + return ( + + Aguardando amostras reais + + O gráfico aparece quando o sensor enviar BPM pelo characteristic 2A37. + + + ); + } + + if (renderable === null) { + return ( + + Sem amostras na janela + + Toque em [↺] para voltar ao fit ou [+] [−] para ajustar a janela. + + + ); + } + + return ( + + + + + + + + + + {renderable.maxBpm} + + + {renderable.minBpm} + + {ticks.map((tick, index) => ( + + + + {tick.label} + + + ))} + + + + {pinPoint && !pinPoint.offViewport ? ( + <> + = viewport.startMs && + pinPoint.sample.timestamp <= viewport.endMs + ? xFromTimestampMs( + pinPoint.sample.timestamp, + viewport, + CHART_WIDTH, + PAD_X, + ) + : PAD_X + } + x2={ + pinPoint.sample.timestamp >= viewport.startMs && + pinPoint.sample.timestamp <= viewport.endMs + ? xFromTimestampMs( + pinPoint.sample.timestamp, + viewport, + CHART_WIDTH, + PAD_X, + ) + : PAD_X + } + y1={PAD_Y} + y2={CHART_HEIGHT - PAD_Y} + /> + + + ) : null} + + + + + } + onPress={onZoomInPress} + /> + } + onPress={onZoomOutPress} + /> + } + onPress={onResetPress} + /> + + + + + {pinPoint + ? 'AMOSTRA PINADA' + : viewport.liveTail + ? 'AO VIVO' + : 'JANELA'} + + + {pinPoint + ? `${formatSampleTime(pinPoint.sample.timestamp)} · ${ + pinPoint.sample.bpm + } BPM` + : `${formatSampleTime(renderable.lastPoint.sample.timestamp)} · ${ + renderable.lastPoint.sample.bpm + } BPM`} + + + {!viewport.liveTail && + liveTail && + renderable.lastPoint.sample.timestamp < (lastSampleTimestamp ?? 0) ? ( + + Dados mais novos disponíveis. Arraste para a direita ou toque em ↺. + + ) : null} + {pinPoint && pinPoint.offViewport ? ( + + PIN FORA DA JANELA. Toque em ↺ para retornar. + + ) : null} + + ); +} + +// -------- Helpers -------- + +function PinMarker({ + timestampMs, + bpm, + minBpm, + maxBpm, + viewport, +}: { + readonly timestampMs: number; + readonly bpm: number; + readonly minBpm: number; + readonly maxBpm: number; + readonly viewport: Viewport; +}) { + const x = xFromTimestampMs(timestampMs, viewport, CHART_WIDTH, PAD_X); + const bpmRange = Math.max(1, maxBpm - minBpm); + const y = + CHART_HEIGHT - + PAD_Y - + ((bpm - minBpm) / bpmRange) * (CHART_HEIGHT - PAD_Y * 2); + return ( + <> + + + + ); +} + +function ControlButton({ + accessibilityLabel, + icon, + onPress, +}: { + readonly accessibilityLabel: string; + readonly icon: React.ReactNode; + readonly onPress: () => void; +}) { + return ( + [ + styles.controlButton, + pressed ? styles.controlButtonPressed : null, + ]} + > + {icon} + + ); +} + +type Tick = { + readonly timestampMs: number; + readonly x: number; + readonly label: string; +}; + +function buildTicks( + viewport: Viewport, + granularity: Granularity, + target: number, +): readonly Tick[] { + const ticks: Tick[] = []; + const step = granularity.stepMs; + // Snap start to the next multiple of step so labels look aligned. + const firstTick = Math.ceil(viewport.startMs / step) * step; + for (let t = firstTick; t <= viewport.endMs; t += step) { + ticks.push({ + timestampMs: t, + x: xFromTimestampMs(t, viewport, CHART_WIDTH, PAD_X), + label: formatTickLabel(t, granularity), + }); + if (ticks.length >= target + 2) break; + } + return ticks; +} + +function applyZoom( + viewport: Viewport, + focusMs: number, + targetSpan: number, + options: { minSpanMs: number; maxSpanMs: number }, +): Viewport { + const span = Math.max( + options.minSpanMs, + Math.min(options.maxSpanMs, targetSpan), + ); + const currentSpan = Math.max(1, viewport.endMs - viewport.startMs); + const focusRatio = (focusMs - viewport.startMs) / currentSpan; + const newStart = focusMs - focusRatio * span; + return { + startMs: newStart, + endMs: newStart + span, + liveTail: false, + }; +} + +function formatSampleTime(timestamp: number) { + return new Date(timestamp).toLocaleTimeString('pt-BR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +const styles = StyleSheet.create({ + wrap: { + borderWidth: 1, + borderColor: colors.line, + borderRadius: radius.md, + backgroundColor: colors.surfaceHot, + overflow: 'hidden', + }, + chartRow: { + flexDirection: 'row', + alignItems: 'stretch', + }, + chartSurface: { + flex: 1, + minHeight: CHART_HEIGHT, + }, + controls: { + borderLeftWidth: 1, + borderLeftColor: colors.line, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.xxs, + gap: spacing.xs, + justifyContent: 'flex-start', + alignItems: 'center', + }, + controlButton: { + width: 32, + height: 32, + borderRadius: radius.xs, + borderWidth: 1, + borderColor: colors.line, + backgroundColor: colors.surfaceRaised, + alignItems: 'center', + justifyContent: 'center', + }, + controlButtonPressed: { + opacity: 0.6, + }, + readout: { + borderTopWidth: 1, + borderTopColor: colors.line, + minHeight: 42, + paddingHorizontal: spacing.sm, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + }, + readoutLabel: { + ...typography.label, + color: colors.warning, + }, + readoutValue: { + ...typography.label, + color: colors.text, + flexShrink: 1, + textAlign: 'right', + }, + tailNotice: { + ...typography.label, + color: colors.textDim, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderTopWidth: 1, + borderTopColor: colors.line, + }, + pinOffNotice: { + ...typography.label, + color: colors.warning, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderTopWidth: 1, + borderTopColor: colors.line, + }, + emptyWrap: { + minHeight: CHART_HEIGHT, + borderWidth: 1, + borderColor: colors.line, + borderRadius: radius.md, + backgroundColor: colors.surfaceHot, + padding: spacing.md, + justifyContent: 'center', + }, + emptyTitle: { + ...typography.label, + color: colors.warning, + }, + emptyCopy: { + ...typography.body, + color: colors.textMuted, + marginTop: spacing.xs, + }, +}); diff --git a/src/training/chartViewport.ts b/src/training/chartViewport.ts new file mode 100644 index 0000000..14fa13c --- /dev/null +++ b/src/training/chartViewport.ts @@ -0,0 +1,253 @@ +import { type HeartRateSample } from './trainingTypes'; + +export type GranularityKind = + | 'minute' + | 'halfMinute' + | 'quarterMinute' + | 'tenSeconds' + | 'second' + | 'halfSecond'; + +export type Granularity = { + readonly kind: GranularityKind; + readonly stepMs: number; +}; + +const HALF_SECOND_MS = 500; +const SECOND_MS = 1_000; +const TEN_SECONDS_MS = 10_000; +const QUARTER_MINUTE_MS = 15_000; +const HALF_MINUTE_MS = 30_000; +const MINUTE_MS = 60_000; +const FIVE_MINUTES_MS = 5 * MINUTE_MS; + +export function granularityForSpan(spanMs: number): Granularity { + if (spanMs < 2 * SECOND_MS) { + return { kind: 'halfSecond', stepMs: HALF_SECOND_MS }; + } + if (spanMs < TEN_SECONDS_MS) { + return { kind: 'second', stepMs: SECOND_MS }; + } + if (spanMs < HALF_MINUTE_MS) { + return { kind: 'tenSeconds', stepMs: TEN_SECONDS_MS }; + } + if (spanMs < MINUTE_MS) { + return { kind: 'quarterMinute', stepMs: QUARTER_MINUTE_MS }; + } + if (spanMs < FIVE_MINUTES_MS) { + return { kind: 'halfMinute', stepMs: HALF_MINUTE_MS }; + } + return { kind: 'minute', stepMs: MINUTE_MS }; +} + +function pad2(value: number): string { + return value < 10 ? `0${value}` : `${value}`; +} + +export function formatTickLabel( + timestampMs: number, + granularity: Granularity, +): string { + const date = new Date(timestampMs); + const hours = pad2(date.getHours()); + const minutes = pad2(date.getMinutes()); + const seconds = pad2(date.getSeconds()); + + if (granularity.kind === 'minute') { + return `${hours}:${minutes}`; + } + + if (granularity.kind === 'halfSecond') { + const tenths = Math.floor(date.getMilliseconds() / 100); + return `${hours}:${minutes}:${seconds}.${tenths}`; + } + + return `${hours}:${minutes}:${seconds}`; +} + +export type Viewport = { + readonly startMs: number; + readonly endMs: number; + readonly liveTail: boolean; +}; + +export type BuildViewportOptions = { + readonly defaultSpanMs: number; + readonly liveTail?: boolean; +}; + +export function buildChartViewport( + samples: readonly HeartRateSample[], + options: BuildViewportOptions, +): Viewport | null { + if (samples.length < 2) { + return null; + } + + const sortedSamples = samples + .slice() + .sort((left, right) => left.timestamp - right.timestamp); + const firstTimestamp = sortedSamples[0].timestamp; + const lastTimestamp = sortedSamples[sortedSamples.length - 1].timestamp; + const totalSpan = lastTimestamp - firstTimestamp; + const liveTail = options.liveTail ?? true; + + if (totalSpan <= 0) { + return { + startMs: firstTimestamp, + endMs: firstTimestamp + options.defaultSpanMs, + liveTail, + }; + } + + if (totalSpan <= options.defaultSpanMs) { + return { + startMs: firstTimestamp, + endMs: lastTimestamp, + liveTail, + }; + } + + return { + startMs: lastTimestamp - options.defaultSpanMs, + endMs: lastTimestamp, + liveTail, + }; +} + +const DEFAULT_MIN_SPAN_MS = 1_000; +const DEFAULT_MAX_SPAN_MS = 24 * 60 * 60 * 1_000; +const DEFAULT_SNAP_FRACTION = 0.05; + +export type ZoomOptions = { + readonly minSpanMs?: number; + readonly maxSpanMs?: number; +}; + +export function applyZoomIn( + viewport: Viewport, + focusMs: number, + options: ZoomOptions = {}, +): Viewport { + const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS; + const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS; + const currentSpan = viewport.endMs - viewport.startMs; + const targetSpan = clampSpan(currentSpan / 2, minSpan, maxSpan); + return zoomAroundFocus(viewport, focusMs, targetSpan); +} + +export function applyZoomOut( + viewport: Viewport, + focusMs: number, + options: ZoomOptions = {}, +): Viewport { + const minSpan = options.minSpanMs ?? DEFAULT_MIN_SPAN_MS; + const maxSpan = options.maxSpanMs ?? DEFAULT_MAX_SPAN_MS; + const currentSpan = viewport.endMs - viewport.startMs; + const targetSpan = clampSpan(currentSpan * 2, minSpan, maxSpan); + return zoomAroundFocus(viewport, focusMs, targetSpan); +} + +function clampSpan(span: number, min: number, max: number): number { + if (span < min) return min; + if (span > max) return max; + return span; +} + +function zoomAroundFocus( + viewport: Viewport, + focusMs: number, + newSpan: number, +): Viewport { + const currentSpan = Math.max(1, viewport.endMs - viewport.startMs); + const focusRatio = (focusMs - viewport.startMs) / currentSpan; + const newStart = focusMs - focusRatio * newSpan; + return { + startMs: newStart, + endMs: newStart + newSpan, + liveTail: false, + }; +} + +export function applyPan( + viewport: Viewport, + dxPixels: number, + pixelsPerMs: number, +): Viewport { + if (pixelsPerMs === 0) { + return { + startMs: viewport.startMs, + endMs: viewport.endMs, + liveTail: false, + }; + } + const dxMs = dxPixels / pixelsPerMs; + return { + startMs: viewport.startMs + dxMs, + endMs: viewport.endMs + dxMs, + liveTail: false, + }; +} + +export function isLiveTailSnap( + viewport: Viewport, + lastSampleTimestampMs: number, + snapFraction: number = DEFAULT_SNAP_FRACTION, +): boolean { + const visibleSpan = viewport.endMs - viewport.startMs; + if (visibleSpan <= 0) return false; + const distance = lastSampleTimestampMs - viewport.endMs; + if (distance < 0) return false; + return distance <= visibleSpan * snapFraction; +} + +export function pickSampleAtTimestamp( + samples: readonly HeartRateSample[], + timestampMs: number, + toleranceMs: number, +): HeartRateSample | null { + if (samples.length === 0) { + return null; + } + + let closest: HeartRateSample | null = null; + let closestDistance = Number.POSITIVE_INFINITY; + + for (const candidate of samples) { + const distance = Math.abs(candidate.timestamp - timestampMs); + if (distance < closestDistance) { + closestDistance = distance; + closest = candidate; + } + } + + return closestDistance <= toleranceMs ? closest : null; +} + +export function xFromTimestampMs( + timestampMs: number, + viewport: Viewport, + chartWidth: number, + padX: number, +): number { + const span = Math.max(1, viewport.endMs - viewport.startMs); + const ratio = (timestampMs - viewport.startMs) / span; + return padX + ratio * (chartWidth - 2 * padX); +} + +export function timestampMsFromX( + x: number, + viewport: Viewport, + chartWidth: number, + padX: number, +): number { + const usableWidth = chartWidth - 2 * padX; + if (usableWidth <= 0) { + return viewport.startMs; + } + const ratio = (x - padX) / usableWidth; + const ts = viewport.startMs + ratio * (viewport.endMs - viewport.startMs); + if (ts < viewport.startMs) return viewport.startMs; + if (ts > viewport.endMs) return viewport.endMs; + return ts; +}