Расширение «Моё время в Битрикс24»

Часы по задачам за месяц и остаток до нормы, через REST от сессии браузера.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Alex Cube
2026-10-26 01:14:06 +03:00
co-authored by Claude Opus 5.5
commit c701f07483
8 changed files with 252 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Alexey Yakubov
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+36
View File
@@ -0,0 +1,36 @@
# Моё время в Битрикс24
Расширение для Chrome, Яндекс Браузера и других браузеров на Chromium. По кнопке
показывает, сколько часов вы затратили на задачи Битрикс24 за месяц, с разбивкой
по задачам, и сколько осталось до месячной нормы.
Работает с правами обычного сотрудника: не нужны ни администратор портала, ни
вебхуки, ни подписка на Маркет. Расширение обращается к REST API портала от
имени вашей сессии в браузере и только читает ваши записи затраченного времени.
## Установка
1. Скачайте репозиторий (или архив) и распакуйте.
2. Откройте `chrome://extensions/` (в Яндекс Браузере `browser://extensions/`),
включите «Режим разработчика».
3. «Загрузить распакованное расширение» → папка `extension`.
4. Откроются настройки: адрес портала (необязательно) и норма часов в месяц.
## Как пользоваться
Откройте портал, нажмите значок расширения. Повторное нажатие закрывает окно.
Месяц можно переключить. Время учитывается по записям «Затраченное время» в
задачах: пока таймер идёт, запись ещё не создана и в сумму не попадает.
## Проверка
```
node test/test_mytime.mjs
```
Прогоняет `mytime.js` на поддельном портале: постраничная загрузка, сумма по
задачам, отсев чужого месяца, строка нормы.
## Лицензия
MIT
+16
View File
@@ -0,0 +1,16 @@
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'install') chrome.runtime.openOptionsPage();
});
chrome.action.onClicked.addListener(async tab => {
const cfg = await chrome.storage.sync.get({ portal: '', norm: 0 });
if (cfg.portal && !(tab.url || '').startsWith(cfg.portal)) return chrome.tabs.create({ url: cfg.portal });
const target = { tabId: tab.id };
try {
// MAIN — чтобы скрипту были видны BX и сессия портала
await chrome.scripting.executeScript({ target, world: 'MAIN', func: c => { window.myTimeConfig = c; }, args: [cfg] });
await chrome.scripting.executeScript({ target, world: 'MAIN', files: ['mytime.js'] });
} catch {
chrome.runtime.openOptionsPage(); // служебная страница браузера, туда скрипт не пустят
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"manifest_version": 3,
"name": "Моё время в Битрикс24",
"version": "1.1",
"description": "Сколько часов я отработал по задачам за месяц и сколько осталось до нормы",
"permissions": ["activeTab", "scripting", "storage"],
"background": { "service_worker": "background.js" },
"action": { "default_title": "Моё время" },
"options_page": "options.html"
}
+76
View File
@@ -0,0 +1,76 @@
(async () => {
const old = document.getElementById('mytime');
if (old) { old.remove(); return; }
if (!window.BX || !BX.bitrix_sessid) { alert('Открой портал Битрикс24 и войди в него'); return; }
const norm = (window.myTimeConfig || {}).norm || 0;
const call = async (method, params) => {
const r = await fetch('/rest/' + method + '.json?sessid=' + BX.bitrix_sessid(), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params),
});
const d = await r.json();
if (d.error) throw d.error + ' ' + (d.error_description || '');
return d.result;
};
const pad = n => String(n).padStart(2, '0');
const hm = s => Math.floor(s / 3600) + ':' + pad(Math.floor(s % 3600 / 60));
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const box = document.createElement('div');
box.id = 'mytime';
box.style.cssText = 'position:fixed;top:70px;right:20px;z-index:99999;background:#fff;padding:16px 20px;' +
'box-shadow:0 4px 24px rgba(0,0,0,.25);border-radius:8px;max-height:80vh;overflow:auto;font:14px/1.5 sans-serif;color:#333';
const now = new Date();
box.innerHTML = '<b>Моё время</b> <input type="month" id="mytime-m" value="' + now.getFullYear() + '-' + pad(now.getMonth() + 1) + '">' +
' <button id="mytime-x" style="float:right">✕</button><div id="mytime-out"></div>';
document.body.append(box);
const out = document.getElementById('mytime-out');
document.getElementById('mytime-x').onclick = () => box.remove();
async function run() {
const ym = document.getElementById('mytime-m').value;
const [y, m] = ym.split('-').map(Number);
const next = m === 12 ? (y + 1) + '-01' : y + '-' + pad(m + 1);
out.textContent = 'Считаю…';
try {
const me = (await call('user.current', {})).ID;
const perTask = {};
for (let page = 1; ; page++) {
const items = await call('task.elapseditem.getlist', {
ORDER: { ID: 'asc' },
FILTER: { USER_ID: me, '>=CREATED_DATE': ym + '-01T00:00:00', '<CREATED_DATE': next + '-01T00:00:00' },
SELECT: ['ID', 'TASK_ID', 'SECONDS', 'CREATED_DATE'],
PARAMS: { NAV_PARAMS: { nPageSize: 50, iNumPage: page } },
});
// повторная проверка месяца на случай, если портал понял фильтр по дате иначе
for (const it of items) if (it.CREATED_DATE.startsWith(ym)) perTask[it.TASK_ID] = (perTask[it.TASK_ID] || 0) + Number(it.SECONDS);
out.textContent = 'Считаю… страница ' + page;
if (items.length < 50) break;
}
const ids = Object.keys(perTask);
const titles = {};
for (let i = 0; i < ids.length; i += 50) {
const r = await call('tasks.task.list', { filter: { ID: ids.slice(i, i + 50) }, select: ['ID', 'TITLE'] });
for (const t of r.tasks) titles[t.id] = t.title;
}
const total = ids.reduce((a, id) => a + perTask[id], 0);
const left = norm * 3600 - total;
const normRow = !norm ? '' : '<tr><td></td><td>' + (left > 0
? 'До нормы ' + norm + ' ч осталось <b>' + hm(left) + '</b>'
: 'Норма ' + norm + ' ч выполнена' + (left < 0 ? ', сверх нормы ' + hm(-left) : '')) + '</td></tr>';
out.innerHTML = '<table style="border-collapse:collapse;margin-top:10px">' +
ids.sort((a, b) => perTask[b] - perTask[a]).map(id =>
'<tr><td style="text-align:right;padding:2px 12px 2px 0;white-space:nowrap">' + hm(perTask[id]) + '</td>' +
'<td><a href="/company/personal/user/' + me + '/tasks/task/view/' + id + '/">#' + id + '</a> ' + esc(titles[id] || '') + '</td></tr>'
).join('') +
'<tr style="font-weight:bold;border-top:2px solid #999"><td style="text-align:right;padding:4px 12px 0 0">' + hm(total) +
'</td><td>Итого за ' + ym + '</td></tr>' + normRow + '</table>';
} catch (e) {
out.textContent = 'Ошибка: ' + e;
}
}
document.getElementById('mytime-m').onchange = run;
run();
})();
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Моё время — настройки</title>
<style>
body { font: 14px/1.6 system-ui, sans-serif; margin: 20px; color: #333; min-width: 420px; }
label { display: block; margin-bottom: 12px; }
input { display: block; width: 100%; box-sizing: border-box; padding: 4px 6px; margin-top: 2px; }
small { color: #777; }
</style>
</head>
<body>
<label>Адрес портала
<input id="portal" placeholder="https://company.bitrix24.ru/">
<small>Если открыта другая вкладка, кнопка откроет портал. Можно оставить пустым.</small>
</label>
<label>Норма часов в месяц
<input id="norm" type="number" min="0" step="0.5" placeholder="20">
<small>Покажу, сколько осталось до нормы. 0 или пусто — не показывать.</small>
</label>
<button id="save">Сохранить</button> <span id="status"></span>
<script src="options.js"></script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
const portal = document.getElementById('portal');
const norm = document.getElementById('norm');
const status = document.getElementById('status');
chrome.storage.sync.get({ portal: '', norm: 0 }, cfg => {
portal.value = cfg.portal;
norm.value = cfg.norm || '';
});
document.getElementById('save').onclick = async () => {
let p = portal.value.trim();
if (p) {
try {
p = new URL(p.includes('://') ? p : 'https://' + p).origin + '/';
} catch {
status.textContent = 'Адрес портала не похож на адрес';
return;
}
}
await chrome.storage.sync.set({ portal: p, norm: Math.max(0, Number(norm.value) || 0) });
portal.value = p;
status.textContent = 'Сохранено';
};
+45
View File
@@ -0,0 +1,45 @@
// Прогон mytime.js на поддельных DOM, BX и REST: node test/test_mytime.mjs
import fs from 'node:fs';
const els = {};
const el = id => (els[id] ??= { id, value: id === 'mytime-m' ? '2026-10' : '', textContent: '', innerHTML: '', style: {}, remove() {} });
globalThis.window = globalThis;
globalThis.alert = m => { throw new Error('alert: ' + m); };
globalThis.document = {
getElementById: id => (id === 'mytime' ? null : el(id)),
createElement: () => ({ style: {}, set innerHTML(v) {}, remove() {} }),
body: { append() {} },
};
globalThis.BX = { bitrix_sessid: () => 'S' };
globalThis.myTimeConfig = { norm: 20 };
// 120 записей: 60 по задаче 1 (по 10 мин), 59 по задаче 2 (по 5 мин), одна сентябрьская — должна отсеяться
const items = [];
for (let i = 0; i < 60; i++) items.push({ TASK_ID: '1', SECONDS: '600', CREATED_DATE: '2026-10-05T10:00:00+03:00' });
for (let i = 0; i < 59; i++) items.push({ TASK_ID: '2', SECONDS: '300', CREATED_DATE: '2026-10-06T10:00:00+03:00' });
items.push({ TASK_ID: '3', SECONDS: '9999', CREATED_DATE: '2026-09-30T23:00:00+03:00' });
const calls = [];
globalThis.fetch = async (url, opt) => {
const method = url.match(/rest\/(.+)\.json/)[1];
const p = JSON.parse(opt.body);
calls.push(method);
let result;
if (method === 'user.current') result = { ID: '7' };
else if (method === 'task.elapseditem.getlist') {
if (p.FILTER.USER_ID !== '7') throw new Error('USER_ID not passed');
const n = p.PARAMS.NAV_PARAMS;
result = items.slice((n.iNumPage - 1) * n.nPageSize, n.iNumPage * n.nPageSize);
} else if (method === 'tasks.task.list') result = { tasks: p.filter.ID.map(id => ({ id, title: 'T<' + id + '>' })) };
return { json: async () => ({ result }) };
};
eval(fs.readFileSync(new URL('../extension/mytime.js', import.meta.url), 'utf8'));
await new Promise(r => setTimeout(r, 50));
const html = els['mytime-out'].innerHTML;
const must = ['>10:00<', '>4:55<', '>14:55<', 'осталось <b>5:05</b>', 'T&lt;1&gt;', '/user/7/tasks/task/view/1/'];
for (const s of must) if (!html.includes(s)) throw new Error('missing ' + s + '\n' + html);
if (html.includes('#3')) throw new Error('September record counted');
if (calls.filter(c => c === 'task.elapseditem.getlist').length !== 3) throw new Error('pages: ' + calls);
console.log('ok');