repos
/ timelite-nextjs master

timelite-nextjs

mirror archived upstream

A dead simple time tracker that keeps everything in local storage. Next.js, no accounts and no server.

handcodedlocalstoragenextjsreactself-hostedserverlesstime-trackingtimer

5.5 KB · 190 lines · JavaScript Raw History
  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};