();
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: groupContent(b, flags),
order: idx,
className: idx % 2 === 0 ? 'row-even' : 'row-odd',
}));
const items = bookings.map((bk, idx) => {
const end = new Date(bk.end_date + 'T00:00:00Z');
end.setUTCDate(end.getUTCDate() + 1);
const brand = bk.brand || '';
const company = bk.company_name || '';
const barParts: string[] = [];
if (flags.withBrand && brand) barParts.push(escapeHtml(brand));
if (flags.withCompany && company) barParts.push(escapeHtml(company));
const barContent = barParts.join(' | ');
const partnerIdxs = collisionPartners.get(idx);
const isCollision = !!(partnerIdxs && partnerIdxs.length);
// Colour: collision colour overrides the per-status colour.
const statusColor = colorForStatus(appearance, bk.task_status);
const color = isCollision ? appearance.collisionColor : statusColor;
// ---- hover tooltip (structured HTML; values escaped) ----
const esc = escapeHtml;
const dur = durationLabel(bk.start_date, bk.end_date);
const datesRow = `📅${fmtDate(bk.start_date)} – ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}
`;
let tooltipHtml: string;
if (!flags.managerView) {
// Anonymous tier: date range only — no title, task №, status or Planfix hint.
tooltipHtml = `${datesRow}
`;
} else {
const title = brand || company || 'Без названия';
const managers = (bk.manager || []).filter(Boolean).join(', ');
const tt: string[] = [];
tt.push('');
tt.push(`${esc(title)}`);
if (bk.task_id) tt.push(`№${esc(bk.task_id)}`);
tt.push('
');
if (brand && company) tt.push(`${esc(company)}
`);
tt.push('');
tt.push(datesRow);
if (bk.task_status) tt.push(`${esc(statusLabel(bk.task_status))}
`);
if (managers) tt.push(`👤${esc(managers)}
`);
if (isCollision) {
tt.push('⚠ Коллизия
');
for (const pIdx of partnerIdxs!) {
const p = bookings[pIdx]!;
const pLabel = p.brand || p.company_name || 'Без названия';
tt.push(`
${esc(pLabel)} ${fmtDate(p.start_date)} – ${fmtDate(p.end_date)}
`);
}
tt.push('
');
}
tt.push('↗ Клик — открыть в ПланФикс
');
tooltipHtml = `${tt.join('')}
`;
}
return {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: `${barContent}
`,
type: 'range',
tooltipHtml,
taskId: bk.task_id,
};
});
groupsDS.clear();
groupsDS.add(groups);
itemsDS.clear();
itemsDS.add(items);
// Persistent tint on the current-month column (aligned to the axis months,
// which the hover highlight also keys off UTC month boundaries).
const now = new Date();
itemsDS.add({
id: CUR_MONTH_BG_ID,
type: 'background',
start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)),
end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)),
className: 'cur-month-bg',
} as never);
lastMonthKey = null;
if (fit) timeline.fit({ animation: false });
scheduleHeightFit();
scheduleBarText();
}
function refresh(): void {
if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags);
}
// Light: only rewrite the group (row-label) contents — the bars/collisions
// stay put, so toggling a column doesn't rebuild the whole timeline.
function refreshLabels(f: RenderFlags): void {
lastFlags = f;
if (!lastBoards.length) return;
groupsDS.update(lastBoards.map((b) => ({ id: b.board_id, content: groupContent(b, f) })));
}
function applyLayout(): void {
timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } });
timeline.redraw();
scheduleHeightFit();
}
function setScale(days: number): void {
const range = timeline.getWindow();
const center = (range.start.getTime() + range.end.getTime()) / 2;
const half = (days * 86400000) / 2;
timeline.setWindow(new Date(center - half), new Date(center + half));
}
return {
render,
refresh,
refreshLabels,
applyLayout,
setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },
};
}