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) <noreply@anthropic.com>
This commit is contained in:
jeirmeister 2026-04-07 12:23:10 -07:00
commit 1a0cdebdad
2 changed files with 632 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.claude/

631
ihss-autofill.user.js Normal file
View file

@ -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 = `
<style>
#ihss-autofill-panel {
position: fixed; top: 80px; right: 20px; z-index: 99999;
background: #1a1a2e; color: #eee; border-radius: 12px;
padding: 16px; width: 340px; font-family: -apple-system, sans-serif;
box-shadow: 0 8px 32px rgba(0,0,0,0.5); border: 1px solid #0f3460;
}
#ihss-autofill-panel h3 {
margin: 0 0 12px 0; color: #e94560; font-size: 15px;
user-select: none;
}
#ihss-autofill-panel label {
font-size: 12px; color: #8892b0; display: block; margin-bottom: 3px;
}
#ihss-autofill-panel input[type=number],
#ihss-autofill-panel input[type=time] {
padding: 5px 7px; border: 2px solid #0f3460;
border-radius: 6px; background: #0a0a23; color: #eee; font-size: 14px;
}
#ihss-autofill-panel input[type=number] { width: 70px; }
#ihss-autofill-panel input[type=time] { width: 110px; }
#ihss-autofill-panel input:focus { outline: none; border-color: #e94560; }
#ihss-autofill-panel button {
padding: 7px 14px; border: none; border-radius: 6px; font-size: 13px;
cursor: pointer; font-weight: 600; transition: all 0.15s;
}
.ihss-btn-gen { background: #0f3460; color: #a8b2d1; }
.ihss-btn-gen:hover { background: #1a4a7a; }
.ihss-btn-fill { background: #e94560; color: white; }
.ihss-btn-fill:hover { background: #c73e54; }
.ihss-btn-save { background: #2d6a4f; color: white; }
.ihss-btn-save:hover { background: #40916c; }
.ihss-btn-settings { background: none; color: #8892b0; font-size: 16px; padding: 4px 8px; }
.ihss-btn-settings:hover { color: #eee; }
#ihss-preview { max-height: 220px; overflow-y: auto; margin-top: 10px; font-size: 12px; }
#ihss-preview table { width: 100%; border-collapse: collapse; }
#ihss-preview th { color: #8892b0; text-align: left; padding: 3px 6px; border-bottom: 1px solid #0f3460; }
#ihss-preview td { padding: 3px 6px; border-bottom: 1px solid #0a0a23; }
#ihss-status { margin-top: 8px; font-size: 12px; min-height: 18px; }
.ihss-ok { color: #4ade80; }
.ihss-err { color: #f87171; }
.ihss-info { color: #60a5fa; }
.ihss-row { display: flex; gap: 8px; align-items: end; margin-bottom: 10px; }
.ihss-btn-row { display: flex; gap: 6px; flex-wrap: wrap; }
#ihss-minimize {
position: absolute; top: 8px; right: 12px; background: none;
border: none; color: #8892b0; cursor: pointer; font-size: 18px; padding: 0;
}
#ihss-minimize:hover { color: #eee; }
#ihss-settings-panel {
display: none; margin-top: 10px; padding-top: 10px;
border-top: 1px solid #0f3460;
}
.ihss-day-checks { display: flex; gap: 4px; margin: 6px 0 10px 0; }
.ihss-day-check {
width: 36px; height: 28px; border-radius: 4px; border: 2px solid #0f3460;
background: #0a0a23; color: #8892b0; font-size: 11px; font-weight: 600;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s; user-select: none;
}
.ihss-day-check.active { background: #0f3460; color: #eee; border-color: #e94560; }
.ihss-time-row { display: flex; gap: 10px; margin-bottom: 8px; }
.ihss-time-row > div { flex: 1; }
</style>
<button id="ihss-minimize" title="Minimize">_</button>
<h3>IHSS Autofill</h3>
<div class="ihss-row">
<div>
<label for="ihss-hours">Total hours</label>
<input type="number" id="ihss-hours" min="1" max="200" value="${prefs.lastHours}">
</div>
<button class="ihss-btn-gen" id="ihss-gen">Generate</button>
<button class="ihss-btn-settings" id="ihss-settings-toggle" title="Settings">&#9881;</button>
</div>
<div id="ihss-settings-panel">
<label>Work days</label>
<div class="ihss-day-checks">
${DAY_NAMES.map(
(name, i) =>
`<div class="ihss-day-check ${prefs.workDays.includes(i) ? "active" : ""}" data-day="${i}">${name}</div>`
).join("")}
</div>
<div class="ihss-time-row">
<div>
<label for="ihss-start-time">Start no earlier than</label>
<input type="time" id="ihss-start-time" value="${prefs.startNoEarlier}">
</div>
<div>
<label for="ihss-end-time">End no later than</label>
<input type="time" id="ihss-end-time" value="${prefs.endNoLater}">
</div>
</div>
<label>Days per week: <strong id="ihss-dpw-display">${prefs.daysPerWeek}</strong></label>
<input type="range" id="ihss-dpw" min="1" max="7" value="${prefs.daysPerWeek}"
style="width:100%; margin-bottom:8px; accent-color:#e94560;">
<div style="font-size:11px; color:#4ade80; margin-top:4px;">Settings auto-save</div>
</div>
<div id="ihss-preview"></div>
<div class="ihss-btn-row" id="ihss-actions" style="display:none;">
<button class="ihss-btn-gen" id="ihss-regen">Regenerate</button>
<button class="ihss-btn-fill" id="ihss-fill">Fill Form</button>
</div>
<div id="ihss-status"></div>
`;
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 = `<span class="${cls}">${msg}</span>`;
};
// --- 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 = "<table><tr><th>Day</th><th>Hrs</th><th>Time</th></tr>";
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 += `<tr><td>${dayStr}</td><td>${e.hours}h</td><td>${e.start}-${e.end}</td></tr>`;
}
html += "</table>";
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);
})();