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"; import { readyToReload } from "./update.ts"; import { THEMES, THEME_COLORS, isTheme, type Theme } from "./theme.ts"; type View = "timer" | "log" | "insights" | "settings"; type Me = { authenticated: boolean; dev: boolean; githubConfigured: boolean; vapidPublicKey: string | null }; const app = document.getElementById("app")!; let records: TimeRecord[] = []; let view: View = "timer"; const startTheme = document.documentElement.dataset.theme; let theme: Theme = isTheme(startTheme) ? startTheme : "nord"; 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; let updateReady = false; let busyCount = 0; function h(value: unknown): string { return String(value ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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", THEME_COLORS[next]); 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); } /** Marks work that a reload must not interrupt, such as a save or an import. */ async function busy(work: () => Promise): Promise { 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 ``; } 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 `
${h(today)}
${updateReady ? `` : ""}${statusText()}
${content}
${editingId ? editorModal() : ""}${message ? `
${h(message.text)}
` : ""}`; } function loginPage() { const offline = !navigator.onLine; return ``; } function pageHeading(title: string, meta = "", eyebrow = "") { return `
${eyebrow ? `

${eyebrow}

` : ""}

${title}

${meta ? `${meta}` : ""}
`; } 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 => ``).join(""); const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6); const weekMax = Math.max(...week.trend, 1); return `
${active ? `
● SESSION IN PROGRESS${h(active.tag.toUpperCase())}
` : ""}
${active ? countdown(dueMs(active) - Date.now()) : `${pad(selectedDuration)}:00`}
${active ? `
Started ${clock(active.startMs)} · Ends ${clock(dueMs(active))}
` : ""}
${active ? `
TAG
${h(active.tag)}
DURATION
${duration(active.targetSeconds! * 1000)}
` : `
${[15, 25, 30, 45, 60, 120].map(n => ``).join("")}
`}
// TODAY${h(shortDate(todayWindow.start).toUpperCase())}
${duration(today.totalMs)}
${completedToday} ${completedToday === 1 ? "session" : "sessions"} logged${active ? " · 1 running" : ""}
THIS WEEK${duration(week.totalMs)}
${week.trend.map((value, i) => `
`).join("")}
MONSUN

Today's log

${latest.length ? latest.map(record => entryRow(record)).join("") : `
No time logged today. Start when you're ready.
`}
`; } function entryRow(record: TimeRecord, editable = false) { const end = recordEnd(record); return `
${clock(record.startMs)}–${record.endMs === null ? "NOW" : clock(end)} ${duration(end - record.startMs)} ${editable ? `` : `${record.isSuccess === false ? "○" : "·"}`}
`; } 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(); 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 => ``).join(""); return `
${pageHeading("Your time, in order.", `${filtered.length} ENTRIES`)}
${h(month.toLocaleDateString(undefined, { month: "long", year: "numeric" }).toUpperCase())}
${groups.size ? [...groups].map(([label, entries]) => `
${h(label.toUpperCase())}${duration(entries.reduce((sum, record) => sum + Math.max(0, recordEnd(record) - record.startMs), 0))}
${entries.map(record => entryRow(record, true)).join("")}
`).join("") : `
No entries for this month${logTag !== "all" ? " and tag" : ""}.
`}
`; } 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 `
${pageHeading("Where time goes.")}
${(["day", "week", "month", "year"] as RangeKind[]).map(kind => ``).join("")}
${h(range.label.toUpperCase())}
// TIME TREND${h(insightKind.toUpperCase())}
${duration(stats.totalMs)}
${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period
// BY TAG${stats.tags.length} CATEGORIES
${stats.tags.length ? stats.tags.map(([tag, value]) => `
${h(tag)}${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%
`).join("") : `
No time logged in this period.
`}

Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.

`; } function settingsPage() { const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window; const permission = "Notification" in window ? Notification.permission : "unsupported"; return `
${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
// DATA & DEVICEPRIVATE

Appearance

Nord and Catppuccin colors. Saved on this device.

${THEMES.map(option => ``).join("")}

Forest CSV import

Bring in your existing sessions. Re-importing the same file skips duplicates.

Forest CSV export

Download your completed sessions with Forest's six-column layout.

Completion notifications

${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."}

Sync

${statusText()}. Sessions remain here until the server can receive them.

Account

${me?.dev ? "Local development mode" : "Signed in with your GitHub account"}

`; } 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 ``; } function render() { if (!me) { app.innerHTML = ``; 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); } } 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(isTheme(element.dataset.theme) ? element.dataset.theme : "nord"); 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("[data-action]"); // 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]; 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(".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(); }); 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(() => { // 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) { // 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 = ``; });