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]>
59 lines
2.1 KiB
TypeScript
59 lines
2.1 KiB
TypeScript
import type { TimeRecord } from "../shared/record.ts";
|
|
import { allRecords, applyRemote, cursor, pendingOperations, removeOperations } from "./store.ts";
|
|
|
|
export type SyncStatus = "offline" | "syncing" | "synced" | "pending" | "signed-out";
|
|
let current: Promise<void> | null = null;
|
|
let rerunRequested = false;
|
|
|
|
class SignedOut extends Error {}
|
|
|
|
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(url, init);
|
|
if (response.status === 401) throw new SignedOut();
|
|
if (!response.ok) throw new Error(`Sync failed (${response.status})`);
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
export function syncNow(onChange: (status: SyncStatus, records?: TimeRecord[]) => void): Promise<void> {
|
|
if (current) {
|
|
rerunRequested = true;
|
|
return current;
|
|
}
|
|
current = (async () => {
|
|
if (!navigator.onLine) { onChange("offline"); return; }
|
|
onChange("syncing");
|
|
try {
|
|
let pending = await pendingOperations();
|
|
while (pending.length) {
|
|
const batch = pending.slice(0, 200);
|
|
const body = await api<{ accepted: string[] }>("/api/operations", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ operations: batch.map(({ id, record }) => ({ id, record })) }),
|
|
});
|
|
await removeOperations(body.accepted);
|
|
pending = await pendingOperations();
|
|
}
|
|
let since = await cursor();
|
|
for (;;) {
|
|
const body = await api<{ records: TimeRecord[]; cursor: number; hasMore: boolean }>(
|
|
`/api/state?since=${since}`, { cache: "no-store" });
|
|
await applyRemote(body.records, body.cursor);
|
|
since = body.cursor;
|
|
if (!body.hasMore) break;
|
|
}
|
|
onChange("synced", await allRecords());
|
|
} catch (error) {
|
|
if (error instanceof SignedOut) { onChange("signed-out"); return; }
|
|
console.error(error);
|
|
onChange("pending");
|
|
}
|
|
})().finally(() => {
|
|
current = null;
|
|
if (rerunRequested) {
|
|
rerunRequested = false;
|
|
void syncNow(onChange);
|
|
}
|
|
});
|
|
return current;
|
|
}
|