2023-05-26 18:12:29 +02:00
|
|
|
const stores = {
|
|
|
|
billa: {
|
|
|
|
name: "Billa",
|
|
|
|
budgetBrands: ["clever"],
|
|
|
|
color: "rgb(255 255 225)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://shop.billa.at${item.url}`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
2023-05-28 21:06:19 +02:00
|
|
|
spar: {
|
|
|
|
name: "Spar",
|
|
|
|
budgetBrands: ["s-budget"],
|
|
|
|
color: "rgb(225 244 225)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://www.interspar.at/shop/lebensmittel${item.url}`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
|
|
|
hofer: {
|
|
|
|
name: "Hofer",
|
|
|
|
budgetBrands: ["milfina"],
|
|
|
|
color: "rgb(230 230 255)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://www.roksh.at/hofer/produkte/${item.url}`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
|
|
|
lidl: {
|
|
|
|
name: "Lidl",
|
|
|
|
budgetBrands: ["milbona"],
|
|
|
|
color: "rgb(255 225 225)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://www.lidl.at${item.url}`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
|
|
|
mpreis: {
|
|
|
|
name: "MPREIS",
|
|
|
|
budgetBrands: [],
|
|
|
|
color: "rgb(255 230 230)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://www.mpreis.at/shop/p/${item.id}`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
2023-05-28 21:06:19 +02:00
|
|
|
dm: {
|
|
|
|
name: "DM",
|
2023-05-30 10:01:24 +02:00
|
|
|
budgetBrands: ["balea"],
|
2023-05-28 21:06:19 +02:00
|
|
|
color: "rgb(255 240 230)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://www.dm.at/product-p${item.id}.html`,
|
2023-05-26 18:12:29 +02:00
|
|
|
},
|
2023-05-29 00:08:32 +02:00
|
|
|
unimarkt: {
|
|
|
|
name: "Unimarkt",
|
|
|
|
budgetBrands: ["jeden tag", "unipur"],
|
|
|
|
color: "rgb(179, 217, 255)",
|
2023-06-02 10:38:14 +02:00
|
|
|
getUrl: (item) => `https://shop.unimarkt.at/${item.url}`,
|
2023-05-29 00:08:32 +02:00
|
|
|
},
|
2023-06-01 14:40:28 +02:00
|
|
|
penny: {
|
|
|
|
name: "Penny",
|
2023-06-02 16:45:54 +02:00
|
|
|
budgetBrands: ["bravo", "echt bio!", "san fabio", "federike", "blik", "berida", "today", "ich bin österreich"],
|
2023-06-01 14:40:28 +02:00
|
|
|
color: "rgb(255, 180, 180)",
|
2023-06-04 21:51:50 +02:00
|
|
|
getUrl: (item) => `https://www.penny.at/produkte/${item.url}`,
|
2023-06-03 01:24:22 +02:00
|
|
|
},
|
2023-06-04 02:19:13 +02:00
|
|
|
dmDe: {
|
|
|
|
name: "DM DE",
|
|
|
|
budgetBrands: ["balea"],
|
|
|
|
color: "rgb(236 254 253)",
|
|
|
|
getUrl: (item) => `https://www.dm.de/product-p${item.id}.html`,
|
|
|
|
},
|
|
|
|
reweDe: {
|
|
|
|
name: "REWE DE",
|
|
|
|
budgetBrands: ["ja!"],
|
|
|
|
color: "rgb(236 231 225)",
|
|
|
|
getUrl: (item) => `https://shop.rewe.de/p/${item.name.toLowerCase().replace(/ /g, "-")}/${item.id}`,
|
|
|
|
},
|
2023-05-26 18:12:29 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
const STORE_KEYS = Object.keys(stores);
|
2023-06-02 16:45:54 +02:00
|
|
|
const BUDGET_BRANDS = [...new Set([].concat(...Object.values(stores).map((store) => store.budgetBrands)))];
|
2023-05-26 18:12:29 +02:00
|
|
|
|
2023-05-29 17:31:00 +02:00
|
|
|
/**
|
|
|
|
* @description Returns the current date in ISO format
|
|
|
|
* @returns {string} ISO date string in format YYYY-MM-DD
|
|
|
|
*/
|
2023-05-18 18:14:51 +02:00
|
|
|
function currentDate() {
|
2023-06-03 01:24:22 +02:00
|
|
|
const currentDate = new Date();
|
|
|
|
const year = currentDate.getFullYear();
|
|
|
|
const month = String(currentDate.getMonth() + 1).padStart(2, "0");
|
|
|
|
const day = String(currentDate.getDate()).padStart(2, "0");
|
|
|
|
return `${year}-${month}-${day}`;
|
2023-05-18 18:14:51 +02:00
|
|
|
}
|
|
|
|
|
2023-05-29 17:31:00 +02:00
|
|
|
/**
|
|
|
|
* @description Gets the query parameter from the URL
|
|
|
|
* @param {string} name Name of the query parameter
|
|
|
|
* @returns {string | null} Value of the query parameter or null if not found
|
|
|
|
*/
|
2023-05-19 16:01:43 +02:00
|
|
|
function getQueryParameter(name) {
|
2023-05-29 19:05:19 +02:00
|
|
|
const url = new URL(window.location.href);
|
2023-05-30 11:52:35 +02:00
|
|
|
const params = url.searchParams.getAll(name);
|
|
|
|
return params.length > 1 ? params : params?.[0];
|
2023-05-19 16:01:43 +02:00
|
|
|
}
|
|
|
|
|
2023-05-29 17:31:00 +02:00
|
|
|
/**
|
|
|
|
* @description Converts a string to a number
|
|
|
|
* @param {string} value String to convert
|
|
|
|
* @param {number} defaultValue Default value if conversion fails
|
|
|
|
* @returns {number} Converted number or default value
|
|
|
|
*/
|
2023-05-19 14:47:40 +02:00
|
|
|
function toNumber(value, defaultValue) {
|
2023-05-29 19:13:36 +02:00
|
|
|
try {
|
|
|
|
return Number.parseFloat(value);
|
|
|
|
} catch (e) {
|
|
|
|
return defaultValue;
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
}
|
|
|
|
|
2023-05-29 17:31:00 +02:00
|
|
|
/**
|
|
|
|
* @description Create dom element from html string and add inner html via string template
|
|
|
|
* @param {string} el Element type
|
|
|
|
* @param {string} html Inner html
|
|
|
|
* @returns {HTMLElement} DOM element
|
|
|
|
*/
|
2023-05-30 11:52:35 +02:00
|
|
|
function dom(el, html = null) {
|
2023-05-29 19:13:36 +02:00
|
|
|
const element = document.createElement(el);
|
2023-05-30 11:52:35 +02:00
|
|
|
if (html != null) element.innerHTML = html;
|
2023-05-29 19:13:36 +02:00
|
|
|
return element;
|
2023-05-18 18:14:51 +02:00
|
|
|
}
|
|
|
|
|
2023-05-30 10:34:25 +02:00
|
|
|
function decompress(compressedItems) {
|
|
|
|
const items = [];
|
2023-06-02 10:38:14 +02:00
|
|
|
const stores_ = compressedItems.stores;
|
2023-05-30 10:34:25 +02:00
|
|
|
const data = compressedItems.data;
|
|
|
|
const numItems = compressedItems.n;
|
|
|
|
let i = 0;
|
|
|
|
while (items.length < numItems) {
|
2023-06-02 10:38:14 +02:00
|
|
|
const store = stores_[data[i++]];
|
2023-05-30 10:34:25 +02:00
|
|
|
const id = data[i++];
|
|
|
|
const name = data[i++];
|
|
|
|
const numPrices = data[i++];
|
|
|
|
const prices = [];
|
|
|
|
for (let j = 0; j < numPrices; j++) {
|
|
|
|
const date = data[i++];
|
|
|
|
const price = data[i++];
|
|
|
|
prices.push({
|
2023-06-02 16:45:54 +02:00
|
|
|
date: date.substring(0, 4) + "-" + date.substring(4, 6) + "-" + date.substring(6, 8),
|
2023-05-30 22:44:45 +02:00
|
|
|
price,
|
2023-05-30 10:34:25 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
const unit = data[i++];
|
|
|
|
const quantity = data[i++];
|
|
|
|
const isWeighted = data[i++] == 1;
|
|
|
|
const bio = data[i++] == 1;
|
2023-06-03 01:24:22 +02:00
|
|
|
const url = stores[store].getUrl({ id, name, url: data[i++] });
|
2023-05-30 10:34:25 +02:00
|
|
|
|
|
|
|
items.push({
|
|
|
|
store,
|
|
|
|
id,
|
|
|
|
name,
|
|
|
|
price: prices[0].price,
|
|
|
|
priceHistory: prices,
|
|
|
|
isWeighted,
|
|
|
|
unit,
|
|
|
|
quantity,
|
|
|
|
bio,
|
2023-05-30 22:44:45 +02:00
|
|
|
url,
|
2023-05-30 10:34:25 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
return items;
|
|
|
|
}
|
|
|
|
|
2023-05-23 20:07:26 +02:00
|
|
|
async function loadItems() {
|
2023-05-30 10:34:25 +02:00
|
|
|
now = performance.now();
|
2023-05-30 13:08:43 +02:00
|
|
|
const compressedItemsPerStore = [];
|
|
|
|
for (const store of STORE_KEYS) {
|
2023-05-30 22:44:45 +02:00
|
|
|
compressedItemsPerStore.push(
|
|
|
|
new Promise(async (resolve) => {
|
|
|
|
const now = performance.now();
|
|
|
|
try {
|
2023-06-03 22:00:52 +02:00
|
|
|
const response = await fetch(`data/latest-canonical.${store}.compressed.json`);
|
2023-05-30 22:44:45 +02:00
|
|
|
const json = await response.json();
|
2023-06-02 16:45:54 +02:00
|
|
|
console.log(`Loading compressed items for ${store} took ${(performance.now() - now) / 1000} secs`);
|
2023-05-30 22:44:45 +02:00
|
|
|
resolve(decompress(json));
|
|
|
|
} catch {
|
|
|
|
console.log(
|
2023-06-02 16:45:54 +02:00
|
|
|
`Error while loading compressed items for ${store}. It took ${(performance.now() - now) / 1000} secs, continueing...`
|
2023-05-30 22:44:45 +02:00
|
|
|
);
|
|
|
|
resolve([]);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
2023-05-30 13:08:43 +02:00
|
|
|
}
|
2023-05-30 22:44:45 +02:00
|
|
|
const items = [].concat(...(await Promise.all(compressedItemsPerStore)));
|
2023-06-02 16:45:54 +02:00
|
|
|
console.log("Loading compressed items in parallel took " + (performance.now() - now) / 1000 + " secs");
|
2023-05-29 19:05:19 +02:00
|
|
|
|
2023-05-30 10:34:25 +02:00
|
|
|
now = performance.now();
|
2023-06-01 18:28:45 +02:00
|
|
|
alasql.fn.hasPriceChange = (priceHistory, date, endDate) => {
|
2023-05-30 22:44:45 +02:00
|
|
|
if (!endDate) return priceHistory.some((price) => price.date == date);
|
2023-06-02 16:45:54 +02:00
|
|
|
else return priceHistory.some((price) => price.date >= date && price.date <= endDate);
|
2023-05-30 22:44:45 +02:00
|
|
|
};
|
2023-05-29 19:05:19 +02:00
|
|
|
for (const item of items) {
|
2023-05-31 16:42:41 +02:00
|
|
|
item.search = item.name + " " + item.quantity + " " + item.unit;
|
2023-05-29 19:13:36 +02:00
|
|
|
item.search = item.search.toLowerCase().replace(",", ".");
|
2023-05-29 19:05:19 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
item.numPrices = item.priceHistory.length;
|
2023-06-02 16:45:54 +02:00
|
|
|
item.priceOldest = item.priceHistory[item.priceHistory.length - 1].price;
|
2023-05-29 19:13:36 +02:00
|
|
|
item.dateOldest = item.priceHistory[item.priceHistory.length - 1].date;
|
|
|
|
item.date = item.priceHistory[0].date;
|
|
|
|
let highestPriceBefore = -1;
|
2023-06-02 09:56:48 +02:00
|
|
|
let lowestPriceBefore = 100000;
|
2023-05-29 19:13:36 +02:00
|
|
|
for (let i = 1; i < item.priceHistory.length; i++) {
|
|
|
|
const price = item.priceHistory[i];
|
2023-06-01 14:24:50 +02:00
|
|
|
if (i < 10) {
|
|
|
|
item["price" + i] = price.price;
|
|
|
|
item["date" + i] = price.date;
|
|
|
|
}
|
2023-05-29 19:13:36 +02:00
|
|
|
highestPriceBefore = Math.max(highestPriceBefore, price.price);
|
2023-06-02 09:56:48 +02:00
|
|
|
lowestPriceBefore = Math.min(lowestPriceBefore, price.price);
|
2023-05-29 19:13:36 +02:00
|
|
|
}
|
|
|
|
if (highestPriceBefore == -1) highestPriceBefore = item.price;
|
2023-06-02 09:56:48 +02:00
|
|
|
if (lowestPriceBefore == 100000) lowestPriceBefore = item.price;
|
2023-05-29 19:13:36 +02:00
|
|
|
item.highestBefore = highestPriceBefore;
|
2023-06-02 09:56:48 +02:00
|
|
|
item.lowestBefore = lowestPriceBefore;
|
2023-05-23 20:07:26 +02:00
|
|
|
}
|
2023-06-02 16:45:54 +02:00
|
|
|
console.log("Processing items took " + (performance.now() - now) / 1000 + " secs");
|
2023-05-29 19:05:19 +02:00
|
|
|
return items;
|
2023-05-23 20:07:26 +02:00
|
|
|
}
|
|
|
|
|
2023-05-29 17:31:00 +02:00
|
|
|
/**
|
2023-05-29 19:05:19 +02:00
|
|
|
* @description Class for managing the shopping carts, which are stored in local storage
|
2023-05-29 17:31:00 +02:00
|
|
|
*/
|
2023-05-29 19:05:19 +02:00
|
|
|
class ShoppingCarts {
|
2023-05-29 19:13:36 +02:00
|
|
|
constructor() {
|
|
|
|
this.carts = [];
|
|
|
|
this.load();
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
/**
|
|
|
|
* @description Load the shopping carts from local storage into carts array
|
|
|
|
*/
|
|
|
|
load() {
|
|
|
|
const val = localStorage.getItem("carts");
|
|
|
|
this.carts = val ? JSON.parse(val) : [];
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
/**
|
|
|
|
* @description Save the shopping carts to local storage, with key "carts"
|
|
|
|
*/
|
|
|
|
save() {
|
|
|
|
localStorage.setItem("carts", JSON.stringify(this.carts, null, 2));
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
/**
|
|
|
|
* @description Check if the shopping carts contains a cart with the given name
|
|
|
|
* @param {string} name Name of the shopping cart to check
|
|
|
|
*/
|
|
|
|
has(name) {
|
|
|
|
for (const cart of this.carts) {
|
|
|
|
if (cart.name === name) return true;
|
|
|
|
}
|
|
|
|
return false;
|
2023-05-19 14:47:40 +02:00
|
|
|
}
|
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
/**
|
|
|
|
* @description Add new shopping card to array and save new carts array to local storage
|
|
|
|
* @param {string} name Name of the shopping cart to add
|
|
|
|
*/
|
|
|
|
add(name) {
|
|
|
|
this.carts.push({
|
|
|
|
name: name,
|
|
|
|
items: [],
|
|
|
|
});
|
|
|
|
this.save();
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
/**
|
|
|
|
* @description Remove shopping cart from carts array based on name and save updated array to local storage
|
|
|
|
* @param {string} name Name of the shopping cart to remove
|
|
|
|
*/
|
|
|
|
remove(name) {
|
|
|
|
this.carts = this.carts.filter((cart) => cart.name !== name);
|
|
|
|
this.save();
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
}
|
|
|
|
|
2023-05-18 18:20:58 +02:00
|
|
|
function itemToStoreLink(item) {
|
2023-05-26 18:12:29 +02:00
|
|
|
if (STORE_KEYS.includes(item.store)) {
|
2023-05-30 22:44:45 +02:00
|
|
|
return `<a target="_blank" class="itemname itemname--${item.store}" rel="noopener noreferrer nofollow" href="${item.url}">${item.name}</a>`;
|
2023-05-26 18:12:29 +02:00
|
|
|
}
|
|
|
|
return `<span class="itemname itemname--${item.store} itemname--nolink">${item.name}</span>`;
|
2023-05-18 18:20:58 +02:00
|
|
|
}
|
|
|
|
|
2023-05-18 18:14:51 +02:00
|
|
|
function itemToDOM(item) {
|
2023-05-30 22:44:45 +02:00
|
|
|
let quantity = item.quantity || "";
|
2023-05-28 19:25:15 +02:00
|
|
|
let unit = item.unit || "";
|
2023-05-30 22:44:45 +02:00
|
|
|
if (quantity >= 1000 && (unit == "g" || unit == "ml")) {
|
2023-05-28 19:25:15 +02:00
|
|
|
quantity = parseFloat((0.001 * quantity).toFixed(2));
|
2023-05-30 22:44:45 +02:00
|
|
|
unit = unit == "ml" ? "l" : "kg";
|
2023-05-28 19:25:15 +02:00
|
|
|
}
|
2023-05-25 07:03:21 +02:00
|
|
|
let increase = "";
|
|
|
|
if (item.priceHistory.length > 1) {
|
2023-06-02 16:45:54 +02:00
|
|
|
let percentageChange = Math.round(((item.priceHistory[0].price - item.priceHistory[1].price) / item.priceHistory[1].price) * 100);
|
|
|
|
increase = `<span class="${percentageChange > 0 ? "increase" : "decrease"}">${
|
2023-05-30 22:44:45 +02:00
|
|
|
percentageChange > 0 ? "+" + percentageChange : percentageChange
|
|
|
|
}%</span>`;
|
2023-05-25 07:03:21 +02:00
|
|
|
}
|
2023-06-04 14:45:05 +02:00
|
|
|
|
|
|
|
let priceHistory = "";
|
2023-05-24 16:59:43 +02:00
|
|
|
for (let i = 0; i < item.priceHistory.length; i++) {
|
|
|
|
const date = item.priceHistory[i].date;
|
|
|
|
const currPrice = item.priceHistory[i].price;
|
2023-06-02 16:45:54 +02:00
|
|
|
const lastPrice = item.priceHistory[i + 1] ? item.priceHistory[i + 1].price : currPrice;
|
|
|
|
const increase = Math.round(((currPrice - lastPrice) / lastPrice) * 100);
|
2023-05-24 16:59:43 +02:00
|
|
|
let priceColor = "black";
|
|
|
|
if (increase > 0) priceColor = "red";
|
|
|
|
if (increase < 0) priceColor = "green";
|
2023-06-04 14:45:05 +02:00
|
|
|
priceHistory += `<span style="color: ${priceColor}">${date} ${currPrice} ${increase > 0 ? "+" + increase : increase}%</span>`;
|
|
|
|
if (i != item.priceHistory.length - 1) priceHistory += "<br>";
|
2023-05-24 16:59:43 +02:00
|
|
|
}
|
2023-06-04 14:45:05 +02:00
|
|
|
|
|
|
|
const row = dom(
|
|
|
|
"tr",
|
|
|
|
`
|
|
|
|
<td data-label="Kette">${item.store}</td>
|
|
|
|
<td data-label="Name">${itemToStoreLink(item)}</td>
|
|
|
|
<td data-label="Menge">${(item.isWeighted ? "⚖ " : "") + `${quantity} ${unit}`}
|
|
|
|
<td data-label="Preis">
|
|
|
|
${Number(item.price).toFixed(2)} ${increase} ${item.priceHistory.length > 1 ? "(" + (item.priceHistory.length - 1) + ")" : ""}
|
|
|
|
<div class="priceinfo hide">${priceHistory}</div>
|
|
|
|
</td>
|
|
|
|
`
|
|
|
|
);
|
|
|
|
row.style["background"] = stores[item.store]?.color;
|
|
|
|
|
|
|
|
row.querySelectorAll('td[data-label="Preis"]').forEach((priceDom) => {
|
2023-05-18 18:14:51 +02:00
|
|
|
priceDom.style["cursor"] = "pointer";
|
|
|
|
priceDom.addEventListener("click", () => {
|
2023-05-24 16:59:43 +02:00
|
|
|
const pricesDom = priceDom.querySelector(".priceinfo");
|
|
|
|
if (pricesDom.classList.contains("hide")) {
|
|
|
|
pricesDom.classList.remove("hide");
|
2023-05-18 18:14:51 +02:00
|
|
|
} else {
|
2023-05-24 16:59:43 +02:00
|
|
|
pricesDom.classList.add("hide");
|
2023-05-18 18:14:51 +02:00
|
|
|
}
|
|
|
|
});
|
2023-06-04 14:45:05 +02:00
|
|
|
});
|
|
|
|
|
2023-05-18 18:14:51 +02:00
|
|
|
return row;
|
|
|
|
}
|
|
|
|
|
2023-05-19 14:47:40 +02:00
|
|
|
let componentId = 0;
|
2023-05-18 18:14:51 +02:00
|
|
|
|
2023-06-02 16:45:54 +02:00
|
|
|
function searchItems(items, query, checkedStores, budgetBrands, minPrice, maxPrice, exact, bio) {
|
2023-05-23 11:21:48 +02:00
|
|
|
query = query.trim();
|
2023-05-19 14:47:40 +02:00
|
|
|
if (query.length < 3) return [];
|
2023-05-18 18:14:51 +02:00
|
|
|
|
2023-05-29 19:13:36 +02:00
|
|
|
if (query.charAt(0) == "!") {
|
2023-05-23 11:21:48 +02:00
|
|
|
query = query.substring(1);
|
2023-05-26 16:50:33 +02:00
|
|
|
return alasql("select * from ? where " + query, [items]);
|
2023-05-23 11:21:48 +02:00
|
|
|
}
|
|
|
|
|
2023-06-02 16:45:54 +02:00
|
|
|
const tokens = query.split(/\s+/).map((token) => token.toLowerCase().replace(",", "."));
|
2023-05-18 18:14:51 +02:00
|
|
|
|
2023-05-23 11:21:48 +02:00
|
|
|
let hits = [];
|
2023-05-29 17:31:00 +02:00
|
|
|
for (const item of items) {
|
2023-05-19 14:47:40 +02:00
|
|
|
let allFound = true;
|
2023-05-29 17:31:00 +02:00
|
|
|
for (const token of tokens) {
|
2023-05-29 19:13:36 +02:00
|
|
|
if (token.length === 0) continue;
|
2023-05-19 14:47:40 +02:00
|
|
|
const index = item.search.indexOf(token);
|
|
|
|
if (index < 0) {
|
|
|
|
allFound = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
if (exact) {
|
2023-06-02 16:45:54 +02:00
|
|
|
if (index > 0 && item.search.charAt(index - 1) != " " && item.search.charAt(index - 1) != "-") {
|
2023-05-19 14:47:40 +02:00
|
|
|
allFound = false;
|
|
|
|
break;
|
|
|
|
}
|
2023-06-02 16:45:54 +02:00
|
|
|
if (index + token.length < item.search.length && item.search.charAt(index + token.length) != " ") {
|
2023-05-19 14:47:40 +02:00
|
|
|
allFound = false;
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2023-05-23 20:07:26 +02:00
|
|
|
if (allFound) {
|
|
|
|
const name = item.name.toLowerCase();
|
2023-06-02 16:45:54 +02:00
|
|
|
if (checkedStores.length && !checkedStores.includes(item.store)) continue;
|
2023-05-23 20:07:26 +02:00
|
|
|
if (item.price < minPrice) continue;
|
|
|
|
if (item.price > maxPrice) continue;
|
2023-06-02 16:45:54 +02:00
|
|
|
if (budgetBrands && !BUDGET_BRANDS.some((budgetBrand) => name.indexOf(budgetBrand) >= 0)) continue;
|
2023-05-24 17:16:57 +02:00
|
|
|
if (bio && !item.bio) continue;
|
2023-05-19 14:47:40 +02:00
|
|
|
hits.push(item);
|
2023-05-23 11:21:48 +02:00
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
}
|
2023-05-23 20:07:26 +02:00
|
|
|
return hits;
|
2023-05-23 11:21:48 +02:00
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-06-04 02:19:13 +02:00
|
|
|
function newSearchComponent(parentElement, items, searched, filter, headerModifier, itemDomModifier, chartCallback) {
|
2023-05-19 14:47:40 +02:00
|
|
|
let id = componentId++;
|
|
|
|
parentElement.innerHTML = "";
|
|
|
|
parentElement.innerHTML = `
|
2023-06-04 02:19:13 +02:00
|
|
|
<div class="filters-container">
|
2023-06-04 19:54:28 +02:00
|
|
|
<input id="search-${id}" class="search" type="text" placeholder="Produkte suchen...">
|
2023-06-04 02:19:13 +02:00
|
|
|
<div class="filters">
|
2023-06-02 15:05:11 +02:00
|
|
|
<label><input id="all-${id}" type="checkbox" checked="true"><strong>Alle</strong></label>
|
2023-06-04 23:38:52 +02:00
|
|
|
${STORE_KEYS.map(
|
|
|
|
(store) =>
|
|
|
|
`<label><input id="${store}-${id}" type="checkbox" ${stores[store].name.toLowerCase().endsWith("de") ? "" : "checked"}>${
|
|
|
|
stores[store].name
|
|
|
|
}</label>`
|
|
|
|
).join(" ")}
|
2023-05-30 22:44:45 +02:00
|
|
|
</div>
|
2023-06-04 14:45:05 +02:00
|
|
|
<div class="filters" style="margin-bottom: 0em">
|
2023-05-30 22:44:45 +02:00
|
|
|
<label>
|
2023-06-03 01:24:22 +02:00
|
|
|
<input id="budgetBrands-${id}" type="checkbox"> Nur
|
2023-06-02 16:45:54 +02:00
|
|
|
<abbr title="${BUDGET_BRANDS.map((budgetBrand) => budgetBrand.toUpperCase()).join(", ")}">
|
2023-05-30 22:44:45 +02:00
|
|
|
Diskont-Eigenmarken
|
|
|
|
</abbr>
|
|
|
|
</label>
|
|
|
|
<label><input id="bio-${id}" type="checkbox"> Nur Bio</label>
|
2023-06-04 02:19:13 +02:00
|
|
|
<label><input id="exact-${id}" type="checkbox"> Exaktes Wort</label>
|
2023-05-30 22:44:45 +02:00
|
|
|
<label>Min € <input id="minprice-${id}" type="number" min="0" value="0"></label>
|
|
|
|
<label>Max € <input id="maxprice-${id}" type="number" min="0" value="100"></label>
|
|
|
|
</div>
|
2023-06-04 14:45:05 +02:00
|
|
|
<div id="links-${id}" class="results hide">
|
|
|
|
<label>Sortieren <select id="sort-${id}">
|
|
|
|
<option value="priceasc">Preis aufsteigend</option>
|
|
|
|
<option value="pricedesc">Preis absteigend</option>
|
|
|
|
<option value="namesim">Namensähnlichkeit</option>
|
|
|
|
</select></label>
|
|
|
|
<div class="row">
|
|
|
|
<span id="numresults-${id}"></span>
|
|
|
|
<strong>
|
|
|
|
<a id="querylink-${id}" class="querylink">Teilen</a>
|
|
|
|
<a id="json-${id}" href="">JSON</a>
|
|
|
|
</strong>
|
|
|
|
<label class="hide"><input id="chart-${id}" type="checkbox"> Diagramm</input>
|
|
|
|
</div>
|
2023-06-04 02:19:13 +02:00
|
|
|
</div>
|
2023-05-31 16:42:41 +02:00
|
|
|
</div>
|
2023-05-27 20:56:26 +02:00
|
|
|
<table id="result-${id}" class="searchresults"></table>
|
2023-05-19 14:47:40 +02:00
|
|
|
`;
|
|
|
|
|
|
|
|
const searchInput = parentElement.querySelector(`#search-${id}`);
|
2023-06-04 02:19:13 +02:00
|
|
|
const links = parentElement.querySelector(`#links-${id}`);
|
2023-05-26 11:28:40 +02:00
|
|
|
const queryLink = parentElement.querySelector(`#querylink-${id}`);
|
2023-05-31 16:42:41 +02:00
|
|
|
const jsonLink = parentElement.querySelector(`#json-${id}`);
|
2023-06-04 02:19:13 +02:00
|
|
|
const chart = parentElement.querySelector(`#chart-${id}`);
|
2023-05-19 14:47:40 +02:00
|
|
|
const exact = parentElement.querySelector(`#exact-${id}`);
|
|
|
|
const table = parentElement.querySelector(`#result-${id}`);
|
2023-05-26 18:12:29 +02:00
|
|
|
const budgetBrands = parentElement.querySelector(`#budgetBrands-${id}`);
|
2023-05-24 17:16:57 +02:00
|
|
|
const bio = parentElement.querySelector(`#bio-${id}`);
|
2023-06-02 14:17:00 +02:00
|
|
|
const allCheckbox = parentElement.querySelector(`#all-${id}`);
|
2023-06-02 16:45:54 +02:00
|
|
|
const storeCheckboxes = STORE_KEYS.map((store) => parentElement.querySelector(`#${store}-${id}`));
|
2023-05-19 14:47:40 +02:00
|
|
|
const minPrice = parentElement.querySelector(`#minprice-${id}`);
|
|
|
|
const maxPrice = parentElement.querySelector(`#maxprice-${id}`);
|
2023-05-23 11:21:48 +02:00
|
|
|
const numResults = parentElement.querySelector(`#numresults-${id}`);
|
2023-05-31 16:42:41 +02:00
|
|
|
const sort = parentElement.querySelector(`#sort-${id}`);
|
|
|
|
|
2023-06-04 02:19:13 +02:00
|
|
|
if (chartCallback) {
|
|
|
|
chart.parentElement.classList.remove("hide");
|
2023-06-05 00:06:10 +02:00
|
|
|
chart.addEventListener("change", () => chartCallback(chart.checked));
|
2023-06-04 02:19:13 +02:00
|
|
|
}
|
|
|
|
|
2023-05-31 16:42:41 +02:00
|
|
|
let lastHits = [];
|
|
|
|
jsonLink.addEventListener("click", (event) => {
|
|
|
|
event.preventDefault();
|
|
|
|
downloadFile("items.json", JSON.stringify(lastHits, null, 2));
|
2023-05-30 22:44:45 +02:00
|
|
|
});
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-30 11:52:35 +02:00
|
|
|
const setQuery = () => {
|
|
|
|
const query = searchInput.value.trim();
|
|
|
|
if (query.length === 0) {
|
2023-06-04 02:19:13 +02:00
|
|
|
links.classList.add("hide");
|
2023-05-30 11:52:35 +02:00
|
|
|
return;
|
|
|
|
}
|
2023-06-04 02:19:13 +02:00
|
|
|
links.classList.remove("hide");
|
2023-05-30 11:52:35 +02:00
|
|
|
const inputs = [...table.querySelectorAll("input:checked")];
|
2023-06-02 16:45:54 +02:00
|
|
|
let checked = inputs.length ? inputs.map((item) => item.dataset.id) : getQueryParameter("c");
|
2023-05-30 22:44:45 +02:00
|
|
|
if (typeof checked === "string") checked = [checked];
|
2023-06-02 16:45:54 +02:00
|
|
|
queryLink.setAttribute("href", `/?q=${encodeURIComponent(query)}${checked?.length ? `&c=${checked.join("&c=")}` : ""}`);
|
2023-05-30 11:52:35 +02:00
|
|
|
};
|
|
|
|
|
2023-05-19 14:47:40 +02:00
|
|
|
let search = (query) => {
|
2023-05-24 12:00:33 +02:00
|
|
|
let hits = [];
|
2023-05-27 20:56:26 +02:00
|
|
|
let now = performance.now();
|
2023-05-24 12:00:33 +02:00
|
|
|
try {
|
2023-05-30 22:44:45 +02:00
|
|
|
hits = searchItems(
|
|
|
|
items,
|
|
|
|
query,
|
2023-05-26 18:12:29 +02:00
|
|
|
STORE_KEYS.filter((store, i) => storeCheckboxes[i].checked),
|
2023-05-30 22:44:45 +02:00
|
|
|
budgetBrands.checked,
|
|
|
|
toNumber(minPrice.value, 0),
|
|
|
|
toNumber(maxPrice.value, 100),
|
|
|
|
exact.checked,
|
|
|
|
bio.checked,
|
|
|
|
sort.value
|
2023-05-24 12:00:33 +02:00
|
|
|
);
|
|
|
|
} catch (e) {
|
|
|
|
console.log("Query: " + query + "\n" + e.message);
|
|
|
|
}
|
2023-06-02 16:45:54 +02:00
|
|
|
console.log("Search took " + (performance.now() - now) / 1000.0 + " secs");
|
2023-05-23 20:07:26 +02:00
|
|
|
if (searched) hits = searched(hits);
|
2023-05-19 14:47:40 +02:00
|
|
|
if (filter) hits = hits.filter(filter);
|
|
|
|
table.innerHTML = "";
|
2023-05-23 20:07:26 +02:00
|
|
|
if (hits.length == 0) {
|
2023-06-04 02:19:13 +02:00
|
|
|
numResults.innerHTML = "<strong>Resultate:</strong> 0";
|
2023-05-23 20:07:26 +02:00
|
|
|
return;
|
|
|
|
}
|
2023-06-02 16:45:54 +02:00
|
|
|
if (query.trim().charAt(0) != "!" || query.trim().toLowerCase().indexOf("order by") == -1) {
|
2023-05-31 16:42:41 +02:00
|
|
|
if (sort.value == "priceasc") {
|
|
|
|
hits.sort((a, b) => a.price - b.price);
|
|
|
|
} else if (sort.value == "pricedesc") {
|
|
|
|
hits.sort((a, b) => b.price - a.price);
|
|
|
|
} else {
|
|
|
|
vectorizeItems(hits);
|
|
|
|
hits = similaritySortItems(hits);
|
|
|
|
}
|
|
|
|
}
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-06-04 14:45:05 +02:00
|
|
|
let header = dom("tr", `<th>Kette</th><th>Name</th><th>Menge</th><th>Preis <span class="expander">+</span></th>`);
|
2023-05-25 16:09:34 +02:00
|
|
|
if (headerModifier) header = headerModifier(header);
|
2023-06-04 14:45:05 +02:00
|
|
|
const showHideAll = header.querySelectorAll("th:nth-child(4)")[0];
|
|
|
|
showHideAll.style["cursor"] = "pointer";
|
|
|
|
showHideAll.showAll = true;
|
|
|
|
showHideAll.addEventListener("click", () => {
|
|
|
|
showHideAll.querySelector(".expander").innerText = showHideAll.querySelector(".expander").innerText == "+" ? "-" : "+";
|
|
|
|
table.querySelectorAll(".priceinfo").forEach((el) => (showHideAll.showAll ? el.classList.remove("hide") : el.classList.add("hide")));
|
|
|
|
showHideAll.showAll = !showHideAll.showAll;
|
|
|
|
});
|
2023-05-26 14:35:21 +02:00
|
|
|
const thead = dom("thead", ``);
|
|
|
|
thead.appendChild(header);
|
|
|
|
table.appendChild(thead);
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-05-27 20:56:26 +02:00
|
|
|
now = performance.now();
|
2023-05-23 11:21:48 +02:00
|
|
|
let num = 0;
|
2023-06-02 09:56:48 +02:00
|
|
|
let limit = isMobile() ? 500 : 2000;
|
2023-06-02 16:45:54 +02:00
|
|
|
hits.every((hit) => {
|
2023-05-19 16:01:43 +02:00
|
|
|
let itemDom = itemToDOM(hit);
|
2023-06-02 16:45:54 +02:00
|
|
|
if (itemDomModifier) itemDom = itemDomModifier(hit, itemDom, hits, setQuery);
|
2023-05-19 16:01:43 +02:00
|
|
|
table.appendChild(itemDom);
|
2023-05-23 11:21:48 +02:00
|
|
|
num++;
|
2023-05-31 16:42:41 +02:00
|
|
|
return num < limit;
|
2023-05-19 14:47:40 +02:00
|
|
|
});
|
2023-06-02 16:45:54 +02:00
|
|
|
console.log("Building DOM took: " + (performance.now() - now) / 1000.0 + " secs");
|
2023-06-04 02:19:13 +02:00
|
|
|
numResults.innerHTML = "<strong>Resultate:</strong> " + hits.length + (num < hits.length ? ", " + num + " angezeigt" : "");
|
2023-05-31 16:42:41 +02:00
|
|
|
lastHits = hits;
|
2023-05-30 22:44:45 +02:00
|
|
|
};
|
2023-05-19 14:47:40 +02:00
|
|
|
|
2023-06-01 17:40:11 +02:00
|
|
|
let timeoutId;
|
2023-05-19 14:47:40 +02:00
|
|
|
searchInput.addEventListener("input", (event) => {
|
2023-06-01 17:40:11 +02:00
|
|
|
clearTimeout(timeoutId);
|
|
|
|
timeoutId = setTimeout(() => {
|
|
|
|
const query = searchInput.value.trim();
|
|
|
|
if (query == 0) {
|
|
|
|
minPrice.value = 0;
|
|
|
|
maxPrice.value = 100;
|
|
|
|
}
|
|
|
|
if (query?.charAt(0) == "!") {
|
2023-06-04 21:30:55 +02:00
|
|
|
parentElement.querySelectorAll(".filters").forEach((f) => f.classList.add("hide"));
|
2023-06-01 17:40:11 +02:00
|
|
|
} else {
|
2023-06-04 21:30:55 +02:00
|
|
|
parentElement.querySelectorAll(".filters").forEach((f) => f.classList.remove("hide"));
|
2023-06-01 17:40:11 +02:00
|
|
|
}
|
|
|
|
setQuery();
|
|
|
|
search(searchInput.value);
|
|
|
|
}, 50);
|
2023-05-18 18:14:51 +02:00
|
|
|
});
|
2023-05-26 18:12:29 +02:00
|
|
|
budgetBrands.addEventListener("change", () => search(searchInput.value));
|
2023-05-24 19:38:09 +02:00
|
|
|
bio.addEventListener("change", () => search(searchInput.value));
|
2023-06-02 16:45:54 +02:00
|
|
|
allCheckbox.addEventListener("change", () => storeCheckboxes.forEach((store) => (store.checked = allCheckbox.checked)));
|
|
|
|
storeCheckboxes.map((store) => store.addEventListener("change", () => search(searchInput.value)));
|
2023-05-31 16:42:41 +02:00
|
|
|
sort.addEventListener("change", () => search(searchInput.value));
|
2023-05-19 14:47:40 +02:00
|
|
|
minPrice.addEventListener("change", () => search(searchInput.value));
|
|
|
|
maxPrice.addEventListener("change", () => search(searchInput.value));
|
2023-05-30 22:44:45 +02:00
|
|
|
exact.addEventListener("change", () => search(searchInput.value));
|
2023-05-18 18:14:51 +02:00
|
|
|
|
2023-06-05 00:06:10 +02:00
|
|
|
return {
|
|
|
|
searchInput,
|
|
|
|
links,
|
|
|
|
queryLink,
|
|
|
|
jsonLink,
|
|
|
|
chart,
|
|
|
|
exact,
|
|
|
|
table,
|
|
|
|
budgetBrands,
|
|
|
|
bio,
|
|
|
|
allCheckbox,
|
|
|
|
storeCheckboxes,
|
|
|
|
minPrice,
|
|
|
|
maxPrice,
|
|
|
|
numResults,
|
|
|
|
sort,
|
|
|
|
};
|
2023-05-23 20:07:26 +02:00
|
|
|
}
|
|
|
|
|
2023-05-30 20:02:03 +02:00
|
|
|
function showChart(canvasDom, items, chartType) {
|
2023-05-23 20:07:26 +02:00
|
|
|
if (items.length == 0) {
|
2023-06-04 02:19:13 +02:00
|
|
|
canvasDom.classList.add("hide");
|
2023-05-23 20:07:26 +02:00
|
|
|
return;
|
|
|
|
} else {
|
2023-06-04 02:19:13 +02:00
|
|
|
canvasDom.classList.remove("hide");
|
2023-05-23 20:07:26 +02:00
|
|
|
}
|
|
|
|
|
2023-06-02 16:45:54 +02:00
|
|
|
const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date));
|
2023-05-23 20:07:26 +02:00
|
|
|
const uniqueDates = [...new Set(allDates)];
|
|
|
|
uniqueDates.sort();
|
|
|
|
|
2023-05-30 22:44:45 +02:00
|
|
|
const datasets = items.map((product) => {
|
2023-05-23 20:07:26 +02:00
|
|
|
let price = null;
|
2023-05-30 22:44:45 +02:00
|
|
|
const prices = uniqueDates.map((date) => {
|
2023-06-02 16:45:54 +02:00
|
|
|
const priceObj = product.priceHistory.find((item) => item.date === date);
|
2023-05-23 20:07:26 +02:00
|
|
|
if (!price && priceObj) price = priceObj.price;
|
|
|
|
return priceObj ? priceObj.price : null;
|
|
|
|
});
|
|
|
|
|
|
|
|
for (let i = 0; i < prices.length; i++) {
|
2023-05-24 16:59:43 +02:00
|
|
|
if (prices[i] == null) {
|
2023-05-23 20:07:26 +02:00
|
|
|
prices[i] = price;
|
|
|
|
} else {
|
|
|
|
price = prices[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2023-05-29 00:42:24 +02:00
|
|
|
label: (product.store ? product.store + " " : "") + product.name,
|
2023-05-23 20:07:26 +02:00
|
|
|
data: prices,
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2023-05-30 22:44:45 +02:00
|
|
|
const ctx = canvasDom.getContext("2d");
|
2023-05-30 21:42:47 +02:00
|
|
|
let scrollTop = -1;
|
|
|
|
if (canvasDom.lastChart) {
|
|
|
|
scrollTop = document.documentElement.scrollTop;
|
|
|
|
canvasDom.lastChart.destroy();
|
|
|
|
}
|
2023-05-23 20:07:26 +02:00
|
|
|
canvasDom.lastChart = new Chart(ctx, {
|
2023-05-30 22:44:45 +02:00
|
|
|
type: chartType ? chartType : "line",
|
2023-05-23 20:07:26 +02:00
|
|
|
data: {
|
|
|
|
labels: uniqueDates,
|
2023-05-30 22:44:45 +02:00
|
|
|
datasets: datasets,
|
2023-05-23 20:07:26 +02:00
|
|
|
},
|
|
|
|
options: {
|
|
|
|
responsive: true,
|
2023-05-27 12:33:18 +02:00
|
|
|
aspectRation: 16 / 9,
|
|
|
|
scales: {
|
|
|
|
y: {
|
|
|
|
title: {
|
|
|
|
display: true,
|
2023-05-30 22:44:45 +02:00
|
|
|
text: "EURO",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
2023-05-23 20:07:26 +02:00
|
|
|
});
|
2023-05-30 22:44:45 +02:00
|
|
|
if (scrollTop != -1) document.documentElement.scrollTop = scrollTop;
|
2023-05-24 16:59:43 +02:00
|
|
|
}
|
|
|
|
|
2023-06-01 17:40:11 +02:00
|
|
|
function getOldestDate(items) {
|
|
|
|
let oldestDate = "9999-01-01";
|
|
|
|
for (item of items) {
|
|
|
|
if (oldestDate > item.dateOldest) oldestDate = item.dateOldest;
|
|
|
|
}
|
|
|
|
return oldestDate;
|
|
|
|
}
|
|
|
|
|
2023-06-02 16:45:54 +02:00
|
|
|
function showCharts(canvasDom, items, sum, sumStores, todayOnly, startDate, endDate) {
|
2023-06-01 15:54:44 +02:00
|
|
|
let itemsToShow = [];
|
|
|
|
|
|
|
|
if (sum && items.length > 0) {
|
|
|
|
itemsToShow.push({
|
|
|
|
name: "Preissumme Warenkorb",
|
2023-06-02 16:45:54 +02:00
|
|
|
priceHistory: calculateOverallPriceChanges(items, todayOnly, startDate, endDate),
|
2023-06-01 15:54:44 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
if (sumStores && items.length > 0) {
|
2023-05-30 22:44:45 +02:00
|
|
|
STORE_KEYS.forEach((store) => {
|
|
|
|
const storeItems = items.filter((item) => item.store === store);
|
2023-06-01 15:54:44 +02:00
|
|
|
if (storeItems.length > 0) {
|
|
|
|
itemsToShow.push({
|
|
|
|
name: "Preissumme " + store,
|
2023-06-02 16:45:54 +02:00
|
|
|
priceHistory: calculateOverallPriceChanges(storeItems, todayOnly, startDate, endDate),
|
2023-06-01 15:54:44 +02:00
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
items.forEach((item) => {
|
2023-06-01 17:40:11 +02:00
|
|
|
if (item.chart) {
|
|
|
|
itemsToShow.push({
|
|
|
|
name: item.store + " " + item.name,
|
2023-05-30 22:44:45 +02:00
|
|
|
priceHistory: todayOnly
|
|
|
|
? [{ date: currentDate(), price: item.price }]
|
2023-06-02 16:45:54 +02:00
|
|
|
: item.priceHistory.filter((price) => price.date >= startDate && price.date <= endDate),
|
2023-06-01 17:40:11 +02:00
|
|
|
});
|
|
|
|
}
|
2023-06-01 15:54:44 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
showChart(canvasDom, itemsToShow, todayOnly ? "bar" : "line");
|
|
|
|
}
|
|
|
|
|
2023-06-01 17:40:11 +02:00
|
|
|
function calculateOverallPriceChanges(items, todayOnly, startDate, endDate) {
|
2023-05-24 16:59:43 +02:00
|
|
|
if (items.length == 0) return { dates: [], changes: [] };
|
2023-05-30 21:42:47 +02:00
|
|
|
|
|
|
|
if (todayOnly) {
|
|
|
|
let sum = 0;
|
|
|
|
for (item of items) sum += item.price;
|
|
|
|
return [{ date: currentDate(), price: sum }];
|
|
|
|
}
|
|
|
|
|
2023-06-02 16:45:54 +02:00
|
|
|
const allDates = items.flatMap((product) => product.priceHistory.map((item) => item.date));
|
2023-06-01 17:40:11 +02:00
|
|
|
let uniqueDates = [...new Set(allDates)];
|
2023-05-24 16:59:43 +02:00
|
|
|
uniqueDates.sort();
|
|
|
|
|
2023-05-30 22:44:45 +02:00
|
|
|
const allPrices = items.map((product) => {
|
2023-05-24 16:59:43 +02:00
|
|
|
let price = null;
|
2023-05-30 22:44:45 +02:00
|
|
|
const prices = uniqueDates.map((date) => {
|
2023-06-02 16:45:54 +02:00
|
|
|
const priceObj = product.priceHistory.find((item) => item.date === date);
|
2023-05-24 16:59:43 +02:00
|
|
|
if (!price && priceObj) price = priceObj.price;
|
|
|
|
return priceObj ? priceObj.price : null;
|
|
|
|
});
|
|
|
|
|
|
|
|
for (let i = 0; i < prices.length; i++) {
|
|
|
|
if (!prices[i]) {
|
|
|
|
prices[i] = price;
|
|
|
|
} else {
|
|
|
|
price = prices[i];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return prices;
|
|
|
|
});
|
|
|
|
|
|
|
|
const priceChanges = [];
|
|
|
|
for (let i = 0; i < uniqueDates.length; i++) {
|
2023-06-04 21:37:33 +02:00
|
|
|
if (uniqueDates[i] < startDate || uniqueDates[i] > endDate) continue;
|
2023-05-24 16:59:43 +02:00
|
|
|
let price = 0;
|
|
|
|
for (let j = 0; j < allPrices.length; j++) {
|
|
|
|
price += allPrices[j][i];
|
|
|
|
}
|
|
|
|
priceChanges.push({ date: uniqueDates[i], price });
|
|
|
|
}
|
|
|
|
|
|
|
|
return priceChanges;
|
2023-05-30 10:01:24 +02:00
|
|
|
}
|
2023-05-31 16:42:41 +02:00
|
|
|
|
|
|
|
function downloadFile(filename, content) {
|
2023-05-30 22:44:45 +02:00
|
|
|
const blob = new Blob([content], { type: "text/plain" });
|
|
|
|
const element = document.createElement("a");
|
2023-05-31 16:42:41 +02:00
|
|
|
element.href = URL.createObjectURL(blob);
|
|
|
|
element.download = filename;
|
2023-05-30 22:44:45 +02:00
|
|
|
element.style.display = "none";
|
2023-05-31 16:42:41 +02:00
|
|
|
document.body.appendChild(element);
|
|
|
|
element.click();
|
|
|
|
document.body.removeChild(element);
|
|
|
|
URL.revokeObjectURL(element.href);
|
|
|
|
}
|
|
|
|
|
|
|
|
/* by Joder Illi, Snowball mailing list */
|
|
|
|
function stem(word) {
|
|
|
|
/*
|
|
|
|
Put u and y between vowels into upper case
|
|
|
|
*/
|
2023-05-30 22:44:45 +02:00
|
|
|
word = word.replace(/([aeiouyäöü])u([aeiouyäöü])/g, "$1U$2");
|
|
|
|
word = word.replace(/([aeiouyäöü])y([aeiouyäöü])/g, "$1Y$2");
|
2023-05-31 16:42:41 +02:00
|
|
|
|
|
|
|
/*
|
|
|
|
and then do the following mappings,
|
|
|
|
(a) replace ß with ss,
|
|
|
|
(a) replace ae with ä, Not doing these,
|
|
|
|
have trouble with diphtongs
|
|
|
|
(a) replace oe with ö, Not doing these,
|
|
|
|
have trouble with diphtongs
|
|
|
|
(a) replace ue with ü unless preceded by q. Not doing these,
|
|
|
|
have trouble with diphtongs
|
|
|
|
So in quelle, ue is not mapped to ü because it follows q, and in
|
|
|
|
feuer it is not mapped because the first part of the rule changes it to
|
|
|
|
feUer, so the u is not found.
|
|
|
|
*/
|
2023-05-30 22:44:45 +02:00
|
|
|
word = word.replace(/ß/g, "ss");
|
2023-05-31 16:42:41 +02:00
|
|
|
//word = word.replace(/ae/g, 'ä');
|
|
|
|
//word = word.replace(/oe/g, 'ö');
|
|
|
|
//word = word.replace(/([^q])ue/g, '$1ü');
|
|
|
|
|
|
|
|
/*
|
|
|
|
R1 and R2 are first set up in the standard way (see the note on R1
|
|
|
|
and R2), but then R1 is adjusted so that the region before it contains at
|
|
|
|
least 3 letters.
|
|
|
|
R1 is the region after the first non-vowel following a vowel, or is
|
|
|
|
the null region at the end of the word if there is no such non-vowel.
|
|
|
|
R2 is the region after the first non-vowel following a vowel in R1,
|
|
|
|
or is the null region at the end of the word if there is no such non-vowel.
|
|
|
|
*/
|
|
|
|
|
|
|
|
var r1Index = word.search(/[aeiouyäöü][^aeiouyäöü]/);
|
2023-05-30 22:44:45 +02:00
|
|
|
var r1 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
if (r1Index != -1) {
|
|
|
|
r1Index += 2;
|
|
|
|
r1 = word.substring(r1Index);
|
|
|
|
}
|
|
|
|
|
|
|
|
var r2Index = -1;
|
2023-05-30 22:44:45 +02:00
|
|
|
var r2 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
|
|
|
|
if (r1Index != -1) {
|
|
|
|
var r2Index = r1.search(/[aeiouyäöü][^aeiouyäöü]/);
|
|
|
|
if (r2Index != -1) {
|
|
|
|
r2Index += 2;
|
|
|
|
r2 = r1.substring(r2Index);
|
|
|
|
r2Index += r1Index;
|
|
|
|
} else {
|
2023-05-30 22:44:45 +02:00
|
|
|
r2 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (r1Index != -1 && r1Index < 3) {
|
|
|
|
r1Index = 3;
|
|
|
|
r1 = word.substring(r1Index);
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
Define a valid s-ending as one of b, d, f, g, h, k, l, m, n, r or t.
|
|
|
|
Define a valid st-ending as the same list, excluding letter r.
|
|
|
|
*/
|
|
|
|
|
|
|
|
/*
|
|
|
|
Do each of steps 1, 2 and 3.
|
|
|
|
*/
|
|
|
|
|
|
|
|
/*
|
|
|
|
Step 1:
|
|
|
|
Search for the longest among the following suffixes,
|
|
|
|
(a) em ern er
|
|
|
|
(b) e en es
|
|
|
|
(c) s (preceded by a valid s-ending)
|
|
|
|
*/
|
|
|
|
var a1Index = word.search(/(em|ern|er)$/g);
|
|
|
|
var b1Index = word.search(/(e|en|es)$/g);
|
|
|
|
var c1Index = word.search(/([bdfghklmnrt]s)$/g);
|
|
|
|
if (c1Index != -1) {
|
|
|
|
c1Index++;
|
|
|
|
}
|
|
|
|
var index1 = 10000;
|
2023-05-30 22:44:45 +02:00
|
|
|
var optionUsed1 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
if (a1Index != -1 && a1Index < index1) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed1 = "a";
|
2023-05-31 16:42:41 +02:00
|
|
|
index1 = a1Index;
|
|
|
|
}
|
|
|
|
if (b1Index != -1 && b1Index < index1) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed1 = "b";
|
2023-05-31 16:42:41 +02:00
|
|
|
index1 = b1Index;
|
|
|
|
}
|
|
|
|
if (c1Index != -1 && c1Index < index1) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed1 = "c";
|
2023-05-31 16:42:41 +02:00
|
|
|
index1 = c1Index;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
and delete if in R1. (Of course the letter of the valid s-ending is
|
|
|
|
not necessarily in R1.) If an ending of group (b) is deleted, and the ending
|
|
|
|
is preceded by niss, delete the final s.
|
|
|
|
(For example, äckern -> äck, ackers -> acker, armes -> arm,
|
|
|
|
bedürfnissen -> bedürfnis)
|
|
|
|
*/
|
|
|
|
|
|
|
|
if (index1 != 10000 && r1Index != -1) {
|
|
|
|
if (index1 >= r1Index) {
|
|
|
|
word = word.substring(0, index1);
|
2023-05-30 22:44:45 +02:00
|
|
|
if (optionUsed1 == "b") {
|
2023-05-31 16:42:41 +02:00
|
|
|
if (word.search(/niss$/) != -1) {
|
|
|
|
word = word.substring(0, word.length - 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
/*
|
|
|
|
Step 2:
|
|
|
|
Search for the longest among the following suffixes,
|
|
|
|
(a) en er est
|
|
|
|
(b) st (preceded by a valid st-ending, itself preceded by at least 3
|
|
|
|
letters)
|
|
|
|
*/
|
|
|
|
|
|
|
|
var a2Index = word.search(/(en|er|est)$/g);
|
|
|
|
var b2Index = word.search(/(.{3}[bdfghklmnt]st)$/g);
|
|
|
|
if (b2Index != -1) {
|
|
|
|
b2Index += 4;
|
|
|
|
}
|
|
|
|
|
|
|
|
var index2 = 10000;
|
2023-05-30 22:44:45 +02:00
|
|
|
var optionUsed2 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
if (a2Index != -1 && a2Index < index2) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed2 = "a";
|
2023-05-31 16:42:41 +02:00
|
|
|
index2 = a2Index;
|
|
|
|
}
|
|
|
|
if (b2Index != -1 && b2Index < index2) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed2 = "b";
|
2023-05-31 16:42:41 +02:00
|
|
|
index2 = b2Index;
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
and delete if in R1.
|
|
|
|
(For example, derbsten -> derbst by step 1, and derbst -> derb by
|
|
|
|
step 2, since b is a valid st-ending, and is preceded by just 3 letters)
|
|
|
|
*/
|
|
|
|
|
|
|
|
if (index2 != 10000 && r1Index != -1) {
|
|
|
|
if (index2 >= r1Index) {
|
|
|
|
word = word.substring(0, index2);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
Step 3: d-suffixes (*)
|
|
|
|
Search for the longest among the following suffixes, and perform the
|
|
|
|
action indicated.
|
|
|
|
end ung
|
|
|
|
delete if in R2
|
|
|
|
if preceded by ig, delete if in R2 and not preceded by e
|
|
|
|
ig ik isch
|
|
|
|
delete if in R2 and not preceded by e
|
|
|
|
lich heit
|
|
|
|
delete if in R2
|
|
|
|
if preceded by er or en, delete if in R1
|
|
|
|
keit
|
|
|
|
delete if in R2
|
|
|
|
if preceded by lich or ig, delete if in R2
|
|
|
|
*/
|
|
|
|
|
|
|
|
var a3Index = word.search(/(end|ung)$/g);
|
|
|
|
var b3Index = word.search(/[^e](ig|ik|isch)$/g);
|
|
|
|
var c3Index = word.search(/(lich|heit)$/g);
|
|
|
|
var d3Index = word.search(/(keit)$/g);
|
|
|
|
if (b3Index != -1) {
|
|
|
|
b3Index++;
|
|
|
|
}
|
|
|
|
|
|
|
|
var index3 = 10000;
|
2023-05-30 22:44:45 +02:00
|
|
|
var optionUsed3 = "";
|
2023-05-31 16:42:41 +02:00
|
|
|
if (a3Index != -1 && a3Index < index3) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed3 = "a";
|
2023-05-31 16:42:41 +02:00
|
|
|
index3 = a3Index;
|
|
|
|
}
|
|
|
|
if (b3Index != -1 && b3Index < index3) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed3 = "b";
|
2023-05-31 16:42:41 +02:00
|
|
|
index3 = b3Index;
|
|
|
|
}
|
|
|
|
if (c3Index != -1 && c3Index < index3) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed3 = "c";
|
2023-05-31 16:42:41 +02:00
|
|
|
index3 = c3Index;
|
|
|
|
}
|
|
|
|
if (d3Index != -1 && d3Index < index3) {
|
2023-05-30 22:44:45 +02:00
|
|
|
optionUsed3 = "d";
|
2023-05-31 16:42:41 +02:00
|
|
|
index3 = d3Index;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (index3 != 10000 && r2Index != -1) {
|
|
|
|
if (index3 >= r2Index) {
|
|
|
|
word = word.substring(0, index3);
|
|
|
|
var optionIndex = -1;
|
2023-05-30 22:44:45 +02:00
|
|
|
var optionSubsrt = "";
|
|
|
|
if (optionUsed3 == "a") {
|
2023-05-31 16:42:41 +02:00
|
|
|
optionIndex = word.search(/[^e](ig)$/);
|
|
|
|
if (optionIndex != -1) {
|
|
|
|
optionIndex++;
|
|
|
|
if (optionIndex >= r2Index) {
|
|
|
|
word = word.substring(0, optionIndex);
|
|
|
|
}
|
|
|
|
}
|
2023-05-30 22:44:45 +02:00
|
|
|
} else if (optionUsed3 == "c") {
|
2023-05-31 16:42:41 +02:00
|
|
|
optionIndex = word.search(/(er|en)$/);
|
|
|
|
if (optionIndex != -1) {
|
|
|
|
if (optionIndex >= r1Index) {
|
|
|
|
word = word.substring(0, optionIndex);
|
|
|
|
}
|
|
|
|
}
|
2023-05-30 22:44:45 +02:00
|
|
|
} else if (optionUsed3 == "d") {
|
2023-05-31 16:42:41 +02:00
|
|
|
optionIndex = word.search(/(lich|ig)$/);
|
|
|
|
if (optionIndex != -1) {
|
|
|
|
if (optionIndex >= r2Index) {
|
|
|
|
word = word.substring(0, optionIndex);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/*
|
|
|
|
Finally,
|
|
|
|
turn U and Y back into lower case, and remove the umlaut accent from
|
|
|
|
a, o and u.
|
|
|
|
*/
|
2023-05-30 22:44:45 +02:00
|
|
|
word = word.replace(/U/g, "u");
|
|
|
|
word = word.replace(/Y/g, "y");
|
|
|
|
word = word.replace(/ä/g, "a");
|
|
|
|
word = word.replace(/ö/g, "o");
|
|
|
|
word = word.replace(/ü/g, "u");
|
2023-05-31 16:42:41 +02:00
|
|
|
|
|
|
|
return word;
|
|
|
|
}
|
|
|
|
|
|
|
|
function vector(tokens) {
|
|
|
|
const vector = {};
|
|
|
|
for (token of tokens) {
|
|
|
|
if (token.length > 3) {
|
|
|
|
for (let i = 0; i < token.length - 3; i++) {
|
|
|
|
let trigram = token.substring(i, i + 3);
|
|
|
|
vector[trigram] = (vector[trigram] || 0) + 1;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
vector[token] = (vector[token] || 0) + 1;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
normalizeVector(vector);
|
|
|
|
return vector;
|
|
|
|
}
|
|
|
|
|
|
|
|
function dotProduct(vector1, vector2) {
|
|
|
|
let product = 0;
|
|
|
|
for (const key in vector1) {
|
|
|
|
if (vector2.hasOwnProperty(key)) {
|
|
|
|
product += vector1[key] * vector2[key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return product;
|
|
|
|
}
|
|
|
|
|
|
|
|
function addVector(vector1, vector2) {
|
|
|
|
for (const key in vector2) {
|
|
|
|
vector1[key] = (vector1[key] || 0) + vector2[key];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function scaleVector(vector, scalar) {
|
|
|
|
for (const key in vector) {
|
|
|
|
vector[key] *= scalar;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function normalizeVector(vector) {
|
|
|
|
const len = magnitude(vector);
|
|
|
|
for (const key in vector) {
|
|
|
|
vector[key] /= len;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function magnitude(vector) {
|
|
|
|
let sumOfSquares = 0;
|
|
|
|
for (const key in vector) {
|
|
|
|
sumOfSquares += vector[key] ** 2;
|
|
|
|
}
|
|
|
|
return Math.sqrt(sumOfSquares);
|
|
|
|
}
|
|
|
|
|
|
|
|
function similaritySortItems(items) {
|
|
|
|
if (items.length == 0) return items;
|
|
|
|
sortedItems = [items.shift()];
|
|
|
|
let refItem = sortedItems[0];
|
|
|
|
while (items.length > 0) {
|
|
|
|
let maxSimilarity = -1;
|
|
|
|
let similarItem = null;
|
|
|
|
let similarItemIdx = -1;
|
|
|
|
items.forEach((item, idx) => {
|
|
|
|
let similarity = dotProduct(refItem.vector, item.vector);
|
|
|
|
if (similarity > maxSimilarity) {
|
|
|
|
maxSimilarity = similarity;
|
|
|
|
similarItem = item;
|
|
|
|
similarItemIdx = idx;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
sortedItems.push(similarItem);
|
|
|
|
items.splice(similarItemIdx, 1);
|
|
|
|
refItem = similarItem;
|
|
|
|
}
|
|
|
|
return sortedItems;
|
|
|
|
}
|
|
|
|
|
|
|
|
function vectorizeItems(items) {
|
2023-05-30 22:44:45 +02:00
|
|
|
items.forEach((item) => {
|
|
|
|
let name = item.name
|
|
|
|
.toLowerCase()
|
|
|
|
.replace(/[^\w\s]|_/g, "")
|
|
|
|
.replace("-", " ");
|
|
|
|
item.tokens = name.split(/\s+/).map((token) => stem(token));
|
2023-05-31 17:06:38 +02:00
|
|
|
if (item.quantity) item.tokens.push("" + item.quantity);
|
|
|
|
if (item.unit) item.tokens.push(item.unit);
|
2023-05-31 16:42:41 +02:00
|
|
|
item.vector = vector(item.tokens);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
function isMobile() {
|
2023-06-02 16:45:54 +02:00
|
|
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
2023-05-31 16:42:41 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2023-06-05 23:04:03 +02:00
|
|
|
exports.decompress = decompress;
|
2023-05-31 16:42:41 +02:00
|
|
|
exports.vector = vector;
|
|
|
|
exports.dotProduct = dotProduct;
|
|
|
|
exports.addVector = addVector;
|
|
|
|
exports.scaleVector = scaleVector;
|
|
|
|
exports.normalizeVector = normalizeVector;
|
|
|
|
exports.stem = stem;
|
|
|
|
exports.cluster = cluster;
|
|
|
|
exports.flattenClusters = flattenClusters;
|
|
|
|
exports.vectorizeItems = vectorizeItems;
|
|
|
|
exports.similaritySortItems = similaritySortItems;
|
|
|
|
} catch (e) {
|
|
|
|
// hax
|
2023-05-30 22:44:45 +02:00
|
|
|
}
|
2023-06-03 23:46:43 +02:00
|
|
|
|
2023-06-05 23:04:03 +02:00
|
|
|
if (typeof window !== "undefined") {
|
|
|
|
function setupLiveEdit() {
|
|
|
|
if (window.location.host.indexOf("localhost") < 0 && window.location.host.indexOf("127.0.0.1") < 0) return;
|
|
|
|
var script = document.createElement("script");
|
|
|
|
script.type = "text/javascript";
|
|
|
|
script.onload = () => {
|
|
|
|
let lastChangeTimestamp = null;
|
|
|
|
let socket = io({ transports: ["websocket"] });
|
|
|
|
socket.on("connect", () => console.log("Connected"));
|
|
|
|
socket.on("disconnect", () => console.log("Disconnected"));
|
|
|
|
socket.on("message", (timestamp) => {
|
|
|
|
if (lastChangeTimestamp != timestamp) {
|
|
|
|
setTimeout(() => location.reload(), 100);
|
|
|
|
lastChangeTimestamp = timestamp;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
};
|
|
|
|
script.src = "js/socket.io.js";
|
|
|
|
document.body.appendChild(script);
|
|
|
|
}
|
|
|
|
setupLiveEdit();
|
2023-06-03 23:46:43 +02:00
|
|
|
}
|