mirror of
https://github.com/arthur-pbty/chrono.git
synced 2026-06-16 23:57:39 +02:00
first commit
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
|
||||
const STORAGE_KEY = "chrono-stopwatch";
|
||||
|
||||
interface SavedState {
|
||||
elapsed: number;
|
||||
isRunning: boolean;
|
||||
laps: number[];
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export function useStopwatch() {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [laps, setLaps] = useState<number[]>([]);
|
||||
|
||||
const originRef = useRef(0);
|
||||
const rafRef = useRef(0);
|
||||
const isRunningRef = useRef(false);
|
||||
const lapsRef = useRef<number[]>([]);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
const saveNow = useCallback(
|
||||
(e: number, running: boolean, l: number[]) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
elapsed: e,
|
||||
isRunning: running,
|
||||
laps: l,
|
||||
savedAt: Date.now(),
|
||||
} satisfies SavedState)
|
||||
);
|
||||
} catch {
|
||||
// Storage not available
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const tick = useCallback(() => {
|
||||
if (!isRunningRef.current) return;
|
||||
setElapsed(Date.now() - originRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, []);
|
||||
|
||||
// Initialize from localStorage
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const saved: SavedState = JSON.parse(raw);
|
||||
|
||||
let restoredElapsed = saved.elapsed || 0;
|
||||
if (saved.isRunning && saved.savedAt) {
|
||||
restoredElapsed += Date.now() - saved.savedAt;
|
||||
}
|
||||
|
||||
setElapsed(restoredElapsed);
|
||||
setLaps(saved.laps || []);
|
||||
lapsRef.current = saved.laps || [];
|
||||
|
||||
if (saved.isRunning) {
|
||||
originRef.current = Date.now() - restoredElapsed;
|
||||
isRunningRef.current = true;
|
||||
setIsRunning(true);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
} catch {
|
||||
// Parse error
|
||||
}
|
||||
}, [tick]);
|
||||
|
||||
// Background tab handling
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (!isRunningRef.current) return;
|
||||
if (!document.hidden) {
|
||||
setElapsed(Date.now() - originRef.current);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}, [tick]);
|
||||
|
||||
// Periodic save when running
|
||||
useEffect(() => {
|
||||
if (!isRunning) return;
|
||||
const interval = setInterval(() => {
|
||||
saveNow(Date.now() - originRef.current, true, lapsRef.current);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, saveNow]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
const currentElapsed =
|
||||
isRunningRef.current ? Date.now() - originRef.current : elapsed;
|
||||
originRef.current = Date.now() - currentElapsed;
|
||||
isRunningRef.current = true;
|
||||
setIsRunning(true);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
saveNow(currentElapsed, true, lapsRef.current);
|
||||
}, [elapsed, tick, saveNow]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
isRunningRef.current = false;
|
||||
setIsRunning(false);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
const currentElapsed = Date.now() - originRef.current;
|
||||
setElapsed(currentElapsed);
|
||||
saveNow(currentElapsed, false, lapsRef.current);
|
||||
}, [saveNow]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
isRunningRef.current = false;
|
||||
setIsRunning(false);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
setElapsed(0);
|
||||
setLaps([]);
|
||||
lapsRef.current = [];
|
||||
originRef.current = 0;
|
||||
saveNow(0, false, []);
|
||||
}, [saveNow]);
|
||||
|
||||
const lap = useCallback(() => {
|
||||
if (!isRunningRef.current) return;
|
||||
const currentElapsed = Date.now() - originRef.current;
|
||||
const newLaps = [...lapsRef.current, currentElapsed];
|
||||
lapsRef.current = newLaps;
|
||||
setLaps(newLaps);
|
||||
saveNow(currentElapsed, true, newLaps);
|
||||
}, [saveNow]);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (isRunningRef.current) {
|
||||
pause();
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
}, [start, pause]);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { elapsed, isRunning, laps, start, pause, reset, lap, toggle };
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { playAlarm, sendNotification, requestNotificationPermission } from "../lib/utils";
|
||||
|
||||
const STORAGE_KEY = "chrono-timers";
|
||||
|
||||
export interface TimerItem {
|
||||
id: string;
|
||||
label: string;
|
||||
duration: number;
|
||||
remaining: number;
|
||||
isRunning: boolean;
|
||||
endTime: number;
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
interface SavedTimers {
|
||||
timers: TimerItem[];
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export function useTimers() {
|
||||
const [timers, setTimers] = useState<TimerItem[]>([]);
|
||||
const rafRef = useRef(0);
|
||||
const notifiedRef = useRef(new Set<string>());
|
||||
const timersRef = useRef<TimerItem[]>([]);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
timersRef.current = timers;
|
||||
}, [timers]);
|
||||
|
||||
const saveTimers = useCallback((t: TimerItem[]) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ timers: t, savedAt: Date.now() } satisfies SavedTimers)
|
||||
);
|
||||
} catch {
|
||||
// Storage unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Animation loop
|
||||
const tick = useCallback(() => {
|
||||
const now = Date.now();
|
||||
setTimers((prev) => {
|
||||
let changed = false;
|
||||
const updated = prev.map((t) => {
|
||||
if (!t.isRunning || t.finished) return t;
|
||||
const remaining = t.endTime - now;
|
||||
if (remaining <= 0) {
|
||||
changed = true;
|
||||
return { ...t, remaining: 0, isRunning: false, finished: true };
|
||||
}
|
||||
changed = true;
|
||||
return { ...t, remaining };
|
||||
});
|
||||
return changed ? updated : prev;
|
||||
});
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}, []);
|
||||
|
||||
// Detect finished timers - trigger alarm and notification
|
||||
useEffect(() => {
|
||||
timers.forEach((t) => {
|
||||
if (t.finished && !notifiedRef.current.has(t.id)) {
|
||||
notifiedRef.current.add(t.id);
|
||||
playAlarm();
|
||||
sendNotification(t.label || "Minuteur");
|
||||
}
|
||||
});
|
||||
}, [timers]);
|
||||
|
||||
// Start/stop animation loop
|
||||
useEffect(() => {
|
||||
const hasRunning = timers.some((t) => t.isRunning && !t.finished);
|
||||
if (hasRunning) {
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
} else {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
}
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [timers.some((t) => t.isRunning), tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Background tab handling
|
||||
useEffect(() => {
|
||||
const handleVisibility = () => {
|
||||
if (!document.hidden) {
|
||||
const hasRunning = timersRef.current.some(
|
||||
(t) => t.isRunning && !t.finished
|
||||
);
|
||||
if (hasRunning) {
|
||||
// Force update
|
||||
const now = Date.now();
|
||||
setTimers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (!t.isRunning || t.finished) return t;
|
||||
const remaining = t.endTime - now;
|
||||
if (remaining <= 0) {
|
||||
return { ...t, remaining: 0, isRunning: false, finished: true };
|
||||
}
|
||||
return { ...t, remaining };
|
||||
})
|
||||
);
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
return () =>
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}, [tick]);
|
||||
|
||||
// Load from localStorage
|
||||
useEffect(() => {
|
||||
if (initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
requestNotificationPermission();
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const saved: SavedTimers = JSON.parse(raw);
|
||||
const elapsed = Date.now() - (saved.savedAt || Date.now());
|
||||
|
||||
const restored = saved.timers.map((t) => {
|
||||
if (t.isRunning && !t.finished) {
|
||||
const newRemaining = t.remaining - elapsed;
|
||||
if (newRemaining <= 0) {
|
||||
return { ...t, remaining: 0, isRunning: false, finished: true };
|
||||
}
|
||||
return {
|
||||
...t,
|
||||
remaining: newRemaining,
|
||||
endTime: Date.now() + newRemaining,
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
setTimers(restored);
|
||||
} catch {
|
||||
// Parse error
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Periodic save
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
saveTimers(timersRef.current);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, [saveTimers]);
|
||||
|
||||
const addTimer = useCallback(
|
||||
(duration: number, label: string = "") => {
|
||||
const timer: TimerItem = {
|
||||
id: crypto.randomUUID(),
|
||||
label,
|
||||
duration,
|
||||
remaining: duration,
|
||||
isRunning: false,
|
||||
endTime: 0,
|
||||
finished: false,
|
||||
};
|
||||
setTimers((prev) => {
|
||||
const next = [...prev, timer];
|
||||
saveTimers(next);
|
||||
return next;
|
||||
});
|
||||
return timer.id;
|
||||
},
|
||||
[saveTimers]
|
||||
);
|
||||
|
||||
const startTimer = useCallback(
|
||||
(id: string) => {
|
||||
setTimers((prev) => {
|
||||
const next = prev.map((t) => {
|
||||
if (t.id !== id || t.finished) return t;
|
||||
return {
|
||||
...t,
|
||||
isRunning: true,
|
||||
endTime: Date.now() + t.remaining,
|
||||
};
|
||||
});
|
||||
saveTimers(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[saveTimers]
|
||||
);
|
||||
|
||||
const pauseTimer = useCallback(
|
||||
(id: string) => {
|
||||
setTimers((prev) => {
|
||||
const next = prev.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
return {
|
||||
...t,
|
||||
isRunning: false,
|
||||
remaining: Math.max(0, t.endTime - Date.now()),
|
||||
};
|
||||
});
|
||||
saveTimers(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[saveTimers]
|
||||
);
|
||||
|
||||
const resetTimer = useCallback(
|
||||
(id: string) => {
|
||||
notifiedRef.current.delete(id);
|
||||
setTimers((prev) => {
|
||||
const next = prev.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
return {
|
||||
...t,
|
||||
remaining: t.duration,
|
||||
isRunning: false,
|
||||
endTime: 0,
|
||||
finished: false,
|
||||
};
|
||||
});
|
||||
saveTimers(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[saveTimers]
|
||||
);
|
||||
|
||||
const deleteTimer = useCallback(
|
||||
(id: string) => {
|
||||
notifiedRef.current.delete(id);
|
||||
setTimers((prev) => {
|
||||
const next = prev.filter((t) => t.id !== id);
|
||||
saveTimers(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[saveTimers]
|
||||
);
|
||||
|
||||
const toggleTimer = useCallback(
|
||||
(id: string) => {
|
||||
const timer = timersRef.current.find((t) => t.id === id);
|
||||
if (!timer || timer.finished) return;
|
||||
if (timer.isRunning) {
|
||||
pauseTimer(id);
|
||||
} else {
|
||||
startTimer(id);
|
||||
}
|
||||
},
|
||||
[startTimer, pauseTimer]
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
timers,
|
||||
addTimer,
|
||||
startTimer,
|
||||
pauseTimer,
|
||||
resetTimer,
|
||||
deleteTimer,
|
||||
toggleTimer,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user