A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useContext, useRef, useEffect, useCallback, useMemo } from "react";
2import { useForm, Controller } from "react-hook-form";
3import PropTypes from "prop-types";
4
5import { timeString } from "../utils/time";
6import { Context } from "../components/context";
7import TagNoteInput from "./tagNoteInput";
8
9import styles from "../styles/components/entry.module.css";
10
11const extractTags = (text) =>
12 text
13 .split(/\s+/)
14 .filter((word) => word.startsWith("#") && word.length > 1)
15 .map((word) => word.toLowerCase());
16
17const toDatetimeLocal = (date) => {
18 const d = date instanceof Date ? date : new Date(date);
19 const pad = (n) => String(n).padStart(2, "0");
20 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
21};
22
23const Entry = React.forwardRef(
24 ({ entry, removeEntry, isSelected, style }, forwardedRef) => {
25 const { state, dispatch } = useContext(Context);
26 const { register, handleSubmit, reset, control } = useForm({
27 defaultValues: {
28 note: entry.note,
29 start: toDatetimeLocal(entry.start),
30 end: toDatetimeLocal(entry.end),
31 },
32 });
33 const allTags = useMemo(() => {
34 const s = new Set();
35 for (const e of state.log) for (const t of e.tags || []) s.add(t);
36 return [...s].sort();
37 }, [state.log]);
38 const focusedEntry = useRef(null);
39 const isEditing = state.edit && isSelected == entry.id;
40
41 useEffect(() => {
42 if (isEditing) {
43 reset({
44 note: entry.note,
45 start: toDatetimeLocal(entry.start),
46 end: toDatetimeLocal(entry.end),
47 });
48 }
49 }, [isEditing, entry.id, entry.note, entry.start, entry.end, reset]);
50
51 const setRefs = useCallback(
52 (node) => {
53 focusedEntry.current = node;
54 if (typeof forwardedRef === "function") {
55 forwardedRef(node);
56 } else if (forwardedRef && "current" in forwardedRef) {
57 forwardedRef.current = node;
58 }
59 },
60 [forwardedRef]
61 );
62
63 const onSubmit = (data) => {
64 const start = new Date(data.start);
65 const end = new Date(data.end);
66 if (isNaN(start) || isNaN(end) || end < start) {
67 dispatch({ type: "TOGGLE_EDITION", edit: false });
68 return;
69 }
70 dispatch({
71 type: "EDIT_LOG",
72 entry: {
73 ...entry,
74 start,
75 end,
76 note: data.note,
77 tags: extractTags(data.note),
78 },
79 });
80 dispatch({ type: "TOGGLE_EDITION", edit: false });
81 };
82
83 useEffect(() => {
84 if (isSelected == entry.id && focusedEntry.current) {
85 focusedEntry.current.focus();
86 focusedEntry.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
87 }
88 }, [isSelected, entry.id]);
89
90 const containerClasses = [styles.entryContainer];
91 if (isSelected == entry.id) containerClasses.push(styles.selected);
92 if (isEditing) {
93 containerClasses.push(styles.zoom);
94 containerClasses.push(styles.editing);
95 }
96
97 return (
98 <div
99 style={style}
100 className={containerClasses.join(" ")}
101 ref={setRefs}
102 tabIndex={-1}
103 >
104 {isEditing ? (
105 <form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}>
106 <div className={`${styles.entryTime} ${styles.entryTimeEditing}`}>
107 <div className={styles.entryDuration}>
108 {timeString(entry.end - entry.start)}
109 </div>
110 <label className={styles.entryTimeRow}>
111 <span className={styles.entryTimeRowLabel}>From</span>
112 <input
113 type="datetime-local"
114 step="1"
115 aria-label="Start time"
116 className={styles.entryTimeInput}
117 {...register("start")}
118 />
119 </label>
120 <label className={styles.entryTimeRow}>
121 <span className={styles.entryTimeRowLabel}>To</span>
122 <input
123 type="datetime-local"
124 step="1"
125 aria-label="End time"
126 className={styles.entryTimeInput}
127 {...register("end")}
128 />
129 </label>
130 </div>
131 <div className={styles.entryNote}>
132 <Controller
133 name="note"
134 control={control}
135 render={({ field }) => (
136 <TagNoteInput
137 className={styles.entryNoteInput}
138 aria-label="Note"
139 placeholder="Note with #tags"
140 autoFocus
141 value={field.value}
142 onChange={field.onChange}
143 allTags={allTags}
144 />
145 )}
146 />
147 </div>
148 <button
149 className={`${styles.entryButton} ${styles.entrySubmit}`}
150 type="submit"
151 aria-label="Save"
152 title="Save (Enter)"
153 >
154 <IconCheck />
155 </button>
156 <button
157 className={`${styles.entryButton} ${styles.entryRemove}`}
158 type="button"
159 aria-label="Cancel"
160 title="Cancel"
161 onClick={() => dispatch({ type: "TOGGLE_EDITION", edit: false })}
162 >
163 <IconX />
164 </button>
165 </form>
166 ) : (
167 <>
168 <div className={styles.entryTime}>
169 {timeString(entry.end - entry.start)}
170 <span>{entry.start.toLocaleTimeString()}</span>
171 </div>
172 <div
173 className={`${styles.entryNote} ${entry.note.length === 0 ? styles.entryNoteEmpty : ""}`.trim()}
174 >
175 {entry.note}
176 {entry.tags.length > 0 && (
177 <small>{entry.tags.join(", ")}</small>
178 )}
179 </div>
180 <button
181 className={`${styles.entryButton} ${styles.entryEdit}`}
182 aria-label="Edit"
183 title="Edit (⎇+E)"
184 onClick={() => {
185 dispatch({ type: "SELECT_LOG_ITEM", id: entry.id });
186 dispatch({ type: "TOGGLE_EDITION", edit: true });
187 }}
188 >
189 <IconPencil />
190 </button>
191 <button
192 className={`${styles.entryButton} ${styles.entryRemove}`}
193 aria-label="Delete"
194 title="Delete (⎇+D)"
195 onClick={() => {
196 dispatch({ type: "SELECT_LOG_ITEM", id: "" });
197 removeEntry(entry.id);
198 }}
199 >
200 <IconTrash />
201 </button>
202 </>
203 )}
204 </div>
205 );
206 }
207);
208
209const IconPencil = () => (
210 <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
211 <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
212 </svg>
213);
214
215const IconTrash = () => (
216 <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
217 <path d="M6 19a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
218 </svg>
219);
220
221const IconCheck = () => (
222 <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
223 <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z"/>
224 </svg>
225);
226
227const IconX = () => (
228 <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
229 <path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
230 </svg>
231);
232
233Entry.propTypes = {
234 entry: PropTypes.object,
235 removeEntry: PropTypes.func,
236 isSelected: PropTypes.string,
237 style: PropTypes.object,
238};
239
240Entry.displayName = "Entry";
241
242export default Entry;