Skip to content

Commit 8588e7f

Browse files
authored
Merge pull request #7 from RavelloH/copilot/add-split-timing-feature
Add lap timing to stopwatch with modal display
2 parents 2f716ec + 6a71a46 commit 8588e7f

4 files changed

Lines changed: 230 additions & 5 deletions

File tree

components/Countdown/TimerDisplay.js

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@ import { useTimers } from '../../context/TimerContext';
44
import { useTranslation } from '../../hooks/useTranslation';
55
import DigitColumn from './DigitColumn';
66
import { addNotification } from '../../utils/notificationManager';
7-
import { FiPlay, FiPause, FiSquare } from 'react-icons/fi';
7+
import { FiPlay, FiPause, FiSquare, FiFlag, FiList } from 'react-icons/fi';
8+
import LapTimesModal from '../UI/LapTimesModal';
89

910
export default function TimerDisplay() {
1011
const { getActiveTimer, updateTimer, checkAndUpdateDefaultTimer } = useTimers();
11-
const { t } = useTranslation();
12+
const { t, currentLang } = useTranslation();
1213
const [timeValue, setTimeValue] = useState({ years: 0, days: 0, hours: 0, minutes: 0, seconds: 0 });
1314
const [showDays, setShowDays] = useState(true);
1415
const [showYears, setShowYears] = useState(false);
1516
const [isFinished, setIsFinished] = useState(false);
1617
const [isRunning, setIsRunning] = useState(false);
18+
const [isLapModalOpen, setIsLapModalOpen] = useState(false);
1719

1820
// 使用 ref 跟踪最后计算的时间,避免不必要的重渲染
1921
const lastTimeRef = useRef({ years: 0, days: 0, hours: 0, minutes: 0, seconds: 0 });
@@ -338,12 +340,29 @@ export default function TimerDisplay() {
338340
isRunning: false,
339341
startTime: now.toISOString(),
340342
pausedAt: null,
341-
totalPausedTime: 0
343+
totalPausedTime: 0,
344+
laps: [] // 清空分段记录
342345
});
343346
setIsRunning(false);
344347
setTimeValue({ years: 0, days: 0, hours: 0, minutes: 0, seconds: 0 });
345348
lastTimeRef.current = { years: 0, days: 0, hours: 0, minutes: 0, seconds: 0 };
346349
break;
350+
351+
case 'lap':
352+
// Record lap time
353+
const startTime = new Date(timer.startTime);
354+
const elapsedMs = now - startTime - (timer.totalPausedTime || 0);
355+
const laps = timer.laps || [];
356+
// Use a combination of timestamp and random to avoid collision
357+
const newLap = {
358+
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
359+
timestamp: now.toISOString(),
360+
elapsedMs: elapsedMs
361+
};
362+
updateTimer(timer.id, {
363+
laps: [...laps, newLap]
364+
});
365+
break;
347366
}
348367
};
349368

@@ -500,6 +519,20 @@ export default function TimerDisplay() {
500519
>
501520
{isRunning ? <FiPause className="text-xl pointer-events-none" /> : <FiPlay className="text-xl pointer-events-none" />}
502521
</button>
522+
<button
523+
onClick={() => handleStopwatchControl('lap')}
524+
disabled={!isRunning}
525+
className="glass-card p-4 rounded-full hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer select-none disabled:opacity-50 disabled:cursor-not-allowed"
526+
style={{
527+
color: activeTimer.color,
528+
zIndex: 41,
529+
position: 'relative',
530+
pointerEvents: 'auto',
531+
userSelect: 'none'
532+
}}
533+
>
534+
<FiFlag className="text-xl pointer-events-none" />
535+
</button>
503536
<button
504537
onClick={() => handleStopwatchControl('stop')}
505538
className="glass-card p-4 rounded-full hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer select-none"
@@ -541,6 +574,40 @@ export default function TimerDisplay() {
541574
>
542575
{getTimerDescription()}
543576
</motion.p>
577+
578+
{/* 分段计时按钮 */}
579+
{activeTimer.type === 'stopwatch' && activeTimer.laps && activeTimer.laps.length > 0 && (
580+
<motion.button
581+
className="mt-6 glass-card px-6 py-3 rounded-xl hover:bg-white/10 dark:hover:bg-black/10 transition-colors cursor-pointer"
582+
style={{
583+
color: activeTimer.color,
584+
zIndex: 10,
585+
position: 'relative',
586+
pointerEvents: 'auto'
587+
}}
588+
onClick={() => setIsLapModalOpen(true)}
589+
initial={{ opacity: 0, y: 20 }}
590+
animate={{ opacity: 1, y: 0 }}
591+
transition={{ delay: 0.5 }}
592+
>
593+
<div className="flex items-center space-x-2">
594+
<FiList className="text-xl" />
595+
<span className="font-medium">{t('lap.title')}</span>
596+
<span className="text-sm opacity-70">({activeTimer.laps.length})</span>
597+
</div>
598+
</motion.button>
599+
)}
600+
601+
{/* 分段计时弹窗 */}
602+
<AnimatePresence>
603+
{isLapModalOpen && activeTimer.type === 'stopwatch' && (
604+
<LapTimesModal
605+
onClose={() => setIsLapModalOpen(false)}
606+
laps={activeTimer.laps || []}
607+
timerColor={activeTimer.color}
608+
/>
609+
)}
610+
</AnimatePresence>
544611
</motion.div>
545612
);
546613
}

