/* Hoursprig maths: forgiving time parsing, shifts across midnight, rounding, daily/weekly/7th-day overtime, pay, CSV. * Pure functions, no DOM. Works in the browser (window.HourCalc) and in Node (require). * © 2026 CyberMax. All rights reserved. */ (function (root) { "use strict"; var DAY = 1440; var DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; var DOW_LONG = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; var CURRENCIES = [ { code: "USD", locale: "en-US" }, { code: "CAD", locale: "en-CA" }, { code: "GBP", locale: "en-GB" }, { code: "EUR", locale: "de-DE" }, { code: "AUD", locale: "en-AU" }, { code: "NZD", locale: "en-NZ" }, { code: "INR", locale: "en-IN" }, { code: "ZAR", locale: "en-ZA" }, { code: "MXN", locale: "es-MX" }, { code: "PHP", locale: "en-PH" }, { code: "SGD", locale: "en-SG" }, { code: "CHF", locale: "de-CH" }, ]; var ROUNDING = [0, 5, 6, 15]; /* ---------- time parsing ---------- */ /** * Parse a clock time typed the way people type it: "9", "9a", "9am", "9:30pm", "21:30", "0930", "930p", "9.30", "noon", "midnight". * Returns { min: minutes after midnight (0..1439), explicit: true when am/pm or 24-hour form makes the meaning certain } or null. */ function parseTime(s) { var t = String(s == null ? "" : s).trim().toLowerCase().replace(/\s+/g, "").replace(/\./g, ":"); if (!t) return null; if (t === "noon" || t === "12n") return { min: 720, explicit: true }; if (t === "midnight" || t === "12m") return { min: 0, explicit: true }; var h, mi, mer = "", lead = false, m; if ((m = /^(\d{1,2})(?::(\d{2}))?(am|a|pm|p)?$/.exec(t))) { h = +m[1]; mi = m[2] ? +m[2] : 0; mer = m[3] || ""; lead = m[1].length === 2 && m[1].charAt(0) === "0"; } else if ((m = /^(\d{3,4})(am|a|pm|p)?$/.exec(t))) { h = +m[1].slice(0, -2); mi = +m[1].slice(-2); mer = m[2] || ""; lead = m[1].length === 4 && m[1].charAt(0) === "0"; } else if ((m = /^(\d{1,2})h(\d{2})?$/.exec(t))) { h = +m[1]; mi = m[2] ? +m[2] : 0; lead = true; } else return null; if (mi > 59) return null; if (mer) { if (h < 1 || h > 12) return null; if (h === 12) h = 0; if (mer.charAt(0) === "p") h += 12; return { min: h * 60 + mi, explicit: true }; } if (h === 24 && mi === 0) return { min: 0, explicit: true, end: true }; if (h > 23) return null; return { min: h * 60 + mi, explicit: h === 0 || h >= 13 || lead }; } /** * Minutes between an in and an out time. Overnight shifts cross midnight. An out time typed without am/pm that would * otherwise end before the start is read as afternoon/evening when that gives a shift under 12 hours ("9" to "5" = 8 h). * Returns { start, end, minutes, overnight } (start/end in minutes from the day's midnight; end may exceed 1440) or null. */ function span(inStr, outStr) { var a = parseTime(inStr), b = parseTime(outStr); if (!a || !b) return null; var start = a.min, end = b.min; if (b.end && end === 0) end = DAY; if (end <= start && !b.explicit && end < 720 && end + 720 > start) end += 720; var overnight = false; if (end <= start) { end += DAY; overnight = true; } return { start: start, end: end, minutes: end - start, overnight: overnight }; } /** Round a clock time (minutes) to the nearest step (0 = exact). Half-way rounds up (7.5 min on a 15-minute step goes up). */ function roundTo(min, step) { step = +step || 0; if (!step) return min; return Math.round(min / step) * step; } function fmtClock(min, h24) { min = ((Math.round(min) % DAY) + DAY) % DAY; var h = Math.floor(min / 60), m = min % 60; if (h24) return pad(h) + ":" + pad(m); var mer = h < 12 ? "AM" : "PM", hh = h % 12 || 12; return hh + ":" + pad(m) + " " + mer; } function pad(n) { return (n < 10 ? "0" : "") + n; } /** Normalise a typed time for display ("9a" -> "9:00 AM"); returns the input unchanged if it cannot be parsed. */ function normalize(str, h24) { var p = parseTime(str); if (!p) return String(str == null ? "" : str).trim(); return p.end ? (h24 ? "24:00" : "12:00 AM") : fmtClock(p.min, h24); } /** * Tidy an in/out pair for display without changing its meaning: the out time is written from the worked span, so * "9" to "5" becomes "9:00 AM" to "5:00 PM" (never 5:00 AM). Parts that cannot be read are returned as typed. */ function normalizePair(inStr, outStr, h24) { var a = parseTime(inStr), sp = span(inStr, outStr); return { in: a ? normalize(inStr, h24) : String(inStr == null ? "" : inStr).trim(), out: sp ? fmtClock(sp.end, h24) : String(outStr == null ? "" : outStr).trim() }; } /* ---------- durations ---------- */ /** 512 minutes -> "8:32" */ function hm(min) { min = Math.round(min || 0); var neg = min < 0; if (neg) min = -min; return (neg ? "-" : "") + Math.floor(min / 60) + ":" + pad(min % 60); } /** 512 minutes -> "8.53" (hours, 2 decimals, half-up) */ function dec(min, dp) { dp = dp == null ? 2 : dp; return round(min / 60, dp).toFixed(dp); } function round(v, dp) { var f = Math.pow(10, dp || 0); return Math.round((v + (v >= 0 ? 1 : -1) * Number.EPSILON * Math.max(1, Math.abs(v))) * f) / f; } function parseNum(s) { if (typeof s === "number") return isFinite(s) ? s : 0; var t = String(s == null ? "" : s).replace(/[^\d,.\-]/g, ""); if (!t) return 0; if (t.indexOf(",") > -1 && t.indexOf(".") > -1) t = t.lastIndexOf(",") > t.lastIndexOf(".") ? t.replace(/\./g, "").replace(",", ".") : t.replace(/,/g, ""); else if (t.indexOf(",") > -1) t = /,\d{1,2}$/.test(t) ? t.replace(",", ".") : t.replace(/,/g, ""); var v = parseFloat(t); return isFinite(v) ? v : 0; } /* ---------- dates (ISO yyyy-mm-dd, UTC maths so DST never shifts a day) ---------- */ function toDate(iso) { var p = String(iso).split("-"); return new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])); } function iso(d) { return d.getUTCFullYear() + "-" + pad(d.getUTCMonth() + 1) + "-" + pad(d.getUTCDate()); } function addDays(isoStr, n) { var d = toDate(isoStr); d.setUTCDate(d.getUTCDate() + n); return iso(d); } function dow(isoStr) { return toDate(isoStr).getUTCDay(); } function validISO(s) { return /^\d{4}-\d{2}-\d{2}$/.test(s || "") && !isNaN(toDate(s).getTime()); } function fmtDate(isoStr, long) { if (!validISO(isoStr)) return ""; var d = toDate(isoStr); return (long ? DOW[d.getUTCDay()] + " " : "") + MONTHS[d.getUTCMonth()] + " " + d.getUTCDate() + (long === "y" || long === 2 ? ", " + d.getUTCFullYear() : ""); } function fmtRange(a, b) { var da = toDate(a), db = toDate(b); var y = da.getUTCFullYear() === db.getUTCFullYear(); return MONTHS[da.getUTCMonth()] + " " + da.getUTCDate() + (y ? "" : ", " + da.getUTCFullYear()) + " – " + MONTHS[db.getUTCMonth()] + " " + db.getUTCDate() + ", " + db.getUTCFullYear(); } /** The dates in a pay period. kind: week (7 days), biweek (14), semimonth (1st–15th or 16th–end of month). */ function periodDates(kind, start) { if (!validISO(start)) start = iso(new Date()); var out = [], n; if (kind === "semimonth") { var d = toDate(start), y = d.getUTCFullYear(), mo = d.getUTCMonth(), first = d.getUTCDate() <= 15; var from = first ? 1 : 16, to = first ? 15 : new Date(Date.UTC(y, mo + 1, 0)).getUTCDate(); for (n = from; n <= to; n++) out.push(iso(new Date(Date.UTC(y, mo, n)))); return out; } var len = kind === "biweek" ? 14 : 7; for (n = 0; n < len; n++) out.push(addDays(start, n)); return out; } /** Start of the period that contains `date` (semimonth: 1st or 16th; week/biweek: `date` itself). */ function periodStart(kind, date) { if (kind !== "semimonth") return date; var d = toDate(date); return iso(new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() <= 15 ? 1 : 16))); } /** Start of the next / previous period. */ function shiftPeriod(kind, start, dir) { if (kind === "semimonth") { var d = toDate(periodStart(kind, start)), day = d.getUTCDate(), y = d.getUTCFullYear(), m = d.getUTCMonth(); if (dir > 0) return day === 1 ? iso(new Date(Date.UTC(y, m, 16))) : iso(new Date(Date.UTC(y, m + 1, 1))); return day === 16 ? iso(new Date(Date.UTC(y, m, 1))) : iso(new Date(Date.UTC(y, m - 1, 16))); } return addDays(start, (kind === "biweek" ? 14 : 7) * (dir > 0 ? 1 : -1)); } /* ---------- settings ---------- */ function defaults() { return { kind: "week", weekStart: 0, rounding: 0, h24: false, currency: "USD", rate: "", weeklyOn: true, weeklyAfter: 40, otMult: 1.5, dailyOn: false, dailyAfter: 8, dtOn: false, dtAfter: 12, dtMult: 2, seventhOn: false, }; } function settingsOf(s) { var d = defaults(), o = {}; Object.keys(d).forEach(function (k) { o[k] = s && s[k] != null && s[k] !== "" ? s[k] : d[k]; }); ["weeklyAfter", "otMult", "dailyAfter", "dtAfter", "dtMult", "rounding", "weekStart"].forEach(function (k) { o[k] = parseNum(o[k]); }); o.rate = parseNum(s && s.rate); return o; } /** Pro-only rules are switched off for free users (the weekly rule stays). */ function freeRules(s) { var o = Object.assign({}, s); o.dailyOn = false; o.dtOn = false; o.seventhOn = false; return o; } /* ---------- a day's worked time ---------- */ /** * day: { date, pairs: [{in, out}], brk: unpaid break minutes, note } * Returns { minutes, shifts: [{start, end, minutes, overnight}], errors: [pairIndex], brk } */ function dayWorked(day, rounding) { var shifts = [], errors = [], sum = 0; (day.pairs || []).forEach(function (p, i) { var hasIn = String(p.in || "").trim(), hasOut = String(p.out || "").trim(); if (!hasIn && !hasOut) return; var sp = span(p.in, p.out); if (!sp) { errors.push(i); return; } var st = roundTo(sp.start, rounding), en = roundTo(sp.end, rounding); var mins = Math.max(0, en - st); shifts.push({ start: st, end: en, minutes: mins, overnight: sp.overnight || en > DAY }); sum += mins; }); var brk = Math.max(0, Math.round(parseNum(day.brk))); return { minutes: Math.max(0, sum - (shifts.length ? brk : 0)), shifts: shifts, errors: errors, brk: shifts.length ? brk : 0 }; } /** * Split worked minutes into regular / overtime / double time for a whole period. * Rules: daily overtime after `dailyAfter` hours, double time after `dtAfter` hours in a day, 7th consecutive day in a * workweek (first 8 h overtime, the rest double time when double time is on), weekly overtime after `weeklyAfter` hours. * Hours already paid as daily overtime are not counted again toward the weekly limit (no pyramiding). * Workweeks: 7-day blocks from the period start (week, bi-weekly) or starting on `weekStart` (semi-monthly). */ function compute(period, settingsIn) { var s = settingsOf(settingsIn); var dates = periodDates(period.kind || s.kind, period.start); var byDate = {}; (period.days || []).forEach(function (d) { byDate[d.date] = d; }); var days = dates.map(function (date) { var d = byDate[date] || { date: date, pairs: [], brk: "" }; var w = dayWorked(d, s.rounding); return { date: date, dow: dow(date), src: d, worked: w.minutes, shifts: w.shifts, errors: w.errors, brk: w.brk, note: d.note || "", reg: 0, ot: 0, dt: 0, seventh: false }; }); // workweeks var weeks = [], cur = null; var kind = period.kind || s.kind; days.forEach(function (d, i) { var newWeek = !cur || (kind === "semimonth" ? d.dow === s.weekStart : i % 7 === 0); if (newWeek) { cur = { days: [], reg: 0, ot: 0, dt: 0, worked: 0 }; weeks.push(cur); } cur.days.push(d); }); weeks.forEach(function (wk) { var regSoFar = 0, consecutive = 0; wk.days.forEach(function (d, idx) { var m = d.worked, ot = 0, dt = 0, reg; consecutive = m > 0 ? consecutive + 1 : 0; if (s.seventhOn && idx === 6 && consecutive === 7 && m > 0) { d.seventh = true; ot = Math.min(m, 480); dt = m - ot; if (!s.dtOn) { ot += dt; dt = 0; } reg = 0; } else { if (s.dtOn) dt = Math.max(0, m - s.dtAfter * 60); if (s.dailyOn) ot = Math.max(0, Math.min(m, s.dtOn ? s.dtAfter * 60 : m) - s.dailyAfter * 60); reg = m - ot - dt; if (s.weeklyOn) { var room = Math.max(0, s.weeklyAfter * 60 - regSoFar); if (reg > room) { ot += reg - room; reg = room; } } } regSoFar += reg; d.reg = reg; d.ot = ot; d.dt = dt; wk.reg += reg; wk.ot += ot; wk.dt += dt; wk.worked += m; }); wk.start = wk.days[0].date; wk.end = wk.days[wk.days.length - 1].date; }); var T = { worked: 0, reg: 0, ot: 0, dt: 0, brk: 0, daysWorked: 0, errors: 0 }; days.forEach(function (d) { T.worked += d.worked; T.reg += d.reg; T.ot += d.ot; T.dt += d.dt; T.brk += d.brk; if (d.worked > 0) T.daysWorked++; T.errors += d.errors.length; }); // pay from exact minutes, each line rounded to cents var rate = s.rate; T.rate = rate; T.regPay = round(T.reg / 60 * rate, 2); T.otPay = round(T.ot / 60 * rate * s.otMult, 2); T.dtPay = round(T.dt / 60 * rate * s.dtMult, 2); T.gross = round(T.regPay + T.otPay + T.dtPay, 2); return { days: days, weeks: weeks, totals: T, settings: s, start: dates[0], end: dates[dates.length - 1], kind: kind }; } /* ---------- money ---------- */ function cur(code) { return CURRENCIES.filter(function (c) { return c.code === code; })[0] || CURRENCIES[0]; } function money(v, code) { var c = cur(code); try { return new Intl.NumberFormat(c.locale, { style: "currency", currency: c.code }).format(v || 0); } catch (e) { return c.code + " " + (v || 0).toFixed(2); } } /* ---------- descriptions ---------- */ function ruleLines(s) { var o = []; if (s.weeklyOn) o.push("Weekly overtime after " + s.weeklyAfter + " h at " + s.otMult + "x"); if (s.dailyOn) o.push("daily overtime after " + s.dailyAfter + " h"); if (s.dtOn) o.push("double time (" + s.dtMult + "x) after " + s.dtAfter + " h a day"); if (s.seventhOn) o.push("7th consecutive day rule"); if (!o.length) o.push("No overtime rules"); var r = s.rounding ? "Clock times rounded to the nearest " + s.rounding + " min" : "Exact minutes, no rounding"; return o.join(", ") + ". " + r + "."; } function shiftText(sh, h24) { return sh.map(function (x) { return fmtClock(x.start, h24) + "–" + fmtClock(x.end, h24) + (x.end > DAY ? " (+1)" : ""); }).join(", "); } var KIND_LABEL = { week: "Weekly", biweek: "Bi-weekly", semimonth: "Semi-monthly" }; /* ---------- CSV (formula-injection guarded) ---------- */ function csvCell(v) { var s = String(v == null ? "" : v); if (/^[=+\-@\t\r]/.test(s) && !/^-?\d+(\.\d+)?$/.test(s)) s = "'" + s; return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function csvRow(a) { return a.map(csvCell).join(","); } /** Day-by-day CSV for one or more timesheets: [{employee:{name, ref}, result}] */ function csvDays(sheets) { var rows = [["Employee", "Employee ID", "Period start", "Period end", "Date", "Day", "Shifts", "Unpaid break (min)", "Hours worked", "Regular hours", "Overtime hours", "Double time hours", "Note"]]; sheets.forEach(function (sh) { var r = sh.result, e = sh.employee || {}; r.days.forEach(function (d) { if (!d.worked && !d.note) return; rows.push([e.name || "", e.ref || "", r.start, r.end, d.date, DOW[d.dow], shiftText(d.shifts, true), d.brk || 0, dec(d.worked), dec(d.reg), dec(d.ot), dec(d.dt), d.note]); }); }); return rows.map(csvRow).join("\r\n") + "\r\n"; } /** One line per timesheet: payroll summary. */ function csvSummary(sheets) { var rows = [["Employee", "Employee ID", "Period start", "Period end", "Hours worked", "Regular hours", "Overtime hours", "Double time hours", "Hourly rate", "Regular pay", "Overtime pay", "Double time pay", "Gross pay", "Currency"]]; sheets.forEach(function (sh) { var r = sh.result, T = r.totals, e = sh.employee || {}; rows.push([e.name || "", e.ref || "", r.start, r.end, dec(T.worked), dec(T.reg), dec(T.ot), dec(T.dt), T.rate.toFixed(2), T.regPay.toFixed(2), T.otPay.toFixed(2), T.dtPay.toFixed(2), T.gross.toFixed(2), r.settings.currency]); }); return rows.map(csvRow).join("\r\n") + "\r\n"; } /* ---------- examples ---------- */ function blankPeriod(kind, start) { return { kind: kind, start: start, days: periodDates(kind, start).map(function (d) { return { date: d, pairs: [{ in: "", out: "" }], brk: "", note: "" }; }) }; } function fill(period, list) { list.forEach(function (x, i) { if (x) { var d = period.days[i]; d.pairs = x.p.map(function (q) { return { in: q[0], out: q[1] }; }); d.brk = x.b == null ? "" : String(x.b); d.note = x.n || ""; } }); return period; } function example(which) { if (which === "night") { var p2 = blankPeriod("biweek", "2026-09-20"); var N = { p: [["10p", "6:30a"]], b: 30 }; fill(p2, [null, N, N, N, N, { p: [["6p", "6:30a"]], b: 30, n: "Covered a double shift" }, N, null, N, N, null, N, N, null]); return { settings: Object.assign(defaults(), { kind: "biweek", rate: "19.00" }), employee: { name: "Sam Okafor", ref: "N-207" }, company: "Riverside Care Home", period: p2 }; } if (which === "daily") { var p3 = blankPeriod("week", "2026-09-27"); fill(p3, [{ p: [["7a", "3:30p"]], b: 30 }, { p: [["7a", "3:30p"]], b: 30 }, { p: [["6a", "6:30p"]], b: 30, n: "Inventory count" }, { p: [["7a", "5:30p"]], b: 30 }, { p: [["7a", "3:30p"]], b: 30 }, { p: [["8a", "2p"]] }, { p: [["8a", "6:30p"]], b: 30 }]); return { settings: Object.assign(defaults(), { kind: "week", rate: "24.00", dailyOn: true, dtOn: true, seventhOn: true }), employee: { name: "Maria Lopez", ref: "W-031" }, company: "Valley Produce Co.", period: p3 }; } var p = blankPeriod("week", "2026-09-28"); fill(p, [{ p: [["7a", "11:30"], ["12", "3:30p"]], n: "Split shift" }, { p: [["7", "3:30"]], b: 30 }, { p: [["7a", "5p"]], b: 30 }, { p: [["7:00", "16:00"]], b: 30 }, { p: [["7a", "5:30p"]], b: 30, n: "Delivery day" }, { p: [["8a", "12:30p"]] }, null]); return { settings: Object.assign(defaults(), { kind: "week", rate: "22.50" }), employee: { name: "Jordan Rivera", ref: "E-104" }, company: "Northgate Hardware", period: p }; } var api = { DAY: DAY, DOW: DOW, DOW_LONG: DOW_LONG, CURRENCIES: CURRENCIES, ROUNDING: ROUNDING, KIND_LABEL: KIND_LABEL, parseTime: parseTime, span: span, normalizePair: normalizePair, roundTo: roundTo, fmtClock: fmtClock, normalize: normalize, hm: hm, dec: dec, round: round, parseNum: parseNum, addDays: addDays, dow: dow, validISO: validISO, fmtDate: fmtDate, fmtRange: fmtRange, iso: iso, toDate: toDate, periodDates: periodDates, periodStart: periodStart, shiftPeriod: shiftPeriod, defaults: defaults, settingsOf: settingsOf, freeRules: freeRules, dayWorked: dayWorked, compute: compute, money: money, cur: cur, ruleLines: ruleLines, shiftText: shiftText, csvCell: csvCell, csvDays: csvDays, csvSummary: csvSummary, blankPeriod: blankPeriod, example: example, }; if (typeof module === "object" && module.exports) module.exports = api; else root.HourCalc = api; })(typeof self !== "undefined" ? self : this);