Vibro call-back for drag & Drop (does not work in Firefox). New chart - Number of Workouts.
This commit is contained in:
@@ -68,12 +68,67 @@ const SortablePlanStep: React.FC<SortablePlanStepProps> = ({ step, index, toggle
|
|||||||
position: 'relative' as 'relative',
|
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 (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes}>
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
<Card
|
<Card
|
||||||
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${isDragging ? 'bg-surface-container-high shadow-elevation-3' : ''}`}
|
className={`flex items-center gap-3 transition-all hover:bg-surface-container-high ${isDragging ? 'bg-surface-container-high shadow-elevation-3' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="text-on-surface-variant p-1 cursor-grab touch-none" {...listeners}>
|
<div
|
||||||
|
className="text-on-surface-variant p-1 cursor-grab touch-none"
|
||||||
|
{...listeners}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
>
|
||||||
<GripVertical size={20} />
|
<GripVertical size={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,17 +191,19 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
|
|
||||||
// Dnd Sensors
|
// Dnd Sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor), // Handle mouse and basic pointer events
|
useSensor(MouseSensor, {
|
||||||
useSensor(KeyboardSensor, {
|
activationConstraint: {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
distance: 10,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, {
|
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: {
|
activationConstraint: {
|
||||||
distance: 5,
|
delay: 300,
|
||||||
|
tolerance: 5,
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -288,6 +345,16 @@ const Plans: React.FC<PlansProps> = ({ lang }) => {
|
|||||||
setSteps(steps.filter(s => s.id !== stepId));
|
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 handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ const Stats: React.FC<StatsProps> = ({ lang }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const volumeData = useMemo(() => {
|
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 sessionWeight = session.userBodyWeight || 70;
|
||||||
const work = session.sets.reduce((acc, set) => {
|
const work = session.sets.reduce((acc, set) => {
|
||||||
let setWork = 0;
|
let setWork = 0;
|
||||||
@@ -40,26 +42,65 @@ const Stats: React.FC<StatsProps> = ({ lang }) => {
|
|||||||
return acc + Math.max(0, setWork);
|
return acc + Math.max(0, setWork);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return {
|
if (work > 0) {
|
||||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
const dateStr = new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' });
|
||||||
work: Math.round(work)
|
const lastEntry = data[data.length - 1];
|
||||||
};
|
if (lastEntry && lastEntry.date === dateStr) {
|
||||||
}).filter(d => d.work > 0);
|
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;
|
return data;
|
||||||
}, [sessions, lang]);
|
}, [sessions, lang]);
|
||||||
|
|
||||||
const setsData = useMemo(() => {
|
const setsData = useMemo(() => {
|
||||||
return [...sessions].reverse().map(session => ({
|
const data: { date: string; sets: number }[] = [];
|
||||||
date: new Date(session.startTime).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
|
||||||
sets: session.sets.length
|
[...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]);
|
}, [sessions, lang]);
|
||||||
|
|
||||||
const weightData = useMemo(() => {
|
const weightData = useMemo(() => {
|
||||||
return [...weightRecords].reverse().map(record => ({
|
const data: { date: string; weight: number }[] = [];
|
||||||
date: new Date(record.date).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'short' }),
|
|
||||||
weight: record.weight
|
[...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]);
|
}, [weightRecords, lang]);
|
||||||
|
|
||||||
if (sessions.length < 2) {
|
if (sessions.length < 2) {
|
||||||
@@ -99,6 +140,25 @@ const Stats: React.FC<StatsProps> = ({ lang }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sessions Count Chart */}
|
||||||
|
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||||
|
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sessions_count_title', lang)}</h3>
|
||||||
|
<div className="h-64 min-h-64 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height={256}>
|
||||||
|
<BarChart data={sessionsCountData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#49454F" vertical={false} opacity={0.5} />
|
||||||
|
<XAxis dataKey="date" stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} dy={10} />
|
||||||
|
<YAxis stroke="#CAC4D0" fontSize={12} tickLine={false} axisLine={false} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#2B2930', borderColor: '#49454F', color: '#E6E0E9', borderRadius: '12px' }}
|
||||||
|
cursor={{ fill: 'rgba(255,255,255,0.05)' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="sessions" fill="#4FD1C5" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Sets Chart */}
|
{/* Sets Chart */}
|
||||||
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
<div className="bg-surface-container p-5 rounded-[24px] shadow-elevation-1 border border-outline-variant/20">
|
||||||
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
|
<h3 className="text-title-medium font-medium text-on-surface mb-6">{t('sets_title', lang)}</h3>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const translations = {
|
|||||||
progress: 'Progress',
|
progress: 'Progress',
|
||||||
volume_title: 'Work Volume',
|
volume_title: 'Work Volume',
|
||||||
volume_subtitle: 'Tonnage (kg * reps)',
|
volume_subtitle: 'Tonnage (kg * reps)',
|
||||||
|
sessions_count_title: 'Number of Sessions',
|
||||||
sets_title: 'Number of Sets',
|
sets_title: 'Number of Sets',
|
||||||
weight_title: 'Body Weight History',
|
weight_title: 'Body Weight History',
|
||||||
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
|
not_enough_data: 'Not enough data for statistics. Complete a few workouts!',
|
||||||
@@ -303,6 +304,7 @@ const translations = {
|
|||||||
progress: 'Прогресс',
|
progress: 'Прогресс',
|
||||||
volume_title: 'Объем работы',
|
volume_title: 'Объем работы',
|
||||||
volume_subtitle: 'Тоннаж (кг * повторения)',
|
volume_subtitle: 'Тоннаж (кг * повторения)',
|
||||||
|
sessions_count_title: 'Количество тренировок',
|
||||||
sets_title: 'Количество сетов',
|
sets_title: 'Количество сетов',
|
||||||
weight_title: 'История веса тела',
|
weight_title: 'История веса тела',
|
||||||
not_enough_data: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!',
|
not_enough_data: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!',
|
||||||
|
|||||||
105
tests/drag-drop-vibration.spec.ts
Normal file
105
tests/drag-drop-vibration.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user