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,78 @@
|
||||
import { recordEnd, type TimeRecord } from "../shared/record.ts";
|
||||
|
||||
export type RangeKind = "day" | "week" | "month" | "year";
|
||||
export type Window = { start: number; end: number; label: string; bins: { start: number; end: number; label: string }[] };
|
||||
|
||||
export function overlapMs(record: TimeRecord, start: number, end: number, now = Date.now()): number {
|
||||
if (record.deleted) return 0;
|
||||
return Math.max(0, Math.min(recordEnd(record, now), end) - Math.max(record.startMs, start));
|
||||
}
|
||||
|
||||
function dayStart(value: Date): Date {
|
||||
return new Date(value.getFullYear(), value.getMonth(), value.getDate());
|
||||
}
|
||||
|
||||
export function rangeWindow(kind: RangeKind, offset = 0, base = new Date()): Window {
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
const bins: Window["bins"] = [];
|
||||
if (kind === "day") {
|
||||
startDate = dayStart(base);
|
||||
startDate.setDate(startDate.getDate() + offset);
|
||||
endDate = new Date(startDate);
|
||||
endDate.setDate(endDate.getDate() + 1);
|
||||
for (let hour = 0; hour < 24; hour += 2) {
|
||||
const a = new Date(startDate); a.setHours(hour);
|
||||
const b = new Date(startDate); b.setHours(hour + 2);
|
||||
bins.push({ start: +a, end: +b, label: String(hour).padStart(2, "0") });
|
||||
}
|
||||
} else if (kind === "week") {
|
||||
startDate = dayStart(base);
|
||||
startDate.setDate(startDate.getDate() - (startDate.getDay() + 6) % 7 + offset * 7);
|
||||
endDate = new Date(startDate); endDate.setDate(endDate.getDate() + 7);
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const a = new Date(startDate); a.setDate(a.getDate() + day);
|
||||
const b = new Date(a); b.setDate(b.getDate() + 1);
|
||||
bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { weekday: "short" }).slice(0, 2) });
|
||||
}
|
||||
} else if (kind === "month") {
|
||||
startDate = new Date(base.getFullYear(), base.getMonth() + offset, 1);
|
||||
endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 1, 1);
|
||||
for (let day = 1; day <= new Date(startDate.getFullYear(), startDate.getMonth() + 1, 0).getDate(); day++) {
|
||||
const a = new Date(startDate.getFullYear(), startDate.getMonth(), day);
|
||||
const b = new Date(startDate.getFullYear(), startDate.getMonth(), day + 1);
|
||||
bins.push({ start: +a, end: +b, label: day % 5 === 1 ? String(day) : "" });
|
||||
}
|
||||
} else {
|
||||
startDate = new Date(base.getFullYear() + offset, 0, 1);
|
||||
endDate = new Date(startDate.getFullYear() + 1, 0, 1);
|
||||
for (let month = 0; month < 12; month++) {
|
||||
const a = new Date(startDate.getFullYear(), month, 1);
|
||||
const b = new Date(startDate.getFullYear(), month + 1, 1);
|
||||
bins.push({ start: +a, end: +b, label: a.toLocaleDateString(undefined, { month: "short" }).slice(0, 1) });
|
||||
}
|
||||
}
|
||||
const label = kind === "day" ? startDate.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
|
||||
: kind === "week" ? `${startDate.toLocaleDateString(undefined, { month: "short", day: "numeric" })} – ${new Date(+endDate - 1).toLocaleDateString(undefined, { month: "short", day: "numeric" })}`
|
||||
: kind === "month" ? startDate.toLocaleDateString(undefined, { month: "long", year: "numeric" })
|
||||
: String(startDate.getFullYear());
|
||||
return { start: +startDate, end: +endDate, label, bins };
|
||||
}
|
||||
|
||||
export function totals(records: TimeRecord[], window: Window, now = Date.now()) {
|
||||
const trend = window.bins.map(bin => records.reduce((sum, record) => sum + overlapMs(record, bin.start, bin.end, now), 0));
|
||||
const byTag = new Map<string, number>();
|
||||
let sessionCount = 0;
|
||||
for (const record of records) {
|
||||
const duration = overlapMs(record, window.start, window.end, now);
|
||||
if (duration <= 0) continue;
|
||||
sessionCount++;
|
||||
byTag.set(record.tag, (byTag.get(record.tag) || 0) + duration);
|
||||
}
|
||||
return {
|
||||
totalMs: [...byTag.values()].reduce((a, b) => a + b, 0),
|
||||
sessionCount,
|
||||
trend,
|
||||
tags: [...byTag].sort((a, b) => b[1] - a[1]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { TimeRecord } from "../shared/record.ts";
|
||||
import { normalizeTag } from "../shared/tag.ts";
|
||||
|
||||
|
||||
const FOREST_HEADERS = ["Start Time", "End Time", "Tag", "Note", "Tree Type", "Is Success"];
|
||||
|
||||
export function parseCsv(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
const input = text.replace(/^\uFEFF/, "");
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const char = input[i]!;
|
||||
if (quoted) {
|
||||
if (char === '"' && input[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (char === '"') quoted = false;
|
||||
else field += char;
|
||||
} else if (char === '"' && field === "") {
|
||||
quoted = true;
|
||||
} else if (char === ",") {
|
||||
row.push(field); field = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && input[i + 1] === "\n") i++;
|
||||
row.push(field);
|
||||
if (row.some(value => value !== "")) rows.push(row);
|
||||
row = []; field = "";
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
if (quoted) throw new Error("CSV has an unfinished quoted field");
|
||||
row.push(field);
|
||||
if (row.some(value => value !== "")) rows.push(row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseForestDate(value: string): number {
|
||||
const normalized = value.trim().replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
||||
const ms = Date.parse(normalized);
|
||||
if (!Number.isFinite(ms)) throw new Error(`Invalid Forest timestamp: ${value}`);
|
||||
return ms;
|
||||
}
|
||||
|
||||
function hashHex(bytes: ArrayBuffer) {
|
||||
return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function importForestCsv(text: string): Promise<TimeRecord[]> {
|
||||
const rows = parseCsv(text);
|
||||
if (!rows.length || FOREST_HEADERS.some((header, index) => rows[0]![index]?.trim() !== header)) {
|
||||
throw new Error("This is not a Forest CSV with the expected six columns");
|
||||
}
|
||||
const records: TimeRecord[] = [];
|
||||
for (const [index, row] of rows.slice(1).entries()) {
|
||||
if (row.length !== 6) throw new Error(`Row ${index + 2} has ${row.length} columns; expected 6`);
|
||||
const [rawStart, rawEnd, tag, note, treeType, rawSuccess] = row as [string, string, string, string, string, string];
|
||||
const startMs = parseForestDate(rawStart);
|
||||
const endMs = parseForestDate(rawEnd);
|
||||
if (endMs < startMs) throw new Error(`Row ${index + 2} ends before it starts`);
|
||||
if (rawSuccess !== "True" && rawSuccess !== "False") throw new Error(`Row ${index + 2} has an invalid success value`);
|
||||
const fingerprint = [rawStart, rawEnd, tag, note, treeType, rawSuccess].join("\u001f");
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(fingerprint));
|
||||
records.push({
|
||||
id: `forest_${hashHex(digest).slice(0, 32)}`,
|
||||
startMs, endMs, targetSeconds: null, tag: normalizeTag(tag) || "Unset", note,
|
||||
treeType, isSuccess: rawSuccess === "True", source: "forest",
|
||||
rawStart, rawEnd, deleted: false,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function forestDate(ms: number) {
|
||||
const date = new Date(ms);
|
||||
const pad = (n: number, width = 2) => String(n).padStart(width, "0");
|
||||
const offset = -date.getTimezoneOffset();
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const offsetText = `${sign}${pad(Math.floor(Math.abs(offset) / 60))}${pad(Math.abs(offset) % 60)}`;
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}${offsetText}`;
|
||||
}
|
||||
|
||||
function quote(value: string) {
|
||||
return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
||||
}
|
||||
|
||||
export function exportForestCsv(records: TimeRecord[]): string {
|
||||
const lines = [FOREST_HEADERS.join(",")];
|
||||
for (const record of records.filter(r => !r.deleted && r.endMs !== null).sort((a, b) => a.startMs - b.startMs)) {
|
||||
const values = [record.rawStart || forestDate(record.startMs), record.rawEnd || forestDate(record.endMs!),
|
||||
record.tag, record.note, record.treeType, record.isSuccess ? "True" : "False"];
|
||||
lines.push(values.map(quote).join(","));
|
||||
}
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="72" fill="#1e1e2e"/>
|
||||
<path d="M96 118h320v276H96z" fill="none" stroke="#cba6f7" stroke-width="18"/>
|
||||
<path d="M132 162h84m-84 38h52m-52 38h66" stroke="#a6adc8" stroke-width="14"/>
|
||||
<path d="M267 176h93m-46 0v160" stroke="#cdd6f4" stroke-width="21" stroke-linecap="square"/>
|
||||
<path d="M267 336h94" stroke="#cdd6f4" stroke-width="21" stroke-linecap="square"/>
|
||||
<path d="M132 350h74" stroke="#a6adc8" stroke-width="14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
@@ -0,0 +1,28 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1e1e2e" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="A quiet place to log your time." />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||||
<script>
|
||||
try {
|
||||
if (localStorage.getItem("timelog-theme") === "latte") {
|
||||
document.documentElement.dataset.theme = "latte";
|
||||
document.querySelector('meta[name="theme-color"]').setAttribute("content", "#e6e9ef");
|
||||
}
|
||||
} catch {}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/styles.css?v=__BUILD_ID__" />
|
||||
<title>Timelog</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/assets/app.js?v=__BUILD_ID__"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>`; });
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Timelog",
|
||||
"short_name": "Timelog",
|
||||
"description": "A quiet place to log your time.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#1e1e2e",
|
||||
"theme_color": "#1e1e2e",
|
||||
"icons": [
|
||||
{ "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { TimeRecord } from "../shared/record.ts";
|
||||
|
||||
type OutboxItem = { id: string; createdAt: number; record: TimeRecord };
|
||||
const DATABASE_NAME = "timelog-local-v1";
|
||||
|
||||
let opened: Promise<IDBDatabase> | null = null;
|
||||
function database(): Promise<IDBDatabase> {
|
||||
if (opened) return opened;
|
||||
opened = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
db.createObjectStore("records", { keyPath: "id" });
|
||||
db.createObjectStore("outbox", { keyPath: "id" });
|
||||
db.createObjectStore("meta", { keyPath: "key" });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return opened;
|
||||
}
|
||||
|
||||
function done(tx: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
function result<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function allRecords(): Promise<TimeRecord[]> {
|
||||
const db = await database();
|
||||
return result(db.transaction("records").objectStore("records").getAll()) as Promise<TimeRecord[]>;
|
||||
}
|
||||
|
||||
export function saveRecord(record: TimeRecord) { return saveRecords([record]); }
|
||||
|
||||
export async function saveRecords(records: TimeRecord[]) {
|
||||
const db = await database();
|
||||
for (let offset = 0; offset < records.length; offset += 200) {
|
||||
const tx = db.transaction(["records", "outbox"], "readwrite");
|
||||
for (const record of records.slice(offset, offset + 200)) {
|
||||
tx.objectStore("records").put(record);
|
||||
tx.objectStore("outbox").put({ id: crypto.randomUUID(), createdAt: Date.now() + offset, record });
|
||||
}
|
||||
await done(tx);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pendingOperations(): Promise<OutboxItem[]> {
|
||||
const db = await database();
|
||||
const items = await result(db.transaction("outbox").objectStore("outbox").getAll()) as OutboxItem[];
|
||||
return items.sort((a, b) => a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
export async function removeOperations(ids: string[]) {
|
||||
const db = await database();
|
||||
const tx = db.transaction("outbox", "readwrite");
|
||||
for (const id of ids) tx.objectStore("outbox").delete(id);
|
||||
await done(tx);
|
||||
}
|
||||
|
||||
export async function cursor(): Promise<number> {
|
||||
const db = await database();
|
||||
const value = await result(db.transaction("meta").objectStore("meta").get("cursor")) as { value?: number } | undefined;
|
||||
return value?.value || 0;
|
||||
}
|
||||
|
||||
export async function applyRemote(records: TimeRecord[], nextCursor: number) {
|
||||
const db = await database();
|
||||
const pending = await pendingOperations();
|
||||
const pendingIds = new Set(pending.map(item => item.record.id));
|
||||
const tx = db.transaction(["records", "meta"], "readwrite");
|
||||
for (const record of records) if (!pendingIds.has(record.id)) tx.objectStore("records").put(record);
|
||||
tx.objectStore("meta").put({ key: "cursor", value: nextCursor });
|
||||
await done(tx);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #1e1e2e;
|
||||
--panel: #181825;
|
||||
--panel-soft: #313244;
|
||||
--field-bg: #11111b;
|
||||
--ink: #cdd6f4;
|
||||
--muted: #bac2de;
|
||||
--faint: #a6adc8;
|
||||
--line: #6c7086;
|
||||
--line-bright: #7f849c;
|
||||
--accent: #cba6f7;
|
||||
--accent-ink: #11111b;
|
||||
--chart: #94e2d5;
|
||||
--good: #a6e3a1;
|
||||
--danger: #f38ba8;
|
||||
--scrim: #11111bcc;
|
||||
--shadow: #11111b99;
|
||||
--font: "SFMono-Regular", "Cascadia Code", "IBM Plex Mono", "DejaVu Sans Mono", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
:root[data-theme="latte"] {
|
||||
color-scheme: light;
|
||||
--bg: #e6e9ef;
|
||||
--panel: #eff1f5;
|
||||
--panel-soft: #dce0e8;
|
||||
--field-bg: #e6e9ef;
|
||||
--ink: #4c4f69;
|
||||
--muted: #5c5f77;
|
||||
--faint: #5c5f77;
|
||||
--line: #7c7f93;
|
||||
--line-bright: #6c6f85;
|
||||
--accent: #8839ef;
|
||||
--accent-ink: #eff1f5;
|
||||
--chart: #179299;
|
||||
--good: #40a02b;
|
||||
--danger: #d20f39;
|
||||
--scrim: #4c4f6999;
|
||||
--shadow: #4c4f6933;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--bg); }
|
||||
body { margin: 0; color: var(--ink); background: var(--bg); font-family: var(--font); font-size: 14px; line-height: 1.5; }
|
||||
button, input, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
button, input, select { -webkit-tap-highlight-color: transparent; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
::selection { background: var(--accent); color: var(--accent-ink); }
|
||||
|
||||
.shell { min-height: 100dvh; display: grid; grid-template-columns: 248px minmax(0, 1fr); }
|
||||
.sidebar { border-right: 1px solid var(--line); min-height: 100dvh; padding: 32px 22px; display: flex; flex-direction: column; position: sticky; top: 0; height: 100dvh; }
|
||||
.brand { font-size: 22px; font-weight: 700; letter-spacing: -.08em; line-height: 1; display: flex; align-items: center; gap: 9px; }
|
||||
.brand-mark { border: 1px solid var(--accent); padding: 3px 6px 4px; font-size: 15px; letter-spacing: -.1em; }
|
||||
.brand-name { letter-spacing: -.08em; }
|
||||
.sidebar .brand { margin-bottom: 42px; }
|
||||
.nav { display: grid; gap: 6px; }
|
||||
.nav-btn { width: 100%; text-align: left; padding: 12px 13px; color: var(--muted); background: none; border: 1px solid transparent; border-radius: 0; text-transform: uppercase; letter-spacing: .08em; font-size: 12px; }
|
||||
.nav-btn:hover { color: var(--ink); border-color: var(--line); }
|
||||
.nav-btn.active { color: var(--accent); border-color: var(--accent); background: var(--panel); }
|
||||
.nav-prefix { margin-right: 11px; color: var(--faint); }
|
||||
.nav-btn.active .nav-prefix { color: var(--accent); }
|
||||
.side-bottom { margin-top: auto; border-top: 1px solid var(--line); padding-top: 18px; font-size: 11px; color: var(--muted); }
|
||||
.side-bottom .status-row { display: flex; align-items: center; gap: 9px; }
|
||||
.status-dot { width: 6px; height: 6px; background: var(--good); display: inline-block; flex: 0 0 auto; }
|
||||
.status-dot.offline, .status-dot.pending, .status-dot.signed-out { background: var(--faint); }
|
||||
|
||||
.main { width: 100%; max-width: 1250px; margin: 0 auto; padding: 36px clamp(24px, 5vw, 76px) 90px; }
|
||||
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding-bottom: 24px; border-bottom: 1px solid var(--line); }
|
||||
.topbar-date { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .1em; }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 16px; }
|
||||
.topbar-status { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.icon-btn { border: 1px solid var(--line); background: transparent; color: var(--ink); height: 35px; min-width: 35px; padding: 0 9px; }
|
||||
.icon-btn:hover { border-color: var(--accent); }
|
||||
.page { padding-top: 34px; }
|
||||
.page-heading { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 26px; }
|
||||
.eyebrow { color: var(--muted); font-size: 11px; letter-spacing: .18em; text-transform: uppercase; margin: 0 0 10px; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 0; font-size: clamp(28px, 4vw, 44px); line-height: 1.1; font-weight: 500; letter-spacing: -.075em; }
|
||||
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .13em; margin: 0; }
|
||||
.heading-meta { color: var(--muted); font-size: 11px; white-space: nowrap; padding-bottom: 5px; }
|
||||
.grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 20px; }
|
||||
.panel { border: 1px solid var(--line); background: var(--panel); min-width: 0; }
|
||||
.panel-inner { padding: clamp(18px, 2.4vw, 31px); }
|
||||
.panel-label { display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; letter-spacing: .13em; text-transform: uppercase; gap: 12px; }
|
||||
.panel-label .end { color: var(--faint); }
|
||||
.dash { border-top: 1px dashed var(--line); margin: 24px 0; }
|
||||
.timer-display { font-size: clamp(64px, 9vw, 118px); font-weight: 500; line-height: 1; letter-spacing: -.11em; font-variant-numeric: tabular-nums; margin: 28px 0 14px -7px; white-space: nowrap; }
|
||||
.timer-display.idle { margin-top: 0; }
|
||||
.timer-display .seconds { color: var(--muted); }
|
||||
.timer-hint { color: var(--muted); font-size: 12px; min-height: 18px; }
|
||||
.progress-track { height: 5px; border: 1px solid var(--line-bright); margin: 25px 0 30px; }
|
||||
.progress-fill { height: 3px; background: var(--accent); width: 0; transition: width .4s linear; }
|
||||
.field-label { display: block; color: var(--muted); font-size: 11px; letter-spacing: .13em; text-transform: uppercase; margin-bottom: 9px; }
|
||||
.field { width: 100%; border: 1px solid var(--line-bright); background: var(--field-bg); color: var(--ink); border-radius: 0; height: 47px; padding: 0 13px; outline: none; }
|
||||
.field:focus { border-color: var(--accent); }
|
||||
.field::placeholder { color: var(--faint); }
|
||||
select.field { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 20px) 20px, calc(100% - 15px) 20px; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 35px; }
|
||||
.field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.duration-row { display: flex; gap: 7px; flex-wrap: wrap; margin-top: 9px; }
|
||||
.chip { border: 1px solid var(--line); background: transparent; color: var(--muted); min-height: 34px; padding: 5px 10px; font-size: 11px; }
|
||||
.chip:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.chip.active { border-color: var(--accent); color: var(--accent-ink); background: var(--accent); }
|
||||
.primary-btn { width: 100%; border: 1px solid var(--accent); background: var(--accent); color: var(--accent-ink); min-height: 54px; padding: 12px 20px; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-top: 27px; }
|
||||
.primary-btn:hover { background: transparent; color: var(--accent); }
|
||||
.primary-btn.outline { background: transparent; color: var(--accent); }
|
||||
.primary-btn.outline:hover { background: var(--accent); color: var(--accent-ink); }
|
||||
.text-btn { border: 0; background: none; color: var(--muted); padding: 6px 0; font-size: 11px; text-decoration: underline; text-underline-offset: 4px; }
|
||||
.text-btn:hover { color: var(--ink); }
|
||||
.side-stat { font-size: clamp(36px, 4vw, 56px); letter-spacing: -.09em; font-weight: 500; line-height: 1; margin: 25px 0 10px; }
|
||||
.muted { color: var(--muted); }
|
||||
.mini-bars { display: flex; align-items: end; gap: 5px; height: 72px; margin-top: 23px; border-bottom: 1px solid var(--line); }
|
||||
.mini-bar { flex: 1; min-width: 4px; background: var(--line-bright); height: 3px; }
|
||||
.mini-bar.current { background: var(--accent); }
|
||||
.section { margin-top: 31px; }
|
||||
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
|
||||
.section-head .text-btn { text-decoration: none; }
|
||||
.entry { display: grid; grid-template-columns: 130px minmax(0, 1fr) auto auto; align-items: center; gap: 16px; min-height: 60px; border-bottom: 1px solid var(--line); color: var(--ink); }
|
||||
.entry-time { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.entry-tag { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.entry-duration { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.entry-status { color: var(--faint); font-size: 10px; min-width: 14px; }
|
||||
.entry-btn { border: 0; background: transparent; color: var(--faint); font-size: 12px; min-width: 36px; min-height: 40px; padding: 9px; }
|
||||
.entry-btn:hover { color: var(--ink); }
|
||||
.empty { color: var(--muted); font-size: 12px; padding: 27px 0; }
|
||||
|
||||
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 17px; }
|
||||
.toolbar .field { width: auto; min-width: 180px; height: 40px; }
|
||||
.toolbar .spacer { flex: 1; }
|
||||
.small-btn { border: 1px solid var(--line-bright); background: transparent; color: var(--ink); min-height: 40px; padding: 8px 13px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.small-btn:hover { border-color: var(--accent); background: var(--panel-soft); }
|
||||
.log-group { margin-bottom: 28px; }
|
||||
.log-date { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .13em; display: flex; justify-content: space-between; padding: 11px 0; border-bottom: 1px dashed var(--line); }
|
||||
.log-date span:last-child { color: var(--ink); }
|
||||
.log-entry { grid-template-columns: 125px minmax(0, 1fr) 70px 28px; }
|
||||
.log-entry .entry-time { font-size: 11px; }
|
||||
.log-entry .entry-tag small { color: var(--faint); display: block; font-size: 10px; }
|
||||
.log-entry .entry-tag { line-height: 1.25; }
|
||||
.log-entry .entry-duration { text-align: right; }
|
||||
|
||||
.range-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--line-bright); }
|
||||
.segment { border: 0; border-right: 1px solid var(--line); background: transparent; color: var(--muted); padding: 9px 11px; font-size: 11px; text-transform: uppercase; }
|
||||
.segment:last-child { border-right: 0; }
|
||||
.segment.active { background: var(--accent); color: var(--accent-ink); }
|
||||
.range-label { color: var(--muted); font-size: 11px; min-width: 115px; text-align: center; }
|
||||
.insight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.insight-big { font-size: clamp(36px, 5vw, 60px); letter-spacing: -.09em; line-height: 1; margin: 24px 0 10px; }
|
||||
.trend-chart { height: 220px; display: flex; gap: 8px; align-items: stretch; margin-top: 28px; }
|
||||
.trend-col { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: end; align-items: center; gap: 7px; }
|
||||
.trend-value { color: var(--muted); font-size: 9px; white-space: nowrap; }
|
||||
.trend-track { height: 170px; width: 100%; display: flex; align-items: end; border-bottom: 1px solid var(--line-bright); }
|
||||
.trend-fill { width: 100%; min-height: 2px; background: var(--muted); }
|
||||
.trend-col.current .trend-fill { background: var(--accent); }
|
||||
.trend-label { color: var(--faint); font-size: 10px; overflow: hidden; white-space: nowrap; max-width: 100%; }
|
||||
.tag-row { margin-top: 19px; }
|
||||
.tag-row-head { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; margin-bottom: 6px; }
|
||||
.tag-row-head span:last-child { color: var(--muted); white-space: nowrap; }
|
||||
.tag-track { height: 7px; background: var(--line); }
|
||||
.tag-fill { height: 7px; background: var(--chart); }
|
||||
.insight-note { color: var(--faint); font-size: 10px; margin: 24px 0 0; }
|
||||
.settings-row { border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 22px 0; }
|
||||
.settings-row h3 { font-size: 12px; letter-spacing: .04em; margin: 0 0 4px; }
|
||||
.settings-row p { color: var(--muted); font-size: 11px; margin: 0; max-width: 520px; }
|
||||
.settings-row .small-btn { flex-shrink: 0; }
|
||||
.theme-toggle { display: inline-flex; border: 1px solid var(--line-bright); flex-shrink: 0; }
|
||||
.theme-option { min-width: 88px; min-height: 42px; border: 0; background: transparent; color: var(--ink); padding: 8px 14px; font-size: 11px; font-weight: 700; letter-spacing: .08em; }
|
||||
.theme-option + .theme-option { border-left: 1px solid var(--line-bright); }
|
||||
.theme-option:hover:not(.active) { background: var(--panel-soft); }
|
||||
.theme-option.active { background: var(--accent); color: var(--accent-ink); }
|
||||
.hidden-input { display: none; }
|
||||
.notice { border: 1px solid var(--line-bright); padding: 12px 14px; color: var(--muted); font-size: 11px; margin: 16px 0; }
|
||||
.notice.error { border-color: var(--danger); color: var(--danger); }
|
||||
.notice.success { border-color: var(--good); color: var(--ink); }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 20; background: var(--scrim); display: flex; align-items: center; justify-content: center; padding: 18px; }
|
||||
.modal { width: min(100%, 470px); max-height: min(90dvh, 720px); overflow: auto; border: 1px solid var(--line-bright); background: var(--panel); padding: 25px; box-shadow: 8px 8px 0 var(--shadow); }
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px; }
|
||||
.modal .field { margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; gap: 9px; margin-top: 12px; }
|
||||
.modal-actions .small-btn { flex: 1; }
|
||||
.modal-actions .danger { color: var(--danger); border-color: var(--danger); }
|
||||
.toast { position: fixed; right: 25px; bottom: 25px; z-index: 30; background: var(--accent); color: var(--accent-ink); padding: 13px 17px; font-size: 12px; max-width: min(380px, calc(100vw - 36px)); box-shadow: 5px 5px 0 var(--shadow); }
|
||||
.login { min-height: 100dvh; display: flex; justify-content: center; align-items: center; padding: 24px; }
|
||||
.login-card { border: 1px solid var(--line-bright); background: var(--panel); width: min(100%, 430px); padding: clamp(24px, 5vw, 42px); }
|
||||
.login-card h1 { margin: 35px 0 17px; }
|
||||
.login-card p { color: var(--muted); font-size: 12px; }
|
||||
.login-card .primary-btn { display: block; text-decoration: none; text-align: center; }
|
||||
.mobile-nav { display: none; }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar { display: none; }
|
||||
.main { padding: calc(28px + env(safe-area-inset-top, 0px)) calc(26px + env(safe-area-inset-right, 0px)) calc(104px + env(safe-area-inset-bottom, 0px)) calc(26px + env(safe-area-inset-left, 0px)); }
|
||||
.topbar .icon-btn { min-width: 48px; height: 48px; }
|
||||
.grid, .insight-grid { grid-template-columns: 1fr; }
|
||||
.mobile-nav { display: grid; grid-template-columns: repeat(3, 1fr); position: fixed; z-index: 10; left: 0; right: 0; bottom: 0; background: var(--bg); border-top: 1px solid var(--line-bright); padding: 7px 8px calc(7px + env(safe-area-inset-bottom)); }
|
||||
.mobile-nav .nav-btn { text-align: center; padding: 10px 3px; min-height: 43px; font-size: 10px; }
|
||||
.mobile-nav .nav-prefix { display: none; }
|
||||
.toast { bottom: calc(75px + env(safe-area-inset-bottom)); right: 18px; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.main { padding: calc(24px + env(safe-area-inset-top, 0px)) calc(20px + env(safe-area-inset-right, 0px)) calc(100px + env(safe-area-inset-bottom, 0px)) calc(20px + env(safe-area-inset-left, 0px)); }
|
||||
.topbar { padding-bottom: 17px; }
|
||||
.topbar-date { font-size: 10px; }
|
||||
.topbar-status { display: none; }
|
||||
.page { padding-top: 27px; }
|
||||
.page-heading { margin-bottom: 22px; }
|
||||
.heading-meta { display: none; }
|
||||
.panel-inner { padding: 19px; }
|
||||
.timer-display { font-size: clamp(58px, 14vw, 84px); margin-left: -4px; }
|
||||
.field-grid { grid-template-columns: 1fr; gap: 17px; }
|
||||
.entry { grid-template-columns: 102px minmax(0, 1fr) auto auto; gap: 7px; font-size: 11px; }
|
||||
.entry-time { font-size: 10px; }
|
||||
.log-entry { grid-template-columns: 102px minmax(0, 1fr) 50px 36px; }
|
||||
.settings-row { align-items: start; flex-direction: column; gap: 10px; }
|
||||
.theme-toggle { width: 100%; }
|
||||
.theme-option { flex: 1; }
|
||||
.trend-chart { gap: 4px; }
|
||||
.trend-value { font-size: 8px; }
|
||||
.trend-label { font-size: 9px; }
|
||||
.range-controls { display: grid; grid-template-columns: 40px minmax(0, 1fr) 40px; gap: 10px; }
|
||||
.range-controls .segmented { grid-column: 1 / -1; display: flex; }
|
||||
.range-controls .segment { flex: 1; }
|
||||
.range-controls .spacer { display: none; }
|
||||
.range-controls .range-label { min-width: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
||||
@@ -0,0 +1,42 @@
|
||||
const CACHE = "timelog-__BUILD_ID__";
|
||||
const ASSETS = ["/", "/index.html", "/styles.css?v=__BUILD_ID__", "/assets/app.js?v=__BUILD_ID__", "/manifest.webmanifest", "/icon.svg", "/icon-192.png", "/icon-512.png", "/icon-180.png"];
|
||||
|
||||
self.addEventListener("install", event => {
|
||||
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(ASSETS)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", event => {
|
||||
event.waitUntil((async () => {
|
||||
for (const name of await caches.keys()) if (name !== CACHE) await caches.delete(name);
|
||||
await self.clients.claim();
|
||||
})());
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", event => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin || url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return;
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(fetch(request).catch(async () => (await caches.match("/index.html")) || Response.error()));
|
||||
return;
|
||||
}
|
||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||
});
|
||||
|
||||
self.addEventListener("push", event => {
|
||||
let data = { title: "Session complete", body: "Your time is up.", tag: "timelog", url: "/" };
|
||||
try { data = { ...data, ...event.data.json() }; } catch {}
|
||||
event.waitUntil(self.registration.showNotification(data.title, {
|
||||
body: data.body, tag: data.tag, icon: "/icon-192.png", badge: "/icon-192.png", data: { url: data.url },
|
||||
}));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
event.waitUntil((async () => {
|
||||
const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
const existing = clients.find(client => new URL(client.url).pathname === "/");
|
||||
if (existing) return existing.focus();
|
||||
return self.clients.openWindow(event.notification.data?.url || "/");
|
||||
})());
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { TimeRecord } from "../shared/record.ts";
|
||||
import { allRecords, applyRemote, cursor, pendingOperations, removeOperations } from "./store.ts";
|
||||
|
||||
export type SyncStatus = "offline" | "syncing" | "synced" | "pending" | "signed-out";
|
||||
let current: Promise<void> | null = null;
|
||||
let rerunRequested = false;
|
||||
|
||||
class SignedOut extends Error {}
|
||||
|
||||
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) throw new SignedOut();
|
||||
if (!response.ok) throw new Error(`Sync failed (${response.status})`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function syncNow(onChange: (status: SyncStatus, records?: TimeRecord[]) => void): Promise<void> {
|
||||
if (current) {
|
||||
rerunRequested = true;
|
||||
return current;
|
||||
}
|
||||
current = (async () => {
|
||||
if (!navigator.onLine) { onChange("offline"); return; }
|
||||
onChange("syncing");
|
||||
try {
|
||||
let pending = await pendingOperations();
|
||||
while (pending.length) {
|
||||
const batch = pending.slice(0, 200);
|
||||
const body = await api<{ accepted: string[] }>("/api/operations", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ operations: batch.map(({ id, record }) => ({ id, record })) }),
|
||||
});
|
||||
await removeOperations(body.accepted);
|
||||
pending = await pendingOperations();
|
||||
}
|
||||
let since = await cursor();
|
||||
for (;;) {
|
||||
const body = await api<{ records: TimeRecord[]; cursor: number; hasMore: boolean }>(
|
||||
`/api/state?since=${since}`, { cache: "no-store" });
|
||||
await applyRemote(body.records, body.cursor);
|
||||
since = body.cursor;
|
||||
if (!body.hasMore) break;
|
||||
}
|
||||
onChange("synced", await allRecords());
|
||||
} catch (error) {
|
||||
if (error instanceof SignedOut) { onChange("signed-out"); return; }
|
||||
console.error(error);
|
||||
onChange("pending");
|
||||
}
|
||||
})().finally(() => {
|
||||
current = null;
|
||||
if (rerunRequested) {
|
||||
rerunRequested = false;
|
||||
void syncNow(onChange);
|
||||
}
|
||||
});
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dueMs, type TimeRecord } from "../shared/record.ts";
|
||||
|
||||
const APP_NAME = "Timelog";
|
||||
|
||||
export function pad(value: number) { return String(value).padStart(2, "0"); }
|
||||
|
||||
export function countdown(ms: number) {
|
||||
const total = Math.max(0, Math.ceil(ms / 1000));
|
||||
return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
|
||||
}
|
||||
|
||||
/** The browser tab title: the remaining time and tag while a session runs. */
|
||||
export function pageTitle(active: Pick<TimeRecord, "startMs" | "targetSeconds" | "tag"> | null, now = Date.now()) {
|
||||
if (!active) return APP_NAME;
|
||||
return `${countdown(dueMs(active) - now)} · ${active.tag} — ${APP_NAME}`;
|
||||
}
|
||||
Reference in new issue
Block a user