The theme picker shows NORD, MOCHA and LATTE. Nord is the base theme in the CSS, the browser bar color and the PWA manifest. The startup script applies the theme before the first paint. A device that saved Mocha changes to Nord one time. After that, a user can select Mocha again and the app keeps it. The Nord red #bf616a has only 3.05:1 contrast on the background, so the danger color is the lighter #dc8890 (4.74:1). Co-Authored-By: Claude Opus 5.5 <[email protected]>
540 lines
33 KiB
TypeScript
540 lines
33 KiB
TypeScript
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<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">
|
||
<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">> 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>Nord and Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme">${THEMES.map(option => `<button class="theme-option ${theme === option ? "active" : ""}" data-action="theme" data-theme="${option}" aria-pressed="${theme === option}" type="button">${option.toUpperCase()}</button>`).join("")}</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); }
|
||
}
|
||
|
||
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<HTMLElement>("[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<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) 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 = `<div class="login"><div class="notice error">Could not open the local log: ${h(error)}</div></div>`; });
|