// ==UserScript== // @name IHSS Timesheet Autofill // @namespace ihss-autofill // @version 2.0 // @description Auto-populate IHSS timesheet hours with random distribution // @match https://etimesheets.ihss.ca.gov/* // @grant GM_getValue // @grant GM_setValue // @run-at document-idle // @downloadURL https://git.jeirslab.xyz/jeirmeister/tampermonkey-scripts/raw/branch/master/scripts/ihss-autofill.user.js // @updateURL https://git.jeirslab.xyz/jeirmeister/tampermonkey-scripts/raw/branch/master/scripts/ihss-autofill.user.js // ==/UserScript== (function () { "use strict"; const TARGET_PATH = "/provider-ts-details"; // --- Default preferences --- const DEFAULTS = { location: "Home", liveIn: false, daysPerWeek: 5, workDays: [1, 2, 3, 4, 5], // 0=Sun, 1=Mon, ..., 6=Sat startNoEarlier: "07:00", endNoLater: "18:00", lastHours: 40, }; const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; // --- Persistent preferences --- function loadPrefs() { const saved = GM_getValue("ihss_prefs", null); if (!saved) return { ...DEFAULTS }; try { return { ...DEFAULTS, ...JSON.parse(saved) }; } catch { return { ...DEFAULTS }; } } function savePrefs(prefs) { GM_setValue("ihss_prefs", JSON.stringify(prefs)); } // --- Utility --- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function waitForElement(selector, timeout = 5000) { return new Promise((resolve, reject) => { const el = document.querySelector(selector); if (el) return resolve(el); const observer = new MutationObserver(() => { const el = document.querySelector(selector); if (el) { observer.disconnect(); resolve(el); } }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => { observer.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeout); }); } const nativeSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value" ).set; function setInputValue(el, value) { el.focus(); nativeSetter.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); el.dispatchEvent(new Event("blur", { bubbles: true })); } // --- Read available dates from the page --- function readPageDates() { const labels = document.querySelectorAll('[id^="hours-label-a11y-"]'); const inputIds = []; document.querySelectorAll('[id^="hours-"]').forEach((el) => { const m = el.id.match(/^hours-(\d+)$/); if (m) inputIds.push(parseInt(m[1])); }); inputIds.sort((a, b) => a - b); const dates = []; labels.forEach((label, i) => { if (i >= inputIds.length) return; const text = label.textContent.trim(); const match = text.match(/Hours for (.+)/); if (!match) return; const parsed = new Date(match[1]); if (isNaN(parsed)) return; dates.push({ iso: parsed.toISOString().split("T")[0], index: inputIds[i], dateObj: parsed, dayOfWeek: parsed.getDay(), // 0=Sun }); }); return dates; } // --- Distribute hours --- function distributeHours(totalHours, availableDates, prefs) { // Filter to preferred work days only const eligible = availableDates.filter((d) => prefs.workDays.includes(d.dayOfWeek) ); if (eligible.length === 0) return []; // Group by ISO week const weeks = {}; for (const d of eligible) { const dt = new Date(d.iso); const jan1 = new Date(dt.getFullYear(), 0, 1); const dayOfYear = Math.floor((dt - jan1) / 86400000); const weekNum = Math.ceil((dayOfYear + 1) / 7); if (!weeks[weekNum]) weeks[weekNum] = []; weeks[weekNum].push(d); } // Pick random days from each week (up to daysPerWeek) const selected = []; for (const wk of Object.keys(weeks).sort((a, b) => a - b)) { const pool = weeks[wk]; let pick = Math.min(prefs.daysPerWeek, pool.length); if (pool.length < prefs.daysPerWeek) { pick = Math.max(1, Math.round((pool.length * prefs.daysPerWeek) / 7)); } const shuffled = [...pool].sort(() => Math.random() - 0.5); selected.push( ...shuffled.slice(0, pick).sort((a, b) => a.index - b.index) ); } if (selected.length === 0) return []; // Spread hours const n = selected.length; const base = Math.floor(totalHours / n); let remainder = totalHours % n; const dayHours = selected.map(() => { const h = base + (remainder > 0 ? 1 : 0); if (remainder > 0) remainder--; return h; }); for (let i = dayHours.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [dayHours[i], dayHours[j]] = [dayHours[j], dayHours[i]]; } // Parse time constraints const [earliestH, earliestM] = prefs.startNoEarlier.split(":").map(Number); const [latestH, latestM] = prefs.endNoLater.split(":").map(Number); const earliestMinutes = earliestH * 60 + earliestM; const latestMinutes = latestH * 60 + latestM; return selected .map((d, i) => { const h = dayHours[i]; if (h === 0) return null; const workMinutes = h * 60; // Latest possible start so we finish by endNoLater const latestStart = latestMinutes - workMinutes; const effectiveEarliest = Math.max(earliestMinutes, 7 * 60); const effectiveLatest = Math.max(effectiveEarliest, latestStart); // Random start in 15-min increments within the allowed window const slots = []; for (let m = effectiveEarliest; m <= effectiveLatest; m += 15) { slots.push(m); } if (slots.length === 0) slots.push(effectiveEarliest); const startMins = slots[Math.floor(Math.random() * slots.length)]; const endMins = startMins + workMinutes; const sh = Math.floor(startMins / 60); const sm = startMins % 60; const eh = Math.floor(endMins / 60); const em = endMins % 60; return { ...d, hours: h, minutes: 0, start: `${String(sh).padStart(2, "0")}:${String(sm).padStart(2, "0")}`, end: `${String(eh).padStart(2, "0")}:${String(em).padStart(2, "0")}`, }; }) .filter(Boolean); } // --- Expand all workweek accordions --- async function expandAllAccordions() { const panels = document.querySelectorAll("mat-expansion-panel"); for (const panel of panels) { if (!panel.classList.contains("mat-expanded")) { const header = panel.querySelector("mat-expansion-panel-header"); if (header) { header.click(); await sleep(600); } } } } // --- Dismiss live-in modal --- async function dismissModal(liveIn) { const dialog = document.querySelector("mat-dialog-container"); if (!dialog) return; const buttons = dialog.querySelectorAll("mat-dialog-actions button"); for (const btn of buttons) { const text = btn.textContent.trim(); if ((!liveIn && text === "No") || (liveIn && text === "Yes")) { btn.click(); await sleep(800); return; } } } // --- Select a mat-option --- async function selectMatOption(text) { await waitForElement("mat-option"); await sleep(200); const options = document.querySelectorAll("mat-option"); for (const opt of options) { if (opt.textContent.trim().includes(text)) { opt.click(); await sleep(300); return; } } throw new Error(`Option "${text}" not found`); } // --- Fill a single day --- async function fillDay(entry, location) { const idx = entry.index; const hoursEl = document.getElementById(`hours-${idx}`); const minutesEl = document.getElementById(`minutes-${idx}`); const startEl = document.getElementById(`starttime-${idx}`); const endEl = document.getElementById(`endtime-${idx}`); const startLocEl = document.getElementById(`start-locationSelect-${idx}`); const endLocEl = document.getElementById(`end-locationSelect-${idx}`); if (!hoursEl) throw new Error(`hours-${idx} not found — is the accordion expanded?`); setInputValue(hoursEl, String(entry.hours).padStart(2, "0")); setInputValue(minutesEl, String(entry.minutes).padStart(2, "0")); setInputValue(startEl, entry.start); setInputValue(endEl, entry.end); startLocEl.click(); await selectMatOption(location); endLocEl.click(); await selectMatOption(location); } // --- Save workweeks --- async function saveWorkweeks(indices) { const wws = new Set( indices.map((idx) => (idx <= 6 ? 0 : idx <= 13 ? 1 : 2)) ); for (const ww of [...wws].sort()) { const btn = document.getElementById(`save-timesheet-button-${ww}`); if (btn && !btn.disabled) { btn.scrollIntoView({ behavior: "smooth", block: "center" }); await sleep(300); btn.click(); await sleep(2000); } } } // --- URL check --- function isOnTimesheetPage() { return window.location.pathname.includes(TARGET_PATH); } // --- Build the floating UI --- function createUI() { if (!isOnTimesheetPage()) return; if (document.getElementById("ihss-autofill-panel")) return; const prefs = loadPrefs(); const panel = document.createElement("div"); panel.id = "ihss-autofill-panel"; panel.innerHTML = `
| Day | Hrs | Time |
|---|---|---|
| ${dayStr} | ${e.hours}h | ${e.start}-${e.end} |