From dbb4beb56d4692e4c2303cf4c2fb79b89c26118b Mon Sep 17 00:00:00 2001 From: AG Date: Sat, 13 Dec 2025 00:30:12 +0200 Subject: [PATCH] Vibro call-back for drag & Drop (does not work in Firefox). New chart - Number of Workouts. --- src/components/Plans.tsx | 83 ++++++++++++++++++++--- src/components/Stats.tsx | 88 +++++++++++++++++++++---- src/services/i18n.ts | 2 + tests/drag-drop-vibration.spec.ts | 105 ++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 22 deletions(-) create mode 100644 tests/drag-drop-vibration.spec.ts diff --git a/src/components/Plans.tsx b/src/components/Plans.tsx index 26639e6..1aeeeb1 100644 --- a/src/components/Plans.tsx +++ b/src/components/Plans.tsx @@ -68,12 +68,67 @@ const SortablePlanStep: React.FC = ({ step, index, toggle position: 'relative' as 'relative', }; + const handlePointerDown = (e: React.PointerEvent) => { + listeners?.onPointerDown?.(e); + + // Only trigger vibration for touch input (long press logic) + if (e.pointerType === 'touch') { + const startTime = Date.now(); + + // Use pattern [0, 300, 50] to vibrate after 300ms delay, triggered synchronously by user gesture + // This works around Firefox Android blocking async vibrate calls + if (typeof navigator !== 'undefined' && navigator.vibrate) { + try { + navigator.vibrate([0, 300, 50]); + } catch (err) { + // Ignore potential errors if vibrate is blocked or invalid + } + } + + // Cleanup / Cancel logic + const cancelVibration = () => { + // Only cancel if less than 300ms has passed (meaning we aborted the long press) + // If > 300ms, the vibration (50ms) is either playing or done, we let it finish. + if (Date.now() - startTime < 300) { + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate(0); + } + } + cleanup(); + }; + + const startX = e.clientX; + const startY = e.clientY; + + const onMove = (me: PointerEvent) => { + const diff = Math.hypot(me.clientX - startX, me.clientY - startY); + if (diff > 10) { // 10px tolerance + cancelVibration(); + } + }; + + const cleanup = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', cancelVibration); + window.removeEventListener('pointercancel', cancelVibration); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', cancelVibration); + window.addEventListener('pointercancel', cancelVibration); + } + }; + return (
-
+
@@ -136,17 +191,19 @@ const Plans: React.FC = ({ lang }) => { // Dnd Sensors const sensors = useSensors( - useSensor(PointerSensor), // Handle mouse and basic pointer events - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + }, }), useSensor(TouchSensor, { - // Small delay or tolerance can help distinguish scrolling from dragging, - // but usually for a handle drag, instant is fine or defaults work. - // Let's add a small activation constraint to prevent accidental drags while scrolling if picking by handle activationConstraint: { - distance: 5, + delay: 300, + tolerance: 5, }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, }) ); @@ -288,6 +345,16 @@ const Plans: React.FC = ({ lang }) => { setSteps(steps.filter(s => s.id !== stepId)); }; + /* Vibration handled in SortablePlanStep locally for better touch support */ + /* + const handleDragStart = () => { + console.log('handleDragStart called'); + if (typeof navigator !== 'undefined' && navigator.vibrate) { + navigator.vibrate(50); + } + }; + */ + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index b33e9e7..9453198 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -22,7 +22,9 @@ const Stats: React.FC = ({ lang }) => { }, []); const volumeData = useMemo(() => { - const data = [...sessions].reverse().map(session => { + const data: { date: string; work: number }[] = []; + + [...sessions].reverse().forEach(session => { const sessionWeight = session.userBodyWeight || 70; const work = session.sets.reduce((acc, set) => { let setWork = 0; @@ -40,26 +42,65 @@ const Stats: React.FC = ({ lang }) => { return acc + Math.max(0, setWork); }, 0); - return { - date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }), - work: Math.round(work) - }; - }).filter(d => d.work > 0); + if (work > 0) { + const dateStr = new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }); + const lastEntry = data[data.length - 1]; + if (lastEntry && lastEntry.date === dateStr) { + lastEntry.work += Math.round(work); + } else { + data.push({ date: dateStr, work: Math.round(work) }); + } + } + }); + return data; + }, [sessions, lang]); + + const sessionsCountData = useMemo(() => { + const data: { date: string; sessions: number }[] = []; + + [...sessions].reverse().forEach(session => { + const dateStr = new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }); + const lastEntry = data[data.length - 1]; + if (lastEntry && lastEntry.date === dateStr) { + lastEntry.sessions += 1; + } else { + data.push({ date: dateStr, sessions: 1 }); + } + }); + return data; }, [sessions, lang]); const setsData = useMemo(() => { - return [...sessions].reverse().map(session => ({ - date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }), - sets: session.sets.length - })); + const data: { date: string; sets: number }[] = []; + + [...sessions].reverse().forEach(session => { + const dateStr = new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }); + const lastEntry = data[data.length - 1]; + if (lastEntry && lastEntry.date === dateStr) { + lastEntry.sets += session.sets.length; + } else { + data.push({ date: dateStr, sets: session.sets.length }); + } + }); + + return data; }, [sessions, lang]); const weightData = useMemo(() => { - return [...weightRecords].reverse().map(record => ({ - date: new Date(record.date).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }), - weight: record.weight - })); + const data: { date: string; weight: number }[] = []; + + [...weightRecords].reverse().forEach(record => { + const dateStr = new Date(record.date).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }); + const lastEntry = data[data.length - 1]; + if (lastEntry && lastEntry.date === dateStr) { + lastEntry.weight = record.weight; + } else { + data.push({ date: dateStr, weight: record.weight }); + } + }); + + return data; }, [weightRecords, lang]); if (sessions.length < 2) { @@ -99,6 +140,25 @@ const Stats: React.FC = ({ lang }) => {
+ {/* Sessions Count Chart */} +
+

{t('sessions_count_title', lang)}

+
+ + + + + + + + + +
+
+ {/* Sets Chart */}

{t('sets_title', lang)}

diff --git a/src/services/i18n.ts b/src/services/i18n.ts index 9cf3c5a..71987d4 100644 --- a/src/services/i18n.ts +++ b/src/services/i18n.ts @@ -123,6 +123,7 @@ const translations = { progress: 'Progress', volume_title: 'Work Volume', volume_subtitle: 'Tonnage (kg * reps)', + sessions_count_title: 'Number of Sessions', sets_title: 'Number of Sets', weight_title: 'Body Weight History', not_enough_data: 'Not enough data for statistics. Complete a few workouts!', @@ -303,6 +304,7 @@ const translations = { progress: 'Прогресс', volume_title: 'Объем работы', volume_subtitle: 'Тоннаж (кг * повторения)', + sessions_count_title: 'Количество тренировок', sets_title: 'Количество сетов', weight_title: 'История веса тела', not_enough_data: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!', diff --git a/tests/drag-drop-vibration.spec.ts b/tests/drag-drop-vibration.spec.ts new file mode 100644 index 0000000..2d4eae7 --- /dev/null +++ b/tests/drag-drop-vibration.spec.ts @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Plan Editor Drag & Drop Vibration', () => { + test.beforeEach(async ({ page }) => { + // Mock navigator.vibrate + await page.addInitScript(() => { + try { + Object.defineProperty(navigator, 'vibrate', { + value: (pattern) => { + console.log(`Vibration triggered: ${pattern}`); + window.dispatchEvent(new CustomEvent('vibration-triggered', { detail: pattern })); + return true; + }, + writable: true, + configurable: true, + }); + } catch (e) { + console.error('Failed to mock vibrate', e); + } + }); + + await page.goto('/'); + + // Create a new user + const uniqueId = Date.now().toString(); + const email = `dragvibetest${uniqueId}@example.com`; + + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', 'password123'); + await page.click('button:has-text("Sign Up")'); + await page.waitForURL('**/dashboard'); + + if (await page.getByPlaceholder('Enter your name').isVisible()) { + await page.getByPlaceholder('Enter your name').fill('Vibe Tester'); + await page.getByRole('button', { name: 'Complete Profile' }).click(); + } + }); + + test('should trigger vibration on drag start', async ({ page }) => { + // Navigate to Plans + await page.getByRole('button', { name: 'Plans' }).click(); + + // Create Plan + await page.getByLabel('Create Plan').click(); + await page.getByLabel('Plan Name').fill('Vibration Plan'); + + // Add Exercises + await page.getByRole('button', { name: 'Add Exercise' }).click(); + await page.getByRole('button', { name: 'Create New Exercise' }).click(); + await page.getByLabel('Exercise Name').fill('Exercise 1'); + await page.getByRole('button', { name: 'Save Exercise' }).click(); + + await page.getByRole('button', { name: 'Add Exercise' }).click(); + await page.getByRole('button', { name: 'Create New Exercise' }).click(); + await page.getByLabel('Exercise Name').fill('Exercise 2'); + await page.getByRole('button', { name: 'Save Exercise' }).click(); + + // Listen for vibration event with timeout + let vibrationDetected = false; + page.on('console', msg => { + if (msg.text().includes('Vibration triggered') || msg.text().includes('handlePointerDown')) { + console.log('Browser Console:', msg.text()); + } + if (msg.text().includes('Vibration triggered')) vibrationDetected = true; + }); + + // Drag + const dragHandle = page.locator('.cursor-grab').first(); + const dragDest = page.locator('.cursor-grab').nth(1); + + // Drag using manual pointer control simulating TOUCH via evaluate + const box = await dragHandle.boundingBox(); + if (box) { + // Dispatch directly in browser to ensure React synthetic event system picks it up + await dragHandle.evaluate((el) => { + const event = new PointerEvent('pointerdown', { + bubbles: true, + cancelable: true, + pointerType: 'touch', + clientX: 0, + clientY: 0, + isPrimary: true + }); + el.dispatchEvent(event); + }); + + // Wait for usage + await page.waitForTimeout(500); + + // Dispatch pointerup + await dragHandle.evaluate((el) => { + const event = new PointerEvent('pointerup', { + bubbles: true, + cancelable: true, + pointerType: 'touch', + isPrimary: true + }); + el.dispatchEvent(event); + }); + } + + // Check flag + expect(vibrationDetected).toBeTruthy(); + }); +}); \ No newline at end of file