mapdash/frontend/src/timeline.ts
aaverbitskiy aeb4651f6f polish: appearance menu + clean status names everywhere (v0.2.1)
Appearance ("Оформление") menu:
- add missing status "РК. Предварительное бронирование" (8 statuses total)
- strip the systemic "РК. " prefix from status names in the UI (menu,
  tooltip, status filter dropdown, active-filter chips); raw values stay
  intact for filtering/URLs
- fixed-position menu: opens 25px below the header, ends >=30px above the
  window bottom, scrolls inside — never overflows the viewport
- drop the "Прочие статусы" row
- per-status grey reset glyph (circular arrows) restoring its default colour

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 15:37:03 +00:00

412 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Timeline view: wraps vis-timeline (groups = boards, range items = bookings).
import { Timeline, DataSet } from 'vis-timeline/standalone';
import type { TimelineOptions } from 'vis-timeline/standalone';
import { escapeHtml } from './util';
import { computeCollisions } from './collisions';
import { colorForStatus, statusLabel } from './appearance';
import type { Appearance } from './appearance';
import type { Board, Booking } from './types';
export interface RenderFlags {
withBrand: boolean;
withCompany: boolean;
withCollisions: boolean;
// Manager tier: show the rich tooltip (title, task №, status, manager, Planfix
// hint). Anonymous tier gets a minimal tooltip with only the date range.
managerView: boolean;
}
export interface TimelineElements {
timelineEl: HTMLElement;
chartWrap: HTMLElement;
tooltip: HTMLElement;
empty: HTMLElement;
colResizer: HTMLElement;
}
// Clicking a bar opens the corresponding Planfix task.
const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
{ days: 91, label: '3 месяца' },
{ days: 183, label: '6 месяцев' },
{ days: 274, label: '9 месяцев' },
{ days: 365, label: '12 месяцев' },
{ days: 457, label: '15 месяцев' },
{ days: 548, label: '18 месяцев' },
{ days: 639, label: '21 месяц' },
{ days: 731, label: '24 месяца' },
{ days: 822, label: '27 месяцев' },
{ days: 913, label: '30 месяцев' },
];
const MONTH_BG_ID = '__month_hover_bg';
const MIN_LABEL_W = 140;
const MAX_LABEL_W = 760;
// "2026-06-01" -> "01.06.2026"
function fmtDate(iso: string): string {
const p = iso.split('-');
return p.length === 3 ? `${p[2]}.${p[1]}.${p[0]}` : iso;
}
function pluralRu(n: number, one: string, few: string, many: string): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
// Inclusive span between two ISO dates as a human label ("14 дней" / "7 мес").
function durationLabel(startIso: string, endIso: string): string {
const s = new Date(startIso + 'T00:00:00Z').getTime();
const e = new Date(endIso + 'T00:00:00Z').getTime();
if (Number.isNaN(s) || Number.isNaN(e) || e < s) return '';
const days = Math.round((e - s) / 86400000) + 1;
if (days < 31) return `${days} ${pluralRu(days, 'день', 'дня', 'дней')}`;
return `${Math.round(days / 30.44)} мес`;
}
export interface TimelineView {
render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void;
/** Re-run the last render (e.g. after colours changed). */
refresh(): void;
/** Re-apply row height / centring from appearance and redraw. */
applyLayout(): void;
setScale(days: number): void;
/** Called with a board_id when its row label is clicked. */
setOnBoardClick(fn: (boardId: string) => void): void;
}
export function createTimelineView(el: TimelineElements, appearance: Appearance): TimelineView {
const groupsDS = new DataSet<any>();
const itemsDS = new DataSet<any>();
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true };
let onBoardClick: ((boardId: string) => void) | null = null;
const options: TimelineOptions = {
editable: false,
selectable: false,
stack: true,
zoomable: false,
moveable: true,
verticalScroll: true,
zoomMin: zoomSteps[0]!.days * 86400000,
zoomMax: zoomSteps[zoomSteps.length - 1]!.days * 86400000,
groupHeightMode: 'auto',
margin: { item: { horizontal: 2, vertical: 0 } },
orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 },
locale: 'ru',
xss: { disabled: true },
};
const timeline = new Timeline(el.timelineEl, itemsDS, groupsDS, options);
// ---- dynamic height / scroll fix ----
let lastSetHeightPx: number | null = null;
function fitTimelineHeight(): void {
const available = el.chartWrap.clientHeight;
if (!available) return;
const labelSet = el.timelineEl.querySelector<HTMLElement>('.vis-labelset');
const axisEl = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-top');
const groupCount = groupsDS.length;
const axisHeight = axisEl ? axisEl.offsetHeight : 0;
const contentHeight = labelSet ? axisHeight + labelSet.scrollHeight : 0;
if (!labelSet && groupCount > 0) return;
const target = groupCount === 0 ? available : Math.min(contentHeight || available, available);
const px = Math.max(1, Math.round(target));
if (px === lastSetHeightPx) return;
lastSetHeightPx = px;
timeline.setOptions({ height: px + 'px' });
}
let heightFitQueued = false;
function scheduleHeightFit(): void {
if (heightFitQueued) return;
heightFitQueued = true;
requestAnimationFrame(() => {
heightFitQueued = false;
fitTimelineHeight();
});
}
timeline.on('changed', scheduleHeightFit);
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
// ---- clicks: bar -> Planfix task; row label -> board detail panel ----
timeline.on('click', (props: any) => {
if (props.item) {
const item = itemsDS.get(props.item) as any;
if (item && item.taskId) {
window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener');
}
return;
}
if (props.what === 'group-label' && props.group != null && onBoardClick) {
onBoardClick(String(props.group));
}
});
// ---- draggable label-column width ----
function currentLabelW(): number {
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
return parseInt(v, 10) || 340;
}
function positionResizer(): void {
const leftPanel = el.timelineEl.querySelector<HTMLElement>('.vis-panel.vis-left');
if (!leftPanel || el.timelineEl.style.display === 'none') {
el.colResizer.style.display = 'none';
return;
}
const wrapRect = el.chartWrap.getBoundingClientRect();
const panelRect = leftPanel.getBoundingClientRect();
el.colResizer.style.left = panelRect.right - wrapRect.left + 'px';
el.colResizer.style.display = 'block';
}
timeline.on('changed', positionResizer);
window.addEventListener('resize', positionResizer);
let colDragging = false;
let colDragStartX = 0;
let colDragStartW = 0;
let colDragLastX = 0;
let colDragRafPending = false;
function applyColDrag(): void {
colDragRafPending = false;
let w = colDragStartW + (colDragLastX - colDragStartX);
w = Math.max(MIN_LABEL_W, Math.min(MAX_LABEL_W, w));
document.documentElement.style.setProperty('--label-w', w + 'px');
timeline.redraw();
positionResizer();
}
el.colResizer.addEventListener('mousedown', (e) => {
colDragging = true;
colDragStartX = e.clientX;
colDragStartW = currentLabelW();
el.colResizer.classList.add('dragging');
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!colDragging) return;
colDragLastX = e.clientX;
if (colDragRafPending) return;
colDragRafPending = true;
requestAnimationFrame(applyColDrag);
});
window.addEventListener('mouseup', () => {
if (!colDragging) return;
colDragging = false;
el.colResizer.classList.remove('dragging');
document.body.style.userSelect = '';
document.body.style.cursor = '';
scheduleHeightFit();
});
// ---- tooltip ----
let hoveredItemId: string | number | null = null;
timeline.on('itemover', (props: any) => {
hoveredItemId = props.item;
const item = itemsDS.get(props.item) as any;
if (item) {
el.tooltip.innerHTML = item.tooltipHtml;
el.tooltip.style.display = 'block';
}
});
timeline.on('itemout', () => {
hoveredItemId = null;
el.tooltip.style.display = 'none';
});
document.addEventListener('mousemove', (e) => {
if (hoveredItemId === null) return;
const pad = 12;
const tw = el.tooltip.offsetWidth;
const th = el.tooltip.offsetHeight;
let left = e.clientX + pad;
let top = e.clientY + pad;
// Flip above the cursor when the tooltip would spill past the bottom edge.
if (top + th > window.innerHeight - 8) top = e.clientY - pad - th;
// Flip to the left when it would spill past the right edge.
if (left + tw > window.innerWidth - 8) left = e.clientX - pad - tw;
if (top < 8) top = 8;
if (left < 8) left = 8;
el.tooltip.style.left = left + 'px';
el.tooltip.style.top = top + 'px';
});
// ---- row hover highlight ----
let hoveredRowIdx = -1;
function setRowHover(idx: number): void {
if (idx === hoveredRowIdx) return;
const labels = document.querySelectorAll<HTMLElement>('#timeline .vis-label');
const rows = document.querySelectorAll<HTMLElement>('#timeline .vis-group');
if (hoveredRowIdx >= 0) {
labels[hoveredRowIdx]?.classList.remove('row-hover');
rows[hoveredRowIdx]?.classList.remove('row-hover');
}
if (idx >= 0) {
labels[idx]?.classList.add('row-hover');
rows[idx]?.classList.add('row-hover');
}
hoveredRowIdx = idx;
}
// ---- hovered-month column highlight ----
let lastMonthKey: string | null = null;
timeline.on('mouseMove', (props: any) => {
if (props.time) {
const t: Date = props.time;
const y = t.getUTCFullYear();
const m = t.getUTCMonth();
const key = y + '-' + m;
if (key !== lastMonthKey) {
lastMonthKey = key;
const monthStart = new Date(Date.UTC(y, m, 1));
const monthEnd = new Date(Date.UTC(y, m + 1, 1));
itemsDS.update([
{ id: MONTH_BG_ID, type: 'background', start: monthStart, end: monthEnd, className: 'month-hover-bg' },
]);
}
}
if (props.group !== null && props.group !== undefined) {
const idx = lastBoards.findIndex((b) => b.board_id === props.group);
setRowHover(idx);
} else {
setRowHover(-1);
}
});
el.timelineEl.addEventListener('mouseleave', () => {
lastMonthKey = null;
itemsDS.remove(MONTH_BG_ID);
setRowHover(-1);
});
function render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void {
lastBoards = boards;
lastBookings = bookings;
lastFlags = flags;
if (boards.length === 0) {
groupsDS.clear();
itemsDS.clear();
el.timelineEl.style.display = 'none';
el.empty.style.display = 'flex';
return;
}
el.timelineEl.style.display = '';
el.empty.style.display = 'none';
const collisionPartners = flags.withCollisions ? computeCollisions(bookings) : new Map<number, number[]>();
const groups = boards.map((b, idx) => ({
id: b.board_id,
content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')',
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 = `<div class="tt-row"><span class="tt-ico">📅</span><span>${fmtDate(bk.start_date)} ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}</span></div>`;
let tooltipHtml: string;
if (!flags.managerView) {
// Anonymous tier: date range only — no title, task №, status or Planfix hint.
tooltipHtml = `<div class="tt" style="--tt-accent:${color}">${datesRow}</div>`;
} else {
const title = brand || company || 'Без названия';
const managers = (bk.manager || []).filter(Boolean).join(', ');
const tt: string[] = [];
tt.push('<div class="tt-head">');
tt.push(`<span class="tt-title">${esc(title)}</span>`);
if (bk.task_id) tt.push(`<span class="tt-id">№${esc(bk.task_id)}</span>`);
tt.push('</div>');
if (brand && company) tt.push(`<div class="tt-sub">${esc(company)}</div>`);
tt.push('<div class="tt-sep"></div>');
tt.push(datesRow);
if (bk.task_status) tt.push(`<div class="tt-row"><span class="tt-dot" style="background:${statusColor}"></span><span>${esc(statusLabel(bk.task_status))}</span></div>`);
if (managers) tt.push(`<div class="tt-row"><span class="tt-ico">👤</span><span>${esc(managers)}</span></div>`);
if (isCollision) {
tt.push('<div class="tt-warn"><div class="tt-warn-head">⚠ Коллизия</div>');
for (const pIdx of partnerIdxs!) {
const p = bookings[pIdx]!;
const pLabel = p.brand || p.company_name || 'Без названия';
tt.push(`<div class="tt-warn-row">${esc(pLabel)} <span class="tt-warn-dates">${fmtDate(p.start_date)} ${fmtDate(p.end_date)}</span></div>`);
}
tt.push('</div>');
}
tt.push('<div class="tt-hint">↗ Клик — открыть в ПланФикс</div>');
tooltipHtml = `<div class="tt" style="--tt-accent:${color}">${tt.join('')}</div>`;
}
return {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
type: 'range',
tooltipHtml,
taskId: bk.task_id,
};
});
groupsDS.clear();
groupsDS.add(groups);
itemsDS.clear();
itemsDS.add(items);
lastMonthKey = null;
if (fit) timeline.fit({ animation: false });
scheduleHeightFit();
}
function refresh(): void {
if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags);
}
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,
applyLayout,
setScale,
setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },
};
}