Add Timelog: show the timer in the tab title and simplify the code
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]>
This commit is contained in:
38 files changed
+2069
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { TimeRecord } from "../src/shared/record.ts";
|
||||
|
||||
let processHandle: ReturnType<typeof Bun.spawn>;
|
||||
let folder: string;
|
||||
const port = 33000 + Math.floor(Math.random() * 10000);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
folder = await mkdtemp(join(tmpdir(), "timelog-api-test-"));
|
||||
processHandle = Bun.spawn([process.execPath, "src/api/server.ts"], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, API_PORT: String(port), DB_PATH: join(folder, "test.sqlite"),
|
||||
PUBLIC_ORIGIN: "http://localhost:3000", DEV_AUTH: "1" },
|
||||
stdout: "ignore", stderr: "pipe",
|
||||
});
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
try { if ((await fetch(`${base}/api/health`)).ok) return; } catch {}
|
||||
await Bun.sleep(50);
|
||||
}
|
||||
throw new Error("API did not start");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
processHandle?.kill();
|
||||
await processHandle?.exited;
|
||||
if (folder) await rm(folder, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("sync is idempotent and completes a due session at its target", async () => {
|
||||
const startMs = Date.now() - 10_000;
|
||||
const record: TimeRecord = {
|
||||
id: "session_integration_123", startMs, endMs: null, targetSeconds: 5,
|
||||
tag: "job hunting", note: "", treeType: "", isSuccess: null, source: "timer",
|
||||
rawStart: null, rawEnd: null, deleted: false,
|
||||
};
|
||||
const request = { operations: [{ id: "op_integration_123", record }] };
|
||||
const options = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(request) };
|
||||
expect((await fetch(`${base}/api/operations`, options)).status).toBe(200);
|
||||
expect((await fetch(`${base}/api/operations`, options)).status).toBe(200);
|
||||
const state = await (await fetch(`${base}/api/state?since=0`)).json() as {
|
||||
records: TimeRecord[]; cursor: number;
|
||||
};
|
||||
expect(state.records).toHaveLength(1);
|
||||
expect(state.records[0]!.endMs).toBe(startMs + 5000);
|
||||
expect(state.records[0]!.isSuccess).toBe(true);
|
||||
expect(state.records[0]!.tag).toBe("Job Hunting");
|
||||
expect(state.cursor).toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { exportForestCsv, importForestCsv, parseCsv } from "../src/client/csv.ts";
|
||||
import { overlapMs, rangeWindow, totals } from "../src/client/analytics.ts";
|
||||
import { normalizeTag } from "../src/shared/tag.ts";
|
||||
|
||||
describe("Forest CSV", () => {
|
||||
test("parses quotes, commas, and newlines", () => {
|
||||
expect(parseCsv('a,b\r\n"one,two","line 1\nline 2"\r\n')).toEqual([
|
||||
["a", "b"], ["one,two", "line 1\nline 2"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("imports the supplied Forest export and round trips its data", async () => {
|
||||
const source = [...new Bun.Glob("*.csv").scanSync(".")][0];
|
||||
expect(source).toBeDefined();
|
||||
const records = await importForestCsv(await Bun.file(source!).text());
|
||||
expect(records).toHaveLength(2709);
|
||||
expect(new Set(records.map(record => record.id)).size).toBe(records.length);
|
||||
expect(records.filter(record => record.isSuccess === false)).toHaveLength(28);
|
||||
expect(records.filter(record => record.tag === "Job Hunting")).toHaveLength(3);
|
||||
expect(records.filter(record => record.tag === "Music")).toHaveLength(12);
|
||||
expect(records.filter(record => record.tag === "Paperwork")).toHaveLength(2);
|
||||
const output = parseCsv(exportForestCsv(records));
|
||||
expect(output).toHaveLength(2710);
|
||||
expect(output[0]).toEqual(["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"]);
|
||||
expect(output[1]).toEqual([
|
||||
"2021-04-28T20:38:16.930-0400", "2021-04-28T21:03:16.930-0400", "Study", "", "Cedar", "True",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("tag capitalization keeps mixed-case names and short acronyms", () => {
|
||||
expect(normalizeTag(" job hunting ")).toBe("Job Hunting");
|
||||
expect(normalizeTag("iOS AI study")).toBe("iOS AI Study");
|
||||
});
|
||||
|
||||
describe("insights", () => {
|
||||
test("splits a session crossing midnight and counts early stops", () => {
|
||||
const start = +new Date(2026, 8, 20, 23, 30);
|
||||
const end = +new Date(2026, 8, 21, 0, 30);
|
||||
const record = {
|
||||
id: "session_test", startMs: start, endMs: end, targetSeconds: 7200, tag: "Study", note: "",
|
||||
treeType: "", isSuccess: false, source: "timer" as const, rawStart: null, rawEnd: null, deleted: false,
|
||||
};
|
||||
const first = rangeWindow("day", 0, new Date(2026, 8, 20));
|
||||
const second = rangeWindow("day", 0, new Date(2026, 8, 21));
|
||||
expect(overlapMs(record, first.start, first.end)).toBe(30 * 60 * 1000);
|
||||
expect(totals([record], second).totalMs).toBe(30 * 60 * 1000);
|
||||
expect(totals([record], first).tags).toEqual([["Study", 30 * 60 * 1000]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { dueMs, recordEnd, type TimeRecord } from "../src/shared/record.ts";
|
||||
|
||||
const base: TimeRecord = {
|
||||
id: "session_record_test", startMs: 1_000_000, endMs: null, targetSeconds: 60, tag: "Study", note: "",
|
||||
treeType: "", isSuccess: null, source: "timer", rawStart: null, rawEnd: null, deleted: false,
|
||||
};
|
||||
|
||||
test("a running session ends now until it is due", () => {
|
||||
expect(dueMs(base)).toBe(1_060_000);
|
||||
expect(recordEnd(base, 1_030_000)).toBe(1_030_000);
|
||||
expect(recordEnd(base, 2_000_000)).toBe(1_060_000);
|
||||
});
|
||||
|
||||
test("a finished record keeps its own end", () => {
|
||||
expect(recordEnd({ ...base, endMs: 1_010_000 }, 2_000_000)).toBe(1_010_000);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { countdown, pageTitle } from "../src/client/title.ts";
|
||||
|
||||
const start = +new Date(2026, 10, 5, 9, 0, 0);
|
||||
const session = { startMs: start, targetSeconds: 25 * 60, tag: "Study" };
|
||||
|
||||
describe("browser tab title", () => {
|
||||
test("is the app name when no session runs", () => {
|
||||
expect(pageTitle(null, start)).toBe("Timelog");
|
||||
});
|
||||
|
||||
test("shows the remaining time and tag while a session runs", () => {
|
||||
expect(pageTitle(session, start)).toBe("25:00 · Study — Timelog");
|
||||
expect(pageTitle(session, start + 61_500)).toBe("23:59 · Study — Timelog");
|
||||
});
|
||||
|
||||
test("stops at 00:00 when the session is due", () => {
|
||||
expect(pageTitle(session, start + 26 * 60_000)).toBe("00:00 · Study — Timelog");
|
||||
});
|
||||
|
||||
test("countdown rounds partial seconds up", () => {
|
||||
expect(countdown(1)).toBe("00:01");
|
||||
expect(countdown(120 * 60_000)).toBe("120:00");
|
||||
expect(countdown(-5)).toBe("00:00");
|
||||
});
|
||||
});
|
||||
|
||||
test("redundant UI text is gone", async () => {
|
||||
const source = (await Bun.file("src/client/main.ts").text()).toLowerCase();
|
||||
for (const text of [
|
||||
"a quiet record of time", "local first", "forest csv / v1",
|
||||
"one session at a time", "make time count", "actual logged time",
|
||||
]) expect(source).not.toContain(text);
|
||||
});
|
||||
Reference in new issue
Block a user