Spaces:
Running
Running
Download hoursprig/app.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 38.1 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/hoursprig/app.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/hoursprig/app.js
-
curl -L -o app.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/hoursprig/app.js
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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()); } }; | |
| })(); | |