A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
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 };