commit 1a0cdebdadac74831eede5c417d0eabaf4173e3e Author: jeirmeister Date: Tue Apr 7 12:23:10 2026 -0700 Add IHSS timesheet autofill userscript Tampermonkey/Violentmonkey script that auto-populates IHSS timesheet hours with random distribution across configurable work days. Co-Authored-By: Claude Opus 4.6 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ diff --git a/ihss-autofill.user.js b/ihss-autofill.user.js new file mode 100644 index 0000000..6ded565 --- /dev/null +++ b/ihss-autofill.user.js @@ -0,0 +1,631 @@ +// ==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 +// ==/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 = ` + + +

IHSS Autofill

+
+
+ + +
+ + +
+
+ +
+ ${DAY_NAMES.map( + (name, i) => + `
${name}
` + ).join("")} +
+
+
+ + +
+
+ + +
+
+ + +
Settings auto-save
+
+
+ +
+ `; + document.body.appendChild(panel); + + // --- Draggable --- + let isDragging = false, offsetX, offsetY; + const handle = panel.querySelector("h3"); + handle.style.cursor = "grab"; + handle.addEventListener("mousedown", (e) => { + isDragging = true; + offsetX = e.clientX - panel.getBoundingClientRect().left; + offsetY = e.clientY - panel.getBoundingClientRect().top; + handle.style.cursor = "grabbing"; + }); + document.addEventListener("mousemove", (e) => { + if (!isDragging) return; + panel.style.left = e.clientX - offsetX + "px"; + panel.style.top = e.clientY - offsetY + "px"; + panel.style.right = "auto"; + }); + document.addEventListener("mouseup", () => { + isDragging = false; + handle.style.cursor = "grab"; + }); + + // --- Minimize --- + let minimized = false; + const bodyEls = panel.querySelectorAll( + ":scope > :not(#ihss-minimize):not(style)" + ); + document.getElementById("ihss-minimize").addEventListener("click", () => { + minimized = !minimized; + bodyEls.forEach((el) => (el.style.display = minimized ? "none" : "")); + panel.style.width = minimized ? "auto" : "340px"; + panel.style.padding = minimized ? "8px 16px" : "16px"; + document.getElementById("ihss-minimize").textContent = minimized + ? "+" + : "_"; + }); + + // --- Settings panel toggle --- + document + .getElementById("ihss-settings-toggle") + .addEventListener("click", () => { + const sp = document.getElementById("ihss-settings-panel"); + sp.style.display = sp.style.display === "none" ? "block" : "none"; + }); + + // --- Work day toggles --- + panel.querySelectorAll(".ihss-day-check").forEach((el) => { + el.addEventListener("click", () => { + el.classList.toggle("active"); + updatePrefsFromUI(); + }); + }); + + // --- Days per week slider --- + document.getElementById("ihss-dpw").addEventListener("input", (e) => { + document.getElementById("ihss-dpw-display").textContent = e.target.value; + updatePrefsFromUI(); + }); + + // --- Time inputs auto-save --- + document + .getElementById("ihss-start-time") + .addEventListener("change", updatePrefsFromUI); + document + .getElementById("ihss-end-time") + .addEventListener("change", updatePrefsFromUI); + + function updatePrefsFromUI() { + const workDays = []; + panel.querySelectorAll(".ihss-day-check.active").forEach((el) => { + workDays.push(parseInt(el.dataset.day)); + }); + const p = { + ...loadPrefs(), + workDays, + daysPerWeek: parseInt(document.getElementById("ihss-dpw").value), + startNoEarlier: document.getElementById("ihss-start-time").value, + endNoLater: document.getElementById("ihss-end-time").value, + }; + savePrefs(p); + } + + // --- State --- + let currentEntries = []; + const status = document.getElementById("ihss-status"); + const setStatus = (msg, cls) => { + status.innerHTML = `${msg}`; + }; + + // --- Generate --- + function generate() { + const hours = parseInt(document.getElementById("ihss-hours").value); + if (!hours || hours < 1) { + setStatus("Enter a number of hours", "ihss-err"); + return; + } + + // Save last-used hours + const p = loadPrefs(); + p.lastHours = hours; + savePrefs(p); + + const dates = readPageDates(); + if (dates.length === 0) { + setStatus( + "No timesheet inputs found — are you on the right page?", + "ihss-err" + ); + return; + } + + currentEntries = distributeHours(hours, dates, loadPrefs()); + if (currentEntries.length === 0) { + setStatus( + "No eligible days — check your work day settings", + "ihss-err" + ); + return; + } + + let html = ""; + for (const e of currentEntries) { + const d = new Date(e.iso + "T12:00:00"); + const dayStr = d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + html += ``; + } + html += "
DayHrsTime
${dayStr}${e.hours}h${e.start}-${e.end}
"; + document.getElementById("ihss-preview").innerHTML = html; + document.getElementById("ihss-actions").style.display = "flex"; + setStatus(`${currentEntries.length} days, ${hours}h total`, "ihss-ok"); + } + + document.getElementById("ihss-gen").addEventListener("click", generate); + document.getElementById("ihss-regen").addEventListener("click", generate); + document + .getElementById("ihss-hours") + .addEventListener("keydown", (e) => { + if (e.key === "Enter") generate(); + }); + + // --- Fill --- + document + .getElementById("ihss-fill") + .addEventListener("click", async () => { + if (currentEntries.length === 0) return; + + const prefs = loadPrefs(); + const btn = document.getElementById("ihss-fill"); + btn.disabled = true; + btn.textContent = "Filling..."; + + try { + await dismissModal(prefs.liveIn); + + setStatus("Expanding accordions...", "ihss-info"); + await expandAllAccordions(); + await sleep(500); + + for (let i = 0; i < currentEntries.length; i++) { + const entry = currentEntries[i]; + const d = new Date(entry.iso + "T12:00:00"); + const dayStr = d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + setStatus( + `Filling ${dayStr} (${i + 1}/${currentEntries.length})...`, + "ihss-info" + ); + await fillDay(entry, prefs.location); + await sleep(200); + } + + setStatus("Saving workweeks...", "ihss-info"); + await saveWorkweeks(currentEntries.map((e) => e.index)); + + setStatus("Done! Review and submit when ready.", "ihss-ok"); + } catch (err) { + setStatus(`Error: ${err.message}`, "ihss-err"); + console.error("IHSS Autofill error:", err); + } finally { + btn.disabled = false; + btn.textContent = "Fill Form"; + } + }); + } + + // --- Remove panel when navigating away --- + function removeUI() { + const panel = document.getElementById("ihss-autofill-panel"); + if (panel) panel.remove(); + } + + // --- SPA-aware lifecycle --- + let lastPath = window.location.pathname; + + function checkRoute() { + const currentPath = window.location.pathname; + if (currentPath !== lastPath) { + lastPath = currentPath; + if (isOnTimesheetPage()) { + setTimeout(createUI, 1000); + } else { + removeUI(); + } + } + } + + // Initial check + if (isOnTimesheetPage()) { + setTimeout(createUI, 1500); + } + + // Watch for SPA navigation + const observer = new MutationObserver(checkRoute); + observer.observe(document.body, { childList: true, subtree: true }); + + // Also poll in case MutationObserver misses a route change + setInterval(checkRoute, 1000); +})();