const { downloadJSON, downloadFile, dom, onVisibleOnce, isMobile, getBooleanAttribute, deltaTime, log, itemsToCSV, numberToLocale, } = require("../js/misc"); const { vectorizeItems, similaritySortItems } = require("../js/knn"); const { stores } = require("../model/stores"); const { View } = require("./view"); const { ItemsChart } = require("./items-chart"); const { __ } = require("../browser_i18n"); class ItemsList extends View { static priceTypeId = 0; constructor() { super(); this._json = getBooleanAttribute(this, "json"); this._chart = getBooleanAttribute(this, "chart"); this._remove = getBooleanAttribute(this, "remove"); this._add = getBooleanAttribute(this, "add"); this._updown = getBooleanAttribute(this, "updown"); this._noSort = getBooleanAttribute(this, "nosort"); const hideSort = this._noSort ? "hidden" : ""; this.innerHTML = /*html*/ ` `; const elements = this.elements; if (this._share) elements.shareLink.classList.remove("hidden"); if (this._json) elements.json.classList.remove("hidden"); if (this._json) elements.csv.classList.remove("hidden"); if (this._chart) elements.enableChart.classList.remove("hidden"); elements.json.addEventListener("click", (event) => { event.preventDefault(); if (!this.model) return; this.download(this.model.filteredItems, true); }); elements.csv.addEventListener("click", (event) => { event.preventDefault(); if (!this.model) return; this.download(this.model.filteredItems); }); elements.enableChart.addEventListener("x-change", () => { if (elements.enableChart.checked) elements.chart.classList.remove("hidden"); else elements.chart.classList.add("hidden"); }); // Cache in a field, so we don't have to call this.elements in each renderItem() call. this._showAllPriceHistories = false; elements.expandPriceHistories.addEventListener("click", () => { const showAll = (this._showAllPriceHistories = !this._showAllPriceHistories); elements.expandPriceHistories.innerText = __("ItemsList_Preis") + (showAll ? " ▲" : " ▼"); elements.tableBody.querySelectorAll(".priceinfo").forEach((el) => (showAll ? el.classList.remove("hidden") : el.classList.add("hidden"))); }); elements.chart.unitPrice = elements.unitPrice.checked; elements.unitPrice.addEventListener("change", () => { elements.chart.unitPrice = elements.unitPrice.checked; elements.chart.render(); }); elements.salesPrice.addEventListener("change", () => { elements.chart.unitPrice = elements.unitPrice.checked; elements.chart.render(); }); this.setupEventHandlers(); this.addEventListener("x-change", (event) => { if (this._ignoreChange) { this._ignoreChange = false; return; } this.render(); }); } set model(model) { super.model = model; this.elements.chart.model = model; } get model() { return super.model; } download(items, json) { const cleanedItems = []; items.forEach((item) => { cleanedItems.push({ store: item.store, id: item.id, name: item.name, category: item.category, price: item.price, priceHistory: item.priceHistory, isWeighted: item.isWeighted, unit: item.unit, quantity: item.quantity, bio: item.bio, available: !(item.unavailable ?? false), url: stores[item.store].getUrl(item), }); }); if (json) { downloadJSON("items.json", cleanedItems); } else { downloadFile("items.csv", itemsToCSV(cleanedItems)); } } sort(items) { const sortType = this.elements.sort.value; if (sortType == "price-asc") { if (this.elements.salesPrice.checked) items.sort((a, b) => a.price - b.price); else items.sort((a, b) => a.unitPrice - b.unitPrice); } else if (sortType == "price-desc") { if (this.elements.salesPrice.checked) items.sort((a, b) => b.price - a.price); else items.sort((a, b) => b.salesPrice - a.salesPrice); } else if (sortType == "quantity-asc") { items.sort((a, b) => { if (a.unit != b.unit) return a.unit.localeCompare(b.unit); return a.quantity - b.quantity; }); } else if (sortType == "quantity-desc") { items.sort((a, b) => { if (a.unit != b.unit) return a.unit.localeCompare(b.unit); return b.quantity - a.quantity; }); } else if (sortType == "store-and-name") { items.sort((a, b) => { if (a.store < b.store) { return -1; } else if (a.store > b.store) { return 1; } if (a.name < b.name) { return -1; } else if (a.name > b.name) { return 1; } return 0; }); } else { vectorizeItems(items); items = similaritySortItems(items); } return items; } highlightMatches(keywords, name) { let highlightedName = name; for (let i = 0; i < keywords.length; i++) { const string = keywords[i]; // check if keyword is not preceded by a < or $&"); } return `${highlightedName}`; } renderItem(item) { if (!this._itemTemplate) { this._itemTemplate = dom( "tr", /*html*/ `
` ); } let price = item.priceHistory[0].price; let unitPrice = item.priceHistory[0].unitPrice; let prevPrice = item.priceHistory[1] ? item.priceHistory[1].price : -1; if (this.model.priceChangesToday) { // any item we get with this filter will have a history length >= 2 for (let i = 0; i < item.priceHistory.length; i++) { if (item.priceHistory[i].date == this.model.priceChangesToday) { price = item.priceHistory[i].price; unitPrice = item.priceHistory[i].unitPrice; prevPrice = item.priceHistory[i + 1].price; break; } } } let quantity = item.quantity || ""; let unit = item.unit || ""; if (quantity >= 1000 && (unit === "g" || unit === "ml")) { quantity = parseFloat((0.001 * quantity).toFixed(2)); unit = unit === "ml" ? "l" : "kg"; } let percentageChange = ""; if (prevPrice != -1) { percentageChange = Math.round(((price - prevPrice) / prevPrice) * 100); } let showUnitPrice = this.elements.unitPrice.checked; let priceUnit = ""; if (showUnitPrice) { if (item.unit === "g") priceUnit = " / kg"; else if (item.unit === "ml") priceUnit = " / l"; else priceUnit = " / stk"; } let priceHistory = ""; let priceBase = 150 / (showUnitPrice ? unitPrice : price); for (let i = 0; i < item.priceHistory.length; i++) { const date = item.priceHistory[i].date; const textBold = this.model.priceChangesToday && this.model.priceChangesToday == date ? "bold" : ""; const currPrice = showUnitPrice ? item.priceHistory[i].unitPrice : item.priceHistory[i].price; const lastPrice = item.priceHistory[i + 1] ? showUnitPrice ? item.priceHistory[i + 1].unitPrice : item.priceHistory[i + 1].price : currPrice; const increase = Math.round(((currPrice - lastPrice) / lastPrice) * 100); priceHistory += ` ${date}
€ ${currPrice.toFixed(2)} ${priceUnit}
${ increase > 0 ? ` + ${increase}%` : increase < 0 ? ` ${increase}%` : ` ${increase}%` } `; } let itemDom = this._itemTemplate.cloneNode(true); itemDom.setAttribute( "class", `item group ${stores[item.store]?.color} ${percentageChange > 0 ? "increased" : percentageChange < 0 ? "decreased" : "neutral"}` ); itemDom.setAttribute("x-notraverse", "true"); const elements = View.elements(itemDom); elements.store.innerText = item.store; elements.name.href = stores[item.store].getUrl(item); elements.name.innerHTML = this.highlightMatches(this.model.lastQueryTokens ?? [], item.name) + (item.unavailable ? " 💀" : ""); elements.quantity.innerText = (item.isWeighted ? "⚖ " : "") + `${quantity} ${unit}`; elements.price.innerText = `€ ${Number(showUnitPrice ? unitPrice : price).toFixed(2)} ${priceUnit}`; elements.priceHistory.innerHTML = priceHistory; elements.percentageChange.classList.add(percentageChange > 0 ? "text-red-500" : percentageChange < 0 ? "text-green-500" : "hidden"); elements.percentageChange.innerText = `${percentageChange > 0 ? "+" + percentageChange : percentageChange}%`; elements.numPrices.innerText = item.priceHistory.length > 1 ? "(" + (item.priceHistory.length - 1) + ")" : ""; itemDom.querySelectorAll('td[data-label="Preis"]').forEach((priceDom) => { priceDom.style["cursor"] = "pointer"; priceDom.addEventListener("click", (event) => { let target = event.target; if (!target.classList.contains("chevron")) { target = target.querySelector("chevron"); } const pricesDom = priceDom.parentNode.querySelector(".priceinfo"); if (pricesDom.classList.contains("hidden")) { pricesDom.classList.remove("hidden"); pricesDom.ariaHidden = false; if (target) target.innerHTML = "▲"; } else { pricesDom.classList.add("hidden"); pricesDom.ariaHidden = true; if (target) target.innerHTML = "▼"; } }); }); if (this._chart) { elements.chartCheckbox.checked = item.chart; elements.chartCheckbox.addEventListener("change", (event) => { item.chart = elements.chartCheckbox.checked; if (item.chart && !this.elements.enableChart.checked) { this.elements.enableChart.checked = true; this.elements.chart.classList.remove("hidden"); } this.elements.chart.render(); this._ignoreChange = true; this.fireChangeEvent(); }); } if (this.model.items.length != this.model.filteredItems.length) { elements.up.classList.add("hidden"); elements.down.classList.add("hidden"); } elements.add.addEventListener("click", () => { if (this._addCallback) this._addCallback(item); }); elements.remove.addEventListener("click", () => { let index = this.model.items.indexOf(item); if (index >= 0) this.model.items.splice(index, 1); index = this.model.filteredItems.indexOf(item); if (index >= 0) this.model.filteredItems.splice(index, 1); itemDom.remove(); this.elements.chart.render(); if (this._removeCallback) this._removeCallback(item); }); elements.up.addEventListener("click", () => { const index = itemDom.rowIndex - 1; if (index === 0) return; let otherItem = this.model.items[index - 1]; this.model.items[index - 1] = item; this.model.items[index] = otherItem; let rowBefore = this.elements.tableBody.rows[index - 1]; this.elements.tableBody.insertBefore(itemDom, rowBefore); if (this._upCallback) this._upCallback(item); }); elements.down.addEventListener("click", () => { const index = itemDom.rowIndex - 1; if (index === this.model.items.length - 1) return; let otherItem = this.model.items[index + 1]; this.model.items[index + 1] = item; this.model.items[index] = otherItem; let rowAfter = this.elements.tableBody.rows[index + 1]; this.elements.tableBody.insertBefore(rowAfter, itemDom); if (this._downCallback) this._downCallback(item); }); if (this._showAllPriceHistories) elements.priceHistory.classList.remove("hidden"); return itemDom; } render() { const start = performance.now(); const elements = this.elements; if (!this.model) return; elements.chart.unitPrice = elements.unitPrice.checked; if (this.model.filteredItems.length != 0 && this.model.filteredItems.length <= (isMobile() ? 200 : 1500)) { elements.nameSimilarity.removeAttribute("disabled"); } else { elements.nameSimilarity.setAttribute("disabled", "true"); if (this.model.filteredItems.length != 0 && elements.sort.value === "name-similarity") elements.sort.value = "price-asc"; } let items = [...this.model.filteredItems]; if (this.model.lastQuery && this.model.lastQuery.charAt(0) === "!" && this.model.lastQuery.toLowerCase().indexOf("order by") >= 0) { elements.sort.parentElement.classList.add("hidden"); } else { if (!this._noSort) { elements.sort.parentElement.classList.remove("hidden"); items = this.sort(items); } } if (items.length === 0) { elements.chart.classList.add("hidden"); elements.options.classList.add("hidden"); elements.itemsTable.classList.add("hidden"); } else { if (elements.enableChart.checked) elements.chart.classList.remove("hidden"); elements.options.classList.remove("hidden"); elements.itemsTable.classList.remove("hidden"); } elements.numItems.innerHTML = numberToLocale(items.length) + (this.model.totalItems > items.length ? " / " + numberToLocale(this.model.totalItems) : ""); const tableBody = elements.tableBody; tableBody.innerHTML = ""; let i = 0; const batches = []; let batch = []; items.forEach((item) => { if (batch.length === 25) { batches.push(batch); batch = []; } batch.push(item); }); if (batch.length > 0) batches.push(batch); const renderBatch = () => { const batch = batches.shift(); if (!batch) return; let itemDom = null; for (const item of batch) { itemDom = this.renderItem(item); tableBody.append(itemDom); } if (itemDom) onVisibleOnce(itemDom, renderBatch); }; renderBatch(); log(`ItemsList - rendering ${items.length} items took ${deltaTime(start).toFixed(4)} secs`); } set addCallback(callback) { this._addCallback = callback; } set removeCallback(callback) { this._removeCallback = callback; } set upCallback(callback) { this._upCallback = callback; } set downCallback(callback) { this._downCallback = callback; } } customElements.define("items-list", ItemsList);