This is the first commit of the project, at the state that runs on timelog.minhtrannhat.com. Changes in this state: - Remove redundant UI text from the sidebar, timer and insights pages. - Show the remaining time and tag in the browser tab title while a session runs. - Put the shared record type and the due and end helpers in src/shared/record.ts. - Remove the startup tag migration and the one-time Forest import script. The live data does not need them. - Remove unused CSS, Playwright ignore rules and duplicate helpers. - Do not track Forest CSV exports, because they contain private data. Co-Authored-By: Claude Opus 5.5 <[email protected]>
96 lines
4.0 KiB
TypeScript
96 lines
4.0 KiB
TypeScript
import type { TimeRecord } from "../shared/record.ts";
|
|
import { normalizeTag } from "../shared/tag.ts";
|
|
|
|
|
|
const FOREST_HEADERS = ["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"];
|
|
|
|
export function parseCsv(text: string): string[][] {
|
|
const rows: string[][] = [];
|
|
let row: string[] = [];
|
|
let field = "";
|
|
let quoted = false;
|
|
const input = text.replace(/^\uFEFF/, "");
|
|
for (let i = 0; i < input.length; i++) {
|
|
const char = input[i]!;
|
|
if (quoted) {
|
|
if (char === '"' && input[i + 1] === '"') { field += '"'; i++; }
|
|
else if (char === '"') quoted = false;
|
|
else field += char;
|
|
} else if (char === '"' && field === "") {
|
|
quoted = true;
|
|
} else if (char === ",") {
|
|
row.push(field); field = "";
|
|
} else if (char === "\n" || char === "\r") {
|
|
if (char === "\r" && input[i + 1] === "\n") i++;
|
|
row.push(field);
|
|
if (row.some(value => value !== "")) rows.push(row);
|
|
row = []; field = "";
|
|
} else {
|
|
field += char;
|
|
}
|
|
}
|
|
if (quoted) throw new Error("CSV has an unfinished quoted field");
|
|
row.push(field);
|
|
if (row.some(value => value !== "")) rows.push(row);
|
|
return rows;
|
|
}
|
|
|
|
function parseForestDate(value: string): number {
|
|
const normalized = value.trim().replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
|
const ms = Date.parse(normalized);
|
|
if (!Number.isFinite(ms)) throw new Error(`Invalid Forest timestamp: ${value}`);
|
|
return ms;
|
|
}
|
|
|
|
function hashHex(bytes: ArrayBuffer) {
|
|
return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join("");
|
|
}
|
|
|
|
export async function importForestCsv(text: string): Promise<TimeRecord[]> {
|
|
const rows = parseCsv(text);
|
|
if (!rows.length || FOREST_HEADERS.some((header, index) => rows[0]![index]?.trim() !== header)) {
|
|
throw new Error("This is not a Forest CSV with the expected six columns");
|
|
}
|
|
const records: TimeRecord[] = [];
|
|
for (const [index, row] of rows.slice(1).entries()) {
|
|
if (row.length !== 6) throw new Error(`Row ${index + 2} has ${row.length} columns; expected 6`);
|
|
const [rawStart, rawEnd, tag, note, treeType, rawSuccess] = row as [string, string, string, string, string, string];
|
|
const startMs = parseForestDate(rawStart);
|
|
const endMs = parseForestDate(rawEnd);
|
|
if (endMs < startMs) throw new Error(`Row ${index + 2} ends before it starts`);
|
|
if (rawSuccess !== "True" && rawSuccess !== "False") throw new Error(`Row ${index + 2} has an invalid success value`);
|
|
const fingerprint = [rawStart, rawEnd, tag, note, treeType, rawSuccess].join("\u001f");
|
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(fingerprint));
|
|
records.push({
|
|
id: `forest_${hashHex(digest).slice(0, 32)}`,
|
|
startMs, endMs, targetSeconds: null, tag: normalizeTag(tag) || "Unset", note,
|
|
treeType, isSuccess: rawSuccess === "True", source: "forest",
|
|
rawStart, rawEnd, deleted: false,
|
|
});
|
|
}
|
|
return records;
|
|
}
|
|
|
|
function forestDate(ms: number) {
|
|
const date = new Date(ms);
|
|
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
|
const offset = -date.getTimezoneOffset();
|
|
const sign = offset >= 0 ? "+" : "-";
|
|
const offsetText = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}${pad(Math.abs(offset) % 60)}`;
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offsetText}`;
|
|
}
|
|
|
|
function quote(value: string) {
|
|
return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
}
|
|
|
|
export function exportForestCsv(records: TimeRecord[]): string {
|
|
const lines = [FOREST_HEADERS.join(",")];
|
|
for (const record of records.filter(r => !r.deleted && r.endMs !== null).sort((a, b) => a.startMs - b.startMs)) {
|
|
const values = [record.rawStart || forestDate(record.startMs), record.rawEnd || forestDate(record.endMs!),
|
|
record.tag, record.note, record.treeType, record.isSuccess ? "True" : "False"];
|
|
lines.push(values.map(quote).join(","));
|
|
}
|
|
return lines.join("\r\n") + "\r\n";
|
|
}
|