Skip to content

Instantly share code, notes, and snippets.

@franciscopaglia
Last active August 19, 2025 10:32
Show Gist options
  • Save franciscopaglia/3b3c2dd9ceb0c0f24b94581f2c341a56 to your computer and use it in GitHub Desktop.
Save franciscopaglia/3b3c2dd9ceb0c0f24b94581f2c341a56 to your computer and use it in GitHub Desktop.
Factorial Quick Shift Creator Tamper Monkey Script
// ==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