|
<!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">←</button> |
|
<div class="day"> |
|
<div class="label" id="label"></div> |
|
<div class="date" id="date"></div> |
|
</div> |
|
<button id="next" title="Next day">→</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> |