A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useContext, useState } from "react";
2import { useRouter } from "next/router";
3import { GlobalHotKeys, configure } from "react-hotkeys";
4import PropTypes from "prop-types";
5import { toast } from "react-toastify";
6
7import { Context } from "../components/context";
8import KeyHelpOverlay from "./keyHelpOverlay";
9import contextStrings from "../l10n/context";
10
11configure({
12 ignoreTags: [],
13 ignoreEventsCondition: (keyEvent) => {
14 if (keyEvent.key === "Enter" || keyEvent.key === " ") {
15 return true;
16 }
17 const t = keyEvent.target;
18 const isTypingField =
19 t &&
20 (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable);
21 // Arrow keys move the caret / change values inside a field; stealing
22 // them for log selection while typing is hostile.
23 if (
24 isTypingField &&
25 (keyEvent.key === "?" ||
26 keyEvent.key === "ArrowUp" ||
27 keyEvent.key === "ArrowDown")
28 ) {
29 return true;
30 }
31 return false;
32 },
33});
34
35const keyMap = {
36 RESET: "alt+r",
37 ADD_LOG: "alt+a",
38 PAUSE_TOGGLE: "alt+p",
39 TIMER_PAGE: "alt+t",
40 LOG_PAGE: "alt+l",
41 ABOUT_PAGE: "alt+o",
42 SUMMARY_PAGE: "alt+s",
43 CLEAR_LOG: "alt+c",
44 LOG_NEXT: "ArrowDown",
45 LOG_PREVIOUS: "ArrowUp",
46 LOG_EDIT: "alt+e",
47 LOG_DELETE_SINGLE: "alt+d",
48 KEY_HELP: ["shift+/", "?"],
49};
50
51const HotKeysMapping = (props) => {
52 const { state, dispatch } = useContext(Context);
53 const [showHelp, setShowHelp] = useState(false);
54
55 const router = useRouter();
56
57 const onLogPage = () => router.pathname === "/log";
58
59 const handlers = {
60 RESET: () => dispatch({ type: "NEW_TIMER" }),
61 ADD_LOG: (event) => {
62 event.preventDefault();
63 if (state.note.trim()) {
64 dispatch({ type: "ADD_LOG", note: state.note });
65 contextStrings.setLanguage(state.language);
66 toast.success(contextStrings.addedEntry);
67 }
68 },
69 PAUSE_TOGGLE: (event) => {
70 event.preventDefault();
71 dispatch({
72 type: state.timerPausedAt ? "RESUME_TIMER" : "PAUSE_TIMER",
73 });
74 },
75 TIMER_PAGE: (event) => {
76 event.preventDefault();
77 router.push("/");
78 },
79 LOG_PAGE: () => router.push("/log"),
80 ABOUT_PAGE: () => router.push("/about"),
81 SUMMARY_PAGE: () => router.push("/summary"),
82 CLEAR_LOG: () => {
83 if (state.log.length === 0) return;
84 contextStrings.setLanguage(state.language);
85 if (!window.confirm(contextStrings.confirmClearLog)) return;
86 dispatch({ type: "CLEAR_LOG" });
87 toast.error(contextStrings.resetLog);
88 },
89 LOG_NEXT: (event) => {
90 event.preventDefault();
91 if (onLogPage()) {
92 dispatch({ type: "TOGGLE_EDITION", edit: false });
93 dispatch({ type: "NEXT_LOG_ITEM" });
94 }
95 },
96 LOG_PREVIOUS: (event) => {
97 event.preventDefault();
98 if (onLogPage()) {
99 dispatch({ type: "TOGGLE_EDITION", edit: false });
100 dispatch({ type: "PREVIOUS_LOG_ITEM" });
101 }
102 },
103 LOG_EDIT: (event) => {
104 event.preventDefault();
105 if (!state.logSelectedEntry) return;
106 if (onLogPage()) dispatch({ type: "TOGGLE_EDITION", edit: true });
107 },
108 LOG_DELETE_SINGLE: (event) => {
109 event.preventDefault();
110 if (!state.logSelectedEntry) return;
111 if (!onLogPage()) return;
112 contextStrings.setLanguage(state.language);
113 if (!window.confirm(contextStrings.confirmDeleteEntry)) return;
114 dispatch({ type: "REMOVE_LOG" });
115 toast.error(contextStrings.deletedEntry);
116 },
117 KEY_HELP: (event) => {
118 event.preventDefault();
119 setShowHelp((v) => !v);
120 },
121 };
122
123 return (
124 <GlobalHotKeys keyMap={keyMap} handlers={handlers}>
125 {props.children}
126 <KeyHelpOverlay open={showHelp} onClose={() => setShowHelp(false)} />
127 </GlobalHotKeys>
128 );
129};
130
131HotKeysMapping.propTypes = {
132 children: PropTypes.oneOfType([
133 PropTypes.element,
134 PropTypes.arrayOf(PropTypes.element),
135 ]),
136};
137
138export default HotKeysMapping;