components/UI/LapTimesModal.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { motion } from 'framer-motion';
2+
import { FiX } from 'react-icons/fi';
3+
import { useTranslation } from '../../hooks/useTranslation';
4+
5+
export default function LapTimesModal({ onClose, laps, timerColor }) {
6+
const { t, currentLang } = useTranslation();
7+
8+
// Format milliseconds to time string
9+
const formatTimeFromMs = (ms) => {
10+
const totalSeconds = Math.floor(ms / 1000);
11+
const hours = Math.floor(totalSeconds / 3600);
12+
const minutes = Math.floor((totalSeconds % 3600) / 60);
13+
const seconds = totalSeconds % 60;
14+
15+
const formatNumber = (num) => num.toString().padStart(2, '0');
16+
17+
if (hours > 0) {
18+
return `${formatNumber(hours)}:${formatNumber(minutes)}:${formatNumber(seconds)}`;
19+
}
20+
return `${formatNumber(minutes)}:${formatNumber(seconds)}`;
21+
};
22+
23+
if (!laps || laps.length === 0) {
24+
return (
25+
<motion.div
26+
initial={{ opacity: 0 }}
27+
animate={{ opacity: 1 }}
28+
exit={{ opacity: 0 }}
29+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 backdrop-blur-sm overflow-y-auto py-4"
30+
onClick={onClose}
31+
>
32+
<motion.div
33+
initial={{ scale: 0.9, opacity: 0 }}
34+
animate={{ scale: 1, opacity: 1 }}
35+
exit={{ scale: 0.9, opacity: 0 }}
36+
className="glass-card w-full max-w-md m-4 p-6 rounded-2xl"
37+
onClick={e => e.stopPropagation()}
38+
>
39+
<div className="flex justify-between items-center mb-6">
40+
<h2 className="text-2xl font-semibold">{t('lap.title')}</h2>
41+
<button
42+
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
43+
onClick={onClose}
44+
>
45+
<FiX className="text-xl" />
46+
</button>
47+
</div>
48+
49+
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
50+
{t('lap.noLaps')}
51+
</div>
52+
53+
<div className="mt-6 flex justify-end">
54+
<button
55+
className="btn-glass-secondary"
56+
onClick={onClose}
57+
>
58+
{t('common.close')}
59+
</button>
60+
</div>
61+
</motion.div>
62+
</motion.div>
63+
);
64+
}
65+
66+
return (
67+
<motion.div
68+
initial={{ opacity: 0 }}
69+
animate={{ opacity: 1 }}
70+
exit={{ opacity: 0 }}
71+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 backdrop-blur-sm overflow-y-auto py-4"
72+
onClick={onClose}
73+
>
74+
<motion.div
75+
initial={{ scale: 0.9, opacity: 0 }}
76+
animate={{ scale: 1, opacity: 1 }}
77+
exit={{ scale: 0.9, opacity: 0 }}
78+
className="glass-card w-full max-w-md m-4 p-6 rounded-2xl max-h-[90vh] overflow-y-auto"
79+
onClick={e => e.stopPropagation()}
80+
>
81+
<div className="flex justify-between items-center mb-6">
82+
<h2 className="text-2xl font-semibold" style={{ color: timerColor }}>
83+
{t('lap.title')}
84+
</h2>
85+
<button
86+
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"
87+
onClick={onClose}
88+
>
89+
<FiX className="text-xl" />
90+
</button>
91+
</div>
92+
93+
<div className="mb-4 text-sm text-gray-500 dark:text-gray-400">
94+
{laps.length} {t('lap.lapTime')}
95+
</div>
96+
97+
<div className="space-y-2">
98+
{laps.slice().reverse().map((lap, index) => {
99+
const lapNumber = laps.length - index;
100+
const prevLap = lapNumber > 1 ? laps[lapNumber - 2] : null;
101+
const intervalMs = prevLap ? lap.elapsedMs - prevLap.elapsedMs : lap.elapsedMs;
102+
103+
return (
104+
<motion.div
105+
key={lap.id}
106+
className="flex justify-between items-center p-3 rounded-lg bg-white/5 dark:bg-black/5"
107+
initial={{ opacity: 0, x: -20 }}
108+
animate={{ opacity: 1, x: 0 }}
109+
transition={{ delay: index * 0.05 }}
110+
>
111+
<span className="font-medium" style={{ color: timerColor }}>
112+
{currentLang === 'en-US' ? `Lap ${lapNumber}` : `第 ${lapNumber} 段`}
113+
</span>
114+
<div className="flex space-x-4 text-sm">
115+
<div className="text-right">
116+
<div className="text-gray-400 text-xs">{t('lap.lapInterval')}</div>
117+
<div className="font-mono">{formatTimeFromMs(intervalMs)}</div>
118+
</div>
119+
<div className="text-right">
120+
<div className="text-gray-400 text-xs">{t('lap.totalTime')}</div>
121+
<div className="font-mono">{formatTimeFromMs(lap.elapsedMs)}</div>
122+
</div>
123+
</div>
124+
</motion.div>
125+
);
126+
})}
127+
</div>
128+
129+
<div className="mt-6 flex justify-end">
130+
<button
131+
className="btn-glass-secondary"
132+
onClick={onClose}
133+
>
134+
{t('common.close')}
135+
</button>
136+
</div>
137+
</motion.div>
138+
</motion.div>
139+
);
140+
}

public/locales/en/common.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,16 @@
225225
"pause": "Pause",
226226
"resume": "Resume",
227227
"stop": "Stop",
228-
"reset": "Reset"
228+
"reset": "Reset",
229+
"lap": "Lap"
230+
},
231+
"lap": {
232+
"title": "Lap Times",
233+
"lapTime": "Lap",
234+
"lapInterval": "Interval",
235+
"totalTime": "Total",
236+
"noLaps": "No lap records",
237+
"clear": "Clear Records"
229238
},
230239
"country": {
231240
"中国": "China",

public/locales/zh/common.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,16 @@
225225
"pause": "暂停",
226226
"resume": "继续",
227227
"stop": "停止",
228-
"reset": "重置"
228+
"reset": "重置",
229+
"lap": "计次"
230+
},
231+
"lap": {
232+
"title": "分段计时",
233+
"lapTime": "分段",
234+
"lapInterval": "间隔",
235+
"totalTime": "累计",
236+
"noLaps": "暂无分段记录",
237+
"clear": "清空记录"
229238
},
230239
"country": {
231240
"中国": "中国",

0 commit comments

Comments
 (0)