A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useState, useRef, useMemo, useCallback, useEffect } from "react";
2import PropTypes from "prop-types";
3
4import styles from "../styles/components/tagNoteInput.module.css";
5
6const tokenAtCursor = (text, cursorPos) => {
7 let start = cursorPos;
8 while (start > 0 && !/\s/.test(text[start - 1])) start--;
9 let end = cursorPos;
10 while (end < text.length && !/\s/.test(text[end])) end++;
11 return { start, end, token: text.slice(start, end) };
12};
13
14const TagNoteInput = React.forwardRef(function TagNoteInput(
15 {
16 value,
17 onChange,
18 allTags,
19 className,
20 wrapperClassName,
21 placeholder,
22 autoFocus,
23 "aria-label": ariaLabel,
24 },
25 forwardedRef
26) {
27 const inputRef = useRef(null);
28 const [cursorPos, setCursorPos] = useState(0);
29 const [isFocused, setIsFocused] = useState(false);
30 const [highlightIndex, setHighlightIndex] = useState(0);
31 const [pendingCursor, setPendingCursor] = useState(null);
32
33 const setRefs = useCallback(
34 (node) => {
35 inputRef.current = node;
36 if (typeof forwardedRef === "function") forwardedRef(node);
37 else if (forwardedRef && "current" in forwardedRef)
38 forwardedRef.current = node;
39 },
40 [forwardedRef]
41 );
42
43 useEffect(() => {
44 if (pendingCursor != null && inputRef.current) {
45 inputRef.current.setSelectionRange(pendingCursor, pendingCursor);
46 setPendingCursor(null);
47 }
48 }, [pendingCursor]);
49
50 const { start, end, token } = useMemo(
51 () => tokenAtCursor(value || "", cursorPos),
52 [value, cursorPos]
53 );
54
55 const suggestions = useMemo(() => {
56 if (!isFocused) return [];
57 if (!token.startsWith("#") || token.length < 2) return [];
58 const needle = token.toLowerCase();
59 return allTags
60 .filter((t) => t !== needle && t.startsWith(needle))
61 .slice(0, 8);
62 }, [isFocused, token, allTags]);
63
64 useEffect(() => {
65 if (highlightIndex >= suggestions.length) setHighlightIndex(0);
66 }, [suggestions.length, highlightIndex]);
67
68 const applySuggestion = (tag) => {
69 const before = (value || "").slice(0, start);
70 const after = (value || "").slice(end);
71 const needsSpace = after.length === 0 || !/^\s/.test(after);
72 const replacement = tag + (needsSpace ? " " : "");
73 const next = before + replacement + after;
74 const newCursor = before.length + replacement.length;
75 onChange(next);
76 setPendingCursor(newCursor);
77 };
78
79 const handleKeyDown = (e) => {
80 if (suggestions.length === 0) return;
81 if (e.key === "ArrowDown") {
82 e.preventDefault();
83 setHighlightIndex((i) => (i + 1) % suggestions.length);
84 } else if (e.key === "ArrowUp") {
85 e.preventDefault();
86 setHighlightIndex(
87 (i) => (i - 1 + suggestions.length) % suggestions.length
88 );
89 } else if (e.key === "Tab" || (e.key === "Enter" && suggestions.length)) {
90 if (e.key === "Enter" && !isFocused) return;
91 e.preventDefault();
92 applySuggestion(suggestions[highlightIndex]);
93 } else if (e.key === "Escape") {
94 setIsFocused(false);
95 e.stopPropagation();
96 }
97 };
98
99 const handleChange = (e) => {
100 onChange(e.target.value);
101 setCursorPos(e.target.selectionStart ?? e.target.value.length);
102 setHighlightIndex(0);
103 };
104
105 const handleSelect = (e) => {
106 setCursorPos(e.target.selectionStart ?? 0);
107 };
108
109 return (
110 <div className={`${styles.wrap} ${wrapperClassName || ""}`.trim()}>
111 <input
112 type="text"
113 ref={setRefs}
114 className={className}
115 value={value || ""}
116 onChange={handleChange}
117 onKeyDown={handleKeyDown}
118 onSelect={handleSelect}
119 onFocus={() => setIsFocused(true)}
120 onBlur={() => setTimeout(() => setIsFocused(false), 120)}
121 placeholder={placeholder}
122 aria-label={ariaLabel}
123 autoFocus={autoFocus}
124 autoComplete="off"
125 />
126 {suggestions.length > 0 && (
127 <ul
128 className={styles.list}
129 role="listbox"
130 onMouseDown={(e) => e.preventDefault()}
131 >
132 {suggestions.map((tag, i) => (
133 <li
134 key={tag}
135 role="option"
136 aria-selected={i === highlightIndex}
137 className={`${styles.item} ${i === highlightIndex ? styles.itemActive : ""}`.trim()}
138 onMouseEnter={() => setHighlightIndex(i)}
139 onClick={() => applySuggestion(tag)}
140 >
141 {tag}
142 </li>
143 ))}
144 </ul>
145 )}
146 </div>
147 );
148});
149
150TagNoteInput.propTypes = {
151 value: PropTypes.string,
152 onChange: PropTypes.func.isRequired,
153 allTags: PropTypes.arrayOf(PropTypes.string).isRequired,
154 className: PropTypes.string,
155 wrapperClassName: PropTypes.string,
156 placeholder: PropTypes.string,
157 autoFocus: PropTypes.bool,
158 "aria-label": PropTypes.string,
159};
160
161export default TagNoteInput;