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.5 KB · 262 lines · JavaScript Raw History
  1import React, { useEffect } from "react";
  2import { useReducer, createContext } from "react";
  3import PropTypes from "prop-types";
  4import localForage from "localforage";
  5
  6const newId = () =>
  7  typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
  8    ? crypto.randomUUID()
  9    : `id-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
 10
 11const initialState = {
 12  note: "",
 13  language: "en",
 14  timer: new Date(),
 15  timerPausedAt: null,
 16  log: [],
 17  logSelectedEntry: "",
 18  edit: false,
 19};
 20
 21const Context = createContext();
 22
 23// Persist-and-return for every state transition. setItem returns a promise;
 24// unhandled, a quota or private-mode failure would be an invisible unhandled
 25// rejection while the user keeps "saving" into the void.
 26const persist = (newState) => {
 27  localForage.setItem("context", newState).catch((err) => {
 28    console.warn("timelite: persisting state failed", err);
 29  });
 30  return newState;
 31};
 32
 33const reducer = (state, action) => {
 34  let newState = {};
 35
 36  switch (action.type) {
 37    case "LOCALDATA_READY": {
 38      // Stored state can predate new fields (merge over initialState), and
 39      // dates round-trip as strings through some storage drivers; coerce so
 40      // the timer math always sees Dates and tags is always an array.
 41      const loaded = action.localdata || {};
 42      return {
 43        ...initialState,
 44        ...loaded,
 45        timer: loaded.timer ? new Date(loaded.timer) : new Date(),
 46        timerPausedAt: loaded.timerPausedAt
 47          ? new Date(loaded.timerPausedAt)
 48          : null,
 49        log: Array.isArray(loaded.log)
 50          ? loaded.log.map((e) => ({
 51              ...e,
 52              start: new Date(e.start),
 53              end: new Date(e.end),
 54              tags: Array.isArray(e.tags) ? e.tags : [],
 55            }))
 56          : [],
 57      };
 58    }
 59    case "SET_LANGUAGE":
 60      newState = {
 61        ...state,
 62        language: action.language,
 63      };
 64      return persist(newState);
 65    case "NEW_TIMER":
 66      newState = {
 67        ...state,
 68        timer: new Date(),
 69        timerPausedAt: null,
 70      };
 71      return persist(newState);
 72    case "PAUSE_TIMER":
 73      if (state.timerPausedAt) return state;
 74      newState = {
 75        ...state,
 76        timerPausedAt: new Date(),
 77      };
 78      return persist(newState);
 79    case "RESUME_TIMER": {
 80      if (!state.timerPausedAt) return state;
 81      const pausedElapsed =
 82        +new Date(state.timerPausedAt) - +new Date(state.timer);
 83      newState = {
 84        ...state,
 85        timer: new Date(Date.now() - pausedElapsed),
 86        timerPausedAt: null,
 87      };
 88      return persist(newState);
 89    }
 90    case "NOTE_UPDATED":
 91      newState = {
 92        ...state,
 93        note: action.note,
 94      };
 95      return persist(newState);
 96    case "ADD_LOG": {
 97      const end = state.timerPausedAt
 98        ? new Date(state.timerPausedAt)
 99        : new Date();
100      newState = {
101        ...state,
102        timer: new Date(),
103        timerPausedAt: null,
104        log: [
105          {
106            id: newId(),
107            start: state.timer,
108            end,
109            note: state.note,
110            tags: state.note
111              .split(/\s+/)
112              .filter((word) => word.startsWith("#") && word.length > 1)
113              .map((word) => word.toLowerCase()),
114          },
115          ...state.log,
116        ],
117        note: "",
118      };
119      return persist(newState);
120    }
121    case "IMPORT_LOG": {
122      // The seen-set grows as we assign, so duplicate ids *within* the
123      // imported file get fresh ids too, not just collisions with the
124      // existing log.
125      const seen = new Set(state.log.map((e) => e.id));
126      const prepared = action.entries.map((e) => {
127        const id = !e.id || seen.has(e.id) ? newId() : e.id;
128        seen.add(id);
129        return { ...e, id };
130      });
131      const merged = [...state.log, ...prepared].sort(
132        (a, b) => +new Date(b.start) - +new Date(a.start)
133      );
134      return persist({ ...state, log: merged });
135    }
136    case "ADD_MANUAL_LOG": {
137      const { start, end, note } = action;
138      const entry = {
139        id: newId(),
140        start,
141        end,
142        note,
143        tags: note
144          .split(/\s+/)
145          .filter((word) => word.startsWith("#") && word.length > 1)
146          .map((word) => word.toLowerCase()),
147      };
148      const merged = [...state.log, entry].sort(
149        (a, b) => +new Date(b.start) - +new Date(a.start)
150      );
151      newState = { ...state, log: merged };
152      return persist(newState);
153    }
154    case "EDIT_LOG":
155      newState = {
156        ...state,
157        log: [
158          ...state.log.map((entry) => {
159            return entry.id == action.entry.id ? action.entry : entry;
160          }),
161        ],
162      };
163      return persist(newState);
164    case "REMOVE_LOG":
165      if (action.id !== undefined)
166        newState = {
167          ...state,
168          log: [...state.log.filter((entry) => entry.id !== action.id)],
169          logSelectedEntry:
170            state.logSelectedEntry == action.id ? "" : state.logSelectedEntry,
171        };
172      else {
173        newState = {
174          ...state,
175          log: [
176            ...state.log.filter((entry) => entry.id !== state.logSelectedEntry),
177          ],
178          logSelectedEntry: "",
179        };
180      }
181      return persist(newState);
182    case "CLEAR_LOG":
183      newState = {
184        ...state,
185        log: [],
186        logSelectedEntry: "",
187        edit: false,
188      };
189      return persist(newState);
190    case "CLEAR_TAG":
191      newState = {
192        ...state,
193        log: [...state.log.filter((entry) => !entry.tags.includes(action.tag))],
194      };
195      return persist(newState);
196    case "NEXT_LOG_ITEM":
197      if (state.log.length === 0) return state;
198      if (!state.logSelectedEntry) {
199        newState = { ...state, logSelectedEntry: state.log[0].id };
200      } else {
201        const index = state.log.findIndex(
202          (el) => el.id == state.logSelectedEntry
203        );
204        if (index + 1 < state.log.length)
205          newState = { ...state, logSelectedEntry: state.log[index + 1].id };
206        else newState = { ...state };
207      }
208      return persist(newState);
209    case "PREVIOUS_LOG_ITEM":
210      if (state.log.length === 0) return state;
211      if (!state.logSelectedEntry) {
212        newState = { ...state, logSelectedEntry: state.log[0].id };
213      } else {
214        const index = state.log.findIndex(
215          (el) => el.id == state.logSelectedEntry
216        );
217        if (index - 1 >= 0) {
218          newState = { ...state, logSelectedEntry: state.log[index - 1].id };
219        } else newState = { ...state };
220      }
221      return persist(newState);
222    case "SELECT_LOG_ITEM":
223      newState = { ...state, logSelectedEntry: action.id };
224      return persist(newState);
225    case "TOGGLE_EDITION":
226      newState = { ...state, edit: action.edit };
227      return persist(newState);
228    default:
229      return state;
230  }
231};
232
233const ContextProvider = ({ children }) => {
234  const [state, dispatch] = useReducer(reducer, initialState);
235  const value = { state, dispatch };
236
237  useEffect(() => {
238    if (typeof window !== "undefined") {
239      localForage
240        .getItem("context")
241        .then((value) => {
242          if (value !== null)
243            dispatch({ type: "LOCALDATA_READY", localdata: value });
244        })
245        .catch(() => {});
246    }
247  }, []);
248
249  return <Context.Provider value={value}>{children}</Context.Provider>;
250};
251
252ContextProvider.propTypes = {
253  children: PropTypes.oneOfType([
254    PropTypes.element,
255    PropTypes.arrayOf(PropTypes.element),
256  ]),
257};
258
259const ContextConsumer = Context.Consumer;
260
261export { Context, ContextProvider, ContextConsumer };