Files
timelog/src/client/main.ts
T

538 lines
33 KiB
TypeScript
Raw Normal View History

import { exportForestCsv, importForestCsv } from "./csv.ts";
import { overlapMs, rangeWindow, totals, type RangeKind } from "./analytics.ts";
import { allRecords, saveRecord, saveRecords } from "./store.ts";
import { syncNow, type SyncStatus } from "./sync.ts";
import { dueMs, recordEnd, type TimeRecord } from "../shared/record.ts";
import { normalizeTag } from "../shared/tag.ts";
import { countdown, pad, pageTitle } from "./title.ts";
2026-11-08 14:49:29 -05:00
import { readyToReload } from "./update.ts";
type View = "timer" | "log" | "insights" | "settings";
type Theme = "latte" | "mocha";
type Me = { authenticated: boolean; dev: boolean; githubConfigured: boolean; vapidPublicKey: string | null };
const app = document.getElementById("app")!;
let records: TimeRecord[] = [];
let view: View = "timer";
let theme: Theme = document.documentElement.dataset.theme === "latte" ? "latte" : "mocha";
let me: Me | null = null;
let syncStatus: SyncStatus = navigator.onLine ? "pending" : "offline";
let selectedTag = normalizeTag(localStorage.getItem("timelog-last-tag") || "Study");
let selectedDuration = Number(localStorage.getItem("timelog-last-duration") || 25);
let logMonthOffset = 0;
let logTag = "all";
let insightKind: RangeKind = "week";
let insightOffset = 0;
let editingId: string | null = null;
let message: { text: string; kind: "success" | "error" } | null = null;
let toastTimer = 0;
let pushRegistration: ServiceWorkerRegistration | null = null;
2026-11-08 14:49:29 -05:00
let updateReady = false;
let busyCount = 0;
function h(value: unknown): string {
return String(value ?? "").replace(/[&<>"']/g, char => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
}
function clock(ms: number) { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
function dateLabel(ms: number) { return new Date(ms).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); }
function shortDate(ms: number) { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); }
function duration(ms: number) {
if (ms <= 0) return "0m";
const minutes = Math.round(ms / 60000);
if (minutes <= 0) return "<1m";
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return hours ? `${hours}h${rest ? ` ${rest}m` : ""}` : `${rest}m`;
}
function datetimeLocal(ms: number) {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function tags() {
return [...new Set([selectedTag, "Study", ...records.filter(r => !r.deleted).map(r => r.tag)])].sort((a, b) => a.localeCompare(b));
}
function activeRecord() { return records.find(r => !r.deleted && r.endMs === null) || null; }
function progress(r: TimeRecord) { return Math.min(100, Math.max(0, (Date.now() - r.startMs) / (dueMs(r) - r.startMs) * 100)); }
function statusText() {
return ({ offline: "OFFLINE · SAVED HERE", syncing: "SYNCING...", synced: "ALL SYNCED", pending: "SYNC PENDING", "signed-out": "SIGN IN TO SYNC" } as const)[syncStatus];
}
function markSignedOut() {
me = { ...(me || { dev: false, githubConfigured: true, vapidPublicKey: null }), authenticated: false };
localStorage.removeItem("timelog-auth-seen");
syncStatus = "signed-out";
}
function setTheme(next: Theme) {
theme = next;
document.documentElement.dataset.theme = next;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "latte" ? "#e6e9ef" : "#1e1e2e");
localStorage.setItem("timelog-theme", next);
render();
}
function showMessage(text: string, kind: "success" | "error" = "success") {
message = { text, kind };
document.querySelector(".toast")?.remove();
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
toast.textContent = text;
app.appendChild(toast);
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => { message = null; document.querySelector(".toast")?.remove(); }, 5000);
}
2026-11-08 14:49:29 -05:00
/** Marks work that a reload must not interrupt, such as a save or an import. */
async function busy<T>(work: () => Promise<T>): Promise<T> {
busyCount++;
try { return await work(); } finally { busyCount--; }
}
function applyUpdateIfSafe() {
if (readyToReload({ updateReady, hidden: document.hidden, editing: editingId !== null, busy: busyCount > 0 })) location.reload();
}
function checkForUpdate() {
void pushRegistration?.update().catch(() => {});
}
function navButton(target: View, label: string, index: string) {
return `<button class="nav-btn ${view === target ? "active" : ""}" data-action="nav" data-view="${target}" type="button"><span class="nav-prefix">${index}</span>${label}</button>`;
}
function navButtons() {
return `${navButton("timer", "Timer", "01")}${navButton("log", "Log", "02")}${navButton("insights", "Insights", "03")}`;
}
function frame(content: string) {
const today = new Date().toLocaleDateString(undefined, { weekday: "long", month: "short", day: "numeric", year: "numeric" });
return `<div class="shell">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
<nav class="nav" aria-label="Main navigation">
${navButtons()}
</nav>
<div class="side-bottom"><div class="status-row"><span class="status-dot ${syncStatus}"></span>${statusText()}</div></div>
</aside>
<main class="main">
2026-11-08 14:49:29 -05:00
<header class="topbar"><span class="topbar-date">${h(today)}</span><div class="topbar-actions">${updateReady ? `<button class="update-btn" data-action="reload-update" title="Reload into the new version. A running timer continues." type="button">UPDATE</button>` : ""}<span class="topbar-status">${statusText()}</span><button class="icon-btn" title="Settings" aria-label="Settings" data-action="nav" data-view="settings" type="button">⚙</button></div></header>
${content}
</main>
<nav class="mobile-nav" aria-label="Mobile navigation">${navButtons()}</nav>
</div>${editingId ? editorModal() : ""}${message ? `<div class="toast" role="status">${h(message.text)}</div>` : ""}`;
}
function loginPage() {
const offline = !navigator.onLine;
return `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div>
<h1>Time, plainly.</h1><p>One timer. One log. A clear view of where the hours went.</p>
${offline ? `<div class="notice">You're offline. Sign in once while connected to use this device offline.</div>` : ""}
${me && !me.githubConfigured ? `<div class="notice error">GitHub sign-in has not been configured on the server yet.</div>` : ""}
<a class="primary-btn" href="/auth/github">&gt; SIGN IN WITH GITHUB</a><p style="margin-top:22px">Private to your account. No repository access.</p>
</div></div>`;
}
function pageHeading(title: string, meta = "", eyebrow = "") {
return `<div class="page-heading"><div>${eyebrow ? `<p class="eyebrow">${eyebrow}</p>` : ""}<h1>${title}</h1></div>${meta ? `<span class="heading-meta">${meta}</span>` : ""}</div>`;
}
function timerPage() {
const active = activeRecord();
const todayWindow = rangeWindow("day");
const today = totals(records, todayWindow);
const completed = records.filter(r => !r.deleted && r.endMs !== null && overlapMs(r, todayWindow.start, todayWindow.end) > 0);
const completedToday = completed.length;
const week = totals(records, rangeWindow("week"));
const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === selectedTag ? "selected" : ""}>${h(tag)}</option>`).join("");
const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6);
const weekMax = Math.max(...week.trend, 1);
return `<section class="page">
<div class="grid">
<div class="panel"><div class="panel-inner">
${active ? `<div class="panel-label"><span>● SESSION IN PROGRESS</span><span class="end">${h(active.tag.toUpperCase())}</span></div>` : ""}
<div class="timer-display ${active ? "" : "idle"}" id="timer-display">${active ? countdown(dueMs(active) - Date.now()) : `${pad(selectedDuration)}<span class="seconds">:00</span>`}</div>
${active ? `<div class="timer-hint" id="timer-hint">Started ${clock(active.startMs)} · Ends ${clock(dueMs(active))}</div>` : ""}
<div class="progress-track"><div class="progress-fill" id="progress-fill" style="width:${active ? progress(active) : 0}%"></div></div>
${active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center">${h(active.tag)}</div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center">${duration(active.targetSeconds! * 1000)}</div></div></div>
<button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY &amp; LOG TIME</button>` : `
<div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag">${tagOptions}<option value="__new__">+ New tag...</option></select></div>
<div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value="${selectedDuration}" /></div></div>
<div class="duration-row">${[15, 25, 30, 45, 60, 120].map(n => `<button class="chip ${n === selectedDuration ? "active" : ""}" data-action="duration" data-minutes="${n}" type="button">${n}m</button>`).join("")}</div>
<button class="primary-btn" data-action="start" type="button">&gt; START SESSION</button>`}
</div></div>
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end">${h(shortDate(todayWindow.start).toUpperCase())}</span></div>
<div class="side-stat" id="today-total">${duration(today.totalMs)}</div><div class="muted" style="font-size:12px">${completedToday} ${completedToday === 1 ? "session" : "sessions"} logged${active ? " · 1 running" : ""}</div>
<div class="dash"></div><div class="panel-label"><span>THIS WEEK</span><span class="end">${duration(week.totalMs)}</span></div>
<div class="mini-bars">${week.trend.map((value, i) => `<div class="mini-bar ${i === ((new Date().getDay() + 6) % 7) ? "current" : ""}" title="${duration(value)}" style="height:${Math.max(3, value / weekMax * 100)}%"></div>`).join("")}</div>
<div class="muted" style="display:flex;justify-content:space-between;font-size:10px;margin-top:8px"><span>MON</span><span>SUN</span></div>
</div></div>
</div>
<section class="section"><div class="section-head"><h2>Today's log</h2><button class="text-btn" data-action="nav" data-view="log" type="button">VIEW ALL →</button></div>
${latest.length ? latest.map(record => entryRow(record)).join("") : `<div class="empty">No time logged today. Start when you're ready.</div>`}
</section>
</section>`;
}
function entryRow(record: TimeRecord, editable = false) {
const end = recordEnd(record);
return `<div class="entry ${editable ? "log-entry" : ""}"><span class="entry-time">${clock(record.startMs)}–${record.endMs === null ? "NOW" : clock(end)}</span>
<span class="entry-tag">${h(record.tag)}${editable && record.note ? `<small>${h(record.note)}</small>` : ""}</span>
<span class="entry-duration">${duration(end - record.startMs)}</span>
${editable ? `<button class="entry-btn" data-action="edit" data-id="${h(record.id)}" aria-label="Edit ${h(record.tag)} session" type="button">✎</button>` : `<span class="entry-status" title="${record.isSuccess === false ? "Stopped early or unsuccessful" : "Complete"}">${record.isSuccess === false ? "○" : "·"}</span>`}
</div>`;
}
function monthDate(offset: number) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth() + offset, 1); }
function logPage() {
const month = monthDate(logMonthOffset);
const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1);
const filtered = records.filter(r => !r.deleted && r.startMs >= +month && r.startMs < +nextMonth && (logTag === "all" || r.tag === logTag))
.sort((a, b) => b.startMs - a.startMs);
const groups = new Map<string, TimeRecord[]>();
for (const record of filtered) {
const key = new Date(record.startMs).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(record);
}
const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === logTag ? "selected" : ""}>${h(tag)}</option>`).join("");
return `<section class="page">${pageHeading("Your time, in order.", `${filtered.length} ENTRIES`)}
<div class="toolbar"><button class="small-btn" data-action="prev-log" type="button" aria-label="Previous month">←</button>
<span class="range-label">${h(month.toLocaleDateString(undefined, { month: "long", year: "numeric" }).toUpperCase())}</span>
<button class="small-btn" data-action="next-log" type="button" aria-label="Next month">→</button>
<select class="field" id="log-tag" aria-label="Filter by tag"><option value="all">ALL TAGS</option>${tagOptions}</select>
<span class="spacer"></span><button class="small-btn" data-action="add" type="button">+ ADD ENTRY</button></div>
${groups.size ? [...groups].map(([label, entries]) => `<div class="log-group"><div class="log-date"><span>${h(label.toUpperCase())}</span><span>${duration(entries.reduce((sum, record) => sum + Math.max(0, recordEnd(record) - record.startMs), 0))}</span></div>${entries.map(record => entryRow(record, true)).join("")}</div>`).join("") : `<div class="empty">No entries for this month${logTag !== "all" ? " and tag" : ""}.</div>`}
</section>`;
}
function insightsPage() {
const range = rangeWindow(insightKind, insightOffset);
const stats = totals(records, range);
const max = Math.max(...stats.trend, 1);
const maxTag = stats.tags[0]?.[1] || 1;
return `<section class="page">${pageHeading("Where time goes.")}
<div class="toolbar range-controls"><div class="segmented" role="group" aria-label="Insight range">${(["day", "week", "month", "year"] as RangeKind[]).map(kind => `<button class="segment ${insightKind === kind ? "active" : ""}" data-action="range" data-range="${kind}" type="button">${kind}</button>`).join("")}</div>
<span class="spacer"></span><button class="small-btn" data-action="prev-insight" type="button" aria-label="Previous period">←</button>
<span class="range-label">${h(range.label.toUpperCase())}</span><button class="small-btn" data-action="next-insight" type="button" aria-label="Next period">→</button></div>
<div class="insight-grid"><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TIME TREND</span><span class="end">${h(insightKind.toUpperCase())}</span></div>
<div class="insight-big">${duration(stats.totalMs)}</div><div class="muted" style="font-size:11px">${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period</div>
<div class="trend-chart" role="img" aria-label="Logged time trend for ${h(range.label)}">${range.bins.map((bin, index) => {
const value = stats.trend[index]!;
const isCurrent = Date.now() >= bin.start && Date.now() < bin.end;
return `<div class="trend-col ${isCurrent ? "current" : ""}" title="${h(bin.label)}: ${duration(value)}"><span class="trend-value">${value ? duration(value) : ""}</span><div class="trend-track"><div class="trend-fill" style="height:${Math.max(2, value / max * 100)}%"></div></div><span class="trend-label">${h(bin.label)}</span></div>`;
}).join("")}</div>
</div></div><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// BY TAG</span><span class="end">${stats.tags.length} CATEGORIES</span></div>
${stats.tags.length ? stats.tags.map(([tag, value]) => `<div class="tag-row"><div class="tag-row-head"><span>${h(tag)}</span><span>${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%</span></div><div class="tag-track"><div class="tag-fill" style="width:${Math.max(1, value / maxTag * 100)}%"></div></div></div>`).join("") : `<div class="empty">No time logged in this period.</div>`}
<p class="insight-note">Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.</p>
</div></div></div>
</section>`;
}
function settingsPage() {
const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window;
const permission = "Notification" in window ? Notification.permission : "unsupported";
return `<section class="page">${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// DATA &amp; DEVICE</span><span class="end">PRIVATE</span></div>
<div class="settings-row"><div><h3>Appearance</h3><p>Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme"><button class="theme-option ${theme === "latte" ? "active" : ""}" data-action="theme" data-theme="latte" aria-pressed="${theme === "latte"}" type="button">LATTE</button><button class="theme-option ${theme === "mocha" ? "active" : ""}" data-action="theme" data-theme="mocha" aria-pressed="${theme === "mocha"}" type="button">MOCHA</button></div></div>
<div class="settings-row"><div><h3>Forest CSV import</h3><p>Bring in your existing sessions. Re-importing the same file skips duplicates.</p></div><button class="small-btn" data-action="import" type="button">IMPORT CSV</button><input class="hidden-input" id="csv-file" type="file" accept=".csv,text/csv" /></div>
<div class="settings-row"><div><h3>Forest CSV export</h3><p>Download your completed sessions with Forest's six-column layout.</p></div><button class="small-btn" data-action="export" type="button">EXPORT CSV</button></div>
<div class="settings-row"><div><h3>Completion notifications</h3><p>${permission === "denied" ? "Blocked in your device settings." : !canNotify ? "On iPhone, add Timelog to your Home Screen, then open it there to enable alerts." : permission === "granted" ? "Permission granted on this device. Reconnect if alerts stop arriving." : "Get an alert when a session reaches its duration. On iPhone, open Timelog from your Home Screen first."}</p></div><button class="small-btn" data-action="notify" type="button" ${!canNotify || !pushRegistration || permission === "denied" ? "disabled" : ""}>${permission === "granted" ? "RECONNECT" : "ENABLE"}</button></div>
<div class="settings-row"><div><h3>Sync</h3><p>${statusText()}. Sessions remain here until the server can receive them.</p></div><button class="small-btn" data-action="sync" type="button">SYNC NOW</button></div>
<div class="settings-row"><div><h3>Account</h3><p>${me?.dev ? "Local development mode" : "Signed in with your GitHub account"}</p></div><button class="small-btn" data-action="logout" type="button">SIGN OUT</button></div>
</div></div>
</section>`;
}
function editorModal() {
const existing = records.find(r => r.id === editingId);
const start = existing?.startMs || Date.now() - 25 * 60000;
const end = existing?.endMs || Date.now();
return `<div class="modal-backdrop"><div class="modal" role="dialog" aria-modal="true" aria-label="${existing ? "Edit" : "Add"} log entry">
<div class="modal-head"><h2>${existing ? "EDIT ENTRY" : "ADD ENTRY"}</h2><button class="icon-btn" data-action="close-modal" aria-label="Close" type="button">×</button></div>
<label class="field-label" for="edit-tag">TAG</label><input class="field" id="edit-tag" maxlength="100" value="${h(existing?.tag || selectedTag)}" list="known-tags" />
<datalist id="known-tags">${tags().map(tag => `<option value="${h(tag)}"></option>`).join("")}</datalist>
<label class="field-label" for="edit-start">START</label><input class="field" id="edit-start" type="datetime-local" step="1" value="${datetimeLocal(start)}" />
<label class="field-label" for="edit-end">END</label><input class="field" id="edit-end" type="datetime-local" step="1" value="${datetimeLocal(end)}" />
<label class="field-label" for="edit-note">NOTE / OPTIONAL</label><input class="field" id="edit-note" maxlength="2000" value="${h(existing?.note || "")}" />
<div class="modal-actions"><button class="small-btn" data-action="save-edit" type="button">SAVE ENTRY</button>${existing ? `<button class="small-btn danger" data-action="delete-edit" type="button">DELETE</button>` : ""}</div>
</div></div>`;
}
function render() {
if (!me) {
app.innerHTML = `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div><p style="margin-top:32px">Opening your log...</p></div></div>`;
return;
}
if (me && !me.authenticated && !(localStorage.getItem("timelog-auth-seen") && !navigator.onLine)) {
app.innerHTML = loginPage(); return;
}
updateTitle();
const content = view === "timer" ? timerPage() : view === "log" ? logPage() : view === "insights" ? insightsPage() : settingsPage();
app.innerHTML = frame(content);
}
function updateTitle() {
const next = pageTitle(activeRecord());
if (document.title !== next) document.title = next;
}
function refreshFromStore() { return allRecords().then(all => { records = all; render(); }); }
function sync() {
void syncNow((status, updated) => {
syncStatus = status;
if (updated) records = updated;
if (status === "signed-out") markSignedOut();
render();
});
}
async function notifyFinished(record: TimeRecord) {
if (!("Notification" in window) || Notification.permission !== "granted" || !("serviceWorker" in navigator)) return;
try {
const registration = pushRegistration || await navigator.serviceWorker.ready;
// The server already pushes completed sessions it knows about. Showing a
// local notification too produces duplicate alerts on the same device.
if (record.revision && await registration.pushManager?.getSubscription()) return;
await registration.showNotification("Session complete", {
body: `${record.tag} time is up.`, tag: `timelog-${record.id}`, icon: "/icon-192.png",
});
} catch (error) { console.warn("Local notification failed", error); }
}
2026-11-08 14:49:29 -05:00
function settleDue() { return busy(settleDueNow); }
async function settleDueNow() {
const now = Date.now();
const due = records.filter(r => !r.deleted && r.endMs === null && r.targetSeconds !== null && dueMs(r) <= now);
if (!due.length) return;
for (const record of due) {
record.endMs = dueMs(record);
record.isSuccess = true;
await saveRecord(record);
void notifyFinished(record);
}
showMessage(due.length === 1 ? "Session complete. Time logged." : `${due.length} sessions complete. Time logged.`);
sync();
}
async function startSession() {
if (activeRecord()) return;
const tag = normalizeTag(selectedTag);
const minutes = Number((document.getElementById("timer-duration") as HTMLInputElement | null)?.value ?? selectedDuration);
selectedDuration = minutes;
if (!tag || !Number.isInteger(minutes) || minutes < 1 || minutes > 180) { showMessage("Choose a tag and a duration from 1 to 180 minutes.", "error"); return; }
const record: TimeRecord = {
id: `session_${crypto.randomUUID()}`, startMs: Date.now(), endMs: null, targetSeconds: minutes * 60,
tag, note: "", treeType: "", isSuccess: null, source: "timer", rawStart: null, rawEnd: null, deleted: false,
};
localStorage.setItem("timelog-last-tag", tag);
localStorage.setItem("timelog-last-duration", String(minutes));
await saveRecord(record);
await refreshFromStore();
sync();
}
async function stopSession() {
const record = activeRecord();
if (!record) return;
const due = dueMs(record);
record.endMs = Math.min(Date.now(), due);
record.isSuccess = record.endMs >= due;
await saveRecord(record);
await refreshFromStore();
showMessage(`Logged ${duration(record.endMs - record.startMs)} for ${record.tag}.`);
sync();
}
async function saveEditor() {
const tag = normalizeTag((document.getElementById("edit-tag") as HTMLInputElement).value);
const startMs = new Date((document.getElementById("edit-start") as HTMLInputElement).value).getTime();
const endMs = new Date((document.getElementById("edit-end") as HTMLInputElement).value).getTime();
const note = (document.getElementById("edit-note") as HTMLInputElement).value;
if (!tag || !Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) {
showMessage("Enter a tag and valid start and end times.", "error"); return;
}
const existing = records.find(r => r.id === editingId);
const record: TimeRecord = existing ? { ...existing } : {
id: `manual_${crypto.randomUUID()}`, startMs, endMs, targetSeconds: null, tag, note,
treeType: "", isSuccess: true, source: "manual", rawStart: null, rawEnd: null, deleted: false,
};
record.startMs = startMs; record.endMs = endMs; record.tag = tag; record.note = note;
record.rawStart = null; record.rawEnd = null;
if (record.source === "forest") record.source = "manual";
if (record.targetSeconds !== null) record.isSuccess = endMs >= dueMs(record);
await saveRecord(record);
editingId = null;
await refreshFromStore();
showMessage("Entry saved.");
sync();
}
async function deleteEditor() {
const record = records.find(r => r.id === editingId);
if (!record || !window.confirm("Delete this entry?")) return;
await saveRecord({ ...record, deleted: true });
editingId = null;
await refreshFromStore();
showMessage("Entry deleted.");
sync();
}
async function importCsvFile(file: File) {
try {
const incoming = await importForestCsv(await file.text());
const existing = new Set(records.map(r => r.id));
const fresh = incoming.filter(r => !existing.has(r.id));
await saveRecords(fresh);
await refreshFromStore();
showMessage(`Imported ${fresh.length} sessions. ${incoming.length - fresh.length} already in your log.`);
sync();
} catch (error) { showMessage(error instanceof Error ? error.message : "Import failed", "error"); }
}
function exportCsvFile() {
const blob = new Blob([exportForestCsv(records)], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `Timelog_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function vapidBytes(key: string) {
const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - key.length % 4) % 4);
return Uint8Array.from(atob(base64), char => char.charCodeAt(0));
}
async function enableNotifications() {
if (!("Notification" in window) || !pushRegistration?.pushManager) { showMessage("Open the installed app to enable notifications.", "error"); return; }
if (!me?.vapidPublicKey) { showMessage("Server push is not available right now.", "error"); return; }
try {
// Start the subscription from the tap so iOS can show its permission prompt.
const subscription = await pushRegistration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: vapidBytes(me.vapidPublicKey) });
const response = await fetch("/api/push/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(subscription) });
if (!response.ok) throw new Error("Could not connect notifications to the server");
showMessage("Completion notifications enabled.");
} catch (error) { showMessage(error instanceof Error ? error.message : "Could not enable notifications", "error"); }
}
async function dispatch(action: string, element: HTMLElement) {
switch (action) {
case "nav": view = element.dataset.view as View; editingId = null; render(); break;
case "duration": selectedDuration = Number(element.dataset.minutes); render(); break;
case "start": await startSession(); break;
case "stop": await stopSession(); break;
case "prev-log": logMonthOffset--; render(); break;
case "next-log": logMonthOffset++; render(); break;
case "prev-insight": insightOffset--; render(); break;
case "next-insight": insightOffset++; render(); break;
case "range": insightKind = element.dataset.range as RangeKind; insightOffset = 0; render(); break;
case "theme": setTheme(element.dataset.theme === "latte" ? "latte" : "mocha"); break;
case "add": editingId = "new"; render(); break;
case "edit": editingId = element.dataset.id || null; render(); break;
case "close-modal": editingId = null; render(); break;
case "save-edit": await saveEditor(); break;
case "delete-edit": await deleteEditor(); break;
case "import": (document.getElementById("csv-file") as HTMLInputElement).click(); break;
case "export": exportCsvFile(); break;
case "notify": await enableNotifications(); break;
case "sync": sync(); break;
case "logout":
if (navigator.onLine) await fetch("/api/logout", { method: "POST" });
markSignedOut(); render(); break;
}
}
app.addEventListener("click", event => {
const element = (event.target as HTMLElement).closest<HTMLElement>("[data-action]");
2026-11-08 14:49:29 -05:00
// Check this before busy(), which would count the click itself as running work.
if (element?.dataset.action === "reload-update") { if (busyCount === 0) location.reload(); }
else if (element) void busy(() => dispatch(element.dataset.action!, element)).catch(error => showMessage(String(error), "error"));
else if ((event.target as HTMLElement).classList.contains("modal-backdrop")) { editingId = null; render(); }
});
app.addEventListener("change", event => {
const target = event.target as HTMLInputElement | HTMLSelectElement;
if (target.id === "timer-tag") {
if (target.value === "__new__") {
const answer = window.prompt("New tag name")?.trim();
if (answer && answer.length <= 100) selectedTag = normalizeTag(answer);
} else selectedTag = target.value;
render();
} else if (target.id === "timer-duration") {
selectedDuration = Number(target.value);
} else if (target.id === "log-tag") {
logTag = target.value; render();
} else if (target.id === "csv-file") {
const file = (target as HTMLInputElement).files?.[0];
2026-11-08 14:49:29 -05:00
if (file) void busy(() => importCsvFile(file));
}
});
app.addEventListener("input", event => {
const target = event.target as HTMLInputElement;
if (target.id !== "timer-duration") return;
selectedDuration = Number(target.value);
const display = document.getElementById("timer-display");
if (display) display.textContent = Number.isFinite(selectedDuration) ? countdown(selectedDuration * 60_000) : "--:--";
document.querySelectorAll<HTMLButtonElement>(".chip[data-minutes]").forEach(chip => {
chip.classList.toggle("active", Number(chip.dataset.minutes) === selectedDuration);
});
});
window.addEventListener("online", () => { syncStatus = "pending"; sync(); });
window.addEventListener("offline", () => { syncStatus = "offline"; render(); });
2026-11-08 14:49:29 -05:00
document.addEventListener("visibilitychange", () => {
if (document.hidden) applyUpdateIfSafe();
else { void settleDue(); sync(); checkForUpdate(); }
});
document.addEventListener("keydown", event => { if (event.key === "Escape" && editingId) { editingId = null; render(); } });
setInterval(() => {
2026-11-08 14:49:29 -05:00
// Retry here, because a save can block the reload at the moment the page hides.
if (document.hidden) { applyUpdateIfSafe(); return; }
void settleDue();
updateTitle();
const active = activeRecord();
if (!active || view !== "timer") return;
const display = document.getElementById("timer-display");
if (display) display.textContent = countdown(dueMs(active) - Date.now());
const fill = document.getElementById("progress-fill");
if (fill) fill.style.width = `${progress(active)}%`;
const total = document.getElementById("today-total");
if (total) total.textContent = duration(totals(records, rangeWindow("day")).totalMs);
}, 1000);
async function initialize() {
records = await allRecords();
render();
if ("serviceWorker" in navigator) {
2026-11-08 14:49:29 -05:00
// On the first install, the new worker also takes control. That is not an update.
let hadController = Boolean(navigator.serviceWorker.controller);
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!hadController) { hadController = true; return; }
updateReady = true;
render();
applyUpdateIfSafe();
});
// An installed PWA can stay open for days without a navigation, so ask for updates.
setInterval(checkForUpdate, 60 * 60_000);
try {
await navigator.serviceWorker.register("/sw.js");
void navigator.serviceWorker.ready.then(registration => { pushRegistration = registration; render(); });
} catch (error) { console.warn("Service worker registration failed", error); }
}
try {
const response = await fetch("/api/me", { cache: "no-store" });
me = await response.json() as Me;
if (me.authenticated) localStorage.setItem("timelog-auth-seen", "1");
if (me.authenticated) sync();
} catch {
me = { authenticated: Boolean(localStorage.getItem("timelog-auth-seen")), dev: false, githubConfigured: true, vapidPublicKey: null };
syncStatus = "offline";
}
await settleDue();
render();
}
void initialize().catch(error => { app.innerHTML = `<div class="login"><div class="notice error">Could not open the local log: ${h(error)}</div></div>`; });