A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.
handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer
1const stripSanitizePrefix = (s) =>
2 typeof s === "string" && s.startsWith("'") ? s.slice(1) : s;
3
4const parseCsv = (text) => {
5 const rows = [];
6 let row = [];
7 let cell = "";
8 let inQuotes = false;
9 for (let i = 0; i < text.length; i++) {
10 const ch = text[i];
11 if (inQuotes) {
12 if (ch === '"') {
13 if (text[i + 1] === '"') {
14 cell += '"';
15 i++;
16 } else {
17 inQuotes = false;
18 }
19 } else {
20 cell += ch;
21 }
22 } else {
23 if (ch === '"') {
24 inQuotes = true;
25 } else if (ch === ",") {
26 row.push(cell);
27 cell = "";
28 } else if (ch === "\n") {
29 row.push(cell);
30 rows.push(row);
31 row = [];
32 cell = "";
33 } else if (ch === "\r") {
34 // skip
35 } else {
36 cell += ch;
37 }
38 }
39 }
40 if (cell.length > 0 || row.length > 0) {
41 row.push(cell);
42 rows.push(row);
43 }
44 return rows.filter((r) => r.length > 1 || (r.length === 1 && r[0] !== ""));
45};
46
47const extractTagsFromNote = (note) =>
48 (note || "")
49 .split(/\s+/)
50 .filter((w) => w.startsWith("#") && w.length > 1)
51 .map((w) => w.toLowerCase());
52
53const normalizeEntry = (raw) => {
54 const start = raw.start instanceof Date ? raw.start : new Date(raw.start);
55 const end = raw.end instanceof Date ? raw.end : new Date(raw.end);
56 if (isNaN(+start) || isNaN(+end)) return null;
57 const note = stripSanitizePrefix(raw.note || "");
58 let tags = raw.tags;
59 if (typeof tags === "string") {
60 tags = stripSanitizePrefix(tags)
61 .split(/\s+/)
62 .filter((t) => t.startsWith("#") && t.length > 1)
63 .map((t) => t.toLowerCase());
64 } else if (Array.isArray(tags)) {
65 tags = tags
66 .filter((t) => typeof t === "string" && t.startsWith("#") && t.length > 1)
67 .map((t) => t.toLowerCase());
68 } else {
69 tags = extractTagsFromNote(note);
70 }
71 return {
72 id: raw.id || null,
73 start,
74 end,
75 note,
76 tags,
77 };
78};
79
80export const parseImport = (text, filename) => {
81 const lower = (filename || "").toLowerCase();
82 const looksLikeJson = lower.endsWith(".json") || text.trim().startsWith("[") || text.trim().startsWith("{");
83
84 if (looksLikeJson) {
85 const data = JSON.parse(text);
86 const list = Array.isArray(data) ? data : data.log;
87 if (!Array.isArray(list)) throw new Error("JSON must be an array of entries");
88 return list.map(normalizeEntry).filter(Boolean);
89 }
90
91 const rows = parseCsv(text);
92 if (rows.length < 2) return [];
93 const [header, ...body] = rows;
94 const idx = {};
95 header.forEach((h, i) => {
96 idx[h.trim().toLowerCase()] = i;
97 });
98 if (idx.start === undefined || idx.end === undefined) {
99 throw new Error("CSV must include start and end columns");
100 }
101 return body
102 .map((r) =>
103 normalizeEntry({
104 id: idx.id !== undefined ? r[idx.id] : null,
105 start: r[idx.start],
106 end: r[idx.end],
107 note: idx.note !== undefined ? r[idx.note] : "",
108 tags: idx.tags !== undefined ? r[idx.tags] : undefined,
109 })
110 )
111 .filter(Boolean);
112};
113
114export const buildJsonExport = (log) =>
115 JSON.stringify(
116 {
117 version: 1,
118 exportedAt: new Date().toISOString(),
119 log: log.map((e) => ({
120 id: e.id,
121 start: (e.start instanceof Date ? e.start : new Date(e.start)).toISOString(),
122 end: (e.end instanceof Date ? e.end : new Date(e.end)).toISOString(),
123 note: e.note || "",
124 tags: e.tags || [],
125 })),
126 },
127 null,
128 2
129 );
130
131export const buildMarkdownExport = (log) => {
132 if (log.length === 0) return "# Timelite Log\n\n*(empty)*\n";
133 const groups = new Map();
134 for (const e of log) {
135 const d = e.start instanceof Date ? e.start : new Date(e.start);
136 const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
137 if (!groups.has(key)) groups.set(key, { date: d, entries: [] });
138 groups.get(key).entries.push(e);
139 }
140 const sortedKeys = [...groups.keys()].sort().reverse();
141
142 const pad = (n) => String(n).padStart(2, "0");
143 const hms = (ms) => {
144 const h = Math.floor(ms / 3600000);
145 const m = Math.floor((ms % 3600000) / 60000);
146 const s = Math.floor((ms % 60000) / 1000);
147 return `${pad(h)}:${pad(m)}:${pad(s)}`;
148 };
149 const hm = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
150
151 const lines = ["# Timelite Log", ""];
152 for (const key of sortedKeys) {
153 const group = groups.get(key);
154 const dayLabel = group.date.toLocaleDateString(undefined, {
155 weekday: "long",
156 year: "numeric",
157 month: "long",
158 day: "numeric",
159 });
160 const total = group.entries.reduce(
161 (sum, e) => sum + (+new Date(e.end) - +new Date(e.start)),
162 0
163 );
164 lines.push(`## ${dayLabel} — ${hms(total)}`);
165 lines.push("");
166 for (const e of group.entries) {
167 const s = e.start instanceof Date ? e.start : new Date(e.start);
168 const en = e.end instanceof Date ? e.end : new Date(e.end);
169 const dur = hms(+en - +s);
170 const note = (e.note || "").trim();
171 const tags = (e.tags || []).length ? ` *${e.tags.join(", ")}*` : "";
172 lines.push(`- \`${dur}\` ${hm(s)} → ${hm(en)}${note ? ` — ${note}` : ""}${tags}`);
173 }
174 lines.push("");
175 }
176 return lines.join("\n");
177};
178
179export const downloadTextFile = (text, filename, mime) => {
180 const blob = new Blob([text], { type: mime });
181 const url = URL.createObjectURL(blob);
182 const a = document.createElement("a");
183 a.href = url;
184 a.download = filename;
185 document.body.appendChild(a);
186 a.click();
187 document.body.removeChild(a);
188 setTimeout(() => URL.revokeObjectURL(url), 0);
189};