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,502 @@
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
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", 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);
|
||||
}
|
||||
|
||||
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">
|
||||
<header class="topbar"><span class="topbar-date">${h(today)}</span><div class="topbar-actions"><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">> 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 & 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">> 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 & 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); }
|
||||
}
|
||||
|
||||
async function settleDue() {
|
||||
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]");
|
||||
if (element) void 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 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(); });
|
||||
document.addEventListener("visibilitychange", () => { if (!document.hidden) { void settleDue(); sync(); } });
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape" && editingId) { editingId = null; render(); } });
|
||||
|
||||
setInterval(() => {
|
||||
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) {
|
||||
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>`; });
|
||||
Reference in new issue
Block a user