// ==UserScript== // @name RecoRu Work Clock // @name:en RecoRu Work Clock // @name:ja RecoRu 稼働時計 // @name:zh-CN RecoRu 工时时钟 // @namespace https://github.com/CheerChen // @version 1.0.0 // @description Show scheduled end time, working/labor time, and an analog clock on RecoRu. Auto-select workplace when accessed from office IPs. // @description:en Show scheduled end time, working/labor time, and an analog clock on RecoRu. Auto-select workplace when accessed from office IPs. // @description:ja RecoRu に所定退勤時刻・稼働時間・労働時間とアナログ時計を表示。オフィス IP からアクセス時は勤務地を自動選択します。 // @description:zh-CN 在 RecoRu 页面显示所定退勤时间、稼働时间、劳动时间与模拟时钟;从办公室 IP 访问时自动选择办公地点。 // @author cheerchen37 // @match https://app.recoru.in/ap/home/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @connect checkip.amazonaws.com // @icon https://www.google.com/s2/favicons?domain=app.recoru.in // @run-at document-idle // @license MIT // @homepage https://github.com/CheerChen/userscripts // @supportURL https://github.com/CheerChen/userscripts/issues // @updateURL https://raw.githubusercontent.com/CheerChen/userscripts/master/recoru-work-clock.user.js // ==/UserScript== (function () { 'use strict'; // -------------------------------------------------------------------------------- // ClockModule: canvas drawing with HiDPI support (ported from src/clock.js) // -------------------------------------------------------------------------------- const ClockModule = (function () { // Prepares the canvas with the correct DPI settings for HiDPI displays. // Returns the clock radius. function prepareCanvasDpi(canvas, context) { const dpr = window.devicePixelRatio; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; context.scale(dpr, dpr); canvas.style.width = `${rect.width}px`; canvas.style.height = `${rect.height}px`; const minSide = Math.min(rect.width, rect.height); const padding = 30; const radius = minSide / 2 - padding; const midX = rect.width / 2; const midY = rect.height / 2; context.translate(midX, midY); return radius; } // Draws the clock. excludeBreak toggles the 1-hour break visualization after 6 hours. function drawClock(context, radius, start_time, end_time, now = new Date(), excludeBreak = true) { drawFace(context, radius); const diff = now.getTime() - end_time.getTime(); // Gray pie for total scheduled office time (9 hours including break). const sixHourMarkGray = new Date(start_time.getTime() + 6 * 60 * 60 * 1000); const sevenHourMarkGray = new Date(start_time.getTime() + 7 * 60 * 60 * 1000); drawWorkingTimePie(context, radius, 4, start_time, sixHourMarkGray, '#aaa'); drawWorkingTimePie(context, radius, 4, sixHourMarkGray, sevenHourMarkGray, '#ccc'); drawWorkingTimePie(context, radius, 4, sevenHourMarkGray, end_time, '#aaa'); if (!excludeBreak) { if (diff < 0) { drawWorkingTimePie(context, radius, 8, start_time, now, '#2a2'); } else { drawWorkingTimePie(context, radius, 8, start_time, end_time, '#2a2'); drawWorkingTimePie(context, radius, 8, end_time, now, 'orange'); } } else { const elapsed = now.getTime() - start_time.getTime(); const sixHours = 6 * 60 * 60 * 1000; const sevenHours = 7 * 60 * 60 * 1000; const nineHours = 9 * 60 * 60 * 1000; if (elapsed <= sixHours) { drawWorkingTimePie(context, radius, 8, start_time, now, '#2a2'); } else if (elapsed <= sevenHours) { const sixHourMark = new Date(start_time.getTime() + sixHours); drawWorkingTimePie(context, radius, 8, start_time, sixHourMark, '#2a2'); drawWorkingTimePie(context, radius, 8, sixHourMark, now, '#87CEEB'); } else if (elapsed <= nineHours) { const sixHourMark = new Date(start_time.getTime() + sixHours); drawWorkingTimePie(context, radius, 8, start_time, sixHourMark, '#2a2'); const sevenHourMark = new Date(start_time.getTime() + sevenHours); drawWorkingTimePie(context, radius, 8, sixHourMark, sevenHourMark, '#87CEEB'); drawWorkingTimePie(context, radius, 8, sevenHourMark, now, '#2a2'); } else { const sixHourMark = new Date(start_time.getTime() + sixHours); drawWorkingTimePie(context, radius, 8, start_time, sixHourMark, '#2a2'); const sevenHourMark = new Date(start_time.getTime() + sevenHours); drawWorkingTimePie(context, radius, 8, sixHourMark, sevenHourMark, '#87CEEB'); const nineHourMark = new Date(start_time.getTime() + nineHours); drawWorkingTimePie(context, radius, 8, sevenHourMark, nineHourMark, '#2a2'); // Late-night overtime (after 22:00) shown in a distinct color. const lateNightStart = new Date(now); lateNightStart.setHours(22, 0, 0, 0); if (now.getTime() <= lateNightStart.getTime()) { drawWorkingTimePie(context, radius, 8, nineHourMark, now, 'orange'); } else if (nineHourMark.getTime() >= lateNightStart.getTime()) { drawWorkingTimePie(context, radius, 8, nineHourMark, now, '#CC3366'); } else { drawWorkingTimePie(context, radius, 8, nineHourMark, lateNightStart, 'orange'); drawWorkingTimePie(context, radius, 8, lateNightStart, now, '#CC3366'); } } } drawNumbers(context, radius); drawTime(context, radius, now); } function drawFace(ctx, radius) { ctx.beginPath(); ctx.arc(0, 0, radius, 0, 2 * Math.PI); ctx.fillStyle = 'white'; ctx.fill(); ctx.lineWidth = 8; ctx.strokeStyle = '#333'; ctx.stroke(); } function drawNumbers(ctx, radius) { ctx.font = `${radius * 0.15}px Arial`; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillStyle = '#333'; for (let num = 1; num <= 12; num++) { const angle = (num * Math.PI) / 6 - Math.PI / 2; const x = radius * 0.85 * Math.cos(angle); const y = radius * 0.85 * Math.sin(angle); ctx.fillText(num.toString(), x, y); } } function drawWorkingTimePie(ctx, radius, distance, startTime, endTime, fillStyle) { const rad = radius - distance; const zeroPoint = -Math.PI / 2; const lengthPerHour = Math.PI / 6; const startPoint = (startTime.getHours() + startTime.getMinutes() / 60) * lengthPerHour + zeroPoint; const endPoint = (endTime.getHours() + endTime.getMinutes() / 60) * lengthPerHour + zeroPoint; ctx.lineWidth = 1; ctx.fillStyle = fillStyle; ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(rad * Math.cos(startPoint), rad * Math.sin(startPoint)); ctx.arc(0, 0, rad, startPoint, endPoint); ctx.lineTo(0, 0); ctx.fill(); } function drawTime(ctx, radius, now = new Date()) { drawCenterDot(ctx, radius); const hour = now.getHours() % 12; const minute = now.getMinutes(); const second = now.getSeconds(); const hourAngle = ((hour + minute / 60) * Math.PI) / 6; drawHand(ctx, hourAngle, radius * 0.5, 8); const minuteAngle = ((minute + second / 60) * Math.PI) / 30; drawHand(ctx, minuteAngle, radius * 0.8, 6); const secondAngle = (second * Math.PI) / 30; drawHand(ctx, secondAngle, radius * 0.9, 2, 'red'); } function drawCenterDot(ctx, radius, color = '#333') { ctx.beginPath(); ctx.arc(0, 0, radius * 0.1, 0, 2 * Math.PI); ctx.fillStyle = color; ctx.fill(); } function drawHand(ctx, pos, length, width, color = '#333') { ctx.beginPath(); ctx.lineWidth = width; ctx.lineCap = 'round'; ctx.strokeStyle = color; ctx.moveTo(0, 0); ctx.rotate(pos); ctx.lineTo(0, -length); ctx.stroke(); ctx.rotate(-pos); } return { drawClock: drawClock, prepareCanvasDpi: prepareCanvasDpi, }; })(); // -------------------------------------------------------------------------------- // Content logic (ported from src/content.js) // -------------------------------------------------------------------------------- // Storage key for user-configured office IP list (kept out of source so the // published script carries no company-internal information). const OFFICE_IPS_KEY = 'recoruWorkClock.officeIps'; // Returns the user's configured office IP list, or [] if unset. function getOfficeIps() { const ips = GM_getValue(OFFICE_IPS_KEY, []); return Array.isArray(ips) ? ips : []; } // Check current IP address via GM_xmlhttpRequest (cross-origin) and set // workplace to オフィス if the IP is in the user-configured office list. function checkIPAndSetWorkplace() { const officeIps = getOfficeIps(); if (officeIps.length === 0) { // No office IPs configured — skip the lookup entirely. return; } GM_xmlhttpRequest({ method: 'GET', url: 'https://checkip.amazonaws.com', onload: function (response) { const currentIP = (response.responseText || '').trim(); if (currentIP && officeIps.includes(currentIP)) { setWorkplaceToOffice(); } }, onerror: function (error) { console.error('RecoRu Work Clock: failed to check IP address:', error); }, }); } function setWorkplaceToOffice() { const workPlaceSelect = document.getElementById('workPlaceId'); if (workPlaceSelect) { // Find the option with value "1" (オフィス) and select it const officeOption = workPlaceSelect.querySelector('option[value="1"]'); if (officeOption) { workPlaceSelect.value = '1'; console.log('RecoRu Work Clock: workplace automatically set to オフィス based on office IP'); } } } function refreshTime(div, start_time, context, radius) { const end_time = new Date(start_time.getTime() + 9 * 60 * 60 * 1000); const now = new Date(); const elapsed = now.getTime() - start_time.getTime(); const elapsed_hour = Math.floor(elapsed / (60 * 60 * 1000)); const elapsed_min = Math.floor((elapsed % (60 * 60 * 1000)) / (60 * 1000)); const elapsed_time = formatTime(elapsed_hour, elapsed_min); // 労働時間: assumes a 1-hour break after 6 hours of work (legal minimum). const labor_time = formatLaborTime(elapsed_hour, elapsed_min); const diff_time = calculateDiffTime(now, end_time); const end_time_str = formatTime(end_time.getHours(), end_time.getMinutes()); updateDivContent(div, end_time_str, elapsed_time, labor_time, diff_time, now, end_time); ClockModule.drawClock(context, radius, start_time, end_time, now, true); } function formatTime(hours, minutes) { return ("0" + hours).slice(-2) + ":" + ("0" + minutes).slice(-2); } // 労働時間の表示 // 「毎日1時間の休憩」と仮定する, UI表示上、始業から6時間経過したら1時間休憩 function formatLaborTime(elapsed_hour, elapsed_min) { let labor_hour = elapsed_hour < 6 ? elapsed_hour : (elapsed_hour - 1); return formatTime(labor_hour, elapsed_min); } function calculateDiffTime(now, end_time) { const diff = now.getTime() - end_time.getTime(); const diff_hour = Math.floor(Math.abs(diff) / (60 * 60 * 1000)); const diff_min = Math.floor((Math.abs(diff) % (60 * 60 * 1000)) / (60 * 1000)); const sign = diff < 0 ? "-" : "+"; return sign + formatTime(diff_hour, diff_min); } function updateDivContent(div, end_time_str, elapsed_time, labor_time, diff_time, now, end_time) { const childIdx = 1; div.childNodes[childIdx + 0].textContent = "所定退勤:" + end_time_str; div.childNodes[childIdx + 1].textContent = "稼働時間:+" + elapsed_time; div.childNodes[childIdx + 2].textContent = "労働時間:+" + labor_time; div.childNodes[childIdx + 3].textContent = "過不足:" + diff_time; const isOvertime = now.getTime() > end_time.getTime(); div.childNodes[childIdx + 2].style.color = isOvertime ? "#97D077" : "red"; div.childNodes[childIdx + 3].style.color = isOvertime ? "#FFB570" : "red"; } function createDivElement() { const div = document.createElement('div'); div.style.display = 'block'; div.classList.add('h-gadget', 'h-punchGadget'); div.appendChild(createHeader()); for (let i = 0; i < 4; i++) { const p = document.createElement('div'); p.style.fontSize = '14px'; div.appendChild(p); } const canvas = document.createElement('canvas'); canvas.width = 250; canvas.height = 250; div.appendChild(canvas); return div; } function modifyPage() { const div = createDivElement(); const pgNode = document.getElementById('PG'); pgNode.parentNode.insertBefore(div, pgNode.nextSibling); const canvas = div.querySelector('canvas'); const context = canvas.getContext('2d'); const radius = ClockModule.prepareCanvasDpi(canvas, context); let start_time = tryRetrieveStartTime(); // Check IP and set workplace if user hasn't clocked in yet if (!start_time) { checkIPAndSetWorkplace(); } if (start_time) { refreshTime(div, start_time, context, radius); } setInterval(() => { if (!start_time) { start_time = tryRetrieveStartTime(); } if (start_time) { refreshTime(div, start_time, context, radius); } }, 1000); } // 始業時間を取得する function tryRetrieveStartTime() { const startTimeLabel = document.querySelector('#PunchGadgetForm > table > tbody > tr:nth-child(1) > td:nth-child(1) > label'); if (startTimeLabel) { const str_start_time = startTimeLabel.textContent.trim(); if (str_start_time) { const start_time = new Date(); const [hours, minutes] = str_start_time.split(':').map(Number); start_time.setHours(hours); start_time.setMinutes(minutes); return start_time; } } return null; } function createHeader() { const header = document.createElement('div'); header.classList.add('h-gadget-header', 'h-punchGadget-header'); const headerTitle = document.createElement('label'); headerTitle.textContent = '稼働情報'; header.appendChild(headerTitle); // Right-aligned action button: office-IP config only. const actions = document.createElement('label'); actions.style = "float:right; margin-right: 5px; margin-top: -2px;"; actions.appendChild(createConfigButton()); header.appendChild(actions); return header; } function createConfigButton() { const iconButton = document.createElement('a'); iconButton.href = "#"; iconButton.title = "オフィス IP を設定"; iconButton.classList.add('link', 'icon-f16', 'icon-settings-white'); iconButton.onclick = (e) => { e.preventDefault(); openOfficeIpConfigDialog(); }; return iconButton; } // -------------------------------------------------------------------------------- // Office-IP configuration dialog (pure DOM, no dependencies). // -------------------------------------------------------------------------------- function openOfficeIpConfigDialog() { // Guard against duplicate overlays. const existing = document.getElementById('recoruWorkClockConfigOverlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.id = 'recoruWorkClockConfigOverlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:99999;display:flex;align-items:center;justify-content:center;font-family:sans-serif;'; const dialog = document.createElement('div'); dialog.style.cssText = 'background:#fff;padding:20px;border-radius:8px;width:420px;max-width:90vw;box-shadow:0 4px 20px rgba(0,0,0,0.2);'; const title = document.createElement('h3'); title.textContent = 'オフィス IP 一覧'; title.style.cssText = 'margin:0 0 8px 0;font-size:16px;'; const hint = document.createElement('p'); hint.textContent = '1 行に 1 件ずつ入力してください。これらの IP からアクセスした場合、勤務地が自動で「オフィス」に設定されます。空欄の場合はこの機能は無効になります。'; hint.style.cssText = 'margin:0 0 12px 0;font-size:12px;color:#666;line-height:1.4;'; const textarea = document.createElement('textarea'); textarea.value = getOfficeIps().join('\n'); textarea.placeholder = '203.0.113.10\n203.0.113.11\n...'; textarea.style.cssText = 'width:100%;height:180px;box-sizing:border-box;font-family:monospace;font-size:13px;padding:8px;border:1px solid #ccc;border-radius:4px;margin-bottom:12px;'; const btnRow = document.createElement('div'); btnRow.style.cssText = 'display:flex;justify-content:flex-end;gap:8px;'; const cancelBtn = document.createElement('button'); cancelBtn.textContent = 'キャンセル'; cancelBtn.style.cssText = 'padding:6px 14px;border:1px solid #ccc;background:#f5f5f5;border-radius:4px;cursor:pointer;'; const saveBtn = document.createElement('button'); saveBtn.textContent = '保存'; saveBtn.style.cssText = 'padding:6px 14px;border:none;background:#2a2;color:#fff;border-radius:4px;cursor:pointer;'; cancelBtn.onclick = () => overlay.remove(); saveBtn.onclick = () => { const ips = textarea.value .split('\n') .map(s => s.trim()) .filter(Boolean); GM_setValue(OFFICE_IPS_KEY, ips); overlay.remove(); }; btnRow.appendChild(cancelBtn); btnRow.appendChild(saveBtn); dialog.appendChild(title); dialog.appendChild(hint); dialog.appendChild(textarea); dialog.appendChild(btnRow); overlay.appendChild(dialog); // Click on backdrop closes the dialog. overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; document.body.appendChild(overlay); textarea.focus(); } // -------------------------------------------------------------------------------- // Init: wait for RecoRu's #PG node to be populated, then inject. // Replaces the original window.onload hook with a ready-state-aware observer // so it also works under userscript @run-at document-idle. // -------------------------------------------------------------------------------- function startObserver() { const observer = new MutationObserver((mutations, obs) => { const pgNode = document.getElementById('PG'); if (pgNode && pgNode.childElementCount > 0) { obs.disconnect(); modifyPage(); } }); // Fast path: #PG already populated when the script runs. const pgNode = document.getElementById('PG'); if (pgNode && pgNode.childElementCount > 0) { modifyPage(); return; } observer.observe(document.body, { childList: true, subtree: true }); } if (document.body) { startObserver(); } else { document.addEventListener('DOMContentLoaded', startObserver, { once: true }); } })();