Last active
November 14, 2025 07:03
-
-
Save yangirov/683598fc2228ec7dd4cede497d6c4854 to your computer and use it in GitHub Desktop.
Раскладка UfaDevConf в виде таблицы
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Ufacoder Schedule Table With Favorites + Section Toggles | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.1 | |
| // @description Строит таблицу программы по трекам. | |
| // @author You | |
| // @match https://dc.ufacoder.com/* | |
| // @grant none | |
| // @run-at document-idle | |
| // @noframes | |
| // ==/UserScript== | |
| (() => { | |
| if (document.querySelector('.tt-wrap')) return; | |
| // --- CSS --- | |
| const css = ` | |
| .tt-wrap{margin:0 auto 40px;padding:0 10px;overflow:auto} | |
| .tt-filters{margin:10px 0 8px;display:flex;flex-wrap:wrap;gap:12px;align-items:center;font:14px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,'Noto Sans',sans-serif} | |
| .tt-filters .tt-badge{opacity:.7} | |
| .tt-filters label{display:inline-flex;align-items:center;gap:6px;cursor:pointer;user-select:none} | |
| .tt-table{width:100%;border-collapse:collapse;font:14px/1.45 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,'Noto Sans',sans-serif} | |
| .tt-table th,.tt-table td{border:1px solid #e6e6e6;padding:10px 12px;vertical-align:top;background:#fff} | |
| .tt-table thead th{position:sticky;top:0;background:#fff;z-index:3;font-weight:600} | |
| .tt-table tr th:first-child{position:sticky;left:0;background:#fff;z-index:2;white-space:nowrap;width:120px} | |
| .tt-table td{min-width:180px;position:relative} | |
| .tt-table tr:hover td{background:#fafbfc} | |
| .tt-talk{font-weight:600;margin:0 0 4px} | |
| .tt-speaker{font-size:12px;opacity:.9;margin:0} | |
| .tt-desc{font-size:12px;opacity:.75;margin:2px 0 0} | |
| .tt-break{background:#f7f8fa;color:#555} | |
| .tt-empty{background:repeating-linear-gradient(45deg,#fff,#fff 8px,#fafafa 8px,#fafafa 16px)} | |
| .tt-fav{background:#ffeaf3 !important; box-shadow:inset 0 0 0 2px #ff8fc2} | |
| .tt-fav::after{content:'❤️'; position:absolute; top:6px; right:8px; font-size:14px; opacity:.9} | |
| .tt-flash{animation:ttflash 1.2s ease-out} | |
| @keyframes ttflash{0%{box-shadow:0 0 0 0 rgba(255,143,194,.9)}100%{box-shadow:0 0 0 16px rgba(255,143,194,0)}} | |
| @media (max-width:960px){.tt-table td{min-width:140px}} | |
| `; | |
| const style = document.createElement('style'); | |
| style.textContent = css; | |
| (document.head || document.documentElement).appendChild(style); | |
| // --- Колонки (обнови id при необходимости) --- | |
| const tracks = [ | |
| { id: '#rec1254949481', name: 'Backend' }, | |
| { id: '#rec1254949511', name: 'Frontend' }, | |
| { id: '#rec1367845201', name: 'AI/ML' }, | |
| { id: '#rec1254949521', name: 'DevOps' }, | |
| { id: '#rec1367845221', name: 'Management' }, | |
| { id: '#rec1538298301', name: 'Management Workshop' }, | |
| { id: '#rec1372132381', name: 'АРИТ' } | |
| ]; | |
| const waitForMany = (sels, cb, tries = 80) => { | |
| const tick = () => { | |
| if (sels.every(s => document.querySelector(s))) return cb(); | |
| if (tries-- <= 0) return; | |
| setTimeout(tick, 250); | |
| }; | |
| tick(); | |
| }; | |
| waitForMany(['#rec1254949451', '.t516__row'], build); | |
| function build() { | |
| const isBreak = (s) => /(перерыв|кофе|обед|регистрац)/i.test(s || ''); | |
| const normTime = (t) => String(t || '') | |
| .replace(/\s+/g, ' ') | |
| .replace(/[—–-]/g, '–') | |
| .replace(/\s*–\s*/, ' – ') | |
| .trim(); | |
| const startMinutes = (t) => { | |
| const m = normTime(t).match(/(\d{1,2}):(\d{2})/); | |
| return m ? (+m[1] * 60 + +m[2]) : 0; | |
| }; | |
| // Собираем данные по всем трекам | |
| const timesMap = new Map(); | |
| tracks.forEach(tr => { | |
| const root = document.querySelector(tr.id); | |
| if (!root) return; | |
| root.querySelectorAll('.t516__row').forEach(row => { | |
| const timeEl = row.querySelector('.t516__leftcol .t516__time'); | |
| if (!timeEl) return; | |
| const time = normTime(timeEl.textContent); | |
| const titleEl = row.querySelector('.t516__sectiontextwrapper .t516__title'); | |
| const talkEl = row.querySelector('.t516__sectiontextwrapper .t516__text'); | |
| const descEl = row.querySelector('.t516__sectiontextwrapper .t516__persdescr'); | |
| const talk = (talkEl ? talkEl.textContent : (titleEl ? titleEl.textContent : '')).trim(); | |
| const speaker = (talkEl && titleEl ? titleEl.textContent.trim() : ''); | |
| const desc = (descEl ? descEl.innerHTML?.split("<br>").join(", ").trim() : ''); | |
| if (!timesMap.has(time)) timesMap.set(time, {}); | |
| timesMap.get(time)[tr.name] = { talk, speaker, desc }; | |
| }); | |
| }); | |
| const times = Array.from(timesMap.keys()).sort((a, b) => startMinutes(a) - startMinutes(b)); | |
| // --- Контейнер + фильтры + таблица --- | |
| const wrap = document.createElement('div'); | |
| wrap.className = 'tt-wrap'; | |
| const filters = document.createElement('div'); | |
| filters.className = 'tt-filters'; | |
| filters.innerHTML = `<span class="tt-badge">Секции:</span>`; | |
| const table = document.createElement('table'); | |
| table.className = 'tt-table'; | |
| const thead = document.createElement('thead'); | |
| const hr = document.createElement('tr'); | |
| ['Время', ...tracks.map(t => t.name)].forEach(h => { | |
| const th = document.createElement('th'); | |
| th.textContent = h; | |
| hr.appendChild(th); | |
| }); | |
| thead.appendChild(hr); | |
| const tbody = document.createElement('tbody'); | |
| times.forEach(time => { | |
| const tr = document.createElement('tr'); | |
| const th = document.createElement('th'); | |
| th.textContent = time; | |
| tr.appendChild(th); | |
| tracks.forEach(col => { | |
| const td = document.createElement('td'); | |
| const data = (timesMap.get(time) || {})[col.name]; | |
| if (data) { | |
| if (isBreak(data.talk)) td.classList.add('tt-break'); | |
| const t = document.createElement('div'); | |
| t.className = 'tt-talk'; | |
| t.textContent = data.talk || '—'; | |
| td.appendChild(t); | |
| if (data.speaker) { | |
| const s = document.createElement('div'); | |
| s.className = 'tt-speaker'; | |
| s.textContent = data.speaker; | |
| td.appendChild(s); | |
| } | |
| if (data.desc && data.speaker) { | |
| const d = document.createElement('div'); | |
| d.className = 'tt-desc'; | |
| d.textContent = data.desc; | |
| td.appendChild(d); | |
| } | |
| const key = makeKey(time, col.name); | |
| td.dataset.key = key; | |
| td.dataset.time = time; | |
| td.dataset.track = col.name; | |
| td.id = 'tt_' + key.replace(/\W+/g,'_'); | |
| } else { | |
| td.classList.add('tt-empty'); | |
| } | |
| tr.appendChild(td); | |
| }); | |
| tbody.appendChild(tr); | |
| }); | |
| table.appendChild(thead); | |
| table.appendChild(tbody); | |
| // Вставка после блока «Программа» | |
| const programBlock = document.querySelector('#rec1254949451'); | |
| wrap.appendChild(filters); | |
| wrap.appendChild(table); | |
| if (programBlock) programBlock.insertAdjacentElement('afterend', wrap); | |
| else document.body.prepend(wrap); | |
| // --- Избранное (подсветка + ❤️, без отдельного блока) --- | |
| const LSK = 'ttFavoritesV1'; | |
| const loadFavs = () => { try { return JSON.parse(localStorage.getItem(LSK) || '{}'); } catch { return {}; } }; | |
| const saveFavs = (obj) => localStorage.setItem(LSK, JSON.stringify(obj)); | |
| let favs = loadFavs(); | |
| applyFavClasses(); | |
| table.addEventListener('click', (e) => { | |
| const td = e.target.closest('td'); | |
| if (!td || !td.dataset.key) return; | |
| toggleFav(td); | |
| }); | |
| function makeKey(time, track){ return `${time}__${track}`; } | |
| function toggleFav(td){ | |
| const key = td.dataset.key; | |
| if (!key) return; | |
| if (favs[key]) { | |
| delete favs[key]; | |
| saveFavs(favs); | |
| td.classList.remove('tt-fav'); | |
| } else { | |
| const item = { | |
| key, | |
| time: td.dataset.time || '', | |
| track: td.dataset.track || '', | |
| talk: (td.querySelector('.tt-talk')?.textContent || '').trim(), | |
| speaker: (td.querySelector('.tt-speaker')?.textContent || '').trim() | |
| }; | |
| favs[key] = item; | |
| saveFavs(favs); | |
| td.classList.add('tt-fav','tt-flash'); | |
| setTimeout(()=>td.classList.remove('tt-flash'), 1200); | |
| } | |
| } | |
| function applyFavClasses(){ | |
| document.querySelectorAll('.tt-table td[data-key]').forEach(td => { | |
| const key = td.dataset.key; | |
| if (key && favs[key]) td.classList.add('tt-fav'); | |
| else td.classList.remove('tt-fav'); | |
| }); | |
| } | |
| // --- Тогглы секций (колонок) с сохранением состояния --- | |
| const LSF = 'ttHiddenTracksV1'; | |
| const loadHidden = () => { | |
| try { return new Set(JSON.parse(localStorage.getItem(LSF) || '[]')); } catch { return new Set(); } | |
| }; | |
| const saveHidden = (set) => localStorage.setItem(LSF, JSON.stringify([...set])); | |
| const hidden = loadHidden(); | |
| // построить чекбоксы | |
| tracks.forEach((t, idx) => { | |
| const id = `tt_ch_${idx}`; | |
| const label = document.createElement('label'); | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; | |
| cb.id = id; | |
| const initiallyVisible = !hidden.has(t.name); | |
| cb.checked = initiallyVisible; | |
| cb.addEventListener('change', () => { | |
| setColumnVisible(idx, cb.checked); | |
| if (cb.checked) hidden.delete(t.name); | |
| else hidden.add(t.name); | |
| saveHidden(hidden); | |
| }); | |
| const span = document.createElement('span'); | |
| span.textContent = t.name; | |
| label.appendChild(cb); | |
| label.appendChild(span); | |
| filters.appendChild(label); | |
| }); | |
| // применить сохранённое состояние | |
| tracks.forEach((t, idx) => { | |
| const visible = !hidden.has(t.name); | |
| setColumnVisible(idx, visible); | |
| const cb = document.getElementById(`tt_ch_${idx}`); | |
| if (cb) cb.checked = visible; | |
| }); | |
| function setColumnVisible(trackIndex, visible){ | |
| const dsp = visible ? '' : 'none'; | |
| const headRow = thead.querySelector('tr'); | |
| // +1 потому что 0-й столбец — «Время» | |
| const hCell = headRow?.children[trackIndex + 1]; | |
| if (hCell) hCell.style.display = dsp; | |
| tbody.querySelectorAll('tr').forEach(tr => { | |
| const cell = tr.children[trackIndex + 1]; | |
| if (cell) cell.style.display = dsp; | |
| }); | |
| } | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment