Last active
June 1, 2026 02:44
-
-
Save kujiy/546770a0a7d811fc4abc8a93d6859e05 to your computer and use it in GitHub Desktop.
AKASHI フレックス進捗ダッシュボード (v4.0)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name AKASHI フレックス進捗&打刻ダッシュボード (v8.0 完成版) | |
| // @namespace http://tampermonkey.net/ | |
| // @version 8.0 | |
| // @description SPAの壁を突破し、打刻ボタンへの実績表示とダッシュボードの最適配置を行います。 | |
| // @match *://*.ak4.jp/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| function timeToMins(timeStr) { | |
| if (!timeStr) return 0; | |
| const parts = timeStr.split(':'); | |
| return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10); | |
| } | |
| function minsToTime(mins) { | |
| const isNegative = mins < 0; | |
| const absMins = Math.abs(mins); | |
| const h = Math.floor(absMins / 60); | |
| const m = absMins % 60; | |
| return (isNegative ? "-" : "") + h + ":" + m.toString().padStart(2, '0'); | |
| } | |
| function extractData(doc) { | |
| console.log('--- [AKASHI Parse v8.0] データ抽出処理開始 ---'); | |
| let req = 0, act = 0; | |
| const headers = Array.from(doc.querySelectorAll('th, td, div')).filter(el => | |
| el.children.length <= 1 && ( | |
| el.textContent.trim() === '月間所定内労働時間' || | |
| el.textContent.trim() === '月間実総労働時間' | |
| ) | |
| ); | |
| headers.forEach(el => { | |
| const text = el.textContent.trim(); | |
| const isReq = text.includes('所定'); | |
| const tr = el.closest('tr'); | |
| if (tr) { | |
| const colIndex = Array.from(tr.children).indexOf(el.closest('td, th')); | |
| let nextTr = tr.nextElementSibling; | |
| if (!nextTr && tr.closest('thead')) { | |
| const tbody = tr.closest('table').querySelector('tbody'); | |
| if (tbody) nextTr = tbody.children[0]; | |
| } | |
| if (nextTr && nextTr.children.length > colIndex) { | |
| const valText = nextTr.children[colIndex].textContent.trim(); | |
| const match = valText.match(/\d{1,3}:\d{2}/); | |
| if (match) { | |
| if (isReq) req = timeToMins(match[0]); | |
| else act = timeToMins(match[0]); | |
| } | |
| } | |
| } | |
| }); | |
| let passed = 0, remaining = 0; | |
| const trs = Array.from(doc.querySelectorAll('tbody tr')); | |
| const now = new Date(); | |
| const currentMonth = now.getMonth() + 1; | |
| const currentDate = now.getDate(); | |
| let todayStamps = { attendance: '', leaving: '', break_start: '', break_end: '' }; | |
| trs.forEach((tr) => { | |
| const cells = Array.from(tr.querySelectorAll('td, th')); | |
| if (cells.length < 2) return; | |
| const rowText = tr.textContent.trim(); | |
| const dateMatch = rowText.match(/(\d{2})\/(\d{2})\([日月火水木金土]\)/); | |
| if (!dateMatch) return; | |
| const rowMonth = parseInt(dateMatch[1], 10); | |
| const rowDate = parseInt(dateMatch[2], 10); | |
| const isHoliday = rowText.includes('休日'); | |
| let isPastOrToday = false; | |
| if (rowMonth < currentMonth) isPastOrToday = true; | |
| else if (rowMonth === currentMonth && rowDate <= currentDate) isPastOrToday = true; | |
| if (!isHoliday) { | |
| if (isPastOrToday) passed++; | |
| else remaining++; | |
| } | |
| if (rowMonth === currentMonth && rowDate === currentDate) { | |
| console.log(`[AKASHI Parse] 🎯 今日の行を発見: ${rowMonth}/${rowDate}`); | |
| let timeCellText = ""; | |
| cells.forEach(cell => { | |
| const text = cell.textContent.trim(); | |
| if (text.includes(':') || text.includes('--')) { | |
| timeCellText += text + " "; | |
| } | |
| }); | |
| const timeMatches = timeCellText.match(/\d{2}:\d{2}/g); | |
| console.log(`[AKASHI Parse] 抽出された打刻時間:`, timeMatches); | |
| if (timeMatches && timeMatches.length > 0) { | |
| todayStamps.attendance = timeMatches[0]; | |
| if (timeMatches.length > 1) todayStamps.leaving = timeMatches[1]; | |
| if (timeMatches.length > 2) todayStamps.break_start = timeMatches[2]; | |
| if (timeMatches.length > 3) todayStamps.break_end = timeMatches[3]; | |
| } | |
| } | |
| }); | |
| if (passed === 0 && remaining === 0) { passed = 1; remaining = 1; } | |
| const totalDays = passed + remaining; | |
| if (req === 0 && totalDays > 0) req = totalDays * 8 * 60; | |
| const stdMinsPerDay = totalDays > 0 ? (req / totalDays) : (8 * 60); | |
| const shortage = req - act; | |
| const paceTarget = passed * stdMinsPerDay; | |
| const paceDiff = act - Math.round(paceTarget); | |
| const dailyQuota = remaining > 0 ? (shortage > 0 ? Math.ceil(shortage / remaining) : 0) : 0; | |
| return { req, act, shortage, paceDiff, remaining, passed, dailyQuota, todayStamps }; | |
| } | |
| function createDashboard(data) { | |
| const ourRow = document.createElement('div'); | |
| ourRow.id = 'akashi-flex-extended'; | |
| ourRow.style.display = 'flex'; | |
| ourRow.style.flexWrap = 'wrap'; | |
| ourRow.style.gap = '15px'; | |
| ourRow.style.marginTop = '25px'; | |
| ourRow.style.marginBottom = '25px'; | |
| ourRow.style.padding = '15px 20px'; | |
| ourRow.style.backgroundColor = '#f9fbfc'; | |
| ourRow.style.border = '1px dashed #c0ccda'; | |
| ourRow.style.borderRadius = '8px'; | |
| const titleHeader = document.createElement('div'); | |
| titleHeader.style.width = '100%'; | |
| titleHeader.style.fontSize = '14px'; | |
| titleHeader.style.fontWeight = 'bold'; | |
| titleHeader.style.color = '#5c6ac4'; | |
| titleHeader.style.marginBottom = '10px'; | |
| titleHeader.innerText = '📊 フレックス進捗・不足時間チェッカー'; | |
| ourRow.appendChild(titleHeader); | |
| function createBlock(title, valueStr, isSuccess, isInfo = false) { | |
| const block = document.createElement('div'); | |
| block.style.display = 'flex'; | |
| block.style.flexDirection = 'column'; | |
| block.style.padding = '12px 16px'; | |
| block.style.backgroundColor = '#fff'; | |
| block.style.borderLeft = `5px solid ${isSuccess ? '#28a745' : '#dc3545'}`; | |
| if (isInfo) block.style.borderLeft = `5px solid #17a2b8`; | |
| block.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)'; | |
| block.style.borderRadius = '4px'; | |
| block.style.minWidth = '180px'; | |
| block.style.flex = '1'; | |
| const titleEl = document.createElement('div'); | |
| titleEl.style.fontSize = '12px'; | |
| titleEl.style.color = '#6c757d'; | |
| titleEl.style.marginBottom = '6px'; | |
| titleEl.innerText = title; | |
| const valEl = document.createElement('div'); | |
| valEl.style.fontSize = '20px'; | |
| valEl.style.fontWeight = 'bold'; | |
| valEl.style.color = '#343a40'; | |
| valEl.innerHTML = valueStr; | |
| block.appendChild(titleEl); | |
| block.appendChild(valEl); | |
| return block; | |
| } | |
| ourRow.appendChild(createBlock('月間不足時間 (ノルマ)', data.shortage > 0 ? `🔥 ${minsToTime(data.shortage)}` : `✅ 0:00 <span style="font-size:14px; font-weight:normal; color:#6c757d;">(貯金 +${minsToTime(Math.abs(data.shortage))})</span>`, data.shortage <= 0)); | |
| ourRow.appendChild(createBlock('現在の過不足 (ペース)', data.paceDiff >= 0 ? `✅ +${minsToTime(data.paceDiff)}` : `🔥 ${minsToTime(data.paceDiff)}`, data.paceDiff >= 0)); | |
| ourRow.appendChild(createBlock('当月の残り営業日数', `🗓️ ${data.remaining} 日 <span style="font-size:14px; font-weight:normal; color:#6c757d;">(経過: ${data.passed}日)</span>`, true, true)); | |
| ourRow.appendChild(createBlock('今日の目標 (1日あたり)', data.shortage <= 0 ? `✅ 0:00` : `🔥 ${minsToTime(data.dailyQuota)} <span style="font-size:14px; font-weight:normal; color:#6c757d;">/日</span>`, data.shortage <= 0)); | |
| return ourRow; | |
| } | |
| // <a>タグの兄弟要素として実績時間を追加する関数 | |
| function appendStampTimesToButtons(stamps) { | |
| document.querySelectorAll('a[data-punch-type]').forEach(btn => { | |
| const type = btn.getAttribute('data-punch-type'); | |
| let timeStr = ''; | |
| // 打刻タイプに応じた時間の割り当て | |
| if (['attendance', 'straight_go'].includes(type)) timeStr = stamps.attendance; | |
| else if (['leaving', 'straight_return'].includes(type)) timeStr = stamps.leaving; | |
| else if (type === 'break_start') timeStr = stamps.break_start; | |
| else if (type === 'break_end') timeStr = stamps.break_end; | |
| // 時間が存在し、まだ追加されていなければ <a> タグの直後(兄弟要素)に追加 | |
| if (timeStr && !btn.parentNode.querySelector('.custom-stamp-time')) { | |
| // 外側の div-wrapper | |
| const wrapperDiv = document.createElement('div'); | |
| wrapperDiv.className = 'custom-stamp-time'; | |
| wrapperDiv.style.fontSize = '14px'; | |
| wrapperDiv.style.color = '#5c6ac4'; | |
| wrapperDiv.style.fontWeight = 'bold'; | |
| wrapperDiv.style.marginTop = '4px'; | |
| wrapperDiv.style.width = '100%'; | |
| wrapperDiv.style.textAlign = 'center'; | |
| wrapperDiv.style.position = 'relative'; | |
| // 内側の位置調整用 div | |
| const innerDiv = document.createElement('div'); | |
| innerDiv.style.position = 'relative'; | |
| innerDiv.style.top = '-70px'; | |
| innerDiv.style.left = '-23px'; | |
| innerDiv.innerText = `✅ ${timeStr}`; | |
| wrapperDiv.appendChild(innerDiv); | |
| // <a> タグの直後に兄弟要素として挿入 | |
| btn.parentNode.insertBefore(wrapperDiv, btn.nextSibling); | |
| } | |
| }); | |
| } | |
| function calculateAndDisplay() { | |
| if (document.getElementById('akashi-flex-extended')) return; | |
| const isPunchPage = window.location.pathname.includes('/punch'); | |
| const isAttendancePage = window.location.pathname.includes('/attendance'); | |
| if (isAttendancePage) { | |
| const data = extractData(document); | |
| if (!data) return; | |
| const targetWrapper = document.querySelector('.p-roster-table__wrapper') || document.querySelector('.p-roster-table') || document.querySelector('table'); | |
| if (targetWrapper && targetWrapper.parentNode) { | |
| targetWrapper.parentNode.insertBefore(createDashboard(data), targetWrapper.nextSibling); | |
| } | |
| } | |
| else if (isPunchPage) { | |
| // p-embossing-content を基準にする | |
| const targetWrapper = document.querySelector('.c-column__wrapper'); | |
| if (!targetWrapper) return; | |
| if (document.getElementById('akashi-hidden-iframe')) return; | |
| console.log('[AKASHI Iframe] 裏側で隠しIframeを作成し、本物のDOM描画を待ちます...'); | |
| const iframe = document.createElement('iframe'); | |
| iframe.id = 'akashi-hidden-iframe'; | |
| iframe.style.display = 'none'; | |
| const now = new Date(); | |
| const yyyy = now.getFullYear(); | |
| const mm = String(now.getMonth() + 1).padStart(2, '0'); | |
| iframe.src = `/ja/attendance/${yyyy}${mm}`; | |
| iframe.onload = () => { | |
| let attempts = 0; | |
| const checkInterval = setInterval(() => { | |
| attempts++; | |
| try { | |
| const doc = iframe.contentDocument || iframe.contentWindow.document; | |
| const trs = doc.querySelectorAll('tbody tr'); | |
| if (trs.length > 5) { | |
| clearInterval(checkInterval); | |
| console.log('[AKASHI Iframe] ✅ テーブルの描画を確認しました!'); | |
| const data = extractData(doc); | |
| if (data) { | |
| // .c-column__wrapper の「最後の子要素」として追加 | |
| targetWrapper.appendChild(createDashboard(data)); | |
| appendStampTimesToButtons(data.todayStamps); | |
| } | |
| iframe.remove(); | |
| } else if (attempts > 20) { | |
| clearInterval(checkInterval); | |
| console.error('[AKASHI Iframe] ❌ タイムアウト: テーブルが描画されませんでした。'); | |
| iframe.remove(); | |
| } | |
| } catch (e) { | |
| // クロスオリジン等の一時的なエラーは無視 | |
| } | |
| }, 500); | |
| }; | |
| document.body.appendChild(iframe); | |
| } | |
| } | |
| setInterval(calculateAndDisplay, 1000); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment