Vibro call-back for drag & Drop (does not work in Firefox). New chart - Number of Workouts.

This commit is contained in:
AG
2025-12-13 00:30:12 +02:00
parent f169c7c4d3
commit dbb4beb56d
4 changed files with 256 additions and 22 deletions

View File

@@ -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;

View File

@@ -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>