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:
minhtrannhatandClaude Opus 5.5 committed 2026-11-05 00:42:58 -05:00
commit a6ca255835
38 files changed
+2069

No files matched your search

+78
View File
@@ -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]),
};
}
+95
View File
@@ -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

+8
View File
@@ -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

+28
View File
@@ -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>
+502
View File
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]!);
}
function clock(ms: number) { const d = new Date(ms); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
function dateLabel(ms: number) { return new Date(ms).toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); }
function shortDate(ms: number) { return new Date(ms).toLocaleDateString(undefined, { month: "short", day: "numeric" }); }
function duration(ms: number) {
if (ms <= 0) return "0m";
const minutes = Math.round(ms / 60000);
if (minutes <= 0) return "<1m";
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return hours ? `${hours}h${rest ? ` ${rest}m` : ""}` : `${rest}m`;
}
function datetimeLocal(ms: number) {
const d = new Date(ms);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function tags() {
return [...new Set([selectedTag, "Study", ...records.filter(r => !r.deleted).map(r => r.tag)])].sort((a, b) => a.localeCompare(b));
}
function activeRecord() { return records.find(r => !r.deleted && r.endMs === null) || null; }
function progress(r: TimeRecord) { return Math.min(100, Math.max(0, (Date.now() - r.startMs) / (dueMs(r) - r.startMs) * 100)); }
function statusText() {
return ({ offline: "OFFLINE · SAVED HERE", syncing: "SYNCING...", synced: "ALL SYNCED", pending: "SYNC PENDING", "signed-out": "SIGN IN TO SYNC" } as const)[syncStatus];
}
function markSignedOut() {
me = { ...(me || { dev: false, githubConfigured: true, vapidPublicKey: null }), authenticated: false };
localStorage.removeItem("timelog-auth-seen");
syncStatus = "signed-out";
}
function setTheme(next: Theme) {
theme = next;
document.documentElement.dataset.theme = next;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "latte" ? "#e6e9ef" : "#1e1e2e");
localStorage.setItem("timelog-theme", next);
render();
}
function showMessage(text: string, kind: "success" | "error" = "success") {
message = { text, kind };
document.querySelector(".toast")?.remove();
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
toast.textContent = text;
app.appendChild(toast);
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => { message = null; document.querySelector(".toast")?.remove(); }, 5000);
}
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">&gt; SIGN IN WITH GITHUB</a><p style="margin-top:22px">Private to your account. No repository access.</p>
</div></div>`;
}
function pageHeading(title: string, meta = "", eyebrow = "") {
return `<div class="page-heading"><div>${eyebrow ? `<p class="eyebrow">${eyebrow}</p>` : ""}<h1>${title}</h1></div>${meta ? `<span class="heading-meta">${meta}</span>` : ""}</div>`;
}
function timerPage() {
const active = activeRecord();
const todayWindow = rangeWindow("day");
const today = totals(records, todayWindow);
const completed = records.filter(r => !r.deleted && r.endMs !== null && overlapMs(r, todayWindow.start, todayWindow.end) > 0);
const completedToday = completed.length;
const week = totals(records, rangeWindow("week"));
const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === selectedTag ? "selected" : ""}>${h(tag)}</option>`).join("");
const latest = completed.sort((a, b) => b.startMs - a.startMs).slice(0, 6);
const weekMax = Math.max(...week.trend, 1);
return `<section class="page">
<div class="grid">
<div class="panel"><div class="panel-inner">
${active ? `<div class="panel-label"><span>● SESSION IN PROGRESS</span><span class="end">${h(active.tag.toUpperCase())}</span></div>` : ""}
<div class="timer-display ${active ? "" : "idle"}" id="timer-display">${active ? countdown(dueMs(active) - Date.now()) : `${pad(selectedDuration)}<span class="seconds">:00</span>`}</div>
${active ? `<div class="timer-hint" id="timer-hint">Started ${clock(active.startMs)} · Ends ${clock(dueMs(active))}</div>` : ""}
<div class="progress-track"><div class="progress-fill" id="progress-fill" style="width:${active ? progress(active) : 0}%"></div></div>
${active ? `<div class="field-grid"><div><span class="field-label">TAG</span><div class="field" style="display:flex;align-items:center">${h(active.tag)}</div></div><div><span class="field-label">DURATION</span><div class="field" style="display:flex;align-items:center">${duration(active.targetSeconds! * 1000)}</div></div></div>
<button class="primary-btn outline" data-action="stop" type="button">■ STOP EARLY &amp; LOG TIME</button>` : `
<div class="field-grid"><div><label class="field-label" for="timer-tag">TAG</label><select class="field" id="timer-tag">${tagOptions}<option value="__new__">+ New tag...</option></select></div>
<div><label class="field-label" for="timer-duration">DURATION / MIN</label><input class="field" id="timer-duration" type="number" min="1" max="180" inputmode="numeric" value="${selectedDuration}" /></div></div>
<div class="duration-row">${[15, 25, 30, 45, 60, 120].map(n => `<button class="chip ${n === selectedDuration ? "active" : ""}" data-action="duration" data-minutes="${n}" type="button">${n}m</button>`).join("")}</div>
<button class="primary-btn" data-action="start" type="button">&gt; START SESSION</button>`}
</div></div>
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TODAY</span><span class="end">${h(shortDate(todayWindow.start).toUpperCase())}</span></div>
<div class="side-stat" id="today-total">${duration(today.totalMs)}</div><div class="muted" style="font-size:12px">${completedToday} ${completedToday === 1 ? "session" : "sessions"} logged${active ? " · 1 running" : ""}</div>
<div class="dash"></div><div class="panel-label"><span>THIS WEEK</span><span class="end">${duration(week.totalMs)}</span></div>
<div class="mini-bars">${week.trend.map((value, i) => `<div class="mini-bar ${i === ((new Date().getDay() + 6) % 7) ? "current" : ""}" title="${duration(value)}" style="height:${Math.max(3, value / weekMax * 100)}%"></div>`).join("")}</div>
<div class="muted" style="display:flex;justify-content:space-between;font-size:10px;margin-top:8px"><span>MON</span><span>SUN</span></div>
</div></div>
</div>
<section class="section"><div class="section-head"><h2>Today's log</h2><button class="text-btn" data-action="nav" data-view="log" type="button">VIEW ALL →</button></div>
${latest.length ? latest.map(record => entryRow(record)).join("") : `<div class="empty">No time logged today. Start when you're ready.</div>`}
</section>
</section>`;
}
function entryRow(record: TimeRecord, editable = false) {
const end = recordEnd(record);
return `<div class="entry ${editable ? "log-entry" : ""}"><span class="entry-time">${clock(record.startMs)}–${record.endMs === null ? "NOW" : clock(end)}</span>
<span class="entry-tag">${h(record.tag)}${editable && record.note ? `<small>${h(record.note)}</small>` : ""}</span>
<span class="entry-duration">${duration(end - record.startMs)}</span>
${editable ? `<button class="entry-btn" data-action="edit" data-id="${h(record.id)}" aria-label="Edit ${h(record.tag)} session" type="button">✎</button>` : `<span class="entry-status" title="${record.isSuccess === false ? "Stopped early or unsuccessful" : "Complete"}">${record.isSuccess === false ? "○" : "·"}</span>`}
</div>`;
}
function monthDate(offset: number) { const now = new Date(); return new Date(now.getFullYear(), now.getMonth() + offset, 1); }
function logPage() {
const month = monthDate(logMonthOffset);
const nextMonth = new Date(month.getFullYear(), month.getMonth() + 1, 1);
const filtered = records.filter(r => !r.deleted && r.startMs >= +month && r.startMs < +nextMonth && (logTag === "all" || r.tag === logTag))
.sort((a, b) => b.startMs - a.startMs);
const groups = new Map<string, TimeRecord[]>();
for (const record of filtered) {
const key = new Date(record.startMs).toDateString();
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(record);
}
const tagOptions = tags().map(tag => `<option value="${h(tag)}" ${tag === logTag ? "selected" : ""}>${h(tag)}</option>`).join("");
return `<section class="page">${pageHeading("Your time, in order.", `${filtered.length} ENTRIES`)}
<div class="toolbar"><button class="small-btn" data-action="prev-log" type="button" aria-label="Previous month">←</button>
<span class="range-label">${h(month.toLocaleDateString(undefined, { month: "long", year: "numeric" }).toUpperCase())}</span>
<button class="small-btn" data-action="next-log" type="button" aria-label="Next month">→</button>
<select class="field" id="log-tag" aria-label="Filter by tag"><option value="all">ALL TAGS</option>${tagOptions}</select>
<span class="spacer"></span><button class="small-btn" data-action="add" type="button">+ ADD ENTRY</button></div>
${groups.size ? [...groups].map(([label, entries]) => `<div class="log-group"><div class="log-date"><span>${h(label.toUpperCase())}</span><span>${duration(entries.reduce((sum, record) => sum + Math.max(0, recordEnd(record) - record.startMs), 0))}</span></div>${entries.map(record => entryRow(record, true)).join("")}</div>`).join("") : `<div class="empty">No entries for this month${logTag !== "all" ? " and tag" : ""}.</div>`}
</section>`;
}
function insightsPage() {
const range = rangeWindow(insightKind, insightOffset);
const stats = totals(records, range);
const max = Math.max(...stats.trend, 1);
const maxTag = stats.tags[0]?.[1] || 1;
return `<section class="page">${pageHeading("Where time goes.")}
<div class="toolbar range-controls"><div class="segmented" role="group" aria-label="Insight range">${(["day", "week", "month", "year"] as RangeKind[]).map(kind => `<button class="segment ${insightKind === kind ? "active" : ""}" data-action="range" data-range="${kind}" type="button">${kind}</button>`).join("")}</div>
<span class="spacer"></span><button class="small-btn" data-action="prev-insight" type="button" aria-label="Previous period">←</button>
<span class="range-label">${h(range.label.toUpperCase())}</span><button class="small-btn" data-action="next-insight" type="button" aria-label="Next period">→</button></div>
<div class="insight-grid"><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// TIME TREND</span><span class="end">${h(insightKind.toUpperCase())}</span></div>
<div class="insight-big">${duration(stats.totalMs)}</div><div class="muted" style="font-size:11px">${stats.sessionCount} ${stats.sessionCount === 1 ? "session" : "sessions"} in this period</div>
<div class="trend-chart" role="img" aria-label="Logged time trend for ${h(range.label)}">${range.bins.map((bin, index) => {
const value = stats.trend[index]!;
const isCurrent = Date.now() >= bin.start && Date.now() < bin.end;
return `<div class="trend-col ${isCurrent ? "current" : ""}" title="${h(bin.label)}: ${duration(value)}"><span class="trend-value">${value ? duration(value) : ""}</span><div class="trend-track"><div class="trend-fill" style="height:${Math.max(2, value / max * 100)}%"></div></div><span class="trend-label">${h(bin.label)}</span></div>`;
}).join("")}</div>
</div></div><div class="panel"><div class="panel-inner"><div class="panel-label"><span>// BY TAG</span><span class="end">${stats.tags.length} CATEGORIES</span></div>
${stats.tags.length ? stats.tags.map(([tag, value]) => `<div class="tag-row"><div class="tag-row-head"><span>${h(tag)}</span><span>${duration(value)} · ${Math.round(value / stats.totalMs * 100)}%</span></div><div class="tag-track"><div class="tag-fill" style="width:${Math.max(1, value / maxTag * 100)}%"></div></div></div>`).join("") : `<div class="empty">No time logged in this period.</div>`}
<p class="insight-note">Stopped-early sessions count for the time actually spent. Sessions crossing midnight are split between days.</p>
</div></div></div>
</section>`;
}
function settingsPage() {
const canNotify = "Notification" in window && "serviceWorker" in navigator && "PushManager" in window;
const permission = "Notification" in window ? Notification.permission : "unsupported";
return `<section class="page">${pageHeading("Preferences.", "KEEP IT SIMPLE", "// SYSTEM")}
<div class="panel"><div class="panel-inner"><div class="panel-label"><span>// DATA &amp; DEVICE</span><span class="end">PRIVATE</span></div>
<div class="settings-row"><div><h3>Appearance</h3><p>Catppuccin colors. Saved on this device.</p></div><div class="theme-toggle" role="group" aria-label="Color theme"><button class="theme-option ${theme === "latte" ? "active" : ""}" data-action="theme" data-theme="latte" aria-pressed="${theme === "latte"}" type="button">LATTE</button><button class="theme-option ${theme === "mocha" ? "active" : ""}" data-action="theme" data-theme="mocha" aria-pressed="${theme === "mocha"}" type="button">MOCHA</button></div></div>
<div class="settings-row"><div><h3>Forest CSV import</h3><p>Bring in your existing sessions. Re-importing the same file skips duplicates.</p></div><button class="small-btn" data-action="import" type="button">IMPORT CSV</button><input class="hidden-input" id="csv-file" type="file" accept=".csv,text/csv" /></div>
<div class="settings-row"><div><h3>Forest CSV export</h3><p>Download your completed sessions with Forest's six-column layout.</p></div><button class="small-btn" data-action="export" type="button">EXPORT CSV</button></div>
<div class="settings-row"><div><h3>Completion notifications</h3><p>${permission === "denied" ? "Blocked in your device settings." : !canNotify ? "On iPhone, add Timelog to your Home Screen, then open it there to enable alerts." : permission === "granted" ? "Permission granted on this device. Reconnect if alerts stop arriving." : "Get an alert when a session reaches its duration. On iPhone, open Timelog from your Home Screen first."}</p></div><button class="small-btn" data-action="notify" type="button" ${!canNotify || !pushRegistration || permission === "denied" ? "disabled" : ""}>${permission === "granted" ? "RECONNECT" : "ENABLE"}</button></div>
<div class="settings-row"><div><h3>Sync</h3><p>${statusText()}. Sessions remain here until the server can receive them.</p></div><button class="small-btn" data-action="sync" type="button">SYNC NOW</button></div>
<div class="settings-row"><div><h3>Account</h3><p>${me?.dev ? "Local development mode" : "Signed in with your GitHub account"}</p></div><button class="small-btn" data-action="logout" type="button">SIGN OUT</button></div>
</div></div>
</section>`;
}
function editorModal() {
const existing = records.find(r => r.id === editingId);
const start = existing?.startMs || Date.now() - 25 * 60000;
const end = existing?.endMs || Date.now();
return `<div class="modal-backdrop"><div class="modal" role="dialog" aria-modal="true" aria-label="${existing ? "Edit" : "Add"} log entry">
<div class="modal-head"><h2>${existing ? "EDIT ENTRY" : "ADD ENTRY"}</h2><button class="icon-btn" data-action="close-modal" aria-label="Close" type="button">×</button></div>
<label class="field-label" for="edit-tag">TAG</label><input class="field" id="edit-tag" maxlength="100" value="${h(existing?.tag || selectedTag)}" list="known-tags" />
<datalist id="known-tags">${tags().map(tag => `<option value="${h(tag)}"></option>`).join("")}</datalist>
<label class="field-label" for="edit-start">START</label><input class="field" id="edit-start" type="datetime-local" step="1" value="${datetimeLocal(start)}" />
<label class="field-label" for="edit-end">END</label><input class="field" id="edit-end" type="datetime-local" step="1" value="${datetimeLocal(end)}" />
<label class="field-label" for="edit-note">NOTE / OPTIONAL</label><input class="field" id="edit-note" maxlength="2000" value="${h(existing?.note || "")}" />
<div class="modal-actions"><button class="small-btn" data-action="save-edit" type="button">SAVE ENTRY</button>${existing ? `<button class="small-btn danger" data-action="delete-edit" type="button">DELETE</button>` : ""}</div>
</div></div>`;
}
function render() {
if (!me) {
app.innerHTML = `<div class="login"><div class="login-card"><div class="brand"><span class="brand-mark">TL</span><span class="brand-name">timelog</span></div><p style="margin-top:32px">Opening your log...</p></div></div>`;
return;
}
if (me && !me.authenticated && !(localStorage.getItem("timelog-auth-seen") && !navigator.onLine)) {
app.innerHTML = loginPage(); return;
}
updateTitle();
const content = view === "timer" ? timerPage() : view === "log" ? logPage() : view === "insights" ? insightsPage() : settingsPage();
app.innerHTML = frame(content);
}
function updateTitle() {
const next = pageTitle(activeRecord());
if (document.title !== next) document.title = next;
}
function refreshFromStore() { return allRecords().then(all => { records = all; render(); }); }
function sync() {
void syncNow((status, updated) => {
syncStatus = status;
if (updated) records = updated;
if (status === "signed-out") markSignedOut();
render();
});
}
async function notifyFinished(record: TimeRecord) {
if (!("Notification" in window) || Notification.permission !== "granted" || !("serviceWorker" in navigator)) return;
try {
const registration = pushRegistration || await navigator.serviceWorker.ready;
// The server already pushes completed sessions it knows about. Showing a
// local notification too produces duplicate alerts on the same device.
if (record.revision && await registration.pushManager?.getSubscription()) return;
await registration.showNotification("Session complete", {
body: `${record.tag} time is up.`, tag: `timelog-${record.id}`, icon: "/icon-192.png",
});
} catch (error) { console.warn("Local notification failed", error); }
}
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>`; });
+16
View File
@@ -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" }
]
}
+84
View File
@@ -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);
}
+229
View File
@@ -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; } }
+42
View File
@@ -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 || "/");
})());
});
+58
View File
@@ -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;
}
+16
View File
@@ -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}`;
}