ui: visual refresh, richer bar tooltip (+manager), header alignment fixes

Frontend (styles.css, index.html, main.ts):
- Design tokens: single accent/border/radius/shadow/spacing system; flat header
  (dropped the boxed groups) with a subtle bottom divider.
- Unified controls: 36px height, consistent focus ring; search field gets a
  magnifier icon; view-mode switch is now a compact vertical segmented control.
- Fixed the dropdown option font: `.field label` styles were leaking into the
  option <label>s (they're inside .field), forcing uppercase + bold; options are
  now normal weight / normal case.
- Header alignment: mode switch fills the row so its bottom lines up with the
  date fields; the counters block is pinned to the same bottom line.
- Dropdown lists no longer show the column-resizer line bleeding through: the
  header now establishes a stacking context (position:relative + z-index) so its
  panels sit above the timeline.
- Dark theme is implemented (tokens + no-flash) but hidden for now (toggle
  display:none, forced light) until it's polished — easy to re-enable.

Timeline tooltip (timeline.ts, types.ts):
- Replaced the plain multi-line text tooltip with a structured card: bold brand
  title + task №, company subtitle, localized dates + duration, status as a
  coloured dot, manager, a distinct collision section, and a "click to open in
  Planfix" hint. Left accent stripe in the bar colour; matches the map tooltip.
- Values are escaped (escapeHtml) before going into innerHTML.

Backend (main.py):
- /api/bookings now returns `manager` (Array(String)), canonicalized to the
  "Фамилия Имя" form (see canon_manager) so the tooltip can show it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
aaverbitskiy 2026-08-06 04:39:55 +00:00
parent 530bb4754e
commit 557522afcf
6 changed files with 328 additions and 117 deletions

View File

@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Адресная программа</title>
<script>
// Theme is set before first paint (no flash). Dark theme is temporarily
// hidden — force light regardless of stored/OS preference. To re-enable,
// restore the localStorage/prefers-color-scheme logic and unhide the toggle.
document.documentElement.setAttribute('data-theme', 'light');
</script>
</head>
<body>
@ -24,6 +30,7 @@
<button type="button" class="mode-btn" id="mode-map">Карта</button>
<button type="button" class="mode-btn" id="mode-split">Карта и график</button>
</div>
<button type="button" class="theme-toggle" id="theme-toggle" title="Переключить тему" aria-label="Переключить тему"></button>
</div>
<!-- Group 2: fields (4-column grid; dates + search on the 2nd row) -->

View File

@ -34,8 +34,26 @@ const el = {
mapEl: document.getElementById('map') as HTMLElement,
mapEmpty: document.getElementById('map-empty') as HTMLElement,
mapCounter: document.getElementById('map-counter') as HTMLElement,
themeToggle: document.getElementById('theme-toggle') as HTMLButtonElement,
};
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
function currentTheme(): 'light' | 'dark' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
}
function applyThemeIcon(): void {
const dark = currentTheme() === 'dark';
el.themeToggle.textContent = dark ? '☀' : '☾';
el.themeToggle.title = dark ? 'Светлая тема' : 'Тёмная тема';
}
el.themeToggle.addEventListener('click', () => {
const next = currentTheme() === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
try { localStorage.setItem('mapdash.theme', next); } catch { /* ignore */ }
applyThemeIcon();
});
applyThemeIcon();
const YANDEX_KEY = ((import.meta as any).env?.VITE_YANDEX_KEY as string) || '';
const appearance = loadAppearance();

View File

@ -1,52 +1,99 @@
:root {
--bar-color: #4169E1;
--grid-line: #e2e2e2;
--label-w: 340px;
--row-even: #f0f0f0;
--row-odd: #FFFFFF;
--row-hover: #FFECB3;
--month-hover: rgba(65, 105, 225, 0.10);
/* appearance (overridden at runtime by appearance.ts) */
--row-h: 30px;
--bar-h: 18px;
--bar-font: 11px;
--label-font: 12px;
--label-w: 340px;
/* ---- design tokens (light) ---- */
--bg: #ffffff;
--surface: #f7f8fa;
--surface-2: #eef1f5;
--border: #e5e7eb;
--border-strong: #d1d6de;
--text: #1f2430;
--text-muted: #6b7280;
--accent: #2e66d6;
--accent-weak: rgba(46, 102, 214, .12);
--accent-ring: rgba(46, 102, 214, .20);
--radius: 8px;
--radius-sm: 6px;
--shadow-sm: 0 1px 2px rgba(16, 24, 40, .06);
--shadow-md: 0 6px 18px rgba(16, 24, 40, .12);
--transition: .13s ease;
/* timeline / map surfaces (built on the tokens above) */
--grid-line: var(--border);
--row-even: #f5f6f8;
--row-odd: #ffffff;
--row-hover: #fff3cd;
--month-hover: rgba(46, 102, 214, .08);
--tip-bg: #1f2430;
--tip-text: #ffffff;
--tip-sub: #cfd3d6;
--today: #ef5350;
}
/* Dark theme single source of truth is data-theme on <html>, set by the
no-flash inline script in index.html (defaults to prefers-color-scheme). */
:root[data-theme="dark"] {
--bg: #0f1420;
--surface: #161c2b;
--surface-2: #1e2637;
--border: #273043;
--border-strong: #33405a;
--text: #e6e9ef;
--text-muted: #93a0b5;
--accent: #5b8def;
--accent-weak: rgba(91, 141, 239, .16);
--accent-ring: rgba(91, 141, 239, .30);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
--shadow-md: 0 6px 18px rgba(0, 0, 0, .5);
--row-even: #141a27;
--row-odd: #0f1420;
--row-hover: #33361f;
--month-hover: rgba(91, 141, 239, .12);
--tip-bg: #0b0f18;
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; overflow: hidden; }
body {
display: flex;
flex-direction: column;
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
background: #fff;
color: #222;
background: var(--bg);
color: var(--text);
}
header {
flex: 0 0 auto;
padding: 12px 16px;
border-bottom: 1px solid #e2e2e2;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-sm);
display: flex;
gap: 14px;
gap: 18px;
align-items: stretch;
/* Responsive: the fields group compresses (and wraps its own fields) first;
if the window is still too narrow, whole gray blocks drop to the next row
if the window is still too narrow, whole blocks drop to the next row
instead of overlapping. */
flex-wrap: wrap;
align-content: flex-start;
background: #fff;
z-index: 20;
background: var(--bg);
/* position:relative makes the z-index actually take effect, so the header
(and its open dropdown panels) forms a stacking context above the timeline
otherwise the column-resizer line bleeds over the filter dropdown lists. */
position: relative;
z-index: 40;
}
/* Header groups (boxed blocks). By default they keep their natural width. */
/* Header groups. Flat now (no boxes) — the header itself is the panel. */
.hgroup {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 14px;
background: #f2f2f2;
border: 1px solid #e2e2e2;
border-radius: 8px;
padding: 4px 0;
flex-shrink: 1;
min-width: 0;
}
@ -55,7 +102,7 @@ header {
flex-shrink: 4; min-width: 0;
display: grid;
grid-template-columns: repeat(4, minmax(130px, 1fr));
gap: 10px 14px;
gap: 12px 14px;
align-items: start;
align-content: start;
}
@ -66,132 +113,199 @@ header {
.hgroup-fields input[type=text],
.hgroup-fields input[type=date] { min-width: 0; width: 100%; }
@media (max-width: 760px) { .hgroup-fields { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
.hgroup-brand { background: #f2f2f2; }
/* Fields group: top-align so the date input sits UNDER Город/Бренд. */
.hgroup-fields { align-items: flex-start; }
.fcol { display: flex; flex-direction: column; gap: 8px; }
input[type=date] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
input[type=date],
input[type=text] {
height: 36px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0 10px;
font-size: 13px;
min-width: 0;
width: 100%;
box-sizing: border-box;
background: var(--bg);
color: var(--text);
transition: border-color var(--transition), box-shadow var(--transition);
}
/* Compact single-column layout: the three checkboxes stack in three rows so the
block takes as little horizontal space as possible. */
input[type=date]:hover,
input[type=text]:hover { border-color: var(--border-strong); }
input[type=date]:focus,
input[type=text]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
input::placeholder { color: var(--text-muted); }
/* Search field: magnifier icon inside the input. */
#search {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='11' cy='11' r='7'/%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: 10px center;
background-size: 15px;
padding-left: 32px;
}
:root[data-theme="dark"] input[type=date]::-webkit-calendar-picker-indicator {
filter: invert(1) opacity(.65);
}
/* Compact single-column layout: the three checkboxes stack in three rows. */
.hgroup-checks {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 6px;
gap: 8px;
}
.hgroup-checks .checkbox-field { align-self: flex-start; }
.brand { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
/* Let the brand block fill the header height so the counters can sit at the
bottom, level with the date fields / mode switch. */
.hgroup-brand { align-items: stretch; }
.brand { display: flex; flex-direction: column; justify-content: flex-start; line-height: 1.2; }
.brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; }
.brand-sub { font-size: 15px; font-weight: 400; color: #666; white-space: nowrap; }
.brand-sub { font-size: 15px; font-weight: 400; color: var(--text-muted); white-space: nowrap; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: .03em; }
input[type=text] {
border: 1px solid #ccc;
border-radius: 4px;
padding: 6px 8px;
font-size: 13px;
min-width: 0;
width: 100%;
.field { display: flex; flex-direction: column; gap: 5px; }
.field label {
font-size: 11px; color: var(--text-muted);
text-transform: uppercase; letter-spacing: .05em; font-weight: 500;
}
/* Counters — compact inset inside the brand block. */
.brand #status {
margin-top: auto; padding: 6px 10px;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-sm); font-size: 12px; color: var(--text-muted);
line-height: 1.5; white-space: nowrap;
}
/* Counters as a compact gray block, pushed to the end of the header row so it
sits in line with the other boxed groups instead of floating below. */
/* Counters now live inside the brand block as a compact inset. */
.brand #status { margin-top: 10px; padding: 6px 10px; background: #fff; border: 1px solid #e2e2e2; border-radius: 6px; font-size: 12px; color: #666; line-height: 1.5; white-space: nowrap; }
/* Scale group: stack the "Оформление" button under the zoom slider. */
.hgroup-scale { flex-direction: column; align-items: flex-start; gap: 10px; }
.hgroup-scale { flex-direction: column; align-items: flex-start; gap: 12px; }
.hgroup-scale .decor-wrap { align-self: flex-start; }
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
.checkbox-field label {
display: flex; align-items: center; gap: 6px;
font-size: 13px; color: #222; text-transform: none; letter-spacing: normal;
cursor: pointer; white-space: nowrap;
font-size: 13px; color: var(--text); text-transform: none; letter-spacing: normal;
font-weight: 400; cursor: pointer; white-space: nowrap;
}
.checkbox-field input[type=checkbox] { cursor: pointer; }
.checkbox-field input[type=checkbox] { cursor: pointer; accent-color: var(--accent); }
.zoom-field input[type=range] { width: 150px; cursor: pointer; }
.zoom-field input[type=range] { width: 160px; cursor: pointer; accent-color: var(--accent); }
.zoom-field label { white-space: nowrap; }
/* checkbox dropdown filter */
.dropdown { position: relative; }
.dropdown-btn {
border: 1px solid #ccc; border-radius: 4px; background: #fff;
padding: 6px 10px; font-size: 13px; min-width: 180px; text-align: left;
height: 36px;
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
color: var(--text); padding: 0 12px; font-size: 13px; min-width: 180px; text-align: left;
cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 8px;
transition: border-color var(--transition), box-shadow var(--transition);
}
.dropdown-btn:hover { border-color: #999; }
.dropdown-btn .caret { font-size: 10px; color: #888; }
.dropdown-btn:hover { border-color: var(--border-strong); }
.dropdown.open .dropdown-btn { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); }
.dropdown-btn .caret { font-size: 10px; color: var(--text-muted); }
.dropdown-panel {
display: none; position: absolute; top: calc(100% + 4px); left: 0;
background: #fff; border: 1px solid #ccc; border-radius: 4px;
box-shadow: 0 4px 14px rgba(0,0,0,.12); padding: 6px 0;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 6px 0;
min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
display: flex; align-items: center; gap: 8px; padding: 5px 12px;
font-size: 13px; cursor: pointer; white-space: nowrap;
display: flex; align-items: center; gap: 8px; padding: 6px 12px;
font-size: 13px; cursor: pointer; white-space: nowrap; color: var(--text);
/* Option labels are <label>s inside .field, so they'd inherit the uppercase /
weight / letter-spacing of `.field label`. Reset to clean body text. */
font-weight: 400; text-transform: none; letter-spacing: normal;
}
.dropdown-panel label:hover { background: #f4f4f4; }
.dropdown-panel label:hover { background: var(--surface); }
.dropdown-panel input[type=checkbox] { accent-color: var(--accent); }
.dropdown-panel .dp-actions {
display: flex; gap: 10px; padding: 4px 12px 8px;
border-bottom: 1px solid #eee; margin-bottom: 4px;
border-bottom: 1px solid var(--border); margin-bottom: 4px;
}
.dropdown-panel .dp-actions a { font-size: 11px; color: #567; cursor: pointer; text-decoration: underline; }
.dropdown-panel .dp-actions a { font-size: 11px; color: var(--accent); cursor: pointer; text-decoration: none; font-weight: 600; }
.dropdown-panel .dp-actions a:hover { text-decoration: underline; }
/* Оформление button + context menu */
.decor-wrap { position: relative; align-self: center; }
.decor-btn {
border: 1px solid #ccc; border-radius: 4px; background: #fff;
padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
color: var(--text); padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
transition: border-color var(--transition), background var(--transition);
}
.decor-btn:hover { border-color: #999; }
.decor-btn:hover { border-color: var(--border-strong); background: var(--surface); }
.decor-menu {
display: none; position: absolute; top: calc(100% + 6px); right: 0;
background: #fff; border: 1px solid #ccc; border-radius: 6px;
box-shadow: 0 6px 20px rgba(0,0,0,.15); padding: 10px 12px; z-index: 50;
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: var(--shadow-md); padding: 12px 14px; z-index: 50;
width: max-content; max-width: min(420px, 92vw); max-height: calc(100vh - 96px); overflow-y: auto;
}
.decor-menu.open { display: block; }
.decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid #eee; }
.decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
.decor-section:last-of-type { border-bottom: none; margin-bottom: 8px; }
.decor-section-title {
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
color: #666; margin: 6px 0 4px;
font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
color: var(--text-muted); margin: 6px 0 8px;
}
.decor-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 3px 0; font-size: 13px;
padding: 4px 0; font-size: 13px;
}
.decor-row > span { color: #222; }
.decor-row input[type=number] { width: 84px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
.decor-row input[type=color] { width: 42px; height: 26px; padding: 0; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
.decor-reset { margin-top: 6px; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
.decor-reset:hover { background: #efefef; }
.decor-row > span { color: var(--text); }
.decor-row input[type=number] {
width: 84px; padding: 5px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm);
font-size: 13px; background: var(--bg); color: var(--text);
}
.decor-row input[type=color] {
width: 42px; height: 28px; padding: 0; border: 1px solid var(--border);
border-radius: var(--radius-sm); background: var(--bg); cursor: pointer;
}
.decor-reset {
margin-top: 6px; width: 100%; padding: 8px; font-size: 12px;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--surface); color: var(--text); cursor: pointer;
transition: background var(--transition);
}
.decor-reset:hover { background: var(--surface-2); }
/* ---- view modes: timeline / map / split ---- */
/* Stacked, individual buttons (compact vertical column). */
.mode-switch { display: flex; flex-direction: column; gap: 6px; }
/* ---- view modes: segmented control ---- */
.mode-switch {
display: inline-flex; flex-direction: column; gap: 3px;
background: var(--surface-2); border: 1px solid var(--border);
border-radius: var(--radius); padding: 3px;
height: 100%; /* fill the header row so the bottom lines up with the date fields */
}
.mode-btn {
border: 1px solid #ccc; border-radius: 6px; background: #fff; color: #333; cursor: pointer;
padding: 6px 12px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border: none; background: transparent; color: var(--text-muted); cursor: pointer;
padding: 6px 13px; font-size: 13px; white-space: nowrap; text-align: left; width: 100%;
border-radius: var(--radius-sm); transition: background var(--transition), color var(--transition);
flex: 1; display: flex; align-items: center; /* even heights, vertically centred */
}
.mode-btn:hover { background: #f0f0f0; }
.mode-btn.active { background: #2e66d6; border-color: #2e66d6; color: #fff; }
.mode-btn:hover { color: var(--text); }
.mode-btn.active { background: var(--bg); color: var(--accent); font-weight: 600; box-shadow: var(--shadow-sm); }
#view { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; --split-left: 50%; }
/* Theme toggle */
.theme-toggle {
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
color: var(--text); width: 34px; height: 34px; font-size: 16px; line-height: 1; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: border-color var(--transition), background var(--transition);
}
.theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); }
/* Dark theme temporarily hidden toggle stays in the DOM, just not shown.
Re-enable by removing this rule + restoring the head script in index.html. */
.theme-toggle { display: none; }
/* Top-align the mode switch with the field labels / brand instead of centring it
vertically, so it lines up with the rest of the header row. */
.hgroup-modes { gap: 10px; align-items: stretch; }
#view { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; --split-left: 50%; background: var(--bg); }
#pane-timeline { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
#pane-map { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
#map { width: 100%; height: 100%; }
@ -210,15 +324,16 @@ input[type=text] {
#view.mode-split #split-resizer { display: block; }
#split-resizer {
flex: 0 0 6px; background: #e2e2e2; cursor: col-resize; position: relative;
flex: 0 0 6px; background: var(--border); cursor: col-resize; position: relative;
transition: background var(--transition);
}
#split-resizer:hover { background: #b9c6e6; }
#split-resizer:hover { background: var(--accent); }
#map-legend {
position: absolute; left: 10px; bottom: 10px; z-index: 5;
background: rgba(255,255,255,.92); border: 1px solid #ddd; border-radius: 6px;
padding: 6px 10px; font-size: 12px; color: #333; display: flex; gap: 14px; align-items: center;
box-shadow: 0 2px 8px rgba(0,0,0,.12);
background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius);
padding: 7px 11px; font-size: 12px; color: var(--text); display: flex; gap: 14px; align-items: center;
box-shadow: var(--shadow-md);
}
#map-legend .lg-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
#map-legend .lg-dot { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }
@ -226,22 +341,22 @@ input[type=text] {
#map-legend .lg-busy { background: #e35b45; }
#map-empty {
position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
color: #888; font-size: 14px; z-index: 5;
color: var(--text-muted); font-size: 14px; z-index: 5;
}
/* Custom hover tooltip for map markers and clusters. */
.map-tip {
position: fixed; z-index: 1000; pointer-events: none;
background: rgba(33,33,33,.94); color: #fff; font-size: 12px; line-height: 1.35;
padding: 8px 10px; border-radius: 6px; max-width: 340px;
box-shadow: 0 4px 14px rgba(0,0,0,.3);
background: var(--tip-bg); color: var(--tip-text); font-size: 12px; line-height: 1.35;
padding: 8px 10px; border-radius: var(--radius-sm); max-width: 340px;
box-shadow: var(--shadow-md);
}
.map-tip .tip-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; vertical-align: baseline; }
.map-tip .tip-free { background: #59a831; }
.map-tip .tip-busy { background: #e35b45; }
.map-tip .tip-row { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.map-tip .tip-head { font-weight: 700; margin-bottom: 3px; }
.map-tip .tip-sub { color: #cfd3d6; }
.map-tip .tip-more { color: #cfd3d6; margin-top: 3px; }
.map-tip .tip-sub { color: var(--tip-sub); }
.map-tip .tip-more { color: var(--tip-sub); margin-top: 3px; }
#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
#timeline { border: none; }
@ -268,7 +383,7 @@ input[type=text] {
height: var(--bar-h);
width: 100%;
box-sizing: border-box;
border-radius: 3px;
border-radius: 4px;
display: flex;
align-items: center;
padding: 0 6px;
@ -295,20 +410,48 @@ input[type=text] {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
}
/* vis-timeline chrome — themed so it reads in dark mode too. */
.vis-timeline { border-color: var(--border); }
.vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right,
.vis-panel.vis-top, .vis-panel.vis-bottom { border-color: var(--border); }
.vis-time-axis .vis-text { color: var(--text-muted); }
.vis-time-axis .vis-grid.vis-minor { border-color: var(--grid-line); }
.vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); }
.vis-labelset .vis-label,
.vis-foreground .vis-group { border-color: var(--grid-line); }
.vis-current-time { background-color: var(--today); }
/* Column resize handle */
#col-resizer {
position: absolute; top: 0; bottom: 0; width: 9px; margin-left: -4px;
cursor: col-resize; z-index: 25; display: none;
}
#col-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: #ccc; }
#col-resizer:hover::after, #col-resizer.dragging::after { background: var(--bar-color); width: 2px; left: 3px; }
#col-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: var(--border); }
#col-resizer:hover::after, #col-resizer.dragging::after { background: var(--accent); width: 2px; left: 3px; }
/* Hovered-month column highlight */
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); }
#tooltip {
position: fixed; display: none; background: #222; color: #fff;
padding: 6px 10px; border-radius: 4px; font-size: 12px; pointer-events: none;
z-index: 100; max-width: 320px; line-height: 1.4; white-space: pre-line;
/* Rich hover tooltip for timeline bars (structured HTML, built in timeline.ts).
Shares the dark look of the map tooltip (.map-tip) for a consistent feel. */
#tooltip { position: fixed; display: none; pointer-events: none; z-index: 100; max-width: 340px; }
.tt {
background: var(--tip-bg); color: var(--tip-text);
border-left: 3px solid var(--tt-accent, var(--accent));
border-radius: var(--radius-sm); box-shadow: var(--shadow-md);
padding: 9px 12px; font-size: 12px; line-height: 1.4; min-width: 170px;
}
#empty { padding: 40px; text-align: center; color: #999; font-size: 13px; }
.tt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.tt-title { font-weight: 700; font-size: 13px; }
.tt-id { color: var(--tip-sub); font-size: 11px; white-space: nowrap; }
.tt-sub { color: var(--tip-sub); margin-top: 1px; }
.tt-sep { height: 1px; background: rgba(255, 255, 255, .14); margin: 7px 0; }
.tt-row { display: flex; align-items: center; gap: 7px; margin: 3px 0; }
.tt-ico { width: 15px; text-align: center; opacity: .85; flex: 0 0 auto; }
.tt-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; margin: 0 3px; }
.tt-warn { margin-top: 8px; padding-top: 7px; border-top: 1px solid rgba(255, 255, 255, .14); }
.tt-warn-head { color: #ffcc66; font-weight: 600; margin-bottom: 3px; }
.tt-warn-row { font-size: 11.5px; color: #f0d9b0; margin: 2px 0; }
.tt-warn-dates { color: var(--tip-sub); }
.tt-hint { margin-top: 9px; color: var(--tip-sub); font-size: 11px; }
#empty { padding: 40px; text-align: center; color: var(--text-muted); font-size: 13px; }

View File

@ -41,6 +41,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). */
@ -180,7 +204,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
hoveredItemId = props.item;
const item = itemsDS.get(props.item) as any;
if (item) {
el.tooltip.textContent = item.tooltipText;
el.tooltip.innerHTML = item.tooltipHtml;
el.tooltip.style.display = 'block';
}
});
@ -276,26 +300,39 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
if (flags.withCompany && company) barParts.push(escapeHtml(company));
const barContent = barParts.join(' | ');
const tooltipLines: string[] = [];
if (bk.task_id) tooltipLines.push(bk.task_id);
if (brand) tooltipLines.push(brand);
if (company) tooltipLines.push(company);
tooltipLines.push(bk.start_date + ' — ' + bk.end_date);
if (bk.task_status) tooltipLines.push(bk.task_status);
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;
// ---- rich hover tooltip (structured HTML; values escaped) ----
const esc = escapeHtml;
const title = brand || company || 'Без названия';
const managers = (bk.manager || []).filter(Boolean).join(', ');
const dur = durationLabel(bk.start_date, bk.end_date);
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(`<div class="tt-row"><span class="tt-ico">📅</span><span>${fmtDate(bk.start_date)} ${fmtDate(bk.end_date)}${dur ? ' · ' + esc(dur) : ''}</span></div>`);
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) {
tooltipLines.push('⚠ Коллизия с:');
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 || 'Без названия';
tooltipLines.push(' ' + pLabel + ' ' + p.start_date + ' — ' + p.end_date);
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>');
}
// Colour: collision colour overrides the per-status colour.
const color = isCollision ? appearance.collisionColor : colorForStatus(appearance, bk.task_status);
tt.push('<div class="tt-hint">↗ Клик — открыть в ПланФикс</div>');
const tooltipHtml = `<div class="tt" style="--tt-accent:${color}">${tt.join('')}</div>`;
return {
id: bk.board_id + '__' + idx,
@ -304,7 +341,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance)
end: end.toISOString().slice(0, 10),
content: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
type: 'range',
tooltipText: tooltipLines.join('\n'),
tooltipHtml,
taskId: bk.task_id,
};
});

View File

@ -28,6 +28,7 @@ export interface Booking {
end_date: string; // ISO date YYYY-MM-DD
brand: string;
company_name: string;
manager: string[]; // canonical "Фамилия Имя" names (a task may have several)
}
/** Current filter selection shared by boards/bookings queries. */

View File

@ -225,12 +225,17 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager
toString(start_date) AS start_date,
toString(end_date) AS end_date,
brand,
company_name
company_name,
manager
FROM default.pf_board FINAL
WHERE {where}
ORDER BY board_id, start_date
"""
rows = await ch_query(sql, params)
# manager is Array(String) and comes in mixed word orders (see MANAGER_KEY_ARR);
# collapse to canonical "Фамилия Имя" names for display.
for r in rows:
r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m})
return rows