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',
|
||||
};
|
||||
|
||||
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 (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<Card
|
||||
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} />
|
||||
</div>
|
||||
|
||||
@@ -136,17 +191,19 @@ const Plans: React.FC<PlansProps> = ({ 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<PlansProps> = ({ 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;
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ const Stats: React.FC<StatsProps> = ({ 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<StatsProps> = ({ 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<StatsProps> = ({ lang }) => {
|
||||
</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 */}
|
||||
<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>
|
||||
|
||||
@@ -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: 'Недостаточно данных для статистики. Проведите хотя бы пару тренировок!',
|
||||
|
||||
Reference in New Issue
Block a user