cybermax / hoursprig /app.js
CyberMax-tools's picture
Deploy hoursprig to the hub
825b18e verified
Raw History Blame Contribute Delete
38.1 kB
/* Hoursprig app: time card editor, live totals, timesheet preview, PDF/print/CSV, Pro features. Everything stays in this browser.
* © 2026 CyberMax. All rights reserved. */
(function () {
"use strict";
var C = window.HourCalc, P = window.HourPDF, X = window.CMXPro;
var KEY = "hoursprig:v1";
/* Recipient-facing credit on free timesheets (Pro removes it): links to the app's page on cybermaxtools.com, tagged so
* the page's view counter records it (utm_source=hoursprig-share, utm_campaign=pdf|print). */
var SITE = "https://cybermaxtools.com/apps/time-card-calculator/";
var CREDIT_LEAD = "Made with Hoursprig by CyberMax", CREDIT_ASK = "make your own free: cybermaxtools.com/apps/time-card-calculator";
var CREDIT = CREDIT_LEAD + " \u2014 " + CREDIT_ASK;
function creditUrl(ch) { return SITE + "?utm_source=hoursprig-share&utm_medium=made-with&utm_campaign=" + ch; }
var MAX_PAIRS = 4;
var $ = function (id) { return document.getElementById(id); };
var form = $("editor");
function pro() { return X.isPro(); }
function toast(m) { X.toast(m); }
function upsell(msg, from) { X.upsell(msg || "That is a Hoursprig Pro feature.", from); }
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); }
function clone(o) { return JSON.parse(JSON.stringify(o)); }
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
function todayISO() { var d = new Date(); return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1)).slice(-2) + "-" + ("0" + d.getDate()).slice(-2); }
/* ---------- state ---------- */
function entriesOf(period) {
var e = {};
(period.days || []).forEach(function (d) { e[d.date] = { pairs: d.pairs, brk: d.brk, note: d.note }; });
return e;
}
function tidy(entries, h24) {
Object.keys(entries).forEach(function (d) { entries[d].pairs.forEach(function (q) { var n = C.normalizePair(q.in, q.out, h24); q.in = n.in; q.out = n.out; }); });
return entries;
}
function fromExample(k) {
var ex = C.example(k), id = uid();
var settings = Object.assign(C.defaults(), ex.settings, { paper: "" });
var rate = settings.rate; delete settings.rate;
return {
settings: settings, company: ex.company, notes: "",
employee: { id: id, name: ex.employee.name, ref: ex.employee.ref, rate: rate },
period: { kind: ex.period.kind, start: ex.period.start, entries: tidy(entriesOf(ex.period), settings.h24) },
};
}
function fresh() {
var ex = fromExample("weekly");
var o = { v: 1, settings: ex.settings, company: ex.company, notes: "", employees: [ex.employee], active: ex.employee.id, periods: {}, history: [], logo: null, results: 0 };
o.periods[ex.employee.id] = ex.period;
return o;
}
function valid(s) { return s && typeof s === "object" && Array.isArray(s.employees) && s.employees.length && s.periods && s.settings; }
function load() {
var s = null;
try { s = JSON.parse(localStorage.getItem(KEY) || "null"); } catch (e) { s = null; }
if (!valid(s)) return fresh();
var f = fresh();
Object.keys(f).forEach(function (k) { if (s[k] == null) s[k] = f[k]; });
return s;
}
var S = load();
var saveT = null;
function save() {
clearTimeout(saveT);
saveT = setTimeout(saveNow, 150);
}
function saveNow() { clearTimeout(saveT); try { localStorage.setItem(KEY, JSON.stringify(S)); } catch (e) { toast("Could not save in this browser (storage full or private mode)."); } }
function emp() {
if (!pro()) return S.employees[0];
return S.employees.filter(function (e) { return e.id === S.active; })[0] || S.employees[0];
}
function period() {
var e = emp(), p = S.periods[e.id];
if (!p) { p = S.periods[e.id] = { kind: S.settings.kind || "week", start: defaultStart(S.settings.kind || "week"), entries: {} }; }
return p;
}
function defaultStart(kind) {
var t = todayISO();
if (kind === "semimonth") return C.periodStart(kind, t);
return C.addDays(t, -C.dow(t)); // most recent Sunday
}
function blankEntry() { return { pairs: [{ in: "", out: "" }], brk: "", note: "" }; }
function entry(date) { var p = period(); if (!p.entries[date]) p.entries[date] = blankEntry(); return p.entries[date]; }
function periodObj(p) {
p = p || period();
return { kind: p.kind, start: p.start, days: C.periodDates(p.kind, p.start).map(function (d) { return Object.assign({ date: d }, p.entries[d] || blankEntry()); }) };
}
function proRulesOn(s) { return !!(s.dailyOn || s.dtOn || s.seventhOn); }
function effSettings(settings, e, kind) {
var s = Object.assign({}, settings, { rate: e.rate, kind: kind });
return pro() ? s : C.freeRules(s);
}
function result() { var p = period(); return C.compute(periodObj(p), effSettings(S.settings, emp(), p.kind)); }
function sheet() { return { company: S.company, employee: { name: emp().name, ref: emp().ref }, result: result(), notes: S.notes }; }
function hasTimes(p) {
return Object.keys(p.entries || {}).some(function (d) { var x = p.entries[d]; return (x.pairs || []).some(function (q) { return String(q.in || "").trim() || String(q.out || "").trim(); }); });
}
/* ---------- employee + period form ---------- */
function fillSelects() {
$("r-currency").innerHTML = C.CURRENCIES.map(function (c) { return '<option value="' + c.code + '">' + c.code + "</option>"; }).join("");
$("r-weekStart").innerHTML = C.DOW_LONG.map(function (d, i) { return '<option value="' + i + '">' + d + "</option>"; }).join("");
}
function renderEmployees() {
var list = pro() ? S.employees : [S.employees[0]];
$("f-emp").innerHTML = list.map(function (e, i) { return '<option value="' + esc(e.id) + '">' + esc(e.name || "Employee " + (i + 1)) + "</option>"; }).join("");
$("f-emp").value = emp().id;
$("f-emp").disabled = list.length < 2;
$("btn-del-emp").hidden = !(pro() && S.employees.length > 1);
$("plan-chip").textContent = pro() ? S.employees.length + (S.employees.length === 1 ? " employee" : " employees") : "Free: 1 employee";
}
function toForm() {
var e = emp(), p = period();
$("f-name").value = e.name || ""; $("f-ref").value = e.ref || ""; $("f-rate").value = e.rate || "";
$("f-company").value = S.company || ""; $("f-notes").value = S.notes || "";
$("f-start").value = p.start;
Array.prototype.forEach.call(document.querySelectorAll(".seg button"), function (b) { b.setAttribute("aria-checked", String(b.dataset.kind === p.kind)); });
document.body.classList.toggle("semi", p.kind === "semimonth");
Array.prototype.forEach.call(form.querySelectorAll("[data-set]"), function (el) {
var v = S.settings[el.dataset.set];
if (el.type === "checkbox") el.checked = !!v;
else if (el.dataset.set === "h24") el.value = v ? "1" : "";
else el.value = v == null ? "" : String(v);
});
renderEmployees();
renderDays();
hints();
}
form.addEventListener("submit", function (e) { e.preventDefault(); });
form.addEventListener("input", function (e) {
var el = e.target;
if (el.dataset.emp) { emp()[el.dataset.emp] = el.value; if (el.dataset.emp === "name") renderEmployees(); changed(); }
else if (el.dataset.top) { S[el.dataset.top] = el.value; changed(); }
else if (el.dataset.set && el.type !== "checkbox" && el.tagName === "INPUT") { S.settings[el.dataset.set] = el.value; changed(); }
});
form.addEventListener("change", function (e) {
var el = e.target, k = el.dataset.set;
if (!k) return;
if (el.type === "checkbox") {
if (el.checked && el.dataset.pro && !pro()) {
el.checked = false;
return upsell("Daily overtime, double time and the 7th-day rule are part of Pro.", "ot-rules");
}
S.settings[k] = el.checked;
} else if (k === "h24") { S.settings.h24 = !!el.value; tidy(period().entries, S.settings.h24); renderDays(); }
else if (k === "rounding" || k === "weekStart") S.settings[k] = +el.value;
else if (el.tagName === "SELECT") S.settings[k] = el.value;
hints(); changed();
});
Array.prototype.forEach.call(document.querySelectorAll(".seg button"), function (b) {
b.addEventListener("click", function () { setPeriod(b.dataset.kind, null); });
});
$("f-start").addEventListener("change", function () { if (C.validISO(this.value)) setPeriod(null, this.value); else this.value = period().start; });
$("btn-prev").addEventListener("click", function () { var p = period(); setPeriod(null, C.shiftPeriod(p.kind, p.start, -1)); });
$("btn-next").addEventListener("click", function () { var p = period(); setPeriod(null, C.shiftPeriod(p.kind, p.start, 1)); });
/** Change period kind and/or start. Free keeps one period: moving to a different one clears times (after a confirm); Pro saves the old one to history. */
function setPeriod(kind, start) {
var p = period();
kind = kind || p.kind;
start = C.periodStart(kind, start || p.start);
if (kind === p.kind && start === p.start) return toForm();
var oldDates = C.periodDates(p.kind, p.start), newDates = C.periodDates(kind, start);
var moving = start !== p.start && newDates.indexOf(oldDates[0]) < 0;
if (moving && hasTimes(p)) {
if (pro()) { recordHistory(p, true); toast("Saved " + C.fmtRange(oldDates[0], oldDates[oldDates.length - 1]) + " to your pay periods."); }
else if (!confirm("The free plan keeps one pay period. Start a new period and clear the current times?\n\nHoursprig Pro saves every period to history.")) { $("f-start").value = p.start; return; }
}
var keep = {};
newDates.forEach(function (d) { if (p.entries[d] && !moving) keep[d] = p.entries[d]; });
p.kind = kind; p.start = start; p.entries = keep;
S.settings.kind = kind;
toForm(); changed();
}
function hints() {
var p = period(), dates = C.periodDates(p.kind, p.start), s = S.settings;
var txt = C.fmtRange(dates[0], dates[dates.length - 1]) + " · " + dates.length + " days. ";
if (p.kind === "semimonth") txt += "Weekly overtime uses workweeks starting " + C.DOW_LONG[s.weekStart] + "; a workweek that crosses into the next period only counts the days in this one.";
else txt += "Weekly overtime counts each 7 days from the start date as one workweek" + (p.kind === "biweek" ? " (2 workweeks)." : ".");
$("period-hint").textContent = txt;
var r = +s.rounding;
$("round-hint").textContent = !r ? "Exact minutes are used. Rounding, if your workplace uses it, moves each in and out time to the nearest step." :
r === 15 ? "Each in and out time moves to the nearest quarter hour: 8:07 becomes 8:00, 8:08 becomes 8:15." :
r === 6 ? "Each in and out time moves to the nearest 6 minutes (0.1 h): 8:02 becomes 8:00, 8:03 becomes 8:06." :
"Each in and out time moves to the nearest 5 minutes: 8:02 becomes 8:00, 8:03 becomes 8:05.";
}
/* ---------- employees (Pro) ---------- */
$("btn-add-emp").addEventListener("click", function () {
if (!pro()) return upsell("The free plan covers 1 employee. Hoursprig Pro keeps every employee's timesheet in one place.", "employees");
var p = period(), e = { id: uid(), name: "", ref: "", rate: emp().rate || "" };
S.employees.push(e); S.active = e.id;
S.periods[e.id] = { kind: p.kind, start: p.start, entries: {} };
toForm(); changed(); $("f-name").focus();
toast("New employee added. Type their name and times.");
});
$("f-emp").addEventListener("change", function () { S.active = this.value; toForm(); changed(); });
$("btn-del-emp").addEventListener("click", function () {
if (S.employees.length < 2) return;
var e = emp();
if (!confirm("Remove " + (e.name || "this employee") + " and their current times? Saved pay periods stay in history.")) return;
S.employees = S.employees.filter(function (x) { return x.id !== e.id; });
delete S.periods[e.id]; S.active = S.employees[0].id;
toForm(); changed();
});
/* ---------- the time card ---------- */
function renderDays() {
var p = period(), h24 = !!S.settings.h24;
$("days").innerHTML = C.periodDates(p.kind, p.start).map(function (date) {
var x = p.entries[date] || blankEntry(), dw = C.DOW[C.dow(date)], lab = dw + " " + C.fmtDate(date);
var list = x.pairs && x.pairs.length ? x.pairs : [{ in: "", out: "" }], n = list.length;
var pairs = list.map(function (q, i) {
return '<div class="pair" data-p="' + i + '"><input class="t" data-k="in" value="' + esc(q.in) + '" placeholder="In" aria-label="' + lab + " in" + (i ? ", shift " + (i + 1) : "") + '" inputmode="text" enterkeyhint="next">' +
'<span class="dash" aria-hidden="true">–</span><input class="t" data-k="out" value="' + esc(q.out) + '" placeholder="Out" aria-label="' + lab + " out" + (i ? ", shift " + (i + 1) : "") + '" inputmode="text" enterkeyhint="next">' +
(i ? '<button type="button" class="icon-btn sm" data-act="delpair" aria-label="Remove shift ' + (i + 1) + " on " + lab + '" title="Remove this shift">×</button>' :
'<button type="button" class="icon-btn sm add" data-act="addpair" aria-label="Add a split shift on ' + lab + '" title="Add a split shift"' + (n >= MAX_PAIRS ? " disabled" : "") + ">+</button>") + "</div>";
}).join("");
return '<div class="day" data-date="' + date + '"><div class="dl"><b>' + dw + "</b><span>" + C.fmtDate(date) + "</span></div>" +
'<div class="pairs">' + pairs + "</div>" +
'<div class="brk"><input data-k="brk" value="' + esc(x.brk) + '" inputmode="numeric" placeholder="0" aria-label="' + lab + ' unpaid break in minutes"><span>min</span></div>' +
'<div class="dt" aria-live="polite"></div>' +
'<input class="note" data-k="note" value="' + esc(x.note) + '" placeholder="Note" aria-label="' + lab + ' note"></div>';
}).join("");
dayTotals();
}
function dayTotals(r) {
r = r || result();
var map = {};
r.days.forEach(function (d) { map[d.date] = d; });
Array.prototype.forEach.call($("days").querySelectorAll(".day"), function (row) {
var d = map[row.dataset.date]; if (!d) return;
var on = d.worked > 0;
row.classList.toggle("off", !on);
var over = d.shifts.some(function (s) { return s.end > C.DAY; });
row.querySelector(".dt").innerHTML = on ? "<b>" + C.hm(d.worked) + "</b><small>" + C.dec(d.worked) + " h" + (d.ot || d.dt ? "<br>OT " + C.hm(d.ot + d.dt) : "") + (over ? "<br>+1 day" : "") + "</small>" : '<b class="z">0:00</b>';
Array.prototype.forEach.call(row.querySelectorAll(".pair"), function (pr) {
var i = +pr.dataset.p, bad = d.errors.indexOf(i) > -1;
var ins = pr.querySelectorAll("input.t");
Array.prototype.forEach.call(ins, function (inp) {
var v = inp.value.trim(), b = bad && (!v || !C.parseTime(v));
inp.classList.toggle("bad", b);
if (b) { inp.setAttribute("aria-invalid", "true"); inp.title = v ? "Could not read this time. Try 9, 9a, 9:30pm or 21:30." : "Add the " + inp.dataset.k + " time."; }
else { inp.removeAttribute("aria-invalid"); inp.removeAttribute("title"); }
});
});
});
}
$("days").addEventListener("input", function (e) {
var el = e.target, row = el.closest(".day"); if (!row || !el.dataset.k) return;
var x = entry(row.dataset.date);
if (el.dataset.k === "in" || el.dataset.k === "out") {
var i = +el.closest(".pair").dataset.p;
if (!x.pairs[i]) x.pairs[i] = { in: "", out: "" };
x.pairs[i][el.dataset.k] = el.value;
} else x[el.dataset.k] = el.value;
changed(true);
});
$("days").addEventListener("focusout", function (e) {
var el = e.target;
if (!el.classList || !el.classList.contains("t")) return;
var pr = el.closest(".pair"), ins = pr.querySelectorAll("input.t"), inEl = ins[0], outEl = ins[1];
if (!el.value.trim()) return;
if (el === outEl && !inEl.value.trim()) return;
var n = C.normalizePair(inEl.value, outEl.value, !!S.settings.h24);
if (el === inEl && !C.span(inEl.value, outEl.value)) n.out = outEl.value;
var x = entry(el.closest(".day").dataset.date), q = x.pairs[+pr.dataset.p];
if (!q) return;
if (n.in !== inEl.value) inEl.value = n.in;
if (n.out !== outEl.value) outEl.value = n.out;
q.in = n.in; q.out = n.out; save();
});
$("days").addEventListener("click", function (e) {
var b = e.target.closest("[data-act]"); if (!b) return;
var row = b.closest(".day"), x = entry(row.dataset.date);
if (b.dataset.act === "addpair" && x.pairs.length < MAX_PAIRS) x.pairs.push({ in: "", out: "" });
if (b.dataset.act === "delpair") x.pairs.splice(+b.closest(".pair").dataset.p, 1);
renderDays(); changed(true);
if (b.dataset.act === "addpair") { var ins = $("days").querySelectorAll('.day[data-date="' + row.dataset.date + '"] .pair input.t'); if (ins.length) ins[ins.length - 2].focus(); }
});
$("btn-copy").addEventListener("click", function () {
var p = period(), dates = C.periodDates(p.kind, p.start);
var src = null;
dates.some(function (d) { var x = p.entries[d]; if (x && x.pairs.some(function (q) { return String(q.in).trim() && String(q.out).trim(); })) { src = x; return true; } return false; });
if (!src) return toast("Type the first day's times, then copy them.");
var n = 0;
dates.forEach(function (d) { var w = C.dow(d); if (w >= 1 && w <= 5 && p.entries[d] !== src) { p.entries[d] = { pairs: clone(src.pairs), brk: src.brk, note: "" }; n++; } });
renderDays(); changed(true); toast("Copied to " + n + " weekdays.");
});
$("btn-clear").addEventListener("click", function () {
if (!confirm("Clear every time on this card?")) return;
period().entries = {}; renderDays(); changed(true);
});
/* ---------- totals + preview ---------- */
function renderSummary(r) {
var T = r.totals, s = r.settings, m = function (v) { return esc(C.money(v, s.currency)); };
var showDT = s.dtOn || s.seventhOn || T.dt > 0;
var rows = '<tr><th scope="row">Regular</th><td>' + C.hm(T.reg) + "</td><td>" + C.dec(T.reg) + "</td><td>" + (T.rate ? m(T.regPay) : "") + "</td></tr>" +
'<tr><th scope="row">Overtime ×' + esc(s.otMult) + "</th><td>" + C.hm(T.ot) + "</td><td>" + C.dec(T.ot) + "</td><td>" + (T.rate ? m(T.otPay) : "") + "</td></tr>" +
(showDT ? '<tr><th scope="row">Double time ×' + esc(s.dtMult) + "</th><td>" + C.hm(T.dt) + "</td><td>" + C.dec(T.dt) + "</td><td>" + (T.rate ? m(T.dtPay) : "") + "</td></tr>" : "");
var locked = !pro() && proRulesOn(S.settings);
$("sum").innerHTML =
'<div class="sum-top"><div><div class="k">Total hours</div><div class="big" id="total-hm">' + C.hm(T.worked) + '</div><div class="sub">' + C.dec(T.worked) + " h decimal · " + T.daysWorked + (T.daysWorked === 1 ? " day" : " days") + "</div></div>" +
'<div class="pay"><div class="k">' + (T.rate ? "Gross pay" : "Add an hourly rate for pay") + '</div><div class="big" id="total-pay">' + (T.rate ? m(T.gross) : "—") + "</div>" + (T.rate ? '<div class="sub">at ' + m(T.rate) + "/h</div>" : "") + "</div></div>" +
'<table class="sum-t"><thead><tr><th></th><th>h:mm</th><th>Decimal</th><th>Pay</th></tr></thead><tbody>' + rows + "</tbody></table>" +
'<p class="rules-line">' + esc(C.ruleLines(s)) + "</p>" +
(locked ? '<p class="locked-note">Daily overtime, double time and the 7th-day rule are switched on but only apply with Pro, so this shows weekly overtime only. <button type="button" class="linkish" id="btn-rules-pro">Unlock all rules</button></p>' : "");
var lb = $("btn-rules-pro"); if (lb) lb.onclick = function () { upsell(null, "ot-rules"); };
var hs = $("hero-summary");
if (hs) {
hs.hidden = !T.worked;
hs.innerHTML = '<span class="hs-k">' + esc((emp().name || "This card") + " · " + (function () { var ds = C.periodDates(period().kind, period().start); return C.fmtRange(ds[0], ds[ds.length - 1]); })()) + '</span>' +
'<span class="hs-row"><b>' + C.hm(T.worked) + '</b> worked · <b>' + C.hm(T.ot + (T.dt || 0)) + '</b> overtime' + (T.rate ? ' · <b>' + m(T.gross) + '</b> gross' : '') + '</span>' +
'<span class="hs-go">See the timesheet ready to sign ↓</span>';
}
$("warn").textContent = T.errors ? T.errors + (T.errors === 1 ? " shift has" : " shifts have") + " a time that could not be read or is missing (marked in red)." : "";
}
function previewHTML(sh, opts) {
var r = sh.result, s = r.settings, T = r.totals, h24 = !!s.h24, emp = sh.employee || {};
var m = function (v) { return esc(C.money(v, s.currency)); };
var showDT = s.dtOn || s.seventhOn || T.dt > 0, multi = r.weeks.length > 1;
var rows = r.weeks.map(function (wk, wi) {
return wk.days.map(function (d) {
var on = d.worked > 0;
return '<tr class="' + (on ? "" : "off") + '"><td>' + esc(C.fmtDate(d.date, true)) + "</td><td>" + (on ? d.shifts.map(function (x) { return esc(C.fmtClock(x.start, h24) + " – " + C.fmtClock(x.end, h24)) + (x.end > C.DAY ? " <i>(+1 day)</i>" : ""); }).join("<br>") : "—") +
'</td><td class="n">' + (on && d.brk ? d.brk + " min" : "") + '</td><td class="n b">' + (on ? C.hm(d.worked) : "—") + '</td><td class="n">' + (on ? C.hm(d.reg) : "") + '</td><td class="n">' + (d.ot ? C.hm(d.ot) : "") + "</td>" +
(showDT ? '<td class="n">' + (d.dt ? C.hm(d.dt) : "") + "</td>" : "") + '<td class="nt">' + esc((d.seventh ? "7th day. " : "") + d.note) + "</td></tr>";
}).join("") + (multi ? '<tr class="wk"><td>Week ' + (wi + 1) + "</td><td>" + esc(C.fmtDate(wk.start) + " – " + C.fmtDate(wk.end)) + '</td><td></td><td class="n">' + C.hm(wk.worked) + '</td><td class="n">' + C.hm(wk.reg) + '</td><td class="n">' + C.hm(wk.ot) + "</td>" + (showDT ? '<td class="n">' + C.hm(wk.dt) + "</td>" : "") + "<td></td></tr>" : "");
}).join("");
var pay = [["Regular", T.reg, T.regPay]];
if (s.weeklyOn || s.dailyOn || s.seventhOn || T.ot) pay.push(["Overtime ×" + s.otMult, T.ot, T.otPay]);
if (showDT) pay.push(["Double time ×" + s.dtMult, T.dt, T.dtPay]);
return '<header class="p-head">' + (opts.logo ? '<img class="p-logo" src="' + opts.logo + '" alt="">' : "") + '<div class="p-co"><div class="p-name">' + esc(sh.company) + '</div><div class="p-kind">' + esc(C.KIND_LABEL[r.kind]) + ' timesheet</div></div><div class="p-title"><div>TIMESHEET</div><span>' + esc(C.fmtRange(r.start, r.end)) + "</span></div></header>" +
'<div class="p-band"><div><small>EMPLOYEE</small><b>' + esc(emp.name || "—") + "</b></div><div><small>EMPLOYEE ID</small><b>" + esc(emp.ref || "—") + "</b></div><div><small>PAY PERIOD</small><b>" + esc(C.fmtDate(r.start, "y") + " to " + C.fmtDate(r.end, "y")) + "</b></div><div><small>HOURLY RATE</small><b>" + (T.rate ? m(T.rate) : "—") + "</b></div></div>" +
'<table class="p-t"><thead><tr><th>Date</th><th>In – Out</th><th class="n">Break</th><th class="n">Hours</th><th class="n">Regular</th><th class="n">Overtime</th>' + (showDT ? '<th class="n">Double</th>' : "") + "<th>Note</th></tr></thead><tbody>" + rows +
'<tr class="tot"><td>Total</td><td>' + T.daysWorked + (T.daysWorked === 1 ? " day" : " days") + ' worked</td><td class="n">' + (T.brk ? T.brk + " min" : "") + '</td><td class="n">' + C.hm(T.worked) + '</td><td class="n">' + C.hm(T.reg) + '</td><td class="n">' + C.hm(T.ot) + "</td>" + (showDT ? '<td class="n">' + C.hm(T.dt) + "</td>" : "") + '<td class="nt">' + C.dec(T.worked) + " h decimal</td></tr></tbody></table>" +
'<section class="p-bottom"><div class="p-rules"><small>HOW THIS WAS CALCULATED</small><p>' + esc(C.ruleLines(s)) + "</p>" + (sh.notes ? "<small>NOTES</small><p>" + esc(sh.notes) + "</p>" : "") + "</div>" +
'<table class="p-pay">' + pay.map(function (x) { return "<tr><td>" + esc(x[0]) + "</td><td>" + C.hm(x[1]) + " (" + C.dec(x[1]) + " h)</td><td>" + (T.rate ? m(x[2]) : "") + "</td></tr>"; }).join("") +
'<tr class="g"><td colspan="2">' + (T.rate ? "Gross pay" : "Total hours") + "</td><td>" + (T.rate ? m(T.gross) : C.hm(T.worked)) + "</td></tr></table></section>" +
'<p class="p-confirm">I confirm the hours above are a true record of the time worked.</p>' +
'<div class="p-sig"><div><span></span><small>EMPLOYEE SIGNATURE</small><em>' + esc(emp.name) + '</em></div><div class="d"><span></span><small>DATE</small></div><div><span></span><small>SUPERVISOR / EMPLOYER SIGNATURE</small><em>' + esc(sh.company) + '</em></div><div class="d"><span></span><small>DATE</small></div></div>' +
'<footer class="p-foot"><span class="p-disc">Calculated from the entered times and settings; not legal or payroll advice.</span>' + (opts.credit ? '<span class="p-credit">' + CREDIT_LEAD + ' \u2014 <a href="' + creditUrl("print") + '" target="_blank" rel="noopener">' + CREDIT_ASK + "</a></span>" : "") + "</footer>";
}
function renderPreview(r) {
var sh = sheet(); if (r) sh.result = r;
var el = $("doc");
el.className = "paper" + (P.pageSize(S.settings.paper, S.settings.currency)[0] === 612 ? " letter" : "");
el.innerHTML = previewHTML(sh, { credit: !pro(), logo: pro() && S.logo && S.logo.data ? S.logo.data : "" });
fit();
}
function fit() {
var wrap = $("paper-wrap"), el = $("doc");
var s = Math.min(1, wrap.clientWidth / el.offsetWidth);
el.style.transform = "scale(" + s + ")";
wrap.style.height = Math.ceil(el.offsetHeight * s) + "px";
}
window.addEventListener("resize", fit);
function limitsLine() {
$("limits").innerHTML = pro() ? "<b>Pro:</b> every employee, saved pay periods, CSV for payroll, all overtime rules, no footer line." :
'<b>Free plan:</b> 1 employee · current pay period only · print and PDF carry a small Hoursprig line. <a href="#cmx-pro" id="lim-pro">Pro is $15 once</a>';
var a = $("lim-pro"); if (a) a.onclick = function (e) { e.preventDefault(); upsell(null, "limits"); };
}
var changeT = null;
/* ---------- undo / redo (16 Oct 2026 quality pass): whole-card snapshots after each burst of edits ---------- */
var hist = [], hi = -1, snapT = 0;
function snapNow() {
clearTimeout(snapT);
var j = JSON.stringify(S);
if (hi >= 0 && hist[hi] === j) return undoUi();
hist = hist.slice(0, hi + 1); hist.push(j); if (hist.length > 80) hist.shift(); hi = hist.length - 1; undoUi();
}
function undoUi() { var u = $("btn-undo"), r = $("btn-redo"); if (u) { u.disabled = hi <= 0; r.disabled = hi >= hist.length - 1; } }
function stepHist(d) {
snapNow();
var n = hi + d; if (n < 0 || n >= hist.length) return;
hi = n; S = JSON.parse(hist[hi]); saveNow(); toForm(); renderHistory(); logoUi(); renderDays(); changed(); undoUi();
toast(d < 0 ? "Undone." : "Redone.");
}
function changed(daysOnly) {
clearTimeout(snapT); snapT = setTimeout(snapNow, 400);
if ($("btn-undo") && hist.length) $("btn-undo").disabled = false;
save();
clearTimeout(changeT);
changeT = setTimeout(function () {
var r = result();
dayTotals(r); renderSummary(r); renderPreview(r);
}, daysOnly ? 40 : 60);
}
/* ---------- PDF, print, CSV ---------- */
function b64ToBytes(dataUrl) {
var b = atob(dataUrl.split(",")[1] || ""), u = new Uint8Array(b.length);
for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i);
return u;
}
function pdfFor(sh) {
var logo = pro() && S.logo && S.logo.data ? { bytes: b64ToBytes(S.logo.data), w: S.logo.w, h: S.logo.h } : null;
return P.build(sh, { credit: !pro(), creditText: CREDIT, creditUrl: creditUrl("pdf"), logo: logo, paper: S.settings.paper });
}
$("btn-pdf").addEventListener("click", function () {
var sh = sheet(), bytes;
if (!sh.result.totals.worked) return toast("Add some in and out times first.");
try { bytes = pdfFor(sh); } catch (e) { return toast("Sorry, the PDF could not be made: " + e.message); }
X.download(P.fileName(sh), new Blob([bytes], { type: "application/pdf" }));
done("Timesheet PDF downloaded.");
});
$("m-pdf").addEventListener("click", function () { $("btn-pdf").click(); });
$("btn-print").addEventListener("click", function () { renderPreview(); done(null); setTimeout(function () { window.print(); }, 30); });
$("btn-csv").addEventListener("click", function () {
if (!pro()) return upsell("CSV export for payroll is part of Hoursprig Pro.", "csv");
var sh = sheet();
X.download(P.fileName(sh, "csv"), C.csvDays([sh]) + "\r\n" + C.csvSummary([sh]), "text/csv;charset=utf-8");
done("CSV downloaded.");
});
function done(msg) {
S.results = (S.results || 0) + 1; saveNow();
if (!pro()) $("pro-cta").hidden = false;
if (msg) toast(msg);
}
/* ---------- Pro: saved periods ---------- */
function recordHistory(p, quiet) {
var e = emp();
var id = e.id + ":" + p.start + ":" + p.kind;
var item = { id: id, saved: new Date().toISOString(), employee: { id: e.id, name: e.name, ref: e.ref, rate: e.rate }, company: S.company, notes: S.notes, settings: clone(S.settings), period: clone(p) };
S.history = [item].concat(S.history.filter(function (h) { return h.id !== id; })).slice(0, 1000);
renderHistory();
if (!quiet) save();
}
function histResult(h) { return C.compute(periodObj(h.period), Object.assign({}, h.settings, { rate: h.employee.rate, kind: h.period.kind })); }
function histSheet(h) { return { company: h.company, employee: { name: h.employee.name, ref: h.employee.ref }, result: histResult(h), notes: h.notes }; }
function renderHistory() {
var body = $("hist-body");
$("hist-btns").hidden = !pro();
if (!pro()) {
body.innerHTML = '<div class="locked"><p>The free plan keeps the current pay period. Pro saves every period for every employee, reopens any of them, and exports one CSV for your payroll or accountant.</p><button type="button" class="btn" id="btn-hist-pro">Unlock saved periods</button></div>';
$("btn-hist-pro").onclick = function () { upsell(null, "history"); };
return;
}
if (!S.history.length) { body.innerHTML = '<p class="hint">Press “Save this period” (or move to the next period) and it appears here.</p>'; return; }
body.innerHTML = '<div class="table-wrap"><table class="ht"><thead><tr><th>Employee</th><th>Period</th><th class="num">Hours</th><th class="num">Overtime</th><th class="num">Gross pay</th><th>Actions</th></tr></thead><tbody>' +
S.history.map(function (h) {
var r = histResult(h), T = r.totals;
return '<tr data-id="' + esc(h.id) + '"><td>' + esc(h.employee.name || "—") + "</td><td>" + esc(C.fmtRange(r.start, r.end)) + '</td><td class="num">' + C.hm(T.worked) + '</td><td class="num">' + C.hm(T.ot + T.dt) + '</td><td class="num">' + (T.rate ? esc(C.money(T.gross, r.settings.currency)) : "—") +
'</td><td><button type="button" class="btn ghost sm" data-h="open">Open</button> <button type="button" class="btn ghost sm" data-h="pdf">PDF</button> <button type="button" class="icon-btn" data-h="del" aria-label="Delete this saved period">×</button></td></tr>';
}).join("") + "</tbody></table></div>";
}
$("hist-body").addEventListener("click", function (e) {
var b = e.target.closest("[data-h]"); if (!b) return;
var id = b.closest("tr").dataset.id, h = S.history.filter(function (x) { return x.id === id; })[0]; if (!h) return;
var act = b.dataset.h;
if (act === "pdf") { var sh = histSheet(h); X.download(P.fileName(sh), new Blob([pdfFor(sh)], { type: "application/pdf" })); return; }
if (act === "open") {
var e2 = S.employees.filter(function (x) { return x.id === h.employee.id; })[0];
if (!e2) { e2 = { id: h.employee.id, name: h.employee.name, ref: h.employee.ref, rate: h.employee.rate }; S.employees.push(e2); }
S.active = e2.id; S.periods[e2.id] = clone(h.period); S.settings = Object.assign(S.settings, clone(h.settings));
toForm(); changed(); $("app").scrollIntoView({ behavior: "smooth" }); toast("Opened " + (h.employee.name || "the timesheet") + ".");
return;
}
if (act === "del") { if (!confirm("Delete this saved pay period?")) return; S.history = S.history.filter(function (x) { return x.id !== id; }); save(); renderHistory(); }
});
$("btn-save-hist").addEventListener("click", function () {
if (!pro()) return upsell("Saved pay periods are part of Hoursprig Pro.", "history");
recordHistory(period()); toast("Saved this pay period.");
});
$("btn-csv-all").addEventListener("click", function () {
if (!pro()) return upsell("CSV export for payroll is part of Hoursprig Pro.", "csv");
var list = S.history.map(histSheet);
if (!list.length) return toast("Save a pay period first.");
X.download("hoursprig-payroll-" + todayISO() + ".csv", C.csvSummary(list) + "\r\n" + C.csvDays(list), "text/csv;charset=utf-8");
});
/* ---------- logo (Pro) ---------- */
$("f-logo").addEventListener("click", function (e) { if (!pro()) { e.preventDefault(); upsell("Your company logo on timesheets is part of Pro.", "logo"); } });
$("f-logo").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = "";
if (!f) return;
if (f.size > 5e6) return toast("That image is over 5 MB. Try a smaller logo.");
var url = URL.createObjectURL(f), img = new Image();
img.onload = function () {
var s = Math.min(1, 600 / img.naturalWidth, 240 / img.naturalHeight) || 1;
var w = Math.max(1, Math.round((img.naturalWidth || 300) * s)), h = Math.max(1, Math.round((img.naturalHeight || 120) * s));
var c = document.createElement("canvas"); c.width = w; c.height = h;
var g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, w, h); g.drawImage(img, 0, 0, w, h);
S.logo = { data: c.toDataURL("image/jpeg", 0.9), w: w, h: h };
URL.revokeObjectURL(url); save(); logoUi(); renderPreview(); toast("Logo added.");
};
img.onerror = function () { URL.revokeObjectURL(url); toast("That file could not be read as an image."); };
img.src = url;
});
$("btn-logo-clear").addEventListener("click", function () { S.logo = null; save(); logoUi(); renderPreview(); });
function logoUi() {
var has = pro() && S.logo && S.logo.data;
$("logo-prev").innerHTML = has ? '<img src="' + S.logo.data + '" alt="Your logo">' : "No logo";
$("btn-logo-clear").hidden = !has;
}
/* ---------- example, backup ---------- */
$("btn-example").addEventListener("click", function () {
var ex = fromExample($("example-kind").value);
S.settings = ex.settings; S.company = ex.company; S.notes = "";
var e = emp();
e.name = ex.employee.name; e.ref = ex.employee.ref; e.rate = ex.employee.rate;
S.periods[e.id] = ex.period;
toForm(); changed();
if (!pro() && proRulesOn(S.settings)) toast("Example loaded. Its daily and 7th-day rules apply with Pro; free shows weekly overtime.");
if (window.innerWidth < 980) setTimeout(function () { $("preview").scrollIntoView({ behavior: "smooth", block: "start" }); }, 80);
});
$("btn-backup").addEventListener("click", function () {
if (!pro()) return upsell("JSON backup is part of Hoursprig Pro.", "backup");
saveNow();
X.download("hoursprig-backup-" + todayISO() + ".json", JSON.stringify({ app: "hoursprig", version: 1, saved: new Date().toISOString(), data: S }, null, 1), "application/json");
});
$("f-restore").addEventListener("change", function () {
var f = this.files && this.files[0]; this.value = "";
if (!f) return;
f.text().then(function (t) {
var j = JSON.parse(t), d = j && j.app === "hoursprig" ? j.data : null;
if (!valid(d)) throw new Error("not a Hoursprig backup");
var f0 = fresh(); Object.keys(f0).forEach(function (k) { if (d[k] == null) d[k] = f0[k]; });
S = d; saveNow(); toForm(); renderHistory(); logoUi(); changed();
toast("Backup restored.");
}).catch(function (e) { toast("Could not restore: " + e.message + "."); });
});
$("btn-cta").addEventListener("click", function () { upsell(null, "mid-cta"); });
/* ---------- Pro state ---------- */
function onPro(isPro) {
$("pro-pill").textContent = isPro ? "Pro ✓" : "Go Pro";
if (isPro) $("pro-cta").hidden = true;
limitsLine(); renderHistory(); logoUi(); toForm(); changed();
}
/* ---------- install + offline ---------- */
var deferred = null;
window.addEventListener("beforeinstallprompt", function (e) { e.preventDefault(); deferred = e; $("btn-install").hidden = false; });
$("btn-install").addEventListener("click", function () {
if (!deferred) return;
deferred.prompt(); deferred.userChoice.finally(function () { deferred = null; $("btn-install").hidden = true; });
});
window.addEventListener("appinstalled", function () { $("btn-install").hidden = true; });
if ("serviceWorker" in navigator && (location.protocol === "https:" || /^(localhost|127\.0\.0\.1)$/.test(location.hostname))) {
window.addEventListener("load", function () { navigator.serviceWorker.register("sw.js").catch(function () { /* offline cache is optional */ }); });
}
/* ---------- start ---------- */
fillSelects();
X.onChange(onPro);
var r0 = result(); renderSummary(r0); renderPreview(r0); snapNow();
$("btn-undo").addEventListener("click", function () { stepHist(-1); });
$("btn-redo").addEventListener("click", function () { stepHist(1); });
document.addEventListener("keydown", function (e) {
var mod = e.ctrlKey || e.metaKey, k = (e.key || "").toLowerCase();
if (!mod || e.altKey || /^(INPUT|TEXTAREA|SELECT)$/.test((e.target && e.target.tagName) || "")) return;
if (k === "z") { e.preventDefault(); stepHist(e.shiftKey ? 1 : -1); } else if (k === "y") { e.preventDefault(); stepHist(1); }
});
window.Hoursprig = { state: function () { return S; }, result: result, pdf: function () { return pdfFor(sheet()); } };
})();