Skip to content

Instantly share code, notes, and snippets.

@derlin
Last active October 19, 2026 05:07
Show Gist options
  • Select an option

  • Save derlin/f4366018d4281a1e636ca71e8b2b7dc8 to your computer and use it in GitHub Desktop.

Select an option

Save derlin/f4366018d4281a1e636ca71e8b2b7dc8 to your computer and use it in GitHub Desktop.
Simple Time Tracker (made to show in Vivaldi panel)

Simple Time Tracker

A basic HTML page that you can open directly. I shows one timer per day with a circle that goes full at 8:24 (assuming weekdays of 42 hours). Data are stored in local storage. Edit, reset and history of at least 7 days.

The goal is to track how much you work in a day, to later enter it in a real time management system.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Simple Time Tracker</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='14' fill='%2322b573'/><path d='M16 8v8h6' stroke='white' stroke-width='3' fill='none' stroke-linecap='round'/></svg>">
<style>
:root {
--bg: #f6f7f6;
--surface: #ffffff;
--fg: #1d2420;
--muted: #7a847e;
--line: #dde2df;
--accent: #22b573;
--accent-dark: #1a8f5a;
--stop: #e5533d;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1c1f1d;
--surface: #262a27;
--fg: #e8ece9;
--muted: #8d9791;
--line: #333935;
}
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
user-select: none;
}
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.nav {
width: 100%;
max-width: 360px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav button {
width: 40px; height: 40px;
border-radius: 50%;
font-size: 20px;
}
.nav button:hover:not(:disabled) { background: var(--line); }
.nav button:disabled { opacity: 0.25; cursor: default; }
.day { text-align: center; }
.day .label { font-weight: 600; }
.day .date { color: var(--muted); font-size: 13px; }
.circle {
position: relative;
margin: 28px 0 20px;
width: min(72vw, 260px);
aspect-ratio: 1;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
transition: background 0.15s;
}
.circle:hover:not(:disabled) { background: color-mix(in srgb, var(--accent) 8%, transparent); }
.circle.running { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.circle.running:hover { background: color-mix(in srgb, var(--accent) 26%, transparent); }
.circle:disabled { cursor: default; }
.ring {
position: absolute;
inset: 0;
transform: rotate(-90deg);
pointer-events: none;
}
.ring circle { fill: none; stroke-width: 6; }
.ring .track { stroke: var(--line); }
.ring .progress {
stroke: var(--accent);
stroke-linecap: round;
opacity: 0.55;
transition: stroke-dasharray 0.5s;
}
.circle.running .ring .progress { opacity: 1; }
.circle .time {
font-size: min(11vw, 40px);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.circle .hint { font-size: 13px; opacity: 0.75; }
.actions { display: flex; gap: 8px; }
.actions button, dialog button {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--line);
font-size: 14px;
}
.actions button:hover, dialog button:hover { background: var(--line); }
dialog {
width: min(90vw, 300px);
padding: 20px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--surface);
color: var(--fg);
}
dialog::backdrop { background: rgb(0 0 0 / 0.4); }
dialog p { margin: 0 0 16px; }
dialog input {
width: 100%;
font: inherit;
font-size: 24px;
font-variant-numeric: tabular-nums;
text-align: center;
color: inherit;
background: transparent;
border: 1px solid var(--line);
border-radius: 6px;
padding: 6px;
}
dialog .error { color: var(--stop); font-size: 13px; min-height: 1.4em; margin: 6px 0 0; }
dialog .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
dialog .primary { background: var(--accent); border-color: var(--accent); color: white; }
dialog .primary:hover { background: var(--accent-dark); }
dialog .danger { background: var(--stop); border-color: var(--stop); color: white; }
dialog .danger:hover { background: var(--stop); filter: brightness(0.9); }
</style>
</head>
<body>
<div class="nav">
<button id="prev" title="Previous day">&larr;</button>
<div class="day">
<div class="label" id="label"></div>
<div class="date" id="date"></div>
</div>
<button id="next" title="Next day">&rarr;</button>
</div>
<button class="circle" id="circle">
<svg class="ring" viewBox="0 0 100 100">
<circle class="track" cx="50" cy="50" r="46.5"/>
<circle class="progress" id="progress" cx="50" cy="50" r="46.5" pathLength="100" stroke-dasharray="0 100"/>
</svg>
<span class="time" id="time">0:00:00</span>
<span class="hint" id="hint"></span>
</button>
<div class="actions">
<button id="edit">Edit</button>
<button id="reset">Reset</button>
</div>
<dialog id="edit-dialog">
<form method="dialog">
<p>Set the time for <span class="day-name"></span></p>
<input id="edit-input" placeholder="h:mm" autocomplete="off">
<p class="error" id="edit-error"></p>
<div class="buttons">
<button type="button" id="edit-cancel">Cancel</button>
<button class="primary">Save</button>
</div>
</form>
</dialog>
<dialog id="reset-dialog">
<form method="dialog">
<p>Reset <span class="day-name"></span> to 0:00?</p>
<div class="buttons">
<button value="cancel">Cancel</button>
<button value="reset" class="danger">Reset</button>
</div>
</form>
</dialog>
<script>
const STORAGE_KEY = "simple-time-tracker";
const KEEP_DAYS = 7;
const DAY_TARGET = (8 * 60 + 24) * 60000; // 42h over 5 days
// totals: accumulated ms per local day ("YYYY-MM-DD").
// running: start of the running timer in epoch ms, or null.
let state = load();
let offset = 0; // days back from today
function load() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || { totals: {}, running: null };
} catch {
return { totals: {}, running: null };
}
}
function save() {
const oldest = dayKey(dayStart(-(KEEP_DAYS - 1)));
for (const k of Object.keys(state.totals)) {
if (k < oldest) delete state.totals[k];
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
function dayStart(off, from = Date.now()) {
const d = new Date(from);
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + off);
return d.getTime();
}
function dayKey(ms) {
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function add(ms, amount) {
const k = dayKey(ms);
state.totals[k] = (state.totals[k] || 0) + amount;
}
// A timer running past midnight: book the elapsed part on its own day.
function settle() {
let changed = false;
while (state.running !== null && dayStart(1, state.running) <= Date.now()) {
const midnight = dayStart(1, state.running);
add(state.running, midnight - state.running);
state.running = midnight;
changed = true;
}
if (changed) save();
}
function total(off) {
const start = dayStart(off);
const live = off === 0 && state.running !== null ? Date.now() - state.running : 0;
return (state.totals[dayKey(start)] || 0) + live;
}
function toggle() {
settle();
if (state.running === null) {
state.running = Date.now();
} else {
add(state.running, Date.now() - state.running);
state.running = null;
}
save();
render();
}
// Sets the day total. A running timer keeps running from the new value.
function setTotal(off, ms) {
settle();
if (off === 0 && state.running !== null) state.running = Date.now();
state.totals[dayKey(dayStart(off))] = ms;
save();
render();
}
function fmtDuration(ms, seconds = true) {
const t = Math.floor(ms / 1000);
const h = Math.floor(t / 3600);
const m = String(Math.floor(t / 60) % 60).padStart(2, "0");
const s = String(t % 60).padStart(2, "0");
return seconds ? `${h}:${m}:${s}` : `${h}:${m}`;
}
// Accepts "7:30", "7h30", "7.5" or "7".
function parseDuration(text) {
text = text.trim().replace(",", ".");
let m = text.match(/^(\d{1,2})\s*[:h]\s*(\d{1,2})?$/i);
if (m) {
const min = Number(m[2] || 0);
return min < 60 ? (Number(m[1]) * 60 + min) * 60000 : null;
}
m = text.match(/^\d{1,2}(\.\d+)?$/);
return m ? Math.round(Number(text) * 60) * 60000 : null;
}
function dayLabel(off) {
if (off === 0) return "Today";
if (off === -1) return "Yesterday";
return new Date(dayStart(off)).toLocaleDateString([], { weekday: "long" });
}
function render() {
settle();
const isRunning = state.running !== null;
const isToday = offset === 0;
document.getElementById("label").textContent = dayLabel(offset);
document.getElementById("date").textContent =
new Date(dayStart(offset)).toLocaleDateString([], { day: "numeric", month: "short", year: "numeric" });
document.getElementById("prev").disabled = offset <= -(KEEP_DAYS - 1);
document.getElementById("next").disabled = isToday;
const circle = document.getElementById("circle");
circle.disabled = !isToday;
circle.classList.toggle("running", isToday && isRunning);
const t = total(offset);
const pct = Math.min(t / DAY_TARGET, 1) * 100;
// A round linecap draws a dot at 0, so hide the arc when empty.
document.getElementById("progress").setAttribute("stroke-dasharray", `${pct} 100`);
document.getElementById("progress").style.visibility = pct > 0 ? "visible" : "hidden";
document.getElementById("time").textContent = fmtDuration(t);
document.getElementById("hint").textContent = t < DAY_TARGET
? `${fmtDuration(DAY_TARGET - t, false)} left`
: `+${fmtDuration(t - DAY_TARGET, false)}`;
circle.title = !isToday ? "" : isRunning ? "Click to stop" : "Click to start";
document.title = isRunning ? `▶ ${fmtDuration(total(0))}` : "Simple Time Tracker";
}
function go(delta) {
const target = offset + delta;
if (target < -(KEEP_DAYS - 1) || target > 0) return;
offset = target;
render();
}
function openDialog(dialog) {
for (const el of dialog.querySelectorAll(".day-name")) el.textContent = dayLabel(offset).toLowerCase();
dialog.showModal();
}
const editDialog = document.getElementById("edit-dialog");
const editInput = document.getElementById("edit-input");
const editError = document.getElementById("edit-error");
const resetDialog = document.getElementById("reset-dialog");
document.getElementById("edit").onclick = () => {
editInput.value = fmtDuration(total(offset), false);
editError.textContent = "";
openDialog(editDialog);
editInput.select();
};
document.getElementById("edit-cancel").onclick = () => editDialog.close();
editDialog.querySelector("form").onsubmit = e => {
const ms = parseDuration(editInput.value);
if (ms === null) {
e.preventDefault();
editError.textContent = "Use h:mm, for example 7:30.";
return;
}
setTotal(offset, ms);
};
editInput.oninput = () => { editError.textContent = ""; };
document.getElementById("reset").onclick = () => openDialog(resetDialog);
resetDialog.onclose = () => {
if (resetDialog.returnValue === "reset") setTotal(offset, 0);
resetDialog.returnValue = "";
};
document.getElementById("circle").onclick = toggle;
document.getElementById("prev").onclick = () => go(-1);
document.getElementById("next").onclick = () => go(1);
document.addEventListener("keydown", e => {
if (document.querySelector("dialog[open]")) return;
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
});
// Keep several open tabs or panels in sync.
window.addEventListener("storage", e => {
if (e.key === STORAGE_KEY) { state = load(); render(); }
});
localStorage.removeItem("simple-time-tracker.sessions");
save();
render();
setInterval(render, 1000);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment