This commit is contained in:
Martin Slachta
2026-06-11 19:03:29 +02:00
commit 0d829845c4
150 changed files with 38582 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Elements
Some repeating components of the UI.
+196
View File
@@ -0,0 +1,196 @@
const RsvCalendarPicker = (() => {
function get_first_day_of_month(date) {
const day = new Date(date.getFullYear(), date.getMonth(), 1).getDay();
return day === 0 ? 6 : day - 1; // Mon=0 … Sun=6
}
function is_same_day(a, b) {
return a.getUTCFullYear() === b.getUTCFullYear()
&& a.getUTCMonth() === b.getUTCMonth()
&& a.getUTCDate() === b.getUTCDate();
}
function is_same_month(a, b) {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth();
}
function clear_class(root, cls) {
root.querySelectorAll('.' + cls).forEach(el => el.classList.remove(cls));
}
function set_cell(cell, date, outside) {
cell.classList.toggle('dimm', outside);
const iso = date.toISOString();
cell.setAttribute('datetime', iso);
cell.children[0].id = iso;
cell.children[0].setAttribute('datetime', iso);
cell.children[1].textContent = date.getUTCDate();
cell.children[1].setAttribute('for', iso);
}
function render(state, date) {
const year = date.getFullYear();
const month = date.getMonth();
const first = get_first_day_of_month(date);
const in_cur = new Date(year, month + 1, 0).getDate();
const in_prev = new Date(year, month, 0).getDate();
const today = new Date();
const rows = state.body.querySelectorAll('tr');
clear_class(state.body, 'rsv-cal-cell-current');
clear_class(state.body, 'rsv-cal-cell-today');
let idx = 0;
for (let d = in_prev - first + 1; d <= in_prev; d++, idx++) {
const dt = new Date(Date.UTC(year, month - 1, d));
const cell = rows[0].children[idx];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; i <= in_cur; i++, idx++) {
const dt = new Date(Date.UTC(year, month, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, false);
if (is_same_day(dt, date)) cell.querySelector('input').checked = true;
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
for (let i = 1; idx < 42; i++, idx++) {
const dt = new Date(Date.UTC(year, month + 1, i));
const cell = rows[Math.floor(idx / 7)].children[idx % 7];
set_cell(cell, dt, true);
if (is_same_day(dt, today)) cell.classList.add('rsv-cal-cell-today');
}
state.month_el.textContent =
new Date(year, month).toLocaleString(navigator.language, { month: 'long' }) + ' ' + year;
}
function day_names() {
// Generate short weekday names starting on Monday using the browser locale.
return Array.from({ length: 7 }, (_, i) =>
new Date(2024, 0, 1 + i) // 2024-01-01 is a Monday
.toLocaleDateString(navigator.language, { weekday: 'short' })
);
}
const ARROW_L = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0"/></svg>`;
const ARROW_R = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708"/></svg>`;
function nav_btn(icon, handler) {
const btn = document.createElement('button');
btn.type = 'button';
btn.innerHTML = icon;
btn.classList.add('rsv-cal-btn-nav');
btn.addEventListener('click', handler);
const wrap = document.createElement('div');
wrap.appendChild(btn);
return wrap;
}
function build_header(state) {
const month_el = document.createElement('span');
month_el.classList.add('rsv-cal-month');
state.month_el = month_el;
const controls = document.createElement('div');
controls.classList.add('rsv-cal-controls');
controls.append(
nav_btn(ARROW_L, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() - 1, state.date.getDate()))),
month_el,
nav_btn(ARROW_R, () => state.set_date(new Date(state.date.getFullYear(), state.date.getMonth() + 1, state.date.getDate())))
);
const ctrl_td = document.createElement('td');
ctrl_td.colSpan = 7;
ctrl_td.appendChild(controls);
const ctrl_row = document.createElement('tr');
ctrl_row.appendChild(ctrl_td);
const names_row = document.createElement('tr');
day_names().forEach(name => {
const th = document.createElement('th');
th.textContent = name;
names_row.appendChild(th);
});
const header = document.createElement('thead');
header.classList.add('rsv-cal-header');
header.append(ctrl_row, names_row);
return header;
}
function build_body(name, on_select) {
const tbody = document.createElement('tbody');
tbody.classList.add('rsv-cal-grid');
for (let y = 0; y < 6; y++) {
const row = document.createElement('tr');
for (let x = 0; x < 7; x++) {
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = name + '.date';
radio.hidden = true;
radio.addEventListener('change', on_select);
const label = document.createElement('label');
label.setAttribute('unselectable', 'true');
const cell = document.createElement('td');
cell.classList.add('rsv-cal-cell');
cell.append(radio, label);
row.appendChild(cell);
}
tbody.appendChild(row);
}
return tbody;
}
return {
create(container, name) {
const state = {
date: null,
month_el: null,
body: null,
container,
set_date(date) {
if (this.date !== null && is_same_day(date, this.date)) return;
const month_changed = this.date === null || !is_same_month(date, this.date);
if (month_changed) {
const prev = this.body.querySelector('input[type="radio"]:checked');
if (prev) prev.checked = false;
render(this, date);
const next = this.body.querySelector(`input[id="${date.toISOString()}"]`);
if (next) next.checked = true;
}
const first = get_first_day_of_month(date);
const cell_idx = first + date.getDate() - 1;
const rows = this.body.querySelectorAll('tr');
rows[Math.floor(cell_idx / 7)].children[cell_idx % 7].querySelector('input').checked = true;
this.date = date;
this.container.value = date;
this.container.dispatchEvent(
new InputEvent('change', { bubbles: true, cancelable: true, composed: true })
);
},
};
container.classList.add('rsv-calendar');
const table = document.createElement('table');
table.appendChild(build_header(state));
table.appendChild(build_body(name, e => state.set_date(new Date(e.target.getAttribute('datetime')))));
state.body = table.querySelector('tbody');
container.appendChild(table);
state.set_date(new Date());
return state;
},
};
})();
+422
View File
@@ -0,0 +1,422 @@
/**
* RSV Dynamic datagrid
* Allows fetching with JS instead of page reload.
*/
window.RsvDataGrid = window.RsvDataGrid || {
create_header(self, columns, has_actions) {
let thead = document.createElement('thead');
thead.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
if (has_actions) {
let th = document.createElement('th');
th.innerText = 'Actions';
thead.appendChild(th);
}
return thead;
},
create_footer(self, columns, has_actions) {
let tfoot = document.createElement('tfoot');
let trow = document.createElement('tr');
trow.replaceChildren(...Object.entries(columns).map(([key, value]) => {
let th = document.createElement('th');
if (value.width) {
th.style.width = value.width + 'px';
}
th.classList.add('manage-columns', 'column-' + key);
if (value.is_sortable) {
th.classList.add('sortable', key);
let button = document.createElement('a');
button.onclick = () => {
self.sort_by(key);
};
button.innerHTML =
`<span>${value.label}</span>
<span class="sorting-indicators">
<span class="sorting-indicator asc" aria-hidden="true"></span>
<span class="sorting-indicator desc" aria-hidden="true"></span>
</span>
<span class="screen-reader-text">Sort ascending.</span>
`;
th.appendChild(button);
} else {
th.innerText = value.label;
}
return th;
}));
tfoot.appendChild(trow);
return tfoot;
},
create_dg_row(self, data, index = 0) {
let row = document.createElement('tr');
row.classList.add('iedit', 'author-self', 'level-0', 'type-page', 'status-publish', 'hentry');
row.replaceChildren(...Object.entries(self.columns).map(([key, value]) => {
let td = document.createElement('td');
if (self.mappings[key] != null) {
td = self.mappings[key](self, row, data);
} else {
td.innerText = data[key];
}
if(value.actions != null && Object.entries(value.actions).length > 0) {
const visible_actions = Object.entries(value.actions).filter(([, action]) =>
action.condition == null || action.condition(data, index)
);
if (visible_actions.length === 0) return td;
row.classList.add('has-row-actions')
const action_cell = document.createElement('div');
action_cell.classList.add('row-actions', 'visible');
const action_spans = visible_actions.map(([key, value]) => {
if (value.is_link) {
let span = document.createElement('span');
let a = document.createElement('a');
a.innerText = key;
a.href = value.func(data);
span.appendChild(a);
return span;
} else {
let span = document.createElement('span');
let button = document.createElement('a');
button.onclick = function () { value.func(self, row, data) };
button.innerText = key;
span.appendChild(button);
return span;
}
});
const action_nodes = action_spans.flatMap((span, i) =>
i < action_spans.length - 1 ? [span, document.createTextNode(' | ')] : [span]
);
action_cell.replaceChildren(...action_nodes);
td.appendChild(action_cell);
}
return td;
}));
return row;
},
async render_data_grid(self) {
const rows = self.fetch_resource()
.then(x => { self.set_total(x.total); return x; })
.then(x => x.data.map((x, i) => RsvDataGrid.create_dg_row(self, x, i)))
.then(x => self.body.replaceChildren(...x))
.catch(error => {
console.error(error);
return []; // empty the rows
});
},
link_action(func, condition = null) {
return { is_link: true, func, condition };
},
func_action(func, condition = null) {
return { is_link: false, func, condition };
},
edit_action(func, condition = null) {
return { is_link: false, func, condition };
},
action_column(label, is_sortable, actions) {
return {
label: label,
is_sortable: is_sortable,
actions: actions,
};
},
column(label, is_sortable = false, width = 0) {
return {
label: label,
is_sortable: is_sortable,
width: width
};
},
create_paging_button(text) {
let button = document.createElement('a');
button.classList.add('button');
let label = document.createElement('span');
label.innerText = text;
button.appendChild(label);
return button;
},
create_last_page_btn() {
let btn = this.create_paging_button("»");
btn.classList.add('last-page');
return btn;
},
create_next_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('next-page');
return btn;
},
create_first_page_btn() {
let btn = this.create_paging_button("«");
btn.classList.add('first-page');
return btn;
},
create_prev_page_btn() {
let btn = this.create_paging_button("");
btn.classList.add('prev-page');
return btn;
},
create_paging_text() {
let text = document.createElement('span');
text.classList.add('paging-input');
let paging_text = document.createElement('span');
paging_text.classList.add('tablenav-paging-text');
text.appendChild(paging_text);
const result = {
container: text,
paging_text: paging_text,
};
return result;
},
create_paging_controls(self) {
let nav = document.createElement('div');
nav.classList.add('tablenav-pages');
let displaying_num = document.createElement('span');
displaying_num.classList.add('displaying-num');
nav.appendChild(displaying_num);
let pagination_links = document.createElement('span');
pagination_links.classList.add('pagination-links');
let first_page_btn = this.create_first_page_btn();
first_page_btn.onclick = () => self.goto_first_page();
let prev_page_btn = this.create_prev_page_btn();
prev_page_btn.onclick = () => self.move_page(-1);
let next_page_btn = this.create_next_page_btn();
next_page_btn.onclick = () => self.move_page(1);
let last_page_btn = this.create_last_page_btn();
last_page_btn.onclick = () => self.goto_last_page();
pagination_links.appendChild(first_page_btn);
pagination_links.appendChild(prev_page_btn);
let paging_text = this.create_paging_text();
pagination_links.appendChild(paging_text.container);
pagination_links.appendChild(next_page_btn);
pagination_links.appendChild(last_page_btn);
// pagination_links.innerHTML = `
// <span class="pagination-links">
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true">«</span>
// <span class="tablenav-pages-navspan button disabled" aria-hidden="true"></span>
// <span class="screen-reader-text">Current Page</span>
// <span id="table-paging" class="paging-input">
// <span class="tablenav-paging-text">1 of <span class="total-pages">2</span></span>
// </span>
// <a class="next-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Next page</span>
// <span aria-hidden="true"></span>
// </a>
// <a class="last-page button" href="http://127.0.0.1/wordpress/wp-admin/edit-tags.php?taxonomy=category&amp;paged=2">
// <span class="screen-reader-text">Last page</span>
// <span aria-hidden="true">»</span>
// </a>
// </span>`;
nav.appendChild(pagination_links);
const paging_controls = {
container: nav,
paging_text: paging_text.paging_text,
display_num: displaying_num,
};
return paging_controls;
},
create_data_grid(container, resource, columns, actions) {
let tbody = document.createElement('tbody');
let state = {
columns: columns,
resource: resource,
actions: actions,
body: tbody,
mappings: {},
params: {},
total: 0,
page: 0,
page_size: 20,
container: container,
order_by: null,
order: 0,
fetch_resource() {
const params = { ...this.params };
if (this.order_by) {
params.orderby = this.order_by;
params.order = this.order === 0 ? 'desc' : 'asc';
}
return this.resource.get_page(this.page * this.page_size, this.page_size, params);
},
refresh() {
RsvDataGrid.render_data_grid(this)
},
refresh_row(row, data) {
let row2 = RsvDataGrid.create_dg_row(this, data);
row.replaceWith(row2);
},
map_column(key, func) {
this.mappings[key] = func;
return this;
},
add_action(key, func) {
this.actions[key] = func;
return this;
},
set_param(key, value) {
this.params[key] = value;
},
remove_param(key, do_refresh = true) {
delete this.params[key];
if (do_refresh) {
this.refresh();
}
},
sort_by(key, dir = null, do_refresh = true) {
let ths = this.container.getElementsByClassName("column-" + key);
if (ths.length > 0) {
this.order = dir ?? (this.order_by === key ? 1 - this.order : 0);
this.order_by = key;
let th = ths[0];
if (this.order === 0) {
th.classList.remove('asc');
th.classList.add('desc');
} else {
th.classList.remove('desc');
th.classList.add('asc');
}
// if (th.classList.contains('asc')) {
// th.classList.replace('asc', 'desc');
// this.set_param('order', 'desc');
// } else {
// th.classList.remove('desc');
// th.classList.add('asc');
// this.set_param('order', 'asc');
// }
let sorted = th.parentElement.getElementsByClassName('sorted');
if (sorted.length > 0)
sorted[0].classList.replace('sorted', 'sortable');
th.classList.replace('sortable', 'sorted');
if (do_refresh) {
this.refresh();
}
}
return this;
},
set_total(count) {
this.total = count;
this.paging_controls.display_num.innerHTML = `${this.total} položek`;
this.paging_controls.paging_text.innerHTML = `${this.page + 1} of <span class="total-pages">${Math.ceil(count / this.page_size)}</span>`;
},
get_total() {
return this.total;
},
move_page(relative) {
this.page = Math.max(0, Math.min(this.page + relative, Math.ceil(this.total / this.page_size) - 1));
this.refresh();
},
goto_first_page() {
this.move_page(-this.page);
},
goto_last_page() {
this.move_page(Math.ceil(this.total / this.page_size) - this.page - 1);
},
};
let paging_controls = this.create_paging_controls(state);
let footer = document.createElement('div');
footer.classList.add('tablenav', 'bottom');
footer.appendChild(paging_controls.container);
state.paging_controls = paging_controls;
let table = document.createElement('table');
table.classList.add('datagrid', 'wp-list-table', 'widefat', 'fixed', 'striped', 'table-view-list');
// const has_actions = Object.entries(actions).length > 0;
table.appendChild(this.create_header(state, columns, false));
table.appendChild(tbody);
table.appendChild(this.create_footer(state, columns, false));
container.appendChild(table);
container.appendChild(footer);
return state;
}
}
@@ -0,0 +1,117 @@
class RsvReservationSelector extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'name', 'price-per-block'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get inputName() { return this.getAttribute('name') ?? 'reservation'; }
get pricePerBlock() { return parseFloat(this.getAttribute('price-per-block')) || 0; }
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._slots = [];
this.classList.add('rsv-timetable-selector');
this._build();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === null || oldVal === newVal || !this.isConnected) return;
this._build();
}
// ---- Public API ---------------------------------------------------------
getValue() {
return {
timetable_id: this.timetableId,
timetable_reservations: this._slots.map(s => s.start_utc),
};
}
clear() {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
}
// ---- Private ------------------------------------------------------------
_build() {
this._slots = [];
this.replaceChildren();
const tid = document.createElement('input');
tid.type = 'hidden';
tid.name = `${this.inputName}.timetable_id`;
tid.value = this.timetableId;
this.appendChild(tid);
const cal_el = document.createElement('div');
cal_el.classList.add('rsv-calendar');
// Create rsv-timeline with timetable-id set before appending so
// connectedCallback sees the correct attribute on first render.
const time_el = document.createElement('rsv-timeline');
time_el.setAttribute('timetable-id', this.timetableId);
this.append(cal_el, time_el);
this._calendar = RsvCalendarPicker.create(cal_el, this.inputName);
// Date change: clear selection, then push new date to the timeline element.
cal_el.addEventListener('change', () => {
this.querySelectorAll('.rsv-slots-slot-selected').forEach(s => s.classList.remove('rsv-slots-slot-selected'));
this._slots = [];
this._commit();
time_el.date = this._calendar.date;
});
// Slot toggle: read selected slots from timeline, then commit.
time_el.addEventListener('input', e => {
e.stopPropagation();
this._slots = Array.from(time_el.querySelectorAll('.rsv-slots-slot-selected')).map(s => ({
start_utc: s.dataset.start_utc,
end_utc: s.dataset.end_utc,
}));
this._commit();
});
this._commit();
}
_commit() {
const name = this.inputName;
this.querySelectorAll(`input[name="${name}.timetable_reservations[]"]`).forEach(i => i.remove());
let json = [];
this._slots.forEach(slot => {
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = `${name}.timetable_reservations[]`;
inp.value = slot.start_utc;
this.appendChild(inp);
json.push(slot.start_utc);
});
this.value = JSON.stringify({
"timetable_id": this.timetableId,
"timetable_reservations": json
});
this.dispatchEvent(new CustomEvent('rsv:slots-changed', {
bubbles: true,
detail: {
name,
slots: this._slots,
price_per_block: this.pricePerBlock,
value: this.getValue(),
},
}));
}
}
customElements.define('rsv-reservation-selector', RsvReservationSelector);
+100
View File
@@ -0,0 +1,100 @@
class RsvReservationSummary extends HTMLElement {
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._all_slots = new Map(); // name → { slots, price_per_block }
this._form = this.closest('form');
this._build();
if (this._form) {
this._handler = e => {
this._all_slots.set(e.detail.name, {
slots: e.detail.slots,
price_per_block: e.detail.price_per_block,
});
this._render();
};
this._form.addEventListener('rsv:slots-changed', this._handler);
}
}
disconnectedCallback() {
if (this._form && this._handler) {
this._form.removeEventListener('rsv:slots-changed', this._handler);
}
}
// ---- Private ------------------------------------------------------------
_build() {
const s = ReservairStrings.summary;
this.innerHTML = `
<div class="rsv-summary-header">
<span class="rsv-summary-title">${s.title}</span>
<button type="button" class="rsv-summary-clear">${s.clear_all}</button>
</div>
<ul class="rsv-summary-list"></ul>
<div class="rsv-summary-footer">
<span class="rsv-summary-count"></span>
<div class="rsv-summary-price"></div>
</div>
`;
this.hidden = true;
this.querySelector('.rsv-summary-clear').addEventListener('click', () => {
this._form?.querySelectorAll('rsv-reservation-selector').forEach(sel => sel.clear());
});
}
_render() {
const all_slots = [...this._all_slots.values()].flatMap(({ slots, price_per_block }) =>
slots.map(s => ({ ...s, price_per_block }))
);
console.log(all_slots);
const n = all_slots.length;
const list = this.querySelector('.rsv-summary-list');
const count_el = this.querySelector('.rsv-summary-count');
const price_el = this.querySelector('.rsv-summary-price');
const s = ReservairStrings.summary;
const locale = navigator.language;
this.hidden = n === 0;
if (n === 0) {
list.replaceChildren();
count_el.textContent = '';
price_el.textContent = '';
return;
}
const time_opts = { hour: '2-digit', minute: '2-digit' };
list.replaceChildren(...all_slots.map(slot => {
const start = new Date(slot.start_utc);
const end = new Date(slot.end_utc);
const li = document.createElement('li');
li.className = 'rsv-summary-item';
li.innerHTML = `
<div class="rsv-summary-item-info">
<span class="rsv-summary-item-date">${start.toLocaleDateString(locale, { weekday: 'long', day: 'numeric', month: 'long' })}</span>
<span class="rsv-summary-item-time">${start.toLocaleTimeString(locale, time_opts)} ${end.toLocaleTimeString(locale, time_opts)}</span>
</div>
${slot.price_per_block > 0 ? `<span class="rsv-summary-item-price">${slot.price_per_block} ${s.currency}</span>` : ''}
`;
return li;
}));
const total = all_slots.reduce((sum, slot) => sum + slot.price_per_block, 0);
count_el.textContent = this._fmt_count(n);
price_el.textContent = total > 0 ? `${total} ${s.currency}` : '';
}
_fmt_count(n) {
const s = ReservairStrings.summary;
if (n === 1) return s.count_one;
if (n < 5) return s.count_few.replace('%d', n);
return s.count_many.replace('%d', n);
}
}
customElements.define('rsv-reservation-summary', RsvReservationSummary);
+141
View File
@@ -0,0 +1,141 @@
class RsvTimeline extends HTMLElement {
static get observedAttributes() {
return ['timetable-id', 'date'];
}
// ---- Attribute accessors ------------------------------------------------
get timetableId() { return parseInt(this.getAttribute('timetable-id')); }
get date() {
const attr = this.getAttribute('date');
// Parse as local midnight so setHours() in block rendering stays in local time.
return attr ? new Date(attr + 'T12:00:00') : new Date();
}
set date(value) {
const d = new Date(value);
const str = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.setAttribute('date', str);
}
// ---- Lifecycle ----------------------------------------------------------
connectedCallback() {
this._version = 0;
this.classList.add('rsv-slots-list');
this.addEventListener('click', this._on_click.bind(this));
this._render();
}
attributeChangedCallback(_attr, oldVal, newVal) {
if (oldVal === newVal || !this.isConnected) return;
this._render();
}
// ---- Private ------------------------------------------------------------
_on_click(event) {
const slot = event.target.closest('.rsv-slots-slot');
if (slot && !slot.classList.contains('rsv-slots-slot-full')) {
slot.classList.toggle('rsv-slots-slot-selected');
slot.dispatchEvent(new Event('input', { bubbles: true }));
}
}
async _render() {
// Version guard: discard renders that were superseded by a newer call.
const v = ++this._version;
const s = ReservairStrings.timeline;
if (this.timetableId === null) {
this.replaceChildren(this._notice(s.not_reservable));
return;
}
try {
const occupancy = await RsvTimetableService.get_availability_for_date(this.timetableId, this.date);
if (v !== this._version) return;
if(occupancy.length === 0) {
this.replaceChildren(this._notice(s.no_blocks));
return;
}
const header = document.createElement('div');
header.classList.add('rsv-slots-label');
header.textContent = this.date.toLocaleDateString(navigator.language, {
weekday: 'long', day: 'numeric', month: 'long',
}).replace(',', '');
const blocks = [];
for (const { from_minutes, to_minutes, block_size_in_minutes, occupancy: block_occ } of occupancy) {
if (from_minutes === to_minutes || block_occ.length === 0) {
continue;
}
const from_block = parseInt(from_minutes) / block_size_in_minutes;
const time_slots = block_occ.map((occ, i) =>
this._block(this.date, occ, block_size_in_minutes, from_block + i)
);
const time_slot_group = document.createElement('div');
time_slot_group.classList.add('rsv-slots-group');
time_slot_group.replaceChildren(...time_slots);
blocks.push(time_slot_group);
}
this.replaceChildren(header, ...blocks);
} catch (_e) {
if (v !== this._version) return;
this.replaceChildren(this._notice(s.no_blocks));
}
}
_block(date, left, block_size, idx) {
const from = new Date(date);
from.setHours(0, idx * block_size, 0, 0);
const to = new Date(from);
to.setMinutes(to.getMinutes() + block_size);
const cell = document.createElement('div');
cell.classList.add('rsv-slots-slot', 'rsv-slots-slot-available');
cell.dataset.start_utc = from.toISOString();
cell.dataset.end_utc = to.toISOString();
if (left === 0) cell.classList.add('rsv-slots-slot-full');
const time_el = document.createElement('span');
time_el.classList.add('rsv-slots-slot-time');
time_el.textContent = `${this._fmt(from)} ${this._fmt(to)}`;
const badge = document.createElement('span');
badge.classList.add('rsv-slots-slot-badge');
const remaining_seats = left;
if (remaining_seats > 0) badge.classList.add('rsv-slots-slot-badge-available');
if (remaining_seats === 1) badge.textContent = `${remaining_seats} místo`;
else if (remaining_seats >= 2 && remaining_seats <= 4) badge.textContent = `${remaining_seats} místa`;
else badge.textContent = `${remaining_seats} míst`;
cell.append(time_el, badge);
return cell;
}
_notice(text) {
const p = document.createElement('p');
p.classList.add('rsv-slots-notice');
p.textContent = text;
return p;
}
_fmt(dt) {
return dt.getHours() + ':' + String(dt.getMinutes()).padStart(2, '0');
}
}
customElements.define('rsv-timeline', RsvTimeline);