A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useState, useEffect, useContext, useRef, useMemo } from "react";
2import { toast } from "react-toastify";
3
4import { Context } from "./context";
5import TagNoteInput from "./tagNoteInput";
6import strings from "../l10n/timer";
7import contextStrings from "../l10n/context";
8import { timeString, timeDiff } from "../utils/time";
9
10import styles from "../styles/components/timer.module.css";
11
12const Timer = () => {
13 const { state, dispatch } = useContext(Context);
14 const isPaused = !!state.timerPausedAt;
15 const initialElapsed = isPaused
16 ? +new Date(state.timerPausedAt) - +new Date(state.timer)
17 : timeDiff(state.timer);
18 const [time, setTime] = useState(timeString(initialElapsed));
19 const refToMain = useRef(null);
20
21 strings.setLanguage(state.language);
22
23 useEffect(() => {
24 if (isPaused) {
25 const frozen = timeString(
26 +new Date(state.timerPausedAt) - +new Date(state.timer)
27 );
28 setTime(frozen);
29 document.title = `${frozen} — Timelite`;
30 return;
31 }
32 setTime(timeString(timeDiff(state.timer)));
33 const timerInterval = setInterval(() => {
34 const timeCurrent = timeString(timeDiff(state.timer));
35 setTime(timeCurrent);
36 document.title = `${timeCurrent} — Timelite`;
37 }, 1000);
38 return () => {
39 clearInterval(timerInterval);
40 };
41 }, [state.timer, state.timerPausedAt, isPaused]);
42
43 useEffect(() => {
44 if (refToMain.current) {
45 refToMain.current.focus();
46 }
47 }, []);
48
49 const allTags = useMemo(() => {
50 const s = new Set();
51 for (const e of state.log) for (const t of e.tags || []) s.add(t);
52 return [...s].sort();
53 }, [state.log]);
54
55 const submitForm = (e) => {
56 e.preventDefault();
57 if (state.note.trim()) {
58 dispatch({ type: "ADD_LOG", note: state.note });
59 dispatch({ type: "NOTE_UPDATED", note: "" });
60 contextStrings.setLanguage(state.language);
61 toast.success(contextStrings.addedEntry);
62 }
63 };
64
65 return (
66 <>
67 <div
68 className={`${styles.time} ${isPaused ? styles.timePaused : ""}`.trim()}
69 suppressHydrationWarning
70 >
71 {time}
72 {isPaused && <span className={styles.pausedBadge}>{strings.paused}</span>}
73 </div>
74 <form onSubmit={submitForm}>
75 <div className={styles.inputs}>
76 <TagNoteInput
77 ref={refToMain}
78 className={styles.note}
79 wrapperClassName={styles.noteWrap}
80 aria-label={strings.note}
81 placeholder={strings.note}
82 value={state.note || ""}
83 allTags={allTags}
84 onChange={(val) => dispatch({ type: "NOTE_UPDATED", note: val })}
85 />
86 </div>
87 <div className={styles.buttons}>
88 <button
89 className={`${styles.button} ${styles.resetButton} timer__button`}
90 type="reset"
91 onClick={() => dispatch({ type: "NEW_TIMER" })}
92 title="⎇+R"
93 >
94 - {strings.reset}
95 </button>
96 <button
97 className={`${styles.button} ${styles.pauseButton} timer__button`}
98 type="button"
99 onClick={() =>
100 dispatch({ type: isPaused ? "RESUME_TIMER" : "PAUSE_TIMER" })
101 }
102 title="⎇+P"
103 >
104 {isPaused ? strings.resume : strings.pause}
105 </button>
106 <button
107 className={`${styles.button} ${styles.addButton} timer__button`}
108 type="submit"
109 title="⎇+A"
110 >
111 {strings.add} +
112 </button>
113 </div>
114 </form>
115 </>
116 );
117};
118
119export default Timer;