Skip to content

Instantly share code, notes, and snippets.

@kujiy
Last active June 1, 2026 02:44
Show Gist options
  • Select an option

  • Save kujiy/546770a0a7d811fc4abc8a93d6859e05 to your computer and use it in GitHub Desktop.

Select an option

Save kujiy/546770a0a7d811fc4abc8a93d6859e05 to your computer and use it in GitHub Desktop.
AKASHI フレックス進捗ダッシュボード (v4.0)
// ==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