mapdash/frontend/src/timeline.ts
aaverbitskiy 1eda6c1e9d auth ui: anon tooltip = dates only; move login button to header top-right
- timeline: RenderFlags.managerView drives the tooltip. Anonymous tier shows
  a minimal tooltip (date range + duration only) — no title, task №, status,
  manager or Planfix hint. Manager tier keeps the rich tooltip.
- header: login/logout button pinned to the top-right corner (.auth-wrap,
  absolute) instead of sitting next to the view-mode switch.

Keycloak: register bare origin in post.logout.redirect.uris so keycloak-js
logout (redirectUri without trailing path) is accepted.
2026-08-14 11:22:16 +00:00

402 lines
14 KiB
TypeScript
Raw 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 } 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) {
el.tooltip.style.left = e.clientX + 12 + 'px';
el.tooltip.style.top = e.clientY + 12 + '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(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; },
};
}