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

4.3 KB · 146 lines · JavaScript Raw History
  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;