A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useContext, useMemo } from "react";
2import { useForm, Controller } from "react-hook-form";
3import PropTypes from "prop-types";
4
5import { Context } from "./context";
6import TagNoteInput from "./tagNoteInput";
7
8import styles from "../styles/components/entry.module.css";
9
10const pad = (n) => String(n).padStart(2, "0");
11const toDatetimeLocal = (date) =>
12 `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
13
14const defaultStart = (log) => {
15 if (log.length > 0) {
16 const mostRecentEnd = new Date(log[0].end);
17 const now = new Date();
18 return mostRecentEnd < now ? mostRecentEnd : new Date(now.getTime() - 30 * 60 * 1000);
19 }
20 return new Date(Date.now() - 30 * 60 * 1000);
21};
22
23const NewEntryForm = ({ onClose }) => {
24 const { state, dispatch } = useContext(Context);
25 const start = defaultStart(state.log);
26 const end = new Date();
27 const { register, handleSubmit, control } = useForm({
28 defaultValues: {
29 note: "",
30 start: toDatetimeLocal(start),
31 end: toDatetimeLocal(end),
32 },
33 });
34 const allTags = useMemo(() => {
35 const s = new Set();
36 for (const e of state.log) for (const t of e.tags || []) s.add(t);
37 return [...s].sort();
38 }, [state.log]);
39
40 const onSubmit = (data) => {
41 const s = new Date(data.start);
42 const e = new Date(data.end);
43 if (isNaN(s) || isNaN(e) || e < s) {
44 onClose();
45 return;
46 }
47 dispatch({
48 type: "ADD_MANUAL_LOG",
49 start: s,
50 end: e,
51 note: data.note || "",
52 });
53 onClose();
54 };
55
56 return (
57 <div
58 className={`${styles.entryContainer} ${styles.editing} ${styles.selected}`}
59 >
60 <form className={styles.entryForm} onSubmit={handleSubmit(onSubmit)}>
61 <div className={`${styles.entryTime} ${styles.entryTimeEditing}`}>
62 <div className={styles.entryDuration}>New entry</div>
63 <label className={styles.entryTimeRow}>
64 <span className={styles.entryTimeRowLabel}>From</span>
65 <input
66 type="datetime-local"
67 step="1"
68 aria-label="Start time"
69 className={styles.entryTimeInput}
70 {...register("start")}
71 />
72 </label>
73 <label className={styles.entryTimeRow}>
74 <span className={styles.entryTimeRowLabel}>To</span>
75 <input
76 type="datetime-local"
77 step="1"
78 aria-label="End time"
79 className={styles.entryTimeInput}
80 {...register("end")}
81 />
82 </label>
83 </div>
84 <div className={styles.entryNote}>
85 <Controller
86 name="note"
87 control={control}
88 render={({ field }) => (
89 <TagNoteInput
90 className={styles.entryNoteInput}
91 aria-label="Note"
92 placeholder="Note with #tags"
93 autoFocus
94 value={field.value}
95 onChange={field.onChange}
96 allTags={allTags}
97 />
98 )}
99 />
100 </div>
101 <button
102 className={`${styles.entryButton} ${styles.entrySubmit}`}
103 type="submit"
104 aria-label="Save"
105 title="Save (Enter)"
106 >
107 <svg
108 xmlns="http://www.w3.org/2000/svg"
109 width="18"
110 height="18"
111 viewBox="0 0 24 24"
112 fill="currentColor"
113 aria-hidden="true"
114 >
115 <path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z" />
116 </svg>
117 </button>
118 <button
119 className={`${styles.entryButton} ${styles.entryRemove}`}
120 type="button"
121 aria-label="Cancel"
122 title="Cancel"
123 onClick={onClose}
124 >
125 <svg
126 xmlns="http://www.w3.org/2000/svg"
127 width="18"
128 height="18"
129 viewBox="0 0 24 24"
130 fill="currentColor"
131 aria-hidden="true"
132 >
133 <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" />
134 </svg>
135 </button>
136 </form>
137 </div>
138 );
139};
140
141NewEntryForm.propTypes = {
142 onClose: PropTypes.func.isRequired,
143};
144
145export default NewEntryForm;