A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1import React, { useEffect, useRef, useContext, useMemo } from "react";
2import Chart from "chart.js/auto";
3
4import Page from "../components/page";
5import { Context } from "../components/context";
6import strings from "../l10n/summary";
7
8import styles from "../styles/pages/summary.module.css";
9
10const PALETTE = [
11 "#6B9E78",
12 "#C9A84C",
13 "#C47055",
14 "#7EAAB8",
15 "#7DB88C",
16 "#DDC06A",
17];
18
19const MS = {
20 minute: 60 * 1000,
21 hour: 60 * 60 * 1000,
22 day: 24 * 60 * 60 * 1000,
23};
24
25const formatDuration = (ms, s) => {
26 if (!ms) return "0" + s.hoursSuffix;
27 const hours = ms / MS.hour;
28 if (hours >= 1) return `${hours.toFixed(hours >= 10 ? 0 : 1)}${s.hoursSuffix}`;
29 const mins = Math.round(ms / MS.minute);
30 return `${mins}${s.minsSuffix}`;
31};
32
33const formatHours = (ms) => Math.round((ms / MS.hour) * 100) / 100;
34
35const startOfDay = (d) => {
36 const x = new Date(d);
37 x.setHours(0, 0, 0, 0);
38 return x;
39};
40
41const Summary = () => {
42 const { state } = useContext(Context);
43 strings.setLanguage(state.language);
44
45 const tagCanvasRef = useRef(null);
46 const dayCanvasRef = useRef(null);
47
48 const log = state.log;
49 const hasData = log.length > 0;
50
51 // Derived stats ----------------------------------------------------------
52 const stats = useMemo(() => {
53 if (!hasData) return null;
54 const now = Date.now();
55 const today = startOfDay(now).getTime();
56 const weekAgo = today - 6 * MS.day;
57
58 let totalMs = 0;
59 let todayMs = 0;
60 let weekMs = 0;
61 let longestMs = 0;
62 const tagSet = new Set();
63
64 for (const e of log) {
65 const start = +new Date(e.start);
66 const end = +new Date(e.end);
67 const dur = end - start;
68 totalMs += dur;
69 if (dur > longestMs) longestMs = dur;
70 if (start >= today) todayMs += dur;
71 if (start >= weekAgo) weekMs += dur;
72 for (const t of e.tags || []) tagSet.add(t);
73 }
74
75 return {
76 totalMs,
77 entryCount: log.length,
78 todayMs,
79 weekMs,
80 longestMs,
81 tagCount: tagSet.size,
82 };
83 }, [log, hasData]);
84
85 // Hours per tag
86 const perTag = useMemo(() => {
87 if (!hasData) return { labels: [], data: [] };
88 const map = new Map();
89 for (const e of log) {
90 const dur = +new Date(e.end) - +new Date(e.start);
91 for (const t of e.tags || []) {
92 map.set(t, (map.get(t) || 0) + dur);
93 }
94 }
95 const entries = [...map.entries()].sort((a, b) => b[1] - a[1]);
96 return {
97 labels: entries.map(([t]) => t),
98 data: entries.map(([, ms]) => formatHours(ms)),
99 };
100 }, [log, hasData]);
101
102 // Hours per day over last 14 days
103 const perDay = useMemo(() => {
104 if (!hasData) return { labels: [], data: [] };
105 const days = 14;
106 const todayStart = startOfDay(Date.now()).getTime();
107 const buckets = new Array(days).fill(0);
108 // DST-safe day stepping: setDate for the labels, round (not floor) for
109 // the bucket index so the one-hour DST drift cannot shift an entry into
110 // the neighboring day.
111 const labels = new Array(days).fill(0).map((_, i) => {
112 const d = new Date(todayStart);
113 d.setDate(d.getDate() - (days - 1 - i));
114 return `${d.getMonth() + 1}/${d.getDate()}`;
115 });
116 for (const e of log) {
117 const start = +new Date(e.start);
118 const daysAgo = Math.round((todayStart - startOfDay(start).getTime()) / MS.day);
119 if (daysAgo < 0 || daysAgo >= days) continue;
120 const dur = +new Date(e.end) - start;
121 buckets[days - 1 - daysAgo] += dur;
122 }
123 return { labels, data: buckets.map((ms) => formatHours(ms)) };
124 }, [log, hasData]);
125
126 // Chart: hours per tag ---------------------------------------------------
127 useEffect(() => {
128 if (!hasData || !tagCanvasRef.current) return;
129 const chart = new Chart(tagCanvasRef.current, {
130 type: "bar",
131 data: {
132 labels: perTag.labels,
133 datasets: [
134 {
135 label: strings.numHours,
136 data: perTag.data,
137 backgroundColor: perTag.labels.map(
138 (_, i) => PALETTE[i % PALETTE.length] + "cc"
139 ),
140 borderColor: perTag.labels.map(
141 (_, i) => PALETTE[i % PALETTE.length]
142 ),
143 borderWidth: 1,
144 borderRadius: 2,
145 },
146 ],
147 },
148 options: {
149 responsive: true,
150 maintainAspectRatio: true,
151 plugins: {
152 legend: { display: false },
153 tooltip: {
154 backgroundColor: "#13120e",
155 borderColor: "rgba(107,158,120,0.3)",
156 borderWidth: 1,
157 titleFont: { family: "JetBrains Mono, monospace" },
158 bodyFont: { family: "JetBrains Mono, monospace" },
159 },
160 },
161 scales: {
162 x: {
163 ticks: {
164 color: "#a09890",
165 font: { family: "JetBrains Mono, monospace", size: 11 },
166 },
167 grid: { color: "rgba(221,215,205,0.04)" },
168 },
169 y: {
170 ticks: {
171 color: "#a09890",
172 font: { family: "JetBrains Mono, monospace", size: 11 },
173 },
174 grid: { color: "rgba(221,215,205,0.04)" },
175 },
176 },
177 },
178 });
179 return () => chart.destroy();
180 }, [perTag, hasData, state.language]);
181
182 // Chart: hours per day ---------------------------------------------------
183 useEffect(() => {
184 if (!hasData || !dayCanvasRef.current) return;
185 const chart = new Chart(dayCanvasRef.current, {
186 type: "line",
187 data: {
188 labels: perDay.labels,
189 datasets: [
190 {
191 label: strings.numHours,
192 data: perDay.data,
193 fill: true,
194 tension: 0.35,
195 backgroundColor: "rgba(107,158,120,0.14)",
196 borderColor: "#6B9E78",
197 borderWidth: 1.5,
198 pointBackgroundColor: "#7DB88C",
199 pointBorderColor: "#0e0d0a",
200 pointRadius: 3,
201 pointHoverRadius: 5,
202 },
203 ],
204 },
205 options: {
206 responsive: true,
207 maintainAspectRatio: true,
208 plugins: {
209 legend: { display: false },
210 tooltip: {
211 backgroundColor: "#13120e",
212 borderColor: "rgba(107,158,120,0.3)",
213 borderWidth: 1,
214 titleFont: { family: "JetBrains Mono, monospace" },
215 bodyFont: { family: "JetBrains Mono, monospace" },
216 },
217 },
218 scales: {
219 x: {
220 ticks: {
221 color: "#a09890",
222 font: { family: "JetBrains Mono, monospace", size: 10 },
223 },
224 grid: { color: "rgba(221,215,205,0.04)" },
225 },
226 y: {
227 ticks: {
228 color: "#a09890",
229 font: { family: "JetBrains Mono, monospace", size: 10 },
230 },
231 grid: { color: "rgba(221,215,205,0.04)" },
232 beginAtZero: true,
233 },
234 },
235 },
236 });
237 return () => chart.destroy();
238 }, [perDay, hasData, state.language]);
239
240 if (!hasData) {
241 return (
242 <Page title="Summary">
243 <div className="page-grid">
244 <main className="page-main">
245 <h1 className="page-title">{strings.pageTitle}</h1>
246 </main>
247 </div>
248 <div className="empty-state">
249 <p className="empty-state-title">{strings.empty}</p>
250 <p className="empty-state-hint">{strings.emptyHint}</p>
251 </div>
252 </Page>
253 );
254 }
255
256 return (
257 <Page title="Summary">
258 <div className="page-grid">
259 <main className="page-main">
260 <h1 className="page-title">{strings.pageTitle}</h1>
261
262 <div className={styles.tiles}>
263 <Tile label={strings.statTotal}>
264 {formatDuration(stats.totalMs, strings)}
265 </Tile>
266 <Tile label={strings.statToday}>
267 {formatDuration(stats.todayMs, strings)}
268 </Tile>
269 <Tile label={strings.statWeek}>
270 {formatDuration(stats.weekMs, strings)}
271 </Tile>
272 <Tile label={strings.statLongest}>
273 {formatDuration(stats.longestMs, strings)}
274 </Tile>
275 <Tile label={strings.statEntries}>{stats.entryCount}</Tile>
276 <Tile label={strings.statTags}>{stats.tagCount}</Tile>
277 </div>
278
279 <h2 className="section-label">{strings.sectionPerTag}</h2>
280 <div className={styles.chartCard}>
281 <canvas ref={tagCanvasRef} />
282 </div>
283
284 <h2 className="section-label">{strings.sectionPerDay}</h2>
285 <div className={styles.chartCard}>
286 <canvas ref={dayCanvasRef} />
287 </div>
288 </main>
289 </div>
290 </Page>
291 );
292};
293
294const Tile = ({ label, children }) => (
295 <div className={styles.tile}>
296 <span className={styles.tileLabel}>{label}</span>
297 <span className={styles.tileValue}>{children}</span>
298 </div>
299);
300
301export default Summary;