repos
/ timelite-nextjs master

timelite-nextjs

mirror archived upstream

A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.

handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer

7.9 KB · 243 lines · JavaScript Raw History
  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;