Last active
August 19, 2025 10:32
-
-
Save franciscopaglia/3b3c2dd9ceb0c0f24b94581f2c341a56 to your computer and use it in GitHub Desktop.
Factorial Quick Shift Creator Tamper Monkey Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Factorial Quick Shift Creator | |
| // @author Francisco Paglia <https://github.com/franciscopaglia> | |
| // @namespace http://tampermonkey.net/ | |
| // @version 2025-08-14 | |
| // @description Adds two action buttons to Factorial HR for creating attendance shifts: 🇺🇸 (office) and 🏡 (home) | |
| // @match https://app.factorialhr.com/attendance/clock-in/* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ==== CONFIG ==== | |
| const EMPLOYEE_ID = "<YOUR_EMPLOYEE_ID>"; // <-- set yours | |
| const CLOCK_IN_TIME = '09:00:00'; // <-- Eight Hour Shift | |
| const CLOCK_OUT_TIME = '17:00:00'; // <-- Eight Hour Shift | |
| const SOURCE = 'desktop'; | |
| // x- headers | |
| const X_DEPLOYMENT_PHASE = 'default'; | |
| const X_FACTORIAL_ORIGIN = 'web'; | |
| const X_FACTORIAL_VERSION = 'b6eb8e6b6bd22364def5f5c0df72fd4156101b98'; | |
| // ==== DATE HELPERS ==== | |
| const monthMap = { // This will only work for Spanish dates, replace for other langs. | |
| 'ene.': 0, 'feb.': 1, 'mar.': 2, 'abr.': 3, 'may.': 4, 'jun.': 5, | |
| 'jul.': 6, 'ago.': 7, 'sep.': 8, 'oct.': 9, 'nov.': 10, 'dic.': 11 | |
| }; | |
| function parseSpanishDate(str) { | |
| const [dayStr, monthStr] = str.trim().split(/\s+/); | |
| const day = parseInt(dayStr, 10); | |
| const monthIndex = monthMap[(monthStr || '').toLowerCase()]; | |
| if (isNaN(day) || monthIndex === undefined) throw new Error(`Invalid date: "${str}"`); | |
| const year = new Date().getFullYear(); | |
| return new Date(year, monthIndex, day); | |
| } | |
| function ymd(date) { | |
| const yyyy = date.getFullYear(); | |
| const mm = String(date.getMonth() + 1).padStart(2, '0'); | |
| const dd = String(date.getDate()).padStart(2, '0'); | |
| return `${yyyy}-${mm}-${dd}`; | |
| } | |
| function localIsoWithOffset(dateOnlyStr, timeStr) { | |
| const [H, M, S] = timeStr.split(':').map(n => parseInt(n, 10)); | |
| const [y, m, d] = dateOnlyStr.split('-').map(n => parseInt(n, 10)); | |
| const dt = new Date(y, m - 1, d, H || 0, M || 0, S || 0); | |
| const tzOffsetMin = -dt.getTimezoneOffset(); | |
| const sign = tzOffsetMin >= 0 ? '+' : '-'; | |
| const tzH = String(Math.floor(Math.abs(tzOffsetMin) / 60)).padStart(2, '0'); | |
| const tzM = String(Math.abs(tzOffsetMin) % 60).padStart(2, '0'); | |
| const hh = String(dt.getHours()).padStart(2, '0'); | |
| const mi = String(dt.getMinutes()).padStart(2, '0'); | |
| const ss = String(dt.getSeconds()).padStart(2, '0'); | |
| return `${dateOnlyStr}T${hh}:${mi}:${ss}${sign}${tzH}:${tzM}`; | |
| } | |
| // ==== MODAL ==== | |
| function openConfirmModal({ title, message, acceptText = 'Accept', denyText = 'Deny' }) { | |
| return new Promise(resolve => { | |
| const host = document.createElement('div'); | |
| const root = host.attachShadow({ mode: 'closed' }); | |
| root.innerHTML = ` | |
| <style> | |
| .backdrop{position:fixed;inset:0;background:rgba(0,0,0,.4);display:flex;align-items:center;justify-content:center;z-index:999999} | |
| .dialog{background:#fff;color:#111;border-radius:12px;min-width:320px;max-width:90vw;box-shadow:0 10px 30px rgba(0,0,0,.2);padding:16px} | |
| .title{font:600 16px system-ui,Arial} | |
| .msg{font:14px system-ui,Arial;white-space:pre-line;margin:8px 0 16px} | |
| .actions{display:flex;gap:8px;justify-content:flex-end} | |
| button{border:0;border-radius:8px;padding:8px 12px;cursor:pointer;font:14px system-ui,Arial} | |
| .deny{background:#e5e7eb}.deny:hover{background:#d1d5db} | |
| .accept{background:#111827;color:#fff}.accept:hover{background:#0b1220} | |
| </style> | |
| <div class="backdrop" role="dialog" aria-modal="true"> | |
| <div class="dialog"> | |
| <div class="title"></div> | |
| <div class="msg"></div> | |
| <div class="actions"> | |
| <button class="deny" type="button"></button> | |
| <button class="accept" type="button"></button> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| root.querySelector('.title').textContent = title || 'Confirm action'; | |
| root.querySelector('.msg').textContent = message || 'Proceed?'; | |
| root.querySelector('.deny').textContent = denyText; | |
| root.querySelector('.accept').textContent = acceptText; | |
| const close = res => { host.remove(); resolve(res); }; | |
| root.querySelector('.backdrop').addEventListener('click', e => { if (e.target === e.currentTarget) close(false); }); | |
| root.querySelector('.deny').addEventListener('click', () => close(false)); | |
| root.querySelector('.accept').addEventListener('click', () => close(true)); | |
| document.body.appendChild(host); | |
| }); | |
| } | |
| // ==== GQL CALL ==== | |
| async function createAttendanceShift({ dateOnly, clockInIso, clockOutIso, referenceDate, locationType }) { | |
| const body = { | |
| operationName: 'CreateAttendanceShift', | |
| variables: { | |
| date: dateOnly, | |
| employeeId: EMPLOYEE_ID, | |
| clockIn: clockInIso, | |
| clockOut: clockOutIso, | |
| referenceDate, | |
| locationType, | |
| source: SOURCE, | |
| workable: true, | |
| }, | |
| query: ` | |
| mutation CreateAttendanceShift( | |
| $clockIn: ISO8601DateTime, $clockOut: ISO8601DateTime, $date: ISO8601Date!, | |
| $employeeId: Int!, $locationType: AttendanceShiftLocationTypeEnum, | |
| $referenceDate: ISO8601Date!, $source: AttendanceEnumsShiftSourceEnum, $workable: Boolean | |
| ) { | |
| attendanceMutations { | |
| createAttendanceShift( | |
| clockIn: $clockIn, clockOut: $clockOut, date: $date, employeeId: $employeeId, | |
| locationType: $locationType, referenceDate: $referenceDate, source: $source, | |
| workable: $workable, | |
| ) { | |
| errors { | |
| __typename | |
| ... on SimpleError { message type } | |
| ... on StructuredError { field messages } | |
| } | |
| } | |
| } | |
| } | |
| ` | |
| }; | |
| const res = await fetch('https://api.factorialhr.com/graphql?CreateAttendanceShift', { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { | |
| 'content-type': 'application/json', | |
| 'x-deployment-phase': X_DEPLOYMENT_PHASE, | |
| 'x-factorial-origin': X_FACTORIAL_ORIGIN, | |
| 'x-factorial-version': X_FACTORIAL_VERSION | |
| }, | |
| body: JSON.stringify(body) | |
| }); | |
| let json; | |
| try { json = await res.json(); } catch { json = null; } | |
| const errors = json?.data?.attendanceMutations?.createAttendanceShift?.errors ?? json?.errors ?? []; | |
| return { status: res.status, errors }; | |
| } | |
| // ==== BUTTON CREATOR ==== | |
| function createActionButton(referenceBtn, label, locationType, row) { | |
| const btn = document.createElement('button'); | |
| btn.type = 'button'; | |
| btn.title = `Create shift (${locationType === 'office' ? 'Office' : 'Work from Home'})`; | |
| btn.className = referenceBtn.className; | |
| btn.textContent = label; // emoji label | |
| btn.addEventListener('click', async (ev) => { | |
| ev.stopPropagation(); | |
| try { | |
| const dateSpan = row.querySelector('td:first-child span'); | |
| if (!dateSpan) throw new Error('Date cell not found'); | |
| const dateText = dateSpan.textContent.trim(); | |
| const dateObj = parseSpanishDate(dateText); | |
| const dateOnly = ymd(dateObj); | |
| const clockInIso = localIsoWithOffset(dateOnly, CLOCK_IN_TIME); | |
| const clockOutIso = localIsoWithOffset(dateOnly, CLOCK_OUT_TIME); | |
| const accepted = await openConfirmModal({ | |
| title: `Create shift (${locationType === 'office' ? 'Office' : 'Work from Home'})?`, | |
| message: | |
| `Date: ${dateOnly}\n` + | |
| `Clock-in: ${clockInIso}\n` + | |
| `Clock-out: ${clockOutIso}\n\nProceed?`, | |
| acceptText: 'Accept', | |
| denyText: 'Deny' | |
| }); | |
| if (!accepted) return; | |
| const { status, errors } = await createAttendanceShift({ | |
| dateOnly, | |
| clockInIso, | |
| clockOutIso, | |
| referenceDate: dateOnly, | |
| locationType | |
| }); | |
| if (errors.length) { | |
| console.warn(`Errors for ${locationType}:`, errors); | |
| alert(`API returned ${errors.length} error(s). Check console.`); | |
| } else if (status >= 200 && status < 300) { | |
| console.log(`Shift (${locationType}) created successfully.`); | |
| } else { | |
| alert(`Unexpected status ${status} for ${locationType}.`); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert(`Failed (${locationType}): ${e.message}`); | |
| } | |
| }); | |
| return btn; | |
| } | |
| // ==== MAIN BUTTON INJECTION ==== | |
| function addButtonsToRow(row) { | |
| const targetDiv = row.querySelector('td:nth-child(4) > div'); | |
| if (!targetDiv) return; | |
| const directButtons = Array.from(targetDiv.querySelectorAll(':scope > button')); | |
| const styledBtn = | |
| directButtons.find(b => b.className && b.className.trim()) || | |
| Array.from(targetDiv.querySelectorAll('button')).find(b => b.className && b.className.trim()); | |
| if (!styledBtn) return; | |
| if (directButtons.length > 0 && directButtons.every(b => b.disabled)) return; | |
| if (targetDiv.querySelector(':scope > button.tamper-office-btn')) return; // Already injected | |
| const officeBtn = createActionButton(styledBtn, '🏢', 'office', row); | |
| officeBtn.classList.add('tamper-office-btn'); | |
| const homeBtn = createActionButton(styledBtn, '🏡', 'work_from_home', row); // This might be a custom label | |
| homeBtn.classList.add('tamper-home-btn'); | |
| targetDiv.appendChild(officeBtn); | |
| targetDiv.appendChild(homeBtn); | |
| } | |
| function processAllRows() { | |
| document.querySelectorAll( | |
| 'table tbody tr' | |
| ).forEach(addButtonsToRow); | |
| } | |
| const observer = new MutationObserver(processAllRows); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| processAllRows(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment