Created
February 9, 2026 11:55
-
-
Save vyach-vasiliev/651ce1e749ff5ecf6ca8825e15f2774f to your computer and use it in GitHub Desktop.
Redmine Issue Hours Tracker Bookmarklet
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
| (async () => { | |
| const userId = 321; | |
| const base = location.origin; | |
| // Валидные ссылки на issue | |
| const issueRegex = /\/issues\/(\d+)(?:\/)?(?:[?#].*)?$/; | |
| const links = [...document.querySelectorAll('a[href*="/issues/"]')]; | |
| const issueMap = new Map(); | |
| // Собираем ссылки по issue id | |
| links.forEach(a => { | |
| const m = a.href.match(issueRegex); | |
| if (!m) return; | |
| const id = m[1]; | |
| if (!issueMap.has(id)) issueMap.set(id, []); | |
| issueMap.get(id).push(a); | |
| }); | |
| if (!issueMap.size) { | |
| alert("Valid issue links not found"); | |
| return; | |
| } | |
| const issueIds = [...issueMap.keys()]; | |
| // Формат часов: 5.0 → 5 | |
| function formatHours(value) { | |
| const num = Number(value) || 0; | |
| return Number.isInteger(num) ? String(num) : num.toFixed(1); | |
| } | |
| // Цвет по соотношению | |
| function getColor(spent, est) { | |
| if (!est) return "#888"; | |
| const ratio = spent / est; | |
| if (ratio > 1) return "#d00"; | |
| if (ratio >= 0.8) return "#e67e00"; | |
| return "#080"; | |
| } | |
| // Получение часов по issue | |
| async function getHours(issueId) { | |
| let offset = 0; | |
| let limit = 100; | |
| let total = Infinity; | |
| let sum = 0; | |
| while (offset < total) { | |
| const url = | |
| `${base}/time_entries.json` + | |
| `?user_id=${userId}` + | |
| `&issue_id=${issueId}` + | |
| `&offset=${offset}` + | |
| `&limit=${limit}`; | |
| const res = await fetch(url, { credentials: "include" }); | |
| const data = await res.json(); | |
| total = data.total_count || 0; | |
| (data.time_entries || []).forEach(e => { | |
| sum += Number(e.hours) || 0; | |
| }); | |
| offset += limit; | |
| } | |
| return sum; | |
| } | |
| // Загружаем фактические часы параллельно | |
| const hoursMap = {}; | |
| await Promise.all( | |
| issueIds.map(async id => { | |
| hoursMap[id] = await getHours(id); | |
| }) | |
| ); | |
| // Получаем оценки задач одним запросом | |
| const issuesUrl = | |
| `${base}/issues.json?status_id=*` + | |
| `&issue_id=${issueIds.join(",")}`; | |
| const issuesRes = await fetch(issuesUrl, { | |
| credentials: "include" | |
| }); | |
| const issuesData = await issuesRes.json(); | |
| const estimateMap = {}; | |
| (issuesData.issues || []).forEach(i => { | |
| estimateMap[i.id] = Number(i.estimated_hours) || 0; | |
| }); | |
| // Отображаем результат рядом со ссылками | |
| issueMap.forEach((anchors, id) => { | |
| const spent = hoursMap[id] || 0; | |
| const est = estimateMap[id] || 0; | |
| const color = getColor(spent, est); | |
| const spentText = formatHours(spent); | |
| const estText = est ? formatHours(est) : "–"; | |
| anchors.forEach(a => { | |
| const span = document.createElement("span"); | |
| span.textContent = ` (${spentText}/${estText} h)`; | |
| span.style.color = color; | |
| span.style.fontWeight = "bold"; | |
| a.after(span); | |
| }); | |
| }); | |
| alert("Done"); | |
| })(); |
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
| javascript:(async()=>{const userId=321,base=location.origin,issueRegex=/\/issues\/(\d+)(?:\/)?(?:[?#].*)?$/;const links=[...document.querySelectorAll('a[href*="/issues/"]')];const issueMap=new Map();links.forEach(a=>{const m=a.href.match(issueRegex);if(!m)return;const id=m[1];if(!issueMap.has(id))issueMap.set(id,[]);issueMap.get(id).push(a)});if(!issueMap.size){alert("Valid issue links not found");return}const issueIds=[...issueMap.keys()];function format(v){const n=Number(v)||0;return Number.isInteger(n)?String(n):n.toFixed(1)}function color(spent,est){if(!est)return"#888";const r=spent/est;if(r>1)return"#d00";if(r>=0.8)return"#e67e00";return"#080"}async function getHours(issueId){let offset=0,limit=100,total=Infinity,sum=0;while(offset<total){const url=`${base}/time_entries.json?user_id=${userId}&issue_id=${issueId}&offset=${offset}&limit=${limit}`;const res=await fetch(url,{credentials:"include"});const data=await res.json();total=data.total_count||0;(data.time_entries||[]).forEach(e=>{sum+=Number(e.hours)||0});offset+=limit}return sum}const hoursMap={};await Promise.all(issueIds.map(async id=>{hoursMap[id]=await getHours(id)}));const issuesUrl=`${base}/issues.json?status_id=*&issue_id=${issueIds.join(",")}`;const issuesRes=await fetch(issuesUrl,{credentials:"include"});const issuesData=await issuesRes.json();const estimateMap={};(issuesData.issues||[]).forEach(i=>{estimateMap[i.id]=Number(i.estimated_hours)||0});issueMap.forEach((anchors,id)=>{const spent=hoursMap[id]||0;const est=estimateMap[id]||0;const col=color(spent,est);const spentText=format(spent);const estText=est?format(est):"–";anchors.forEach(a=>{const span=document.createElement("span");span.textContent=` (${spentText}/${estText} h)`;span.style.color=col;span.style.fontWeight="bold";a.after(span)})});})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment