Skip to content

Instantly share code, notes, and snippets.

@vyach-vasiliev
Created February 9, 2026 11:55
Show Gist options
  • Select an option

  • Save vyach-vasiliev/651ce1e749ff5ecf6ca8825e15f2774f to your computer and use it in GitHub Desktop.

Select an option

Save vyach-vasiliev/651ce1e749ff5ecf6ca8825e15f2774f to your computer and use it in GitHub Desktop.
Redmine Issue Hours Tracker Bookmarklet
(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");
})();
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