/* 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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 '"; }).join(""); $("r-weekStart").innerHTML = C.DOW_LONG.map(function (d, i) { return '"; }).join(""); } function renderEmployees() { var list = pro() ? S.employees : [S.employees[0]]; $("f-emp").innerHTML = list.map(function (e, i) { return '"; }).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 '
' + '' + (i ? '' : '") + "
"; }).join(""); return '
' + dw + "" + C.fmtDate(date) + "
" + '
' + pairs + "
" + '
min
' + '
' + '
'; }).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 ? "" + C.hm(d.worked) + "" + C.dec(d.worked) + " h" + (d.ot || d.dt ? "
OT " + C.hm(d.ot + d.dt) : "") + (over ? "
+1 day" : "") + "
" : '0:00'; 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 = 'Regular' + C.hm(T.reg) + "" + C.dec(T.reg) + "" + (T.rate ? m(T.regPay) : "") + "" + 'Overtime ×' + esc(s.otMult) + "" + C.hm(T.ot) + "" + C.dec(T.ot) + "" + (T.rate ? m(T.otPay) : "") + "" + (showDT ? 'Double time ×' + esc(s.dtMult) + "" + C.hm(T.dt) + "" + C.dec(T.dt) + "" + (T.rate ? m(T.dtPay) : "") + "" : ""); var locked = !pro() && proRulesOn(S.settings); $("sum").innerHTML = '
Total hours
' + C.hm(T.worked) + '
' + C.dec(T.worked) + " h decimal · " + T.daysWorked + (T.daysWorked === 1 ? " day" : " days") + "
" + '
' + (T.rate ? "Gross pay" : "Add an hourly rate for pay") + '
' + (T.rate ? m(T.gross) : "—") + "
" + (T.rate ? '
at ' + m(T.rate) + "/h
" : "") + "
" + '' + rows + "
h:mmDecimalPay
" + '

' + esc(C.ruleLines(s)) + "

" + (locked ? '

Daily overtime, double time and the 7th-day rule are switched on but only apply with Pro, so this shows weekly overtime only.

' : ""); 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 = '' + esc((emp().name || "This card") + " · " + (function () { var ds = C.periodDates(period().kind, period().start); return C.fmtRange(ds[0], ds[ds.length - 1]); })()) + '' + '' + C.hm(T.worked) + ' worked · ' + C.hm(T.ot + (T.dt || 0)) + ' overtime' + (T.rate ? ' · ' + m(T.gross) + ' gross' : '') + '' + 'See the timesheet ready to sign ↓'; } $("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 '' + esc(C.fmtDate(d.date, true)) + "" + (on ? d.shifts.map(function (x) { return esc(C.fmtClock(x.start, h24) + " – " + C.fmtClock(x.end, h24)) + (x.end > C.DAY ? " (+1 day)" : ""); }).join("
") : "—") + '' + (on && d.brk ? d.brk + " min" : "") + '' + (on ? C.hm(d.worked) : "—") + '' + (on ? C.hm(d.reg) : "") + '' + (d.ot ? C.hm(d.ot) : "") + "" + (showDT ? '' + (d.dt ? C.hm(d.dt) : "") + "" : "") + '' + esc((d.seventh ? "7th day. " : "") + d.note) + ""; }).join("") + (multi ? 'Week ' + (wi + 1) + "" + esc(C.fmtDate(wk.start) + " – " + C.fmtDate(wk.end)) + '' + C.hm(wk.worked) + '' + C.hm(wk.reg) + '' + C.hm(wk.ot) + "" + (showDT ? '' + C.hm(wk.dt) + "" : "") + "" : ""); }).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 '
' + (opts.logo ? '' : "") + '
' + esc(sh.company) + '
' + esc(C.KIND_LABEL[r.kind]) + ' timesheet
TIMESHEET
' + esc(C.fmtRange(r.start, r.end)) + "
" + '
EMPLOYEE' + esc(emp.name || "—") + "
EMPLOYEE ID" + esc(emp.ref || "—") + "
PAY PERIOD" + esc(C.fmtDate(r.start, "y") + " to " + C.fmtDate(r.end, "y")) + "
HOURLY RATE" + (T.rate ? m(T.rate) : "—") + "
" + '' + (showDT ? '' : "") + "" + rows + '" + (showDT ? '" : "") + '
DateIn – OutBreakHoursRegularOvertimeDoubleNote
Total' + T.daysWorked + (T.daysWorked === 1 ? " day" : " days") + ' worked' + (T.brk ? T.brk + " min" : "") + '' + C.hm(T.worked) + '' + C.hm(T.reg) + '' + C.hm(T.ot) + "' + C.hm(T.dt) + "' + C.dec(T.worked) + " h decimal
" + '
HOW THIS WAS CALCULATED

' + esc(C.ruleLines(s)) + "

" + (sh.notes ? "NOTES

" + esc(sh.notes) + "

" : "") + "
" + '' + pay.map(function (x) { return ""; }).join("") + '
" + esc(x[0]) + "" + C.hm(x[1]) + " (" + C.dec(x[1]) + " h)" + (T.rate ? m(x[2]) : "") + "
' + (T.rate ? "Gross pay" : "Total hours") + "" + (T.rate ? m(T.gross) : C.hm(T.worked)) + "
" + '

I confirm the hours above are a true record of the time worked.

' + '
EMPLOYEE SIGNATURE' + esc(emp.name) + '
DATE
SUPERVISOR / EMPLOYER SIGNATURE' + esc(sh.company) + '
DATE
' + '"; } 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() ? "Pro: every employee, saved pay periods, CSV for payroll, all overtime rules, no footer line." : 'Free plan: 1 employee · current pay period only · print and PDF carry a small Hoursprig line. Pro is $15 once'; 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 = '

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.

'; $("btn-hist-pro").onclick = function () { upsell(null, "history"); }; return; } if (!S.history.length) { body.innerHTML = '

Press “Save this period” (or move to the next period) and it appears here.

'; return; } body.innerHTML = '
' + S.history.map(function (h) { var r = histResult(h), T = r.totals; return ''; }).join("") + "
EmployeePeriodHoursOvertimeGross payActions
' + esc(h.employee.name || "—") + "" + esc(C.fmtRange(r.start, r.end)) + '' + C.hm(T.worked) + '' + C.hm(T.ot + T.dt) + '' + (T.rate ? esc(C.money(T.gross, r.settings.currency)) : "—") + '
"; } $("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 ? '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()); } }; })();